rails_admin_next 1.2.1 → 1.3.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/rails_admin/skin.css +4 -0
  3. data/app/controllers/rails_admin_next/main_controller.rb +53 -7
  4. data/app/helpers/rails_admin_next/form_builder.rb +23 -4
  5. data/app/helpers/rails_admin_next/main_helper.rb +12 -1
  6. data/app/views/layouts/rails_admin_next/_head.html.erb +1 -1
  7. data/app/views/layouts/rails_admin_next/_navigation.html.erb +1 -1
  8. data/app/views/rails_admin_next/main/_form_nested_many.html.erb +9 -4
  9. data/app/views/rails_admin_next/main/_form_nested_one.html.erb +8 -3
  10. data/app/views/rails_admin_next/main/bulk_delete.html.erb +1 -1
  11. data/app/views/rails_admin_next/main/dashboard.html.erb +52 -50
  12. data/app/views/rails_admin_next/main/index.html.erb +3 -2
  13. data/docs/UPGRADING.md +126 -0
  14. data/lib/rails_admin_next/abstract_model.rb +12 -4
  15. data/lib/rails_admin_next/adapters/active_record.rb +47 -3
  16. data/lib/rails_admin_next/config/actions/bulk_delete.rb +4 -4
  17. data/lib/rails_admin_next/config/actions/index.rb +3 -19
  18. data/lib/rails_admin_next/config/actions.rb +5 -12
  19. data/lib/rails_admin_next/config/fields/association.rb +1 -1
  20. data/lib/rails_admin_next/config/fields/types/enum.rb +8 -2
  21. data/lib/rails_admin_next/config/fields/types/serialized.rb +17 -2
  22. data/lib/rails_admin_next/config/fields/types/timestamp.rb +1 -0
  23. data/lib/rails_admin_next/config/has_fields.rb +1 -1
  24. data/lib/rails_admin_next/config.rb +4 -4
  25. data/lib/rails_admin_next/extensions/paper_trail/auditing_adapter.rb +1 -1
  26. data/lib/rails_admin_next/support/composite_keys_serializer.rb +8 -2
  27. data/lib/rails_admin_next/support/csv_converter.rb +45 -5
  28. data/lib/rails_admin_next/version.rb +2 -2
  29. data/lib/rails_admin_next.rb +4 -16
  30. data/src/rails_admin/controllers/nested_form_controller.js +1 -1
  31. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 957743cf70d6fa24bb0cb1eaf74ca15b4a3b2996e0dca4f5dcee2d6428985d6b
4
- data.tar.gz: a205770a7170c2b1ce7e04a85cd6422f6cda1d150da237df81795387af8cc18d
3
+ metadata.gz: 79af82d2ebf188875574650be98cd21e394a1a324b887cc45518d49f9fa3ec41
4
+ data.tar.gz: 3a1d0d3b539034bc59e0019b1d56ff5dd3420285ea91a73ab193533de5f26c53
5
5
  SHA512:
6
- metadata.gz: ad848e8080f75c1e3d6b12c180dcb745bb4a3caea7791ee08d4fec1d97567836effacf01ea340a764b0febb4a52c345e793b3d1708d7496e9d1c2e3f6a167f6d
7
- data.tar.gz: 37ec4a516104eb2ecc4d8924c758ad862b8fa091f32a0d460b0dfda2f8bc566a233fa61ea81d6cdbbfe49695b1d71eb0aed55f75c281001e9d86a73821fbe8a8
6
+ metadata.gz: c20bd5d419f98867c7f2528ccf8139e5e09618ee13d4d982457fc983905c41d79b60d04c46971021531ccbc9607ceaace4ddd99072242c6003187c8e656a09f5
7
+ data.tar.gz: 3c06806088cbb19f949301c5150eba329392eccde2ee96128c3fb9a8ac23b3639418d358d4074ea59f1995aa186a9a399e17b86374c280953fb526b2ff1deb48
@@ -430,6 +430,10 @@ body.rails_admin #filters_box {
430
430
  display: none;
431
431
  }
432
432
  }
433
+ body.rails_admin .dashboard-statistics {
434
+ overflow-x: auto;
435
+ overscroll-behavior: contain;
436
+ }
433
437
  body.rails_admin #sidescroll {
434
438
  margin-bottom: 20px;
435
439
  overflow-x: auto;
@@ -8,19 +8,35 @@ module RailsAdminNext
8
8
 
9
9
  before_action :check_for_cancel
10
10
 
11
+ helper_method :list_scopes
12
+
11
13
  def bulk_action
12
14
  get_model
13
15
  process(params[:bulk_action]) if params[:bulk_action].in?(RailsAdminNext::Config::Actions.all(:bulkable, controller: self, abstract_model: @abstract_model).collect(&:route_fragment))
14
16
  end
15
17
 
16
- def list_entries(model_config = @model_config, auth_scope_key = :index, additional_scope = get_association_scope_from_params, pagination = !(params[:associated_collection] || params[:all] || params[:bulk_ids]))
18
+ def list_entries(model_config = @model_config, auth_scope_key = :index, additional_scope = nil, pagination = !(params[:all] || params[:bulk_ids]))
17
19
  scope = model_config.scope
18
20
  auth_scope = @authorization_adapter&.query(auth_scope_key, model_config.abstract_model)
19
21
  scope = scope.merge(auth_scope) if auth_scope
22
+ tab = list_scope if model_config == @model_config
23
+ if tab
24
+ scope = scope.public_send(tab)
25
+ scope = within_authorized(scope, auth_scope) if auth_scope
26
+ end
20
27
  scope = scope.instance_eval(&additional_scope) if additional_scope
21
28
  get_collection(model_config, scope, pagination)
