ahoy_captain 0.83 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ }