activeadmin_favorites 0.1.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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/CODE_OF_CONDUCT.md +31 -0
  4. data/CONTRIBUTING.md +29 -0
  5. data/GOVERNANCE.md +29 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +146 -0
  8. data/SECURITY.md +27 -0
  9. data/activeadmin_favorites.gemspec +68 -0
  10. data/app/assets/controllers/activeadmin_favorites/personalization_controller.js +199 -0
  11. data/app/models/active_admin/favorites/application_record.rb +9 -0
  12. data/app/models/active_admin/favorites/combined_favorite.rb +9 -0
  13. data/app/models/active_admin/favorites/favorite/macro_builder.rb +43 -0
  14. data/app/models/active_admin/favorites/favorite/macro_catalog.rb +118 -0
  15. data/app/models/active_admin/favorites/favorite/macro_resolver.rb +86 -0
  16. data/app/models/active_admin/favorites/favorite/macro_validator.rb +87 -0
  17. data/app/models/active_admin/favorites/favorite/query_params.rb +41 -0
  18. data/app/models/active_admin/favorites/favorite/range_detector.rb +54 -0
  19. data/app/models/active_admin/favorites/favorite.rb +339 -0
  20. data/app/models/active_admin/favorites/filters_favorite.rb +8 -0
  21. data/app/models/active_admin/favorites/lens_favorite.rb +9 -0
  22. data/app/models/active_admin/favorites/view_lens/layout.rb +86 -0
  23. data/app/models/active_admin/favorites/view_lens_default.rb +40 -0
  24. data/app/models/active_admin/favorites.rb +6 -0
  25. data/app/views/active_admin/favorites/_lens_catalog_data.html.erb +9 -0
  26. data/app/views/active_admin/favorites/_macro_fields.html.erb +53 -0
  27. data/app/views/active_admin/favorites/_personalization_trigger.html.erb +69 -0
  28. data/app/views/active_admin/resource/index.html.arb +99 -0
  29. data/app/views/active_admin/resource/show.html.arb +13 -0
  30. data/config/locales/activeadmin_favorites.en.yml +60 -0
  31. data/config/locales/activeadmin_favorites.host.yml.example +17 -0
  32. data/config/polyrun_coverage.yml +8 -0
  33. data/db/migrate/20260709100000_create_active_admin_view_lens_defaults.rb +24 -0
  34. data/db/migrate/20260709130000_create_admin_favorites.rb +33 -0
  35. data/lib/activeadmin/favorites/configuration.rb +31 -0
  36. data/lib/activeadmin/favorites/engine.rb +75 -0
  37. data/lib/activeadmin/favorites/hash_coercion.rb +34 -0
  38. data/lib/activeadmin/favorites/install.rb +121 -0
  39. data/lib/activeadmin/favorites/migration_helpers.rb +34 -0
  40. data/lib/activeadmin/favorites/migration_support.rb +19 -0
  41. data/lib/activeadmin/favorites/register_favorites.rb +272 -0
  42. data/lib/activeadmin/favorites/register_view_lens_preferences.rb +98 -0
  43. data/lib/activeadmin/favorites/user_record.rb +26 -0
  44. data/lib/activeadmin/favorites/version.rb +7 -0
  45. data/lib/activeadmin/favorites/view_lens/applicator.rb +150 -0
  46. data/lib/activeadmin/favorites/view_lens/catalog.rb +108 -0
  47. data/lib/activeadmin/favorites/view_lens/current.rb +28 -0
  48. data/lib/activeadmin/favorites/view_lens/patches.rb +95 -0
  49. data/lib/activeadmin/favorites/view_lens/resource_dsl.rb +44 -0
  50. data/lib/activeadmin/favorites/view_lens.rb +8 -0
  51. data/lib/activeadmin/favorites.rb +30 -0
  52. data/lib/activeadmin_favorites.rb +5 -0
  53. data/lib/generators/activeadmin_favorites/install/templates/favorite_policy.rb +56 -0
  54. data/lib/generators/activeadmin_favorites/install/templates/initializer.rb +16 -0
  55. data/lib/generators/activeadmin_favorites/install/templates/locale.en.yml +60 -0
  56. data/lib/generators/activeadmin_favorites/install_generator.rb +37 -0
  57. data/lib/tasks/activeadmin_favorites_tasks.rake +39 -0
  58. data/sig/activeadmin/favorites.rbs +5 -0
  59. metadata +418 -0
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class Favorite
6
+ class MacroCatalog
7
+ Template = Data.define(:key, :category, :requires_attribute, :resolver)
8
+
9
+ CATEGORIES = %i[time_range fiscal].freeze
10
+
11
+ def self.all
12
+ @all ||= build_templates.index_by(&:key)
13
+ end
14
+
15
+ def self.fetch(key)
16
+ all.fetch(key.to_s) { raise KeyError, "Unknown macro template: #{key}" }
17
+ end
18
+
19
+ def self.template_keys
20
+ all.keys
21
+ end
22
+
23
+ def self.options_for_select(category: nil)
24
+ templates = all.values
25
+ templates = templates.select { |template| template.category == category.to_sym } if category.present?
26
+ templates.map { |template| [I18n.t("active_admin.favorites.macro_templates.#{template.key}"), template.key] }
27
+ end
28
+
29
+ def self.resolve(template_key, attribute:, at: Time.zone.now)
30
+ fetch(template_key).resolver.call(attribute:, at: at.in_time_zone)
31
+ end
32
+
33
+ def self.describe(macro)
34
+ case macro["type"]
35
+ when "template"
36
+ attribute = macro["attribute"]
37
+ label = I18n.t("active_admin.favorites.macro_templates.#{macro["name"]}", default: macro["name"].humanize)
38
+ "#{attribute_label(attribute)}: #{label}"
39
+ when "relative_time_range"
40
+ "#{attribute_label(macro["attribute"])}: #{I18n.t("active_admin.favorites.macro_templates.#{macro["preset"]}", default: macro["preset"].humanize)}"
41
+ when "relative_time_bound"
42
+ "#{attribute_label(macro["attribute"])} #{macro["predicate"]} (#{macro["unit"]} #{macro["offset"]})"
43
+ else
44
+ macro["type"].to_s.humanize
45
+ end
46
+ end
47
+
48
+ def self.attribute_label(attribute)
49
+ attribute.to_s.tr("_", " ")
50
+ end
51
+
52
+ def self.build_templates
53
+ [
54
+ template(:previous_calendar_month, :time_range) { |attribute:, at:|
55
+ month = at.to_date.last_month
56
+ range_bounds(attribute, month.beginning_of_month, month.end_of_month)
57
+ },
58
+ template(:current_calendar_month, :time_range) { |attribute:, at:|
59
+ month = at.to_date
60
+ range_bounds(attribute, month.beginning_of_month, month.end_of_month)
61
+ },
62
+ template(:previous_calendar_day, :time_range) { |attribute:, at:|
63
+ day = at.to_date.yesterday
64
+ range_bounds(attribute, day, day)
65
+ },
66
+ template(:current_calendar_day, :time_range) { |attribute:, at:|
67
+ day = at.to_date
68
+ range_bounds(attribute, day, day)
69
+ },
70
+ template(:rolling_last_7_days, :time_range) { |attribute:, at:|
71
+ day = at.to_date
72
+ range_bounds(attribute, day - 6.days, day)
73
+ },
74
+ template(:rolling_last_30_days, :time_range) { |attribute:, at:|
75
+ day = at.to_date
76
+ range_bounds(attribute, day - 29.days, day)
77
+ },
78
+ template(:previous_fiscal_year, :fiscal) { |attribute:, at:|
79
+ start_date = fiscal_year_start(at.to_date) - 1.year
80
+ range_bounds(attribute, start_date, start_date + 1.year - 1.day)
81
+ },
82
+ template(:current_fiscal_year_to_date, :fiscal) { |attribute:, at:|
83
+ start_date = fiscal_year_start(at.to_date)
84
+ range_bounds(attribute, start_date, at.to_date)
85
+ }
86
+ ]
87
+ end
88
+
89
+ def self.template(key, category, &resolver)
90
+ Template.new(
91
+ key: key.to_s,
92
+ category: category,
93
+ requires_attribute: true,
94
+ resolver: resolver
95
+ )
96
+ end
97
+
98
+ def self.range_bounds(attribute, from_date, to_date)
99
+ {
100
+ "#{attribute}_gteq" => format_date(from_date),
101
+ "#{attribute}_lteq" => format_date(to_date)
102
+ }
103
+ end
104
+
105
+ def self.format_date(value)
106
+ value.to_date.strftime("%Y-%m-%d")
107
+ end
108
+
109
+ def self.fiscal_year_start(date)
110
+ year = (date.month >= 4) ? date.year : date.year - 1
111
+ Date.new(year, 4, 1)
112
+ end
113
+
114
+ private_class_method :build_templates, :template, :range_bounds, :fiscal_year_start
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class Favorite
6
+ class MacroResolver
7
+ ALLOWED_UNITS = %w[day week month year].freeze
8
+ ALLOWED_ANCHORS = %w[beginning_of_unit end_of_unit].freeze
9
+ ALLOWED_PREDICATES = %w[gteq lteq].freeze
10
+ MAX_MACROS = 10
11
+
12
+ def self.call(macros:, at: Time.zone.now)
13
+ new(macros:, at:).call
14
+ end
15
+
16
+ def self.q_keys(macros)
17
+ call(macros: macros, at: Time.zone.now).keys
18
+ end
19
+
20
+ def initialize(macros:, at:)
21
+ @macros = Array(macros)
22
+ @at = at.in_time_zone
23
+ end
24
+
25
+ def call
26
+ @macros.each_with_object({}) do |macro, resolved|
27
+ raise ArgumentError, "too many macros" if resolved.size > MAX_MACROS
28
+
29
+ resolved.merge!(resolve_macro(macro))
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_macro(macro)
36
+ case macro["type"]
37
+ when "template"
38
+ resolve_template(macro)
39
+ when "relative_time_range"
40
+ resolve_relative_time_range(macro)
41
+ when "relative_time_bound"
42
+ key, value = resolve_relative_time_bound(macro)
43
+ {key => value}
44
+ else
45
+ raise ArgumentError, "unsupported macro type: #{macro["type"]}"
46
+ end
47
+ end
48
+
49
+ def resolve_template(macro)
50
+ MacroCatalog.resolve(macro.fetch("name"), attribute: macro.fetch("attribute"), at: @at)
51
+ end
52
+
53
+ def resolve_relative_time_range(macro)
54
+ MacroCatalog.resolve(macro.fetch("preset"), attribute: macro.fetch("attribute"), at: @at)
55
+ end
56
+
57
+ def resolve_relative_time_bound(macro)
58
+ attribute = macro.fetch("attribute")
59
+ predicate = macro.fetch("predicate")
60
+ unit = macro.fetch("unit")
61
+ offset = macro.fetch("offset").to_i
62
+ anchor = macro.fetch("anchor")
63
+
64
+ unless ALLOWED_UNITS.include?(unit) && ALLOWED_ANCHORS.include?(anchor) && ALLOWED_PREDICATES.include?(predicate)
65
+ raise ArgumentError, "invalid relative_time_bound macro"
66
+ end
67
+
68
+ if offset.abs > 120
69
+ raise ArgumentError, "offset out of range"
70
+ end
71
+
72
+ date = @at.to_date
73
+ shifted = date.advance("#{unit}s": offset)
74
+ bound =
75
+ if anchor == "beginning_of_unit"
76
+ shifted.public_send("beginning_of_#{unit}")
77
+ else
78
+ shifted.public_send("end_of_#{unit}")
79
+ end
80
+
81
+ ["#{attribute}_#{predicate}", MacroCatalog.format_date(bound)]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class Favorite
6
+ class MacroValidator
7
+ class ValidationError < StandardError; end
8
+
9
+ def self.validate!(macros, resource_key:)
10
+ new(macros, resource_key:).validate!
11
+ end
12
+
13
+ def initialize(macros, resource_key:)
14
+ @macros = Array(macros)
15
+ @resource_key = resource_key
16
+ @allowed_attributes = Favorite.ransackable_attributes_for(resource_key)
17
+ end
18
+
19
+ def validate!
20
+ raise ValidationError, "macros must be an array" unless @macros.is_a?(Array)
21
+ raise ValidationError, "too many macros" if @macros.size > MacroResolver::MAX_MACROS
22
+
23
+ @macros.each { |macro| validate_macro!(macro) }
24
+ true
25
+ end
26
+
27
+ private
28
+
29
+ def validate_macro!(macro)
30
+ raise ValidationError, "macro must be an object" unless macro.is_a?(Hash)
31
+ raise ValidationError, "macro type is required" if macro["type"].blank?
32
+
33
+ case macro["type"]
34
+ when "template"
35
+ validate_template!(macro)
36
+ when "relative_time_range"
37
+ validate_relative_time_range!(macro)
38
+ when "relative_time_bound"
39
+ validate_relative_time_bound!(macro)
40
+ else
41
+ raise ValidationError, "unsupported macro type: #{macro["type"]}"
42
+ end
43
+ end
44
+
45
+ def validate_template!(macro)
46
+ MacroCatalog.fetch(macro.fetch("name"))
47
+ validate_attribute!(macro.fetch("attribute"))
48
+ end
49
+
50
+ def validate_relative_time_range!(macro)
51
+ MacroCatalog.fetch(macro.fetch("preset"))
52
+ validate_attribute!(macro.fetch("attribute"))
53
+ end
54
+
55
+ def validate_relative_time_bound!(macro)
56
+ validate_attribute!(macro.fetch("attribute"))
57
+ predicate = macro.fetch("predicate")
58
+ unit = macro.fetch("unit")
59
+ anchor = macro.fetch("anchor")
60
+ offset = macro.fetch("offset").to_i
61
+
62
+ unless MacroResolver::ALLOWED_PREDICATES.include?(predicate)
63
+ raise ValidationError, "invalid predicate: #{predicate}"
64
+ end
65
+ unless MacroResolver::ALLOWED_UNITS.include?(unit)
66
+ raise ValidationError, "invalid unit: #{unit}"
67
+ end
68
+ unless MacroResolver::ALLOWED_ANCHORS.include?(anchor)
69
+ raise ValidationError, "invalid anchor: #{anchor}"
70
+ end
71
+ if offset.abs > 120
72
+ raise ValidationError, "offset out of range"
73
+ end
74
+ end
75
+
76
+ def validate_attribute!(attribute)
77
+ unless attribute.to_s.match?(/\A[a-z0-9_]+\z/)
78
+ raise ValidationError, "invalid attribute name"
79
+ end
80
+ unless @allowed_attributes.include?(attribute.to_s)
81
+ raise ValidationError, "attribute not allowed for resource: #{attribute}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class Favorite
6
+ module QueryParams
7
+ module_function
8
+
9
+ def parse(query_string)
10
+ return {} if query_string.blank?
11
+
12
+ Rack::Utils.parse_nested_query(query_string.to_s).except("commit")
13
+ end
14
+
15
+ def build(params)
16
+ Rack::Utils.build_nested_query(params.compact_blank).presence
17
+ end
18
+
19
+ def merge_query_string(query_string, q_overrides:)
20
+ params = parse(query_string)
21
+ params["q"] = (params["q"] || {}).merge(q_overrides.stringify_keys)
22
+ build(params)
23
+ end
24
+
25
+ def without_q_keys(query_string, keys:)
26
+ params = parse(query_string)
27
+ query_filters = params["q"]
28
+ return query_string if query_filters.blank?
29
+
30
+ keys.each { |key| query_filters.delete(key.to_s) }
31
+ params["q"] = query_filters.presence
32
+ build(params)
33
+ end
34
+
35
+ def q_keys_for_macros(macros)
36
+ Favorite::MacroResolver.q_keys(macros)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class Favorite
6
+ class RangeDetector
7
+ DATE_PREDICATES = %w[gteq lteq gt lt].freeze
8
+ DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}/
9
+
10
+ def self.call(query_string:, resource_key:)
11
+ new(query_string:, resource_key:).call
12
+ end
13
+
14
+ def initialize(query_string:, resource_key:)
15
+ @query_filters = QueryParams.parse(query_string).fetch("q", {})
16
+ @resource_key = resource_key
17
+ end
18
+
19
+ def call
20
+ attributes = allowed_attributes
21
+ attributes.filter_map do |attribute|
22
+ detect_range(attribute)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def allowed_attributes
29
+ Favorite.ransackable_attributes_for(@resource_key)
30
+ .select { |attribute| date_like_attribute?(attribute) }
31
+ end
32
+
33
+ def date_like_attribute?(attribute)
34
+ DATE_PREDICATES.any? do |predicate|
35
+ value = @query_filters["#{attribute}_#{predicate}"]
36
+ value.present? && value.to_s.match?(DATE_PATTERN)
37
+ end
38
+ end
39
+
40
+ def detect_range(attribute)
41
+ gteq = @query_filters["#{attribute}_gteq"].presence
42
+ lteq = @query_filters["#{attribute}_lteq"].presence
43
+ return if gteq.blank? && lteq.blank?
44
+
45
+ {
46
+ attribute: attribute,
47
+ gteq: gteq,
48
+ lteq: lteq
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end