plutonium 0.60.5 → 0.62.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 (199) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. metadata +113 -2
@@ -8,18 +8,24 @@ module Plutonium
8
8
  # `grid_fields` on the resource definition. Each slot is optional;
9
9
  # `header` falls back to `record.to_label` when undeclared.
10
10
  class Card < Plutonium::UI::Component::Base
11
- attr_reader :record, :resource_definition, :resource_fields
11
+ attr_reader :record, :resource_definition, :resource_fields, :card_fields
12
12
 
13
- def initialize(record, resource_definition:, resource_fields: nil)
13
+ def initialize(record, resource_definition:, resource_fields: nil, card_fields: nil, show_turbo_frame: nil)
14
14
  @record = record
15
15
  @resource_definition = resource_definition
16
16
  @resource_fields = resource_fields
17
+ @card_fields = card_fields
18
+ # Overrides the show link's turbo-frame target. Defaults to the show
19
+ # action's own frame (nil → normal navigation). The kanban board sets
20
+ # "_top" so a card click escapes its column's lazy turbo-frame instead
21
+ # of loading the show page inside the column.
22
+ @show_turbo_frame = show_turbo_frame
17
23
  end
18
24
 
19
25
  def view_template
20
26
  article(
21
27
  class: card_class,
22
- data: {controller: "row-click", action: "click->row-click#click"}
28
+ data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"}
23
29
  ) do
24
30
  render_show_link if can_show?
25
31
  render_actions_dropdown
@@ -32,7 +38,12 @@ module Plutonium
32
38
 
33
39
  private
34
40
 
35
- def slots = resource_definition.defined_grid_fields
41
+ # Returns the slot hash used for rendering.
42
+ # When the kanban board declares `card_fields`, it is passed in
43
+ # explicitly and takes precedence over the resource definition's
44
+ # `defined_grid_fields`. A nil card_fields falls back to the
45
+ # definition, which is the default for the grid view.
46
+ def slots = @card_fields || resource_definition.defined_grid_fields
36
47
 
37
48
  # ---------------------------------------------------------------
38
49
  # Layout shells
@@ -116,14 +127,13 @@ module Plutonium
116
127
  end
117
128
 
118
129
  def render_meta_slot
119
- fields = Array(slots[:meta])
120
- values = fields.map { |f| field_value(f) }.reject(&:blank?)
130
+ pairs = Array(slots[:meta]).map { |name| [name, field_value(name)] }.reject { |_, value| value.blank? }
121
131
 
122
132
  div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
123
- if values.empty?
133
+ if pairs.empty?
124
134
  render_blank_placeholder
125
135
  else
126
- values.each { |v| render_meta_badge(v) }
136
+ pairs.each { |name, value| render_meta_badge(name, value) }
127
137
  end
128
138
  end
129
139
  end
@@ -147,21 +157,38 @@ module Plutonium
147
157
  # rendered by the timeago Stimulus controller.
148
158
  raw safe(helpers.display_datetime_value(value))
149
159
  elsif currency_field?(name)
150
- plain helpers.number_to_currency(value, unit: "")
160
+ plain helpers.number_to_currency(value, unit: currency_unit_for(name))
151
161
  else
152
162
  plain helpers.display_name_of(value)
153
163
  end
154
164
  end
155
165
 
156
- # Renders a meta value as a colored pill, borrowing the Badge
157
- # display component's semantic color + humanize logic. Status-like
158
- # values (published, pending, failed…) get meaningful colors;
159
- # free-form values get a deterministic decorative color.
160
- def render_meta_badge(value)
166
+ # Renders a meta value as a colored pill, borrowing the Badge display
167
+ # component's semantic color + humanize logic. Non-string types are
168
+ # formatted by type first so they don't badge their raw value:
169
+ # - has_cents columns currency (matches render_formatted_value)
170
+ # - associations → display_name_of (label, not an object inspect)
171
+ # - everything else → humanized, with the RAW value driving the
172
+ # variant so status-like enums (in_progress, published…) still
173
+ # resolve to a semantic color.
174
+ # The variant hashes the stable formatted label for currency/associations,
175
+ # so the decorative color no longer churns on an object's memory address.
176
+ def render_meta_badge(name, value)
161
177
  badge = Plutonium::UI::Display::Components::Badge
