avo 4.3.4 → 4.3.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d65ffce7512335a86a24c0bf2e3fc39bf3a4bb5b13db6b8ef88e73ea045f9ff5
4
- data.tar.gz: d4c48cce328b20420e4dd91987f343bbbbcde5df9db020c032c8aa5ccab6d916
3
+ metadata.gz: 5ddab6468262c61b878d497c3c6c35847b0e646d131c06ccb32e7bf89f98a328
4
+ data.tar.gz: 3d6e374b3b15f9272d319bec1dbbd58ae6e4bc8f1f22d7c560ae2d05cd771b53
5
5
  SHA512:
6
- metadata.gz: c063f5cae2dde7b15a45121333425b509b2337408cc2809737f5487d058a5d06d4b21d39bb30ae2bd4302c22ec7d2b8da062a522ae02a0b556f2807065aa2d49
7
- data.tar.gz: 43c6aa663a35c9663c01fe2e99c0fa3335d50126820d0780416e66e2597455e918177cc444cde3a9ea5d68cc46f46fec1f27976e21966cb917ee2b1e02be127f
6
+ metadata.gz: 87458e20763b4c2c4b094d195cbfb7b7dbc23cb7dccd9bb5120b8e7dec6122f0bf258de1fdf13f76acc9a10da11bb7660f6118b403fd2448505edb1cd50fbc1a
7
+ data.tar.gz: f36f83193fed39820345714623eccaafe8c6cce3f8074656758bf3214be30c4bde406af75c6723c394336851e091bb0b364ee704f57bd9f6879d46ef9a234891
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- avo (4.3.4)
4
+ avo (4.3.5)
5
5
  actionview (>= 6.1)
6
6
  active_link_to
7
7
  activerecord (>= 6.1)
@@ -7920,6 +7920,9 @@ tag.tagify__tag {
7920
7920
  width: calc(var(--spacing) * 52);
7921
7921
  }
7922
7922
  }
7923
+ .field-wrapper__label .field_with_errors {
7924
+ display: inline;
7925
+ }
7923
7926
  .field-wrapper__help {
7924
7927
  margin-top: calc(var(--spacing) * 2);
7925
7928
  font-size: var(--text-xs);
@@ -6,6 +6,12 @@
6
6
  @apply flex self-start items-center shrink-0 w-full px-4 font-medium text-content text-sm md:w-52 min-h-10;
7
7
  }
8
8
 
9
+ /* Rails' field_error_proc wraps an errored label in a block div, which would push the
10
+ required asterisk onto its own line. */
11
+ .field-wrapper__label .field_with_errors {
12
+ @apply inline;
13
+ }
14
+
9
15
  .field-wrapper__help {
10
16
  @apply text-content-secondary mt-2 text-xs leading-4;
11
17
  }
@@ -46,7 +46,14 @@
46
46
  <ul>
47
47
  <li>Avo <%= Avo::VERSION %></li>
48
48
  <% Avo.plugin_manager.plugins.each do |plugin| %>
49
- <li><%= plugin.name %> - <%= plugin.version %></li>
49
+ <li>
50
+ <% if plugin.gem_name.present? %>
51
+ <%= link_to plugin.name, "https://avohq.io/gems/#{plugin.gem_name}", target: "_blank", rel: "noopener", class: "underline" %>
52
+ <% else %>
53
+ <%= plugin.name %>
54
+ <% end %>
55
+ - <%= plugin.version %>
56
+ </li>
50
57
  <% end %>
51
58
  </ul>
52
59
  </div>
@@ -78,24 +78,13 @@ module Avo
78
78
  end
79
79
  end
80
80
 
81
- def find_records_from_resource_ids
82
- if (ids = action_params[:fields]&.dig(:avo_resource_ids)&.split(",") || []).any?
83
- @resource.find_record(ids, params: params)
84
- else
85
- []
86
- end
87
81
  # A record may be deleted between the moment it was checked on the index and
