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
@@ -33,6 +33,59 @@ module Plutonium
33
33
  # filtering). Produced by #resolve_form_sections (a later task).
34
34
  ResolvedSection = Struct.new(:section, :fields)
35
35
 
36
+ # First-section-wins ownership, shared by every layout resolver (the resource
37
+ # form via {#resolve_form_sections}, and the wizard's {StepAdapter} /
38
+ # {FieldImporter}). Each field is claimed by the FIRST explicit section that
39
+ # lists it, restricted to the currently-permitted `fields`; a listed field
40
+ # outside that set is simply skipped (never renders, never an error).
41
+ #
42
+ # @param layout [Array<Section>]
43
+ # @param fields [Array<Symbol,String>] the permitted field set, in order
44
+ # @return [Array(Hash, Array<Symbol>)] `[owner_by_field, leftover_fields]`
45
+ def self.assign_ownership(layout, fields)
46
+ fields = fields.map(&:to_sym)
47
+ known = fields.to_set
48
+ owner = {}
49
+ layout.each do |section|
50
+ next if section.ungrouped?
51
+ section.fields.map(&:to_sym).each { |f| owner[f] ||= section.key if known.include?(f) }
52
+ end
53
+ leftovers = fields.reject { |f| owner.key?(f) }
54
+ [owner, leftovers]
55
+ end
56
+
57
+ # The canonical resolution: first-section-wins ownership, with unclaimed
58
+ # permitted fields falling into the (explicit, or a synthesized trailing)
59
+ # ungrouped bucket so nothing silently disappears. Every declared section is
60
+ # kept (empty ones are dropped at render). Shared by {#resolve_form_sections}
61
+ # and the wizard {StepAdapter}'s raw-layout path so the two never drift.
62
+ #
63
+ # @param layout [Array<Section>]
64
+ # @param fields [Array<Symbol,String>] the permitted field set, in order
65
+ # @return [Array<ResolvedSection>]
66
+ def self.resolve_sections(layout, fields)
67
+ owner, leftovers = assign_ownership(layout, fields)
68
+
69
+ resolved = layout.map do |section|
70
+ section_fields =
71
+ if section.ungrouped?
72
+ leftovers
73
+ else
74
+ section.fields.map(&:to_sym).select { |f| owner[f] == section.key }
75
+ end
76
+ ResolvedSection.new(section:, fields: section_fields)
77
+ end
78
+
79
+ unless layout.any?(&:ungrouped?)
80
+ resolved.push(ResolvedSection.new(
81
+ section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
82
+ fields: leftovers
83
+ ))
84
+ end
85
+
86
+ resolved
87
+ end
88
+
36
89
  # Collects section/ungrouped calls from a form_layout block in order.
37
90
  class Builder
38
91
  attr_reader :sections
@@ -102,41 +155,7 @@ module Plutonium
102
155
  layout = defined_form_layout
103
156
  return nil unless layout
104
157
 
105
- resource_fields = resource_fields.map(&:to_sym)
106
- known = resource_fields.to_set
107
-
108
- # First-section-wins assignment: each field is claimed by the first
109
- # section that lists it. A field a section lists but that isn't in the
110
- # currently-permitted set (policy, per-action, entity scoping, nesting)
111
- # is simply skipped — it never renders and is never an error.
112
- owner = {}
113
- layout.each do |section|
114
- next if section.ungrouped?
115
- section.fields.each do |f|
116
- owner[f] ||= section.key if known.include?(f)
117
- end
118
- end
119
- leftovers = resource_fields.reject { |f| owner.key?(f) }
120
-
121
- resolved = layout.map do |section|
122
- fields =
123
- if section.ungrouped?
124
- leftovers
125
- else
126
- section.fields.select { |f| owner[f] == section.key }
127
- end
128
- ResolvedSection.new(section:, fields:)
129
- end
130
-
131
- unless layout.any?(&:ungrouped?)
132
- implicit = ResolvedSection.new(
133
- section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
134
- fields: leftovers
135
- )
136
- resolved.push(implicit)
137
- end
138
-
139
- resolved
158
+ FormLayout.resolve_sections(layout, resource_fields)
140
159
  end
141
160
  end
142
161
  end
@@ -18,7 +18,7 @@ module Plutonium
18
18
  module IndexViews
