ahoy_captain 0.91 → 1.0.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 (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
  }