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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -4
  3. data/app/assets/javascript/ahoy_captain/controllers/active_links_controller.js +19 -3
  4. data/app/assets/javascript/ahoy_captain/controllers/application_controller.js +8 -0
  5. data/app/assets/javascript/ahoy_captain/controllers/combobox_controller.js +341 -0
  6. data/app/assets/javascript/ahoy_captain/controllers/filter_modal_controller.js +45 -0
  7. data/app/assets/javascript/ahoy_captain/controllers/funnel_chart_controller.js +58 -16
  8. data/app/assets/javascript/ahoy_captain/controllers/interval_controller.js +5 -0
  9. data/app/assets/javascript/ahoy_captain/controllers/line_chart_controller.js +170 -19
  10. data/app/assets/javascript/ahoy_captain/controllers/predicate_select_controller.js +0 -1
  11. data/app/assets/javascript/ahoy_captain/controllers/properties_controller.js +8 -0
  12. data/app/assets/javascript/ahoy_captain/controllers/property_filter_controller.js +46 -0
  13. data/app/assets/javascript/ahoy_captain/controllers/realtime_controller.js +4 -2
  14. data/app/assets/javascript/ahoy_captain/controllers/tile_controller.js +9 -0
  15. data/app/assets/javascript/ahoy_captain/helpers/chart_utils.js +156 -0
  16. data/app/assets/javascript/ahoy_captain/helpers/number_formatters.js +55 -0
  17. data/app/components/ahoy_captain/combobox_component.html.erb +33 -0
  18. data/app/components/ahoy_captain/combobox_component.rb +13 -0
  19. data/app/components/ahoy_captain/comparison_link_component.rb +40 -0
  20. data/app/components/ahoy_captain/filter/dropdown_component.html.erb +8 -6
  21. data/app/components/ahoy_captain/filter/modal_component.html.erb +7 -5
  22. data/app/components/ahoy_captain/filter/select_component.html.erb +23 -21
  23. data/app/components/ahoy_captain/filter/select_component.rb +2 -1
  24. data/app/components/ahoy_captain/filter/tag_component.html.erb +1 -1
  25. data/app/components/ahoy_captain/stats/comparable_container_component.html.erb +25 -0
  26. data/app/components/ahoy_captain/stats/comparable_container_component.rb +86 -0
  27. data/app/components/ahoy_captain/stats/container_component.html.erb +13 -6
  28. data/app/components/ahoy_captain/stats/container_component.rb +16 -1
  29. data/app/components/ahoy_captain/sticky_nav_component.html.erb +7 -4
  30. data/app/components/ahoy_captain/sticky_nav_component.rb +3 -0
  31. data/app/components/ahoy_captain/table_component.rb +13 -4
  32. data/app/components/ahoy_captain/tables/devices_table_component.rb +11 -0
  33. data/app/components/ahoy_captain/tables/dynamic_table.rb +13 -0
  34. data/app/components/ahoy_captain/tables/dynamic_table_component.rb +204 -0
  35. data/app/components/ahoy_captain/tables/goals_table_component.rb +17 -0
  36. data/app/components/ahoy_captain/tables/header_component.html.erb +5 -0
  37. data/app/components/ahoy_captain/tables/header_component.rb +18 -0
  38. data/app/components/ahoy_captain/tables/headers/header_component.rb +4 -0
  39. data/app/components/ahoy_captain/tables/properties_table_component.rb +27 -0
  40. data/app/components/ahoy_captain/tables/row_component.html.erb +4 -0
  41. data/app/components/ahoy_captain/tables/rows/row_component.rb +0 -1
  42. data/app/components/ahoy_captain/tile_component.html.erb +19 -9
  43. data/app/components/ahoy_captain/tile_component.rb +2 -1
  44. data/app/controllers/ahoy_captain/application_controller.rb +7 -16
  45. data/app/controllers/ahoy_captain/exports_controller.rb +1 -2
  46. data/app/controllers/ahoy_captain/filters/base_controller.rb +1 -3
  47. data/app/controllers/ahoy_captain/filters/goals_controller.rb +9 -0
  48. data/app/controllers/ahoy_captain/filters/pages/actions_controller.rb +1 -1
  49. data/app/controllers/ahoy_captain/filters/pages/entry_pages_controller.rb +1 -1
  50. data/app/controllers/ahoy_captain/filters/pages/exit_pages_controller.rb +1 -1
  51. data/app/controllers/ahoy_captain/filters/properties/values_controller.rb +4 -4
  52. data/app/controllers/ahoy_captain/filters/utms_controller.rb +1 -1
  53. data/app/controllers/ahoy_captain/properties_controller.rb +41 -0
  54. data/app/controllers/ahoy_captain/stats/base_controller.rb +86 -5
  55. data/app/controllers/ahoy_captain/stats/bounce_rates_controller.rb +1 -1
  56. data/app/controllers/ahoy_captain/stats/total_pageviews_controller.rb +1 -1
  57. data/app/controllers/ahoy_captain/stats/total_visits_controller.rb +1 -1
  58. data/app/controllers/ahoy_captain/stats/unique_visitors_controller.rb +2 -1
  59. data/app/controllers/ahoy_captain/stats/views_per_visits_controller.rb +1 -10
  60. data/app/controllers/ahoy_captain/stats/visit_durations_controller.rb +1 -1
  61. data/app/helpers/ahoy_captain/application_helper.rb +35 -9
  62. data/app/models/ahoy_captain/comparison_mode.rb +72 -0
  63. data/app/models/ahoy_captain/filter_parser.rb +33 -18
  64. data/app/models/ahoy_captain/range_from_params.rb +75 -0
  65. data/app/models/ahoy_captain/rangeable.rb +0 -3
  66. data/app/models/concerns/ahoy_captain/compare_mode.rb +19 -0
  67. data/app/models/concerns/ahoy_captain/limitable.rb +17 -0
  68. data/app/models/concerns/ahoy_captain/range_options.rb +1 -14
  69. data/app/presenters/ahoy_captain/dashboard_presenter.rb +18 -54
  70. data/app/queries/ahoy_captain/application_query.rb +74 -10
  71. data/app/queries/ahoy_captain/event_query.rb +7 -2
  72. data/app/queries/ahoy_captain/stats/average_views_per_visit_query.rb +11 -4
  73. data/app/queries/ahoy_captain/stats/average_visit_duration_query.rb +14 -2
  74. data/app/queries/ahoy_captain/stats/base_query.rb +18 -0
  75. data/app/queries/ahoy_captain/stats/bounce_rates_query.rb +15 -1
  76. data/app/queries/ahoy_captain/stats/total_pageviews_query.rb +2 -2
  77. data/app/queries/ahoy_captain/stats/total_visitors_query.rb +1 -1
  78. data/app/queries/ahoy_captain/stats/unique_visitors_query.rb +1 -1
  79. data/app/queries/ahoy_captain/stats/views_per_visit_query.rb +1 -1
  80. data/app/queries/ahoy_captain/stats/visit_duration_query.rb +3 -3
  81. data/app/queries/ahoy_captain/visit_query.rb +1 -2
  82. data/app/queries/concerns/ahoy_captain/comparable_queries.rb +25 -0
  83. data/app/queries/concerns/ahoy_captain/comparable_query.rb +138 -0
  84. data/app/queries/concerns/ahoy_captain/lazy_comparable_query.rb +42 -0
  85. data/app/views/ahoy_captain/devices/_table.html.erb +1 -4
  86. data/app/views/ahoy_captain/goals/index.html.erb +1 -4
  87. data/app/views/ahoy_captain/layouts/application.html.erb +0 -1
  88. data/app/views/ahoy_captain/properties/_form.html.erb +6 -0
  89. data/app/views/ahoy_captain/properties/index.html.erb +3 -0
  90. data/app/views/ahoy_captain/properties/show.html.erb +6 -0
  91. data/app/views/ahoy_captain/roots/_filters.html.erb +47 -1
  92. data/app/views/ahoy_captain/roots/show.html.erb +60 -31
  93. data/app/views/ahoy_captain/stats/base/index.html.erb +36 -8
  94. data/app/views/ahoy_captain/stats/show.html.erb +8 -10
  95. data/config/routes.rb +2 -0
  96. data/lib/ahoy_captain/ahoy/event_methods.rb +13 -15
  97. data/lib/ahoy_captain/configuration.rb +7 -6
  98. data/lib/ahoy_captain/engine.rb +4 -0
  99. data/lib/ahoy_captain/filters_configuration.rb +5 -1
  100. data/lib/ahoy_captain/version.rb +1 -1
  101. data/lib/ahoy_captain.rb +6 -1
  102. data/lib/generators/ahoy_captain/templates/config.rb.tt +7 -0
  103. metadata +35 -12
  104. data/app/assets/javascript/ahoy_captain/controllers/filter_tag_controller.js +0 -20
  105. data/app/assets/javascript/ahoy_captain/controllers/search_select_controller.js +0 -65
  106. data/app/components/ahoy_captain/tables/headers/devices_header_component.html.erb +0 -3
  107. data/app/components/ahoy_captain/tables/headers/devices_header_component.rb +0 -9
  108. data/app/components/ahoy_captain/tables/headers/goals_header_component.html.erb +0 -6
  109. data/app/components/ahoy_captain/tables/headers/goals_header_component.rb +0 -9
  110. data/app/components/ahoy_captain/tables/rows/devices_row_component.html.erb +0 -5
  111. data/app/components/ahoy_captain/tables/rows/devices_row_component.rb +0 -12
  112. data/app/components/ahoy_captain/tables/rows/goals_row_component.html.erb +0 -11
  113. 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: c7334ddf143eb3d28e42766a6cc39f1acc5ee733679e5824399d668f349e1f68
