rails_pulse 0.1.4 → 0.2.2

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +78 -0
  3. data/Rakefile +152 -3
  4. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  5. data/app/assets/stylesheets/rails_pulse/components/datepicker.css +191 -0
  6. data/app/assets/stylesheets/rails_pulse/components/switch.css +36 -0
  7. data/app/assets/stylesheets/rails_pulse/components/tags.css +98 -0
  8. data/app/assets/stylesheets/rails_pulse/components/utilities.css +26 -0
  9. data/app/controllers/concerns/response_range_concern.rb +15 -2
  10. data/app/controllers/concerns/tag_filter_concern.rb +26 -0
  11. data/app/controllers/concerns/time_range_concern.rb +27 -8
  12. data/app/controllers/rails_pulse/application_controller.rb +73 -0
  13. data/app/controllers/rails_pulse/queries_controller.rb +4 -1
  14. data/app/controllers/rails_pulse/requests_controller.rb +40 -8
  15. data/app/controllers/rails_pulse/routes_controller.rb +4 -2
  16. data/app/controllers/rails_pulse/tags_controller.rb +51 -0
  17. data/app/helpers/rails_pulse/application_helper.rb +2 -0
  18. data/app/helpers/rails_pulse/form_helper.rb +75 -0
  19. data/app/helpers/rails_pulse/tags_helper.rb +29 -0
  20. data/app/javascript/rails_pulse/application.js +6 -0
  21. data/app/javascript/rails_pulse/controllers/custom_range_controller.js +115 -0
  22. data/app/javascript/rails_pulse/controllers/datepicker_controller.js +48 -0
  23. data/app/javascript/rails_pulse/controllers/global_filters_controller.js +110 -0
  24. data/app/models/concerns/taggable.rb +61 -0
  25. data/app/models/rails_pulse/queries/tables/index.rb +10 -2
  26. data/app/models/rails_pulse/query.rb +2 -0
  27. data/app/models/rails_pulse/request.rb +9 -1
  28. data/app/models/rails_pulse/route.rb +2 -0
  29. data/app/models/rails_pulse/routes/tables/index.rb +10 -2
  30. data/app/services/rails_pulse/summary_service.rb +2 -0
  31. data/app/views/layouts/rails_pulse/_global_filters.html.erb +84 -0
  32. data/app/views/layouts/rails_pulse/_menu_items.html.erb +5 -5
  33. data/app/views/layouts/rails_pulse/application.html.erb +8 -5
  34. data/app/views/rails_pulse/components/_page_header.html.erb +20 -0
  35. data/app/views/rails_pulse/operations/show.html.erb +1 -1
  36. data/app/views/rails_pulse/queries/_table.html.erb +3 -1
  37. data/app/views/rails_pulse/queries/index.html.erb +3 -7
  38. data/app/views/rails_pulse/queries/show.html.erb +3 -7
  39. data/app/views/rails_pulse/requests/_table.html.erb +3 -1
  40. data/app/views/rails_pulse/requests/index.html.erb +44 -62
  41. data/app/views/rails_pulse/requests/show.html.erb +1 -1
  42. data/app/views/rails_pulse/routes/_requests_table.html.erb +3 -1
  43. data/app/views/rails_pulse/routes/_table.html.erb +3 -1
  44. data/app/views/rails_pulse/routes/index.html.erb +4 -8
  45. data/app/views/rails_pulse/routes/show.html.erb +3 -7
  46. data/app/views/rails_pulse/tags/_tag_manager.html.erb +73 -0
  47. data/config/routes.rb +5 -0
  48. data/db/rails_pulse_schema.rb +3 -0
  49. data/lib/generators/rails_pulse/install_generator.rb +21 -2
  50. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +3 -0
  51. data/lib/generators/rails_pulse/templates/rails_pulse.rb +21 -0
  52. data/lib/generators/rails_pulse/upgrade_generator.rb +145 -29
  53. data/lib/rails_pulse/configuration.rb +16 -1
  54. data/lib/rails_pulse/version.rb +1 -1
  55. data/public/rails-pulse-assets/rails-pulse-icons.js +16 -15
  56. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  57. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  58. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  59. data/public/rails-pulse-assets/rails-pulse.js +73 -69
  60. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  61. metadata +17 -3
  62. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +0 -12