88
- # the moment the action is submitted, and the bulk lookup above raises for the
89
- # whole batch when that happens. Fall back to the resource's own per-record
90
- # lookup so custom `find_record_method`s and FriendlyId keep working, and drop
91
- # only the records that are really gone. This is O(n) queries on purpose: it
92
- # runs only on the race, never on the happy path.
93
- rescue ActiveRecord::RecordNotFound
94
- ids.filter_map do |id|
95
- @resource.find_record(id, params: params)
96
- rescue ActiveRecord::RecordNotFound
97
- nil
98
- end
82
+ # the moment the action is submitted. Only the records that are really gone drop out.
83
+ def find_records_from_resource_ids
84
+ ids = action_params[:fields]&.dig(:avo_resource_ids)&.split(",") || []
85
+ return [] if ids.empty?
86
+
87
+ @resource.find_records(ids, params: params).compact.uniq
99
88
  end
100
89
 
101
90
  def set_fields
@@ -53,12 +53,7 @@ module Avo
53
53
  @resource.hydrate(record: @record)
54
54
 
55
55
  if @field.present? && !@field.is_searchable?
56
- query = @related_authorization.apply_policy @attachment_class
57
-
58
- # Add the association scope to the query scope
59
- if @field.attach_scope.present?
60
- query = Avo::ExecutionContext.new(target: @field.attach_scope, query: query, parent: @record).handle
61
- end
56
+ query = attachable_query
62
57
 
63
58
  @options = if attach_using_checkbox_list?
64
59
  checkbox_list_options(query)
@@ -100,6 +95,10 @@ module Avo
100
95
  attach_record(association_name, attachment_record)
101
96
  end
102
97
  end
98
+ rescue Avo::Fields::BelongsToField::TargetNotFound => error
99
+ # Worded on the join record, which owns the fields; `@record` has no attribute to put them on
100
+ join_record = @reflection.through_reflection.klass.new
101
+ error.field_ids.each { |field_id| @record.errors.add(:base, ActiveModel::Error.new(join_record, field_id, :invalid).full_message) }
103
102
  end
104
103
  end
105
104
 
@@ -150,13 +149,10 @@ module Avo
150
149
  end
151
150
 
152
151
  def set_attachment_record
153
- if action_name == "create"
154
- @attachment_records = attachment_ids.map do |id|
155
- @related_resource.find_record id, params: params
156
- end
157
- @attachment_record = @attachment_records.first
152
+ @attachment_record = if action_name == "create"
153
+ attachment_records.first
158
154
  else
159
- @attachment_record = @related_resource.find_record attachment_id, params: params
155
+ @related_resource.find_record attachment_id, params: params
160
156
  end
161
157
  end
162
158
 
@@ -174,12 +170,25 @@ module Avo
174
170
  Array(attachment_id).reject(&:blank?)
175
171
  end
176
172
 
173
+ # Whether the finder raises (`find`) or answers nil (`find_by`), a refused id raises like a missing one
177
174
  def attachment_records
178
- @attachment_records ||= attachment_ids.map do |id|
179
- @related_resource.find_record id, params: params
175
+ @attachment_records ||= begin
176
+ query = attachable_query
177
+
178
+ attachment_ids.map do |id|
179
+ @related_resource.find_record(id, query:, params:) || raise(ActiveRecord::RecordNotFound, "Couldn't find #{@attachment_class} with '#{id}'")
180
+ end
180
181
  end
181
182
  end
182
183
 
184
+ # What the attach picker offers, and so the only records the save accepts
185
+ def attachable_query
186
+ query = @related_authorization.apply_policy @attachment_class
187
+ return query if @field.attach_scope.blank?
188
+
189
+ Avo::ExecutionContext.new(target: @field.attach_scope, query: query, parent: @record).handle
190
+ end
191
+
183
192
  def reflection_class
184
193
  if @reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
185
194
  @reflection.through_reflection.class
@@ -277,15 +286,19 @@ module Avo
277
286
  end
278
287
 
279
288
  def additional_params
