ahoy_captain 0.91 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -4
  3. data/Rakefile +23 -2
  4. data/app/assets/javascript/ahoy_captain/controllers/application_controller.js +20 -0
  5. data/app/assets/javascript/ahoy_captain/controllers/combobox_controller.js +371 -0
  6. data/app/assets/javascript/ahoy_captain/controllers/filter_modal_controller.js +45 -0
  7. data/app/assets/javascript/ahoy_captain/controllers/frame_link_controller.js +20 -0
  8. data/app/assets/javascript/ahoy_captain/controllers/funnel_chart_controller.js +58 -16
  9. data/app/assets/javascript/ahoy_captain/controllers/interval_controller.js +5 -0
  10. data/app/assets/javascript/ahoy_captain/controllers/line_chart_controller.js +235 -21
  11. data/app/assets/javascript/ahoy_captain/controllers/map_controller.js +47 -0
  12. data/app/assets/javascript/ahoy_captain/controllers/predicate_select_controller.js +1 -1
  13. data/app/assets/javascript/ahoy_captain/controllers/properties_controller.js +8 -0
  14. data/app/assets/javascript/ahoy_captain/controllers/property_filter_controller.js +45 -0
  15. data/app/assets/javascript/ahoy_captain/controllers/realtime_controller.js +4 -2
  16. data/app/assets/javascript/ahoy_captain/controllers/tile_controller.js +33 -0
  17. data/app/assets/javascript/ahoy_captain/helpers/chart_utils.js +156 -0
  18. data/app/assets/javascript/ahoy_captain/helpers/countries.js +2261 -0
  19. data/app/assets/javascript/ahoy_captain/helpers/number_formatters.js +55 -0
  20. data/app/components/ahoy_captain/combobox_component.html.erb +33 -0
  21. data/app/components/ahoy_captain/combobox_component.rb +13 -0
  22. data/app/components/ahoy_captain/comparison_link_component.html.erb +17 -0
  23. data/app/components/ahoy_captain/comparison_link_component.rb +44 -0
  24. data/app/components/ahoy_captain/dropdown_link_component.html.erb +2 -4
  25. data/app/components/ahoy_captain/dropdown_link_component.rb +4 -0
  26. data/app/components/ahoy_captain/filter/dropdown_component.html.erb +8 -6
  27. data/app/components/ahoy_captain/filter/modal_component.html.erb +7 -5
  28. data/app/components/ahoy_captain/filter/select_component.html.erb +23 -21
  29. data/app/components/ahoy_captain/filter/select_component.rb +2 -1
  30. data/app/components/ahoy_captain/filter/tag_component.html.erb +1 -1
  31. data/app/components/ahoy_captain/previous_next_component.html.erb +8 -0
  32. data/app/components/ahoy_captain/previous_next_component.rb +11 -0
  33. data/app/components/ahoy_captain/stats/comparable_container_component.html.erb +25 -0
  34. data/app/components/ahoy_captain/stats/comparable_container_component.rb +86 -0
  35. data/app/components/ahoy_captain/stats/container_component.html.erb +13 -6
  36. data/app/components/ahoy_captain/stats/container_component.rb +15 -1
  37. data/app/components/ahoy_captain/sticky_nav_component.html.erb +27 -20
  38. data/app/components/ahoy_captain/sticky_nav_component.rb +11 -0
  39. data/app/components/ahoy_captain/table_component.rb +13 -4
  40. data/app/components/ahoy_captain/tables/devices_table_component.rb +11 -0
  41. data/app/components/ahoy_captain/tables/dynamic_table.rb +13 -0
  42. data/app/components/ahoy_captain/tables/dynamic_table_component.rb +204 -0
  43. data/app/components/ahoy_captain/tables/goals_table_component.rb +17 -0
  44. data/app/components/ahoy_captain/tables/header_component.html.erb +5 -0
  45. data/app/components/ahoy_captain/tables/header_component.rb +18 -0
  46. data/app/components/ahoy_captain/tables/headers/header_component.rb +4 -0
  47. data/app/components/ahoy_captain/tables/properties_table_component.rb +27 -0
  48. data/app/components/ahoy_captain/tables/row_component.html.erb +4 -0
  49. data/app/components/ahoy_captain/tables/rows/row_component.rb +0 -1
  50. data/app/components/ahoy_captain/tile_component.html.erb +19 -9
  51. data/app/components/ahoy_captain/tile_component.rb +9 -1
  52. data/app/controllers/ahoy_captain/application_controller.rb +7 -16
  53. data/app/controllers/ahoy_captain/exports_controller.rb +1 -2
  54. data/app/controllers/ahoy_captain/filters/base_controller.rb +1 -3
  55. data/app/controllers/ahoy_captain/filters/goals_controller.rb +9 -0
  56. data/app/controllers/ahoy_captain/filters/pages/actions_controller.rb +1 -1
  57. data/app/controllers/ahoy_captain/filters/pages/entry_pages_controller.rb +1 -1
  58. data/app/controllers/ahoy_captain/filters/pages/exit_pages_controller.rb +1 -1
  59. data/app/controllers/ahoy_captain/filters/properties/values_controller.rb +4 -4
  60. data/app/controllers/ahoy_captain/filters/utms_controller.rb +1 -1
  61. data/app/controllers/ahoy_captain/locations/cities_controller.rb +22 -0
  62. data/app/controllers/ahoy_captain/locations/countries_controller.rb +22 -0
  63. data/app/controllers/ahoy_captain/locations/maps_controller.rb +24 -0
  64. data/app/controllers/ahoy_captain/locations/regions_controller.rb +22 -0
  65. data/app/controllers/ahoy_captain/properties_controller.rb +41 -0
  66. data/app/controllers/ahoy_captain/stats/base_controller.rb +86 -5
  67. data/app/controllers/ahoy_captain/stats/bounce_rates_controller.rb +1 -1
  68. data/app/controllers/ahoy_captain/stats/total_pageviews_controller.rb +1 -1
  69. data/app/controllers/ahoy_captain/stats/total_visits_controller.rb +1 -1
  70. data/app/controllers/ahoy_captain/stats/unique_visitors_controller.rb +2 -1
  71. data/app/controllers/ahoy_captain/stats/views_per_visits_controller.rb +1 -10
  72. data/app/controllers/ahoy_captain/stats/visit_durations_controller.rb +1 -1
  73. data/app/helpers/ahoy_captain/application_helper.rb +33 -9
  74. data/app/models/ahoy_captain/comparison_mode.rb +72 -0
  75. data/app/models/ahoy_captain/filter_parser.rb +33 -18
  76. data/app/models/ahoy_captain/range_from_params.rb +78 -0
  77. data/app/models/ahoy_captain/rangeable.rb +0 -3
  78. data/app/models/concerns/ahoy_captain/compare_mode.rb +19 -0
  79. data/app/models/concerns/ahoy_captain/limitable.rb +17 -0
  80. data/app/models/concerns/ahoy_captain/range_options.rb +1 -14
  81. data/app/presenters/ahoy_captain/dashboard_presenter.rb +18 -54
  82. data/app/queries/ahoy_captain/application_query.rb +74 -10
  83. data/app/queries/ahoy_captain/event_query.rb +7 -2
  84. data/app/queries/ahoy_captain/stats/average_views_per_visit_query.rb +11 -4
  85. data/app/queries/ahoy_captain/stats/average_visit_duration_query.rb +14 -2
  86. data/app/queries/ahoy_captain/stats/base_query.rb +18 -0
  87. data/app/queries/ahoy_captain/stats/bounce_rates_query.rb +15 -1
  88. data/app/queries/ahoy_captain/stats/total_pageviews_query.rb +2 -2
  89. data/app/queries/ahoy_captain/stats/total_visitors_query.rb +1 -1
  90. data/app/queries/ahoy_captain/stats/unique_visitors_query.rb +1 -1
  91. data/app/queries/ahoy_captain/stats/views_per_visit_query.rb +1 -1
  92. data/app/queries/ahoy_captain/stats/visit_duration_query.rb +2 -2
  93. data/app/queries/ahoy_captain/visit_query.rb +1 -2
  94. data/app/queries/concerns/ahoy_captain/comparable_queries.rb +25 -0
  95. data/app/queries/concerns/ahoy_captain/comparable_query.rb +138 -0
  96. data/app/queries/concerns/ahoy_captain/lazy_comparable_query.rb +42 -0
  97. data/app/views/ahoy_captain/devices/_table.html.erb +1 -4
  98. data/app/views/ahoy_captain/goals/index.html.erb +1 -4
  99. data/app/views/ahoy_captain/layouts/application.html.erb +0 -1
  100. data/app/views/ahoy_captain/layouts/shared/_tile_loader.html.erb +12 -0
  101. data/app/views/ahoy_captain/locations/maps/show.html.erb +3 -0
  102. data/app/views/ahoy_captain/properties/_form.html.erb +6 -0
  103. data/app/views/ahoy_captain/properties/index.html.erb +3 -0
  104. data/app/views/ahoy_captain/properties/show.html.erb +6 -0
  105. data/app/views/ahoy_captain/roots/_filters.html.erb +47 -1
  106. data/app/views/ahoy_captain/roots/show.html.erb +109 -48
  107. data/app/views/ahoy_captain/stats/base/index.html.erb +37 -8
  108. data/app/views/ahoy_captain/stats/show.html.erb +14 -11
  109. data/config/routes.rb +9 -3
  110. data/lib/ahoy_captain/ahoy/event_methods.rb +12 -14
  111. data/lib/ahoy_captain/configuration.rb +2 -1
  112. data/lib/ahoy_captain/engine.rb +4 -0
  113. data/lib/ahoy_captain/filters_configuration.rb +10 -6
  114. data/lib/ahoy_captain/version.rb +1 -1
  115. data/lib/ahoy_captain.rb +7 -1
  116. data/lib/generators/ahoy_captain/templates/config.rb.tt +7 -0
  117. metadata +137 -22
  118. data/app/assets/javascript/ahoy_captain/controllers/active_links_controller.js +0 -18
  119. data/app/assets/javascript/ahoy_captain/controllers/filter_tag_controller.js +0 -20
  120. data/app/assets/javascript/ahoy_captain/controllers/search_select_controller.js +0 -65
  121. data/app/components/ahoy_captain/tables/headers/devices_header_component.html.erb +0 -3
  122. data/app/components/ahoy_captain/tables/headers/devices_header_component.rb +0 -9
  123. data/app/components/ahoy_captain/tables/headers/goals_header_component.html.erb +0 -6
  124. data/app/components/ahoy_captain/tables/headers/goals_header_component.rb +0 -9
  125. data/app/components/ahoy_captain/tables/rows/devices_row_component.html.erb +0 -5
  126. data/app/components/ahoy_captain/tables/rows/devices_row_component.rb +0 -12
  127. data/app/components/ahoy_captain/tables/rows/goals_row_component.html.erb +0 -11
  128. data/app/components/ahoy_captain/tables/rows/goals_row_component.rb +0 -20
  129. data/app/controllers/ahoy_captain/cities_controller.rb +0 -20
  130. data/app/controllers/ahoy_captain/countries_controller.rb +0 -20
  131. data/app/controllers/ahoy_captain/regions_controller.rb +0 -20
  132. /data/app/views/ahoy_captain/{cities → locations/cities}/index.html+details.erb +0 -0
  133. /data/app/views/ahoy_captain/{cities → locations/cities}/index.html.erb +0 -0
  134. /data/app/views/ahoy_captain/{countries → locations/countries}/index.html+details.erb +0 -0
  135. /data/app/views/ahoy_captain/{countries → locations/countries}/index.html.erb +0 -0
  136. /data/app/views/ahoy_captain/{regions → locations/regions}/index.html+details.erb +0 -0
  137. /data/app/views/ahoy_captain/{regions → locations/regions}/index.html.erb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdc2d607961f89d3b62c551c8aa157c962ab05e0fe7a61179c6c1c9de1c830d6
