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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/assets/builds/avo/application.css +93 -0
- data/app/assets/builds/avo/application.js +1 -1
- data/app/assets/builds/avo/application.js.map +3 -3
- data/app/assets/builds/avo/avo.custom.js +6 -0
- data/app/assets/builds/avo/avo.custom.js.map +7 -0
- data/app/assets/stylesheets/css/components/ui/dropdown.css +83 -0
- data/app/assets/stylesheets/css/components/ui/panel.css +8 -0
- data/app/components/avo/index/resource_controls_component.rb +6 -2
- data/app/components/avo/keyboard_shortcuts_component.rb +11 -1
- data/app/components/avo/u_i/dropdown_component.html.erb +18 -0
- data/app/components/avo/u_i/dropdown_component.rb +11 -0
- data/app/controllers/avo/associations_controller.rb +70 -12
- data/app/javascript/js/controllers/popover_menu_controller.js +51 -11
- data/lib/avo/version.rb +1 -1
- metadata +3 -1
|
@@ -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
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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? ||
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
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.
|
|
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
|