280
- @additional_params ||= params[:fields].slice(*@attach_fields&.map(&:id))
289
+ @additional_params ||= params[:fields].slice(*@resource.fields_by_param(Array(@attach_fields)).compact.keys)
281
290
  end
282
291
 
283
292
  def set_attach_fields
284
293
  @attach_fields = if @field.attach_fields.present?
294
+ # Attach fields describe the join record, so a belongs_to among them finds its association there
295
+ join_record = @reflection.through_reflection&.klass&.new
296
+
285
297
  Avo::FieldsExecutionContext.new(target: @field.attach_fields)
286
298
  .detect_fields
287
299
  .items_holder
288
300
  .items
301
+ .each { |field| field.hydrate(record: join_record) }
289
302
  end
290
303
  end
291
304
 
@@ -193,6 +193,9 @@ module Avo
193
193
 
194
194
  @record = @resource.fill_record(@record_to_fill, cast_nullable(model_params), extra_params: extra_params)
195
195
  assign_default_value_to_disabled_fields if @view.create?
196
+ rescue Avo::Fields::BelongsToField::TargetNotFound => error
197
+ @record = @record_to_fill
198
+ error.field_ids.each { |field_id| @record.errors.add(field_id, :invalid) }
196
199
  end
197
200
 
198
201
  def is_attach_action?
@@ -231,6 +231,9 @@ module Avo
231
231
  end
232
232
 
233
233
  def save_record
234
+ # Saving runs the validations, which would clear an error that filling the record left on it
235
+ return false if @record.errors.any?
236
+
234
237
  perform_action_and_record_errors do
235
238
  save_record_action
236
239
  end
@@ -57,6 +57,20 @@ module Avo
57
57
  # - is_disabled?
58
58
 
59
59
  class BelongsToField < BaseField
60
+ # A value that matches no record the field may attach, which a form shows on the field.
61
+ # A RecordNotFound, so code that already rescues one keeps working.
62
+ class TargetNotFound < ActiveRecord::RecordNotFound
63
+ attr_reader :field_id
64
+ # This field and every other one the same fill could not resolve, so a form shows them all
65
+ attr_accessor :field_ids
66
+
67
+ def initialize(message, model, id, field_id:)
68
+ @field_id = field_id
69
+ @field_ids = [field_id]
70
+ super(message, model, nil, id)
71
+ end
72
+ end
73
+
60
74
  include Avo::Fields::Concerns::IsSearchable
61
75
  include Avo::Fields::Concerns::UseResource
62
76
 
@@ -204,6 +218,16 @@ module Avo
204
218
  if polymorphic_as.present?
205
219
  valid_model_class = valid_polymorphic_class params[:"#{polymorphic_as}_type"]
206
220
 
221
+ # A type sent without its id keeps the current record. A new type can't take the current id, which names a record only
222
+ # under its own type. With no current id (a create through a parent fills it in later), the type is set as before.
223
+ if valid_model_class.present? && !params.key?(id_input_foreign_key)
224
+ return record if valid_model_class.to_s == record.public_send(type_input_foreign_key)
225
+
226
+ if record.public_send(id_input_foreign_key).present?
227
+ raise TargetNotFound.new("Couldn't find #{valid_model_class} without #{id_input_foreign_key}", valid_model_class.name, nil, field_id: id)
228
+ end
229
+ end
230
+
207
231
  record.send(:"#{polymorphic_as}_type=", valid_model_class)
208
232
 
209
233
  # If the type is blank, reset the id too.
@@ -331,10 +355,11 @@ module Avo
331
355
 
332
356
  private
333
357
 
334
- # A custom finder can answer nil (`find_by`) where the default one raises, so raise for it
358
+ # Whether the finder raises (`find`) or answers nil (`find_by`), the field raises TargetNotFound
335
359
  def find_target!(target, record, id)