@@ -0,0 +1,26 @@
1
+ module TagFilterConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ private
5
+
6
+ # Apply tag filters to a query
7
+ # Excludes records that have ANY of the disabled tags
8
+ def apply_tag_filters(query)
9
+ disabled_tags = session_disabled_tags
10
+ query = disabled_tags.reduce(query) do |q, tag|
11
+ q.without_tag(tag)
12
+ end
13
+
14
+ apply_non_tagged_filter(query)
15
+ end
16
+
17
+ # Apply non-tagged filter to a query
18
+ # If show_non_tagged is false, exclude records with no tags
19
+ def apply_non_tagged_filter(query)
20
+ if session[:show_non_tagged] == false
21
+ query.with_tags
22
+ else
23
+ query
24
+ end
25
+ end
26
+ end
@@ -6,7 +6,8 @@ module TimeRangeConcern
6
6
  const_set(:TIME_RANGE_OPTIONS, [
7
7
  [ "Last 24 hours", :last_day ],
8
8
  [ "Last Week", :last_week ],
9
- [ "Last Month", :last_month ]
9
+ [ "Last Month", :last_month ],
10
+ [ "Custom Range...", :custom ]
10
11
  ].freeze)
11
12
  end
12
13
 
@@ -17,11 +18,8 @@ module TimeRangeConcern
17
18
 
18
19
  ransack_params = params[:q] || {}
19
20
 
20
- if ransack_params[:occurred_at_gteq].present?
21
- # Custom time range from chart zoom where there is no association
22
- start_time = parse_time_param(ransack_params[:occurred_at_gteq])
23
- end_time = parse_time_param(ransack_params[:occurred_at_lt])
24
- elsif ransack_params[:period_start_range]
21
+ # Priority 1: Page-specific preset from dropdown (check this first!)
22
+ if ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym != :custom
25
23
  # Predefined time range from dropdown
26
24
  selected_time_range = ransack_params[:period_start_range]
27
25
  start_time =
@@ -31,7 +29,26 @@ module TimeRangeConcern
31
29
  when :last_month then 1.month.ago
32
30
  else 1.day.ago # Default fallback
33
31
  end
32
+ # Priority 2: Page-specific custom datetime range from picker (only if period_start_range is :custom)
33
+ elsif ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym == :custom && ransack_params[:custom_date_range].present? && ransack_params[:custom_date_range].include?(" to ")
34
+ # Custom datetime range from custom range picker
35
+ dates = ransack_params[:custom_date_range].split(" to ")
36
+ start_time = parse_time_param(dates[0].strip)
37
+ end_time = parse_time_param(dates[1].strip)
38
+ selected_time_range = :custom
39
+ # Priority 3: Page-specific filters (chart zoom)
40
+ elsif ransack_params[:occurred_at_gteq].present? && ransack_params[:occurred_at_lt].present?
41
+ # Custom time range from chart zoom
42
+ start_time = parse_time_param(ransack_params[:occurred_at_gteq])
43
+ end_time = parse_time_param(ransack_params[:occurred_at_lt])
44
+ selected_time_range = :custom
45
+ # Priority 4: Global filters (from session)
46
+ elsif session_global_filters["start_time"].present? || session_global_filters["end_time"].present?
47
+ start_time = parse_time_param(session_global_filters["start_time"]) if session_global_filters["start_time"].present?
48
+ end_time = parse_time_param(session_global_filters["end_time"]) if session_global_filters["end_time"].present?
49
+ selected_time_range = :custom
34
50
  end
51
+ # Priority 5: Default time range (already set above)
35
52
 
