rails_admin_next 1.2.0 → 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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -4
  3. data/app/assets/stylesheets/rails_admin/skin.css +4 -0
  4. data/app/controllers/rails_admin_next/main_controller.rb +64 -7
  5. data/app/helpers/rails_admin_next/form_builder.rb +23 -4
  6. data/app/helpers/rails_admin_next/main_helper.rb +12 -1
  7. data/app/views/layouts/rails_admin_next/_head.html.erb +1 -1
  8. data/app/views/layouts/rails_admin_next/_navigation.html.erb +1 -1
  9. data/app/views/rails_admin_next/main/_form_nested_many.html.erb +9 -4
  10. data/app/views/rails_admin_next/main/_form_nested_one.html.erb +8 -3
  11. data/app/views/rails_admin_next/main/bulk_delete.html.erb +1 -1
  12. data/app/views/rails_admin_next/main/dashboard.html.erb +52 -50
  13. data/app/views/rails_admin_next/main/index.html.erb +3 -2
  14. data/docs/UPGRADING.md +160 -2
  15. data/docs/security.md +5 -0
  16. data/docs/upstream-parity.md +47 -3
  17. data/lib/rails_admin_next/abstract_model.rb +12 -4
  18. data/lib/rails_admin_next/adapters/active_record.rb +47 -3
  19. data/lib/rails_admin_next/config/actions/bulk_delete.rb +4 -4
  20. data/lib/rails_admin_next/config/actions/export.rb +2 -1
  21. data/lib/rails_admin_next/config/actions/index.rb +5 -21
  22. data/lib/rails_admin_next/config/actions/show.rb +1 -1
  23. data/lib/rails_admin_next/config/actions.rb +5 -12
  24. data/lib/rails_admin_next/config/fields/association.rb +1 -1
  25. data/lib/rails_admin_next/config/fields/types/enum.rb +8 -2
  26. data/lib/rails_admin_next/config/fields/types/serialized.rb +17 -2
  27. data/lib/rails_admin_next/config/fields/types/timestamp.rb +1 -0
  28. data/lib/rails_admin_next/config/has_fields.rb +1 -1
  29. data/lib/rails_admin_next/config.rb +4 -4
  30. data/lib/rails_admin_next/extensions/paper_trail/auditing_adapter.rb +1 -1
  31. data/lib/rails_admin_next/extensions/pundit/authorization_adapter.rb +4 -2
  32. data/lib/rails_admin_next/support/composite_keys_serializer.rb +8 -2
  33. data/lib/rails_admin_next/support/csv_converter.rb +45 -5
  34. data/lib/rails_admin_next/support/serialization_schema.rb +74 -0
  35. data/lib/rails_admin_next/version.rb +1 -1
  36. data/lib/rails_admin_next.rb +5 -16
  37. data/src/rails_admin/controllers/nested_form_controller.js +1 -1
  38. metadata +3 -2
@@ -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
@@ -40,11 +40,13 @@ module RailsAdminNext
40
40
 
41
41
  # This is called when needing to scope a database query. It is called within the list
42
42
  # and bulk_delete/destroy actions and should return a scope which limits the records
43
- # to those which the user can perform the given action on.
43
+ # to those which the user can perform the given action on. A model with no policy
44
+ # Scope gets no records: bulk_delete deletes whatever this returns without a
45
+ # per-record check, so a forgotten Scope must not mean "everything".
44
46
  def query(_action, abstract_model)
45
47
  @controller.send(:policy_scope, abstract_model.model.all)
46
48
  rescue ::Pundit::NotDefinedError
47
- abstract_model.model.all
49
+ abstract_model.model.none
48
50
  end
49
51
 
50
52
  # This is called in the new/create actions to determine the initial attributes for new