22
29
  end
23
30
 
31
+ # The association scope chains after get_collection, which only works on an unpaginated relation.
32
+ def association_entries(model_config, collection_scope)
33
+ scope = model_config.scope
34
+ auth_scope = @authorization_adapter&.query(:index, model_config.abstract_model)
35
+ scope = scope.merge(auth_scope) if auth_scope
36
+ scope = get_collection(model_config, scope, false)
37
+ collection_scope ? scope.instance_eval(&collection_scope) : scope
38
+ end
39
+
24
40
  private
25
41
 
26
42
  def action_missing(name, *_args)
@@ -78,9 +94,7 @@ module RailsAdminNext
78
94
  end
79
95
 
80
96
  def get_sort_hash(model_config)
81
- field = model_config.list.fields.detect { |f| f.name.to_s == params[:sort] }
82
- # If no sort param, default to the `sort_by` specified in the list config
83
- field ||= model_config.list.possible_fields.detect { |f| f.name == model_config.list.sort_by.try(:to_sym) }
97
+ field = sort_field(model_config)
84
98
 
85
99
  column =
86
100
  if field.nil? || field.sortable == false # use default sort, asked field does not exist or is not sortable
@@ -93,6 +107,12 @@ module RailsAdminNext
93
107
  {sort: column, sort_reverse: (params[:sort_reverse] == (field&.sort_reverse&.to_s || "true"))}
94
108
  end
95
109
 
110
+ def sort_field(model_config)
111
+ field = model_config.list.fields.detect { |f| f.name.to_s == params[:sort] }
112
+ # If no sort param, default to the `sort_by` specified in the list config
113
+ field || model_config.list.possible_fields.detect { |f| f.name == model_config.list.sort_by.try(:to_sym) }
114
+ end
115
+
96
116
  def redirect_to_on_success
97
117
  notice = I18n.t("admin.flash.successful", name: @model_config.label, action: I18n.t("admin.actions.#{@action.key}.done"))
98
118
  if params[:_add_another]
@@ -178,7 +198,8 @@ module RailsAdminNext
178
198
 
179
199
  def get_collection(model_config, scope, pagination)
180
200
  section = (@action.key == :export) ? model_config.export : model_config.list
181
- eager_loads = section.fields.flat_map(&:eager_load_values)
201
+ # The sort comes from the list, so export must also load the association a list sort orders by.
202
+ eager_loads = (section.fields + Array(sort_field(model_config))).flat_map(&:eager_load_values).uniq
182
203
  options = {}
183
204
  options = options.merge(page: (params[:page] || 1).to_i, per: params[:per] || model_config.list.items_per_page) if pagination
184
205
  options = options.merge(include: eager_loads) unless eager_loads.blank?
@@ -189,14 +210,39 @@ module RailsAdminNext
189
210
  model_config.abstract_model.all(options, scope)
190
211
  end
191
212
 
213
+ def list_scopes
214
+ @_list_scopes ||= @model_config.list.with(controller: self, view: view_context, object: @abstract_model.model.new).scopes
215
+ end
216
+
217
+ # A row-value IN: `where(primary_key => relation)` renders an empty condition for composite keys.
218
+ def within_authorized(scope, auth_scope)
219
+ key = Array(scope.primary_key).map { scope.arel_table[it] }
220
+ scope.where(Arel::Nodes::In.new(Arel::Nodes::Grouping.new(key), auth_scope.reselect(*key).arel.ast))
221
+ end
222
+
223
+ def list_scope
224
+ return if params[:compact]
225
+
226
+ if list_scopes.empty? || params[:scope].blank?
227
+ list_scopes.first
228
+ elsif list_scopes.collect(&:to_s).include?(params[:scope])
229
+ params[:scope].to_sym
230
+ end
231
+ end
232
+
192
233
  def get_association_scope_from_params
193
234
  return nil unless params[:associated_collection].present?
194
235
 
195
236
  source_abstract_model = RailsAdminNext::AbstractModel.new(to_model_name(params[:source_abstract_model]))
196
237
  source_model_config = source_abstract_model.config
197
- source_object = source_abstract_model.get(params[:source_object_id])
198
238
  action = params[:current_action].in?(%w[create update]) ? params[:current_action] : "edit"
199
- @association = source_model_config.send(action).fields.detect { |f| f.name == params[:associated_collection].to_sym }.with(controller: self, object: source_object)
239
+ field = source_model_config.send(action).fields.detect { |f| f.name == params[:associated_collection].to_sym }
240
+ # The picker's params are client-supplied; a field that doesn't target this model would run its scope and labels on the wrong records.
241
+ # Polymorphic pickers never send associated_collection, and their collection ignores the listed records.
242
+ return nil unless field&.association? && !field.polymorphic? && field.associated_model_config == @model_config
243
+
244
+ source_object = source_abstract_model.get(params[:source_object_id])
245
+ @association = field.with(controller: self, object: source_object)
200
246
  @association.associated_collection_scope
201
247
  end
202
248
  end
@@ -4,6 +4,10 @@ module RailsAdminNext
4
4
  class FormBuilder < ::ActionView::Helpers::FormBuilder
5
5
  include ::RailsAdminNext::ApplicationHelper
6
6
 
7
+ # Bounds the nested form of an association that can reach itself. Counting repeats of
8
+ # one association, rather than depth, leaves chains of distinct models uncapped.
9
+ MAX_NESTED_FORM_RECURSION = 3
10
+
7
11
  def generate(options = {})
8
12
  without_field_error_proc_added_div do