36
53
  time_diff = (end_time.to_i - start_time.to_i) / 3600.0
37
54
 
@@ -43,7 +60,7 @@ module TimeRangeConcern
43
60
  end_time = end_time.end_of_day
44
61
  end
45
62
 
46
- [ start_time, end_time, selected_time_range, time_diff ]
63
+ [ start_time.to_i, end_time.to_i, selected_time_range, time_diff ]
47
64
  end
48
65
 
49
66
  private
@@ -53,7 +70,9 @@ module TimeRangeConcern
53
70
  when Time, DateTime
54
71
  param.in_time_zone
55
72
  when String
56
- Time.zone.parse(param)
73
+ # Parse as server local time (not UTC, not Time.zone)
74
+ # This ensures flatpickr datetime strings are interpreted in server's timezone
75
+ Time.parse(param).localtime
57
76
  else
58
77
  # Assume it's an integer timestamp
59
78
  Time.zone.at(param.to_i)
@@ -1,6 +1,8 @@
1
1
  module RailsPulse
2
2
  class ApplicationController < ActionController::Base
3
3
  before_action :authenticate_rails_pulse_user!
4
+ before_action :set_show_non_tagged_default
5
+ helper_method :session_global_filters, :session_disabled_tags
4
6
 
5
7
  def set_pagination_limit(limit = nil)
6
8
  limit = limit || params[:limit]
@@ -12,6 +14,48 @@ module RailsPulse
12
14
  end
13
15
  end
14
16
 
17
+ def set_global_filters
18
+ if params[:clear] == "true"
19
+ session.delete(:global_filters)
20
+ else
21
+ filters = session[:global_filters] || {}
22
+
23
+ # Update time filters if provided
24
+ if params[:start_time].present? && params[:end_time].present?
25
+ filters["start_time"] = params[:start_time]
26
+ filters["end_time"] = params[:end_time]
27
+ end
28
+
29
+ # Update performance threshold if provided (or remove if empty)
30
+ if params[:performance_threshold].present?
31
+ filters["performance_threshold"] = params[:performance_threshold]
32
+ else
33
+ filters.delete("performance_threshold")
34
+ end
35
+
36
+ # Update tag visibility - convert enabled tags to disabled tags
37
+ all_tags = RailsPulse.configuration.tags
38
+ enabled_tags = params[:enabled_tags] || []
39
+
40
+ # Handle "non_tagged" separately
41
+ session[:show_non_tagged] = enabled_tags.include?("non_tagged")
42
+ enabled_tags = enabled_tags - [ "non_tagged" ]
43
+
44
+ disabled_tags = all_tags - enabled_tags
45
+
46
+ if disabled_tags.any?
47
+ filters["disabled_tags"] = disabled_tags
48
+ else
49
+ filters.delete("disabled_tags")
50
+ end
51
+
52
+ session[:global_filters] = filters
53
+ end
54
+
55
+ # Redirect back to the referring page or root
56
+ redirect_back(fallback_location: root_path)
57
+ end
58
+
15
59
  private
16
60
 
17
61
  def authenticate_rails_pulse_user!
@@ -71,5 +115,34 @@ module RailsPulse
71
115
  validated_limit = limit.to_i.clamp(5, 50)
72
116
  session[:pagination_limit] = validated_limit if limit.present?
73
117
  end
118
+
119
+ def session_global_filters
120
+ session[:global_filters] || {}
121
+ end
122
+
123
+ def session_disabled_tags
124
+ session_global_filters["disabled_tags"] || []
125
+ end
126
+
127
+ # Get the minimum duration based on global performance threshold
128
+ # Returns nil if no threshold is set (show all)
129
+ # context: :route, :request, or :query
130
+ def global_performance_threshold_duration(context)
131
+ threshold = session_global_filters["performance_threshold"]
132
+ return nil unless threshold.present?
133
+
134
+ config_key = "#{context}_thresholds".to_sym
135
+ thresholds = RailsPulse.configuration.public_send(config_key)
136
+
137
+ thresholds[threshold.to_sym]
138
+ rescue StandardError => e
139
+ Rails.logger.warn "Failed to get performance threshold: #{e.message}"
140
+ nil
141
+ end
142
+
143
+ # Set default value for show_non_tagged if not already set
144
+ def set_show_non_tagged_default
145
+ session[:show_non_tagged] = true if session[:show_non_tagged].nil?
146
+ end
74
147
  end