336
- target.find_record(id, query: scoped_target_query(target, record, for_find: true)) ||
337
- raise(ActiveRecord::RecordNotFound.new("Couldn't find #{target.model_class.name} with '#{id}'", target.model_class.name))
360
+ target.find_record(id, query: scoped_target_query(target, record, for_find: true)) || raise(ActiveRecord::RecordNotFound)
361
+ rescue ActiveRecord::RecordNotFound
362
+ raise TargetNotFound.new("Couldn't find #{target.model_class.name} with '#{id}'", target.model_class.name, id, field_id: self.id)
338
363
  end
339
364
 
340
365
  def scoped_target_query(resource, parent_record, for_find: false)
@@ -45,8 +45,9 @@ module Avo
45
45
  def fill_field(record, key, value, params)
46
46
  return record unless record.methods.include? key.to_sym
47
47
 
48
- # attach files in one call to avoid index_active_storage_attachments_uniqueness violation
49
- record.send(key).attach(value.compact_blank)
48
+ # `attach` without its immediate save on a persisted record, so the files are saved with the record and a form
49
+ # that fails saves none. One assignment avoids an index_active_storage_attachments_uniqueness violation.
50
+ record.public_send(:"#{key}=", record.public_send(key).blobs + value.compact_blank)
50
51
 
51
52
  record
52
53
  end
@@ -55,12 +55,15 @@ module Avo
55
55
 
56
56
  return super(id, query: fetched_records, params:) if is_active_record_relation?(fetched_records)
57
57
 
58
- # Id is Array when find record is called from actions controller
59
- if id.is_a?(Array)
60
- fetched_records.select { |record| id.map(&:to_s).include?(record.id.to_s) }
61
- else
62
- fetched_records.find { |record| record.id.to_s == id.to_s }
63
- end
58
+ fetched_records.find { |record| record.id.to_s == id.to_s }
59
+ end
60
+
61
+ def find_records(ids, query: nil, params: nil)
62
+ fetched_records = fetch_records
63
+
64
+ return super(ids, query: fetched_records, params:) if is_active_record_relation?(fetched_records)
65
+
66
+ ids.map { |id| fetched_records.find { |record| record.id.to_s == id.to_s } }
64
67
  end
65
68
 
66
69
  def fetch_records(array_of_records = nil)
@@ -319,6 +319,37 @@ module Avo
319
319
  ).handle
320
320
  end
321
321
 
322
+ # The records `ids` name, each the way `find_record` finds one: by its `to_param`, through
323
+ # `find_record_method`. One entry per id, in order, nil where an id names no record in `query`.
324
+ #
325
+ # The finder is asked for the whole set first, then once per id it did not name: a finder
326
+ # that takes one id at a time, or a missing record.
327
+ def find_records(ids, query: nil, params: nil)
328
+ query ||= find_scope
329
+ ids = ids.map(&:to_s)
330
+
331
+ found = begin
332
+ ids.many? ? Array.wrap(find_record(ids.uniq, query:, params:)).index_by { |record| record.to_param.to_s } : {}
333
+ rescue
334
+ {}
335
+ end
336
+
337
+ (ids - found.keys).each do |id|
338
+ found[id] = find_record(id, query:, params:)
339
+ rescue ActiveRecord::ActiveRecordError, RangeError, ArgumentError, TypeError
340
+ nil
341
+ end
342
+
343
+ # Avo's own finder reads through `query`. A custom one may ignore it, so only what `query`
344
+ # also holds is kept.
345
+ unless find_record_method.equal?(Avo::Resources::Base.find_record_method)
346
+ in_query = query.where(model_class.primary_key => found.values.compact.map(&:id)).index_by(&:id)
347
+ found.transform_values! { |record| record if in_query.key?(record&.id) }
348
+ end
349
+
350
+ ids.map { |id| found[id] }
351
+ end
352
+
322
353
  def search_query
323
354
  search.dig(:query)
324
355
  end
@@ -335,7 +366,7 @@ module Avo
335
366
  delegate :plural_name, to: :class
336
367
  delegate :underscore_name, to: :class
337
368
  delegate :to_param, to: :class