9
13
  options.reverse_merge!(
@@ -78,8 +82,10 @@ module RailsAdminNext
78
82
 
79
83
  # Wraps one nested-attributes subform (rendered by native `fields_for`) for
80
84
  # the nested-form Stimulus controller: a `.fields` container, an optional
81
- # `_destroy` hidden field + remove button (gated on `allow_destroy`, always
82
- # present in the blank <template>), then the recursively generated fields.
85
+ # `_destroy` hidden field (gated on `allow_destroy`, always present in the blank
86
+ # <template>), a remove button, then the recursively generated fields. A saved
87
+ # record is removable only under `allow_destroy`; an unsaved one always is, so a
88
+ # row added before a failed save can still be taken back.
83
89
  def nested_field_set(nested_form, field, allow_destroy:)
84
90
  @template.content_tag(:div, class: "fields tab-pane active", data: {"nested-form-target": "subform"}) do
85
91
  contents = []
@@ -88,8 +94,8 @@ module RailsAdminNext
88
94
  # persisted subform from a brand-new one. Suppress the auto-id in the
89
95
  # partials with `include_id: false` so it is not rendered twice.
90
96
  contents << nested_form.hidden_field(:id) if nested_form.object.persisted?
91
- if allow_destroy
92
- contents << nested_form.hidden_field(:_destroy, data: {"nested-form-target": "destroy"})
97
+ contents << nested_form.hidden_field(:_destroy, data: {"nested-form-target": "destroy"}) if allow_destroy
98
+ if allow_destroy || nested_form.object.new_record?
93
99
  contents << @template.content_tag(:button, RailsAdminNext::Icons.svg(:trash).html_safe, type: "button", class: "btn btn-danger remove_nested_fields", data: {action: "nested-form#remove"})
94
100
  end
95
101
  contents << nested_form.generate(action: :nested, model_config: field.associated_model_config, nested_in: field)
@@ -97,6 +103,19 @@ module RailsAdminNext
97
103
  end
98
104
  end
99
105
 
106
+ # Whether this association's subforms, its blank <template> and its add control render
107
+ # here. Saved records can form a cycle too, so the bound covers them as well.
108
+ def nested_within_bound?(field)
109
+ @object_name.to_s.scan("[#{field.name}_attributes]").size < MAX_NESTED_FORM_RECURSION
110
+ end
111
+
112
+ # The child index the <template> carries until a row is added. Tagged by depth, not by
113
+ # association name alone: the add control replaces every occurrence in the template,
114
+ # nested templates included, and a name-tagged token can be a prefix of a nested one's.
115
+ def nested_placeholder(field)
116
+ "new_#{@object_name.to_s.scan("_attributes]").size + 1}_#{field.name}"
117
+ end
118
+
100
119
  def object_infos
101
120
  model_config = RailsAdminNext.config(object)
102
121
  model_label = model_config.label
@@ -29,7 +29,7 @@ module RailsAdminNext
29
29
  return @ordered_filters if @ordered_filters.present?
30
30
 
31
31
  @index = 0
32
- @ordered_filters = (params[:f].try(:permit!).try(:to_h) || @model_config.list.filters).each_with_object({}) do |filter, memo|
32
+ @ordered_filters = (filters_from_params || @model_config.list.filters).each_with_object({}) do |filter, memo|
33
33
  field_name = filter.is_a?(Array) ? filter.first : filter
34
34
  (filter.is_a?(Array) ? filter.last : {(@index += 1) => {"v" => ""}}).each do |index, filter_hash|
35
35
  if filter_hash["disabled"].blank?
@@ -41,6 +41,17 @@ module RailsAdminNext
41
41
  end.to_a.sort_by(&:first)
42
42
  end
43
43
 
44
+ # Only the field => index => filter shape the filter box submits, for fields that
45
+ # are filterable; hand-written or stale params are dropped rather than raising.
46
+ def filters_from_params
47
+ return unless params[:f].respond_to?(:permit!)
48
+
49
+ names = filterable_fields.map { it.name.to_s }
50
+ params[:f].permit!.to_h.filter_map do |name, filters_dump|
51
+ [name, filters_dump.select { |_index, filter| filter.is_a?(Hash) }] if names.include?(name) && filters_dump.is_a?(Hash)
52
+ end.to_h
53
+ end
54
+
44
55
  def ordered_filter_options
45
56
  if ordered_filters
46
57
  @ordered_filter_options ||= ordered_filters.map do |duplet|
@@ -1,7 +1,7 @@
1
1
  <%# locals: () -%>
2
2
  <meta content="IE=edge" http-equiv="X-UA-Compatible">
3
3
  <meta content="text/html; charset=utf-8" http-equiv="Content-Type">
4
- <meta content="width=device-width, initial-scale=1" name="viewport; charset=utf-8">
4
+ <meta content="width=device-width, initial-scale=1" name="viewport">
5
5
  <meta content="NONE,NOARCHIVE" name="robots">
6
6
  <meta content="false" name="turbo-prefetch">
7
7
  <%= csrf_meta_tag %>
@@ -7,7 +7,7 @@
7
7
  </small>
8
8
  </a>
9
9
  <button class="navbar-toggler" type="button" data-collapse-target="trigger" data-action="collapse#toggle" aria-expanded="false" aria-controls="secondary-navigation">
10
- <span class="sr-only">
10
+ <span class="visually-hidden">
11
11
  <%= t('admin.toggle_navigation') %>
12
12
  </span>
13
13
  <span class="navbar-toggler-icon"></span>
@@ -3,18 +3,23 @@
3
3
  associated_model = field.associated_model_config.abstract_model
4
4
  add_label = rails_admin_icon(:new) + " " + wording_for(:link, :new, associated_model)
5
5
  allow_destroy = field.nested_form && field.nested_form[:allow_destroy]
