ahoy_captain 0.9 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +8 -4
- data/app/assets/javascript/ahoy_captain/controllers/active_links_controller.js +19 -3
- data/app/assets/javascript/ahoy_captain/controllers/application_controller.js +8 -0
- data/app/assets/javascript/ahoy_captain/controllers/combobox_controller.js +341 -0
- data/app/assets/javascript/ahoy_captain/controllers/filter_modal_controller.js +45 -0
- data/app/assets/javascript/ahoy_captain/controllers/funnel_chart_controller.js +58 -16
- data/app/assets/javascript/ahoy_captain/controllers/interval_controller.js +5 -0
- data/app/assets/javascript/ahoy_captain/controllers/line_chart_controller.js +170 -19
- data/app/assets/javascript/ahoy_captain/controllers/predicate_select_controller.js +0 -1
- data/app/assets/javascript/ahoy_captain/controllers/properties_controller.js +8 -0
- data/app/assets/javascript/ahoy_captain/controllers/property_filter_controller.js +46 -0
- data/app/assets/javascript/ahoy_captain/controllers/realtime_controller.js +4 -2
- data/app/assets/javascript/ahoy_captain/controllers/tile_controller.js +9 -0
- data/app/assets/javascript/ahoy_captain/helpers/chart_utils.js +156 -0
- data/app/assets/javascript/ahoy_captain/helpers/number_formatters.js +55 -0
- data/app/components/ahoy_captain/combobox_component.html.erb +33 -0
- data/app/components/ahoy_captain/combobox_component.rb +13 -0
- data/app/components/ahoy_captain/comparison_link_component.rb +40 -0
- data/app/components/ahoy_captain/filter/dropdown_component.html.erb +8 -6
- data/app/components/ahoy_captain/filter/modal_component.html.erb +7 -5
- data/app/components/ahoy_captain/filter/select_component.html.erb +23 -21
- data/app/components/ahoy_captain/filter/select_component.rb +2 -1
- data/app/components/ahoy_captain/filter/tag_component.html.erb +1 -1
- data/app/components/ahoy_captain/stats/comparable_container_component.html.erb +25 -0
- data/app/components/ahoy_captain/stats/comparable_container_component.rb +86 -0
- data/app/components/ahoy_captain/stats/container_component.html.erb +13 -6
- data/app/components/ahoy_captain/stats/container_component.rb +16 -1
- data/app/components/ahoy_captain/sticky_nav_component.html.erb +7 -4
- data/app/components/ahoy_captain/sticky_nav_component.rb +3 -0
- data/app/components/ahoy_captain/table_component.rb +13 -4
- data/app/components/ahoy_captain/tables/devices_table_component.rb +11 -0
- data/app/components/ahoy_captain/tables/dynamic_table.rb +13 -0
- data/app/components/ahoy_captain/tables/dynamic_table_component.rb +204 -0
- data/app/components/ahoy_captain/tables/goals_table_component.rb +17 -0
- data/app/components/ahoy_captain/tables/header_component.html.erb +5 -0
- data/app/components/ahoy_captain/tables/header_component.rb +18 -0
- data/app/components/ahoy_captain/tables/headers/header_component.rb +4 -0
- data/app/components/ahoy_captain/tables/properties_table_component.rb +27 -0
- data/app/components/ahoy_captain/tables/row_component.html.erb +4 -0
- data/app/components/ahoy_captain/tables/rows/row_component.rb +0 -1
- data/app/components/ahoy_captain/tile_component.html.erb +19 -9
- data/app/components/ahoy_captain/tile_component.rb +2 -1
- data/app/controllers/ahoy_captain/application_controller.rb +7 -16
- data/app/controllers/ahoy_captain/exports_controller.rb +1 -2
- data/app/controllers/ahoy_captain/filters/base_controller.rb +1 -3
- data/app/controllers/ahoy_captain/filters/goals_controller.rb +9 -0
- data/app/controllers/ahoy_captain/filters/pages/actions_controller.rb +1 -1
- data/app/controllers/ahoy_captain/filters/pages/entry_pages_controller.rb +1 -1
- data/app/controllers/ahoy_captain/filters/pages/exit_pages_controller.rb +1 -1
- data/app/controllers/ahoy_captain/filters/properties/values_controller.rb +4 -4
- data/app/controllers/ahoy_captain/filters/utms_controller.rb +1 -1
- data/app/controllers/ahoy_captain/properties_controller.rb +41 -0
- data/app/controllers/ahoy_captain/stats/base_controller.rb +86 -5
- data/app/controllers/ahoy_captain/stats/bounce_rates_controller.rb +1 -1
- data/app/controllers/ahoy_captain/stats/total_pageviews_controller.rb +1 -1
- data/app/controllers/ahoy_captain/stats/total_visits_controller.rb +1 -1
- data/app/controllers/ahoy_captain/stats/unique_visitors_controller.rb +2 -1
- data/app/controllers/ahoy_captain/stats/views_per_visits_controller.rb +1 -10
- data/app/controllers/ahoy_captain/stats/visit_durations_controller.rb +1 -1
- data/app/helpers/ahoy_captain/application_helper.rb +35 -9
- data/app/models/ahoy_captain/comparison_mode.rb +72 -0
- data/app/models/ahoy_captain/filter_parser.rb +33 -18
- data/app/models/ahoy_captain/range_from_params.rb +75 -0
- data/app/models/ahoy_captain/rangeable.rb +0 -3
- data/app/models/concerns/ahoy_captain/compare_mode.rb +19 -0
- data/app/models/concerns/ahoy_captain/limitable.rb +17 -0
- data/app/models/concerns/ahoy_captain/range_options.rb +1 -14
- data/app/presenters/ahoy_captain/dashboard_presenter.rb +18 -54
- data/app/queries/ahoy_captain/application_query.rb +74 -10
- data/app/queries/ahoy_captain/event_query.rb +7 -2
- data/app/queries/ahoy_captain/stats/average_views_per_visit_query.rb +11 -4
- data/app/queries/ahoy_captain/stats/average_visit_duration_query.rb +14 -2
- data/app/queries/ahoy_captain/stats/base_query.rb +18 -0
- data/app/queries/ahoy_captain/stats/bounce_rates_query.rb +15 -1
- data/app/queries/ahoy_captain/stats/total_pageviews_query.rb +2 -2
- data/app/queries/ahoy_captain/stats/total_visitors_query.rb +1 -1
- data/app/queries/ahoy_captain/stats/unique_visitors_query.rb +1 -1
- data/app/queries/ahoy_captain/stats/views_per_visit_query.rb +1 -1
- data/app/queries/ahoy_captain/stats/visit_duration_query.rb +3 -3
- data/app/queries/ahoy_captain/visit_query.rb +1 -2
- data/app/queries/concerns/ahoy_captain/comparable_queries.rb +25 -0
- data/app/queries/concerns/ahoy_captain/comparable_query.rb +138 -0
- data/app/queries/concerns/ahoy_captain/lazy_comparable_query.rb +42 -0
- data/app/views/ahoy_captain/devices/_table.html.erb +1 -4
- data/app/views/ahoy_captain/goals/index.html.erb +1 -4
- data/app/views/ahoy_captain/layouts/application.html.erb +0 -1
- data/app/views/ahoy_captain/properties/_form.html.erb +6 -0
- data/app/views/ahoy_captain/properties/index.html.erb +3 -0
- data/app/views/ahoy_captain/properties/show.html.erb +6 -0
- data/app/views/ahoy_captain/roots/_filters.html.erb +47 -1
- data/app/views/ahoy_captain/roots/show.html.erb +60 -31
- data/app/views/ahoy_captain/stats/base/index.html.erb +36 -8
- data/app/views/ahoy_captain/stats/show.html.erb +8 -10
- data/config/routes.rb +2 -0
- data/lib/ahoy_captain/ahoy/event_methods.rb +13 -15
- data/lib/ahoy_captain/configuration.rb +7 -6
- data/lib/ahoy_captain/engine.rb +4 -0
- data/lib/ahoy_captain/filters_configuration.rb +5 -1
- data/lib/ahoy_captain/version.rb +1 -1
- data/lib/ahoy_captain.rb +6 -1
- data/lib/generators/ahoy_captain/templates/config.rb.tt +7 -0
- metadata +35 -12
- data/app/assets/javascript/ahoy_captain/controllers/filter_tag_controller.js +0 -20
- data/app/assets/javascript/ahoy_captain/controllers/search_select_controller.js +0 -65
- data/app/components/ahoy_captain/tables/headers/devices_header_component.html.erb +0 -3
- data/app/components/ahoy_captain/tables/headers/devices_header_component.rb +0 -9
- data/app/components/ahoy_captain/tables/headers/goals_header_component.html.erb +0 -6
- data/app/components/ahoy_captain/tables/headers/goals_header_component.rb +0 -9
- data/app/components/ahoy_captain/tables/rows/devices_row_component.html.erb +0 -5
- data/app/components/ahoy_captain/tables/rows/devices_row_component.rb +0 -12
- data/app/components/ahoy_captain/tables/rows/goals_row_component.html.erb +0 -11
- data/app/components/ahoy_captain/tables/rows/goals_row_component.rb +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e860b8082ca3fad364eb34df632f017d81e63ff411c2fcae0501657aef5a578b
|
|
4
|
+
data.tar.gz: 4d0227c2eb7443e4d6a15f817d971f6176c43daad634d057de88f98f7ec8ae7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84a9922fec7838c6733263224f0b2a86e836b147fd8074e11428c47899ffd69317617b57f7dae503e59139d1c1ef134c46c2692ce298493830cd5b742a8fa780
|
|
7
|
+
data.tar.gz: 66e113dc9b86039b504d20153384d3fa55afe1f842a69da47d36aa03829fa0830b56a81411fd4c742626848973d1b89d43c4b523cd909ae8eb7bd6324f8261a5
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
A full-featured, mountable analytics dashboard for your Rails app, shamelessly inspired by Plausible Analytics, powered by the Ahoy gem.
|
|
5
5
|
|
|
6
|
-
<a href="https://github.com/joshmn/ahoy_captain/blob/main/ss.
|
|
6
|
+
<a href="https://github.com/joshmn/ahoy_captain/blob/main/ss.jpg"><img src="ss.jpg" style="max-width:300px" /></a>
|
|
7
7
|
## Notice
|
|
8
8
|
|
|
9
9
|
Currently requires using PG and a JSONB column for your data.
|
|
@@ -26,7 +26,9 @@ $ rails g ahoy_captain:install
|
|
|
26
26
|
|
|
27
27
|
### 3. Make sure your events are setup correctly
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
AhoyCaptain doesn't do any tracking for you; it merely provides a dashboard for your data from the Ahoy gem.
|
|
30
|
+
|
|
31
|
+
By default, AhoyCaptain assumes you're tracking `controller` and `action` in your `Ahoy::Event` properties, and a page view event is named `$view`. See this section for more information: https://github.com/ankane/ahoy#events
|
|
30
32
|
|
|
31
33
|
For a quick sanity check:
|
|
32
34
|
|
|
@@ -35,7 +37,7 @@ AhoyCaptain.event.where(name: AhoyCaptain.config.event[:view_name]).count
|
|
|
35
37
|
AhoyCaptain.event.with_routes.count
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
See the initializer `config/initializers/ahoy_captain.rb` for
|
|
40
|
+
This can be fully-customized. See the initializer `config/initializers/ahoy_captain.rb` for more.
|
|
39
41
|
|
|
40
42
|
### 4. Star this repo
|
|
41
43
|
|
|
@@ -61,11 +63,13 @@ If you have a large dataset (> 1GB) you probably want some indexes. `rails g aho
|
|
|
61
63
|
* OS
|
|
62
64
|
* UTM tags
|
|
63
65
|
* Goal
|
|
66
|
+
* Event Property
|
|
64
67
|
* CSV exports
|
|
68
|
+
* Date comparison
|
|
65
69
|
|
|
66
70
|
## Coming soon ™️
|
|
67
71
|
|
|
68
|
-
*
|
|
72
|
+
* Bug fixes and performance improvements
|
|
69
73
|
|
|
70
74
|
## Contributors
|
|
71
75
|
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
|
-
static targets = ["link"]
|
|
4
|
+
static targets = ["link", "alt"]
|
|
5
|
+
static values = {
|
|
6
|
+
classes: { type: Array, default: ["text-primary", "font-semibold"] }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// sometimes the target is not the link itself but a child element, and we want to highlight something other than the
|
|
10
|
+
// link itself.
|
|
11
|
+
// this can be bettered
|
|
5
12
|
connect() {
|
|
6
13
|
this.handleLinkClick = (event) => {
|
|
7
|
-
|
|
8
|
-
event.target.
|
|
14
|
+
let link = null;
|
|
15
|
+
if(event.target.tagName === "A") {
|
|
16
|
+
link = event.target;
|
|
17
|
+
} else {
|
|
18
|
+
link = (event.target.closest('a').querySelector('[data-active-links-target="link"]'))
|
|
19
|
+
}
|
|
20
|
+
this.linkTargets.forEach(link => this.classesValue.forEach(klass => link.classList.remove(klass)))
|
|
21
|
+
this.classesValue.forEach(klass => link.classList.add(klass))
|
|
9
22
|
}
|
|
10
23
|
this.linkTargets.forEach(link => {
|
|
11
24
|
link.addEventListener('click', this.handleLinkClick)
|
|
12
25
|
})
|
|
26
|
+
this.altTargets.forEach(target => {
|
|
27
|
+
target.addEventListener('click', this.handleLinkClick)
|
|
28
|
+
})
|
|
13
29
|
}
|
|
14
30
|
}
|
|
@@ -2,6 +2,7 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
4
|
connect() {
|
|
5
|
+
window.comboboxConnected = 0;
|
|
5
6
|
if (new URLSearchParams(window.location.search).get('period') === 'realtime') {
|
|
6
7
|
this.element.querySelectorAll('turbo-frame').forEach((frame) => {
|
|
7
8
|
setInterval(() => {
|
|
@@ -10,4 +11,11 @@ export default class extends Controller {
|
|
|
10
11
|
});
|
|
11
12
|
}
|
|
12
13
|
}
|
|
14
|
+
|
|
15
|
+
comboboxInit(event) {
|
|
16
|
+
if(event.detail.combobox.selectTarget.id === "property-name" || event.detail.combobox.selectTarget.id === "property-value") {
|
|
17
|
+
window.comboboxConnected += 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
import "classnames"
|
|
3
|
+
|
|
4
|
+
const debounce = (func, delay) => {
|
|
5
|
+
let debounceTimer
|
|
6
|
+
return function() {
|
|
7
|
+
const context = this
|
|
8
|
+
const args = arguments
|
|
9
|
+
clearTimeout(debounceTimer)
|
|
10
|
+
debounceTimer = setTimeout(() => func.apply(context, args), delay)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
static targets = ["input", "list", "option", "container", "select", "highlighted", "box", "selected"];
|
|
16
|
+
static classes = ["boxOpen"]
|
|
17
|
+
static values = {
|
|
18
|
+
options: Array,
|
|
19
|
+
isLoading: Boolean,
|
|
20
|
+
isOpen: Boolean,
|
|
21
|
+
disabled: Boolean,
|
|
22
|
+
input: String,
|
|
23
|
+
highlightedIndex: Number,
|
|
24
|
+
singleOption: Boolean,
|
|
25
|
+
freeChoice: Boolean,
|
|
26
|
+
selected: Array,
|
|
27
|
+
url: String,
|
|
28
|
+
query: String
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
connect() {
|
|
32
|
+
this.isLoadingValue = false;
|
|
33
|
+
this.isOpenValue = false;
|
|
34
|
+
this.inputValue = '';
|
|
35
|
+
this.highlightedIndexValue = 0;
|
|
36
|
+
this.clickHandler = this.clickHandler.bind(this)
|
|
37
|
+
|
|
38
|
+
this.inputTarget.addEventListener('keydown', this.onKeyDown.bind(this))
|
|
39
|
+
this.debouncedFetchOptions = debounce(this.fetchOptions.bind(this), 250);
|
|
40
|
+
this.checkDisabledState();
|
|
41
|
+
if(this.singleOptionValue) {
|
|
42
|
+
this.selectTarget.removeAttribute('multiple')
|
|
43
|
+
} else {
|
|
44
|
+
this.selectTarget.multiple = true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Object.defineProperty(this.selectTarget, "combobox", {
|
|
48
|
+
enumerable: false,
|
|
49
|
+
configurable: false,
|
|
50
|
+
writable: false,
|
|
51
|
+
value: this,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
window.dispatchEvent(new CustomEvent('combobox:init', { detail: { combobox: this } }))
|
|
55
|
+
this.search = new URLSearchParams(window.location.search);
|
|
56
|
+
this.search.delete(this.selectTarget.name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
checkDisabledState() {
|
|
60
|
+
if (this.disabledValue) {
|
|
61
|
+
this.element.classList.add('opacity-80', 'cursor-default', 'pointer-events-none');
|
|
62
|
+
} else {
|
|
63
|
+
this.element.classList.remove('opacity-80', 'cursor-default', 'pointer-events-none')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onInput(event) {
|
|
68
|
+
this.inputValue = event.target.value;
|
|
69
|
+
this.debouncedFetchOptions(this.inputValue);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fetchOptions(query) {
|
|
73
|
+
if(this.disabledValue) { return }
|
|
74
|
+
this.isLoadingValue = true;
|
|
75
|
+
this.isOpenValue = true;
|
|
76
|
+
|
|
77
|
+
const searchParams = new URLSearchParams(this.search.toString());
|
|
78
|
+
const formData = new FormData(this.element.form);
|
|
79
|
+
|
|
80
|
+
let deleted = [];
|
|
81
|
+
for (const [key, value] of formData) {
|
|
82
|
+
if(!deleted.includes(key)) {
|
|
83
|
+
searchParams.delete(key)
|
|
84
|
+
deleted.push(key)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
searchParams.delete(this.element.name);
|
|
89
|
+
searchParams.delete(this.queryValue);
|
|
90
|
+
searchParams.set(this.queryValue, query);
|
|
91
|
+
|
|
92
|
+
fetch(`${this.urlValue}?${searchParams.toString()}`).then(resp => resp.json()).then(loadedOptions => {
|
|
93
|
+
this.isLoadingValue = false;
|
|
94
|
+
this.highlightedIndexValue = 0;
|
|
95
|
+
this.optionsValue = loadedOptions.map(option => ({ text: option.text, value: option.text }));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
highlight(element) {
|
|
100
|
+
const index = parseInt(element.target.dataset.index);
|
|
101
|
+
this.highlightIndex(index)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
scrollToOption(index) {
|
|
105
|
+
const optionElement = this.listTarget.querySelector(`[data-index="${index}"]`);
|
|
106
|
+
if (optionElement) {
|
|
107
|
+
optionElement.scrollIntoView({ block: 'center' });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
highlightIndex(index) {
|
|
112
|
+
this.highlightedIndexValue = index;
|
|
113
|
+
this.scrollToOption(index);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setSelected(values) {
|
|
117
|
+
this.selectedValue = values;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setDisabled(value) {
|
|
121
|
+
this.disabledValue = value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onKeyDown(event) {
|
|
125
|
+
switch (event.key) {
|
|
126
|
+
case 'Enter':
|
|
127
|
+
if (!this.isOpenValue || this.isLoadingValue || this.optionTargets.length === 0) return;
|
|
128
|
+
const option = this.listTarget.querySelector(`[data-index="${this.highlightedIndexValue}"]`);
|
|
129
|
+
if(option) {
|
|
130
|
+
this.selectOption(option);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
break;
|
|
135
|
+
case 'Escape':
|
|
136
|
+
if (!this.isOpenValue || this.isLoadingValue) return;
|
|
137
|
+
this.isOpenValue = false;
|
|
138
|
+
this.inputTarget.focus();
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
break;
|
|
141
|
+
case 'ArrowDown':
|
|
142
|
+
if(this.isOpenValue) {
|
|
143
|
+
this.highlightIndex(this.highlightedIndexValue + 1)
|
|
144
|
+
} else {
|
|
145
|
+
this.isOpenValue = true
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
case 'ArrowUp':
|
|
149
|
+
if(this.isOpenValue) {
|
|
150
|
+
this.highlightIndex(this.highlightedIndexValue - 1)
|
|
151
|
+
} else {
|
|
152
|
+
this.isOpenValue = true
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
selectOption(selected) {
|
|
159
|
+
let value = null;
|
|
160
|
+
if(selected.tagName) {
|
|
161
|
+
value = selected.dataset.value;
|
|
162
|
+
if(value === undefined) {
|
|
163
|
+
value = selected.parentElement.dataset.value
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
value = selected.target.dataset.value;
|
|
167
|
+
if(value === undefined) {
|
|
168
|
+
value = selected.target.parentElement.dataset.value
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const option = this.optionsValue.filter(option => option.value === value)[0];
|
|
173
|
+
if(this.singleOptionValue) {
|
|
174
|
+
this.selectedValue = [option]
|
|
175
|
+
} else {
|
|
176
|
+
this.selectedValue = [...this.selectedValue, option]
|
|
177
|
+
}
|
|
178
|
+
this.isOpenValue = false;
|
|
179
|
+
this.inputTarget.value = '';
|
|
180
|
+
this.highlightedIndexValue = 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
toggleOpen() {
|
|
184
|
+
if (!this.isOpenValue) {
|
|
185
|
+
this.debouncedFetchOptions(this.inputValue);
|
|
186
|
+
this.inputTarget.focus();
|
|
187
|
+
document.addEventListener('click', this.clickHandler)
|
|
188
|
+
} else {
|
|
189
|
+
this.inputValue = '';
|
|
190
|
+
this.isOpenValue = false;
|
|
191
|
+
document.removeEventListener('click', this.clickHandler)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
clickHandler(event) {
|
|
196
|
+
if(event.target.classList.contains('combobox-option')) {
|
|
197
|
+
return
|
|
198
|
+
} else {
|
|
199
|
+
this.toggleOpen()
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
isOpenValueChanged(current) {
|
|
205
|
+
if(current) {
|
|
206
|
+
this.boxTarget.classList.add(...this.boxOpenClasses)
|
|
207
|
+
} else {
|
|
208
|
+
this.boxTarget.classList.remove(...this.boxOpenClasses)
|
|
209
|
+
|
|
210
|
+
}
|
|
211
|
+
this.listTarget.style.display = current ? 'block' : 'none';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
highlightedIndexValueChanged(current, previous) {
|
|
215
|
+
const prev = this.listTarget.querySelector(`[data-index="${previous}"]`)
|
|
216
|
+
if(prev) {
|
|
217
|
+
prev.classList.remove('bg-primary-600', 'text-white')
|
|
218
|
+
}
|
|
219
|
+
const now = this.listTarget.querySelector(`[data-index="${current}"]`);
|
|
220
|
+
if(now) {
|
|
221
|
+
now.classList.add('bg-primary-600', 'text-white')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
renderDropDownContent() {
|
|
226
|
+
this.listTarget.innerHTML = "";
|
|
227
|
+
|
|
228
|
+
const visibleOptions = this.visibleOptions()
|
|
229
|
+
const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !this.isOptionDisabled(option))
|
|
230
|
+
|
|
231
|
+
if (matchesFound) {
|
|
232
|
+
return this.renderOptions(visibleOptions.filter(option => !this.isOptionDisabled(option)))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if(this.isLoadingValue) {
|
|
236
|
+
this.listTarget.innerHTML = `<div>Is Loading..</div>`
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if(this.freeChoiceValue) {
|
|
241
|
+
this.listTarget.innerHTML = `<div class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Start typing to apply filter</div>`
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this.listTarget.innerHTML = `<div class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
|
|
246
|
+
No matches found in the current dashboard. Try selecting a different time range or searching for something different.
|
|
247
|
+
</div>`
|
|
248
|
+
|
|
249
|
+
}
|
|
250
|
+
renderOptions(options) {
|
|
251
|
+
options.forEach((option, index) => {
|
|
252
|
+
const optionElement = document.createElement("li");
|
|
253
|
+
const isHighlighted = this.highlightedIndexValue === index;
|
|
254
|
+
optionElement.innerHTML = `<span class="block truncate" data-index="${index}">${option.text}</span>`;
|
|
255
|
+
optionElement.className = classNames('combobox-option relative cursor-pointer select-none py-2 px-3 hover:bg-primary-600 hover:text-white', {
|
|
256
|
+
'text-accent-900': !isHighlighted,
|
|
257
|
+
'bg-primary-600 text-white': isHighlighted,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if(isHighlighted) {
|
|
261
|
+
optionElement.dataset.comboboxTarget = "option"
|
|
262
|
+
}
|
|
263
|
+
optionElement.dataset.action = "click->combobox#selectOption"
|
|
264
|
+
optionElement.dataset.index = index;
|
|
265
|
+
optionElement.dataset.value = option.value
|
|
266
|
+
optionElement.id = `combobox-option-${index}`;
|
|
267
|
+
|
|
268
|
+
this.listTarget.appendChild(optionElement);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
optionsValueChanged(current, before) {
|
|
272
|
+
this.renderDropDownContent()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
isOptionDisabled(option) {
|
|
276
|
+
const disabled = this.selectedValue.some((val) => val.value === option.value)
|
|
277
|
+
|
|
278
|
+
return disabled
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
visibleOptions() {
|
|
282
|
+
const visibleOptions = [...this.optionsValue]
|
|
283
|
+
if (this.freeChoiceValue && this.inputTarget.length > 0 && this.optionsValue.every(option => option.value !== this.inputTarget.value)) {
|
|
284
|
+
visibleOptions.push({value: this.inputTarget.value, label: this.inputTarget.value, freeChoice: true})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return visibleOptions
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
selectedValueChanged(current, prev) {
|
|
291
|
+
this.renderSelectedValues()
|
|
292
|
+
this.renderDropDownContent()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
removeOption(e) {
|
|
296
|
+
e.stopPropagation()
|
|
297
|
+
const option = this.selectTarget.querySelector(`option[value="${e.target.dataset.value}"]`);
|
|
298
|
+
option.remove()
|
|
299
|
+
const newValues = [];
|
|
300
|
+
this.selectTarget.querySelectorAll('option[selected]').forEach(option => {
|
|
301
|
+
newValues.push({text: option.text, value: option.value })
|
|
302
|
+
})
|
|
303
|
+
this.selectedValue = newValues;
|
|
304
|
+
this.isOpenValue = false
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
renderSelectedValues() {
|
|
308
|
+
this.selectTarget.innerHTML = ""
|
|
309
|
+
this.selectedTarget.innerHTML = ""
|
|
310
|
+
this.selectedValue.forEach(value => {
|
|
311
|
+
const option = document.createElement('option');
|
|
312
|
+
option.text = value.text;
|
|
313
|
+
option.value = value.value;
|
|
314
|
+
option.setAttribute('selected', 'selected')
|
|
315
|
+
this.selectTarget.appendChild(option)
|
|
316
|
+
|
|
317
|
+
const el = document.createElement("div");
|
|
318
|
+
el.classList.add('text-primary-content', 'bg-primary', 'flex', 'justify-between', 'w-full', 'rounded-sm', 'px-2', 'py-0.5', 'm-0.5', 'text-sm');
|
|
319
|
+
el.innerHTML = `<span class="break-all">${option.text}</span><span class="cursor-pointer font-bold ml-1" data-action="click->combobox#removeOption" data-value="${option.value}" >×</span>`;
|
|
320
|
+
this.selectedTarget.appendChild(el)
|
|
321
|
+
})
|
|
322
|
+
var event = new Event('change');
|
|
323
|
+
this.selectTarget.dispatchEvent(event);
|
|
324
|
+
if(this.selectedValue.length === 0) {
|
|
325
|
+
this.selectedTarget.style.display = "none"
|
|
326
|
+
} else {
|
|
327
|
+
this.selectedTarget.style.display = ""
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
disabledValueChanged(current) {
|
|
332
|
+
if(current) {
|
|
333
|
+
this.isOpenValue = false
|
|
334
|
+
this.inputTarget.disabled = true
|
|
335
|
+
this.checkDisabledState()
|
|
336
|
+
} else {
|
|
337
|
+
this.inputTarget.removeAttribute('disabled')
|
|
338
|
+
this.checkDisabledState()
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
|
|
5
|
+
// reverts the modal back to its original state if it was simply closed
|
|
6
|
+
connect() {
|
|
7
|
+
const targetNode = this.element;
|
|
8
|
+
const config = { attributes: true, childList: false, subtree: false };
|
|
9
|
+
const callback = (mutationList, observer) => {
|
|
10
|
+
for (const mutation of mutationList) {
|
|
11
|
+
if(mutation.attributeName === "open") {
|
|
12
|
+
if(this.element.open) {
|
|
13
|
+
if(!this.originalValues) {
|
|
14
|
+
this.originalValues = {};
|
|
15
|
+
const formElements = this.element.querySelectorAll('select');
|
|
16
|
+
formElements.forEach(el => {
|
|
17
|
+
if(el.combobox) {
|
|
18
|
+
this.originalValues[el.id] = el.combobox.selectedValue;
|
|
19
|
+
} else {
|
|
20
|
+
this.originalValues[el.id] = el.value;
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
const formElements = this.element.querySelectorAll('select');
|
|
26
|
+
formElements.forEach(el => {
|
|
27
|
+
if(this.originalValues[el.id]) {
|
|
28
|
+
if(el.combobox) {
|
|
29
|
+
el.combobox.setSelected(this.originalValues[el.id])
|
|
30
|
+
} else {
|
|
31
|
+
el.value = this.originalValues[el.id]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const observer = new MutationObserver(callback);
|
|
41
|
+
observer.observe(targetNode, config);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
}
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
import 'chartjs-plugin-datalabels';
|
|
3
|
+
import { getCSS, externalTooltipHandler } from "helpers/chart_utils";
|
|
4
|
+
|
|
5
|
+
const calculatePercentageDifference = function(oldValue, newValue) {
|
|
6
|
+
if(!oldValue) { return false }
|
|
7
|
+
if (oldValue == 0 && newValue > 0) {
|
|
8
|
+
return 100
|
|
9
|
+
} else if (oldValue == 0 && newValue == 0) {
|
|
10
|
+
return 0
|
|
11
|
+
} else {
|
|
12
|
+
return Math.round((newValue - oldValue) / oldValue * 100)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
3
15
|
|
|
4
16
|
export default class extends Controller {
|
|
5
17
|
connect() {
|
|
6
|
-
|
|
18
|
+
this.funnel = JSON.parse(this.element.dataset.data);
|
|
7
19
|
|
|
8
20
|
const fontFamily = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
|
|
9
|
-
const labels = funnel.steps.map((step) => step.name);
|
|
10
|
-
const stepData = funnel.steps.map((step) => step.total_events);
|
|
11
|
-
const dropOffData = funnel.steps.map((step) => step.drop_off * 100);
|
|
21
|
+
const labels = this.funnel.steps.map((step) => step.name);
|
|
22
|
+
const stepData = this.funnel.steps.map((step) => step.total_events);
|
|
23
|
+
const dropOffData = this.funnel.steps.map((step) => step.drop_off * 100);
|
|
12
24
|
|
|
13
25
|
const data = {
|
|
14
26
|
labels,
|
|
@@ -17,13 +29,19 @@ export default class extends Controller {
|
|
|
17
29
|
label: 'Visitors',
|
|
18
30
|
data: stepData,
|
|
19
31
|
borderRadius: 4,
|
|
32
|
+
color: getCSS('--ac'),
|
|
33
|
+
backgroundColor: getCSS('--p'),
|
|
20
34
|
stack: 'Stack 0',
|
|
35
|
+
yAxisID: 'y',
|
|
21
36
|
},
|
|
22
37
|
{
|
|
23
38
|
label: 'Dropoff',
|
|
24
39
|
data: dropOffData,
|
|
25
40
|
borderRadius: 4,
|
|
26
41
|
stack: 'Stack 0',
|
|
42
|
+
color: getCSS('--ac'),
|
|
43
|
+
backgroundColor: getCSS('--a'),
|
|
44
|
+
yAxisID: 'yComparison',
|
|
27
45
|
},
|
|
28
46
|
],
|
|
29
47
|
};
|
|
@@ -39,13 +57,11 @@ export default class extends Controller {
|
|
|
39
57
|
padding: 100,
|
|
40
58
|
},
|
|
41
59
|
plugins: {
|
|
42
|
-
legend:
|
|
43
|
-
display: false,
|
|
44
|
-
},
|
|
60
|
+
legend: false,
|
|
45
61
|
tooltip: {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
enabled: false,
|
|
63
|
+
position: 'nearest',
|
|
64
|
+
external: externalTooltipHandler(this)
|
|
49
65
|
},
|
|
50
66
|
datalabels: {
|
|
51
67
|
anchor: 'end',
|
|
@@ -54,9 +70,7 @@ export default class extends Controller {
|
|
|
54
70
|
padding: {
|
|
55
71
|
top: 8, bottom: 8, right: 8, left: 8,
|
|
56
72
|
},
|
|
57
|
-
|
|
58
|
-
size: 12, weight: 'normal', lineHeight: 1.6, family: fontFamily,
|
|
59
|
-
},
|
|
73
|
+
color: getCSS('--pc'),
|
|
60
74
|
textAlign: 'center',
|
|
61
75
|
},
|
|
62
76
|
},
|
|
@@ -69,8 +83,6 @@ export default class extends Controller {
|
|
|
69
83
|
grid: { drawBorder: false, display: false },
|
|
70
84
|
ticks: {
|
|
71
85
|
padding: 8,
|
|
72
|
-
font: { weight: 'bold', family: fontFamily, size: 14 },
|
|
73
|
-
color: 'rgb(228, 228, 231)',
|
|
74
86
|
},
|
|
75
87
|
},
|
|
76
88
|
},
|
|
@@ -79,9 +91,39 @@ export default class extends Controller {
|
|
|
79
91
|
|
|
80
92
|
const visitorsData = [];
|
|
81
93
|
|
|
82
|
-
new Chart(
|
|
94
|
+
this.chart = new Chart(
|
|
83
95
|
this.element,
|
|
84
96
|
config,
|
|
85
97
|
);
|
|
86
98
|
}
|
|
99
|
+
|
|
100
|
+
formatLabel(label) {
|
|
101
|
+
return label
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
formatMetric(metric) {
|
|
105
|
+
return metric
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
extractTooltipData(tooltip) {
|
|
110
|
+
const data = this.funnel.steps.find(step => step.name === tooltip.title[0]);
|
|
111
|
+
|
|
112
|
+
const value = data.total_events;
|
|
113
|
+
const label = "Visitors"
|
|
114
|
+
let comparisonLabel = "Dropoff"
|
|
115
|
+
let comparisonValue = data.unique_visits;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
comparison: true,
|
|
119
|
+
comparisonDifference: false,
|
|
120
|
+
metric: tooltip.title[0],
|
|
121
|
+
label: this.formatLabel(label),
|
|
122
|
+
labelBackgroundColor: getCSS('--bc'),
|
|
123
|
+
formattedValue: value,
|
|
124
|
+
comparisonLabel: comparisonLabel,
|
|
125
|
+
comparisonLabelBackgroundColor: "",
|
|
126
|
+
formattedComparisonValue: comparisonValue,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
87
129
|
}
|
|
@@ -6,5 +6,10 @@ export default class extends Controller {
|
|
|
6
6
|
const interval = event.target.value;
|
|
7
7
|
url.searchParams.set('interval', interval);
|
|
8
8
|
event.target.closest('turbo-frame').src = url.href;
|
|
9
|
+
document.querySelectorAll('a[data-turbo-frame="chart"]').forEach(el => {
|
|
10
|
+
const url = new URL(el.href);
|
|
11
|
+
url.searchParams.set('interval', interval);
|
|
12
|
+
el.href = url.href
|
|
13
|
+
})
|
|
9
14
|
}
|
|
10
15
|
}
|