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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +31 -0
- data/CONTRIBUTING.md +29 -0
- data/GOVERNANCE.md +29 -0
- data/LICENSE.md +21 -0
- data/README.md +146 -0
- data/SECURITY.md +27 -0
- data/activeadmin_favorites.gemspec +68 -0
- data/app/assets/controllers/activeadmin_favorites/personalization_controller.js +199 -0
- data/app/models/active_admin/favorites/application_record.rb +9 -0
- data/app/models/active_admin/favorites/combined_favorite.rb +9 -0
- data/app/models/active_admin/favorites/favorite/macro_builder.rb +43 -0
- data/app/models/active_admin/favorites/favorite/macro_catalog.rb +118 -0
- data/app/models/active_admin/favorites/favorite/macro_resolver.rb +86 -0
- data/app/models/active_admin/favorites/favorite/macro_validator.rb +87 -0
- data/app/models/active_admin/favorites/favorite/query_params.rb +41 -0
- data/app/models/active_admin/favorites/favorite/range_detector.rb +54 -0
- data/app/models/active_admin/favorites/favorite.rb +339 -0
- data/app/models/active_admin/favorites/filters_favorite.rb +8 -0
- data/app/models/active_admin/favorites/lens_favorite.rb +9 -0
- data/app/models/active_admin/favorites/view_lens/layout.rb +86 -0
- data/app/models/active_admin/favorites/view_lens_default.rb +40 -0
- data/app/models/active_admin/favorites.rb +6 -0
- data/app/views/active_admin/favorites/_lens_catalog_data.html.erb +9 -0
- data/app/views/active_admin/favorites/_macro_fields.html.erb +53 -0
- data/app/views/active_admin/favorites/_personalization_trigger.html.erb +69 -0
- data/app/views/active_admin/resource/index.html.arb +99 -0
- data/app/views/active_admin/resource/show.html.arb +13 -0
- data/config/locales/activeadmin_favorites.en.yml +60 -0
- data/config/locales/activeadmin_favorites.host.yml.example +17 -0
- data/config/polyrun_coverage.yml +8 -0
- data/db/migrate/20260709100000_create_active_admin_view_lens_defaults.rb +24 -0
- data/db/migrate/20260709130000_create_admin_favorites.rb +33 -0
- data/lib/activeadmin/favorites/configuration.rb +31 -0
- data/lib/activeadmin/favorites/engine.rb +75 -0
- data/lib/activeadmin/favorites/hash_coercion.rb +34 -0
- data/lib/activeadmin/favorites/install.rb +121 -0
- data/lib/activeadmin/favorites/migration_helpers.rb +34 -0
- data/lib/activeadmin/favorites/migration_support.rb +19 -0
- data/lib/activeadmin/favorites/register_favorites.rb +272 -0
- data/lib/activeadmin/favorites/register_view_lens_preferences.rb +98 -0
- data/lib/activeadmin/favorites/user_record.rb +26 -0
- data/lib/activeadmin/favorites/version.rb +7 -0
- data/lib/activeadmin/favorites/view_lens/applicator.rb +150 -0
- data/lib/activeadmin/favorites/view_lens/catalog.rb +108 -0
- data/lib/activeadmin/favorites/view_lens/current.rb +28 -0
- data/lib/activeadmin/favorites/view_lens/patches.rb +95 -0
- data/lib/activeadmin/favorites/view_lens/resource_dsl.rb +44 -0
- data/lib/activeadmin/favorites/view_lens.rb +8 -0
- data/lib/activeadmin/favorites.rb +30 -0
- data/lib/activeadmin_favorites.rb +5 -0
- data/lib/generators/activeadmin_favorites/install/templates/favorite_policy.rb +56 -0
- data/lib/generators/activeadmin_favorites/install/templates/initializer.rb +16 -0
- data/lib/generators/activeadmin_favorites/install/templates/locale.en.yml +60 -0
- data/lib/generators/activeadmin_favorites/install_generator.rb +37 -0
- data/lib/tasks/activeadmin_favorites_tasks.rake +39 -0
- data/sig/activeadmin/favorites.rbs +5 -0
- 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
|