6
- placeholder = "new_#{field.name}"
6
+ placeholder = form.nested_placeholder(field)
7
+ template = form.nested_within_bound?(field)
7
8
  -%>
8
9
  <div class="controls col-sm-10" data-controller="nested-form" data-nested-form-placeholder-value="<%= placeholder %>">
9
10
  <div class="tab-content" data-nested-form-target="container">
10
- <%= form.fields_for field.name, nil, include_id: false do |nested_form| %>
11
- <%= form.nested_field_set nested_form, field, allow_destroy: allow_destroy %>
11
+ <% if template %>
12
+ <%= form.fields_for field.name, nil, include_id: false do |nested_form| %>
13
+ <%= form.nested_field_set nested_form, field, allow_destroy: allow_destroy %>
14
+ <% end %>
12
15
  <% end %>
13
16
  </div>
14
- <% if field.inline_add %>
17
+ <% if field.inline_add && template %>
15
18
  <button type="button" class="btn btn-info add_nested_fields" data-action="nested-form#add"><%= add_label %></button>
16
19
  <% end %>
17
20
  <%= form.errors_for(field) %>
18
21
  <%= form.help_for(field) %>
22
+ <% if template %>
19
23
  <template data-nested-form-target="template"><%= form.fields_for field.name, associated_model.model.new, child_index: placeholder, include_id: false do |nested_form| %><%= form.nested_field_set nested_form, field, allow_destroy: true %><% end %></template>
24
+ <% end %>
20
25
  </div>
@@ -3,17 +3,22 @@
3
3
  associated_model = field.associated_model_config.abstract_model
4
4
  add_label = rails_admin_icon(:new) + " " + wording_for(:link, :new, associated_model)
5
5
  allow_destroy = field.nested_form && field.nested_form[:allow_destroy]
6
+ template = form.nested_within_bound?(field)
6
7
  -%>
7
8
  <div class="controls col-sm-10" data-controller="nested-form" data-nested-form-singular-value="true">
8
9
  <div class="tab-content" data-nested-form-target="container">
9
- <%= form.fields_for field.name, nil, include_id: false do |nested_form| %>
10
- <%= form.nested_field_set nested_form, field, allow_destroy: allow_destroy %>
10
+ <% if template %>
11
+ <%= form.fields_for field.name, nil, include_id: false do |nested_form| %>
12
+ <%= form.nested_field_set nested_form, field, allow_destroy: allow_destroy %>
13
+ <% end %>
11
14
  <% end %>
12
15
  </div>
13
- <% if field.inline_add %>
16
+ <% if field.inline_add && template %>
14
17
  <button type="button" class="btn btn-info add_nested_fields" data-action="nested-form#add"><%= add_label %></button>
15
18
  <% end %>
16
19
  <%= form.errors_for(field) %>
17
20
  <%= form.help_for(field) %>
21
+ <% if template %>
18
22
  <template data-nested-form-target="template"><%= form.fields_for field.name, associated_model.model.new, include_id: false do |nested_form| %><%= form.nested_field_set nested_form, field, allow_destroy: true %><% end %></template>
23
+ <% end %>
19
24
  </div>
@@ -4,7 +4,7 @@
4
4
  <ul>
5
5
  <%= render partial: "delete_notice", collection: @objects %>
6
6
  </ul>
7
- <%= form_tag bulk_delete_path(model_name: @abstract_model.to_param, bulk_ids: @objects.map(&:id)), method: :delete do %>
7
+ <%= form_tag bulk_delete_path(model_name: @abstract_model.to_param, bulk_ids: @objects.map(&:id), scope: params[:scope].presence), method: :delete do %>
8
8
  <div class="form-actions">
9
9
  <input name="return_to" type="<%= :hidden %>" value="<%= (params[:return_to].presence || request.referer) %>" />
10
10
  <button class="btn btn-danger" data-disable-with="<%= t("admin.form.confirmation") %>" data-turbo-confirm="<%= t("admin.form.bulk_delete") %>" type="submit">
@@ -1,56 +1,58 @@
1
1
  <% if @abstract_models %>