75
148
  end
@@ -1,6 +1,7 @@
1
1
  module RailsPulse
2
2
  class QueriesController < ApplicationController
3
3
  include ChartTableConcern
4
+ include TagFilterConcern
4
5
 
5
6
  before_action :set_query, only: [ :show, :analyze ]
6
7
 
@@ -114,13 +115,15 @@ module RailsPulse
114
115
  def build_table_results
115
116
  if show_action?
116
117
  # For Summary model on show page - ransack params already include query ID and type filters
118
+ # Summaries aren't taggable, so we don't apply tag filters here
117
119
  @ransack_query.result.where(period_type: period_type)
118
120
  else
119
121
  Queries::Tables::Index.new(
120
122
  ransack_query: @ransack_query,
121
123
  period_type: period_type,
122
124
  start_time: @start_time,
123
- params: params
125
+ params: params,
126
+ disabled_tags: session_disabled_tags
124
127
  ).to_table
125
128
  end
126
129
  end
@@ -1,6 +1,14 @@
1
1
  module RailsPulse
2
2
  class RequestsController < ApplicationController
3
3
  include ChartTableConcern
4
+ include TagFilterConcern
5
+
6
+ # Override TIME_RANGE_OPTIONS from TimeRangeConcern with requests-specific options
7
+ remove_const(:TIME_RANGE_OPTIONS) if const_defined?(:TIME_RANGE_OPTIONS)
8
+ TIME_RANGE_OPTIONS = [
9
+ [ "Recent", "recent" ],
10
+ [ "Custom Range", "custom" ]
11
+ ].freeze
4
12
 
5
13
  before_action :set_request, only: :show
6
14
 
@@ -55,11 +63,32 @@ module RailsPulse
55
63
  end
56
64
 
57
65
  def build_table_ransack_params(ransack_params)
58
- params = ransack_params.merge(
59
- occurred_at_gteq: Time.at(@table_start_time),
60
- occurred_at_lt: Time.at(@table_end_time)
61
- )
62
- params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
66
+ params = ransack_params.dup
67
+
68
+ # Handle time mode - check if recent mode is selected
69
+ time_mode = params[:period_start_range] || "recent"
70
+
71
+ if time_mode != "recent"
72
+ # Custom mode - apply time filters
73
+ params.merge!(
74
+ occurred_at_gteq: Time.at(@table_start_time),
75
+ occurred_at_lt: Time.at(@table_end_time)
76
+ )
77
+ end
78
+ # else: Recent mode - no time filters, just rely on sort + pagination
79
+
80
+ # Duration filter - convert symbol to numeric threshold or use @start_duration
81
+ if params[:duration_gteq].present?
82
+ # If it's a symbol like :slow, convert it to the numeric threshold
83
+ if params[:duration_gteq].to_s.in?(%w[slow very_slow critical])
84
+ params[:duration_gteq] = @start_duration
85
+ end
86
+ # else: it's already a number, keep it as is
87
+ elsif @start_duration && @start_duration > 0
88
+ # No duration_gteq param, use @start_duration from concern
89
+ params[:duration_gteq] = @start_duration
90
+ end
91
+
63
92
  params
64
93
  end
65
94
 
@@ -68,10 +97,13 @@ module RailsPulse
68
97
  end
69
98
 
70
99
  def build_table_results
71
- base_query = @ransack_query.result.includes(:route)
100
+ base_query = apply_tag_filters(@ransack_query.result.includes(:route))
101
+
102
+ # If filtering or sorting by route_path, we need to join the routes table
103
+ needs_join = @ransack_query.sorts.any? { |sort| sort.name == "route_path" } ||
104
+ params.dig(:q, :route_path_cont).present?
72
105
 
