plutonium 0.60.5 → 0.61.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 (175) 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 +313 -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 +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  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/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -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,55 @@ 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
+ end
136
+ end
86
137
  end
87
138
 
88
139
  def defined_index_views = self.class.defined_index_views
@@ -90,6 +141,8 @@ module Plutonium
90
141
  def defined_grid_fields = self.class.defined_grid_fields
91
142
  def defined_grid_layout = self.class.defined_grid_layout
92
143
  def defined_grid_columns = self.class.defined_grid_columns
144
+ def defined_kanban_block = self.class.defined_kanban_block
145
+ def defined_kanban_board = self.class.defined_kanban_board
93
146
  end
94
147
  end
95
148
  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
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Kanban
5
+ class Column
6
+ ROLE_PRESETS = {
7
+ backlog: {add: true},
8
+ done: {color: :green, collapsed: true}
9
+ }.freeze
10
+
11
+ attr_reader :key, :label, :color, :wip, :scope, :on_drop, :accepts, :actions
12
+
13
+ def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_drop: nil,
14
+ collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil)
15
+ preset = role ? ROLE_PRESETS.fetch(role) { raise ArgumentError, "Unknown column role: #{role.inspect}. Valid: #{ROLE_PRESETS.keys.inspect}" } : {}
16
+ @key = key.to_sym
17
+ @label = label || key.to_s.titleize
18
+ @color = color.nil? ? preset[:color] : color
19
+ @wip = wip
20
+ @scope = scope
21
+ @on_drop = on_drop
22
+ @collapsed = collapsed.nil? ? preset[:collapsed] : collapsed
23
+ @add = add.nil? ? preset[:add] : add
24
+ @accepts = accepts.nil? || accepts
25
+ @locked = locked || false
26
+ @actions = []
27
+ end
28
+
29
+ def action(key, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
30
+ @actions << Action.new(key: key.to_sym, interaction:, on:, label:, icon:, confirmation:)
31
+ end
32
+
33
+ def collapsed? = !!@collapsed
34
+ def add? = !!@add
35
+ def locked? = @locked
36
+
37
+ # Column-level accepts check — used for client-side drop hints and as the
38
+ # first gate in the move handler (before the record is needed).
39
+ # Proc accepts: is treated as permissive at the column level; call
40
+ # accepts_record? with the actual record to evaluate the predicate.
41
+ def accepts?(source_key)
42
+ case @accepts
43
+ when Array then @accepts.include?(source_key)
44
+ when true, false then @accepts
45
+ # Proc/predicate case: permit at the column level here; the move handler
46
+ # evaluates the predicate per-card via accepts_record? with the actual record.
47
+ else true
48
+ end
49
+ end
50
+
51
+ # Per-card accepts check — evaluates a Proc accepts: against the actual
52
+ # record. Called by the move handler after the record is loaded.
53
+ #
54
+ # Convention for Proc accepts:
55
+ # accepts: ->(card) { … } # receives the record, returns true/false
56
+ #
57
+ # For non-Proc values the behaviour matches accepts?(source_key) exactly,
58
+ # so the move handler can unconditionally switch to accepts_record?.
59
+ def accepts_record?(record, source_key)
60
+ case @accepts
61
+ when Array then @accepts.include?(source_key)
62
+ when true, false then @accepts
63
+ when Proc then @accepts.call(record)
64
+ else true
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Plutonium
6
+ module Kanban
7
+ # Evaluation scope for dynamic `columns do…end` blocks at request time.
8
+ #
9
+ # Delegates everything to the request's view_context so the block can call
10
+ # current_user, current_scoped_entity, params, helpers, etc. directly —
11
+ # exactly like Plutonium::Action::ConditionContext does for action conditions.
12
+ class Context < SimpleDelegator
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Kanban
5
+ class DSL
6
+ def self.build(&block)
7
+ dsl = new
8
+ dsl.instance_eval(&block) if block
9
+ dsl.to_board
10
+ end
11
+
12
+ def initialize
13
+ @columns = []
14
+ @columns_block = nil
15
+ @card_fields = nil
16
+ @per_column = nil
17
+ @realtime = false
18
+ @position_config = Positioning::Config.default
19
+ @lazy = true
20
+ @show_in = nil
21
+ end
22
+
23
+ def column(key, **opts, &blk)
24
+ col = Column.new(key, **opts)
25
+ col.instance_eval(&blk) if blk
26
+ @columns << col
27
+ end
28
+
29
+ # Fluent DSL setters — `attr_writer` would change the call syntax
30
+ # (`per_column 25` → `self.per_column = 25`), so keep them as methods.
31
+ # standard:disable Style/TrivialAccessors
32
+ def columns(&blk) = @columns_block = blk
33
+ def card_fields(**slots) = @card_fields = slots
34
+ def per_column(n) = @per_column = n
35
+ def realtime(v = true) = @realtime = v
36
+ def lazy(v = true) = @lazy = v
37
+ # standard:enable Style/TrivialAccessors
38
+
39
+ # Overrides where a card click opens the record's show page, for this
40
+ # board only:
41
+ # :modal — open in a centered modal dialog
42
+ # :page — navigate the whole page to the show route
43
+ # When unset, the board inherits the definition's `show_in` (default :page).
44
+ def show_in(mode) = @show_in = mode # standard:disable Style/TrivialAccessors
45
+
46
+ def position_on(attr = :position, &blk)
47
+ @position_config =
48
+ if attr == false
49
+ Positioning::Config.disabled
50
+ elsif blk
51
+ Positioning::Config.with_block(attr, blk)
52
+ else
53
+ Positioning::Config.attribute(attr)
54
+ end
55
+ end
56
+
57
+ def to_board
58
+ Board.new(
59
+ columns: @columns,
60
+ columns_block: @columns_block,
61
+ card_fields: @card_fields,
62
+ per_column: @per_column,
63
+ realtime: @realtime,
64
+ position_config: @position_config,
65
+ lazy: @lazy,
66
+ show_in: @show_in
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Kanban
5
+ # Groups an already-authorized, query-applied, UN-paginated relation into
6
+ # ordered, per_column-capped column entries.
7
+ module Grouping
8
+ module_function
9
+
10
+ # Returns [{column:, cards: [records], total: Integer}, ...] in column order.
11
+ def call(board:, relation:, context:)
12
+ columns = resolve_columns(board, context)
13
+ pos = board.position_config
14
+ columns.map do |col|
15
+ scoped = apply_scope(relation, col.scope)
16
+ ordered = pos.order(scoped)
17
+ if board.per_column
18
+ total = ordered.count
19
+ cards = ordered.limit(board.per_column).to_a
20
+ else
21
+ cards = ordered.to_a
22
+ total = cards.size
23
+ end
24
+ {column: col, cards: cards, total: total}
25
+ end
26
+ end
27
+
28
+ # Resolves the column list from a board. For dynamic boards, evaluates
29
+ # the columns_block against the context (which exposes current_user,
30
+ # params, etc. via delegation to view_context). Public so Task 7 (move
31
+ # handler) can call Grouping.resolve_columns(board, context) directly.
32
+ def resolve_columns(board, context)
33
+ return board.columns unless board.dynamic?
34
+ Array(context.instance_exec(&board.columns_block)).flatten
35
+ end
36
+
37
+ # Applies a column scope to a relation.
38
+ # Symbol → relation.public_send(sym) (named scope)
39
+ # Proc → relation.instance_exec(&scope) (inline lambda, e.g. -> { where(status: "todo") })
40
+ # nil → relation unchanged
41
+ def apply_scope(relation, scope)
42
+ case scope
43
+ when Symbol then relation.public_send(scope)
44
+ when Proc then relation.instance_exec(&scope)
45
+ when nil then relation
46
+ else raise ArgumentError, "Unsupported column scope: #{scope.inspect} (expected Symbol, Proc, or nil)"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end