@@ -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)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_admin_next/support/hash_helper"
4
+
5
+ module RailsAdminNext
6
+ # The options handed to to_json/serializable_hash, limited to what a section
7
+ # shows. Both serializers call every name under `methods` and `include` on each
8
+ # record, and serialize every attribute when `only` is missing, so a requested
9
+ # schema is only ever intersected with this, never passed through.
10
+ class SerializationSchema
11
+ def initialize(section, include_associations:)
12
+ @section = section
13
+ @include_associations = include_associations
14
+ end
15
+
16
+ # Attributes only: a virtual field need not be a method on the record, so
17
+ # calling it unasked could raise where the plain serialization never did.
18
+ def default
19
+ {only: allowed[:only]}
20
+ end
21
+
22
+ def sanitize(requested)
23
+ requested = HashHelper.symbolize(requested)
24
+ schema = filter(requested, allowed)
25
+ if @include_associations && requested[:include].is_a?(Hash)
26
+ schema[:include] = requested[:include].each_with_object({}) do |(name, value), included|
27
+ next unless value.is_a?(Hash) && allowed[:include].key?(name)
28
+
29
+ included[name] = filter(value, allowed[:include][name])
30
+ end
31
+ end
32
+ schema
33
+ end
34
+
35
+ private
36
+
37
+ def allowed
38
+ @allowed ||= begin
39
+ fields = @section.visible_fields
40
+ names = field_names(fields.select { |f| !f.association? || f.association.polymorphic? })
41
+ if @include_associations
42
+ names[:include] = fields.select { |f| f.association? && !f.association.polymorphic? }.to_h do |field|
43
+ associated = field.associated_model_config.export.with(@section.bindings.merge(object: field.associated_model_config.abstract_model.model.new))
44
+ [field.name, field_names(associated.visible_fields.reject(&:association?))]
45
+ end
46
+ else
47
+ # The section already shows these associations, so their keys expose nothing more
48
+ names[:only].concat(fields.select { |f| f.association? && f.association.type == :belongs_to && !f.association.polymorphic? }
49
+ .flat_map { |f| Array(f.association.foreign_key).map(&:to_sym) })
50
+ end
51
+ names
52
+ end
53
+ end
54
+
55
+ # Mirrors the names the export form offers (main/export.html.erb).
56
+ def field_names(fields)
57
+ fields.each_with_object({only: [], methods: []}) do |field, names|
58
+ list = field.virtual? ? names[:methods] : names[:only]
59
+ if field.association?
60
+ list << field.method_name.to_sym << field.association.foreign_type.to_sym
61
+ else
62
+ list << field.name.to_sym
63
+ end
64
+ end
65
+ end
66
+
67
+ # An empty `only` serializes no attribute in ActiveRecord, which is the point.
68
+ def filter(requested, permitted)
69
+ schema = {only: Array(requested[:only]) & permitted[:only]}
70
+ schema[:methods] = Array(requested[:methods]) & permitted[:methods] if requested.key?(:methods)
71
+ schema
72
+ end
73
+ end
74
+ end
@@ -6,7 +6,7 @@ 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
9
+ MINOR = 3
10
10
  PATCH = 0
11
11
  PRE = nil
12
12
 
@@ -12,6 +12,7 @@ require "rails_admin_next/extensions/paper_trail"
12
12
  require "rails_admin_next/support/csv_converter"
13
13
  require "rails_admin_next/support/hash_helper"
14
14
  require "rails_admin_next/support/paginated_collection"
15
+ require "rails_admin_next/support/serialization_schema"
15
16
  require "yaml"
16
17
 
17
18
  module RailsAdminNext
@@ -48,25 +49,13 @@ module RailsAdminNext
48
49
  @deprecator ||= ActiveSupport::Deprecation.new(RailsAdminNext::Version.to_s, "RailsAdminNext")
49
50
  end
50
51
 
51
- # Backwards-compatible with safe_yaml/load when SafeYAML isn't available.
52
- # Evaluates available YAML loaders at boot and creates appropriate method,
53
- # so no conditionals are required at runtime.
54
- begin
55
- require "safe_yaml/load"
56
- def self.yaml_load(yaml)
57
- SafeYAML.load(yaml)
58
- end
59
- rescue LoadError
60
- if YAML.respond_to?(:safe_load)
61
- def self.yaml_load(yaml)
62
- YAML.safe_load(yaml)
63
- end
64
- else
65
- raise LoadError.new "Safe-loading of YAML is not available. Please install 'safe_yaml' or install Psych 2.0+"
66
- 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)
67
55
  end
68
56
 
69
57
  def self.yaml_dump(object)
58
+ deprecator.warn("RailsAdminNext.yaml_dump is deprecated and will be removed. Call YAML.dump directly.")
70
59
  YAML.dump(object)
71
60
  end
72
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.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel López Prat
@@ -298,6 +298,7 @@ files:
298
298
  - lib/rails_admin_next/support/csv_converter.rb
299
299
  - lib/rails_admin_next/support/hash_helper.rb
300
300
  - lib/rails_admin_next/support/paginated_collection.rb
301
+ - lib/rails_admin_next/support/serialization_schema.rb
301
302
  - lib/rails_admin_next/version.rb
302
303
  - lib/tasks/rails_admin.rake
303
304
  - skills/rails-admin-next/SKILL.md
@@ -338,7 +339,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
338
339
  requirements:
339
340
  - - ">="
340
341
  - !ruby/object:Gem::Version
341
- version: 4.0.5
342
+ version: 4.0.9
342
343
  required_rubygems_version: !ruby/object:Gem::Requirement
343
344
  requirements:
344
345
  - - ">="