19
19
  extend ActiveSupport::Concern
20
20
 
21
- KNOWN_VIEWS = %i[table grid].freeze
21
+ KNOWN_VIEWS = %i[table grid kanban].freeze
22
22
  GRID_SLOTS = %i[image header subheader body meta footer].freeze
23
23
  GRID_LAYOUTS = %i[compact media].freeze
24
24
 
@@ -28,6 +28,8 @@ module Plutonium
28
28
  class_attribute :defined_grid_fields, default: {}, instance_accessor: false
29
29
  class_attribute :defined_grid_layout, default: :compact, instance_accessor: false
30
30
  class_attribute :defined_grid_columns, default: nil, instance_accessor: false
31
+ class_attribute :defined_kanban_block, default: nil, instance_accessor: false
32
+ class_attribute :defined_kanban_board, default: nil, instance_accessor: false
31
33
  end
32
34
 
33
35
  class_methods do
@@ -83,6 +85,71 @@ module Plutonium
83
85
  def grid_columns(value)
84
86
  self.defined_grid_columns = Integer(value)
85
87
  end
88
+
89
+ # Declares a kanban board for this resource and enables the :kanban
90
+ # index view (mirrors how grid_fields enables :grid). The block is the
91
+ # kanban DSL, compiled lazily into a Plutonium::Kanban::Board later.
92
+ #
93
+ # ## Column action auto-registration
94
+ #
95
+ # Each column action declared inside the block is automatically registered
96
+ # as an interactive resource action (via `action name, interaction:`) so
97
+ # the existing bulk_actions/:key route resolves and
98
+ # `interactive_resource_actions` look-up succeeds at request time.
99
+ #
100
+ # Only STATIC columns (declared with `column :key …`) can be introspected
101
+ # at class-load time. Dynamic boards (`columns do … end`) must declare
102
+ # any column-action interactions as top-level definition `action` calls
103
+ # separately (the constraint is structural: the block is only evaluated at
104
+ # request time with a live context object, so its columns are unknown here).
105
+ def kanban(&block)
106
+ self.defined_kanban_block = block
107
+ self.defined_index_views = defined_index_views + [:kanban] unless defined_index_views.include?(:kanban)
108
+
109
+ # Eagerly compile the board to extract static column actions and
110
+ # register each one as an interactive resource action.
111
+ #
112
+ # Safety of compiling at class-load time:
113
+ # * The board DSL never accesses the database.
114
+ # * BUT interaction constants referenced in column action blocks
115
+ # (e.g. `interaction: ArchiveTasksInteraction`) ARE resolved here,
116
+ # at definition class-load time. They must therefore be autoloadable
117
+ # WITHOUT a circular dependency back on this definition class — an
118
+ # interaction that references the definition at its own load time
119
+ # would deadlock the autoloader. In practice interactions depend only
120
+ # on their model, so this constraint is naturally satisfied.
121
+ board = Plutonium::Kanban::DSL.build(&block)
122
+ # Cache the compiled board so the controller can reuse it instead of
123
+ # recompiling per request (see KanbanActions#current_kanban_board).
124
+ self.defined_kanban_board = board
125
+ board.columns.each do |col|
126
+ col.actions.each do |col_action|
127
+ action(
128
+ col_action.key,
129
+ interaction: col_action.interaction,
130
+ label: col_action.label,
131
+ icon: col_action.icon,
132
+ confirmation: col_action.confirmation
133
+ )
134
+ end
135
+
136
+ # Register each column's enter_interaction as an interactive record
137
+ # action too, so its form and params extraction exist and route the
138
+ # standard way. It is flagged `kanban_drop: true` so it is excluded
139
+ # from the normal show/row/index toolbars — it is reachable only by
140
+ # dropping a card. The key is column-scoped (enter_interaction_key →
141
+ # :<column>_enter_interaction), so it is unique by construction and
142
+ # never collides with another column's interaction. It carries NO
143
+ # policy method of its own — the drop is authorized by kanban_move?.
144
+ if col.enter_interaction?
145
+ action(
146
+ col.enter_interaction_key,
147
+ interaction: col.enter_interaction,
148
+ kanban_drop: true
149
+ )
150
+ end
151
+ end
152
+ end
86
153
  end
87
154
 