4
- data.tar.gz: feef80dd54cb92203aab1468fa80c134e46e8e04f5bf2c6684a83d856ab05ec4
3
+ metadata.gz: 5f41cbc112d82c58bb73215a380b4f0c0ea902f40b68264a52eae971310ec9f1
4
+ data.tar.gz: 335fe3f8ba99b5eb9c6fabaf12a1d03a3690a07d699f9202b0dcac1b5ceef97e
5
5
  SHA512:
6
- metadata.gz: 43b863dd93ad7a3139c2c73564b1edde7f1d4bbba9540466852cb5cce0f930c0eef8f9505eb513e98c1800a6a0c3f2f8563d20568b17a5b0c65c4e61ad52e433
7
- data.tar.gz: 6078f2d0937c955073c150c10979ea9099be46ae2d2382f317369f429b5237d4d381303d28e1aa0e6a3b917e1010b170d5dfcf1bf331383889e6f7d5e699f260
6
+ metadata.gz: 290a874e2afdf97396591d28da4e94ae4bd463bc6966123549271bacd37a76c67024257085b0c6ac06381a6dadec0041c102cf6897c950ea2a9e96d0d08e57e8
7
+ data.tar.gz: f7de6c4e8a0872a2434c51af6c9e55ea5b1dcd61a892c9ca002ee1f6b6ec34753b1d573c102d8e0c7a7e928dafceebb0955d6cf27e04a7588d043e9085ee4efd
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
 
