ahoy_captain 0.83 → 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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -13
  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/item_controller.js +12 -0
  7. data/app/assets/javascript/ahoy_captain/controllers/filter_modal_controller.js +45 -0
  8. data/app/assets/javascript/ahoy_captain/controllers/frame_link_controller.js +20 -0
  9. data/app/assets/javascript/ahoy_captain/controllers/funnel_chart_controller.js +58 -16
  10. data/app/assets/javascript/ahoy_captain/controllers/interval_controller.js +5 -0
  11. data/app/assets/javascript/ahoy_captain/controllers/line_chart_controller.js +236 -22
  12. data/app/assets/javascript/ahoy_captain/controllers/map_controller.js +47 -0
  13. data/app/assets/javascript/ahoy_captain/controllers/predicate_select_controller.js +1 -1
  14. data/app/assets/javascript/ahoy_captain/controllers/properties_controller.js +8 -0
  15. data/app/assets/javascript/ahoy_captain/controllers/property_filter_controller.js +45 -0
  16. data/app/assets/javascript/ahoy_captain/controllers/realtime_controller.js +4 -2
  17. data/app/assets/javascript/ahoy_captain/controllers/tile_controller.js +33 -0
  18. data/app/assets/javascript/ahoy_captain/controllers/toggle_controller.js +17 -0
  19. data/app/assets/javascript/ahoy_captain/helpers/chart_utils.js +156 -0
  20. data/app/assets/javascript/ahoy_captain/helpers/countries.js +2261 -0
  21. data/app/assets/javascript/ahoy_captain/helpers/number_formatters.js +55 -0
  22. data/app/components/ahoy_captain/combobox_component.html.erb +33 -0
  23. data/app/components/ahoy_captain/combobox_component.rb +13 -0
  24. data/app/components/ahoy_captain/comparison_link_component.html.erb +17 -0
  25. data/app/components/ahoy_captain/comparison_link_component.rb +44 -0
  26. data/app/components/ahoy_captain/dropdown_button_component.html.erb +5 -5
  27. data/app/components/ahoy_captain/dropdown_link_component.html.erb +5 -7
  28. data/app/components/ahoy_captain/dropdown_link_component.rb +4 -0
  29. data/app/components/ahoy_captain/filter/dropdown_component.html.erb +50 -0
  30. data/app/components/ahoy_captain/filter/dropdown_component.rb +51 -0
  31. data/app/components/ahoy_captain/filter/modal_component.html.erb +7 -5
  32. data/app/components/ahoy_captain/filter/select_component.html.erb +23 -21
  33. data/app/components/ahoy_captain/filter/select_component.rb +24 -9
  34. data/app/components/ahoy_captain/filter/tag_component.html.erb +8 -4
  35. data/app/components/ahoy_captain/filter/tag_component.rb +6 -30
  36. data/app/components/ahoy_captain/filter/tag_container_component.html.erb +2 -3
  37. data/app/components/ahoy_captain/filter/tag_container_component.rb +1 -8
  38. data/app/components/ahoy_captain/previous_next_component.html.erb +8 -0
  39. data/app/components/ahoy_captain/previous_next_component.rb +11 -0
  40. data/app/components/ahoy_captain/stats/comparable_container_component.html.erb +25 -0
  41. data/app/components/ahoy_captain/stats/comparable_container_component.rb +86 -0
  42. data/app/components/ahoy_captain/stats/container_component.html.erb +15 -0
  43. data/app/components/ahoy_captain/stats/container_component.rb +26 -0
  44. data/app/components/ahoy_captain/sticky_nav_component.html.erb +28 -33
  45. data/app/components/ahoy_captain/sticky_nav_component.rb +19 -0
  46. data/app/components/ahoy_captain/table_component.html.erb +2 -2
  47. data/app/components/ahoy_captain/table_component.rb +15 -3
  48. data/app/components/ahoy_captain/tables/devices_table_component.rb +11 -0
  49. data/app/components/ahoy_captain/tables/dynamic_table.rb +13 -0
  50. data/app/components/ahoy_captain/tables/dynamic_table_component.rb +204 -0
  51. data/app/components/ahoy_captain/tables/goals_table_component.rb +17 -0
  52. data/app/components/ahoy_captain/tables/header_component.html.erb +5 -0
  53. data/app/components/ahoy_captain/tables/header_component.rb +18 -0
  54. data/app/components/ahoy_captain/tables/headers/header_component.html.erb +1 -1
  55. data/app/components/ahoy_captain/tables/headers/header_component.rb +4 -0
  56. data/app/components/ahoy_captain/tables/properties_table_component.rb +27 -0
  57. data/app/components/ahoy_captain/tables/row_component.html.erb +4 -0
  58. data/app/components/ahoy_captain/tables/rows/row_component.rb +2 -3
  59. data/app/components/ahoy_captain/tile_component.html.erb +21 -10
  60. data/app/components/ahoy_captain/tile_component.rb +10 -2
  61. data/app/components/ahoy_captain/tooltip_component.html.erb +2 -2
  62. data/app/controllers/ahoy_captain/application_controller.rb +7 -16
  63. data/app/controllers/ahoy_captain/exports_controller.rb +1 -2
  64. data/app/controllers/ahoy_captain/filters/base_controller.rb +1 -3
  65. data/app/controllers/ahoy_captain/filters/goals_controller.rb +9 -0
  66. data/app/controllers/ahoy_captain/filters/pages/actions_controller.rb +1 -1
  67. data/app/controllers/ahoy_captain/filters/pages/entry_pages_controller.rb +1 -1
  68. data/app/controllers/ahoy_captain/filters/pages/exit_pages_controller.rb +1 -1
  69. data/app/controllers/ahoy_captain/filters/properties/values_controller.rb +4 -4
  70. data/app/controllers/ahoy_captain/filters/sources_controller.rb +1 -1
  71. data/app/controllers/ahoy_captain/filters/utms_controller.rb +1 -1
  72. data/app/controllers/ahoy_captain/locations/cities_controller.rb +22 -0
  73. data/app/controllers/ahoy_captain/locations/countries_controller.rb +22 -0
  74. data/app/controllers/ahoy_captain/locations/maps_controller.rb +24 -0
  75. data/app/controllers/ahoy_captain/locations/regions_controller.rb +22 -0
  76. data/app/controllers/ahoy_captain/properties_controller.rb +41 -0
  77. data/app/controllers/ahoy_captain/stats/base_controller.rb +86 -5
  78. data/app/controllers/ahoy_captain/stats/bounce_rates_controller.rb +1 -1
  79. data/app/controllers/ahoy_captain/stats/total_pageviews_controller.rb +1 -1
  80. data/app/controllers/ahoy_captain/stats/total_visits_controller.rb +1 -1
  81. data/app/controllers/ahoy_captain/stats/unique_visitors_controller.rb +2 -1
  82. data/app/controllers/ahoy_captain/stats/views_per_visits_controller.rb +1 -10
  83. data/app/controllers/ahoy_captain/stats/visit_durations_controller.rb +1 -1
  84. data/app/helpers/ahoy_captain/application_helper.rb +60 -3
  85. data/app/models/ahoy_captain/comparison_mode.rb +72 -0
  86. data/app/models/ahoy_captain/filter_parser.rb +82 -0
  87. data/app/models/ahoy_captain/range_from_params.rb +78 -0
  88. data/app/models/ahoy_captain/rangeable.rb +0 -3
  89. data/app/models/concerns/ahoy_captain/compare_mode.rb +19 -0
  90. data/app/models/concerns/ahoy_captain/limitable.rb +17 -0
  91. data/app/models/concerns/ahoy_captain/range_options.rb +1 -14
  92. data/app/presenters/ahoy_captain/dashboard_presenter.rb +18 -54
  93. data/app/presenters/ahoy_captain/goals_presenter.rb +3 -2
  94. data/app/queries/ahoy_captain/application_query.rb +74 -10
  95. data/app/queries/ahoy_captain/event_query.rb +7 -2
  96. data/app/queries/ahoy_captain/stats/average_views_per_visit_query.rb +11 -4
  97. data/app/queries/ahoy_captain/stats/average_visit_duration_query.rb +14 -2
  98. data/app/queries/ahoy_captain/stats/base_query.rb +18 -0
  99. data/app/queries/ahoy_captain/stats/bounce_rates_query.rb +15 -1
  100. data/app/queries/ahoy_captain/stats/total_pageviews_query.rb +2 -2
  101. data/app/queries/ahoy_captain/stats/total_visitors_query.rb +1 -1
  102. data/app/queries/ahoy_captain/stats/unique_visitors_query.rb +1 -1
  103. data/app/queries/ahoy_captain/stats/views_per_visit_query.rb +1 -1
  104. data/app/queries/ahoy_captain/stats/visit_duration_query.rb +3 -3
  105. data/app/queries/ahoy_captain/visit_query.rb +1 -2
  106. data/app/queries/concerns/ahoy_captain/comparable_queries.rb +25 -0
  107. data/app/queries/concerns/ahoy_captain/comparable_query.rb +138 -0
  108. data/app/queries/concerns/ahoy_captain/lazy_comparable_query.rb +42 -0
  109. data/app/views/ahoy_captain/devices/_table.html.erb +1 -4
  110. data/app/views/ahoy_captain/funnels/show.html.erb +5 -2
  111. data/app/views/ahoy_captain/goals/index.html.erb +1 -4
  112. data/app/views/ahoy_captain/layouts/application.html.erb +3 -4
  113. data/app/views/ahoy_captain/layouts/shared/_tile_loader.html.erb +12 -0
  114. data/app/views/ahoy_captain/locations/maps/show.html.erb +3 -0
  115. data/app/views/ahoy_captain/properties/_form.html.erb +6 -0
  116. data/app/views/ahoy_captain/properties/index.html.erb +3 -0
  117. data/app/views/ahoy_captain/properties/show.html.erb +6 -0
  118. data/app/views/ahoy_captain/realtimes/show.html.erb +1 -1
  119. data/app/views/ahoy_captain/roots/_filters.html.erb +80 -0
  120. data/app/views/ahoy_captain/roots/show.html.erb +113 -122
  121. data/app/views/ahoy_captain/stats/base/index.html.erb +37 -8
  122. data/app/views/ahoy_captain/stats/show.html.erb +14 -56
  123. data/config/routes.rb +9 -3
  124. data/lib/ahoy_captain/ahoy/event_methods.rb +21 -14
  125. data/lib/ahoy_captain/ahoy/visit_methods.rb +1 -1
  126. data/lib/ahoy_captain/configuration.rb +18 -7
  127. data/lib/ahoy_captain/engine.rb +21 -0
  128. data/lib/ahoy_captain/filter_configuration/filter.rb +16 -0
  129. data/lib/ahoy_captain/filter_configuration/filter_collection.rb +48 -0
  130. data/lib/ahoy_captain/filters_configuration.rb +77 -0
  131. data/lib/ahoy_captain/goals.rb +1 -1
  132. data/lib/ahoy_captain/predicate_label.rb +7 -0
  133. data/lib/ahoy_captain/version.rb +1 -1
  134. data/lib/ahoy_captain.rb +8 -1
  135. data/lib/generators/ahoy_captain/templates/config.rb.tt +32 -0
  136. metadata +149 -22
  137. data/app/assets/javascript/ahoy_captain/controllers/active_links_controller.js +0 -15
  138. data/app/assets/javascript/ahoy_captain/controllers/filter_tag_controller.js +0 -20
  139. data/app/assets/javascript/ahoy_captain/controllers/search_select_controller.js +0 -65
  140. data/app/components/ahoy_captain/tables/headers/devices_header_component.html.erb +0 -3
  141. data/app/components/ahoy_captain/tables/headers/devices_header_component.rb +0 -9
  142. data/app/components/ahoy_captain/tables/headers/goals_header_component.html.erb +0 -6
  143. data/app/components/ahoy_captain/tables/headers/goals_header_component.rb +0 -9
  144. data/app/components/ahoy_captain/tables/rows/devices_row_component.html.erb +0 -5
  145. data/app/components/ahoy_captain/tables/rows/devices_row_component.rb +0 -12
  146. data/app/components/ahoy_captain/tables/rows/goals_row_component.html.erb +0 -11
  147. data/app/components/ahoy_captain/tables/rows/goals_row_component.rb +0 -12
  148. data/app/controllers/ahoy_captain/cities_controller.rb +0 -20
  149. data/app/controllers/ahoy_captain/countries_controller.rb +0 -20
  150. data/app/controllers/ahoy_captain/regions_controller.rb +0 -20
  151. /data/app/views/ahoy_captain/{cities → locations/cities}/index.html+details.erb +0 -0
  152. /data/app/views/ahoy_captain/{cities → locations/cities}/index.html.erb +0 -0
  153. /data/app/views/ahoy_captain/{countries → locations/countries}/index.html+details.erb +0 -0
  154. /data/app/views/ahoy_captain/{countries → locations/countries}/index.html.erb +0 -0
  155. /data/app/views/ahoy_captain/{regions → locations/regions}/index.html+details.erb +0 -0
  156. /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: dc0360816004f1a5f8cf41e01f4a55f30b0ff40db97084122678c1b179adf930
