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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39956b77dc24c5bbbd7a35d6c033f516156909bf5557dbb6373b47d4a74c0a8e
4
- data.tar.gz: d1896e708aa942746a7533b511eda01be161af473d7494e02bbd938bca04bc29
3
+ metadata.gz: c9af230951e4e3e672c18aab3e12a7ce14bc7e5f8923960fa00e3749de11b3ac
4
+ data.tar.gz: 0e880b044eb331bac8b975aa5f6d8c9c426290a7792d1fbc2e9e4d9fd4e33360
5
5
  SHA512:
6
- metadata.gz: bd7a950180b5775bb47ca3c515699017efd81516ff1888d7305ed8553dc4d0401879600e5938ecbfdcc58262246b692cdf7a6f2b1bc15a096de12a55d70d481e
7
- data.tar.gz: d84124993cb5bcac958745f9531f13f343ac7b1a7b9c8435980e87eeeeb638a7e0f9cd22d8ad5eb24890021b2d7c540d5e25ed2cfd6b58628544f1eff6595d7b
6
+ metadata.gz: 4c0f04c32828259ac8209ebb26251d716f3b0ffb0b9ff6c10665a7ca7a89482fd1373b0674589fa56b87292c992c2ed4aef9df5aa5ca2c1d2ad321ebdab97e9a
7
+ data.tar.gz: 620e026722f24a47b8421fa20e9306f7015168c3f695bf5e2e41aef8d3587118c4b58ac0ea87c37dc68b4457a38e100fe9043103cea5902af9baaadbb53329f4
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- avo (4.0.24)
4
+ avo (4.0.25)
5
5
  actionview (>= 6.1)
6
6
  active_link_to
7
7
  activerecord (>= 6.1)
@@ -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.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
  )
data/lib/avo/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Avo
2
- VERSION = "4.0.24" 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.24
4
+ version: 4.0.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Marin