rails_admin_next 1.2.1 → 2.0.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/README.md +32 -20
  4. data/app/assets/stylesheets/rails_admin/skin.css +12 -0
  5. data/app/controllers/rails_admin_next/main_controller.rb +53 -7
  6. data/app/helpers/rails_admin_next/form_builder.rb +23 -4
  7. data/app/helpers/rails_admin_next/main_helper.rb +12 -1
  8. data/app/views/layouts/rails_admin_next/_head.html.erb +1 -1
  9. data/app/views/layouts/rails_admin_next/_navigation.html.erb +1 -1
  10. data/app/views/layouts/rails_admin_next/application.html.erb +3 -0
  11. data/app/views/rails_admin_next/main/_form_nested_many.html.erb +9 -4
  12. data/app/views/rails_admin_next/main/_form_nested_one.html.erb +8 -3
  13. data/app/views/rails_admin_next/main/bulk_delete.html.erb +1 -1
  14. data/app/views/rails_admin_next/main/dashboard.html.erb +52 -50
  15. data/app/views/rails_admin_next/main/index.html.erb +3 -2
  16. data/docs/UPGRADING.md +263 -9
  17. data/docs/security.md +4 -0
  18. data/lib/generators/rails_admin_next/templates/initializer.erb +7 -3
  19. data/lib/rails_admin_next/abstract_model.rb +12 -4
  20. data/lib/rails_admin_next/adapters/active_record.rb +47 -3
  21. data/lib/rails_admin_next/config/actions/base.rb +0 -1
  22. data/lib/rails_admin_next/config/actions/bulk_delete.rb +4 -4
  23. data/lib/rails_admin_next/config/actions/index.rb +3 -19
  24. data/lib/rails_admin_next/config/actions.rb +5 -12
  25. data/lib/rails_admin_next/config/fields/association.rb +1 -1
  26. data/lib/rails_admin_next/config/fields/types/enum.rb +8 -2
  27. data/lib/rails_admin_next/config/fields/types/serialized.rb +17 -2
  28. data/lib/rails_admin_next/config/fields/types/timestamp.rb +1 -0
  29. data/lib/rails_admin_next/config/has_fields.rb +1 -1
  30. data/lib/rails_admin_next/config.rb +29 -9
  31. data/lib/rails_admin_next/extensions/action_policy/authorization_adapter.rb +60 -0
  32. data/lib/rails_admin_next/extensions/action_policy.rb +5 -0
  33. data/lib/rails_admin_next/extensions/paper_trail/auditing_adapter.rb +1 -1
  34. data/lib/rails_admin_next/extensions/pundit/authorization_adapter.rb +17 -1
  35. data/lib/rails_admin_next/support/composite_keys_serializer.rb +8 -2
  36. data/lib/rails_admin_next/support/csv_converter.rb +45 -5
  37. data/lib/rails_admin_next/version.rb +3 -3
  38. data/lib/rails_admin_next.rb +5 -17
  39. data/skills/rails-admin-next/SKILL.md +5 -4
  40. data/src/rails_admin/controllers/nested_form_controller.js +1 -1
  41. metadata +3 -3
  42. data/lib/rails_admin_next/extensions/cancancan/authorization_adapter.rb +0 -90
  43. data/lib/rails_admin_next/extensions/cancancan.rb +0 -5
data/docs/UPGRADING.md CHANGED
@@ -8,9 +8,85 @@ the one-time changes a host app needs.
8
8
 
9
9
  The Ruby namespace is renamed **`RailsAdmin` → `RailsAdminNext`** (and `rails_admin` →
10
10
  `rails_admin_next`), so your initializer, engine mount, model macros, and route helpers
