plutonium 0.62.2 → 0.64.0

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.
Files changed (256) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/.standard.yml +1 -1
  13. data/CHANGELOG.md +73 -0
  14. data/CLAUDE.md +87 -0
  15. data/Rakefile +34 -0
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +685 -102
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +53 -53
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/app/views/rodauth/_login_form.html.erb +13 -0
  23. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  24. data/docs/.vitepress/config.ts +81 -3
  25. data/docs/.vitepress/theme/blog.data.ts +44 -0
  26. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  27. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  28. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  29. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  30. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  31. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  32. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  33. data/docs/.vitepress/theme/index.ts +8 -4
  34. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  35. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  36. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  37. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  38. data/docs/blog/index.md +13 -0
  39. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  40. data/docs/blog/introducing-plutonium.md +253 -0
  41. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  42. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  43. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  44. data/docs/blog/two-forms-one-dom-id.md +69 -0
  45. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  46. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  47. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  48. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  49. data/docs/guides/authentication.md +73 -0
  50. data/docs/guides/authorization.md +2 -0
  51. data/docs/guides/creating-packages.md +5 -3
  52. data/docs/guides/custom-actions.md +74 -12
  53. data/docs/guides/customizing-ui.md +9 -2
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +7 -5
  56. data/docs/guides/nested-resources.md +11 -1
  57. data/docs/guides/performance.md +104 -0
  58. data/docs/guides/user-invites.md +1 -1
  59. data/docs/guides/wizards.md +9 -1
  60. data/docs/index.md +3 -3
  61. data/docs/public/images/home/tour-actions.png +0 -0
  62. data/docs/public/images/home/tour-async.png +0 -0
  63. data/docs/public/images/home/tour-kanban.png +0 -0
  64. data/docs/public/images/home/tour-tenancy.png +0 -0
  65. data/docs/public/images/home/tour-wizard.png +0 -0
  66. data/docs/public/images/reference/async-progress-page.png +0 -0
  67. data/docs/public/images/reference/async-running-banner.png +0 -0
  68. data/docs/public/templates/experimental.rb +34 -0
  69. data/docs/public/templates/pluton8.rb +14 -0
  70. data/docs/reference/app/portals.md +15 -3
  71. data/docs/reference/auth/accounts.md +19 -0
  72. data/docs/reference/behavior/async-interactions.md +295 -0
  73. data/docs/reference/behavior/controllers.md +17 -4
  74. data/docs/reference/behavior/index.md +7 -1
  75. data/docs/reference/behavior/interactions.md +152 -22
  76. data/docs/reference/configuration.md +5 -0
  77. data/docs/reference/index.md +1 -0
  78. data/docs/reference/kanban/dsl.md +7 -4
  79. data/docs/reference/kanban/index.md +1 -1
  80. data/docs/reference/kanban/positioning.md +26 -4
  81. data/docs/reference/positioning.md +568 -0
  82. data/docs/reference/resource/actions.md +97 -4
  83. data/docs/reference/resource/definition.md +181 -9
  84. data/docs/reference/tenancy/invites.md +1 -1
  85. data/docs/reference/tenancy/nested-resources.md +60 -2
  86. data/docs/reference/ui/assets.md +4 -0
  87. data/docs/reference/ui/components.md +57 -4
  88. data/docs/reference/ui/displays.md +20 -10
  89. data/docs/reference/ui/index.md +1 -1
  90. data/docs/reference/wizard/dsl.md +33 -0
  91. data/docs/reference/wizard/storage-config.md +1 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  93. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  95. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  97. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  98. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  99. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  100. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  101. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  102. data/gemfiles/postgres.gemfile.lock +85 -85
  103. data/gemfiles/rails_7.gemfile.lock +323 -141
  104. data/gemfiles/rails_8.0.gemfile.lock +126 -116
  105. data/gemfiles/rails_8.1.gemfile.lock +127 -117
  106. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  107. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  108. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  109. data/lib/generators/pu/invites/install_generator.rb +3 -3
  110. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  112. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  113. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  114. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  115. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  116. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  118. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  119. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  120. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  121. data/lib/plutonium/action/base.rb +71 -9
  122. data/lib/plutonium/action/interactive.rb +9 -0
  123. data/lib/plutonium/attachments.rb +254 -0
  124. data/lib/plutonium/configuration.rb +82 -1
  125. data/lib/plutonium/core/controller.rb +50 -7
  126. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  127. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  128. data/lib/plutonium/definition/base.rb +51 -0
  129. data/lib/plutonium/definition/display_layout.rb +112 -0
  130. data/lib/plutonium/definition/index_views.rb +8 -7
  131. data/lib/plutonium/definition/input_aliases.rb +38 -0
  132. data/lib/plutonium/definition/page_widths.rb +65 -0
  133. data/lib/plutonium/definition/positioning.rb +126 -0
  134. data/lib/plutonium/definition/sorting.rb +17 -2
  135. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  136. data/lib/plutonium/interaction/README.md +61 -24
  137. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  138. data/lib/plutonium/interaction/async/context.rb +419 -0
  139. data/lib/plutonium/interaction/async/executor.rb +422 -0
  140. data/lib/plutonium/interaction/async/job.rb +80 -0
  141. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  142. data/lib/plutonium/interaction/async/run.rb +394 -0
  143. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  144. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  145. data/lib/plutonium/interaction/base.rb +34 -7
  146. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  147. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  148. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  149. data/lib/plutonium/kanban/board.rb +14 -0
  150. data/lib/plutonium/kanban/column.rb +4 -2
  151. data/lib/plutonium/kanban/dsl.rb +4 -1
  152. data/lib/plutonium/kanban/grouping.rb +9 -22
  153. data/lib/plutonium/kanban/positioning.rb +5 -65
  154. data/lib/plutonium/positioning/config.rb +94 -0
  155. data/lib/plutonium/positioning/model.rb +128 -0
  156. data/lib/plutonium/positioning.rb +25 -86
  157. data/lib/plutonium/railtie.rb +1 -0
  158. data/lib/plutonium/resource/controller.rb +118 -38
  159. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  160. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  161. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  162. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  163. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  164. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  165. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  166. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  167. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  168. data/lib/plutonium/resource/policy.rb +33 -0
  169. data/lib/plutonium/resource/query_object.rb +36 -0
  170. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  171. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  172. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  173. data/lib/plutonium/testing/resource_policy.rb +6 -2
  174. data/lib/plutonium/ui/action_button.rb +12 -7
  175. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  176. data/lib/plutonium/ui/block.rb +21 -1
  177. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  178. data/lib/plutonium/ui/component/methods.rb +5 -0
  179. data/lib/plutonium/ui/component/positionable.rb +112 -0
  180. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  181. data/lib/plutonium/ui/component/section.rb +185 -0
  182. data/lib/plutonium/ui/display/base.rb +13 -1
  183. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  184. data/lib/plutonium/ui/display/components/section.rb +18 -0
  185. data/lib/plutonium/ui/display/resource.rb +141 -22
  186. data/lib/plutonium/ui/display/theme.rb +20 -1
  187. data/lib/plutonium/ui/export_button.rb +1 -1
  188. data/lib/plutonium/ui/form/base.rb +8 -7
  189. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  190. data/lib/plutonium/ui/form/components/section.rb +7 -62
  191. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  192. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  194. data/lib/plutonium/ui/form/query.rb +2 -4
  195. data/lib/plutonium/ui/form/resource.rb +92 -15
  196. data/lib/plutonium/ui/form/theme.rb +17 -0
  197. data/lib/plutonium/ui/form/wizard.rb +25 -1
  198. data/lib/plutonium/ui/grid/card.rb +79 -11
  199. data/lib/plutonium/ui/grid/resource.rb +47 -5
  200. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  201. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  202. data/lib/plutonium/ui/kanban/card.rb +2 -1
  203. data/lib/plutonium/ui/kanban/column.rb +12 -6
  204. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  205. data/lib/plutonium/ui/layout/base.rb +10 -3
  206. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  207. data/lib/plutonium/ui/page/base.rb +19 -0
  208. data/lib/plutonium/ui/page/edit.rb +4 -1
  209. data/lib/plutonium/ui/page/index.rb +69 -18
  210. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  211. data/lib/plutonium/ui/page/new.rb +4 -1
  212. data/lib/plutonium/ui/page/show.rb +27 -10
  213. data/lib/plutonium/ui/page/wizard.rb +10 -1
  214. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  215. data/lib/plutonium/ui/page_width.rb +58 -0
  216. data/lib/plutonium/ui/table/base.rb +34 -1
  217. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  218. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  219. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  220. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  221. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  222. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  223. data/lib/plutonium/ui/table/resource.rb +50 -5
  224. data/lib/plutonium/ui/table/theme.rb +59 -2
  225. data/lib/plutonium/ui/wizard/review.rb +4 -2
  226. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  227. data/lib/plutonium/version.rb +1 -1
  228. data/lib/plutonium/wizard/attachments.rb +32 -197
  229. data/lib/plutonium/wizard/base.rb +6 -1
  230. data/lib/plutonium/wizard/configuration.rb +12 -0
  231. data/lib/plutonium/wizard/controller.rb +14 -0
  232. data/lib/plutonium/wizard/driving.rb +99 -15
  233. data/lib/plutonium/wizard/dsl.rb +23 -0
  234. data/lib/plutonium/wizard/resume.rb +127 -49
  235. data/lib/plutonium/wizard/runner.rb +46 -2
  236. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  237. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  238. data/lib/plutonium.rb +21 -0
  239. data/lib/rodauth/features/session_isolation.rb +92 -0
  240. data/lib/rodauth/plugins.rb +1 -0
  241. data/package.json +2 -1
  242. data/plutonium.gemspec +20 -20
  243. data/src/css/components.css +89 -1
  244. data/src/css/slim_select.css +20 -0
  245. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  246. data/src/js/controllers/bulk_actions_controller.js +10 -2
  247. data/src/js/controllers/kanban_controller.js +30 -21
  248. data/src/js/controllers/positioned_controller.js +452 -0
  249. data/src/js/controllers/register_controllers.js +6 -0
  250. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  251. data/src/js/controllers/run_progress_controller.js +73 -0
  252. data/src/js/drag/sortable.js +186 -0
  253. data/yarn.lock +108 -63
  254. metadata +101 -22
  255. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  256. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -8,9 +8,28 @@ module Plutonium