338
- delegate :find_record, to: :class
369
+ delegate :find_record, :find_records, to: :class
339
370
  delegate :model_key, to: :class
340
371
  delegate :translation_key, to: :class
341
372
  delegate :tab, to: :items_holder
@@ -646,33 +677,37 @@ module Avo
646
677
  end
647
678
  end
648
679
 
649
- # Map the received params to their actual fields
650
- # 'resource' argument is used on avo-advanced, don't remove
651
- def fields_by_database_id(resource: self)
652
- resource.get_field_definitions
653
- .reject do |field|
654
- field.computed
655
- end
656
- .map do |field|
657
- [field.database_id.to_s, field]
658
- end
680
+ # Every form param, mapped to the field that writes it: each non-computed field's params, and each
681
+ # top-level `extra_params` name, which no field writes (nil). A field hidden from the form keeps its
682
+ # params, as a resource tool can render it. When several fields take one param (a field declared
683
+ # once per view), the one this form shows to this user writes it. `fields` narrows the candidates.
684
+ def fields_by_param(fields = get_field_definitions)
685
+ form = Avo::ViewInquirer.new(view).form?
686
+ shown, hidden = fields.reject(&:computed).partition { |field| form && field.visible_in_view?(view:) && field.visible? }
687
+ extra = Array.wrap(self.class.extra_params).select { |param| param.is_a?(Symbol) || param.is_a?(String) }
688
+
689
+ # Shown fields last, so they win a param they share
690
+ (hidden + shown)
691
+ .flat_map { |field| flatten_keys(Array.wrap(field.to_permitted_param)).map { |param| [param.to_s, field] } }
659
692
  .to_h
693
+ .merge(extra.to_h { |param| [param.to_s, nil] }) { |_param, field| field }
660
694
  end
661
695
 
662
696
  def fill_record(record, permitted_params, extra_params: [], fields: nil)
663
- fields_by_id = fields_by_database_id if fields.blank?
697
+ fields_by_key = fields.present? ? fields_by_param(fields) : fields_by_param
698
+ unresolved = []
664
699
 
665
- # Write the field values
666
- permitted_params.each do |key, value|
667
- field = if fields.present?
668
- fields.find { |f| f.id == key.to_sym }
669
- else
670
- fields_by_id[key]
671
- end
700
+ # Write each field once, whichever of its params arrived. A param left out keeps the record's current value,
701
+ # except a polymorphic id, which its field keeps only under the current type.
702
+ permitted_params.keys.filter_map { |key| fields_by_key[key.to_s] }.uniq.each do |field|
703
+ left_out = fields_by_key.filter_map { |key, owner| key if owner == field && !permitted_params.key?(key) && record.respond_to?(key) && key != field.try(:id_input_foreign_key) }
704
+ params = permitted_params.reverse_merge(left_out.index_with { |key| record.public_send(key) })
705
+ key = field.database_id.to_s
672
706
 
673
- next unless field.present?
674
-
675
- record = field.fill_field record, key, value, permitted_params
707
+ record = field.fill_field record, key, params[key], params
708
+ rescue Avo::Fields::BelongsToField::TargetNotFound => error
709
+ # Raised once every field is filled, so the form that shows the error keeps the user's other edits
710
+ unresolved << error
676
711
  end
677
712
 
678
713
  # Write the user configured extra params to the record
@@ -685,7 +720,14 @@ module Avo
685
720
  record.assign_attributes extra_attributes
686
721
  end
687
722
 
688
- safe_call(:fill_nested_records, record:, permitted_params:) || record
723
+ record = safe_call(:fill_nested_records, record:, permitted_params:) || record
724
+
725
+ if unresolved.any?
726
+ unresolved.first.field_ids = unresolved.map(&:field_id)
727
+ raise unresolved.first
728
+ end
729
+
730
+ record
689
731
  end
690
732
 
691
733
  # On the resource so one that isn't backed by a model can build, save and delete its own records,