73
- # If sorting by route_path, we need to join the routes table
74
- if @ransack_query.sorts.any? { |sort| sort.name == "route_path" }
106
+ if needs_join
75
107
  base_query = base_query.joins(:route)
76
108
  end
77
109
 
@@ -1,6 +1,7 @@
1
1
  module RailsPulse
2
2
  class RoutesController < ApplicationController
3
3
  include ChartTableConcern
4
+ include TagFilterConcern
4
5
 
5
6
  before_action :set_route, only: :show
6
7
 
@@ -86,7 +87,7 @@ module RailsPulse
86
87
  if show_action?
87
88
  # Only show requests that belong to time periods where we have route summaries
88
89
  # This ensures the table data is consistent with the chart data
89
- base_query = @ransack_query.result
90
+ base_query = apply_tag_filters(@ransack_query.result)
90
91
  .joins(<<~SQL)
91
92
  INNER JOIN rails_pulse_summaries ON
92
93
  rails_pulse_summaries.summarizable_id = rails_pulse_requests.route_id AND
@@ -108,7 +109,8 @@ module RailsPulse
108
109
  ransack_query: @ransack_query,
109
110
  period_type: period_type,
110
111
  start_time: @start_time,
111
- params: params
112
+ params: params,
113
+ disabled_tags: session_disabled_tags
112
114
  ).to_table
113
115
  end
114
116
  end
@@ -0,0 +1,51 @@
1
+ module RailsPulse
2
+ class TagsController < ApplicationController
3
+ before_action :set_taggable
4
+
5
+ def create
6
+ tag = params[:tag]
7
+
8
+ if tag.blank?
9
+ render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
10
+ partial: "rails_pulse/tags/tag_manager",
11
+ locals: { taggable: @taggable, error: "Tag cannot be blank" })
12
+ return
13
+ end
14
+
15
+ @taggable.add_tag(tag)
16
+ @taggable.reload
17
+
18
+ render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
19
+ partial: "rails_pulse/tags/tag_manager",
20
+ locals: { taggable: @taggable })
21
+ end
22
+
23
+ def destroy
24
+ tag = params[:tag]
25
+ @taggable.remove_tag(tag)
26
+ @taggable.reload
27
+
28
+ render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
29
+ partial: "rails_pulse/tags/tag_manager",
30
+ locals: { taggable: @taggable })
31
+ end
32
+
33
+ private
34
+
35
+ def set_taggable
36
+ @taggable_type = params[:taggable_type]
37
+ @taggable_id = params[:taggable_id]
38
+
39
+ @taggable = case @taggable_type
40
+ when "route"
41
+ Route.find(@taggable_id)
42
+ when "request"
43
+ Request.find(@taggable_id)
44
+ when "query"
45
+ Query.find(@taggable_id)
46
+ else
47
+ head :not_found
48
+ end
49
+ end
50
+ end
51
+ end
@@ -7,6 +7,8 @@ module RailsPulse
7
7
  include FormattingHelper
8
8
  include StatusHelper
9
9
  include TableHelper
10
+ include FormHelper
11
+ include TagsHelper
10
12
 
11
13
  # Replacement for lucide_icon helper that works with pre-compiled assets
12
14
  # Outputs a custom element that will be hydrated by Stimulus
