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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/rails_admin/skin.css +4 -0
- data/app/controllers/rails_admin_next/main_controller.rb +53 -7
- data/app/helpers/rails_admin_next/form_builder.rb +23 -4
- data/app/helpers/rails_admin_next/main_helper.rb +12 -1
- data/app/views/layouts/rails_admin_next/_head.html.erb +1 -1
- data/app/views/layouts/rails_admin_next/_navigation.html.erb +1 -1
- data/app/views/rails_admin_next/main/_form_nested_many.html.erb +9 -4
- data/app/views/rails_admin_next/main/_form_nested_one.html.erb +8 -3
- data/app/views/rails_admin_next/main/bulk_delete.html.erb +1 -1
- data/app/views/rails_admin_next/main/dashboard.html.erb +52 -50
- data/app/views/rails_admin_next/main/index.html.erb +3 -2
- data/docs/UPGRADING.md +126 -0
- data/lib/rails_admin_next/abstract_model.rb +12 -4
- data/lib/rails_admin_next/adapters/active_record.rb +47 -3
- data/lib/rails_admin_next/config/actions/bulk_delete.rb +4 -4
- data/lib/rails_admin_next/config/actions/index.rb +3 -19
- data/lib/rails_admin_next/config/actions.rb +5 -12
- data/lib/rails_admin_next/config/fields/association.rb +1 -1
- data/lib/rails_admin_next/config/fields/types/enum.rb +8 -2
- data/lib/rails_admin_next/config/fields/types/serialized.rb +17 -2
- data/lib/rails_admin_next/config/fields/types/timestamp.rb +1 -0
- data/lib/rails_admin_next/config/has_fields.rb +1 -1
- data/lib/rails_admin_next/config.rb +4 -4
- data/lib/rails_admin_next/extensions/paper_trail/auditing_adapter.rb +1 -1
- data/lib/rails_admin_next/support/composite_keys_serializer.rb +8 -2
- data/lib/rails_admin_next/support/csv_converter.rb +45 -5
- data/lib/rails_admin_next/version.rb +2 -2
- data/lib/rails_admin_next.rb +4 -16
- data/src/rails_admin/controllers/nested_form_controller.js +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79af82d2ebf188875574650be98cd21e394a1a324b887cc45518d49f9fa3ec41
|
|
4
|
+
data.tar.gz: 3a1d0d3b539034bc59e0019b1d56ff5dd3420285ea91a73ab193533de5f26c53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
#
|
|
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
|
-
|
|
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 = (
|
|
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
|
|
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="
|
|
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 =
|
|
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
|
-
|
|
11
|
-
<%= form.
|
|
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
|
-
|
|
10
|
-
<%= form.
|
|
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
|
-
<
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
<
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<%
|
|
20
|
-
<%
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<div class="
|
|
40
|
-
<%=
|
|
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
|
-
</
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</
|
|
48
|
-
</
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
</
|
|
53
|
-
</
|
|
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
|
|
85
|
+
<% unless list_scopes.empty? %>
|
|
86
86
|
<ul class="nav nav-tabs" id="scope_selector">
|
|
87
|
-
<%
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
53
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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].
|
|
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 ||=
|
|
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
|
|
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
|
-
|
|
19
|
+
YAML.dump(plain(value)) unless value.nil?
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def parse_value(value)
|
|
18
|
-
value.present? ? (
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/rails_admin_next.rb
CHANGED
|
@@ -49,25 +49,13 @@ module RailsAdminNext
|
|
|
49
49
|
@deprecator ||= ActiveSupport::Deprecation.new(RailsAdminNext::Version.to_s, "RailsAdminNext")
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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. "
|
|
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.
|