11
- need a mechanical rename. The **authorization contract is deliberately unchanged** — the
12
- `:access, :rails_admin` subject and the `admin.*` I18n namespace stay exactly as they were,
13
- so your CanCanCan abilities, Pundit policies, and translations need no edits.
11
+ need a mechanical rename. The **authorization contract is deliberately unchanged** — every
12
+ action's authorization key and the `admin.*` I18n namespace stay exactly as they were, so
13
+ your Pundit policies and translations need no edits. CanCanCan is no longer a built-in
14
+ adapter; see the 2.0 section below.
15
+
16
+ ## 2.0: CanCanCan support removed
17
+
18
+ Pundit and ActionPolicy are the built-in authorization adapters.
19
+ `config.authorize_with :cancancan` now raises `ArgumentError` while the initializer loads,
20
+ listing the registered adapters. Any other unregistered key does the same. Move to Pundit,
21
+ move to ActionPolicy (see [ActionPolicy adapter](#actionpolicy-adapter)), or keep CanCanCan
22
+ with a custom adapter.
23
+
24
+ ### Move to Pundit
25
+
26
+ ```ruby
27
+ RailsAdminNext.config do |config|
28
+ config.authorize_with :pundit
29
+ end
30
+ ```
31
+
32
+ - **`ApplicationPolicy`.** The adapter falls back to `::ApplicationPolicy` for the dashboard
33
+ (record `nil`) and for any model without its own policy.
34
+ - **Action predicates.** The adapter calls each action's authorization key with a `?`
35
+ suffix: `dashboard?`, `index?`, `show?`, `new?`, `edit?`, `export?`, `destroy?`,
36
+ `history?` and `show_in_app?`. Saving a new record also calls `create?`, and saving an
37
+ edit calls `update?`. Member actions pass the record; list-level checks pass the class.
38
+ `edit?` and `destroy?` also get the class: the form's "Save and edit" button checks
39
+ `edit?`, and the index's bulk-delete menu checks `destroy?`. A predicate that reads record
40
+ attributes must handle a class, for example `!record.is_a?(Class) && record.active?`.
41
+ - **A `Scope` for every model the admin lists.** A model whose policy resolves no `Scope`
42
+ lists nothing (see
43
+ [Pundit: a model with no policy Scope lists nothing](#pundit-a-model-with-no-policy-scope-lists-nothing)).
44
+ A policy that subclasses `ApplicationPolicy` without its own `Scope` inherits
45
+ `ApplicationPolicy::Scope`, so that class's `resolve` decides what the model lists.
46
+ - **Optional `attributes_for(action)`** on a policy returns the attributes a new record
47
+ starts with, for example `{suspended: true}` for `:new`.
48
+
49
+ Three differences from CanCanCan to check before switching:
50
+
51
+ - **One `Scope` bounds every list.** The adapter passes no action to the `Scope`, so the same
52
+ scope bounds index, export, bulk delete and the dashboard count. Bulk destroy deletes every
53
+ selected record that `Scope` returns, with no per-record `destroy?` check. If an ability
54
+ allowed destroy or export on fewer records than read, resolve the `Scope` to the narrowest
55
+ of them, or stay on a custom adapter whose `query` honours the action.
56
+ - **Nothing checks `:access, :rails_admin`.** Admin access is decided by the same policies,
57
+ and the `ApplicationPolicy` fallback, that the rest of the app uses. If those admit
58
+ ordinary users, gate admin entry separately: in `config.authenticate_with`, or with
59
+ admin-specific `authorization_key` values per action.
60
+ - **The block form only gates entry.** `config.authorize_with { ... }` runs before every
61
+ action, but it checks no records and does not scope lists.
62
+
63
+ ### Keep CanCanCan with a custom adapter
64
+
65
+ Register an adapter before the `RailsAdminNext.config` block that names its key, or the
66
+ `ArgumentError` above fires at boot:
67
+
68
+ ```ruby
69
+ RailsAdminNext.add_extension(:cancancan, MyApp::CanCanCanAdmin, authorization: true)
70
+
71
+ RailsAdminNext.config do |config|
72
+ config.authorize_with :cancancan
73
+ end
74
+ ```
75
+
76
+ The module defines an `AuthorizationAdapter` class. `authorize_with` builds it with
77
+ `new(controller, *args, &block)` and it answers:
78
+
79
+ - `authorize(action, abstract_model, object)`: raises when the action is denied.
80
+ - `authorized?(action, abstract_model, object)`: returns a boolean, for links and buttons.
81
+ - `query(action, abstract_model)`: returns the scope lists, export and bulk actions read from.
82
+ - `attributes_for(action, abstract_model)`: returns the attributes a new record starts with.
83
+ - `self.setup` (optional): runs once when `authorize_with` names the key.
84
+
85
+ `lib/rails_admin_next/extensions/pundit/authorization_adapter.rb` in the gem is the template.
86
+ The removed CanCanCan adapter is a starting point you can copy:
87
+ [v1.3.0 `extensions/cancancan/authorization_adapter.rb`](https://github.com/6temes/rails_admin_next/blob/v1.3.0/lib/rails_admin_next/extensions/cancancan/authorization_adapter.rb),
88
+ with its module in `extensions/cancancan.rb` at the same tag. Abilities keep granting
89
+ `can :access, :rails_admin`.
14
90
 
15
91
  ## 1. Gemfile
16
92
 
@@ -119,19 +195,19 @@ engine no longer provides it.
119
195
 
120
196
  ## Authorization (no change needed)
121
197
 
122
- The authorization contract is held stable — **do not touch your abilities or policies**:
198
+ The authorization contract is held stable — **do not touch your policies**:
123
199
 
124
- - The access subject is `:rails_admin`.
125
200
  - Every action's authorization key (`:dashboard`, `:index`, `:show`, `:new`, `:edit`,
126
201
  `:export`, `:destroy`, `:history`, `:show_in_app`) is unchanged.
202
+ - A custom adapter keeps the `:access, :rails_admin` subject by convention.
127
203
 
128
204
  ```ruby
129
- # This CanCanCan ability keeps working unchanged:
205
+ # A custom CanCanCan adapter's ability still grants access with:
130
206
  can :access, :rails_admin
131
207
  ```
132
208
 
133
- The access subject is deliberately frozen and locked by a spec in the gem — a renamed
134
- subject can fail *open* (a `cannot` rule that no longer matches silently grants access).
209
+ The authorization keys are locked by a spec in the gem — a renamed key can fail *open* (a
210
+ deny rule that no longer matches silently grants access).
135
211
 
136
212
  ## Asset prerequisites
137
213
 
@@ -398,6 +474,19 @@ end
398
474
 
399
475
  Note the block: a bare `inverse_of nil` reads the option rather than setting it.
400
476
 
477
+ ## Nested forms that can reach themselves stop at three levels
478
+
479
+ A nested form for an association that leads back to its own model, such as a self-referential
480
+ `has_many :children` with `accepts_nested_attributes_for`, used to render without end and fail
481
+ with `SystemStackError`, as did saved records that point back at each other. It now nests the
482
+ same association at most three times along one chain; past that, the association's rows, blank
483
+ template and add control are left out of the form, so a deeper tree is edited from a lower
484
+ record. Chains of distinct associations are not capped.
485
+
486
+ The blank row's child-index placeholder is tagged by depth, `new_<depth>_<association>` (for
487
+ example `new_1_comments`), rather than `new_<association>`. Host JavaScript that looked for the
488
+ old token in a nested form's `<template>` needs the new one.
489
+
401
490
  ## The show action answers HTML by default
402
491
 
403
492
  `show` used to declare its JSON format before its HTML one, so a request carrying no
@@ -431,12 +520,26 @@ ignored.
431
520
 
432
521
  If a client relied on a column the section doesn't show, add that field to the section.
433
522
 
523
+ ## Malformed filter params are ignored
524
+
525
+ The index page used to answer a hand-written or stale `f[...]` query string with a 500: a
526
+ filter that wasn't the `field => index => {o:, v:}` shape the filter box submits, one naming
527
+ a field that isn't filterable (`is not currently filterable`), or a value the field couldn't
528
+ parse, such as an impossible date or malformed JSON. Such a filter is now dropped and the
529
+ rest of the list renders. A value that fails to parse counts as no value, so a bookmarked
530
+ link to a field that has since stopped being filterable lists every record rather than
531
+ erroring.
532
+
434
533
  ## Pundit: a model with no policy Scope lists nothing
435
534
 
436
535
  A model whose policy defines no `Scope` used to list every record. It now lists none: the
437
536
  list, export, bulk delete and dashboard count all return an empty set. Bulk delete deletes
438
537
  whatever the scope returns, with no per-record check, so "no Scope" can't mean "everything".
439
538
 
539
+ This holds only when no `Scope` constant resolves. A policy that subclasses `ApplicationPolicy`
540
+ without its own `Scope` inherits `ApplicationPolicy::Scope`: Pundit's generated `resolve` there
541
+ raises `NoMethodError`, and one that returns `scope.all` lists every record.
542
+
440
543
  Define a `Scope` for each model the admin exposes:
441
544
 
442
545
  ```ruby
@@ -449,7 +552,154 @@ class PlayerPolicy < ApplicationPolicy
449
552
  end
450
553
  ```
451
554
 
452
- CanCanCan hosts are unaffected.
555
+ ## ActionPolicy adapter
556
+
557
+ [ActionPolicy](https://github.com/palkan/action_policy) is a built-in adapter next to Pundit.
558
+ Bundle `action_policy` in your app, then:
559
+
560
+ ```ruby
561
+ RailsAdminNext.config do |config|
562
+ config.authorize_with :action_policy
563
+ config.current_user_method(&:current_user)
564
+ end
565
+ ```
566
+
567
+ The adapter authorizes as the user `current_user_method` returns. It checks the record's
568
+ policy with the rule names the Pundit adapter uses: `dashboard?`, `index?`, `show?`, `new?`,
569
+ `edit?`, `export?`, `destroy?`, `history?`, `show_in_app?`, plus `create?` and `update?` when a
570
+ form is submitted, and `<authorization_key>?` for custom actions. Two ActionPolicy defaults
571
+ change how a ported Pundit policy behaves:
572
+
573
+ - `new?` is an alias of `create?` unless the policy defines `new?` itself.
574
+ - A rule the policy does not define resolves to `manage?`, which is `false` on
575
+ `ActionPolicy::Base`, instead of raising.
576
+
577
+ A model with no policy, and the dashboard, are checked against `ApplicationPolicy`. Lists,
578
+ export, bulk delete and dashboard counts use the policy's default `relation_scope`; a model
579
+ with no policy, no relation scope, or only named ones lists nothing. To prefill new records,
580
+ define `attributes_for(action)` on the policy, as with Pundit.
581
+
582
+ ```ruby
583
+ class ApplicationPolicy < ActionPolicy::Base
584
+ def dashboard? = user.admin?
585
+ def index? = user.admin?
586
+ def show? = user.admin?
587
+ def create? = user.admin?
588
+ def update? = user.admin?
589
+ alias_rule :edit?, to: :update?
590
+ def destroy? = user.admin?
591
+ def export? = user.admin?
592
+ end
593
+
594
+ class PlayerPolicy < ApplicationPolicy
595
+ relation_scope { |relation| relation }
596
+ end
597
+ ```
598
+
599
+ ## An `:enum` field with no option source offers no options
600
+
601
+ An `:enum` field takes its options from `enum_method`, which defaults to `<name>_enum` on the
602
+ model class or the record. When neither defines it, the field used to call `<name>` itself and
603
+ treat the record's current value as the option list: the index filter crashed, and the form
604
+ offered that value instead of choices. It now offers no options.
605
+
606
+ If a field's own method returns its option list, name it explicitly:
607
+
608
+ ```ruby
609
+ field :colors, :enum do
610
+ enum_method :colors
611
+ end
612
+ ```
613
+
614
+ ## A redeclared field keeps its `visible` block
615
+
616
+ Declaring a field again in the same section used to discard a `visible { ... }` block set by an
617
+ earlier declaration and show the field to everyone. The block now survives:
618
+
619
+ ```ruby
620
+ field :salary do
621
+ visible { bindings[:view].current_user.admin? }
622
+ end
623
+ field :salary # still hidden from non-admins
624
+ ```
625
+
626
+ If a field you hide conditionally started showing for everyone after a later `field` call, it is
627
+ hidden again. A field hidden with a plain `visible false` is still shown again by a later
628
+ declaration, as before.
629
+
630
+ ## Serialized fields load only plain YAML
631
+
632
+ A `serialized` field (the default for a `store` or `serialize` attribute) now shows its value
633
+ as plain YAML, without `!ruby/...` class tags, and loads the submitted YAML with only `Symbol`,
634
+ `Date` and `Time` permitted. A store's `HashWithIndifferentAccess` comes back as a plain `Hash`,
635
+ which the store wraps again on assignment. YAML naming any other class is rejected.
636
+
637
+ The `safe_yaml` gem is no longer consulted, even when the host bundles it.
638
+ `RailsAdminNext.yaml_load` and `RailsAdminNext.yaml_dump` are deprecated: call
639
+ `YAML.safe_load` and `YAML.dump` directly.
640
+
641
+ ## `config.model "::Order"` configures `Order`
642
+
643
+ A model named as a string or symbol with a leading `::` used to register a second, separate
644
+ entry: its block never reached `Order`, and `Order` appeared twice in the navigation. The `::`
645
+ is now dropped, so the block applies to `Order`. If such a block was silently doing nothing,
646
+ check that what it configures is what you want before upgrading. `included_models` and
647
+ `excluded_models` drop a leading `::` the same way.
648
+
649
+ ## Scope tabs apply to export and bulk delete
650
+
651
+ The tab selected on a list (`list { scopes [...] }`) now also narrows export and bulk
652
+ delete: "Export found" streams only the tab's records, and bulk delete lists and destroys
653
+ only the tab's records, whatever ids are submitted. The bulk form and the confirmation
654
+ form carry `scope` so the tab survives the round trip.
655
+
656
+ A tabbed collection stays inside the authorization scope, so a tab that drops conditions,
657
+ such as `unscoped`, cannot list, export or delete records the user may not see. It still
658
+ drops the model's own configured `scope`. The authorization scope is built on the model's
659
+ `default_scope`, so an `unscoped` tab no longer reaches records `default_scope` hides: a
660
+ soft-delete "trash" tab built on `unscoped` comes up empty. Give such a tab a scope the
661
+ authorization relation also reaches.
662
+
663
+ The `scopes` block is now evaluated with `bindings[:controller]`, `bindings[:view]` and
664
+ `bindings[:object]`, so tabs can depend on the current user:
665
+
666
+ ```ruby
667
+ config.model "Order" do
668
+ list do
669
+ scopes { bindings[:controller].current_user.admin? ? [nil, :refunded] : [nil] }
670
+ end
671
+ end
672
+ ```
673
+
674
+ `list_entries` keeps its four positional arguments, but applies a tab only for the page's
675
+ own model, its `additional_scope` defaults to `nil`, and it no longer reads the
676
+ `associated_collection` picker params. Custom actions that listed picker records through
677
+ it should call `association_entries(model_config, collection_scope)` instead.
678
+
679
+ ## `associated_collection_scope` runs after the list sort
680
+
681
+ Association pickers apply `associated_collection_scope` after the picked model's list sort,
682
+ so a `reorder` in it now decides the order. A plain `order` adds a secondary sort key after
683
+ the list sort. Build on the relation the proc receives: returning a fresh relation discards
684
+ the authorization scope, the picker's search query, sort and eager loads as well. Pickers
685
+ never apply the picked model's scope tabs.
686
+
687
+ ## CSV export follows the list sort
688
+
689
+ CSV export used to walk records with `find_each`, which discards the list sort, so every file
690
+ came out in primary-key ascending order. It now writes rows in the order the list shows them:
691
+ the sort the export request carries, or the list's default sort. For most models that default is
692
+ the primary key descending, so an export with no sort chosen is now newest first.
693
+
694
+ Rows are read in batches of 1000 instead of loading every record up front. An export with no
695
+ matching records now contains the header row instead of an empty line.
696
+
697
+ ## Admin pages show a footer
698
+
699
+ Every admin page now ends with a small, muted "Made in Tokyo with ❤️ and 🤖" line under the page
700
+ content. It is not shown inside modals. To hide it, set `config.show_footer = false` in your
701
+ initializer.
702
+
453
703
  ## Removed: options deprecated before RailsAdmin 3.0
454
704
 
455
705
  These six were deprecated upstream between 2019-07 and 2022-01 and had no effect for
@@ -493,3 +743,7 @@ These are intentionally gone — migrate off them before upgrading:
493
743
 
494
744
  The gem owns **no** history table. Auditing remains host/PaperTrail territory, so there is
495
745
  no gem migration to run — your existing `rails_admin_histories` table (if any) is unaffected.
746
+
747
+ A record's history page now honours its column sort, and with no sort it lists newest
748
+ first, like the model history page. It used to list a record's versions oldest first
749
+ whatever the sort.
data/docs/security.md CHANGED
@@ -16,6 +16,10 @@ destructive-action controls itself; a few things are left for the host to opt in
16
16
  can't name a hidden column, an association the export doesn't show, or a method to call.
17
17
  - **Pundit scopes fail closed.** A model whose policy has no `Scope` lists no records, rather
18
18
  than all of them.
19
+ - **ActionPolicy scopes fail closed.** A model with no policy, or whose policy has no default
20
+ `relation_scope`, lists no records. A rule the policy does not define (`history?`,
21
+ `show_in_app?`, `export?`, a custom authorization key) resolves to the policy's `manage?`, so a
22
+ permissive `manage?` grants every admin action the policy leaves undefined.
19
23
  - **Destructive actions.** `delete` and `bulk_delete` render a confirmation page and additionally
20
24
  carry `data-turbo-confirm` on the destructive submit (Turbo's confirmation, not `@rails/ujs`).
21
25
  - **Mass assignment.** Submitted attributes are sliced to the model config's `visible_fields`
@@ -7,12 +7,12 @@ RailsAdminNext.config do |config|
7
7
  # end
8
8
  # config.current_user_method(&:current_user)
9
9
 
10
- ## == CancanCan ==
11
- # config.authorize_with :cancancan
12
-
13
10
  ## == Pundit ==
14
11
  # config.authorize_with :pundit
15
12
 
13
+ ## == ActionPolicy ==
14
+ # config.authorize_with :action_policy
15
+
16
16
  ## == PaperTrail ==
17
17
  # config.audit_with :paper_trail, 'User', 'PaperTrail::Version' # PaperTrail >= 3.0.0
18
18
 
@@ -22,6 +22,10 @@ RailsAdminNext.config do |config|
22
22
  ## To disable Gravatar integration in Navigation Bar set to false
23
23
  # config.show_gravatar = true
24
24
 
25
+ ## == Footer ==
26
+ ## To hide the "Made in Tokyo" footer under admin pages set to false
27
+ # config.show_footer = true
28
+
25
29
  config.actions do
26
30
  dashboard # mandatory
27
31
  index # mandatory
@@ -118,8 +118,16 @@ module RailsAdminNext
118
118
  extend Adapters::ActiveRecord
119
119
  end
120
120
 
121
+ # A value the field can't parse (an impossible date, malformed JSON or YAML) is
122
+ # treated as no value, element by element in an array. Only parse errors are
123
+ # rescued, so a bug in a custom parse_value still raises. Date::Error is an
124
+ # ArgumentError.
121
125
  def parse_field_value(field, value)
122
- value.is_a?(Array) ? value.map { |v| field.parse_value(v) } : field.parse_value(value)
126
+ return value.map { |v| parse_field_value(field, v) } if value.is_a?(Array)
127
+
128
+ field.parse_value(value)
129
+ rescue ArgumentError, JSON::ParserError, Psych::Exception
130
+ nil
123
131
  end
124
132
 
125
133
  class StatementBuilder
@@ -242,7 +250,7 @@ module RailsAdminNext
242
250
  end
243
251
 
244
252
  def today
245
- [Date.today, Date.today]
253
+ [Date.current, Date.current]
246
254
  end
247
255
 
248
256
  def yesterday
@@ -250,7 +258,7 @@ module RailsAdminNext
250
258
  end
251
259
 
252
260
  def this_week
253
- [Date.today.beginning_of_week, Date.today.end_of_week]
261
+ [Date.current.beginning_of_week, Date.current.end_of_week]
254
262
  end
255
263
 
256
264
  def last_week
@@ -259,7 +267,7 @@ module RailsAdminNext
259
267
  end
260
268
 
261
269
  def between
262
- [@value[1], @value[2]]
270
+ [Array.wrap(@value)[1], Array.wrap(@value)[2]]
263
271
  end
264
272
 
265
273
  def default
@@ -182,7 +182,10 @@ module RailsAdminNext
182
182
 
183
183
  def add(field, value, operator)
184
184
  field.searchable_columns.flatten.each do |column_infos|
185
- statement, value1, value2 = StatementBuilder.new(quote(column_infos[:column]), column_infos[:type], value, operator, @scope.connection.adapter_name).to_statement
185
+ attribute_type = attribute_type(field, column_infos[:column])
186
+ statement, value1, value2 = StatementBuilder.new(quote(column_infos[:column]), column_infos[:type], value, operator, @scope.connection.adapter_name, column_type: attribute_type&.type).to_statement
187
+ statement, value1, value2 = "(1 = 0)" if out_of_range?(attribute_type, [value1, value2])
188
+
186
189
  @statements << statement if statement.present?
187
190
  @values << value1 unless value1.nil?
188
191
  @values << value2 unless value2.nil?
@@ -202,6 +205,29 @@ module RailsAdminNext
202
205
  "#{@scope.connection.quote_table_name(table)}.#{@scope.connection.quote_column_name(name)}"
203
206
  end
204
207
 
208
+ # The column's own attribute type, resolved from the field's model or, for an
209
+ # association, the associated model. Nil for a column on any other table.
210
+ def attribute_type(field, column)
211
+ table, name = column.split(".")
212
+ return unless name
213
+
214
+ models = [field.abstract_model]
215
+ models.concat(Array.wrap(field.associated_model_config).map(&:abstract_model)) if field.association?
216
+ models.map(&:model).detect { it.table_name == table }&.type_for_attribute(name)
217
+ end
218
+
219
+ # A number too wide for the column (an int4 searched with a 10-digit term) makes
220
+ # PostgreSQL reject the whole query. No row can hold it, so the column matches
221
+ # nothing instead: a search still matches the other columns, a filter matches none.
222
+ def out_of_range?(attribute_type, values)
223
+ return false unless attribute_type
224
+
225
+ values.grep(Integer).each { attribute_type.serialize(it) }
226
+ false
227
+ rescue ActiveModel::RangeError
228
+ true
229
+ end
230
+
205
231
  def build
206
232
  scope = @scope.where(@statements.join(" OR "), *@values)
207
233
  scope = scope.references(*@tables.uniq) if @tables.any?
@@ -225,11 +251,20 @@ module RailsAdminNext
225
251
 
226
252
  # filters example => {"string_field"=>{"0055"=>{"o"=>"like", "v"=>"test_value"}}, ...}
227
253
  # "0055" is the filter index, no use here. o is the operator, v the value
254
+ # Hand-written or stale params can have any shape, so anything that isn't
255
+ # field => index => filter with a scalar (or flat array) value, or names no
256
+ # filterable field, is skipped.
228
257
  def filter_scope(scope, filters, fields = config.list.fields.select(&:filterable?))
258
+ return scope unless filters.respond_to?(:each_pair)
259
+
229
260
  filters.each_pair do |field_name, filters_dump|
261
+ field = fields.detect { |f| f.name.to_s == field_name }
262
+ next unless field && filters_dump.respond_to?(:each_value)
263
+
230
264
  filters_dump.each_value do |filter_dump|
265
+ next unless filter_dump.respond_to?(:each_pair) && filter_value?(filter_dump[:v])
266
+
231
267
  wb = WhereBuilder.new(scope)
232
- field = fields.detect { |f| f.name.to_s == field_name }
233
268
  value = parse_field_value(field, filter_dump[:v])
234
269
 
235
270
  wb.add(field, value, filter_dump[:o] || RailsAdminNext::Config.default_search_operator)
@@ -240,14 +275,19 @@ module RailsAdminNext
240
275
  scope
241
276
  end
242
277
 
278
+ def filter_value?(value)
279
+ Array.wrap(value).none? { it.is_a?(Array) || it.respond_to?(:each_pair) }
280
+ end
281
+
243
282
  def build_statement(column, type, value, operator)
244
283
  StatementBuilder.new(column, type, value, operator, model.connection.adapter_name).to_statement
245
284
  end
246
285
 
247
286
  class StatementBuilder < RailsAdminNext::AbstractModel::StatementBuilder
248
- def initialize(column, type, value, operator, adapter_name)
287
+ def initialize(column, type, value, operator, adapter_name, column_type: nil)
249
288
  super(column, type, value, operator)
250
289
  @adapter_name = adapter_name
290
+ @column_type = column_type
251
291
  end
252
292
 
253
293
  protected
@@ -260,6 +300,9 @@ module RailsAdminNext
260
300
  uuid_unary_operators
261
301
  when :integer, :decimal, :float
262
302
  numeric_unary_operators
303
+ when :enum
304
+ # Only a string column can hold '' (and an integer one rejects the comparison).
305
+ %i[string text citext].include?(@column_type) ? generic_unary_operators : enum_unary_operators
263
306
  else
264
307
  generic_unary_operators
265
308
  end
@@ -288,6 +331,7 @@ module RailsAdminNext
288
331
  end
289
332
  alias_method :numeric_unary_operators, :boolean_unary_operators
290
333
  alias_method :uuid_unary_operators, :boolean_unary_operators
334
+ alias_method :enum_unary_operators, :boolean_unary_operators
291
335
 
292
336
  def range_filter(min, max)
293
337
  if min && max && min == max
@@ -104,7 +104,6 @@ module RailsAdminNext
104
104
  key.to_sym
105
105
  end
106
106
 
107
- # For CanCanCan and the like
108
107
  register_instance_option :authorization_key do
109
108
  key.to_sym
110
109
  end
@@ -20,7 +20,7 @@ module RailsAdminNext
20
20
  @objects = list_entries(@model_config, :destroy)
21
21
 
22
22
  if @objects.blank?
23
- flash[:error] = t("admin.flash.error", name: pluralize(0, @model_config.label), action: t("admin.actions.delete.done"))
23
+ flash[:error] = t("admin.flash.error", name: pluralize(0, @model_config.label, plural: @model_config.label_plural), action: t("admin.actions.delete.done"))
24
24
  redirect_to index_path
25
25
  else
26
26
  render @action.template_name
@@ -49,10 +49,10 @@ module RailsAdminNext
49
49
  end
50
50
 
51
51
  if destroyed.nil?
52
- flash[:error] = t("admin.flash.error", name: pluralize(0, @model_config.label), action: t("admin.actions.delete.done"))
52
+ flash[:error] = t("admin.flash.error", name: pluralize(0, @model_config.label, plural: @model_config.label_plural), action: t("admin.actions.delete.done"))
53
53
  else
54
- flash[:success] = t("admin.flash.successful", name: pluralize(destroyed.count, @model_config.label), action: t("admin.actions.delete.done")) unless destroyed.empty?
55
- flash[:error] = t("admin.flash.error", name: pluralize(not_destroyed.count, @model_config.label), action: t("admin.actions.delete.done")) unless not_destroyed.empty?
54
+ flash[:success] = t("admin.flash.successful", name: pluralize(destroyed.count, @model_config.label, plural: @model_config.label_plural), action: t("admin.actions.delete.done")) unless destroyed.empty?
55
+ flash[:error] = t("admin.flash.error", name: pluralize(not_destroyed.count, @model_config.label, plural: @model_config.label_plural), action: t("admin.actions.delete.done")) unless not_destroyed.empty?
56
56
  end
57
57
  redirect_to back_or_index
58
58
  end
@@ -30,26 +30,10 @@ module RailsAdminNext
30
30
 
31
31
  register_instance_option :controller do
32
32
  proc do
33
- # Resolve the active list scope (the scope tabs) and fold it into the
34
- # relation *before* pagination. The paginated collection is a resolved
35
- # page, not a chainable relation, so a named scope can no longer be
36
- # appended afterwards.
37
33
  unless @objects
38
- scopes = @model_config.list.scopes
39
- list_scope =
40
- if scopes.empty? || params[:scope].blank?
41
- scopes.first
42
- elsif scopes.collect(&:to_s).include?(params[:scope])
43
- params[:scope].to_sym
44
- end
45
-
46
- additional_scope = get_association_scope_from_params
47
- if list_scope
48
- base_scope = additional_scope
49
- additional_scope = proc { (base_scope ? instance_eval(&base_scope) : self).send(list_scope) }
50
- end
51
-
52
- @objects = list_entries(@model_config, :index, additional_scope)
34
+ # @association is set only when the picker guard accepts associated_collection; otherwise list normally.
35
+ association_scope = get_association_scope_from_params
36
+ @objects = @association ? association_entries(@model_config, association_scope) : list_entries
53
37
  end
54
38
 
55
39
  respond_to do |format|
@@ -49,12 +49,8 @@ module RailsAdminNext
49
49
 
50
50
  def add_action(key, parent_class, parent, &)
51
51
  a = "RailsAdminNext::Config::Actions::#{parent_class.to_s.camelize}".constantize.new
52
- a.instance_eval(%(
53
- #{parent} true
54
- def key
55
- :#{key}
56
- end
57
- ), __FILE__, __LINE__ - 5)
52
+ a.send(parent, true)
53
+ a.define_singleton_method(:key) { key.to_sym }
58
54
  add_action_custom_key(a, &)
59
55
  end
60
56
 
@@ -68,12 +64,9 @@ module RailsAdminNext
68
64
  name = klass.to_s.demodulize.underscore.to_sym
69
65
  end
70
66
 
71
- instance_eval %{
72
- def #{name}(&block)
73
- action = #{klass}.new
74
- add_action_custom_key(action, &block)
75
- end
76
- }, __FILE__, __LINE__ - 5
67
+ define_singleton_method(name) do |&block|
68
+ add_action_custom_key(klass.new, &block)
69
+ end
77
70
  end
78
71
 
79
72
  private
@@ -137,7 +137,7 @@ module RailsAdminNext
137
137
 
138
138
  # Returns collection of all selectable records
139
139
  def collection(scope = nil)
140
- (scope || bindings[:controller].list_entries(associated_model_config, :index, associated_collection_scope, false))
140
+ (scope || bindings[:controller].association_entries(associated_model_config, associated_collection_scope))
141
141
  .map { |o| [o.send(associated_object_label_method), format_key(o.send(associated_primary_key)).to_s] }
142
142
  end
143
143
 
@@ -21,11 +21,17 @@ module RailsAdminNext
21
21
  end
22
22
 
23
23
  register_instance_option :enum_method do
24
- @enum_method ||= (bindings[:object].class.respond_to?("#{name}_enum") || (bindings[:object] || abstract_model.model.new).respond_to?("#{name}_enum")) ? "#{name}_enum" : name
24
+ @enum_method ||= begin
25
+ method_name = "#{name}_enum"
26
+ method_name if (bindings[:object]&.class || abstract_model.model).respond_to?(method_name) ||
27
+ (bindings[:object] || abstract_model.model.new).respond_to?(method_name)
28
+ end
25
29
  end
26
30
 
27
31
  register_instance_option :enum do
28
- if abstract_model.model.respond_to?(enum_method)
32
+ if enum_method.nil?
33
+ []
34
+ elsif abstract_model.model.respond_to?(enum_method)
29
35
  abstract_model.model.send(enum_method)
30
36
  else
31
37
  (bindings[:object] || abstract_model.model.new).send(enum_method)
@@ -10,17 +10,32 @@ module RailsAdminNext
10
10
  # Register field type for the type loader
11
11
  RailsAdminNext::Config::Fields::Types.register(self)
12
12
 
13
+ # The YAML is admin-supplied, so loading permits no class beyond the scalars a
14
+ # plain value needs. Dumping strips Hash/Array subclasses (a store's
15
+ # HashWithIndifferentAccess) so what the form shows can be loaded back.
16
+ PERMITTED_CLASSES = [::Date, ::Symbol, ::Time].freeze
17
+
13
18
  register_instance_option :formatted_value do
14
- RailsAdminNext.yaml_dump(value) unless value.nil?
19
+ YAML.dump(plain(value)) unless value.nil?
15
20
  end
16
21
 
17
22
  def parse_value(value)
18
- value.present? ? (RailsAdminNext.yaml_load(value) || nil) : nil
23
+ value.present? ? (YAML.safe_load(value, permitted_classes: PERMITTED_CLASSES) || nil) : nil
19
24
  end
20
25
 
21
26
  def parse_input(params)
22
27
  params[name] = parse_value(params[name]) if params[name].is_a?(::String)
23
28
  end
29
+
30
+ private
31
+
32
+ def plain(object)
33
+ case object
34
+ when Hash then object.to_h { |key, value| [plain(key), plain(value)] }
35
+ when Array then object.map { plain(it) }
36
+ else object
37
+ end
38
+ end
24
39
  end
25
40
  end
26
41
  end