@@ -0,0 +1,75 @@
1
+ module RailsPulse
2
+ module FormHelper
3
+ # Renders a time range selector that can switch between preset ranges and a custom datetime picker
4
+ #
5
+ # @param form [ActionView::Helpers::FormBuilder] The form builder instance
6
+ # @param time_range_options [Array] Array of [label, value] pairs for the select options
7
+ # @param selected_time_range [Symbol, String] Currently selected time range
8
+ # @param mode [Symbol] :preset (default) for preset time ranges, :recent_custom for Recent/Custom toggle
9
+ # @return [String] HTML for the time range selector
10
+ def time_range_selector(form, time_range_options:, selected_time_range:, mode: :preset)
11
+ global_filters = session_global_filters
12
+ has_global_date_range = global_filters["start_time"].present? && global_filters["end_time"].present?
13
+ global_date_range = has_global_date_range ? "#{global_filters["start_time"]} to #{global_filters["end_time"]}" : ""
14
+ show_custom_picker = selected_time_range.to_sym == :custom
15
+ custom_date_value = params.dig(:q, :custom_date_range) || (show_custom_picker && has_global_date_range ? global_date_range : "")
16
+
17
+ content_tag(:div, class: "time-range-selector", data: { mode: mode }) do
18
+ concat time_range_select_wrapper(form, time_range_options, selected_time_range, mode)
19
+ concat time_range_picker_wrapper(form, custom_date_value)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def time_range_select_wrapper(form, time_range_options, selected_time_range, mode)
26
+ content_tag(:div, data: { rails_pulse__custom_range_target: "selectWrapper" }, style: "min-width: 150px;") do
27
+ form.select :period_start_range,
28
+ time_range_options,
29
+ { selected: selected_time_range },
30
+ {
31
+ class: "input",
32
+ data: {
33
+ action: "change->rails-pulse--custom-range#handleChange",
34
+ mode: mode
35
+ }
36
+ }
37
+ end
38
+ end
39
+
40
+ def time_range_picker_wrapper(form, custom_date_value)
41
+ content_tag(:div,
42
+ data: { rails_pulse__custom_range_target: "pickerWrapper" },
43
+ style: "display: none; position: relative; min-width: 360px;"
44
+ ) do
45
+ concat time_range_picker_input(form, custom_date_value)
46
+ concat time_range_close_button
47
+ end
48
+ end
49
+
50
+ def time_range_picker_input(form, custom_date_value)
51
+ form.text_field :custom_date_range,
52
+ value: custom_date_value,
53
+ placeholder: "Pick date range",
54
+ class: "input",
55
+ style: "padding-inline-end: 2.5rem;",
56
+ data: {
57
+ controller: "rails-pulse--datepicker",
58
+ rails_pulse__datepicker_mode_value: "range",
59
+ rails_pulse__datepicker_show_months_value: 2,
60
+ rails_pulse__datepicker_type_value: "datetime"
61
+ }
62
+ end
63
+
64
+ def time_range_close_button
65
+ button_tag(
66
+ rails_pulse_icon("x", width: "18"),
67
+ type: "button",
68
+ class: "btn btn--borderless",
69
+ style: "position: absolute; inset-inline-end: 0; inset-block-start: 0; inset-block-end: 0; padding: 0.5rem; background: transparent; border: none;",
70
+ data: { action: "click->rails-pulse--custom-range#showSelect" },
71
+ aria: { label: "Close custom range" }
72
+ )
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ module RailsPulse
2
+ module TagsHelper
3
+ # Display tags as badge elements
4
+ # Accepts:
5
+ # - Taggable objects (with tag_list method)
6
+ # - Raw JSON strings from aggregated queries
7
+ # - Arrays of tags
8
+ def display_tag_badges(tags)
9
+ tag_array = case tags
10
+ when String
11
+ # Parse JSON string from database
12
+ begin
13
+ JSON.parse(tags)
14
+ rescue JSON::ParserError
15
+ []
16
+ end
17
+ when Array
18
+ tags
19
+ else
20
+ # Handle Taggable objects
21
+ tags.respond_to?(:tag_list) ? tags.tag_list : []
22
+ end
23
+
24
+ return content_tag(:span, "-", class: "text-subtle") if tag_array.empty?
25
+
26
+ safe_join(tag_array.map { |tag| content_tag(:div, tag, class: "badge") }, " ")
27
+ end
28
+ end
29
+ end
@@ -5,6 +5,7 @@ import { Application } from "@hotwired/stimulus";
5
5
 
