ahoy_captain 0.9 → 0.10.1

Sign up to get free protection for your applications and to get access to all the features.
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 +45 -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: 5b2500018e9b54eebbe3265041ed28c4a545698362738c9e2dfe34db83edc221
4
+ data.tar.gz: dc8b920328f271d5ff63047360b6e5ef1b06ba12b1598346005063bc3548d102
5
5
  SHA512:
6
- metadata.gz: 737b08e21d4ea18f634ff5c19e10d0f9bc21f73eba746c93cc1f74f6459ebb9f8d95d7ca7ee82236bcb163cfbf6642acb30a547cc710c3d5e63abcaa4d49c1f6
7
- data.tar.gz: 00dd0823f703ce906e201dd227b33a4cc0606f6d67680c63c1a1c6231110557e18ccfbb81a6964b601ba30a81fd8bd72a618b3da407f40c7f91181ed3cc6472a
6
+ metadata.gz: 823d78198fc0b83ed2535fa9482a5b468fee0aa7a8668db13a625b4b10b68b1910c345790b1ade4e3a9eea7a96f86f5042721dae6a162877f3ac78260437973c
7
+ data.tar.gz: c0969c25e1bb0170eb49b310e48b7a4a5b5cde951d927d535af261f76a8c6c49f7bde837978e23e1e50042f0e706e39d90605d159860aefe179ac27842c3a179
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.value }));
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
  }