8
8
  super.merge({
9
9
  base: "",
10
10
  value_wrapper: "max-h-[300px] overflow-y-auto",
11
- fields_wrapper: "pu-card",
11
+ # Merged into the fields_wrapper's Block, which supplies `pu-card`
12
+ # itself — this is only for anything a caller wants to add on top.
13
+ fields_wrapper: nil,
12
14
  fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
13
15
 
16
+ # display_layout sectioning. `sections_wrapper` replaces
17
+ # `fields_inner` as the card's inner padding box when a layout is
18
+ # declared (it stacks sections instead of fields directly), and
19
+ # each section's field grid uses `section_grid` — the same grid as
20
+ # `fields_inner` without the padding, which the wrapper now owns.
21
+ # Split into their own keys so overriding one path doesn't
22
+ # silently reshape the other.
23
+ # Sections are their OWN cards (see Component::Section), so this
24
+ # only has to stack them — no card, no padding of its own.
25
+ sections_wrapper: "space-y-4",
26
+ section_grid: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
27
+
28
+ # Section chrome (heading, accent, divider, collapsible caret).
29
+ # Shared defaults with the form so both read the same; override any
30
+ # key here to restyle show-page sections alone.
31
+ **Plutonium::UI::Component::Section::DEFAULT_THEME,
32
+
14
33
  # Labels and descriptions
15
34
  label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
16
35
  description: "text-sm text-[var(--pu-text-subtle)]",
@@ -63,7 +63,7 @@ module Plutonium
63
63
 
64
64
  def render_menu
65
65
  div(
66
- class: "hidden absolute right-0 top-full z-50 mt-1 w-48 origin-top-right bg-[var(--pu-surface)] " \
66
+ class: "hidden absolute right-0 top-full z-50 mt-1 w-max min-w-48 max-w-[min(20rem,90vw)] origin-top-right bg-[var(--pu-surface)] " \
67
67
  "border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
68
68
  style: "box-shadow: var(--pu-shadow-lg)",
69
69
  data: {resource_drop_down_target: "menu"}
@@ -9,6 +9,7 @@ module Plutonium
9
9
  class Builder < Builder
10
10
  include Phlexi::Field::Common::Tokens
11
11
  include Plutonium::UI::Form::Options::InferredTypes
12
+ include Plutonium::UI::Component::ResolvesTags
12
13
 
13
14
  # Consume `:as` here so it doesn't land in Phlexi's `@options` —
14
15
  # `:as` is a Plutonium-internal concept (it picks the tag method),
@@ -74,16 +75,16 @@ module Plutonium
74
75
  end
75
76
  alias_method :phone_tag, :int_tel_input_tag
76
77
 
77
- # The `as:` values that render through the Uppy file-upload component —
78
- # the single source of truth, also consulted by
79
- # {Plutonium::Wizard::Attachments.field?} so a wizard can detect an
80
- # attachment field model-free without re-listing the aliases.
81
- FILE_INPUT_TYPES = %i[uppy file attachment].freeze
82
-
83
78
  def uppy_tag(**, &)
84
79
  create_component(Components::Uppy, :uppy, **, &)
85
80
  end
86
- (FILE_INPUT_TYPES - [:uppy]).each { |name| alias_method :"#{name}_tag", :uppy_tag }
81
+ # Every file alias renders through Uppy. The list itself lives with the
82
+ # other `as:` semantics in {Plutonium::Definition::InputAliases}, which
83
+ # is also where non-view callers (controller, wizard, generators) ask
84
+ # whether an `as:` is an attachment — so the two never drift.
85
+ (Plutonium::Definition::InputAliases::FILE_INPUT_TYPES - [:uppy]).each do |name|
86
+ alias_method :"#{name}_tag", :uppy_tag
87
+ end
87
88
 
88
89
  def key_value_store_tag(**, &)
89
90
  create_component(Components::KeyValueStore, :key_value_store, **, &)
@@ -35,7 +35,7 @@ module Plutonium
35
35
  # `Plutonium.configuration.default_phone_country`.
36
36
  def build_intl_options
37
37
  options = {}
38
- if (country = attributes.delete(:initial_country) || Plutonium.configuration.default_phone_country)
38
+ if (country = attributes.delete(:initial_country) || Plutonium.configuration.normalized_default_phone_country)
39
39
  options[:initialCountry] = country
40
40
  end
41
41
  if (extra = attributes.delete(:intl_options))
@@ -4,68 +4,13 @@ module Plutonium
4
4
  module UI
5
5
  module Form
6
6
  module Components
7
- # Renders a form section's chrome (heading/description, optional native
8
- # <details> collapsible, and a fields grid) and yields to a block that
9
- # renders the section's fields (the form supplies render_resource_field).
10
- class Section < Plutonium::UI::Component::Base
11
- def initialize(resolved, grid_class:)
12
- @section = resolved.section
13
- @grid_class = grid_class
14
- end
15
-
16
- SECTION_CLASS = "space-y-5 pt-7 first:pt-0"
17
- # A short primary accent rule to the left of the heading — anchors the
18
- # section and adds a touch of brand. Shared by the grouped <div> header
19
- # and the collapsible <summary> so both read the same.
20
- ACCENT_CLASS = "border-l-[3px] border-primary-500 pl-3.5"
21
- HEADING_CLASS = "text-base font-semibold tracking-tight text-[var(--pu-text)]"
22
- SUMMARY_CLASS = "#{HEADING_CLASS} #{ACCENT_CLASS} cursor-pointer select-none"
23
- # font-normal resets the semibold inherited from a <summary> parent.
24
- DESCRIPTION_CLASS = "mt-1 text-sm font-normal text-[var(--pu-text-muted)]"
25
-
26
- def view_template(&fields_block)
27
- if @section.collapsible?
28
- details(open: !@section.collapsed?, class: SECTION_CLASS) do
29
- # <summary> must be the first child of <details> and can't be
30
- # wrapped, so the title text and its description both live inside
31
- # it — keeping the description hugging the title under one accent.
32
- summary(class: SUMMARY_CLASS) do
33
- plain heading_text
34
- describe
35
- end
36
- grid(&fields_block)
37
- end
38
- else
39
- div(class: SECTION_CLASS) do
40
- header_block
41
- grid(&fields_block)
42
- end
43
- end
44
- end
45
-
46
- private
47
-
48
- # Title + description grouped under one accented header so the
49
- # description hugs the heading (mt-1) instead of inheriting the
50
- # section's larger vertical rhythm.
51
- def header_block
52
- return if @section.ungrouped? && @section.options[:label].nil?
53
- div(class: ACCENT_CLASS) do
54
- h3(class: HEADING_CLASS) { heading_text }
55
- describe
56
- end
57
- end
58
-
59
- def heading_text = @section.label
60
-
61
- def describe
62
- return unless @section.description
63
- p(class: DESCRIPTION_CLASS) { @section.description }
64
- end
65
-
66
- def grid(&fields_block)
67
- div(class: @grid_class, &fields_block)
68
- end
7
+ # A form's declared `form_layout` section. All chrome lives in the
8
+ # shared {Plutonium::UI::Component::Section} so a form section and a
9
+ # show-page section cannot drift apart; the form contributes only its
10
+ # own grid class (via `columns:`) and the block that renders the
11
+ # fields (the form supplies render_resource_field).
12
+ class Section < Plutonium::UI::Component::Section
13
+ def self.theme_class = Plutonium::UI::Form::Theme
69
14
  end
70
15
  end
71
16
  end
@@ -54,9 +54,20 @@ module Plutonium
54
54
 
55
55
  private
56
56
 
57
+ # Previews for what is ALREADY attached.
58
+ #
59
+ # A model-backed field holds attachment objects, which answer +url+. An
60
+ # interaction's field does not: between submit and re-render it holds
61
+ # whatever the user posted — an ActionDispatch::Http::UploadedFile, or
62
+ # the token staging replaced it with. Neither answers +url+, and
63
+ # calling it took the whole form down with a NoMethodError on any
64
+ # validation failure, including one on an unrelated attribute.
65
+ #
66
+ # A value that cannot answer +url+ is not an existing attachment, so
67
+ # there is nothing here to preview for it.
57
68
  def render_existing_attachments
58
69
  Array(field.value).each do |attachment|
59
- next unless attachment&.url.present?
70
+ next unless attachment.respond_to?(:url) && attachment.url.present?
60
71
 
61
72
  render_attachment_preview(attachment)
62
73
  end
@@ -23,11 +23,17 @@ module Plutonium
23
23
  class NestedFieldContext
24
24
  attr_reader :name, :definition, :options, :permitted_fields
25
25
 
26
- def initialize(name:, definition:, resource_class:, resource_definition:, object_class:)
26
+ # @param declared_options [Hash] the nested input's own options, already
27
+ # proc-resolved by the form (the context has no form to resolve
28
+ # against). Everything merged in below — nested-attribute macro,
29
+ # limits — is derived from the model, never authored, so this is the
30
+ # only half that can carry a proc.
31
+ def initialize(name:, definition:, resource_class:, resource_definition:, object_class:, declared_options:)
27
32
  @name = name
28
33
  @definition = definition
29
34
  @resource_definition = resource_definition
30
35
  @resource_class = resource_class
36
+ @declared_options = declared_options
31
37
  @options = build_options
32
38
  @permitted_fields = build_permitted_fields
33
39
  @object_class = object_class
@@ -52,7 +58,7 @@ module Plutonium
52
58
  private
53
59
 
54
60
  def build_options
55
- options = @resource_definition.defined_nested_inputs[@name][:options].dup || {}
61
+ options = @declared_options.dup
56
62
  merge_nested_fields_options(options)
57
63
  set_nested_fields_limits(options)
58
64
  options
@@ -97,12 +103,19 @@ module Plutonium
97
103
  return
98
104
  end
99
105
 
106
+ # Resolved here rather than in the context: `condition:` above needs the
107
+ # raw proc (resolve_option_procs deliberately leaves it alone, but it is
108
+ # also evaluated before this point), and the context has no form to
109
+ # resolve the rest against.
110
+ declared_options = resolve_option_procs(nested_input_definition[:options] || {})
111
+
100
112
  context = NestedFieldContext.new(
101
113
  name: name,
102
114
  definition: build_nested_fields_definition(name),
103
115
  resource_class: resource_class,
104
116
  resource_definition: resource_definition,
105
- object_class: nested_input_definition[:options]&.fetch(:object_class, nil)
117
+ object_class: declared_options[:object_class],
118
+ declared_options: declared_options
106
119
  )
107
120
 
108
121
  render_nested_field_container(context) do
@@ -15,7 +15,11 @@ module Plutonium
15
15
 
16
16
  def render_structured_input(name)
17
17
  entry = resource_definition.defined_structured_inputs[name]
18
- options = entry[:options] || {}
18
+ # Same proc resolution the inner fields already get (they render
19
+ # through render_simple_resource_field on this same form), so
20
+ # `repeat:`/`fields:` can depend on the request — or, on a wizard
21
+ # step, on the run: the receiver rule is the form's, not ours.
22
+ options = resolve_option_procs(entry[:options] || {})
19
23
  definition = structured_input_fields_definition(entry)
20
24
  fields = options[:fields] || definition.defined_inputs.keys
21
25
  repeat = options[:repeat]
@@ -102,6 +102,7 @@ module Plutonium
102
102
  button(
103
103
  type: "button",
104
104
  class: "pu-btn pu-btn-secondary px-4 py-3 text-base",
105
+ aria: {expanded: "false"},
105
106
  data: {resource_drop_down_target: "trigger"}
106
107
  ) do
107
108
  render Phlex::TablerIcons::Filter.new(class: "w-4 h-4 inline-block align-text-bottom")
@@ -186,10 +187,7 @@ module Plutonium
186
187
  tag = input_options[:as] || field_options[:as]
187
188
  tag_attributes = input_options.except(:wrapper, :as)
188
189
 
189
- tag_block = input_definition[:block] || ->(f) {
190
- tag ||= f.inferred_field_component
191
- f.send(:"#{tag}_tag", **tag_attributes, class: "w-full")
192
- }
190
+ tag_block = input_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes, class: "w-full") }
193
191
 
194
192
  field_options = field_options.except(:as)
195
193
 
@@ -49,8 +49,20 @@ module Plutonium
49
49
  end
50
50
  end
51
51
 
52
+ # The form is normally a card (theme :base). When a form_layout is
53
+ # declared each section becomes a card of its own, so the form must
54
+ # stop being one — otherwise every section is a card nested inside a
55
+ # card, doubling borders and padding. The sections' own container
56
+ # supplies the spacing that :base's padding used to.
57
+ #
58
+ # Width comes from configuration rather than the theme (`page_width` /
59
+ # `form_width` on the definition), so it is appended here. A modal form
60
+ # gets none of this: the dialog already sets its own width.
52
61
  def form_class
53
- in_modal? ? "flex-1 flex flex-col min-h-0" : super
62
+ return "flex-1 flex flex-col min-h-0" if in_modal?
63
+
64
+ base = resource_definition.defined_form_layout ? themed(:sectioned_base, nil) : super
65
+ tokens(base, resource_definition.form_width_classes)
54
66
  end
55
67
 
56
68
  private
@@ -104,7 +116,15 @@ module Plutonium
104
116
  resource_fields.each { |name| render_resource_field name }
105
117
  }