6
6
  // CSS Zero Controllers
7
7
  import ContextMenuController from "./controllers/context_menu_controller";
8
+ import DatePickerController from "./controllers/datepicker_controller";
8
9
  import DialogController from "./controllers/dialog_controller";
9
10
  import MenuController from "./controllers/menu_controller";
10
11
  import PopoverController from "./controllers/popover_controller";
@@ -19,6 +20,8 @@ import IconController from "./controllers/icon_controller";
19
20
  import ExpandableRowsController from "./controllers/expandable_rows_controller";
20
21
  import CollapsibleController from "./controllers/collapsible_controller";
21
22
  import TableSortController from "./controllers/table_sort_controller";
23
+ import GlobalFiltersController from "./controllers/global_filters_controller";
24
+ import CustomRangeController from "./controllers/custom_range_controller";
22
25
 
23
26
  const application = Application.start();
24
27
 
@@ -33,6 +36,7 @@ window.echarts = echarts;
33
36
  window.Turbo = Turbo;
34
37
 
35
38
  application.register("rails-pulse--context-menu", ContextMenuController);
39
+ application.register("rails-pulse--datepicker", DatePickerController);
36
40
  application.register("rails-pulse--dialog", DialogController);
37
41
  application.register("rails-pulse--menu", MenuController);
38
42
  application.register("rails-pulse--popover", PopoverController);
@@ -46,6 +50,8 @@ application.register("rails-pulse--icon", IconController);
46
50
  application.register("rails-pulse--expandable-rows", ExpandableRowsController);
47
51
  application.register("rails-pulse--collapsible", CollapsibleController);
48
52
  application.register("rails-pulse--table-sort", TableSortController);
53
+ application.register("rails-pulse--global-filters", GlobalFiltersController);
54
+ application.register("rails-pulse--custom-range", CustomRangeController);
49
55
 
50
56
  // Ensure Turbo Frames are loaded after page load
