avo 4.0.23 → 4.0.25

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.
@@ -57,11 +57,15 @@ class Avo::Index::ResourceControlsComponent < Avo::ResourceComponent
57
57
  Avo.resource_manager.get_resource_by_model_class @parent_record.class
58
58
  end
59
59
 
60
+ # Rails uses ThroughReflection for both `has_many :through` and
61
+ # `has_one :through`, so the class alone can't tell a collection from a
62
+ # singular association — `collection?` is what separates them.
60
63
  def is_has_many_association?
64
+ return @reflection.collection? if @reflection.instance_of?(ActiveRecord::Reflection::ThroughReflection)
65
+
61
66
  @reflection.class.in? [
62
67
  ActiveRecord::Reflection::HasManyReflection,
63
- ActiveRecord::Reflection::HasAndBelongsToManyReflection,
64
- ActiveRecord::Reflection::ThroughReflection
68
+ ActiveRecord::Reflection::HasAndBelongsToManyReflection
65
69
  ]
66
70
  end
67
71
 
@@ -17,7 +17,8 @@ class Avo::KeyboardShortcutsComponent < Avo::BaseComponent
17
17
  keys_aria_label: "Up arrow or down arrow"
18
18
  ),
19
19
  shortcut(action: "Go back", keys: ["B"]),
20
- shortcut(action: "Toggle keyboard shortcut badges", keys: ["Shift", "K"])
20
+ shortcut(action: "Toggle keyboard shortcut badges", keys: ["Shift", "K"]),
21
+ *assistant_shortcuts
21
22
  ]
22
23
  ),