162
- variant = badge.variant_for(value)
178
+
179
+ if currency_field?(name)
180
+ label = helpers.number_to_currency(value, unit: currency_unit_for(name))
181
+ variant = badge.variant_for(label)
182
+ elsif association_field?(name)
183
+ label = helpers.display_name_of(value)
184
+ variant = badge.variant_for(label)
185
+ else
186
+ label = badge.humanize(value)
187
+ variant = badge.variant_for(value)
188
+ end
189
+
163
190
  span(class: tokens("pu-badge", "pu-badge-#{variant}")) do
164
- plain badge.humanize(value)
191
+ plain label
165
192
  end
166
193
  end
167
194
 
@@ -170,6 +197,17 @@ module Plutonium
170
197
  klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(name.to_sym)
171
198
  end
172
199
 
200
+ # Delegates to the shared resolver so cards format currency identically to
201
+ # the Currency display component — has_cents unit → configured/i18n default,
202
+ # with `false` meaning no symbol. Cards have no per-display unit (nil).
203
+ def currency_unit_for(name)
204
+ Plutonium::UI::Display::Components::Currency.resolve_unit(nil, record, name)
205
+ end
206
+
207
+ def association_field?(name)
208
+ record.class.reflect_on_association(name.to_sym).present?
209
+ end
210
+
173
211
  # A declared slot with no value renders a muted em-dash rather than
174
212
  # collapsing, so cards in a grid keep an even height instead of
175
213
  # ragged rows when some records lack the field.
@@ -201,7 +239,7 @@ module Plutonium
201
239
  url = route_options_to_url(show.route_options, record)