@@ -168,6 +168,7 @@ Turning a long `belongs_to` / `has_many` picker into a type-to-search field is t
168
168
  - **A `belongs_to` field is disabled when you arrive through that same association.** Editing a Comment reached via a Post's comments panel disables the `post` field. Set `allow_via_detaching: true` to re-enable it.
169
169
  - **`polymorphic_as` and `types` are a pair** — using one without the other raises. `types` is an array of model classes (`[::Post, ::Project]`), matching a `polymorphic: true` `belongs_to`.
170
170
  - **`attach_scope` does not filter listed rows**, only the attach picker. To hide rows from a `has_many`/HABTM table, use `scope` or a Pundit policy scope.
171
+ - **Build `attach_scope` on `query` to keep the policy scope.** `query` arrives already narrowed by the target's policy scope. A fresh relation (`-> { ::User.all }`) widens past it on purpose. On every association the save accepts exactly the records the picker offers, so a record outside the `attach_scope` is refused like a missing one.
171
172
  - **STI child resources must set `self.model_class`.** When a parent class and STI children share a table, give each child resource `self.model_class = "SuperUser"` so Avo maps it correctly. To route association rows / index clicks to the child resource instead of the parent, use `self.link_to_child_resource = true` on the parent resource or `link_to_child_resource:` on the field.
172
173
  - **Searchable can look "broken" but be misconfigured** — no query source means an empty picker, not an error. And it's a paid add-on.
173
174
 
@@ -286,7 +286,7 @@ Search (`self.search`), grid/map view types, record reordering, and i18n live on
286
286
  - **Two resources, one model → wrong one wins.** Avo resolves the default alphabetically. Set `config.model_resource_mapping` and/or `use_resource:` on associations.
287
287
  - **Secondary / namespaced / oddly-named resources need `self.model_class`** (or the matching namespace) or Avo can't infer the model. Namespaced resources whose namespace matches the model's namespace infer automatically.
288
288
  - **Array resources are Beta:** no sorting, and `records` re-runs every request. Cache inside `records` for large sets, or move to an HTTP Resource.
289
- - **`find_record_method` in batch contexts:** `id` arrives as an Array for bulk actions — return a collection (`query.where(...)`) in that branch, not a single record. If that branch raises `ActiveRecord::RecordNotFound` (as `query.find(id)` does when one record was deleted between selection and submit), Avo retries the **scalar** branch once per id and drops the ones that are gone — so keep the scalar branch able to handle every id the array branch receives.
289
+ - **`find_record_method` in batch contexts:** `id` arrives as an Array for bulk actions — return a collection (`query.where(...)`) in that branch, not a single record. For every id that branch doesn't return (it raised, as `query.find(id)` does when one record was deleted between selection and submit, or it takes one id at a time), Avo retries the **scalar** branch once per id and drops the ones that are gone — so keep the scalar branch able to handle every id the array branch receives. In a batch, a custom finder's records are kept only when `query` also holds them, so ignoring it can't reach past the policy scope; a single lookup (Show, Edit, `belongs_to`) uses the finder's answer as-is, so keep reading through `query`. In your own code, `Avo::Resources::Post.find_records(ids, query:)` does the same lookup: one entry per id, in order, `nil` where none.
290
290
  - **`visible_on_sidebar` only affects the auto-generated menu.** If the app uses the menu editor, control visibility in its `visible` block instead.
291
291
  - **System specs that leave an edited form without saving now meet a confirmation dialog.** `config.warn_on_unsaved_changes` is on by default — accept the dialog with `accept_custom_alert { ... }` from `Avo::TestHelpers`, or set the option to `false` in the test environment.
292
292
  - **Don't re-invent fields/associations here.** Field DSL is the avo-fields skill; `belongs_to`/`has_many`/`use_resource` is avo-associations.
data/lib/avo/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Avo
2
- VERSION = "4.3.4" unless const_defined?(:VERSION)
2
+ VERSION = "4.3.5" unless const_defined?(:VERSION)
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: avo
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.3.4
4
+ version: 4.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Marin