4
- data.tar.gz: 98735c61175fe289a4ffc73c6ac395c540c603d1a05ca95e1369eb5619081a99
3
+ metadata.gz: e860b8082ca3fad364eb34df632f017d81e63ff411c2fcae0501657aef5a578b
4
+ data.tar.gz: 4d0227c2eb7443e4d6a15f817d971f6176c43daad634d057de88f98f7ec8ae7b
5
5
  SHA512:
6
- metadata.gz: 737b08e21d4ea18f634ff5c19e10d0f9bc21f73eba746c93cc1f74f6459ebb9f8d95d7ca7ee82236bcb163cfbf6642acb30a547cc710c3d5e63abcaa4d49c1f6
7
- data.tar.gz: 00dd0823f703ce906e201dd227b33a4cc0606f6d67680c63c1a1c6231110557e18ccfbb81a6964b601ba30a81fd8bd72a618b3da407f40c7f91181ed3cc6472a
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.png"><img src="ss.png" style="max-width:300px" /></a>
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
- By default, AhoyCaptain assumes you're tracking `controller` and `action` in your `Ahoy::Event` properties, and a page view event is named `$view`.
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 further customization.
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
- * Date comparison
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
- this.linkTargets.forEach(link => link.classList.remove('text-primary', 'font-semibold'))
8
- event.target.classList.add('text-primary', 'font-semibold')
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
- const funnel = JSON.parse(this.element.dataset.data);
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
- mode: 'index',
47
- intersect: true,
48
- position: 'average',
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
- font: {
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
  }