88
155
  def defined_index_views = self.class.defined_index_views
@@ -90,6 +157,8 @@ module Plutonium
90
157
  def defined_grid_fields = self.class.defined_grid_fields
91
158
  def defined_grid_layout = self.class.defined_grid_layout
92
159
  def defined_grid_columns = self.class.defined_grid_columns
160
+ def defined_kanban_block = self.class.defined_kanban_block
161
+ def defined_kanban_board = self.class.defined_kanban_board
93
162
  end
94
163
  end
95
164
  end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # The `wizard` definition macro (§5.1) — sugar over the Action system, mirroring
6
+ # {Plutonium::Definition::Actions}. It registers a launching action for a
7
+ # wizard auto-mounted on the resource's own controller (see
8
+ # {Plutonium::Resource::Controllers::WizardActions}):
9
+ #
10
+ # class CompanyDefinition < Plutonium::Resource::Definition
11
+ # wizard :configure, ConfigureCompanyWizard # anchored → record action (show + list)
12
+ # wizard :configure, ConfigureCompanyWizard, collection_record_action: false # show page only
13
+ # wizard :onboard, CompanyOnboardingWizard # no anchor → resource action
14
+ # end
15
+ #
16
+ # Placement is dictated by the wizard, mirroring interactions: an **anchored**
17
+ # wizard is a **record** action (the anchor is the URL `:id`, resolved through the
18
+ # resource controller's scoped, policy-gated `resource_record!`); a **non-anchored**
19
+ # wizard is a **resource** (collection) action. It's not overridable — a flag that
20
+ # doesn't apply to the kind raises. The configurable surface is where a RECORD
21
+ # action shows: the show page (`record_action:`) and the list rows
22
+ # (`collection_record_action:`), both on by default. Bulk wizards are not
23
+ # supported (§5.1) — wizards are per-instance flows.
24
+ #
25
+ # The macro keeps a per-definition registry (`registered_wizards`) the
26
+ # resource-mounted {WizardActions} concern reads to resolve the wizard class by
27
+ # the `:wizard_name` route segment, and synthesizes a launch action whose URL
28
+ # resolver targets the auto-mounted member (anchored) or collection routes.
29
+ module Wizards
30
+ extend ActiveSupport::Concern
31
+
32
+ class_methods do
33
+ # @return [Hash{Symbol=>Hash}] registry of wizards declared on this
34
+ # definition: `{name => {wizard_class:, record_action:}}`. Read by
35
+ # {Plutonium::Resource::Controllers::WizardActions} to resolve + gate.
36
+ def registered_wizards
37
+ @registered_wizards ||= {}
38
+ end
39
+
40
+ # Definitions are inheritable; carry the wizard registry to subclasses.
41
+ def inherited(subclass)
42
+ super
43
+ subclass.instance_variable_set(:@registered_wizards, registered_wizards.dup)
44
+ end
45
+
46
+ # Placement is dictated by the wizard, not chosen: an **anchored** wizard is
47
+ # a **record** action (it needs a record — the anchor), a **non-anchored**
48
+ # wizard is a **resource** (collection) action. The only configurable surface
49
+ # is WHERE a *record* action shows — the **show** page (`record_action:`) and
50
+ # the **list** rows (`collection_record_action:`), both on by default. Flags
51
+ # that don't apply to the wizard's kind are rejected.
52
+ #
53
+ # Like an interaction, a `wizard` action is gated by a policy predicate named
54
+ # after its key — `def configure? = update?` for `wizard :configure, …`. The
55
+ # SAME predicate drives both the launch action's visibility
56
+ # (`Action#permitted_by?` on index/show) and its authorization
57
+ # ({WizardActions#authorize_wizard_*_action!}), so the button and the action
58
+ # stay in lockstep. A missing predicate raises `ActionPolicy::UnknownRule`
59
+ # (exactly as a missing interaction predicate does) — define it.
60
+ #
61
+ # @param name [Symbol] the action key (e.g. :configure)
62
+ # @param wizard_class [Class] a Plutonium::Wizard::Base subclass
63
+ # @param opts [Hash] action overrides — chrome (`label:`/`icon:`/`position:`/
64
+ # `category:`/`confirmation:`/`turbo_frame:`) plus, for a RECORD wizard, the
65
+ # show/list surface flags `record_action:`/`collection_record_action:`.
66
+ def wizard(name, wizard_class, **opts)
67
+ is_record = wizard_class.anchored?
68
+ reject_inapplicable_surface!(name, wizard_class, is_record, opts)
69
+
70
+ registered_wizards[name.to_sym] = {wizard_class:, record_action: is_record}
71
+
72
+ resolver = wizard_launch_resolver(name, is_record)
73
+
74
+ # A record (anchored) wizard surfaces on BOTH the show page (`record_action`)
75
+ # AND each list row (`collection_record_action`, scoped to that row's
76
+ # record); a resource (non-anchored) wizard is the collection-level
77
+ # `resource_action`. `opts` are spliced AFTER, so a record wizard can opt out
78
+ # of either surface (e.g. `collection_record_action: false` → show page only).
79
+ action(
80
+ name,
81
+ route_options: Plutonium::Action::RouteOptions.new(
82
+ method: :get, url_resolver: resolver
83
+ ),
84
+ label: wizard_label(wizard_class, name),
85
+ icon: wizard_icon(wizard_class),
86
+ description: wizard_class.description,
87
+ category: :primary,
88
+ record_action: is_record,
89
+ collection_record_action: is_record,
90
+ resource_action: !is_record,
91
+ **opts.except(:condition),
92
+ condition: wizard_launch_condition(wizard_class, opts[:condition])
93
+ )
94
+ end
95
+
96
+ private
97
+
98
+ # Reject surface flags that don't apply to the wizard's kind. Placement is
99
+ # fixed by `anchored?`: a RECORD (anchored) wizard can only be placed on the
100
+ # show page / list rows (`record_action:`/`collection_record_action:`) — it
101
+ # can't be a `resource_action` (there's no record at the collection level); a
102
+ # RESOURCE (non-anchored) wizard is a collection-level action with no record,
103
+ # so the record/list surfaces don't apply.
104
+ def reject_inapplicable_surface!(name, wizard_class, is_record, opts)
105
+ disallowed =
106
+ if is_record
107
+ [:resource_action]
108
+ else
109
+ [:record_action, :collection_record_action]
110
+ end
111
+ bad = disallowed.select { |flag| opts.key?(flag) }
112
+ return if bad.empty?
113
+
114
+ kind = is_record ? "anchored (a record action)" : "not anchored (a resource action)"
115
+ applies = is_record ? "record_action: / collection_record_action: (show page / list rows)" : "none (it's the collection-level action)"
116
+ raise ArgumentError,
117
+ "wizard :#{name} — #{wizard_class} is #{kind}; " \
118
+ "#{bad.join(", ")} #{(bad.size == 1) ? "doesn't" : "don't"} apply. " \
119
+ "Configurable surface here: #{applies}."
120
+ end
121
+
122
+ # The launch action's `condition:` (§9). A **one-time** wizard's launch is
123
+ # hidden once the current user has already completed it: the condition
124
+ # recomputes the wizard's `instance_key` for the current context (same
125
+ # {Plutonium::Wizard.compute_instance_key} the driving layer + gate use) and
126
+ # returns false when a retained `completed` row exists at that key. A
127
+ # repeatable (non-one-time) wizard gets NO completion condition.
128
+ #
129
+ # When the author also passed a `condition:`, the two are AND-ed: the action
130
+ # shows only if the author's condition is met AND the wizard isn't already
131
+ # completed.
132
+ #
133
+ # The proc runs in a {Plutonium::Action::ConditionContext}: `object`/`record`
134
+ # is the anchor for a record action (nil for a resource action), view helpers
135
+ # (`current_user`) delegate to the view context, and `current_scoped_entity` /
136
+ # `scoped_to_entity?` are read off the host controller (`controller`) exactly
137
+ # as the gate recomputes them.
138
+ def wizard_launch_condition(wizard_class, author_condition)
139
+ return author_condition unless wizard_class.one_time?
140
+
141
+ completion_condition = proc do
142
+ # This runs on EVERY index/show render. The `wizard` macro is not gated
143
+ # on `config.wizards.enabled` (only routing is), so guard the DB query:
144
+ # when the subsystem is disabled its routes aren't drawn (a launch button
145
+ # would 404) → hide the action; when it's enabled but the sessions table
146
+ # hasn't been migrated yet, treat the wizard as not-yet-completed (show)
147
+ # rather than raising StatementInvalid mid-render.
148
+ next false unless Plutonium.configuration.wizards.enabled
149
+ next true unless Plutonium::Wizard::Session.table_exists?
150
+
151
+ scope =
152
+ if controller.scoped_to_entity?
153
+ controller.current_scoped_entity
154
+ end
155
+
156
+ instance_key = Plutonium::Wizard.compute_instance_key(
157
+ wizard_class: wizard_class,
158
+ current_user: current_user,
159
+ current_scoped_entity: scope,
160
+ anchor: wizard_class.anchored? ? object : nil,
161
+ wizard_token: nil
162
+ )
163
+
164
+ !Plutonium::Wizard::Store::ActiveRecord.new.completed?(instance_key: instance_key)
165
+ end
166
+
167
+ return completion_condition if author_condition.nil?
168
+
169
+ # AND the author's condition with the completion check; both run in the
170
+ # same ConditionContext, so evaluate each via instance_exec on self.
171
+ proc do
172
+ instance_exec(&author_condition) && instance_exec(&completion_condition)
173
+ end
174
+ end
175
+
176
+ def wizard_label(wizard_class, name)
177
+ if wizard_class.respond_to?(:label) && wizard_class.label.present?
178
+ wizard_class.label
179
+ else
180
+ name.to_s.humanize
181
+ end
182
+ end
183
+
184
+ def wizard_icon(wizard_class)
185
+ wizard_class.icon || Phlex::TablerIcons::Wand
186
+ end
187
+
188
+ # A url_resolver proc (§5.1). Evaluated against the controller with the
189
+ # subject; builds the wizard's bare LAUNCH URL on the auto-mounted resource
190
+ # route — the member route for an anchored wizard (id from the subject), the
191
+ # collection route otherwise. The launch action resolves the run and
192
+ # redirects to its current step (the resumed cursor for an in-progress keyed
193
+ # run, else the first step), with the token already in the URL — so we never
194
+ # hardcode a step here.
195
+ def wizard_launch_resolver(name, is_record)
196
+ wizard_name = name.to_s
197
+
198
+ proc do |subject|
199
+ if is_record
200
+ resource_url_for(subject, wizard: wizard_name)
201
+ else
202
+ resource_url_for(resource_class, wizard: wizard_name)
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -40,6 +40,7 @@ module Plutonium
40
40
  enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