106
118
  else
107
- sections.each { |rs| render_form_section(rs) }
119
+ # Sections live in their own stacking container rather than being
120
+ # emitted straight into the form body. They are cards in their own
121
+ # right, and the container is what spaces them; without it they
122
+ # would also share a parent with the errors block, so any
123
+ # `:first-child` styling in the section chrome would silently
124
+ # target the wrong element.
125
+ div(class: themed(:sections_wrapper, nil)) do
126
+ sections.each { |rs| render_form_section(rs) }
127
+ end
108
128
  end
109
129
  end
110
130
 
@@ -132,10 +152,11 @@ module Plutonium
132
152
  # section's own `condition:`.
133
153
  next if resolved.fields.empty?
134
154
 
135
- # `columns` stays a validated literal; everything else may be a proc.
136
- options = section.options.to_h do |key, value|
137
- [key, (key != :condition && value.is_a?(Proc)) ? instance_exec(&value) : value]
138
- end
155
+ # `columns` stays a validated literal (the Builder rejects a non-Integer
156
+ # at declaration); `label`/`description`/`collapsible`/`collapsed` may be
157
+ # procs, resolved by the SAME arity rule as a field/input option so
158
+ # `-> { … }` means one thing across the whole form DSL.
159
+ options = resolve_option_procs(section.options)
139
160
  Plutonium::Definition::FormLayout::ResolvedSection.new(
140
161
  section: Plutonium::Definition::FormLayout::Section.new(
141
162
  key: section.key, fields: section.fields, options: options.freeze
@@ -289,6 +310,9 @@ module Plutonium
289
310
  input_definition = definition.defined_inputs[name] || {}
290
311
  input_options = input_definition[:options] || {}
291
312
 
313
+ field_options = resolve_option_procs(field_options)
314
+ input_options = resolve_option_procs(input_options)
315
+
292
316
  tag = input_options[:as] || field_options[:as]
293
317
 
294
318
  # Extract field-level options from input_options and merge into field_options
@@ -298,20 +322,28 @@ module Plutonium
298
322
  field_level_options = input_options.slice(*field_level_keys)
299
323
  field_options = field_options.merge(field_level_options)
300
324
 
301
- tag_attributes = input_options.except(:wrapper, :as, :pre_submit, :condition, *field_level_keys)
325
+ # Staging options are stripped here as well as in Wizard::StepAdapter,
326
+ # because an interaction's inputs reach this method directly. A
327
+ # Class-valued `uploader:` is not an HTML attribute and Phlex refuses
328
+ # it, so leaving it in raises rather than rendering.
329
+ tag_attributes = input_options.except(
330
+ :wrapper, :as, :pre_submit, :condition, *field_level_keys,
331
+ *Plutonium::Attachments::STAGING_ONLY_INPUT_OPTIONS
332
+ )
302
333
  if input_options[:pre_submit]
303
334
  tag_attributes["data-action"] = "change->form#preSubmit"
304
335
  end
305
- tag_block = input_definition[:block] || ->(f) do
306
- tag ||= f.inferred_field_component
307
- if tag.is_a?(Class)
308
- f.send :create_component, tag, tag.name.demodulize.underscore.sub(/component$/, "").to_sym
309
- else
310
- f.send(:"#{tag}_tag", **tag_attributes)
311
- end
312
- end
336
+ tag_block = input_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes) }
313
337
 
