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,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class Favorite < ApplicationRecord
6
+ include UserRecord
7
+
8
+ KINDS = %w[filters lens combined].freeze
9
+ ACTIONS = %w[index show].freeze
10
+
11
+ STI_TYPE_BY_KIND = {
12
+ "filters" => "ActiveAdmin::Favorites::FiltersFavorite",
13
+ "lens" => "ActiveAdmin::Favorites::LensFavorite",
14
+ "combined" => "ActiveAdmin::Favorites::CombinedFavorite"
15
+ }.freeze
16
+
17
+ KIND_BY_STI_TYPE = STI_TYPE_BY_KIND.invert.freeze
18
+
19
+ attribute :macros, :json, default: -> { [] }
20
+ attribute :layout, :json
21
+
22
+ attr_accessor :macros_json, :use_advanced_macros, :include_layout
23
+
24
+ validates :name, :resource_key, :path, presence: true
25
+ validates :published, inclusion: {in: [true, false]}
26
+ validates :macros_version, numericality: {only_integer: true, greater_than: 0}
27
+ validates :action, inclusion: {in: ACTIONS}, allow_nil: true
28
+
29
+ validate :path_starts_with_namespace_prefix
30
+ validate :unique_path_query_and_type_per_user
31
+ validate :validate_macros_schema
32
+
33
+ before_validation :normalize_blank_query_string
34
+
35
+ before_save :sync_published_at
36
+ before_validation :assign_default_type
37
+
38
+ scope :visible_to, ->(user) {
39
+ foreign_key = ActiveAdmin::Favorites.config.user_foreign_key
40
+ where(foreign_key => user).or(where(published: true))
41
+ }
42
+
43
+ scope :filters_kind, -> { where(type: FiltersFavorite.name) }
44
+ scope :lens_kind, -> { where(type: LensFavorite.name) }
45
+ scope :combined_kind, -> { where(type: CombinedFavorite.name) }
46
+
47
+ def self.table_name
48
+ ActiveAdmin::Favorites.config.table_name
49
+ end
50
+
51
+ def self.model_name
52
+ @model_name ||= ActiveModel::Name.new(self, nil, "Favorite")
53
+ end
54
+
55
+ def self.policy_class
56
+ ActiveAdmin::Favorites::FavoritePolicy
57
+ end
58
+
59
+ def self.sti_class_for_kind(kind)
60
+ STI_TYPE_BY_KIND.fetch(kind.to_s).constantize
61
+ end
62
+
63
+ def self.kind_for_type(type_name)
64
+ KIND_BY_STI_TYPE.fetch(type_name)
65
+ end
66
+
67
+ def self.sanitize_query_string(raw_query_string)
68
+ return if raw_query_string.blank?
69
+
70
+ QueryParams.build(QueryParams.parse(raw_query_string))
71
+ end
72
+
73
+ def self.suggested_name(resource_key:, query_string:, kind: "filters")
74
+ label = resource_label_for(resource_key)
75
+ summary =
76
+ case kind
77
+ when "lens"
78
+ I18n.t("active_admin.favorites.lens_summary", default: "Page layout")
79
+ when "combined"
80
+ [filter_summary_for(query_string, macros: []), I18n.t("active_admin.favorites.lens_summary", default: "Page layout")].compact.join(" · ")
81
+ else
82
+ filter_summary_for(query_string, macros: [])
83
+ end
84
+ [label, summary].compact.join(" — ").truncate(120)
85
+ end
86
+
87
+ def self.resource_label_for(resource_key)
88
+ return resource_key.humanize if resource_key.blank?
89
+
90
+ config = active_admin_resource_for(resource_key)
91
+ config&.resource_label || resource_key.tr("_", " ").titleize
92
+ end
93
+
94
+ def self.filter_summary_for(query_string, macros: [])
95
+ params = QueryParams.parse(query_string)
96
+ parts = []
97
+ parts << "scope: #{params["scope"]}" if params["scope"].present?
98
+
99
+ macro_parts = Array(macros).map { |macro| MacroCatalog.describe(macro) }
100
+ parts.concat(macro_parts)
101
+
102
+ query_filters = params["q"]
103
+ if query_filters.is_a?(Hash) && query_filters.any?
104
+ parts << query_filters.keys.map { |key| key.sub(/_(eq|gteq|lteq|cont)\z/, "").tr("_", " ") }.join(", ")
105
+ end
106
+
107
+ parts << "sort: #{params["order"]}" if params["order"].present?
108
+ parts.join(" · ").presence
109
+ end
110
+
111
+ def self.ransackable_attributes_for(resource_key)
112
+ resource_class = resource_class_for(resource_key)
113
+ return [] unless resource_class
114
+
115
+ if resource_class.respond_to?(:ransackable_attributes)
116
+ resource_class.ransackable_attributes
117
+ else
118
+ []
119
+ end
120
+ end
121
+
122
+ def self.resource_class_for(resource_key)
123
+ active_admin_resource_for(resource_key)&.resource_class
124
+ end
125
+
126
+ def self.active_admin_resource_for(resource_key)
127
+ namespace = ActiveAdmin.application.namespace(ActiveAdmin::Favorites.config.namespace_name)
128
+ namespace.resources.find do |resource|
129
+ resource.is_a?(ActiveAdmin::Resource) && resource.resource_name.route_key == resource_key
130
+ end
131
+ end
132
+
133
+ def self.detected_ranges(query_string:, resource_key:)
134
+ RangeDetector.call(query_string:, resource_key:)
135
+ end
136
+
137
+ def self.macro_assignments_from_macros(macros)
138
+ Array(macros).filter_map do |macro|
139
+ next unless macro["type"] == "template"
140
+
141
+ {
142
+ "enabled" => "1",
143
+ "attribute" => macro["attribute"],
144
+ "name" => macro["name"]
145
+ }
146
+ end
147
+ end
148
+
149
+ def kind
150
+ self.class.kind_for_type(type)
151
+ end
152
+
153
+ def kind=(value)
154
+ self.type = self.class.sti_class_for_kind(value).name
155
+ end
156
+
157
+ def promote_to_kind!(value)
158
+ sti_class = self.class.sti_class_for_kind(value)
159
+ return self if is_a?(sti_class)
160
+
161
+ promoted = becomes(sti_class)
162
+ promoted.type = sti_class.name
163
+ promoted
164
+ end
165
+
166
+ def filter_summary
167
+ self.class.filter_summary_for(query_string, macros: macros)
168
+ end
169
+
170
+ def resource_label
171
+ self.class.resource_label_for(resource_key)
172
+ end
173
+
174
+ def layout_object
175
+ ViewLens::Layout.new(layout || {})
176
+ end
177
+
178
+ def resolved_query_string(at: Time.zone.now)
179
+ return query_string if macros.blank?
180
+
181
+ q_overrides = MacroResolver.call(macros: macros, at: at)
182
+ QueryParams.merge_query_string(query_string, q_overrides: q_overrides)
183
+ end
184
+
185
+ def index_url(at: Time.zone.now, favorite_id: nil)
186
+ resolved = resolved_query_string(at: at)
187
+ url = resolved.present? ? "#{path}?#{resolved}" : path
188
+ if favorite_id.present?
189
+ separator = url.include?("?") ? "&" : "?"
190
+ url = "#{url}#{separator}favorite_id=#{favorite_id}"
191
+ elsif id.present? && layout.present?
192
+ separator = url.include?("?") ? "&" : "?"
193
+ url = "#{url}#{separator}favorite_id=#{id}"
194
+ end
195
+ url
196
+ end
197
+
198
+ def show_url(favorite_id: nil)
199
+ return path if path.blank?
200
+
201
+ if favorite_id.present? || (id.present? && layout.present?)
202
+ favorite_param = favorite_id || id
203
+ "#{path}?favorite_id=#{favorite_param}"
204
+ else
205
+ path
206
+ end
207
+ end
208
+
209
+ def open_url(at: Time.zone.now)
210
+ if is_a?(LensFavorite)
211
+ (action == "show") ? show_url : index_url(at: at)
212
+ else
213
+ index_url(at: at)
214
+ end
215
+ end
216
+
217
+ def detected_ranges
218
+ self.class.detected_ranges(query_string:, resource_key: resource_key)
219
+ end
220
+
221
+ def macro_form_rows
222
+ rows_by_attribute = {}
223
+
224
+ detected_ranges.each do |range|
225
+ rows_by_attribute[range[:attribute]] = {
226
+ attribute: range[:attribute],
227
+ gteq: range[:gteq],
228
+ lteq: range[:lteq],
229
+ enabled: false,
230
+ name: "previous_calendar_month"
231
+ }
232
+ end
233
+
234
+ Array(macros).each do |macro|
235
+ next unless macro["type"] == "template"
236
+
237
+ attribute = macro["attribute"]
238
+ rows_by_attribute[attribute] ||= {attribute: attribute}
239
+ rows_by_attribute[attribute][:enabled] = true
240
+ rows_by_attribute[attribute][:name] = macro["name"]
241
+ end
242
+
243
+ rows_by_attribute.values
244
+ end
245
+
246
+ def macros_json_for_form
247
+ macros_json.presence || JSON.pretty_generate(macros)
248
+ end
249
+
250
+ def owned_by?(user)
251
+ public_send(:"#{ActiveAdmin::Favorites.config.user_foreign_key}") == user.id
252
+ end
253
+
254
+ def assign_macros_from_form!(template_assignments:, macros_json:, use_advanced_macros:)
255
+ parsed_macros =
256
+ if ActiveModel::Type::Boolean.new.cast(use_advanced_macros) && macros_json.present?
257
+ MacroBuilder.from_json(macros_json)
258
+ else
259
+ MacroBuilder.from_template_assignments(template_assignments)
260
+ end
261
+
262
+ self.macros = parsed_macros
263
+ MacroValidator.validate!(macros, resource_key: resource_key)
264
+ strip_query_string_for_macros!
265
+ true
266
+ rescue JSON::ParserError
267
+ errors.add(:macros_json, I18n.t("active_admin.favorites.macros_json_invalid"))
268
+ false
269
+ rescue MacroValidator::ValidationError => error
270
+ errors.add(:macros, error.message)
271
+ false
272
+ end
273
+
274
+ def kind_label
275
+ I18n.t("active_admin.favorites.kinds.#{kind}", default: kind.humanize)
276
+ end
277
+
278
+ private
279
+
280
+ def assign_default_type
281
+ self.type = FiltersFavorite.name if type.blank?
282
+ end
283
+
284
+ def path_starts_with_namespace_prefix
285
+ return if path.blank?
286
+
287
+ prefix = ActiveAdmin::Favorites.config.namespace_path_prefix
288
+ return if path.start_with?(prefix)
289
+
290
+ errors.add(:path, I18n.t("active_admin.favorites.errors.path_namespace", default: "must stay within the configured admin area"))
291
+ end
292
+
293
+ def normalize_blank_query_string
294
+ self.query_string = "" if query_string.nil?
295
+ end
296
+
297
+ def unique_path_query_and_type_per_user
298
+ foreign_key = ActiveAdmin::Favorites.config.user_foreign_key
299
+ owner_id = public_send(foreign_key)
300
+ return if owner_id.blank? || path.blank?
301
+
302
+ scope = self.class.where(
303
+ path: path,
304
+ query_string: query_string_for_uniqueness,
305
+ type: type
306
+ )
307
+ scope = scope.where(foreign_key => owner_id)
308
+ scope = scope.where.not(id: id) if persisted?
309
+
310
+ return unless scope.exists?
311
+
312
+ errors.add(:base, I18n.t("active_admin.favorites.errors.duplicate_favorite", default: "already saved for this page and filters"))
313
+ end
314
+
315
+ def query_string_for_uniqueness
316
+ query_string.presence || ""
317
+ end
318
+
319
+ def strip_query_string_for_macros!
320
+ keys = QueryParams.q_keys_for_macros(macros)
321
+ self.query_string = QueryParams.without_q_keys(query_string, keys: keys)
322
+ end
323
+
324
+ def validate_macros_schema
325
+ MacroValidator.validate!(macros, resource_key: resource_key)
326
+ rescue MacroValidator::ValidationError => error
327
+ errors.add(:macros, error.message)
328
+ end
329
+
330
+ def sync_published_at
331
+ if published?
332
+ self.published_at = Time.current if published_at.nil? || will_save_change_to_published?
333
+ else
334
+ self.published_at = nil
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class FiltersFavorite < Favorite
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class LensFavorite < Favorite
6
+ validates :layout, presence: true
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ module ViewLens
6
+ class Layout
7
+ VERSION = 1
8
+
9
+ def self.normalize(raw_layout)
10
+ layout = raw_layout.is_a?(Hash) ? raw_layout.deep_stringify_keys : {}
11
+ hidden = layout.fetch("hidden", {})
12
+ {
13
+ "version" => layout.fetch("version", VERSION),
14
+ "hidden" => {
15
+ "columns" => Array(hidden["columns"]).map(&:to_s),
16
+ "filters" => Array(hidden["filters"]).map(&:to_s),
17
+ "action_items" => Array(hidden["action_items"]).map(&:to_s),
18
+ "panels" => Array(hidden["panels"]).map(&:to_s),
19
+ "show_rows" => Array(hidden["show_rows"]).map(&:to_s)
20
+ }
21
+ }
22
+ end
23
+
24
+ def self.empty
25
+ new({})
26
+ end
27
+
28
+ def initialize(raw_layout)
29
+ @layout = self.class.normalize(raw_layout)
30
+ end
31
+
32
+ attr_reader :layout
33
+
34
+ def hidden_columns
35
+ @layout.dig("hidden", "columns")
36
+ end
37
+
38
+ def hidden_filters
39
+ @layout.dig("hidden", "filters")
40
+ end
41
+
42
+ def hidden_action_items
43
+ @layout.dig("hidden", "action_items")
44
+ end
45
+
46
+ def hidden_panels
47
+ @layout.dig("hidden", "panels")
48
+ end
49
+
50
+ def hidden_show_rows
51
+ @layout.dig("hidden", "show_rows")
52
+ end
53
+
54
+ def hidden_column?(column_id)
55
+ column_id.present? && hidden_columns.include?(column_id.to_s)
56
+ end
57
+
58
+ def hidden_filter?(filter_id)
59
+ filter_id.present? && hidden_filters.include?(filter_id.to_s)
60
+ end
61
+
62
+ def hidden_action_item?(action_name)
63
+ action_name.present? && hidden_action_items.include?(action_name.to_s)
64
+ end
65
+
66
+ def hidden_panel?(panel_id)
67
+ panel_id.present? && hidden_panels.include?(panel_id.to_s)
68
+ end
69
+
70
+ def hidden_show_row?(row_id)
71
+ row_id.present? && hidden_show_rows.include?(row_id.to_s)
72
+ end
73
+
74
+ def merge(other)
75
+ return self if other.blank?
76
+
77
+ other_layout = self.class.normalize(other.layout || other)
78
+ merged_hidden = layout.fetch("hidden", {}).merge(other_layout.fetch("hidden", {})) do |_key, left, right|
79
+ (left + right).uniq
80
+ end
81
+ self.class.new("version" => VERSION, "hidden" => merged_hidden)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ class ViewLensDefault < ApplicationRecord
6
+ include UserRecord
7
+
8
+ self.table_name = "active_admin_view_lens_defaults"
9
+
10
+ attribute :layout, :json, default: -> { ViewLens::Layout.empty.layout }
11
+
12
+ validates :resource_key, :action, presence: true
13
+ validates :action, inclusion: {in: %w[index show]}
14
+ validates :resource_key, uniqueness: {scope: [ActiveAdmin::Favorites.config.user_foreign_key, :action]}
15
+
16
+ def self.for_user(user, resource_key:, action:)
17
+ find_by(user_foreign_key_column => user.id, :resource_key => resource_key, :action => action)
18
+ end
19
+
20
+ def self.user_foreign_key_column
21
+ ActiveAdmin::Favorites.config.user_foreign_key
22
+ end
23
+
24
+ def self.upsert_for!(user:, resource_key:, action:, layout:)
25
+ record = find_or_initialize_by(
26
+ user_foreign_key_column => user.id,
27
+ :resource_key => resource_key,
28
+ :action => action
29
+ )
30
+ record.layout = ViewLens::Layout.normalize(layout)
31
+ record.save!
32
+ record
33
+ end
34
+
35
+ def layout_object
36
+ ViewLens::Layout.new(layout)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Favorites
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ <% catalog_payload = ActiveAdmin::Favorites::ViewLens::Applicator.catalog_payload(
2
+ active_admin_config: active_admin_config,
3
+ action: action_name,
4
+ render_context: local_assigns.fetch(:render_context) { self }
5
+ ) %>
6
+ <script
7
+ type="application/json"
8
+ id="favorites-lens-catalog-<%= active_admin_config.resource_name.route_key %>-<%= action_name %>"
9
+ ><%= catalog_payload.to_json %></script>
@@ -0,0 +1,53 @@
1
+ <% bookmark = f.object %>
2
+ <% template_options = ActiveAdmin::Favorites::Favorite::MacroCatalog.options_for_select %>
3
+ <%= hidden_field_tag :macro_form_submitted, "1" %>
4
+
5
+ <fieldset class="inputs">
6
+ <legend><span><%= t("active_admin.favorites.macros_panel") %></span></legend>
7
+ <p class="inline-hints"><%= t("active_admin.favorites.macros_hint") %></p>
8
+
9
+ <% if bookmark.macro_form_rows.any? %>
10
+ <ol class="space-y-4 list-none p-0">
11
+ <% bookmark.macro_form_rows.each_with_index do |row, index| %>
12
+ <li class="rounded-md border border-gray-200 p-4 dark:border-white/10">
13
+ <div class="font-medium text-sm mb-2">
14
+ <%= row[:attribute].to_s.tr("_", " ") %>
15
+ <% if row[:gteq].present? || row[:lteq].present? %>
16
+ <span class="text-gray-500">
17
+ (<%= [row[:gteq], row[:lteq]].compact.join(" – ") %>)
18
+ </span>
19
+ <% end %>
20
+ </div>
21
+ <%= hidden_field_tag "macro_template_assignments[#{index}][attribute]", row[:attribute] %>
22
+ <div class="flex flex-wrap items-center gap-4">
23
+ <label class="inline-flex items-center gap-2 text-sm">
24
+ <%= check_box_tag "macro_template_assignments[#{index}][enabled]", "1", row[:enabled], id: "macro_template_assignments_#{index}_enabled" %>
25
+ <%= t("active_admin.favorites.use_dynamic_range") %>
26
+ </label>
27
+ <%= select_tag "macro_template_assignments[#{index}][name]",
28
+ options_for_select(template_options, row[:name]),
29
+ id: "macro_template_assignments_#{index}_name",
30
+ class: "rounded-md border-gray-300 text-sm dark:border-white/20 dark:bg-gray-900" %>
31
+ </div>
32
+ </li>
33
+ <% end %>
34
+ </ol>
35
+ <% else %>
36
+ <p class="text-sm text-gray-600 dark:text-gray-300"><%= t("active_admin.favorites.no_detected_ranges") %></p>
37
+ <% end %>
38
+ </fieldset>
39
+
40
+ <details class="mt-6 rounded-md border border-gray-200 p-4 dark:border-white/10">
41
+ <summary class="cursor-pointer text-sm font-medium"><%= t("active_admin.favorites.advanced_macros") %></summary>
42
+ <div class="mt-4 space-y-3">
43
+ <label class="inline-flex items-center gap-2 text-sm">
44
+ <%= check_box_tag "favorite[use_advanced_macros]", "1", bookmark.use_advanced_macros, id: "favorite_use_advanced_macros" %>
45
+ <%= t("active_admin.favorites.use_advanced_macros") %>
46
+ </label>
47
+ <%= text_area_tag "favorite[macros_json]", bookmark.macros_json_for_form,
48
+ rows: 10,
49
+ class: "w-full font-mono text-xs rounded-md border-gray-300 dark:border-white/20 dark:bg-gray-900",
50
+ spellcheck: false %>
51
+ <p class="text-xs text-gray-500"><%= t("active_admin.favorites.macros_json_hint") %></p>
52
+ </div>
53
+ </details>
@@ -0,0 +1,69 @@
1
+ <%
2
+ stimulus_controller = ActiveAdmin::Favorites.config.stimulus_controller
3
+ layout = active_admin_favorites_layout
4
+ resource_key = active_admin_config.resource_name.route_key
5
+ hidden = layout.layout.fetch("hidden", {})
6
+ catalog_element_id = "favorites-lens-catalog-#{resource_key}-#{action_name}"
7
+ %>
8
+ <div
9
+ class="relative"
10
+ data-controller="<%= stimulus_controller %>"
11
+ data-<%= stimulus_controller %>-resource-key-value="<%= resource_key %>"
12
+ data-<%= stimulus_controller %>-action-name-value="<%= action_name %>"
13
+ data-<%= stimulus_controller %>-path-value="<%= request.path %>"
14
+ data-<%= stimulus_controller %>-catalog-element-id-value="<%= catalog_element_id %>"
15
+ data-<%= stimulus_controller %>-hidden-value="<%= hidden.to_json %>"
16
+ data-<%= stimulus_controller %>-update-url-value="<%= admin_viewlenspreferences_update_path %>"
17
+ data-<%= stimulus_controller %>-reset-url-value="<%= admin_viewlenspreferences_reset_path %>"
18
+ data-<%= stimulus_controller %>-save-lens-url-value="<%= admin_viewlenspreferences_save_lens_favorite_path %>"
19
+ data-<%= stimulus_controller %>-favorites-url-value="<%= admin_favorites_path %>"
20
+ data-<%= stimulus_controller %>-group-labels-value="<%= {
21
+ columns: t("active_admin.favorites.groups.columns", default: "Columns"),
22
+ filters: t("active_admin.favorites.groups.filters", default: "Filters"),
23
+ action_items: t("active_admin.favorites.groups.action_items", default: "Actions"),
24
+ panels: t("active_admin.favorites.groups.panels", default: "Panels"),
25
+ show_rows: t("active_admin.favorites.groups.show_rows", default: "Rows")
26
+ }.to_json %>"
27
+ data-<%= stimulus_controller %>-empty-catalog-label-value="<%= t("active_admin.favorites.no_personalization_items") %>"
28
+ >
29
+ <button
30
+ type="button"
31
+ class="action-item-button inline-flex items-center gap-2"
32
+ aria-label="<%= t("active_admin.favorites.personalize_page") %>"
33
+ data-dropdown-toggle="personalization-menu-<%= resource_key %>-<%= action_name %>"
34
+ data-dropdown-offset-distance="3"
35
+ data-dropdown-placement="bottom-end"
36
+ data-action="click-><%= stimulus_controller %>#prepareMenu"
37
+ >
38
+ <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
39
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.217.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
40
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
41
+ </svg>
42
+ <span class="sr-only"><%= t("active_admin.favorites.personalize_page") %></span>
43
+ </button>
44
+
45
+ <div
46
+ id="personalization-menu-<%= resource_key %>-<%= action_name %>"
47
+ class="z-50 hidden w-80 max-w-[calc(100vw-2rem)] rounded-md bg-white p-4 shadow-lg ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10"
48
+ role="region"
49
+ aria-label="<%= t("active_admin.favorites.personalization_region") %>"
50
+ data-<%= stimulus_controller %>-target="menu"
51
+ >
52
+ <h3 class="text-sm font-semibold mb-3"><%= t("active_admin.favorites.personalization_region") %></h3>
53
+
54
+ <div data-<%= stimulus_controller %>-target="catalogGroups"></div>
55
+
56
+ <div class="flex flex-col gap-2 border-t border-gray-200 pt-3 dark:border-white/10">
57
+ <button type="button" class="action-item-button w-full" data-action="<%= stimulus_controller %>#saveDefault">
58
+ <%= t("active_admin.favorites.save_default") %>
59
+ </button>
60
+ <button type="button" class="action-item-button w-full" data-action="<%= stimulus_controller %>#resetDefault">
61
+ <%= t("active_admin.favorites.reset_default") %>
62
+ </button>
63
+ <button type="button" class="action-item-button w-full" data-action="<%= stimulus_controller %>#saveLensFavorite">
64
+ <%= t("active_admin.favorites.save_lens_favorite") %>
65
+ </button>
66
+ <%= link_to t("active_admin.favorites.menu"), admin_favorites_path, class: "text-sm text-center no-underline hover:underline" %>
67
+ </div>
68
+ </div>
69
+ </div>