51
57
  document.addEventListener('DOMContentLoaded', () => {
@@ -0,0 +1,115 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["selectWrapper", "pickerWrapper"]
5
+
6
+ connect() {
7
+ const selectElement = this.selectWrapperTarget.querySelector('select')
8
+ if (!selectElement) return
9
+
10
+ // Check if we're in recent_custom mode
11
+ const mode = selectElement.dataset.mode
12
+
13
+ if (mode === 'recent_custom') {
14
+ // In recent_custom mode, "recent" shows no picker, "custom" shows picker
15
+ if (selectElement.value === "custom") {
16
+ this.showPicker()
17
+ this.initializeDatePicker()
18
+ }
19
+ } else {
20
+ // In preset mode, "custom" shows picker
21
+ if (selectElement.value === "custom") {
22
+ this.showPicker()
23
+ this.initializeDatePicker()
24
+ }
25
+ }
26
+ }
27
+
28
+ // When time range is selected from dropdown
29
+ handleChange(event) {
30
+ const mode = event.target.dataset.mode
31
+
32
+ if (mode === 'recent_custom') {
33
+ // In recent_custom mode
34
+ if (event.target.value === "custom") {
35
+ this.showPicker()
36
+ this.openDatePicker()
37
+ } else {
38
+ // "recent" is selected - hide picker
39
+ this.pickerWrapperTarget.style.display = "none"
40
+ this.selectWrapperTarget.style.display = "block"
41
+ }
42
+ } else {
43
+ // In preset mode
44
+ if (event.target.value === "custom") {
45
+ this.showPicker()
46
+ this.openDatePicker()
47
+ }
48
+ }
49
+ }
50
+
51
+ // Show picker, hide select
52
+ showPicker() {
53
+ this.selectWrapperTarget.style.display = "none"
54
+ this.pickerWrapperTarget.style.display = "flex"
55
+ }
56
+
57
+ // Open the flatpickr calendar
58
+ openDatePicker() {
59
+ // Wait a bit for the DOM to update and flatpickr to initialize
60
+ setTimeout(() => {
61
+ // Find the original hidden input that has the datepicker controller
62
+ const hiddenInput = this.pickerWrapperTarget.querySelector('input[name*="custom_date_range"]')
63
+ if (!hiddenInput) return
64
+
65
+ // Get the datepicker controller from the hidden input
66
+ const datepickerController = this.application.getControllerForElementAndIdentifier(
67
+ hiddenInput,
68
+ 'rails-pulse--datepicker'
69
+ )
70
+
71
+ if (datepickerController && datepickerController.flatpickr) {
72
+ datepickerController.flatpickr.open()
73
+ }
74
+ }, 50)
75
+ }
76
+
77
+ // Show select, hide picker
78
+ showSelect() {
79
+ this.pickerWrapperTarget.style.display = "none"
80
+ this.selectWrapperTarget.style.display = "block"
81
+
82
+ // Reset select to default value based on mode
83
+ const selectElement = this.selectWrapperTarget.querySelector('select')
84
+ if (selectElement) {
85
+ const mode = selectElement.dataset.mode
86
+ if (mode === 'recent_custom') {
87
+ selectElement.value = "recent"
88
+ } else {
89
+ selectElement.value = "last_day"
90
+ }
91
+ }
92
+ }
93
+
94
+ // Initialize flatpickr with existing date value
95
+ initializeDatePicker() {
96
+ const dateInput = this.pickerWrapperTarget.querySelector('input[type="text"]')
97
+ if (!dateInput || !dateInput.value) return
98
+
99
+ // Get the datepicker controller
100
+ const datepickerController = this.application.getControllerForElementAndIdentifier(
101
+ dateInput,
102
+ 'rails-pulse--datepicker'
103
+ )
104
+
105
+ if (datepickerController && datepickerController.flatpickr) {
106
+ const value = dateInput.value
107
+ // Parse the "start to end" format
108
+ if (value.includes(' to ')) {
109
+ const [start, end] = value.split(' to ').map(d => d.trim())
110
+ // Set the dates in flatpickr
111
+ datepickerController.flatpickr.setDate([start, end], false)
112
+ }
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import flatpickr from "flatpickr"
3
+
4
+ export default class extends Controller {
5
+ static targets = [ "details" ]
6
+ static values = {
7
+ type: String, disable: Array,
8
+ mode: { type: String, default: "single" },
9
+ showMonths: { type: Number, default: 1 },
10
+ dateFormat: { type: String, default: "F d, Y" },
11
+ dateTimeFormat: { type: String, default: "M d, Y h:i K" }
12
+ }
13
+
14
+ connect() {
15
+ if (this.typeValue == "time") {
16
+ this.flatpickr = flatpickr(this.element, this.#timeOptions)
17
+ } else if (this.typeValue == "datetime") {
18
+ this.flatpickr = flatpickr(this.element, this.#dateTimeOptions)
19
+ } else {
20
+ this.flatpickr = flatpickr(this.element, this.#basicOptions)
21
+ }
22
+ }
23
+
24
+ disconnect() {
25
+ this.flatpickr.destroy()
26
+ }
27
+
28
+ get #timeOptions() {
29
+ return { dateFormat: "H:i", enableTime: true, noCalendar: true }
30
+ }
31
+
32
+ get #dateTimeOptions() {
33
+ return { ...this.#baseOptions, altFormat: this.dateTimeFormatValue, dateFormat: "Y-m-d H:i", enableTime: true }
34
+ }
35
+
36
+ get #basicOptions() {
37
+ return { ...this.#baseOptions, altFormat: this.dateFormatValue, dateFormat: "Y-m-d" }
38
+ }
39
+
40
+ get #baseOptions() {
41
+ return {
42
+ altInput: true,
43
+ disable: this.disableValue,
44
+ mode: this.modeValue,
45
+ showMonths: this.showMonthsValue
46
+ }
47
+ }
48
+ }