314
338
  # Keep `:as` so the Builder can detect hidden fields via `options[:as]`.
339
+ # `as:` may be declared on either `field` or `input`, and the tag
340
+ # lookup above already honours both. Carry the resolved value back
341
+ # into field_options so the Builder sees it too: without this,
342
+ # `input :x, as: :hidden` renders a hidden input inside a fully
343
+ # chromed wrapper, label and all, while `field :x, as: :hidden`
344
+ # renders correctly. The Builder consumes `:as` before Phlexi sees
345
+ # the options, so this cannot leak into a Phlexi field option.
346
+ field_options = field_options.merge(as: tag) if tag
315
347
  field_options = field_options.except(:condition)
316
348
 
317
349
  condition = input_options[:condition] || field_options[:condition]
@@ -346,6 +378,51 @@ module Plutonium
346
378
  end
347
379
  end
348
380
 
381
+ # Resolve proc-valued options for THIS render, so any option can vary per
382
+ # request rather than being frozen when the class loads. One rule for the
383
+ # whole form DSL: `field`/`input`, `section`/`ungrouped`, `structured_input`
384
+ # and nested inputs all route through here.
385
+ #
386
+ # Arity says whether you want the form, NOT which `self` you get:
387
+ #
388
+ # -> { … } a plain call — means what it reads like where it was
389
+ # written, closure and all
390
+ # ->(form) { … } handed this form, for `object`/`params`/helpers
391
+ #
392
+ # This is `choices:`'s rule, adopted rather than replaced. Phlexi has
393
+ # always resolved a `choices:` proc with a plain `.call`, so it is both
394
+ # the oldest of these semantics and the only one that predates Plutonium's
395
+ # own option layer — and it is the unsurprising one: nothing silently
396
+ # rebinds `self` out from under a lambda. `section :x, collapsed: -> { … }`
397
+ # (v0.60) followed the other convention and was migrated to `->(form)` to
398
+ # meet it here.
399
+ #
400
+ # Form::Wizard overrides the zero-arity case, because a step block is
401
+ # instance_exec'd against a throwaway recorder — the closure there holds
402
+ # nothing, so the wizard takes its place.
403
+ #
404
+ # `condition:` is excluded because it is a different kind of thing, not
405
+ # because it is an awkward leftover. An option asks "what value should
406
+ # this have?" and may not care about the render; `condition:` asks
407
+ # "should this render here, now?", which is a question about the render
408
+ # context by definition. So it is evaluated separately, always AGAINST
409
+ # its context — and that context is not always a form (a `column`'s is the
410
+ # table, a step's is the wizard, an action's is a ConditionContext), which
411
+ # is why it cannot take a `form` argument the way an option does.
412
+ # Resolving it here would also collapse it to a boolean before the render
413
+ # site that owns it gets to ask.
414
+ def resolve_option_procs(options)
415
+ return options if options.blank?
416
+
417
+ options.to_h do |key, value|
418
+ [key, resolvable_proc?(key, value) ? call_option_proc(value) : value]
419
+ end
420
+ end
421
+
422
+ def resolvable_proc?(key, value) = key != :condition && value.is_a?(Proc)
423
+
424
+ def call_option_proc(value) = value.arity.zero? ? value.call : value.call(self)
425
+
349
426
  def when_permitted(name, &)
