lookout-ahoy 0.1.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 (223) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +99 -0
  4. data/Rakefile +24 -0
  5. data/app/assets/images/lookout/apple-touch-icon.png +0 -0
  6. data/app/assets/images/lookout/favicon-16x16.png +0 -0
  7. data/app/assets/images/lookout/favicon-32x32.png +0 -0
  8. data/app/assets/images/lookout/logo.png +0 -0
  9. data/app/assets/images/lookout/safari-pinned-tab.png +0 -0
  10. data/app/assets/images/lookout/safari-pinned-tab.svg +199 -0
  11. data/app/assets/javascript/lookout/application.js +2 -0
  12. data/app/assets/javascript/lookout/controllers/application.js +9 -0
  13. data/app/assets/javascript/lookout/controllers/application_controller.js +33 -0
  14. data/app/assets/javascript/lookout/controllers/combobox_controller.js +371 -0
  15. data/app/assets/javascript/lookout/controllers/details_modal_controller.js +18 -0
  16. data/app/assets/javascript/lookout/controllers/dropdown_label_controller.js +39 -0
  17. data/app/assets/javascript/lookout/controllers/filter/item_controller.js +12 -0
  18. data/app/assets/javascript/lookout/controllers/filter_form_controller.js +13 -0
  19. data/app/assets/javascript/lookout/controllers/filter_modal_controller.js +45 -0
  20. data/app/assets/javascript/lookout/controllers/frame_link_controller.js +20 -0
  21. data/app/assets/javascript/lookout/controllers/funnel_chart_controller.js +159 -0
  22. data/app/assets/javascript/lookout/controllers/index.js +4 -0
  23. data/app/assets/javascript/lookout/controllers/interval_controller.js +15 -0
  24. data/app/assets/javascript/lookout/controllers/line_chart_controller.js +251 -0
  25. data/app/assets/javascript/lookout/controllers/predicate_select_controller.js +10 -0
  26. data/app/assets/javascript/lookout/controllers/properties_controller.js +8 -0
  27. data/app/assets/javascript/lookout/controllers/property_filter_controller.js +45 -0
  28. data/app/assets/javascript/lookout/controllers/realtime_controller.js +30 -0
  29. data/app/assets/javascript/lookout/controllers/sparkline_controller.js +64 -0
  30. data/app/assets/javascript/lookout/controllers/tile_controller.js +33 -0
  31. data/app/assets/javascript/lookout/controllers/toggle_controller.js +17 -0
  32. data/app/assets/javascript/lookout/helpers/chart_utils.js +156 -0
  33. data/app/assets/javascript/lookout/helpers/countries.js +2261 -0
  34. data/app/assets/javascript/lookout/helpers/number_formatters.js +55 -0
  35. data/app/assets/manifest/lookout/manifest.js +2 -0
  36. data/app/components/lookout/combobox_component.html.erb +33 -0
  37. data/app/components/lookout/combobox_component.rb +13 -0
  38. data/app/components/lookout/comparison_link_component.html.erb +17 -0
  39. data/app/components/lookout/comparison_link_component.rb +44 -0
  40. data/app/components/lookout/dropdown_button_component.html.erb +16 -0
  41. data/app/components/lookout/dropdown_button_component.rb +14 -0
  42. data/app/components/lookout/dropdown_link_component.html.erb +17 -0
  43. data/app/components/lookout/dropdown_link_component.rb +19 -0
  44. data/app/components/lookout/filter/dropdown_component.html.erb +50 -0
  45. data/app/components/lookout/filter/dropdown_component.rb +51 -0
  46. data/app/components/lookout/filter/modal_component.html.erb +16 -0
  47. data/app/components/lookout/filter/modal_component.rb +13 -0
  48. data/app/components/lookout/filter/select_component.html.erb +25 -0
  49. data/app/components/lookout/filter/select_component.rb +64 -0
  50. data/app/components/lookout/filter/tag_component.html.erb +13 -0
  51. data/app/components/lookout/filter/tag_component.rb +14 -0
  52. data/app/components/lookout/filter/tag_container_component.html.erb +4 -0
  53. data/app/components/lookout/filter/tag_container_component.rb +6 -0
  54. data/app/components/lookout/previous_next_component.html.erb +8 -0
  55. data/app/components/lookout/previous_next_component.rb +11 -0
  56. data/app/components/lookout/stats/comparable_container_component.html.erb +25 -0
  57. data/app/components/lookout/stats/comparable_container_component.rb +103 -0
  58. data/app/components/lookout/stats/container_component.html.erb +23 -0
  59. data/app/components/lookout/stats/container_component.rb +28 -0
  60. data/app/components/lookout/sticky_nav_component.html.erb +32 -0
  61. data/app/components/lookout/sticky_nav_component.rb +24 -0
  62. data/app/components/lookout/table_component.html.erb +16 -0
  63. data/app/components/lookout/table_component.rb +48 -0
  64. data/app/components/lookout/tables/devices_table_component.rb +11 -0
  65. data/app/components/lookout/tables/dynamic_table.rb +13 -0
  66. data/app/components/lookout/tables/dynamic_table_component.rb +207 -0
  67. data/app/components/lookout/tables/goals_table_component.rb +17 -0
  68. data/app/components/lookout/tables/header_component.html.erb +6 -0
  69. data/app/components/lookout/tables/header_component.rb +18 -0
  70. data/app/components/lookout/tables/headers/header_component.html.erb +5 -0
  71. data/app/components/lookout/tables/headers/header_component.rb +16 -0
  72. data/app/components/lookout/tables/properties_table_component.rb +27 -0
  73. data/app/components/lookout/tables/row_component.html.erb +4 -0
  74. data/app/components/lookout/tables/rows/row_component.html.erb +6 -0
  75. data/app/components/lookout/tables/rows/row_component.rb +40 -0
  76. data/app/components/lookout/tile_component.html.erb +24 -0
  77. data/app/components/lookout/tile_component.rb +24 -0
  78. data/app/components/lookout/tooltip_component.html.erb +3 -0
  79. data/app/components/lookout/tooltip_component.rb +18 -0
  80. data/app/controllers/lookout/application_controller.rb +83 -0
  81. data/app/controllers/lookout/campaigns_controller.rb +19 -0
  82. data/app/controllers/lookout/devices_controller.rb +20 -0
  83. data/app/controllers/lookout/entry_pages_controller.rb +19 -0
  84. data/app/controllers/lookout/exit_pages_controller.rb +19 -0
  85. data/app/controllers/lookout/exports_controller.rb +14 -0
  86. data/app/controllers/lookout/filters/base_controller.rb +15 -0
  87. data/app/controllers/lookout/filters/goals_controller.rb +9 -0
  88. data/app/controllers/lookout/filters/locations_controller.rb +11 -0
  89. data/app/controllers/lookout/filters/operating_systems/names_controller.rb +13 -0
  90. data/app/controllers/lookout/filters/operating_systems/versions_controller.rb +13 -0
  91. data/app/controllers/lookout/filters/pages/actions_controller.rb +13 -0
  92. data/app/controllers/lookout/filters/pages/entry_pages_controller.rb +14 -0
  93. data/app/controllers/lookout/filters/pages/exit_pages_controller.rb +15 -0
  94. data/app/controllers/lookout/filters/properties/names_controller.rb +29 -0
  95. data/app/controllers/lookout/filters/properties/values_controller.rb +15 -0
  96. data/app/controllers/lookout/filters/screens_controller.rb +11 -0
  97. data/app/controllers/lookout/filters/sources_controller.rb +11 -0
  98. data/app/controllers/lookout/filters/utms_controller.rb +10 -0
  99. data/app/controllers/lookout/funnels_controller.rb +8 -0
  100. data/app/controllers/lookout/goals_controller.rb +7 -0
  101. data/app/controllers/lookout/locations/cities_controller.rb +22 -0
  102. data/app/controllers/lookout/locations/countries_controller.rb +22 -0
  103. data/app/controllers/lookout/locations/maps_controller.rb +24 -0
  104. data/app/controllers/lookout/locations/regions_controller.rb +22 -0
  105. data/app/controllers/lookout/properties_controller.rb +73 -0
  106. data/app/controllers/lookout/realtimes_controller.rb +7 -0
  107. data/app/controllers/lookout/roots_controller.rb +6 -0
  108. data/app/controllers/lookout/sources_controller.rb +21 -0
  109. data/app/controllers/lookout/stats/base_controller.rb +148 -0
  110. data/app/controllers/lookout/stats/bounce_rates_controller.rb +12 -0
  111. data/app/controllers/lookout/stats/total_pageviews_controller.rb +10 -0
  112. data/app/controllers/lookout/stats/total_visits_controller.rb +10 -0
  113. data/app/controllers/lookout/stats/unique_visitors_controller.rb +11 -0
  114. data/app/controllers/lookout/stats/views_per_visits_controller.rb +11 -0
  115. data/app/controllers/lookout/stats/visit_durations_controller.rb +10 -0
  116. data/app/controllers/lookout/stats_controller.rb +7 -0
  117. data/app/controllers/lookout/top_pages_controller.rb +20 -0
  118. data/app/decorators/lookout/application_decorator.rb +58 -0
  119. data/app/decorators/lookout/campaign_decorator.rb +27 -0
  120. data/app/decorators/lookout/city_decorator.rb +24 -0
  121. data/app/decorators/lookout/country_decorator.rb +38 -0
  122. data/app/decorators/lookout/device_decorator.rb +27 -0
  123. data/app/decorators/lookout/entry_page_decorator.rb +7 -0
  124. data/app/decorators/lookout/exit_page_decorator.rb +7 -0
  125. data/app/decorators/lookout/page_decorator.rb +27 -0
  126. data/app/decorators/lookout/region_decorator.rb +28 -0
  127. data/app/decorators/lookout/source_decorator.rb +27 -0
  128. data/app/decorators/lookout/top_page_decorator.rb +7 -0
  129. data/app/helpers/lookout/application_helper.rb +124 -0
  130. data/app/models/concerns/lookout/compare_mode.rb +19 -0
  131. data/app/models/concerns/lookout/limitable.rb +17 -0
  132. data/app/models/concerns/lookout/range_options.rb +8 -0
  133. data/app/models/lookout/comparison_mode.rb +72 -0
  134. data/app/models/lookout/export.rb +48 -0
  135. data/app/models/lookout/filter_parser.rb +82 -0
  136. data/app/models/lookout/range_from_params.rb +78 -0
  137. data/app/models/lookout/rangeable.rb +7 -0
  138. data/app/models/lookout/widget.rb +15 -0
  139. data/app/presenters/lookout/dashboard_presenter.rb +53 -0
  140. data/app/presenters/lookout/funnel_presenter.rb +75 -0
  141. data/app/presenters/lookout/goals_presenter.rb +72 -0
  142. data/app/queries/concerns/lookout/comparable_queries.rb +25 -0
  143. data/app/queries/concerns/lookout/comparable_query.rb +152 -0
  144. data/app/queries/concerns/lookout/lazy_comparable_query.rb +42 -0
  145. data/app/queries/lookout/application_query.rb +186 -0
  146. data/app/queries/lookout/campaign_query.rb +14 -0
  147. data/app/queries/lookout/city_query.rb +14 -0
  148. data/app/queries/lookout/country_query.rb +10 -0
  149. data/app/queries/lookout/device_query.rb +10 -0
  150. data/app/queries/lookout/entry_pages_query.rb +18 -0
  151. data/app/queries/lookout/event_query.rb +42 -0
  152. data/app/queries/lookout/exit_pages_query.rb +19 -0
  153. data/app/queries/lookout/region_query.rb +14 -0
  154. data/app/queries/lookout/source_query.rb +11 -0
  155. data/app/queries/lookout/stats/average_views_per_visit_query.rb +20 -0
  156. data/app/queries/lookout/stats/average_visit_duration_query.rb +34 -0
  157. data/app/queries/lookout/stats/base_query.rb +18 -0
  158. data/app/queries/lookout/stats/bounce_rates_query.rb +33 -0
  159. data/app/queries/lookout/stats/total_pageviews_query.rb +9 -0
  160. data/app/queries/lookout/stats/total_visitors_query.rb +9 -0
  161. data/app/queries/lookout/stats/unique_visitors_query.rb +9 -0
  162. data/app/queries/lookout/stats/views_per_visit_query.rb +17 -0
  163. data/app/queries/lookout/stats/visit_duration_query.rb +19 -0
  164. data/app/queries/lookout/top_page_query.rb +13 -0
  165. data/app/queries/lookout/visit_query.rb +42 -0
  166. data/app/views/lookout/campaigns/index.html+details.erb +4 -0
  167. data/app/views/lookout/campaigns/index.html.erb +3 -0
  168. data/app/views/lookout/devices/_table.html.erb +2 -0
  169. data/app/views/lookout/devices/index.html+details.erb +4 -0
  170. data/app/views/lookout/devices/index.html.erb +3 -0
  171. data/app/views/lookout/entry_pages/index.html+details.erb +4 -0
  172. data/app/views/lookout/entry_pages/index.html.erb +3 -0
  173. data/app/views/lookout/exit_pages/index.html+details.erb +4 -0
  174. data/app/views/lookout/exit_pages/index.html.erb +3 -0
  175. data/app/views/lookout/funnels/index.html.erb +7 -0
  176. data/app/views/lookout/funnels/show.html.erb +15 -0
  177. data/app/views/lookout/goals/index.html.erb +4 -0
  178. data/app/views/lookout/layouts/application.html.erb +144 -0
  179. data/app/views/lookout/layouts/shared/_tile_loader.html.erb +5 -0
  180. data/app/views/lookout/layouts/shared/_widget_disabled.html+details.erb +3 -0
  181. data/app/views/lookout/layouts/shared/_widget_disabled.html.erb +3 -0
  182. data/app/views/lookout/locations/cities/index.html+details.erb +4 -0
  183. data/app/views/lookout/locations/cities/index.html.erb +3 -0
  184. data/app/views/lookout/locations/countries/index.html+details.erb +5 -0
  185. data/app/views/lookout/locations/countries/index.html.erb +3 -0
  186. data/app/views/lookout/locations/maps/_simple_map.html.erb +26 -0
  187. data/app/views/lookout/locations/maps/show.html.erb +106 -0
  188. data/app/views/lookout/locations/regions/index.html+details.erb +4 -0
  189. data/app/views/lookout/locations/regions/index.html.erb +3 -0
  190. data/app/views/lookout/properties/_form.html.erb +6 -0
  191. data/app/views/lookout/properties/index.html.erb +3 -0
  192. data/app/views/lookout/properties/show.html.erb +6 -0
  193. data/app/views/lookout/realtimes/show.html.erb +9 -0
  194. data/app/views/lookout/roots/_filters.html.erb +80 -0
  195. data/app/views/lookout/roots/show.html.erb +191 -0
  196. data/app/views/lookout/sources/index.html+details.erb +4 -0
  197. data/app/views/lookout/sources/index.html.erb +3 -0
  198. data/app/views/lookout/stats/base/index.html.erb +40 -0
  199. data/app/views/lookout/stats/show.html.erb +15 -0
  200. data/app/views/lookout/top_pages/index.html+details.erb +4 -0
  201. data/app/views/lookout/top_pages/index.html.erb +3 -0
  202. data/config/routes.rb +69 -0
  203. data/lib/generators/lookout/install_generator.rb +31 -0
  204. data/lib/generators/lookout/migration_generator.rb +21 -0
  205. data/lib/generators/lookout/templates/config.rb.tt +185 -0
  206. data/lib/generators/lookout/templates/migration.rb.tt +7 -0
  207. data/lib/lookout/active_record.rb +108 -0
  208. data/lib/lookout/ahoy/event_methods.rb +75 -0
  209. data/lib/lookout/ahoy/visit_methods.rb +24 -0
  210. data/lib/lookout/configuration.rb +58 -0
  211. data/lib/lookout/database_adapter.rb +168 -0
  212. data/lib/lookout/engine.rb +47 -0
  213. data/lib/lookout/filter_configuration/filter.rb +16 -0
  214. data/lib/lookout/filter_configuration/filter_collection.rb +48 -0
  215. data/lib/lookout/filters_configuration.rb +77 -0
  216. data/lib/lookout/funnels.rb +44 -0
  217. data/lib/lookout/goals.rb +51 -0
  218. data/lib/lookout/period_collection.rb +115 -0
  219. data/lib/lookout/predicate_label.rb +7 -0
  220. data/lib/lookout/railtie.rb +9 -0
  221. data/lib/lookout/version.rb +3 -0
  222. data/lib/lookout.rb +78 -0
  223. metadata +673 -0