202
240
  a(
203
241
  href: url,
204
- data: {row_click_target: "show", turbo_frame: show.turbo_frame(resource_definition)},
242
+ data: {row_click_target: "show", turbo_frame: @show_turbo_frame || show.turbo_frame(resource_definition)},
205
243
  class: "sr-only",
206
244
  tabindex: "-1",
207
245
  "aria-label": "Open #{header_text}"
@@ -232,7 +270,7 @@ module Plutonium
232
270
 
233
271
  def row_actions
234
272
  @row_actions ||= resource_definition.defined_actions.values.select { |a|
235
- a.collection_record_action? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
273
+ a.collection_record_action? && !a.kanban_drop? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
236
274
  }
237
275
  end
238
276
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Kanban
6
+ # Thin wrapper around Grid::Card that makes the card draggable.
7
+ #
8
+ # Emits a draggable container div carrying:
9
+ # data-kanban-record-id — the record's id, consumed by the Stimulus
10
+ # drag controller (Task 11) to identify the card
11
+ # data-kanban-column-key — the source column key, used by the move
12
+ # handler (Task 7) to determine which column
13
+ # the card came from
14
+ #
15
+ # All actual card rendering (image, header, meta, actions dropdown) is
16
+ # delegated to Plutonium::UI::Grid::Card which already handles slots,
17
+ # policy-gated actions, and the row-click controller.
18
+ class Card < Plutonium::UI::Component::Base
19
+ attr_reader :record, :column_key, :resource_definition, :resource_fields, :card_fields
20
+
21
+ def initialize(record, column_key:, resource_definition:, resource_fields:, card_fields: nil, show_turbo_frame: "_top")
22
+ @record = record
23
+ @column_key = column_key
24
+ @resource_definition = resource_definition
25
+ @resource_fields = resource_fields
26
+ # Optional slot-layout override from the board's card_fields declaration.
27
+ # Threaded through to Grid::Card so it takes precedence over the
28
+ # resource definition's grid_fields. nil means use the definition.
29
+ @card_fields = card_fields
30
+ # The turbo-frame the card's show link targets — the remote-modal frame
31
+ # (board show_in :modal) or "_top" (show_in :page). Either escapes the
32
+ # column's lazy frame. Defaults to "_top".
33
+ @show_turbo_frame = show_turbo_frame
34
+ end
35
+
36
+ def view_template
37
+ div(
38
+ draggable: "true",
39
+ data: {
40
+ kanban_record_id: record.id,
41
+ kanban_column_key: column_key.to_s
42
+ }
43
+ ) do
44
+ render_grid_card
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Extracted so tests can stub render_grid_card without needing a full
51
+ # view_context (Grid::Card calls policy_for, route_options_to_url, etc.).
52
+ def render_grid_card
53
+ render Plutonium::UI::Grid::Card.new(
54
+ record,
55
+ resource_definition: resource_definition,
56
+ resource_fields: resource_fields,
57
+ card_fields: @card_fields,
58
+ # Escape the column's lazy turbo-frame: either "_top" (full page) or
59
+ # the document-wide remote-modal frame, both of which resolve outside
60
+ # the kanban-col-<key> frame.
61
+ show_turbo_frame: @show_turbo_frame
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Kanban
6
+ # Shared color-dot rendering for the board shell placeholder header
7
+ # (Resource) and the loaded column header (Column), so a column's
8
+ # `color:` shows consistently in both states.
9
+ module ColorDot
10
+ def render_color_dot(color)
11
+ span(
12
+ class: "shrink-0 w-2.5 h-2.5 rounded-full",
13
+ style: "background-color: #{color_css_value(color)}"
14
+ )
15
+ end
16
+
17
+ # Maps a column color symbol to a CSS value via Tailwind design tokens.
18
+ # Raw CSS strings (e.g. "#ff0000") are passed through unchanged.
19
+ def color_css_value(color)
20
+ case color.to_sym
21
+ when :red then "var(--color-red-500)"
22
+ when :orange then "var(--color-orange-500)"
23
+ when :amber then "var(--color-amber-500)"
24
+ when :yellow then "var(--color-yellow-500)"
25
+ when :green then "var(--color-green-500)"
26
+ when :blue then "var(--color-blue-500)"
27
+ when :purple then "var(--color-purple-500)"
28
+ when :pink then "var(--color-pink-500)"
29
+ when :gray then "var(--pu-text-muted)"
30
+ else color.to_s
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Kanban
6
+ # Renders a single column body: header with wip badge, card list, and a
7
+ # "+N more" footer when the total exceeds per_column.
8
+ #
9
+ # This is the component that Task 6's lazy frame endpoint serves. The
10
+ # board shell (Resource) wraps each column in a <turbo-frame>; the frame
11
+ # src hits the column endpoint which renders this component as the body.
12
+ #
13
+ # Collapsed variant: when column.collapsed? the component renders a thin
14
+ # rotated-label strip instead of the full card list. Both the strip and
15
+ # the expanded body are always emitted in the HTML; CSS (controlled by
16
+ # the `pu-kanban-column-collapsed` class on the wrapper) shows one and
17
+ # hides the other. The Stimulus controller's toggleColumn action flips the
18
+ # class and persists the choice to localStorage.
19
+ #
20
+ # Action slot: column.actions are rendered as minimal placeholder buttons
21
+ # so the DOM seam is in place for Task 8 which wires the actual action
22
+ # handlers (interactive bulk actions). The buttons carry data-kanban-action
23
+ # and data-kanban-column attributes for Stimulus targeting.
24
+ #
25
+ # Drop policy: the wrapper emits data-kanban-accepts and data-kanban-locked
26
+ # so the Stimulus drag controller can provide client-side drop hints without
27
+ # re-implementing server-side logic. The server remains the authority.
28
+ class Column < Plutonium::UI::Component::Base
29
+ include ColorDot
30
+ include Phlex::Rails::Helpers::LinkTo
31
+
32
+ attr_reader :column, :cards, :total, :per_column, :resource_definition, :resource_fields
33
+
34
+ # column_action_data: array of {action: Plutonium::Kanban::Action, ids: [Integer, ...]}
35
+ # Resolved by the controller (KanbanActions#render_kanban_column_html) and
36
+ # threaded here so the component can render real bulk-action links without
37
+ # needing to re-query the DB. Defaults to [] when the component is
38
+ # constructed outside of a controller context (e.g., tests or the board
39
+ # shell which renders column frames without card data).
40
+ #
41
+ # column_add_url: URL for the "+ Add" quick-add button (or nil).
42
+ # Set by the controller when column.add? is true and the policy permits
43
+ # create. Carries kanban_column=<key> so the new form pre-fills the
44
+ # grouping attribute via apply_kanban_column_defaults!.
45
+ #
46
+ # card_fields: optional slot-layout hash from the board's card_fields
47
+ # declaration (e.g. { header: :title, meta: [:status] }). Threaded
48
+ # through to each Kanban::Card (and ultimately Grid::Card) so it
49
+ # overrides the resource definition's grid_fields for every card in the
50
+ # column. nil means "use the definition's grid_fields" (default).
51
+ # card_show_frame: the turbo-frame each card's show link targets — the
52
+ # remote-modal frame (board show_in :modal) or "_top" (show_in :page).
53
+ # Resolved by the controller and threaded through to Kanban::Card.
54
+ # Defaults to "_top" so a card always escapes the column's lazy frame when
55
+ # the component is built outside a controller (tests, board shell).
56
+ # collapsed: the EFFECTIVE collapse state for this render (the user's
57
+ # cookie-persisted choice resolved against the column default by the
58
+ # controller). nil → fall back to the column's declared default. The
59
+ # component still emits the DEFAULT separately (data-kanban-default-
60
+ # collapsed) so the controller can store only deltas from it.
61
+ # drop_form_url_template: the kanban_move_form member URL with an __ID__
62
+ # placeholder for the dragged card's record id (mirrors the board's
63
+ # move-url template). Set by the controller ONLY for columns that declare
64
+ # an enter_interaction:; nil for plain columns. When present, the wrapper
65
+ # advertises data-kanban-drop-interaction + data-kanban-drop-form-url-
66
+ # template so Task 6's Stimulus controller opens the interaction modal on
67
+ # drop instead of committing the move immediately.
68
+ def initialize(column:, cards:, total:, per_column:, resource_definition:, resource_fields:, column_action_data: [], column_add_url: nil, board_url: nil, card_fields: nil, card_show_frame: "_top", collapsed: nil, drop_form_url_template: nil, drop_immediate: false, drop_confirm: nil)
69
+ @column = column
70
+ @cards = cards
71
+ @total = total
72
+ @per_column = per_column
73
+ @resource_definition = resource_definition
74
+ @resource_fields = resource_fields
75
+ @column_action_data = column_action_data
76
+ @column_add_url = column_add_url
77
+ @board_url = board_url
78
+ @card_fields = card_fields
79
+ @card_show_frame = card_show_frame
80
+ @collapsed = collapsed
81
+ @drop_form_url_template = drop_form_url_template
82
+ @drop_immediate = drop_immediate
83
+ @drop_confirm = drop_confirm
84
+ end
85
+
86
+ # Effective collapse state: the caller's resolved value, or the column
87
+ # default when none was passed (tests / board shell / non-cookie paths).
88
+ def effective_collapsed?
89
+ @collapsed.nil? ? column.collapsed? : @collapsed
90
+ end
91
+
92
+ def view_template
93
+ # Wrapper carries the drop-policy data attributes and the initial
94
+ # collapsed CSS class. The Stimulus controller reads data-kanban-col
95
+ # to find wrappers unambiguously (distinct from toggle button attrs).
96
+ div(
97
+ class: tokens(
98
+ "pu-kanban-column-wrapper",
99
+ effective_collapsed? && "pu-kanban-column-collapsed"
100
+ ),
101
+ data: wrapper_data
102
+ ) do
103
+ render_collapsed_strip
104
+ render_expanded
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ # Data attributes for the [data-kanban-col] wrapper. Drop-policy hints
111
+ # (accepts/locked) and the collapse default are always present; the
112
+ # drop-interaction attributes are added only when the column declares an
113
+ # enter_interaction: (advertised via the controller-computed template).
114
+ def wrapper_data
115
+ data = {
116
+ kanban_col: column.key.to_s,
117
+ # The DEFAULT (not the effective state) so the controller can store
118
+ # only a delta from it in the cookie.
119
+ kanban_default_collapsed: column.collapsed?.to_s,
120
+ kanban_accepts: accepts_value,
121
+ kanban_locked: column.locked?.to_s
122
+ }
123
+
124
+ if column.enter_interaction?
125
+ data[:kanban_drop_interaction] = "true"
126
+ data[:kanban_drop_form_url_template] = @drop_form_url_template
127
+ # An input-less interaction commits directly on drop (no form modal);
128
+ # the client reads this to skip opening an empty modal. The optional
129
+ # confirm message (auto "<label>?" for immediate actions) gates it.
130
+ data[:kanban_drop_immediate] = "true" if @drop_immediate
131
+ data[:kanban_drop_confirm] = @drop_confirm if @drop_confirm.present?
132
+ end
133
+
134
+ data
135
+ end
136
+
137
+ # ---------------------------------------------------------------
138
+ # Collapsed strip
139
+ # ---------------------------------------------------------------
140
+
141
+ def render_collapsed_strip
142
+ # CSS hides this strip when the wrapper lacks pu-kanban-column-collapsed.
143
+ # Always emitted so the JS toggle can switch between strip and body
144
+ # without a server round-trip.
145
+ div(
146
+ class: "pu-kanban-strip w-10 flex flex-col items-center justify-between " \
147
+ "py-3 gap-2 bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
148
+ "rounded-[var(--pu-radius-md)] select-none",
149
+ data: {kanban_role: "strip"}
150
+ ) do
151
+ # The color dot is the outcome signal for terminal columns (:done
152
+ # green, :lost red) — keep it visible when collapsed too.
153
+ render_color_dot(column.color) if column.color
154
+ span(
155
+ class: "text-xs font-semibold text-[var(--pu-text-muted)] " \
156
+ "[writing-mode:vertical-lr] rotate-180"
157
+ ) { plain column.label }
158
+ span(class: "pu-badge pu-badge-neutral text-xs font-mono") { plain cards.size.to_s }
159
+ # Expand toggle button — the primary interactive seam for
160
+ # collapsing/expanding. data-kanban-column-key is on this button so
161
+ # the integration test can assert the contract without JS execution.
162
+ button(
163
+ class: "p-0.5 rounded text-[var(--pu-text-muted)] " \
164
+ "hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]",
165
+ title: "Expand #{column.label}",
166
+ type: "button",
167
+ data: {
168
+ action: "click->kanban#toggleColumn",
169
+ kanban_column_key: column.key.to_s
170
+ }
171
+ ) { plain "▶" }
172
+ # No column actions here: a collapsed column is a thin strip, so its
173
+ # bulk actions (and "+ Add") stay hidden until it's expanded. They
174
+ # live only in the expanded header (render_header).
175
+ end
176
+ end
177
+
178
+ # ---------------------------------------------------------------
179
+ # Expanded column
180
+ # ---------------------------------------------------------------
181
+
182
+ def render_expanded
183
+ # CSS hides this body when the wrapper has pu-kanban-column-collapsed.
184
+ div(
185
+ class: "pu-kanban-body pu-kanban-column w-72 shrink-0 flex flex-col " \
186
+ "bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] " \
187
+ "rounded-[var(--pu-radius-md)] overflow-hidden",
188
+ data: {kanban_role: "body"}
189
+ ) do
190
+ render_header
191
+ render_card_list
192
+ render_more_footer if more_count > 0
193
+ end
194
+ end
195
+
196
+ def render_header
197
+ div(class: "px-3 py-2 flex items-center justify-between gap-2 border-b border-[var(--pu-border)] bg-[var(--pu-surface)]") do
198
+ div(class: "flex items-center gap-2 min-w-0 flex-1") do
199
+ render_color_dot(column.color) if column.color
200
+ span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
201
+ # Always show a card count (matching the collapsed strip); when a
202
+ # WIP limit is set it becomes the "count/limit" badge instead.
203
+ if column.wip
204
+ render_wip_badge
205
+ else
206
+ render_count_badge
207
+ end
208
+ end
209
+ render_column_actions if @column_add_url || column.actions.any?
210
+ # Collapse toggle — always present in the expanded header so the
211
+ # user can collapse a column even when no other actions are visible.
212
+ # Kept outside render_column_actions so the action-slot tests are
213
+ # unaffected (they check the flex-gap container which is conditional).
214
+ button(
215
+ class: "shrink-0 p-0.5 rounded text-[var(--pu-text-muted)] " \
216
+ "hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]",
217
+ title: "Collapse #{column.label}",
218
+ type: "button",
219
+ data: {
220
+ action: "click->kanban#toggleColumn",
221
+ kanban_column_key: column.key.to_s
222
+ }
223
+ ) { plain "◀" }
224
+ end
225
+ end
226
+
227
+ # Plain card-count badge for columns without a WIP limit — mirrors the
228
+ # count shown on the collapsed strip so the number is visible either way.
229
+ def render_count_badge
230
+ span(class: "pu-badge pu-badge-neutral text-xs font-mono") { plain cards.size.to_s }
231
+ end
232
+
233
+ def render_wip_badge
234
+ over = wip_over_limit?
235
+ span(
236
+ class: tokens(
237
+ "pu-badge text-xs font-mono",
238
+ over ? "pu-badge-danger" : "pu-badge-neutral"
239
+ ),
240
+ title: over ? "WIP limit exceeded" : "WIP limit: #{column.wip}"
241
+ ) do
242
+ plain "#{cards.size}/#{column.wip}"
243
+ end
244
+ end
245
+
246
+ # ---------------------------------------------------------------
247
+ # Card list
248
+ # ---------------------------------------------------------------
249
+
250
+ def render_card_list
251
+ div(
252
+ class: "flex flex-col gap-2 p-2 min-h-[3rem] flex-1",
253
+ data: {
254
+ kanban_target: "column",
255
+ kanban_column_key_value: column.key.to_s
256
+ }
257
+ ) do
258
+ render_cards
259
+ end
260
+ end
261
+
262
+ # Extracted so tests can stub render_cards without needing a real
263
+ # view_context (Grid::Card requires policy_for etc.).
264
+ def render_cards
265
+ cards.each do |record|
266
+ render Plutonium::UI::Kanban::Card.new(
267
+ record,
268
+ column_key: column.key,
269
+ resource_definition: resource_definition,
270
+ resource_fields: resource_fields,
271
+ card_fields: @card_fields,
272
+ show_turbo_frame: @card_show_frame
273
+ )
274
+ end
275
+ end
276
+
277
+ # ---------------------------------------------------------------
278
+ # "+N more" footer
279
+ # ---------------------------------------------------------------
280
+
281
+ def render_more_footer
282
+ div(class: "px-3 py-2 border-t border-[var(--pu-border)] bg-[var(--pu-surface)]") do
283
+ span(class: "text-xs text-[var(--pu-text-muted)]") do
284
+ plain "+#{more_count} more"
285
+ end
286
+ end
287
+ end
288
+
289
+ # ---------------------------------------------------------------
290
+ # Column action slot
291
+ # ---------------------------------------------------------------
292
+
293
+ # Renders the "+ Add" quick-add button that opens the resource's new form
294
+ # in the remote modal frame, pre-seeded for this column.
295
+ def render_add_button
296
+ link_to(
297
+ @column_add_url,
298
+ class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
299
+ title: "Add to #{column.label}",
300
+ data: {turbo_frame: Plutonium::REMOTE_MODAL_FRAME}
301
+ ) do
302
+ plain "+ Add"
303
+ end
304
+ end
305
+
306
+ # Renders bulk-action links for each column action, and the "+ Add"
307
+ # quick-add button when column_add_url is present.
308
+ #
309
+ # Each link targets /resources/bulk_actions/:key?ids[]=… — the same path
310
+ # for the GET form and the POST commit. The render mirrors ActionButton so
311
+ # the action's shape is honoured (see #column_action_link_data):
312
+ # • an interaction with NO user inputs is `immediate` → POST + confirm,
313
+ # executed directly instead of opening an empty form modal;
314
+ # • one with inputs opens the interaction form in the remote modal.
315
+ #
316
+ # The action is only rendered when it's registered in defined_actions
317
+ # (auto-registered by Definition::IndexViews.kanban) and permitted by the
318
+ # policy. The bulk endpoint re-authorizes each record, so this is a
319
+ # display-only gate, not the security boundary.
320
+ def render_column_actions
321
+ div(class: "flex items-center gap-1 shrink-0") do
322
+ render_add_button if @column_add_url
323
+ @column_action_data.each do |entry|
324
+ col_action = entry[:action]
325
+ ids = entry[:ids]
326
+
327
+ registered = current_definition.defined_actions[col_action.key]
328
+ next unless registered&.permitted_by?(current_policy)
329
+
330
+ # Skip when the resolved id set is empty: resource_url_for with
331
+ # ids: [] would resolve to the RESOURCE action route
332
+ # (/resource_actions/:key) rather than the bulk route, misfiring
333
+ # if clicked. An empty column simply renders no action link.
334
+ next if ids.empty?
335
+
336
+ # return_to the board so the action redirects back to it (not the
337
+ # table index) AND the board refreshes: the board-bound redirect is
338
+ # tagged with kanban_reload (KanbanActions#kanban_reload_url), so the
339
+ # column re-fetches and the mutated cards (e.g. archived) update.
340
+ url = resource_url_for(resource_class, interaction: col_action.key, ids: ids, return_to: @board_url)
341
+ label = col_action.label || col_action.key.to_s.humanize
342
+
343
+ link_to(
344
+ url,
345
+ class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
346
+ title: label,
347
+ data: column_action_link_data(col_action, registered)
348
+ ) do
349
+ render col_action.icon.new(class: "h-4 w-4") if col_action.icon
350
+ plain label
351
+ end
352
+ end
353
+ end
354
+ end
355
+
356
+ # Data attributes for a column-action link, honouring the interaction's
357
+ # shape. `immediate` actions (no inputs) POST straight to the commit route
358
+ # with a confirmation, executed directly; the rest open the form in the
359
+ # remote modal. The confirmation prefers the DSL-supplied one, falling
360
+ # back to the action's own default ("<label>?" for immediate actions).
361
+ def column_action_link_data(col_action, registered)
362
+ data = {
363
+ kanban_action: col_action.key.to_s,
364
+ kanban_column: column.key.to_s
365
+ }
366
+
367
+ if registered.immediate
368
+ data[:turbo_method] = :post
369
+ confirmation = col_action.confirmation || registered.confirmation
370
+ data[:turbo_confirm] = confirmation if confirmation.present?
371
+ else
372
+ data[:turbo_frame] = Plutonium::REMOTE_MODAL_FRAME
373
+ data[:turbo_confirm] = col_action.confirmation if col_action.confirmation
374
+ end
375
+
376
+ data
377
+ end
378
+
379
+ # ---------------------------------------------------------------
380
+ # Pure helpers
381
+ # ---------------------------------------------------------------
382
+
383
+ def wip_over_limit?
384
+ column.wip && cards.size > column.wip
385
+ end
386
+
387
+ def more_count
388
+ [total - cards.size, 0].max
389
+ end
390
+
391
+ # Serialises the column's accepts policy for the data-kanban-accepts
392
+ # attribute consumed by the Stimulus drag controller for drop hints.
393
+ # The server remains the authority; this is display only.
394
+ #
395
+ # true → "all" (any source is accepted)
396
+ # false → "none" (no source is accepted)
397
+ # Array → comma-joined list of accepted source column keys
398
+ # Proc → "all" (per-card predicate; treated as permissive at the
399
+ # column level — server evaluates per record)
400
+ def accepts_value
401
+ case column.accepts
402
+ when true then "all"
403
+ when false then "none"
404
+ when Array then column.accepts.join(",")
405
+ else "all"
406
+ end
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end