41
41
 
42
42
  # Callbacks
43
+ before_validation :normalize_email
43
44
  before_validation :set_token_defaults, on: :create
44
45
  after_commit :send_invitation_email, on: :create
45
46
 
@@ -166,6 +167,14 @@ module Plutonium
166
167
  email.split("@").last&.downcase
167
168
  end
168
169
 
170
+ # Normalize the login to lowercase so it agrees with case-insensitive
171
+ # lookups (account_from_login, find_by(email:)) on a case-sensitive DB.
172
+ # This is the single source of truth: every creation path stores a
173
+ # normalized email regardless of how the record was built.
174
+ def normalize_email
175
+ self.email = email.downcase if email.present?
176
+ end
177
+
169
178
  def set_token_defaults
170
179
  self.token ||= SecureRandom.urlsafe_base64(32)
171
180
  self.expires_at ||= 1.week.from_now
@@ -29,6 +29,15 @@ module Plutonium
29
29
  attribute :resource
30
30
  attribute :email
31
31
 
32
+ # Normalize the login to lowercase so the dedup guards
33
+ # (user_not_already_member, no_pending_invitation) and the created
34
+ # invite all agree on case. Lookups elsewhere downcase, and the DB may
35
+ # be case-sensitive. Defined on the class so it overrides the
36
+ # ActiveModel attribute reader; super reaches the stored value.
37
+ def email
38
+ super&.downcase
39
+ end
40
+
32
41
  validates :email, presence: true