@@ -0,0 +1,371 @@
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: { type: Boolean, default: false },
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
+ const targetNode = this.selectTarget;
55
+ const config = { attributes: true };
56
+
57
+ const callback = (mutationList, observer) => {
58
+ for (const mutation of mutationList) {
59
+ if (mutation.type === "attributes") {
60
+ this.handleNameChange()
61
+ }
62
+ }
63
+ };
64
+
65
+ const observer = new MutationObserver(callback);
66
+ observer.observe(targetNode, config);
67
+
68
+
69
+ window.dispatchEvent(new CustomEvent('combobox:init', { detail: { combobox: this } }))
70
+ this.search = new URLSearchParams(window.location.search);
71
+ this.search.delete(this.selectTarget.name)
72
+ }
73
+
74
+ handleNameChange() {
75
+ if(this.selectTarget.name.includes("_cont]")) {
76
+ this.freeChoiceValue = true
77
+ } else {
78
+ this.freeChoiceValue = false
79
+ }
80
+ }
81
+ checkDisabledState() {
82
+ if (this.disabledValue) {
83
+ this.element.classList.add('opacity-80', 'cursor-default', 'pointer-events-none');
84
+ } else {
85
+ this.element.classList.remove('opacity-80', 'cursor-default', 'pointer-events-none')
86
+ }
87
+ }
88
+
89
+ onInput(event) {
90
+ this.inputValue = event.target.value;
91
+ this.debouncedFetchOptions(this.inputValue);
92
+ }
93
+
94
+ fetchOptions(query) {
95
+ if(this.disabledValue) { return }
96
+
97
+ if(this.freeChoiceValue) {
98
+ this.isLoadingValue = false;
99
+ this.highlightedIndexValue = 0;
100
+ this.optionsValue = [{ text: query, value: query }];
101
+ this.isOpenValue = true;
102
+
103
+ } else {
104
+ this.isLoadingValue = true;
105
+ this.isOpenValue = true;
106
+
107
+ const formData = new FormData(this.selectTarget.form);
108
+ const searchParams = new URLSearchParams([...formData.entries()]);
109
+
110
+ searchParams.delete(this.selectTarget.name);
111
+ searchParams.delete(this.queryValue);
112
+ searchParams.set(this.queryValue, query);
113
+
114
+ fetch(`${this.urlValue}?${searchParams.toString()}`).then(resp => resp.json()).then(loadedOptions => {
115
+ this.isLoadingValue = false;
116
+ this.highlightedIndexValue = 0;
117
+ this.optionsValue = loadedOptions.map(option => ({ text: option.text, value: option.value }));
118
+ });
119
+ }
120
+ }
121
+
122
+ highlight(element) {
123
+ const index = parseInt(element.target.dataset.index);
124
+ this.highlightIndex(index)
125
+ }
126
+
127
+ scrollToOption(index) {
128
+ const optionElement = this.listTarget.querySelector(`[data-index="${index}"]`);
129
+ if (optionElement) {
130
+ optionElement.scrollIntoView({ block: 'center' });
131
+ }
132
+ }
133
+
134
+ highlightIndex(index) {
135
+ this.highlightedIndexValue = index;
136
+ this.scrollToOption(index);
137
+ }
138
+
139
+ setSelected(values) {
140
+ this.selectedValue = values;
141
+ }
142
+
143
+ setDisabled(value) {
144
+ this.disabledValue = value
145
+ }
146
+
147
+ onKeyDown(event) {
148
+ switch (event.key) {
149
+ case 'Enter':
150
+ if (!this.isOpenValue || this.isLoadingValue || this.optionTargets.length === 0) return;
151
+ const option = this.listTarget.querySelector(`[data-index="${this.highlightedIndexValue}"]`);
152
+ if(option) {
153
+ this.selectOption(option);
154
+ }
155
+
156
+ event.preventDefault();
157
+ break;
158
+ case 'Escape':
159
+ if (!this.isOpenValue || this.isLoadingValue) return;
160
+ this.isOpenValue = false;
161
+ this.inputTarget.focus();
162
+ event.preventDefault();
163
+ break;
164
+ case 'ArrowDown':
165
+ if(this.isOpenValue) {
166
+ this.highlightIndex(this.highlightedIndexValue + 1)
167
+ } else {
168
+ this.isOpenValue = true
169
+ }
170
+ break;
171
+ case 'ArrowUp':
172
+ if(this.isOpenValue) {
173
+ this.highlightIndex(this.highlightedIndexValue - 1)
174
+ } else {
175
+ this.isOpenValue = true
176
+ }
177
+ break;
178
+ }
179
+ }
180
+
181
+ selectOption(selected) {
182
+ let value = null;
183
+ if(selected.tagName) {
184
+ value = selected.dataset.value;
185
+ if(value === undefined) {
186
+ value = selected.parentElement.dataset.value
187
+ }
188
+ } else {
189
+ value = selected.target.dataset.value;
190
+ if(value === undefined) {
191
+ value = selected.target.parentElement.dataset.value
192
+ }
193
+ }
194
+
195
+ const option = this.optionsValue.filter(option => option.value === value)[0];
196
+ if(this.singleOptionValue) {
197
+ this.selectedValue = [option]
198
+ } else {
199
+ this.selectedValue = [...this.selectedValue, option]
200
+ }
201
+ this.isOpenValue = false;
202
+ this.inputTarget.value = '';
203
+ this.highlightedIndexValue = 0
204
+ }
205
+
206
+ toggleOpen() {
207
+ if (!this.isOpenValue) {
208
+ this.debouncedFetchOptions(this.inputValue);
209
+ this.inputTarget.focus();
210
+ document.addEventListener('click', this.clickHandler)
211
+ } else {
212
+ this.inputValue = '';
213
+ this.isOpenValue = false;
214
+ document.removeEventListener('click', this.clickHandler)
215
+ }
216
+ }
217
+
218
+ clickHandler(event) {
219
+ if(event.target.classList.contains('combobox-option')) {
220
+ return
221
+ } else {
222
+ this.toggleOpen()
223
+ return
224
+ }
225
+ }
226
+
227
+ isOpenValueChanged(current) {
228
+ if(current) {
229
+ this.boxTarget.classList.add(...this.boxOpenClasses)
230
+ } else {
231
+ this.boxTarget.classList.remove(...this.boxOpenClasses)
232
+
233
+ }
234
+ this.listTarget.style.display = current ? 'block' : 'none';
235
+ }
236
+
237
+ highlightedIndexValueChanged(current, previous) {
238
+ const prev = this.listTarget.querySelector(`[data-index="${previous}"]`)
239
+ if(prev) {
240
+ prev.classList.remove('bg-primary-600', 'text-white')
241
+ }
242
+ const now = this.listTarget.querySelector(`[data-index="${current}"]`);
243
+ if(now) {
244
+ now.classList.add('bg-primary-600', 'text-white')
245
+ }
246
+ }
247
+
248
+ renderDropDownContent() {
249
+ this.listTarget.innerHTML = "";
250
+
251
+ const visibleOptions = this.visibleOptions()
252
+ const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !this.isOptionDisabled(option))
253
+
254
+ if (matchesFound) {
255
+ return this.renderOptions(visibleOptions.filter(option => !this.isOptionDisabled(option)))
256
+ }
257
+
258
+ if(this.isLoadingValue) {
259
+ this.listTarget.innerHTML = `<div>Is Loading..</div>`
260
+ return
261
+ }
262
+
263
+ if(this.freeChoiceValue) {
264
+ 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>`
265
+ return
266
+ }
267
+
268
+ this.listTarget.innerHTML = `<div class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
269
+ No matches found in the current dashboard. Try selecting a different time range or searching for something different.
270
+ </div>`
271
+
272
+ }
273
+ renderOptions(options) {
274
+ options.forEach((option, index) => {
275
+ const optionElement = document.createElement("li");
276
+ const isHighlighted = this.highlightedIndexValue === index;
277
+ optionElement.innerHTML = `<span class="block truncate" data-index="${index}">${option.text}</span>`;
278
+ optionElement.className = classNames('combobox-option relative cursor-pointer select-none py-2 px-3 hover:bg-primary-600 hover:text-white', {
279
+ 'text-accent-900': !isHighlighted,
280
+ 'bg-primary-600 text-white': isHighlighted,
281
+ });
282
+
283
+ if(isHighlighted) {
284
+ optionElement.dataset.comboboxTarget = "option"
285
+ }
286
+ optionElement.dataset.action = "click->combobox#selectOption"
287
+ optionElement.dataset.index = index;
288
+ optionElement.dataset.value = option.value
289
+ optionElement.id = `combobox-option-${index}`;
290
+
291
+ this.listTarget.appendChild(optionElement);
292
+ });
293
+ }
294
+ optionsValueChanged(current, before) {
295
+ this.renderDropDownContent()
296
+ }
297
+
298
+ isOptionDisabled(option) {
299
+ const disabled = this.selectedValue.some((val) => val.value === option.value)
300
+
301
+ return disabled
302
+ }
303
+
304
+ visibleOptions() {
305
+ const visibleOptions = [...this.optionsValue]
306
+ if (this.freeChoiceValue && this.inputTarget.length > 0 && this.optionsValue.every(option => option.value !== this.inputTarget.value)) {
307
+ visibleOptions.push({value: this.inputTarget.value, label: this.inputTarget.value, freeChoice: true})
308
+ }
309
+
310
+ return visibleOptions
311
+ }
312
+
313
+ selectedValueChanged(current, prev) {
314
+ this.renderSelectedValues()
315
+ this.renderDropDownContent()
316
+ }
317
+
318
+ removeOption(e) {
319
+ e.stopPropagation()
320
+ const option = this.selectTarget.querySelector(`option[value="${e.target.dataset.value}"]`);
321
+ option.remove()
322
+ const newValues = [];
323
+ this.selectTarget.querySelectorAll('option[selected]').forEach(option => {
324
+ newValues.push({text: option.text, value: option.value })
325
+ })
326
+ this.selectedValue = newValues;
327
+ this.isOpenValue = false
328
+ }
329
+
330
+ renderSelectedValues() {
331
+ this.selectTarget.innerHTML = ""
332
+ this.selectedTarget.innerHTML = ""
333
+ this.selectedValue.forEach(value => {
334
+ const option = document.createElement('option');
335
+ option.text = value.text;
336
+ option.value = value.value;
337
+ option.setAttribute('selected', 'selected')
338
+ this.selectTarget.appendChild(option)
339
+
340
+ const el = document.createElement("div");
341
+ 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');
342
+ 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>`;
343
+ this.selectedTarget.appendChild(el)
344
+ })
345
+ var event = new Event('change');
346
+ this.selectTarget.dispatchEvent(event);
347
+ if(this.selectedValue.length === 0) {
348
+ this.selectedTarget.style.display = "none"
349
+ } else {
350
+ this.selectedTarget.style.display = ""
351
+ }
352
+ }
353
+
354
+ freeChoiceValueChanged(current, prev) {
355
+ if(this.selectedValue.filter(value => value.freeChoice).length) {
356
+ console.log("free choice changed")
357
+ this.setSelected([])
358
+ }
359
+
360
+ }
361
+ disabledValueChanged(current) {
362
+ if(current) {
363
+ this.isOpenValue = false
364
+ this.inputTarget.disabled = true
365
+ this.checkDisabledState()
366
+ } else {
367
+ this.inputTarget.removeAttribute('disabled')
368
+ this.checkDisabledState()
369
+ }
370
+ }
371
+ }
@@ -0,0 +1,18 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ target: String,
6
+ };
7
+
8
+ connect() {
9
+ this.modal = document.querySelector('#detailsModal');
10
+ this.turboFrame = document.querySelector('#detailsModal turbo-frame');
11
+ }
12
+
13
+ openModal(e) {
14
+ e.preventDefault();
15
+ this.modal.showModal();
16
+ this.turboFrame.src = document.querySelector(this.targetValue).src;
17
+ }
18
+ }
@@ -0,0 +1,39 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ static targets = ['label', 'close'];
5
+
6
+ connect() {
7
+ // Listen for clicks on funnel links inside this dropdown
8
+ const funnelLinks = this.element.querySelectorAll('[data-funnel-link]');
9
+ funnelLinks.forEach(link => {
10
+ link.addEventListener('click', () => {
11
+ this.highlightDropdown();
12
+ });
13
+ });
14
+ }
15
+
16
+ setLabel(event) {
17
+ this.labelTarget.innerText = event.target.innerText;
18
+ this.closeTarget.classList.add('hidden');
19
+ }
20
+
21
+ removeHidden(event) {
22
+ this.closeTarget.classList.remove('hidden');
23
+ }
24
+
25
+ highlightDropdown() {
26
+ // Find the dropdown label and highlight it
27
+ const dropdownLabel = this.element.querySelector('[data-funnel-dropdown-label]');
28
+
29
+ // Remove active state from all turbo-frame links
30
+ const frame = dropdownLabel.dataset.turboFrame;
31
+ const otherLinks = document.querySelectorAll(`[data-turbo-frame="${frame}"]`);
32
+ otherLinks.forEach(link => {
33
+ link.classList.remove('text-primary', 'font-semibold');
34
+ });
35
+
36
+ // Add active state to dropdown label
37
+ dropdownLabel.classList.add('text-primary', 'font-semibold');
38
+ }
39
+ }
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="filter--item"
4
+ export default class extends Controller {
5
+ static values = {
6
+ modal: String
7
+ };
8
+
9
+ openModal() {
10
+ document.getElementById(this.modalValue).showModal()
11
+ }
12
+ }
@@ -0,0 +1,13 @@
1
+ import {Controller} from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ handleReset(event) {
5
+ event.preventDefault();
6
+ const openModal = document.querySelector('dialog.modal[open]');
7
+ openModal.querySelectorAll('input, select').forEach(element => {
8
+ element.value = ""
9
+ });
10
+ openModal.close()
11
+ this.element.requestSubmit()
12
+ }
13
+ }
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ static targets = ["link", "alt"]
5
+ static values = {
6
+ classes: { type: Array, default: ["text-primary", "font-semibold"] }
7
+ }
8
+
9
+ connect() {
10
+ this.element.addEventListener('click', () => {
11
+ const frame = this.element.dataset.turboFrame;
12
+ const otherLinks = document.querySelectorAll(`[data-turbo-frame="${frame}"]`);
13
+ otherLinks.forEach(link => {
14
+ link.classList.remove('text-primary', 'font-semibold');
15
+ })
16
+
17
+ this.element.classList.add("text-primary", "font-semibold")
18
+ })
19
+ }
20
+ }
@@ -0,0 +1,159 @@
1
+ import { Controller } from '@hotwired/stimulus';
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
+ }
15
+
16
+ export default class extends Controller {
17
+ connect() {
18
+ this.funnel = JSON.parse(this.element.dataset.data);
19
+ console.log('Funnel data:', this.funnel);
20
+
21
+ 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"';
22
+ const labels = this.funnel.steps.map((step) => step.name);
23
+ const stepData = this.funnel.steps.map((step) => step.total_events);
24
+
25
+ // Calculate conversion rates as percentages for each step
26
+ const conversionRates = this.funnel.steps.map((step, index) => {
27
+ if (index === 0 || !step.drop_off) return 100;
28
+ return (step.drop_off * 100).toFixed(1);
29
+ });
30
+
31
+ const data = {
32
+ labels,
33
+ datasets: [
34
+ {
35
+ label: 'Events',
36
+ data: stepData,
37
+ borderRadius: 4,
38
+ backgroundColor: getCSS('--p'),
39
+ borderColor: getCSS('--p'),
40
+ borderWidth: 2,
41
+ barPercentage: 0.8,
42
+ categoryPercentage: 0.9,
43
+ maxBarThickness: 120,
44
+ },
45
+ ],
46
+ };
47
+
48
+ const config = {
49
+ responsive: true,
50
+ maintainAspectRatio: false,
51
+ plugins: [ChartDataLabels],
52
+ type: 'bar',
53
+ data,
54
+ options: {
55
+ layout: {
56
+ padding: { top: 50, bottom: 20, left: 20, right: 80 },
57
+ },
58
+ plugins: {
59
+ legend: false,
60
+ tooltip: {
61
+ enabled: false,
62
+ position: 'nearest',
63
+ external: externalTooltipHandler(this)
64
+ },
65
+ datalabels: {
66
+ anchor: 'center',
67
+ align: 'center',
68
+ borderRadius: 4,
69
+ padding: { top: 6, bottom: 6, right: 8, left: 8 },
70
+ color: getCSS('--bc'),
71
+ font: {
72
+ weight: 'bold',
73
+ size: 14
74
+ },
75
+ clip: false,
76
+ clamp: false,
77
+ formatter: (value, context) => {
78
+ const index = context.dataIndex;
79
+ const rate = conversionRates[index];
80
+ if (index === 0) {
81
+ return value.toLocaleString();
82
+ }
83
+ return `${value.toLocaleString()}\n(${rate}%)`;
84
+ },
85
+ textAlign: 'center',
86
+ },
87
+ },
88
+ scales: {
89
+ y: {
90
+ display: true,
91
+ border: { display: false },
92
+ grid: {
93
+ drawBorder: false,
94
+ display: true,
95
+ color: 'rgba(255, 255, 255, 0.1)'
96
+ },
97
+ ticks: {
98
+ color: getCSS('--bc'),
99
+ callback: (value) => value.toLocaleString()
100
+ }
101
+ },
102
+ x: {
103
+ position: 'bottom',
104
+ display: true,
105
+ border: { display: false },
106
+ grid: { drawBorder: false, display: false },
107
+ ticks: {
108
+ padding: 8,
109
+ color: getCSS('--bc'),
110
+ font: {
111
+ size: 13,
112
+ weight: '500'
113
+ }
114
+ },
115
+ },
116
+ },
117
+ },
118
+ };
119
+
120
+ const visitorsData = [];
121
+
122
+ this.chart = new Chart(
123
+ this.element,
124
+ config,
125
+ );
126
+ }
127
+
128
+ formatLabel(label) {
129
+ return label
130
+ }
131
+
132
+ formatMetric(metric) {
133
+ return metric
134
+ }
135
+
136
+
137
+ extractTooltipData(tooltip) {
138
+ const stepIndex = this.funnel.steps.findIndex(step => step.name === tooltip.title[0]);
139
+ const data = this.funnel.steps[stepIndex];
140
+
141
+ const value = data.total_events;
142
+ const uniqueVisits = data.unique_visits;
143
+ const conversionRate = stepIndex > 0 && data.drop_off ?
144
+ `${(data.drop_off * 100).toFixed(1)}% of previous` :
145
+ '100%';
146
+
147
+ return {
148
+ comparison: true,
149
+ comparisonDifference: false,
150
+ metric: tooltip.title[0],
151
+ label: "Total Events",
152
+ labelBackgroundColor: getCSS('--p'),
153
+ formattedValue: value.toLocaleString(),
154
+ comparisonLabel: "Unique Visits",
155
+ comparisonLabelBackgroundColor: getCSS('--s'),
156
+ formattedComparisonValue: `${uniqueVisits.toLocaleString()} (${conversionRate})`,
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,4 @@
1
+ import { application } from 'controllers/application';
2
+ import { eagerLoadControllersFrom } from '@hotwired/stimulus-loading';
3
+
4
+ eagerLoadControllersFrom('controllers', application);