data/Rakefile CHANGED
@@ -1,3 +1,24 @@
1
- require "bundler/setup"
1
+ # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
3
+ begin
4
+ require 'bundler/setup'
5
+ rescue LoadError
6
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7
+ end
8
+
9
+ require 'rdoc/task'
10
+
11
+ RDoc::Task.new(:rdoc) do |rdoc|
12
+ rdoc.rdoc_dir = 'rdoc'
13
+ rdoc.title = 'Caffeinate'
14
+ rdoc.options << '--line-numbers'
15
+ rdoc.rdoc_files.include('README.md')
16
+ rdoc.rdoc_files.include('lib/**/*.rb')
17
+ end
18
+
19
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
20
+ load 'rails/tasks/engine.rake'
21
+
22
+ load 'rails/tasks/statistics.rake'
23
+
24
+ require 'bundler/gem_tasks'
@@ -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(() => {
@@ -9,5 +10,24 @@ export default class extends Controller {
9
10
  }, 1000 * 30);
10
11
  });
11
12
  }
13
+
14
+ document.querySelectorAll('a[data-turbo-frame]').forEach(link => {
15
+ const frameSelector = link.dataset.turboFrame;
16
+ const frame = document.querySelector(`turbo-frame#${frameSelector}`);
17
+ if(frame) {
18
+ const src = frame.src;
19
+ if(link.href.includes(src)) {
20
+ link.classList.add('text-primary', 'font-semibold')
21
+ }
22
+ }
23
+
24
+ })
25
+ }
26
+
27
+ comboboxInit(event) {
28
+ if(event.detail.combobox.selectTarget.id === "property-name" || event.detail.combobox.selectTarget.id === "property-value") {
29
+ window.comboboxConnected += 1;
30
+ }
12
31
  }
32
+
13
33
  }
@@ -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,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
+ }
@@ -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
  }