4
- data.tar.gz: a05b89988c2220ca1077bc7eecc80ad1454f777f3eff8621d91b323c52ce6ad7
3
+ metadata.gz: 5f41cbc112d82c58bb73215a380b4f0c0ea902f40b68264a52eae971310ec9f1
4
+ data.tar.gz: 335fe3f8ba99b5eb9c6fabaf12a1d03a3690a07d699f9202b0dcac1b5ceef97e
5
5
  SHA512:
6
- metadata.gz: 1e9806cda9cb8366c5639db6d87fe3983cdd327cff484af95782e8c68134fb9c0f3ed55f8b4b7d269ce41b125bbb5da2faf2f4cf2fc1f190faa5c77bf486e2cc
7
- data.tar.gz: 3ac41065fd0f767d951785c9a46373318ace8d2c4099390d33ae17f2d39701843a4d8c478dbb8232880d8c9b2cc89bf83a0a7ec7ec6065a52281db41d33e9b70
6
+ metadata.gz: 290a874e2afdf97396591d28da4e94ae4bd463bc6966123549271bacd37a76c67024257085b0c6ac06381a6dadec0041c102cf6897c950ea2a9e96d0d08e57e8
7
+ data.tar.gz: f7de6c4e8a0872a2434c51af6c9e55ea5b1dcd61a892c9ca002ee1f6b6ec34753b1d573c102d8e0c7a7e928dafceebb0955d6cf27e04a7588d043e9085ee4efd
data/README.md CHANGED
@@ -1,17 +1,12 @@
1
- # AhoyCaptain
1
+ # <img src="logo.png" style="max-height:100px" /> AhoyCaptain
2
2
 