350
427
  return unless resource_fields.include? name
351
428
 
@@ -7,8 +7,25 @@ module Plutonium
7
7
  def self.theme
8
8
  super.merge({
9
9
  # Form structure
10
+ # Width is NOT baked in here — it is configuration, not theme
11
+ # (`page_width` / `form_width` on the definition, defaulting to
12
+ # `Plutonium.configuration.default_page_width`), so Form::Resource
13
+ # appends it. Overriding these keys therefore restyles the form
14
+ # without silently pinning its width.
10
15
  base: "pu-card my-4 p-8 space-y-8",
16
+ # :base for a form whose sections are cards — no card, no padding
17
+ # of its own, since the section cards provide both.
18
+ sectioned_base: "my-4 space-y-4",
11
19
  fields_wrapper: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-6 grid-flow-row-dense",
20
+
21
+ # Sections are their OWN cards (see Component::Section), so this
22
+ # only has to stack them.
23
+ sections_wrapper: "space-y-4",
24
+
25
+ # Section chrome (heading, accent, divider, collapsible caret).
26
+ # Shared defaults with the show page so both read the same;
27
+ # override any key here to restyle form sections alone.
28
+ **Plutonium::UI::Component::Section::DEFAULT_THEME,
12
29
  actions_wrapper: "flex justify-end gap-3 pt-4 border-t border-[var(--pu-border-muted)]",
13
30
  wrapper: nil,
14
31
  inner_wrapper: "w-full",
@@ -16,8 +16,13 @@ module Plutonium
16
16
  # @param action [String] the current step's POST URL.
17
17
  # @param fields [Array<Symbol>] the step's renderable field names
18
18
  # (scalar attributes + structured inputs).
19
- def initialize(step:, data:, action:, fields:, **options, &)
19
+ # @param wizard [Plutonium::Wizard::Base] the run being rendered. Exposed
20
+ # as `form.wizard` so a step's proc-valued option can reach the run —
21
+ # see {#wizard}. Required: it is read during render, so defaulting it to
22
+ # nil would only defer the failure to a NoMethodError inside a proc.
23
+ def initialize(step:, data:, action:, fields:, wizard:, **options, &)
20
24
  @step = step
25
+ @wizard = wizard
21
26
  options[:key] = :wizard
22
27
  options[:as] = :wizard
23
28
  options[:action] = action
@@ -27,6 +32,25 @@ module Plutonium
27
32
  super(data, **options, &)
28
33
  end
29
34
 
35
+ # The run being rendered. Public because a step's proc-valued option is
36
+ # how an option depends on the run, and reaching the run is the whole
37
+ # point:
38
+ #
39
+ # input :tier, as: :select,
40
+ # choices: ->(form) { form.wizard.anchor.available_tiers }
41
+ #
42
+ # Deliberately NOT reached by rebinding a zero-arity proc to the wizard.
43
+ # A step block is instance_exec'd against a FieldCapture at class load,
44
+ # so its closure holds nothing useful — but that is equally true of a
45
+ # `form_layout` block and its Builder, and the answer there is to ask for
46
+ # the receiver rather than have it swapped in silently. Same answer here:
47
+ # `-> { … }` means what it reads like wherever it is written, and an
48
+ # `input` line moved between a definition and a step keeps its meaning.
49
+ #
50
+ # `form.object`, alongside this, is the step's staged data.
51
+ # @return [Plutonium::Wizard::Base]
52
+ attr_reader :wizard
53
+
30
54
  private
31
55
 
32
56
  attr_reader :step
@@ -10,11 +10,22 @@ module Plutonium
10
10
  class Card < Plutonium::UI::Component::Base
11
11
  attr_reader :record, :resource_definition, :resource_fields, :card_fields
12
12
 
13
- def initialize(record, resource_definition:, resource_fields: nil, card_fields: nil, show_turbo_frame: nil)
13
+ def initialize(record, resource_definition:, resource_fields: nil, card_fields: nil, show_turbo_frame: nil, drag_handle: nil, position_group: nil)
14
14
  @record = record
15
+ # Which positioning group this card belongs to, for a scoped resource;
16
+ # nil when unscoped. Same rationale as the table's row attribute — the
17
+ # drag refuses a drop across groups (see Positionable).
18
+ @position_group = position_group
15
19
  @resource_definition = resource_definition
16
20
  @resource_fields = resource_fields
17
21
  @card_fields = card_fields
22
+ # A prebuilt Table::Components::DragHandle, or nil for a card that is
23
+ # not reorderable. The GRID owns the decision — whether the resource
24
+ # declares `position_on`, whether the collection is in position order,
25
+ # whether this viewer passes `reposition?` — because all three are
26
+ # properties of the collection and its policy, not of one card. The
27
+ # card only knows where to put what it was handed.
28
+ @drag_handle = drag_handle
18
29
  # Overrides the show link's turbo-frame target. Defaults to the show
19
30
  # action's own frame (nil → normal navigation). The kanban board sets
20
31
  # "_top" so a card click escapes its column's lazy turbo-frame instead
@@ -23,11 +34,9 @@ module Plutonium
23
34
  end
24
35
 
25
36
  def view_template
26
- article(
27
- class: card_class,
28
- data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"}
29
- ) do
37
+ article(class: card_class, data: card_data) do
30
38
  render_show_link if can_show?
39
+ render_drag_handle if @drag_handle
31
40
  render_actions_dropdown
32
41
  case resource_definition.defined_grid_layout
33
42
  when :media then render_media_layout
@@ -76,8 +85,16 @@ module Plutonium
76
85
  # Footer falls back to `:created_at` when the slot is unset and
77
86
  # the record has a created_at column. Gives cards a sensible
78
87
  # second line without forcing every grid_fields call to repeat it.
88
+ #
89
+ # That fallback means omitting the slot does NOT remove the footer —
90
+ # unlike every other slot, whose `if slots[:x]` guard collapses it. Pass
91
+ # `footer: false` to opt out. nil still means "unset" so a slot hash built
92
+ # from a nil variable keeps falling back rather than silently losing it.
79
93
  def footer_field
80
- slots[:footer] || (record.respond_to?(:created_at) ? :created_at : nil)
94
+ footer = slots[:footer]
95
+ return nil if footer == false
96
+
97
+ footer || (record.respond_to?(:created_at) ? :created_at : nil)
81
98
  end
82
99
 
83
100
  # ---------------------------------------------------------------
@@ -219,6 +236,16 @@ module Plutonium
219
236
  # Card chrome — selection, actions, show
220
237
  # ---------------------------------------------------------------
221
238
 
239
+ # The grip positions itself (Table::Theme's :drag_handle_card floats it
240
+ # over the card's top-left corner), so there is no wrapper here — unlike
241
+ # the table, where the grip has to ride inside a specific cell.
242
+ #
243
+ # It is a <button> — or an <a> when disabled — and row_click_controller
244
+ # ignores both, so grabbing the grip never navigates to the show page.
245
+ def render_drag_handle
246
+ render @drag_handle
247
+ end
248
+
222
249
  def render_actions_dropdown
223
250
  # Cards have limited surface area, so all collection-record
224
251
  # actions (including primary ones like Edit) live in the
@@ -236,20 +263,42 @@ module Plutonium
236
263
  # action button works in the Table view.
237
264
  def render_show_link
238
265
  show = resource_definition.defined_actions[:show]
239
- url = route_options_to_url(show.route_options, record)
240
- a(
266
+ url = merge_query_params(route_options_to_url(show.route_options, record), show_link_url_params)
267
+ a(**show.link_attributes({
241
268
  href: url,
242
269
  data: {row_click_target: "show", turbo_frame: @show_turbo_frame || show.turbo_frame(resource_definition)},
243
270
  class: "sr-only",
244
271
  tabindex: "-1",
245
272
  "aria-label": "Open #{header_text}"
246
- ) { plain "Open" }
273
+ })) { plain "Open" }
247
274
  end
248
275
 
249
276
  # ---------------------------------------------------------------
250
277
  # Helpers
251
278
  # ---------------------------------------------------------------
252
279
 
280
+ # Extra query params for the show link. A kanban card on a show_in :modal
281
+ # board is the only case a Grid::Card is handed the remote-modal frame
282
+ # explicitly (the grid/table reach that frame via the show action's own
283
+ # default, leaving @show_turbo_frame nil). Since a regular modal show and
284
+ # a kanban card's modal share the frame, tag the kanban one so the show
285
+ # page's in_kanban_modal? can tell them apart and drop the metadata rail.
286
+ def show_link_url_params
287
+ {Plutonium::KANBAN_MODAL_PARAM => "1"} if @show_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
288
+ end
289
+
290
+ # Merges extra query params into a URL, preserving any the base URL
291
+ # already carries (e.g. a nested resource's parent id). Returns the URL
292
+ # unchanged when there's nothing to add.
293
+ def merge_query_params(url, extra)
294
+ return url if extra.blank?
295
+
296
+ uri = URI.parse(url)
297
+ merged = Rack::Utils.parse_nested_query(uri.query).merge(extra.stringify_keys)
298
+ uri.query = merged.to_query.presence
299
+ uri.to_s
300
+ end
301
+
253
302
  def header_text
254
303
  @header_text ||= helpers.display_name_of(field_value(slots[:header]) || record)
255
304
  end
@@ -270,7 +319,7 @@ module Plutonium
270
319
 
271
320
  def row_actions
272
321
  @row_actions ||= resource_definition.defined_actions.values.select { |a|
273
- a.collection_record_action? && !a.kanban_drop? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
322
+ a.collection_record_action? && !a.hidden? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
274
323
  }
275
324
  end
276
325
 
@@ -286,9 +335,28 @@ module Plutonium
286
335
  def card_class
287
336
  tokens(
288
337
  "pu-card relative overflow-hidden transition-shadow",
289
- -> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500"
338
+ -> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500",
339
+ # `group/card` is what lets the grip reveal itself on card hover.
340
+ # NAMED, so a card rendered inside some other hover group (or a
341
+ # future group inside the card) can never reveal it by accident.
342
+ -> { !@drag_handle.nil? } => "group/card"
290
343
  )
291
344
  end
345
+
346
+ # The card — never the grip — is the single source of truth for which
347
+ # record a drag is about. See positioned_controller.js.
348
+ #
349
+ # Carried whenever a grip is rendered, including the disabled one: with
350
+ # no controller wired to the grid the attribute is inert, and making it
351
+ # conditional on the sort would mean the card had to know about the
352
+ # collection's ordering for no gain.
353
+ def card_data
354
+ data = {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"}
355
+ return data unless @drag_handle
356
+
357
+ data = data.merge(positioned_row_id: record.id)
358
+ @position_group.nil? ? data : data.merge(positioned_group: @position_group)
359
+ end
292
360
  end
293
361
  end
294
362
  end