2
- <table class="table table-condensed table-striped table-hover">
3
- <thead>
4
- <tr>
5
- <th class="shrink model-name">
6
- <%= t "admin.table_headers.model_name" %>
7
- </th>
8
- <th class="shrink last-created">
9
- <%= t "admin.table_headers.last_created" %>
10
- </th>
11
- <th class="records">
12
- <%= t "admin.table_headers.records" %>
13
- </th>
14
- <th class="shrink controls"></th>
15
- </tr>
16
- </thead>
17
- <tbody class="table-group-divider">
18
- <% @abstract_models.each do |abstract_model| %>
19
- <% if authorized? :index, abstract_model %>
20
- <% index_path = index_path(model_name: abstract_model.to_param) %>
21
- <% row_class = "#{cycle("odd", "even")}#{" link" if index_path} #{abstract_model.param_key}_links" %>
22
- <tr class="<%= row_class %>" data-link="<%= index_path %>">
23
- <% last_created = @most_recent_created[abstract_model.model.name] %>
24
- <% active = last_created.try(:today?) %>
25
- <td>
26
- <span class="show">
27
- <%= link_to abstract_model.config.label_plural, index_path %>
28
- </span>
29
- </td>
30
- <td>
31
- <% if last_created %>
32
- <%= t "admin.misc.time_ago", time: time_ago_in_words(last_created), default: "#{time_ago_in_words(last_created)} #{t("admin.misc.ago")}" %>
33
- <% end %>
34
- </td>
35
- <td>
36
- <% count = @count[abstract_model.model.name] %>
37
- <% percent = count > 0 ? (@max <= 1 ? count : ((Math.log(count+1) * 100.0) / Math.log(@max+1)).to_i) : -1 %>
38
- <div class="progress" style="margin-bottom:0px">
39
- <div class="bg-<%= get_indicator(percent) %> progress-bar<%= ' progress-bar-striped progress-bar-animated' if active %>" style="width:<%= [2.0, percent].max.to_i %>%">
40
- <%= @count[abstract_model.model.name] %>
2
+ <div class="dashboard-statistics">
3
+ <table class="table table-condensed table-striped table-hover">
4
+ <thead>
5
+ <tr>
6
+ <th class="shrink model-name">
7
+ <%= t "admin.table_headers.model_name" %>
8
+ </th>
9
+ <th class="shrink last-created">
10
+ <%= t "admin.table_headers.last_created" %>
11
+ </th>
12
+ <th class="records">
13
+ <%= t "admin.table_headers.records" %>
14
+ </th>
15
+ <th class="shrink controls"></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody class="table-group-divider">
19
+ <% @abstract_models.each do |abstract_model| %>
20
+ <% if authorized? :index, abstract_model %>
21
+ <% index_path = index_path(model_name: abstract_model.to_param) %>
22
+ <% row_class = "#{cycle("odd", "even")}#{" link" if index_path} #{abstract_model.param_key}_links" %>
23
+ <tr class="<%= row_class %>" data-link="<%= index_path %>">
24
+ <% last_created = @most_recent_created[abstract_model.model.name] %>
25
+ <% active = last_created.try(:today?) %>
26
+ <td>
27
+ <span class="show">
28
+ <%= link_to abstract_model.config.label_plural, index_path %>
29
+ </span>
30
+ </td>
31
+ <td>
32
+ <% if last_created %>
33
+ <%= t "admin.misc.time_ago", time: time_ago_in_words(last_created), default: "#{time_ago_in_words(last_created)} #{t("admin.misc.ago")}" %>
34
+ <% end %>
35
+ </td>
36
+ <td>
37
+ <% count = @count[abstract_model.model.name] %>
38
+ <% percent = count > 0 ? (@max <= 1 ? count : ((Math.log(count+1) * 100.0) / Math.log(@max+1)).to_i) : -1 %>
39
+ <div class="progress" style="margin-bottom:0px">
40
+ <div class="bg-<%= get_indicator(percent) %> progress-bar<%= ' progress-bar-striped progress-bar-animated' if active %>" style="width:<%= [2.0, percent].max.to_i %>%">
41
+ <%= @count[abstract_model.model.name] %>
42
+ </div>
41
43
  </div>
42
- </div>
43
- </td>
44
- <td class="links">
45
- <ul class="nav d-inline list-inline">
46
- <%= menu_for :collection, abstract_model, nil, true %>
47
- </ul>
48
- </td>
49
- </tr>
44
+ </td>
45
+ <td class="links">
46
+ <ul class="nav d-inline list-inline">
47
+ <%= menu_for :collection, abstract_model, nil, true %>
48
+ </ul>
49
+ </td>
50
+ </tr>
51
+ <% end %>
50
52
  <% end %>
51
- <% end %>
52
- </tbody>
53
- </table>
53
+ </tbody>
54
+ </table>
55
+ </div>
54
56
  <% end %>
55
57
  <% if @history && authorized?(:history_index) %>
56
58
  <div class="block" id="block-tables">
@@ -82,9 +82,9 @@
82
82
  </div>
83
83
  </div>
84
84
  <% end %>
85
- <% unless @model_config.list.scopes.empty? %>
85
+ <% unless list_scopes.empty? %>
86
86
  <ul class="nav nav-tabs" id="scope_selector">
87
- <% @model_config.list.scopes.each_with_index do |scope, index| %>
87
+ <% list_scopes.each_with_index do |scope, index| %>
88
88
  <% scope = '_all' if scope.nil? %>
89
89
  <li class="nav-item">
90
90
  <a href="<%= index_path(params.merge(scope: scope, page: nil)) %>" class="nav-link <%= 'active' if scope.to_s == params[:scope] || (params[:scope].blank? && index == 0) %>">
@@ -96,6 +96,7 @@
96
96
  <% end %>
97
97
  <%= form_tag bulk_action_path(model_name: @abstract_model.to_param), method: :post, id: "bulk_form", class: ["form", "mb-3"] do %>
98
98
  <%= hidden_field_tag :bulk_action %>
99
+ <%= hidden_field_tag :scope, params[:scope] if params[:scope].present? %>
99
100
  <% if description.present? %>
100
101
  <p>
101
102
  <strong>
data/docs/UPGRADING.md CHANGED
@@ -398,6 +398,19 @@ end
398
398
 
399
399
  Note the block: a bare `inverse_of nil` reads the option rather than setting it.
400
400
 
401
+ ## Nested forms that can reach themselves stop at three levels
402
+
403
+ A nested form for an association that leads back to its own model, such as a self-referential
404
+ `has_many :children` with `accepts_nested_attributes_for`, used to render without end and fail
405
+ with `SystemStackError`, as did saved records that point back at each other. It now nests the
406
+ same association at most three times along one chain; past that, the association's rows, blank
407
+ template and add control are left out of the form, so a deeper tree is edited from a lower
408
+ record. Chains of distinct associations are not capped.
409
+
410
+ The blank row's child-index placeholder is tagged by depth, `new_<depth>_<association>` (for
411
+ example `new_1_comments`), rather than `new_<association>`. Host JavaScript that looked for the
412
+ old token in a nested form's `<template>` needs the new one.
413
+
401
414
  ## The show action answers HTML by default
402
415
 
403
416
  `show` used to declare its JSON format before its HTML one, so a request carrying no