3
- <img src="logo.png" style="max-width:100px" />
4
3
 
5
4
  A full-featured, mountable analytics dashboard for your Rails app, shamelessly inspired by Plausible Analytics, powered by the Ahoy gem.
6
5
 
7
6
  <a href="https://github.com/joshmn/ahoy_captain/blob/main/ss.jpg"><img src="ss.jpg" style="max-width:300px" /></a>
8
7
  ## Notice
9
8
 
10
- While this is fine to use in production, it was only built against a PostgreSQL instance. Some of the queries are certainly broken.
11
-
12
- ## Some assumptions
13
-
14
- Some hardcoded stuff as of writing; this will be more fully-featured in due time.
9
+ Currently requires using PG and a JSONB column for your data.
15
10
 
16
11
  ## Installation
17
12
 
@@ -29,11 +24,26 @@ $ bundle add ahoy_captain
29
24
  $ rails g ahoy_captain:install
30
25
  ```
31
26
 
32
- ### 3. Star this repo
27
+ ### 3. Make sure your events are setup correctly
28
+
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
32
+
33
+ For a quick sanity check:
34
+
35
+ ```ruby
36
+ AhoyCaptain.event.where(name: AhoyCaptain.config.event[:view_name]).count
37
+ AhoyCaptain.event.with_routes.count
38
+ ```
39
+
40
+ This can be fully-customized. See the initializer `config/initializers/ahoy_captain.rb` for more.
41
+
42
+ ### 4. Star this repo
33
43
 
34
44
  No, seriously, I need all the internet clout I can get.
35
45
 
36
- ### 4. Analyze your nightmares
46
+ ### 5. Analyze your nightmares
37
47
 
38
48
  If you have a large dataset (> 1GB) you probably want some indexes. `rails g ahoy_captain:migration`
39
49
 
@@ -52,12 +62,14 @@ If you have a large dataset (> 1GB) you probably want some indexes. `rails g aho
52
62
  * Device type
53
63
  * OS
54
64
  * UTM tags
65
+ * Goal
66
+ * Event Property
67
+ * CSV exports
68
+ * Date comparison
55
69
 
56
70
  ## Coming soon ™️
57
71
 
58
- * Date comparison
59
- * More filters
60
- * CSV exports
72
+ * Bug fixes and performance improvements
61
73
 
62
74
  ## Contributors
63
75
 
@@ -65,7 +77,7 @@ This was built during the Rails Hackathon in July 2023 with [afogel](https://git
65
77
 
66
78
  ## Contributions
67
79
 
68
- Please and thank you in advance!
80
+ Do your worst; please and thank you in advance! :)
69
81
 
70
82
  ## License
71
83
 
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,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,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
+ }