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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
# Renders the CURRENT wizard step through the existing resource-form pipeline
|
|
7
|
+
# (§7). It rides `Form::Resource` unchanged — the only wizard-specific wiring
|
|
8
|
+
# is the per-step adapter (`resource_definition`), the value source (the
|
|
9
|
+
# wizard's typed `data`, so inputs render seeded from staged data for
|
|
10
|
+
# resume/back, including repeater rows), the `wizard[...]` param namespace,
|
|
11
|
+
# the step POST URL, and a hidden `_direction` (default `next`).
|
|
12
|
+
class Wizard < Resource
|
|
13
|
+
# @param step [Plutonium::Wizard::Step]
|
|
14
|
+
# @param data [Object] the wizard's typed `data` snapshot (the form
|
|
15
|
+
# `object`; responds to every step attribute / structured input name).
|
|
16
|
+
# @param action [String] the current step's POST URL.
|
|
17
|
+
# @param fields [Array<Symbol>] the step's renderable field names
|
|
18
|
+
# (scalar attributes + structured inputs).
|
|
19
|
+
def initialize(step:, data:, action:, fields:, **options, &)
|
|
20
|
+
@step = step
|
|
21
|
+
options[:key] = :wizard
|
|
22
|
+
options[:as] = :wizard
|
|
23
|
+
options[:action] = action
|
|
24
|
+
options[:resource_fields] = fields
|
|
25
|
+
options[:resource_definition] = Plutonium::Wizard::StepAdapter.new(step)
|
|
26
|
+
options[:singular_resource] = true
|
|
27
|
+
super(data, **options, &)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :step
|
|
33
|
+
|
|
34
|
+
def form_template
|
|
35
|
+
# The direction defaults to "next"; the nav buttons in the page override
|
|
36
|
+
# it per-button. The wizard Stimulus controller targets it.
|
|
37
|
+
input(type: :hidden, name: "_direction", value: "next", data: {wizard_target: "direction"})
|
|
38
|
+
render_fields
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The wizard form has no submit footer of its own — the page renders the
|
|
42
|
+
# Back/Next/Finish/Cancel strip. (We still override the resource actions
|
|
43
|
+
# away so no stray "Create"/"Update" button appears.)
|
|
44
|
+
def render_actions
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The step form sits INSIDE the wizard card body (which already supplies the
|
|
48
|
+
# surface + padding), so drop the default `pu-card my-4 p-8` form chrome —
|
|
49
|
+
# otherwise it reads as a card-in-card. Keep just the vertical field rhythm.
|
|
50
|
+
def form_class
|
|
51
|
+
"space-y-6"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :form_action
|
|
55
|
+
|
|
56
|
+
def initialize_attributes
|
|
57
|
+
super
|
|
58
|
+
attributes[:id] = "wizard-form"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -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
|
-
|
|
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
|
|
@@ -201,7 +212,7 @@ module Plutonium
|
|
|
201
212
|
url = route_options_to_url(show.route_options, record)
|
|
202
213
|
a(
|
|
203
214
|
href: url,
|
|
204
|
-
data: {row_click_target: "show", turbo_frame: show.turbo_frame(resource_definition)},
|
|
215
|
+
data: {row_click_target: "show", turbo_frame: @show_turbo_frame || show.turbo_frame(resource_definition)},
|
|
205
216
|
class: "sr-only",
|
|
206
217
|
tabindex: "-1",
|
|
207
218
|
"aria-label": "Open #{header_text}"
|
|
@@ -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,324 @@
|
|
|
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
|
+
def initialize(column:, cards:, total:, per_column:, resource_definition:, resource_fields:, column_action_data: [], column_add_url: nil, card_fields: nil, card_show_frame: "_top")
|
|
57
|
+
@column = column
|
|
58
|
+
@cards = cards
|
|
59
|
+
@total = total
|
|
60
|
+
@per_column = per_column
|
|
61
|
+
@resource_definition = resource_definition
|
|
62
|
+
@resource_fields = resource_fields
|
|
63
|
+
@column_action_data = column_action_data
|
|
64
|
+
@column_add_url = column_add_url
|
|
65
|
+
@card_fields = card_fields
|
|
66
|
+
@card_show_frame = card_show_frame
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def view_template
|
|
70
|
+
# Wrapper carries the drop-policy data attributes and the initial
|
|
71
|
+
# collapsed CSS class. The Stimulus controller reads data-kanban-col
|
|
72
|
+
# to find wrappers unambiguously (distinct from toggle button attrs).
|
|
73
|
+
div(
|
|
74
|
+
class: tokens(
|
|
75
|
+
"pu-kanban-column-wrapper",
|
|
76
|
+
column.collapsed? && "pu-kanban-column-collapsed"
|
|
77
|
+
),
|
|
78
|
+
data: {
|
|
79
|
+
kanban_col: column.key.to_s,
|
|
80
|
+
kanban_accepts: accepts_value,
|
|
81
|
+
kanban_locked: column.locked?.to_s
|
|
82
|
+
}
|
|
83
|
+
) do
|
|
84
|
+
render_collapsed_strip
|
|
85
|
+
render_expanded
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------
|
|
92
|
+
# Collapsed strip
|
|
93
|
+
# ---------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def render_collapsed_strip
|
|
96
|
+
# CSS hides this strip when the wrapper lacks pu-kanban-column-collapsed.
|
|
97
|
+
# Always emitted so the JS toggle can switch between strip and body
|
|
98
|
+
# without a server round-trip.
|
|
99
|
+
div(
|
|
100
|
+
class: "pu-kanban-strip w-10 flex flex-col items-center justify-between " \
|
|
101
|
+
"py-3 gap-2 bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
|
|
102
|
+
"rounded-[var(--pu-radius-md)] select-none",
|
|
103
|
+
data: {kanban_role: "strip"}
|
|
104
|
+
) do
|
|
105
|
+
span(
|
|
106
|
+
class: "text-xs font-semibold text-[var(--pu-text-muted)] " \
|
|
107
|
+
"[writing-mode:vertical-lr] rotate-180"
|
|
108
|
+
) { plain column.label }
|
|
109
|
+
span(class: "pu-badge pu-badge-neutral text-xs font-mono") { plain cards.size.to_s }
|
|
110
|
+
# Expand toggle button — the primary interactive seam for
|
|
111
|
+
# collapsing/expanding. data-kanban-column-key is on this button so
|
|
112
|
+
# the integration test can assert the contract without JS execution.
|
|
113
|
+
button(
|
|
114
|
+
class: "p-0.5 rounded text-[var(--pu-text-muted)] " \
|
|
115
|
+
"hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]",
|
|
116
|
+
title: "Expand #{column.label}",
|
|
117
|
+
type: "button",
|
|
118
|
+
data: {
|
|
119
|
+
action: "click->kanban#toggleColumn",
|
|
120
|
+
kanban_column_key: column.key.to_s
|
|
121
|
+
}
|
|
122
|
+
) { plain "▶" }
|
|
123
|
+
render_column_actions if column.actions.any?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------
|
|
128
|
+
# Expanded column
|
|
129
|
+
# ---------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def render_expanded
|
|
132
|
+
# CSS hides this body when the wrapper has pu-kanban-column-collapsed.
|
|
133
|
+
div(
|
|
134
|
+
class: "pu-kanban-body pu-kanban-column w-72 shrink-0 flex flex-col " \
|
|
135
|
+
"bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] " \
|
|
136
|
+
"rounded-[var(--pu-radius-md)] overflow-hidden",
|
|
137
|
+
data: {kanban_role: "body"}
|
|
138
|
+
) do
|
|
139
|
+
render_header
|
|
140
|
+
render_card_list
|
|
141
|
+
render_more_footer if more_count > 0
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def render_header
|
|
146
|
+
div(class: "px-3 py-2 flex items-center justify-between gap-2 border-b border-[var(--pu-border)] bg-[var(--pu-surface)]") do
|
|
147
|
+
div(class: "flex items-center gap-2 min-w-0 flex-1") do
|
|
148
|
+
render_color_dot(column.color) if column.color
|
|
149
|
+
span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
|
|
150
|
+
render_wip_badge if column.wip
|
|
151
|
+
end
|
|
152
|
+
render_column_actions if @column_add_url || column.actions.any?
|
|
153
|
+
# Collapse toggle — always present in the expanded header so the
|
|
154
|
+
# user can collapse a column even when no other actions are visible.
|
|
155
|
+
# Kept outside render_column_actions so the action-slot tests are
|
|
156
|
+
# unaffected (they check the flex-gap container which is conditional).
|
|
157
|
+
button(
|
|
158
|
+
class: "shrink-0 p-0.5 rounded text-[var(--pu-text-muted)] " \
|
|
159
|
+
"hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]",
|
|
160
|
+
title: "Collapse #{column.label}",
|
|
161
|
+
type: "button",
|
|
162
|
+
data: {
|
|
163
|
+
action: "click->kanban#toggleColumn",
|
|
164
|
+
kanban_column_key: column.key.to_s
|
|
165
|
+
}
|
|
166
|
+
) { plain "◀" }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def render_wip_badge
|
|
171
|
+
over = wip_over_limit?
|
|
172
|
+
span(
|
|
173
|
+
class: tokens(
|
|
174
|
+
"pu-badge text-xs font-mono",
|
|
175
|
+
over ? "pu-badge-danger" : "pu-badge-neutral"
|
|
176
|
+
),
|
|
177
|
+
title: over ? "WIP limit exceeded" : "WIP limit: #{column.wip}"
|
|
178
|
+
) do
|
|
179
|
+
plain "#{cards.size}/#{column.wip}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------
|
|
184
|
+
# Card list
|
|
185
|
+
# ---------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def render_card_list
|
|
188
|
+
div(
|
|
189
|
+
class: "flex flex-col gap-2 p-2 min-h-[3rem] flex-1",
|
|
190
|
+
data: {
|
|
191
|
+
kanban_target: "column",
|
|
192
|
+
kanban_column_key_value: column.key.to_s
|
|
193
|
+
}
|
|
194
|
+
) do
|
|
195
|
+
render_cards
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Extracted so tests can stub render_cards without needing a real
|
|
200
|
+
# view_context (Grid::Card requires policy_for etc.).
|
|
201
|
+
def render_cards
|
|
202
|
+
cards.each do |record|
|
|
203
|
+
render Plutonium::UI::Kanban::Card.new(
|
|
204
|
+
record,
|
|
205
|
+
column_key: column.key,
|
|
206
|
+
resource_definition: resource_definition,
|
|
207
|
+
resource_fields: resource_fields,
|
|
208
|
+
card_fields: @card_fields,
|
|
209
|
+
show_turbo_frame: @card_show_frame
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------
|
|
215
|
+
# "+N more" footer
|
|
216
|
+
# ---------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def render_more_footer
|
|
219
|
+
div(class: "px-3 py-2 border-t border-[var(--pu-border)] bg-[var(--pu-surface)]") do
|
|
220
|
+
span(class: "text-xs text-[var(--pu-text-muted)]") do
|
|
221
|
+
plain "+#{more_count} more"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------
|
|
227
|
+
# Column action slot
|
|
228
|
+
# ---------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
# Renders the "+ Add" quick-add button that opens the resource's new form
|
|
231
|
+
# in the remote modal frame, pre-seeded for this column.
|
|
232
|
+
def render_add_button
|
|
233
|
+
link_to(
|
|
234
|
+
@column_add_url,
|
|
235
|
+
class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
|
|
236
|
+
title: "Add to #{column.label}",
|
|
237
|
+
data: {turbo_frame: Plutonium::REMOTE_MODAL_FRAME}
|
|
238
|
+
) do
|
|
239
|
+
plain "+ Add"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Renders bulk-action links for each column action, and the "+ Add"
|
|
244
|
+
# quick-add button when column_add_url is present.
|
|
245
|
+
#
|
|
246
|
+
# Each bulk-action link targets GET /resources/bulk_actions/:key?ids[]=…,
|
|
247
|
+
# which is the existing interactive_bulk_action route. The action is only
|
|
248
|
+
# rendered when:
|
|
249
|
+
# 1. The resolved action is registered in defined_actions (auto-registered
|
|
250
|
+
# by Definition::IndexViews.kanban at class-load time).
|
|
251
|
+
# 2. current_policy.allowed_to?(:"#{key}?") returns true.
|
|
252
|
+
#
|
|
253
|
+
# The bulk endpoint re-authorizes each record individually, so this
|
|
254
|
+
# check is a display-only gate — not the security boundary.
|
|
255
|
+
def render_column_actions
|
|
256
|
+
div(class: "flex items-center gap-1 shrink-0") do
|
|
257
|
+
render_add_button if @column_add_url
|
|
258
|
+
@column_action_data.each do |entry|
|
|
259
|
+
col_action = entry[:action]
|
|
260
|
+
ids = entry[:ids]
|
|
261
|
+
|
|
262
|
+
registered = current_definition.defined_actions[col_action.key]
|
|
263
|
+
next unless registered&.permitted_by?(current_policy)
|
|
264
|
+
|
|
265
|
+
# Skip when the resolved id set is empty: resource_url_for with
|
|
266
|
+
# ids: [] would resolve to the RESOURCE action route
|
|
267
|
+
# (/resource_actions/:key) rather than the bulk route, misfiring
|
|
268
|
+
# if clicked. An empty column simply renders no action link.
|
|
269
|
+
next if ids.empty?
|
|
270
|
+
|
|
271
|
+
url = resource_url_for(resource_class, interaction: col_action.key, ids: ids)
|
|
272
|
+
label = col_action.label || col_action.key.to_s.humanize
|
|
273
|
+
data_attrs = {
|
|
274
|
+
kanban_action: col_action.key.to_s,
|
|
275
|
+
kanban_column: column.key.to_s
|
|
276
|
+
}
|
|
277
|
+
data_attrs[:turbo_confirm] = col_action.confirmation if col_action.confirmation
|
|
278
|
+
|
|
279
|
+
link_to(
|
|
280
|
+
url,
|
|
281
|
+
class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
|
|
282
|
+
title: label,
|
|
283
|
+
data: data_attrs
|
|
284
|
+
) do
|
|
285
|
+
render col_action.icon.new(class: "h-4 w-4") if col_action.icon
|
|
286
|
+
plain label
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------
|
|
293
|
+
# Pure helpers
|
|
294
|
+
# ---------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def wip_over_limit?
|
|
297
|
+
column.wip && cards.size > column.wip
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def more_count
|
|
301
|
+
[total - cards.size, 0].max
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Serialises the column's accepts policy for the data-kanban-accepts
|
|
305
|
+
# attribute consumed by the Stimulus drag controller for drop hints.
|
|
306
|
+
# The server remains the authority; this is display only.
|
|
307
|
+
#
|
|
308
|
+
# true → "all" (any source is accepted)
|
|
309
|
+
# false → "none" (no source is accepted)
|
|
310
|
+
# Array → comma-joined list of accepted source column keys
|
|
311
|
+
# Proc → "all" (per-card predicate; treated as permissive at the
|
|
312
|
+
# column level — server evaluates per record)
|
|
313
|
+
def accepts_value
|
|
314
|
+
case column.accepts
|
|
315
|
+
when true then "all"
|
|
316
|
+
when false then "none"
|
|
317
|
+
when Array then column.accepts.join(",")
|
|
318
|
+
else "all"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|