23
24
  build_section(
@@ -89,6 +90,15 @@ class Avo::KeyboardShortcutsComponent < Avo::BaseComponent
89
90
 
90
91
  private
91
92
 
93
+ # avo-intelligence binds Cmd/Ctrl+J itself (its chat bar listens for the keydown directly, so it
94
+ # fires from inside a field too). Core has no assistant to open, so the modal only lists it when
95
+ # the gem is installed.
96
+ def assistant_shortcuts
97
+ return [] unless Avo.plugin_manager.installed?("avo-intelligence")
98
+
99
+ [shortcut(action: "Open the assistant", keys: {mac: ["Cmd", "J"], other: ["Ctrl", "J"]})]
100
+ end
101
+
92
102
  def build_section(title, shortcuts)
93
103
  {
94
104
  id: "hotkey-group-#{title.parameterize.underscore}",
@@ -9,11 +9,29 @@
9
9
  class: class_names("popover-menu__panel", @classes),
10
10
  data: {controller: "popover-menu"} do %>
11
11
  <div class="dropdown-menu">
12
+ <% if @searchable %>
13
+ <div class="dropdown-menu__search search-input">
14
+ <input type="search"
15
+ class="search-input__input"
16
+ placeholder="<%= search_placeholder %>"
17
+ aria-label="<%= search_placeholder %>"
18
+ autocomplete="off"
19
+ data-popover-menu-target="searchInput"
20
+ data-action="input->popover-menu#filter">
21
+ <span class="search-input__prefix" aria-hidden="true"><%= helpers.svg "tabler/outline/search" %></span>
22
+ </div>
23
+ <% end %>
12
24
  <div class="dropdown-menu__group">
13
25
  <div class="dropdown-menu__list">
14
26
  <%= items %>
15
27
  </div>
16
28
  </div>
29
+ <% if @searchable %>
30
+ <p class="dropdown-menu__search-empty" data-popover-menu-target="empty" hidden>No matching options</p>
31
+ <% end %>
32
+ <% if footer? %>
33
+ <div class="dropdown-menu__footer"><%= footer %></div>
34
+ <% end %>
17
35
  </div>
18
36
  <% end %>
19
37
  <% end %>
@@ -7,9 +7,16 @@ class Avo::UI::DropdownComponent < Avo::BaseComponent
7
7
  prop :open, default: false
8
8
  prop :dropdown_menu_classes, default: ""
9
9
  prop :popover_mode, default: false
10
+ # Renders a filter input above the items that narrows them as the user types
11
+ # (client-side, over the rendered items). Popover mode only.
12
+ prop :searchable, default: false
13
+ prop :search_placeholder
10
14
 
11
15
  renders_one :trigger
12
16
  renders_one :items
17
+ # A row pinned under the list — outside the scrollable group and the inline
18
+ # search's filter scope, so it stays visible. Popover mode only.
19
+ renders_one :footer
13
20
 
14
21
  # this is used to trigger the dropdown menu from trigger element
15
22
  # data: {action: component.action} => click->dropdown-menu#toggle
@@ -21,6 +28,10 @@ class Avo::UI::DropdownComponent < Avo::BaseComponent
21
28
  @popover_id ||= "popover-#{SecureRandom.hex(3)}"
22
29
  end
23
30
 
31
+ def search_placeholder
32
+ @search_placeholder || I18n.t("avo.search.placeholder", default: "Search")
33
+ end
34
+
24
35
  def data
25
36
  return {} if items.blank?
26
37
 
@@ -110,7 +110,7 @@ module Avo
110
110
  association_name = BaseResource.valid_association_name(@record, @field.for_attribute || params[:related_name])
111
111
 
112
112
  if through_reflection?
113
- join_record.destroy!
113
+ join_record&.destroy!
114
114
  elsif has_many_reflection?
115
115
  @record.send(association_name).delete @attachment_record
116
116
  else
@@ -223,13 +223,29 @@ module Avo
223
223
  @reflection.source_reflection.foreign_key
224
224
  end
225
225
 
226
- def through_foreign_key
227
- @reflection.through_reflection.foreign_key
226
+ # Resolve the join record through the (scoped) through association rather
227
+ # than an unscoped `find_by` on the join model. When a pair of records is
228
+ # linked more than once, the association's scope is the only thing that
229
+ # tells one join row from another — `-> { where level: :admin }` picking the
230
+ # admin row out of a user's memberships, say. An unscoped lookup matches on
231
+ # the two foreign keys alone and destroys whichever row it happens to hit.
232
+ #
233
+ # A `has_many :through` narrows the association down to the record being
234
+ # detached. A `has_one :through` already *is* that record, so it only has to
235
+ # be checked against the one named in the URL — otherwise a stale page or a
236
+ # double detach would destroy whatever is currently attached.
237
+ def join_record
238
+ return through_association.find_by(source_foreign_key => @attachment_record.id) if @reflection.collection?
239
+
240
+ record = through_association
241
+ record if record.present? && record[source_foreign_key] == @attachment_record.id
228
242
  end
229
243
 
230
- def join_record
231
- @reflection.through_reflection.klass.find_by(source_foreign_key => @attachment_record.id,
232
- through_foreign_key => @record.id)
244
+ # The through association itself: a collection proxy for `has_many :through`,
245
+ # the join record (or nil) for `has_one :through`. Reading it rather than the
246
+ # join model is what keeps the association's scope in play.
247
+ def through_association
248
+ @record.send(@reflection.through_reflection.name)
233
249
  end
234
250
 
235
251
  def has_many_reflection?
@@ -243,6 +259,13 @@ module Avo
243
259
  @reflection.instance_of? ActiveRecord::Reflection::ThroughReflection
244
260
  end
245
261
 
262
+ # Rails uses ThroughReflection for both `has_many :through` and
263
+ # `has_one :through`, so `through_reflection?` alone can't tell a collection
264
+ # from a singular association.
265
+ def collection_through_reflection?
266
+ through_reflection? && @reflection.collection?
267
+ end
268
+
246
269
  def additional_params
247
270
  @additional_params ||= params[:fields].slice(*@attach_fields&.map(&:id))
248
271
  end
@@ -257,22 +280,57 @@ module Avo
257
280
  end
258
281
 
259
282
  def attach_record(association_name, attachment_record)
260
- if through_reflection? && additional_params.present?
283
+ # Hand-build the join record only when attach fields have to land before
284
+ # the insert: `<<` saves immediately, and a join model that validates one
285
+ # of those columns (StorePatron#review) fails before we could fill it.
286
+ # Otherwise prefer `<<` — it fills in the polymorphic source type and
287
+ # handles composite primary keys, neither of which we do by hand.
288
+ #
289
+ # Collection-only, and not merely by preference: it builds *through* the
290
+ # association, and `new` is a collection proxy method. A singular through
291
+ # also needs replace semantics, which only assignment gives — building a
292
+ # second join record would leave the association with two rows to pick
293
+ # from.
294
+ if collection_through_reflection? && additional_params.present?
261
295
  new_join_record(attachment_record).save!
262
- elsif has_many_reflection? || through_reflection?
296
+ elsif has_many_reflection? || collection_through_reflection?
263
297
  @record.send(association_name) << attachment_record
264
298
  else
265
299
  @record.send(:"#{association_name}=", attachment_record)
266
300
  @record.save!
301
+
302
+ persist_join_record if through_reflection?
267
303
  end
268
304
  end
269
305
 
306
+ # A singular through association owns exactly one join record, and the
307
+ # assignment in `attach_record` already created or replaced it through the
308
+ # (scoped) through association.
309
+ def persist_join_record
310
+ through_record = through_association
311
+
312
+ return if through_record.blank?
313
+
314
+ # Fill the attach fields onto that row instead of inserting a second one
315
+ # that the association's scope wouldn't even match.
316
+ @resource.fill_record(through_record, additional_params, fields: @attach_fields) if additional_params.present?
317
+
318
+ # Rails writes the join record with `create`, which returns an unsaved
319
+ # record instead of raising when it's invalid. Without this `save!` the
320
+ # attach would report success while nothing was written.
321
+ through_record.save!
322
+ end
323
+
324
+ # Build the join record *through* the (scoped) through association, so Rails
325
+ # stamps the scope's attributes on it — `-> { where level: :admin }` writing
326
+ # `level` — along with the through foreign key. Building it on the join
327
+ # model instead writes the two foreign keys and nothing else, so a scoped
328
+ # association reports a successful attach and then doesn't match the row it
329
+ # just wrote. The `<<` arm below already goes through the association; this
330
+ # only differs in having attach fields to fill.
270
331
  def new_join_record(attachment_record)
271
332
  @resource.fill_record(
272
- @reflection.through_reflection.klass.new(
273
- source_foreign_key => attachment_record.id,
274
- through_foreign_key => @record.id
275
- ),
333
+ through_association.new(source_foreign_key => attachment_record.id),
276
334
  additional_params,
277
335
  fields: @attach_fields
278
336
  )
@@ -1,6 +1,8 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
 
3
3
  export default class extends Controller {
4
+ static targets = ['searchInput', 'empty']
5
+
4
6
  // Used by both onToggle (auto-focus) and handleKeydown (arrow navigation).
5
7
  get focusableItems() {
6
8
  return [...this.element.querySelectorAll('a, button')].filter(
@@ -25,9 +27,19 @@ export default class extends Controller {
25
27
  onToggle(event) {
26
28
  if (event.newState !== 'open') return
27
29
 
30
+ // Each open starts unfiltered — a leftover query from the last visit would
31
+ // silently hide items.
32
+ if (this.hasSearchInputTarget) this.resetFilter()
33
+
28
34
  // requestAnimationFrame ensures the popover is fully rendered before we focus.
29
35
  // Focusing before the browser paints causes the scroll to jump in some cases.
30
36
  requestAnimationFrame(() => {
37
+ // A searchable menu hands focus to its filter input so the user can just type.
38
+ if (this.hasSearchInputTarget) {
39
+ this.searchInputTarget.focus()
40
+ return
41
+ }
42
+
31
43
  const items = this.focusableItems
32
44
  if (items.length === 0) return
33
45
 
@@ -38,24 +50,46 @@ export default class extends Controller {
38
50
  })
39
51
  }
40
52
 
53
+ // Narrows the rendered items to those whose text matches the query. Items are
54
+ // queried at call time (not cached) so lazily loaded content — e.g. a
55
+ // turbo-frame inside the list — is filterable as soon as it arrives. An item
56
+ // carrying data-filter-text matches on that instead of its full text, so
57
+ // decorations (timestamps, tags) don't count as matches.
58
+ filter() {
59
+ const query = this.searchInputTarget.value.trim().toLowerCase()
60
+ let anyVisible = false
61
+
62
+ this.element.querySelectorAll('.dropdown-menu__list a, .dropdown-menu__list button').forEach((item) => {
63
+ const text = item.dataset.filterText ?? item.textContent
64
+ const match = query === '' || text.replace(/\s+/g, ' ').trim().toLowerCase().includes(query)
65
+ item.hidden = !match
66
+ if (match) anyVisible = true
67
+ })
68
+
69
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = anyVisible || query === ''
70
+ }
71
+
72
+ resetFilter() {
73
+ this.searchInputTarget.value = ''
74
+ this.filter()
75
+ }
76
+
41
77
  handleKeydown(event) {
42
78
  // The keydown listener is always attached, so guard against firing when closed.
43
79
  if (!this.element.matches(':popover-open')) return
44
80
 
45
- const items = this.focusableItems
46
- if (items.length === 0) return
47
-
48
- const idx = items.indexOf(document.activeElement)
49
-
50
81
  switch (event.key) {
51
82
  case 'ArrowDown':
83
+ case 'ArrowUp': {
84
+ const items = this.focusableItems
85
+ if (items.length === 0) return
86
+
52
87
  event.preventDefault()
53
- items[idx < items.length - 1 ? idx + 1 : 0].focus()
54
- break
55
- case 'ArrowUp':
56
- event.preventDefault()
57
- items[idx > 0 ? idx - 1 : items.length - 1].focus()
88
+ const idx = items.indexOf(document.activeElement)
89
+ if (event.key === 'ArrowDown') items[idx < items.length - 1 ? idx + 1 : 0].focus()
90
+ else items[idx > 0 ? idx - 1 : items.length - 1].focus()
58
91
  break
92
+ }
59
93
  case 'Escape':
60
94
  // Close on Escape ourselves. Native popover light-dismiss is unreliable
61
95
  // here because other document-level keydown handlers (e.g. the index-row
@@ -63,7 +97,13 @@ export default class extends Controller {
63
97
  // also reacting; hidePopover restores focus to the trigger.
64
98
  event.preventDefault()
65
99
  event.stopPropagation()
66
- this.element.hidePopover()
100
+ // With a non-empty search query the first Escape only clears it; the next closes.
101
+ if (this.hasSearchInputTarget && this.searchInputTarget.value !== '') {
102
+ this.resetFilter()
103
+ this.searchInputTarget.focus()
104
+ } else {
105
+ this.element.hidePopover()
106
+ }
67
107
  break
68
108
  default:
69
109
  }
data/lib/avo/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Avo
2
- VERSION = "4.0.23" unless const_defined?(:VERSION)
2
+ VERSION = "4.0.25" 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.0.23
4
+ version: 4.0.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Marin
@@ -224,6 +224,8 @@ files:
224
224
  - app/assets/builds/avo/application.css
225
225
  - app/assets/builds/avo/application.js
226
226
  - app/assets/builds/avo/application.js.map
227
+ - app/assets/builds/avo/avo.custom.js
228
+ - app/assets/builds/avo/avo.custom.js.map
227
229
  - app/assets/builds/avo/dependencies.css
228
230
  - app/assets/builds/avo/late-registration.js
229
231
  - app/assets/builds/avo/late-registration.js.map