@@ -431,6 +444,16 @@ ignored.
431
444
 
432
445
  If a client relied on a column the section doesn't show, add that field to the section.
433
446
 
447
+ ## Malformed filter params are ignored
448
+
449
+ The index page used to answer a hand-written or stale `f[...]` query string with a 500: a
450
+ filter that wasn't the `field => index => {o:, v:}` shape the filter box submits, one naming
451
+ a field that isn't filterable (`is not currently filterable`), or a value the field couldn't
452
+ parse, such as an impossible date or malformed JSON. Such a filter is now dropped and the
453
+ rest of the list renders. A value that fails to parse counts as no value, so a bookmarked
454
+ link to a field that has since stopped being filterable lists every record rather than
455
+ erroring.
456
+
434
457
  ## Pundit: a model with no policy Scope lists nothing
435
458
 
436
459
  A model whose policy defines no `Scope` used to list every record. It now lists none: the
@@ -450,6 +473,105 @@ end
450
473
  ```
451
474
 
452
475
  CanCanCan hosts are unaffected.
476
+
477
+ ## An `:enum` field with no option source offers no options
478
+
479
+ An `:enum` field takes its options from `enum_method`, which defaults to `<name>_enum` on the
480
+ model class or the record. When neither defines it, the field used to call `<name>` itself and
481
+ treat the record's current value as the option list: the index filter crashed, and the form
482
+ offered that value instead of choices. It now offers no options.
483
+
484
+ If a field's own method returns its option list, name it explicitly:
485
+
486
+ ```ruby
487
+ field :colors, :enum do
488
+ enum_method :colors
489
+ end
490
+ ```
491
+
492
+ ## A redeclared field keeps its `visible` block
493
+
494
+ Declaring a field again in the same section used to discard a `visible { ... }` block set by an
495
+ earlier declaration and show the field to everyone. The block now survives:
496
+
497
+ ```ruby
498
+ field :salary do
499
+ visible { bindings[:view].current_user.admin? }
500
+ end
501
+ field :salary # still hidden from non-admins
502
+ ```
503
+
504
+ If a field you hide conditionally started showing for everyone after a later `field` call, it is
505
+ hidden again. A field hidden with a plain `visible false` is still shown again by a later
506
+ declaration, as before.
507
+
508
+ ## Serialized fields load only plain YAML
509
+
510
+ A `serialized` field (the default for a `store` or `serialize` attribute) now shows its value
511
+ as plain YAML, without `!ruby/...` class tags, and loads the submitted YAML with only `Symbol`,
512
+ `Date` and `Time` permitted. A store's `HashWithIndifferentAccess` comes back as a plain `Hash`,
513
+ which the store wraps again on assignment. YAML naming any other class is rejected.
514
+
515
+ The `safe_yaml` gem is no longer consulted, even when the host bundles it.
516
+ `RailsAdminNext.yaml_load` and `RailsAdminNext.yaml_dump` are deprecated: call
517
+ `YAML.safe_load` and `YAML.dump` directly.
518
+
519
+ ## `config.model "::Order"` configures `Order`
520
+
521
+ A model named as a string or symbol with a leading `::` used to register a second, separate
522
+ entry: its block never reached `Order`, and `Order` appeared twice in the navigation. The `::`
523
+ is now dropped, so the block applies to `Order`. If such a block was silently doing nothing,
524
+ check that what it configures is what you want before upgrading. `included_models` and
525
+ `excluded_models` drop a leading `::` the same way.
526
+
527
+ ## Scope tabs apply to export and bulk delete
528
+
529
+ The tab selected on a list (`list { scopes [...] }`) now also narrows export and bulk
530
+ delete: "Export found" streams only the tab's records, and bulk delete lists and destroys
531
+ only the tab's records, whatever ids are submitted. The bulk form and the confirmation
532
+ form carry `scope` so the tab survives the round trip.
533
+
534
+ A tabbed collection stays inside the authorization scope, so a tab that drops conditions,
535
+ such as `unscoped`, cannot list, export or delete records the user may not see. It still
536
+ drops the model's own configured `scope`. The authorization scope is built on the model's
537
+ `default_scope`, so an `unscoped` tab no longer reaches records `default_scope` hides: a
538
+ soft-delete "trash" tab built on `unscoped` comes up empty. Give such a tab a scope the
539
+ authorization relation also reaches.
540
+
541
+ The `scopes` block is now evaluated with `bindings[:controller]`, `bindings[:view]` and
542
+ `bindings[:object]`, so tabs can depend on the current user:
543
+
544
+ ```ruby
545
+ config.model "Order" do
546
+ list do
547
+ scopes { bindings[:controller].current_user.admin? ? [nil, :refunded] : [nil] }
548
+ end
549
+ end
550
+ ```
551
+
552
+ `list_entries` keeps its four positional arguments, but applies a tab only for the page's
553
+ own model, its `additional_scope` defaults to `nil`, and it no longer reads the
554
+ `associated_collection` picker params. Custom actions that listed picker records through
555
+ it should call `association_entries(model_config, collection_scope)` instead.
556
+
557
+ ## `associated_collection_scope` runs after the list sort
558
+
559
+ Association pickers apply `associated_collection_scope` after the picked model's list sort,
560
+ so a `reorder` in it now decides the order. A plain `order` adds a secondary sort key after
561
+ the list sort. Build on the relation the proc receives: returning a fresh relation discards
562
+ the authorization scope, the picker's search query, sort and eager loads as well. Pickers
563
+ never apply the picked model's scope tabs.
564
+
565
+ ## CSV export follows the list sort
566
+
567
+ CSV export used to walk records with `find_each`, which discards the list sort, so every file
568
+ came out in primary-key ascending order. It now writes rows in the order the list shows them:
569
+ the sort the export request carries, or the list's default sort. For most models that default is
570
+ the primary key descending, so an export with no sort chosen is now newest first.
571
+
572
+ Rows are read in batches of 1000 instead of loading every record up front. An export with no
573
+ matching records now contains the header row instead of an empty line.
574
+
453
575
  ## Removed: options deprecated before RailsAdmin 3.0
454
576
 
455
577
  These six were deprecated upstream between 2019-07 and 2022-01 and had no effect for
@@ -493,3 +615,7 @@ These are intentionally gone — migrate off them before upgrading:
493
615
 
494
616
  The gem owns **no** history table. Auditing remains host/PaperTrail territory, so there is
495
617
  no gem migration to run — your existing `rails_admin_histories` table (if any) is unaffected.
618
+
619
+ A record's history page now honours its column sort, and with no sort it lists newest
620
+ first, like the model history page. It used to list a record's versions oldest first
621
+ whatever the sort.
@@ -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
@@ -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
@@ -9,6 +9,7 @@ module RailsAdminNext
9
9
  class Timestamp < RailsAdminNext::Config::Fields::Types::Datetime
10
10
  # Register field type for the type loader
11
11
  RailsAdminNext::Config::Fields::Types.register(self)
12
+ RailsAdminNext::Config::Fields::Types.register(:timestamptz, self)
12
13
  end
13
14
  end
14
15
  end
@@ -10,7 +10,7 @@ module RailsAdminNext
10
10
 
11
11
  # some fields are hidden by default (belongs_to keys, has_many associations in list views.)
12
12
  # unhide them if config specifically defines them
13
- field.show if field && !field.instance_variable_get("@#{field.name}_registered").is_a?(Proc)
13
+ field.show if field && !field.instance_variable_get(:@visible_registered).is_a?(Proc)
14
14
  # Specify field as virtual if type is not specifically set and field was not
15
15
  # found in default stack
16
16
  if field.nil? && type.nil?
@@ -249,7 +249,7 @@ module RailsAdminNext
249
249
 
250
250
  # pool of all found model names from the whole application
251
251
  def models_pool
252
- (viable_models - excluded_models.collect(&:to_s)).uniq.sort
252
+ (viable_models - excluded_models.collect { it.to_s.delete_prefix("::") }).uniq.sort
253
253
  end
254
254
 
255
255
  # Loads a model configuration instance from the registry or registers
@@ -272,7 +272,7 @@ module RailsAdminNext
272
272
  when Class, ConstLoadSuppressor::ConstProxy
273
273
  entity.name.to_sym
274
274
  when String, Symbol
275
- entity.to_sym
275
+ entity.to_s.delete_prefix("::").to_sym
276
276
  else
277
277
  entity.class.name.to_sym
278
278
  end
@@ -369,7 +369,7 @@ module RailsAdminNext
369
369
  #
370
370
  # @see RailsAdminNext::Config.registry
371
371
  def reset_model(model)
372
- key = model.is_a?(Class) ? model.name.to_sym : model.to_sym
372
+ key = model.is_a?(Class) ? model.name.to_sym : model.to_s.delete_prefix("::").to_sym
373
373
  @registry.delete(key)
374
374
  end
375
375
 
@@ -395,7 +395,7 @@ module RailsAdminNext
395
395
  private
396
396
 
397
397
  def viable_models
398
- included_models.collect(&:to_s).presence || begin
398
+ included_models.collect { it.to_s.delete_prefix("::") }.presence || begin
399
399
  @@system_models ||= # memoization for tests
400
400
  ([Rails.application] + Rails::Engine.subclasses.collect(&:instance)).flat_map do |app|
401
401
  (app.paths["app/models"].to_a + app.config.eager_load_paths).collect do |load_path|
@@ -143,7 +143,7 @@ module RailsAdminNext
143
143
 
144
144
  versions = object.nil? ? versions_for_model(model) : object.public_send(model.model.versions_association_name)
145
145
  versions = versions.where("event LIKE ?", "%#{query}%") if query.present?
146
- versions = versions.order(sort)
146
+ versions = versions.reorder(sort)
147
147
 
148
148
  paginate_versions(versions, all, (page.presence || 1).to_i, per_page.to_i)
149
149
  end
@@ -3,12 +3,18 @@
3
3
  module RailsAdminNext
4
4
  module Support
5
5
  module CompositeKeysSerializer
6
+ # "_" joins the keys, so it is escaped as "~" plus its hex byte. "~" is escaped too so the
7
+ # escape cannot be forged, and both are URL-unreserved. Every other value serializes as-is,
8
+ # keeping existing links valid.
9
+ ESCAPED = /[_~]/
10
+ ESCAPE = /~(5F|7E)/
11
+
6
12
  def self.serialize(keys)
7
- keys.map { |key| key&.to_s&.gsub("_", "__") }.join("_")
13
+ keys.map { |key| key&.to_s&.gsub(ESCAPED) { format("~%02X", it.ord) } }.join("_")
8
14
  end
9
15
 
10
16
  def self.deserialize(string)
11
- string.split("_").map { |key| key&.gsub("__", "_") }
17
+ string.split("_").map { |key| key.gsub(ESCAPE) { $1.hex.chr } }
12
18
  end
13
19
  end
14
20
  end
@@ -4,14 +4,17 @@ require "csv"
4
4
 
5
5
  module RailsAdminNext
6
6
  class CSVConverter
7
+ BATCH_SIZE = 1000
8
+
7
9
  def initialize(objects = [], schema = nil)
8
10
  @fields = []
9
11
  @associations = []
10
12
  schema ||= {}
11
13
 
12
- return if (@objects = objects).blank?
14
+ @objects = objects
15
+ @model = objects.is_a?(ActiveRecord::Relation) ? objects.klass : objects.first&.class
16
+ return unless @model
13
17
 
14
- @model = objects.dup.first.class
15
18
  @abstract_model = RailsAdminNext::AbstractModel.new(@model)
16
19
  @model_config = @abstract_model.config
17
20
  @methods = [(schema[:only] || []) + (schema[:methods] || [])].flatten.compact
@@ -63,17 +66,54 @@ module RailsAdminNext
63
66
 
64
67
  def generate_csv_string(options)
65
68
  generator_options = (options[:generator] || {}).symbolize_keys.delete_if { |_, value| value.blank? }
66
- method = @objects.respond_to?(:find_each) ? :find_each : :each
67
-
68
69
  CSV.generate(**generator_options) do |csv|
69
70
  csv << generate_csv_header unless options[:skip_header]
70
71
 
71
- @objects.send(method) do |object|
72
+ each_object do |object|
72
73
  csv << generate_csv_row(object)
73
74
  end
74
75
  end
75
76
  end
76
77
 
78
+ def each_object(&)
79
+ return @objects.each(&) unless @objects.is_a?(ActiveRecord::Relation)
80
+ # Batching sets its own limit and offset, which would drop the relation's.
81
+ return @objects.each(&) if @objects.limit_value || @objects.offset_value
82
+
83
+ if (direction = primary_key_direction)
84
+ @objects.unscope(:order).find_each(order: direction, batch_size: BATCH_SIZE, &)
85
+ else
86
+ each_in_offset_batches(&)
87
+ end
88
+ end
89
+
90
+ # sort_scope writes the list sort as a single "<table>.<column> <direction>" string.
91
+ def primary_key_direction
92
+ primary_key = @model.primary_key
93
+ return unless primary_key.is_a?(String)
94
+
95
+ orders = @objects.order_values
96
+ return :asc if orders.empty?
97
+ return unless orders.one? && orders.first.is_a?(String)
98
+
99
+ table = Regexp.escape(@model.table_name)
100
+ column = Regexp.escape(primary_key)
101
+ orders.first[/\A(?:\W?#{table}\W?\.)?\W?#{column}\W? (asc|desc)\z/i, 1]&.downcase&.to_sym
102
+ end
103
+
104
+ # The primary-key tie-break makes the order total, so rows that tie on the sort
105
+ # column cannot shift between batches. A short batch is not the last one: sorting
106
+ # on an eager-loaded has-many column returns fewer records than the limit.
107
+ def each_in_offset_batches(&)
108
+ ordered = @objects.order(Array(@model.primary_key).index_with(:asc))
109
+ 0.step(by: BATCH_SIZE) do |offset|
110
+ batch = ordered.limit(BATCH_SIZE).offset(offset).to_a
111
+ break if batch.empty?
112
+
113
+ batch.each(&)
114
+ end
115
+ end
116
+
77
117
  def generate_csv_header
78
118
  @fields.collect do |field|
79
119
  ::I18n.t("admin.export.csv.header_for_root_methods", name: field.label, model: @abstract_model.pretty_name)
@@ -6,8 +6,8 @@ module RailsAdminNext
6
6
  # the tag's version over these constants at build time, so the in-repo values are only
7
7
  # the floor a local `gem build` produces.
8
8
  MAJOR = 1
9
- MINOR = 2
10
- PATCH = 1
9
+ MINOR = 3
10
+ PATCH = 0
11
11
  PRE = nil
12
12
 
13
13
  class << self
@@ -49,25 +49,13 @@ module RailsAdminNext
49
49
  @deprecator ||= ActiveSupport::Deprecation.new(RailsAdminNext::Version.to_s, "RailsAdminNext")
50
50
  end
51
51
 
52
- # Backwards-compatible with safe_yaml/load when SafeYAML isn't available.
53
- # Evaluates available YAML loaders at boot and creates appropriate method,
54
- # so no conditionals are required at runtime.
55
- begin
56
- require "safe_yaml/load"
57
- def self.yaml_load(yaml)
58
- SafeYAML.load(yaml)
59
- end
60
- rescue LoadError
61
- if YAML.respond_to?(:safe_load)
62
- def self.yaml_load(yaml)
63
- YAML.safe_load(yaml)
64
- end
65
- else
66
- raise LoadError.new "Safe-loading of YAML is not available. Please install 'safe_yaml' or install Psych 2.0+"
67
- end
52
+ def self.yaml_load(yaml)
53
+ deprecator.warn("RailsAdminNext.yaml_load is deprecated and will be removed. Call YAML.safe_load directly.")
54
+ YAML.safe_load(yaml)
68
55
  end
69
56
 
70
57
  def self.yaml_dump(object)
58
+ deprecator.warn("RailsAdminNext.yaml_dump is deprecated and will be removed. Call YAML.dump directly.")
71
59
  YAML.dump(object)
72
60
  end
73
61
  end
@@ -7,7 +7,7 @@ import { Controller } from "@hotwired/stimulus";
7
7
  //
8
8
  // - add: clones a blank subform from the <template>. For a has_many
9
9
  // association the template carries a child-index placeholder
10
- // (e.g. "new_nested_field_tests") which is swapped for a unique value so
10
+ // (e.g. "new_1_nested_field_tests") which is swapped for a unique value so
11
11
  // each new row submits under its own index; has_one/belongs_to subforms
12
12
  // have no placeholder. Focus then moves to the first visible field.
13
13
  // - remove on an unsaved subform deletes it from the DOM.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_admin_next
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel López Prat