avo 4.0.24 → 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 +49 -0
- data/app/assets/stylesheets/css/components/ui/dropdown.css +28 -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 +3 -0
- data/app/components/avo/u_i/dropdown_component.rb +3 -0
- data/app/controllers/avo/associations_controller.rb +70 -12
- data/lib/avo/version.rb +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: c9af230951e4e3e672c18aab3e12a7ce14bc7e5f8923960fa00e3749de11b3ac
|
|
4
|
+
data.tar.gz: 0e880b044eb331bac8b975aa5f6d8c9c426290a7792d1fbc2e9e4d9fd4e33360
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c0f04c32828259ac8209ebb26251d716f3b0ffb0b9ff6c10665a7ca7a89482fd1373b0674589fa56b87292c992c2ed4aef9df5aa5ca2c1d2ad321ebdab97e9a
|
|
7
|
+
data.tar.gz: 620e026722f24a47b8421fa20e9306f7015168c3f695bf5e2e41aef8d3587118c4b58ac0ea87c37dc68b4457a38e100fe9043103cea5902af9baaadbb53329f4
|
data/Gemfile.lock
CHANGED
|
@@ -6167,6 +6167,9 @@ tag.tagify__tag {
|
|
|
6167
6167
|
.panel__card, .panel__body {
|
|
6168
6168
|
width: 100%;
|
|
6169
6169
|
}
|
|
6170
|
+
.panel__body > .card + .card {
|
|
6171
|
+
margin-top: calc(var(--spacing) * 4);
|
|
6172
|
+
}
|
|
6170
6173
|
.panel__body[data-content-focus] {
|
|
6171
6174
|
--tw-outline-style: none;
|
|
6172
6175
|
outline-style: none;
|
|
@@ -7194,6 +7197,52 @@ tag.tagify__tag {
|
|
|
7194
7197
|
background-color: var(--color-primary);
|
|
7195
7198
|
outline-offset: var(--focus-outline-offset-inset);
|
|
7196
7199
|
}
|
|
7200
|
+
.dropdown-menu__footer {
|
|
7201
|
+
display: flex;
|
|
7202
|
+
flex-direction: column;
|
|
7203
|
+
align-self: stretch;
|
|
7204
|
+
border-top-style: var(--tw-border-style);
|
|
7205
|
+
border-top-width: 1px;
|
|
7206
|
+
border-color: var(--color-tertiary);
|
|
7207
|
+
padding: var(--spacing);
|
|
7208
|
+
}
|
|
7209
|
+
.dropdown-menu__footer :is(a, button) {
|
|
7210
|
+
display: flex;
|
|
7211
|
+
min-height: calc(var(--spacing) * 8);
|
|
7212
|
+
width: 100%;
|
|
7213
|
+
cursor: pointer;
|
|
7214
|
+
align-items: center;
|
|
7215
|
+
justify-content: flex-start;
|
|
7216
|
+
gap: calc(var(--spacing) * 1.5);
|
|
7217
|
+
border-radius: var(--radius-md);
|
|
7218
|
+
--tw-border-style: none;
|
|
7219
|
+
border-style: none;
|
|
7220
|
+
background-color: transparent;
|
|
7221
|
+
padding-inline: calc(var(--spacing) * 2);
|
|
7222
|
+
padding-block: calc(var(--spacing) * 1.5);
|
|
7223
|
+
font-size: var(--text-sm);
|
|
7224
|
+
line-height: var(--tw-leading, var(--text-sm--line-height));
|
|
7225
|
+
--tw-leading: calc(var(--spacing) * 5);
|
|
7226
|
+
line-height: calc(var(--spacing) * 5);
|
|
7227
|
+
--tw-font-weight: var(--font-weight-normal);
|
|
7228
|
+
font-weight: var(--font-weight-normal);
|
|
7229
|
+
white-space: nowrap;
|
|
7230
|
+
color: var(--color-content-secondary);
|
|
7231
|
+
&:hover {
|
|
7232
|
+
background-color: var(--color-secondary);
|
|
7233
|
+
color: var(--color-content);
|
|
7234
|
+
}
|
|
7235
|
+
&:focus-visible {
|
|
7236
|
+
background-color: var(--color-primary);
|
|
7237
|
+
outline-offset: var(--focus-outline-offset-inset);
|
|
7238
|
+
}
|
|
7239
|
+
svg {
|
|
7240
|
+
width: calc(var(--spacing) * 4);
|
|
7241
|
+
height: calc(var(--spacing) * 4);
|
|
7242
|
+
flex-shrink: 0;
|
|
7243
|
+
color: inherit;
|
|
7244
|
+
}
|
|
7245
|
+
}
|
|
7197
7246
|
.dropdown-menu__icon, .dropdown-menu__list > :is(a, button) svg {
|
|
7198
7247
|
width: calc(var(--spacing) * 4);
|
|
7199
7248
|
height: calc(var(--spacing) * 4);
|
|
@@ -160,6 +160,34 @@
|
|
|
160
160
|
outline-offset: var(--focus-outline-offset-inset);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/* ==========================================================================
|
|
164
|
+
Footer (DropdownComponent with_footer)
|
|
165
|
+
A row under the list, outside the scrollable group and the inline search's
|
|
166
|
+
filter scope — always visible. Items share the menu-item metrics but read
|
|
167
|
+
quieter, as footer actions rather than options.
|
|
168
|
+
========================================================================== */
|
|
169
|
+
|
|
170
|
+
.dropdown-menu__footer {
|
|
171
|
+
@apply flex flex-col self-stretch p-1 border-t border-tertiary;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.dropdown-menu__footer :is(a, button) {
|
|
175
|
+
@apply flex items-center w-full justify-start gap-1.5 px-2 py-1.5 min-h-8 rounded-md border-none bg-transparent whitespace-nowrap text-content-secondary font-normal text-sm cursor-pointer leading-5;
|
|
176
|
+
|
|
177
|
+
&:hover {
|
|
178
|
+
@apply bg-secondary text-content;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
&:focus-visible {
|
|
182
|
+
@apply bg-primary;
|
|
183
|
+
outline-offset: var(--focus-outline-offset-inset);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
svg {
|
|
187
|
+
@apply shrink-0 size-4 text-inherit;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
163
191
|
.dropdown-menu__icon,
|
|
164
192
|
.dropdown-menu__list > :is(a, button) svg {
|
|
165
193
|
@apply shrink-0 size-4 text-inherit;
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
@apply w-full;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/* Several `card`s declared in one `panel do ... end` block land as adjacent
|
|
25
|
+
children of the body, which is a plain block container — so without this they
|
|
26
|
+
render flush, borders touching. Matches the panel's own `gap-y-4` rhythm.
|
|
27
|
+
Adjacent-sibling scoped so a lone card stays flush with the panel. */
|
|
28
|
+
.panel__body > .card + .card {
|
|
29
|
+
@apply mt-4;
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
/* Shift+T anchors focus on the panel body. Mirror the table view's focus state:
|
|
25
33
|
suppress the body's own (clipped) outline and lift the ring to the whole panel,
|
|
26
34
|
exactly like `.card:has(table:focus-visible)` does for the index table. */
|
|
@@ -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}",
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
<% if @searchable %>
|
|
30
30
|
<p class="dropdown-menu__search-empty" data-popover-menu-target="empty" hidden>No matching options</p>
|
|
31
31
|
<% end %>
|
|
32
|
+
<% if footer? %>
|
|
33
|
+
<div class="dropdown-menu__footer"><%= footer %></div>
|
|
34
|
+
<% end %>
|
|
32
35
|
</div>
|
|
33
36
|
<% end %>
|
|
34
37
|
<% end %>
|
|
@@ -14,6 +14,9 @@ class Avo::UI::DropdownComponent < Avo::BaseComponent
|
|
|
14
14
|
|
|
15
15
|
renders_one :trigger
|
|
16
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
|
|
17
20
|
|
|
18
21
|
# this is used to trigger the dropdown menu from trigger element
|
|
19
22
|
# data: {action: component.action} => click->dropdown-menu#toggle
|
|
@@ -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
|
)
|
data/lib/avo/version.rb
CHANGED