33
42
  validate :role_is_present
34
43
  validate :user_not_already_member
@@ -123,7 +123,10 @@ module Plutonium
123
123
 
124
124
  # Handle the signup form submission.
125
125
  def handle_signup_submission
126
- email = @invite.enforce_email? ? @invite.email : params[:email]
126
+ # Normalize the login up front so the existing-account guard below and
127
+ # the account it creates agree on case with case-insensitive lookups
128
+ # (account_from_login) on a case-sensitive DB.
129
+ email = (@invite.enforce_email? ? @invite.email : params[:email])&.downcase
127
130
  password = params[:password]
128
131
  password_confirmation = params[:password_confirmation]
129
132
 
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Kanban
5
+ Action = Data.define(:key, :interaction, :on, :label, :icon, :confirmation)
6
+ end
7
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Kanban
5
+ class Board
6
+ attr_reader :columns, :columns_block, :card_fields, :per_column, :position_config, :show_in
7
+
8
+ # nil means "inherit the definition's show_in"; the board only overrides
9
+ # when explicitly set to :modal or :page.
10
+ VALID_SHOW_IN = [nil, :modal, :page].freeze
11
+
12
+ def initialize(columns:, columns_block:, card_fields:, per_column:, realtime:, position_config:, lazy:, show_in: nil)
13
+ unless VALID_SHOW_IN.include?(show_in)
14
+ raise ArgumentError, "show_in must be one of #{VALID_SHOW_IN.compact.inspect} (or unset), got #{show_in.inspect}"
15
+ end
16
+
17
+ @columns = columns
18
+ @columns_block = columns_block
19
+ @card_fields = card_fields
20
+ @per_column = per_column
21
+ @realtime = realtime
22
+ @position_config = position_config
23
+ @lazy = lazy
24
+ @show_in = show_in
25
+ @columns.each(&:freeze)
26
+ @columns.freeze
27
+ @card_fields&.freeze
28
+ freeze
29
+ end
30
+
31
+ def realtime? = !!@realtime
32
+ def lazy? = !!@lazy
33
+ def dynamic? = !@columns_block.nil?
34
+
35
+ # Resolves the board's effective show_in, falling back to the definition's
36
+ # `show_in` when the board doesn't override it. Pass the resource definition.
37
+ def show_in_for(definition) = @show_in || definition.show_in
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Kanban
5
+ # Builds ActionCable stream names and broadcasts Turbo Stream updates for
6
+ # opt-in realtime kanban boards.
7
+ #
8
+ # ## Tenant isolation
9
+ #
10
+ # The stream name is a three-segment string:
11
+ #
12
+ # "kanban:<tenant>:<resource>"
13
+ #
14
+ # where <tenant> is the scoped entity's GID param (e.g. "Z2lkOi8vYXBwL09yZy8x")
15
+ # or the literal string "global" for unscoped portals.
16
+ #
17
+ # Two viewers are on the SAME stream only when they share identical
18
+ # resource_class AND scoped_entity — different tenants can never share a
19
+ # stream because their GID params are distinct by definition.
20
+ module Broadcaster
21
+ extend self
22
+
23
+ # Returns the streamables array that identifies this board's ActionCable stream.
24
+ #
25
+ # Pass the returned array directly to turbo-rails helpers:
26
+ #
27
+ # turbo_stream_from(*Broadcaster.stream_name(resource_class: Task, scoped_entity: org))
28
+ # Turbo::StreamsChannel.broadcast_stream_to(*stream_name, content:)
29
+ #
30
+ # @param resource_class [Class] the ActiveRecord model class for the board
31
+ # @param scoped_entity [ActiveRecord::Base, nil] the tenant record, or nil
32
+ # for portals that are not entity-scoped
33
+ # @return [Array<String>]
34
+ def stream_name(resource_class:, scoped_entity:)
35
+ entity_segment = scoped_entity&.to_gid_param || "global"
36
+ ["kanban", entity_segment, resource_class.name]
37
+ end
38
+
39
+ # Broadcasts the Turbo Stream HTML to all ActionCable subscribers watching
40
+ # this board's stream.
41
+ #
42
+ # @param resource_class [Class]
43
+ # @param scoped_entity [ActiveRecord::Base, nil]
44
+ # @param content [String] the raw turbo-stream HTML (one or more
45
+ # <turbo-stream> tags) to push to subscribers
46
+ def broadcast(resource_class:, scoped_entity:, content:)
47
+ Turbo::StreamsChannel.broadcast_stream_to(
48
+ *stream_name(resource_class:, scoped_entity:),
49
+ content: content
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end