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,1128 @@
|
|
|
1
|
+
# Kanban DSL Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a declarative `kanban` board as a first-class index view for Plutonium resources — authored in the resource Definition, rendered as lazy per-column turbo frames, with drag-to-move (a direct action), pluggable decimal positioning, column behaviours/archetypes, column-scoped bulk actions, and opt-in real-time.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The `kanban do…end` DSL compiles to a `Plutonium::Kanban::Board` config object. `:kanban` joins the existing `IndexViews` system, so the board rides the view switcher, `?view=`/cookie resolution, and the index query pipeline (search/filters/scopes). The board consumes the **un-paginated, authorized** relation, groups it into columns, orders each by a decimal `position`, and renders a shell of lazy `<turbo-frame>` columns. A move is a **direct, non-form action** (`kanban_move?` → `update?`); its response re-renders the source+dest frames authoritatively (rollback needs no real-time). Column actions reuse the existing `interactive_bulk_action`. Positioning ships as a standalone `Plutonium::Positioning` model concern.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby/Rails (Appraisal: rails-7/8.0/8.1), Phlex view components, Stimulus + SortableJS-style drag, Turbo Streams/Frames, ActionPolicy, TailwindCSS 4, esbuild (`yarn build`/`yarn dev`).
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — no user feedback/sign-off required by the spec. Verification is automated tests + the maintainer running the dummy app.
|
|
12
|
+
|
|
13
|
+
**Spec:** `docs/superpowers/specs/2026-06-26-kanban-dsl-design.md` (read it before starting; section refs like §5.1 point there).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Conventions for every task
|
|
18
|
+
|
|
19
|
+
- **TDD:** write the failing test first, watch it fail, implement minimally, watch it pass, commit.
|
|
20
|
+
- **Test command:** `bundle exec appraisal rails-8.1 ruby -Itest <file>` for a single file; `bundle exec appraisal rails-8.1 rake test` for the suite. The plain `bundle exec ruby` won't load rodauth — always go through appraisal.
|
|
21
|
+
- **Frontend:** after editing anything in `src/js` or `src/css`, run `yarn build` (writes `app/assets/*`) before integration/system tests; keep `yarn dev` running while iterating.
|
|
22
|
+
- **Commit** at the end of each task with the message shown.
|
|
23
|
+
- **Do NOT** commit unless the task's final step says to (the repo owner's standing rule is "don't commit unless asked" — this plan explicitly asks, per task).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## File Structure (created/modified)
|
|
28
|
+
|
|
29
|
+
**New — core (`lib/plutonium/`):**
|
|
30
|
+
- `lib/plutonium/positioning.rb` — standalone decimal-ordering model concern (Task 1)
|
|
31
|
+
- `lib/plutonium/kanban.rb` — namespace requires (Task 2)
|
|
32
|
+
- `lib/plutonium/kanban/dsl.rb` — the `kanban do…end` builder (Task 2)
|
|
33
|
+
- `lib/plutonium/kanban/board.rb` — compiled board config (Task 2)
|
|
34
|
+
- `lib/plutonium/kanban/column.rb` — one column (options, scope, on_drop, actions, behaviours) (Task 2)
|
|
35
|
+
- `lib/plutonium/kanban/action.rb` — compiled column-scoped action (Task 2)
|
|
36
|
+
- `lib/plutonium/kanban/positioning.rb` — Mode A/B/C strategy resolver behind `position_on` (Task 3)
|
|
37
|
+
- `lib/plutonium/kanban/context.rb` — request-bound context for builder/on_drop blocks (Task 4)
|
|
38
|
+
- `lib/plutonium/kanban/grouping.rb` — groups an authorized relation into ordered, capped columns (Task 4)
|
|
39
|
+
- `lib/plutonium/kanban/broadcaster.rb` — opt-in realtime mirror (Task 14)
|
|
40
|
+
|
|
41
|
+
**New — controllers (`lib/plutonium/resource/controllers/`):**
|
|
42
|
+
- `kanban_actions.rb` — move action handler + `kanban_column` frame endpoint + column-action routing (Tasks 6–8)
|
|
43
|
+
|
|
44
|
+
**New — UI (`lib/plutonium/ui/kanban/`):**
|
|
45
|
+
- `resource.rb` — board shell (lazy column frames) (Task 9)
|
|
46
|
+
- `column.rb` — one column's frame body (cards, header, +N more, wip badge) (Task 9, 13)
|
|
47
|
+
- `card.rb` — board card (reuses grid card) (Task 9)
|
|
48
|
+
|
|
49
|
+
**New — assets (`src/`):**
|
|
50
|
+
- `src/js/controllers/kanban_controller.js` — drag/move Stimulus controller (Task 11)
|
|
51
|
+
|
|
52
|
+
**New — tests:** mirrored under `test/plutonium/...` and `test/integration/...` per task.
|
|
53
|
+
|
|
54
|
+
**Modified:**
|
|
55
|
+
- `lib/plutonium/definition/index_views.rb` — add `:kanban` to `KNOWN_VIEWS` + `kanban do…end` DSL entrypoint (Task 0)
|
|
56
|
+
- `lib/plutonium/resource/policy.rb` — `kanban_move?` → `update?` (Task 5)
|
|
57
|
+
- `lib/plutonium/resource/controller.rb` — include `KanbanActions` (Task 7)
|
|
58
|
+
- `lib/plutonium/ui/page/index.rb` — `when :kanban` render branch (Task 10)
|
|
59
|
+
- `lib/plutonium/ui/table/components/view_switcher.rb` — `kanban` segment (Task 10)
|
|
60
|
+
- `lib/plutonium.rb` — require `positioning` + `kanban` (Tasks 1–2)
|
|
61
|
+
- `src/js/controllers/register_controllers.js` — register `kanban` (Task 11)
|
|
62
|
+
- `test/dummy/` — fixtures, a board definition, routes (Task 15)
|
|
63
|
+
- `docs/` + `.claude/skills/` — guide, reference, skill (Tasks 16–17)
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Task 0: Register the `:kanban` index view + `kanban do…end` entrypoint
|
|
68
|
+
|
|
69
|
+
**Goal:** A definition can declare `kanban do … end`; doing so adds `:kanban` to the resource's enabled index views (mirroring how `grid_fields` auto-enables `:grid`). No rendering yet — just registration + a stored board builder block.
|
|
70
|
+
|
|
71
|
+
**Files:**
|
|
72
|
+
- Modify: `lib/plutonium/definition/index_views.rb`
|
|
73
|
+
- Test: `test/plutonium/definition/kanban_index_view_test.rb`
|
|
74
|
+
|
|
75
|
+
**Acceptance Criteria:**
|
|
76
|
+
- [ ] `KNOWN_VIEWS` includes `:kanban`.
|
|
77
|
+
- [ ] `kanban { … }` stores the block and appends `:kanban` to `defined_index_views`.
|
|
78
|
+
- [ ] Declaring `kanban` does not remove `:table` (it appends, like `grid_fields`).
|
|
79
|
+
- [ ] `index_views :kanban` still validates `:kanban` as known.
|
|
80
|
+
|
|
81
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/kanban_index_view_test.rb` → PASS
|
|
82
|
+
|
|
83
|
+
**Steps:**
|
|
84
|
+
|
|
85
|
+
- [ ] **Step 1: Failing test**
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# test/plutonium/definition/kanban_index_view_test.rb
|
|
89
|
+
require "test_helper"
|
|
90
|
+
|
|
91
|
+
class KanbanIndexViewTest < ActiveSupport::TestCase
|
|
92
|
+
def def_class(&blk)
|
|
93
|
+
Class.new(Plutonium::Resource::Definition) do
|
|
94
|
+
class_eval(&blk) if blk
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
test "declaring kanban enables the :kanban view alongside :table" do
|
|
99
|
+
klass = def_class { kanban { } }
|
|
100
|
+
assert_includes klass.defined_index_views, :kanban
|
|
101
|
+
assert_includes klass.defined_index_views, :table
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
test "kanban stores the builder block" do
|
|
105
|
+
klass = def_class { kanban { } }
|
|
106
|
+
assert_kind_of Proc, klass.defined_kanban_block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
test ":kanban is a known view" do
|
|
110
|
+
assert_includes Plutonium::Definition::IndexViews::KNOWN_VIEWS, :kanban
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- [ ] **Step 2: Run → FAIL** (`NoMethodError: undefined method 'kanban'`).
|
|
116
|
+
|
|
117
|
+
- [ ] **Step 3: Implement** in `lib/plutonium/definition/index_views.rb`:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
KNOWN_VIEWS = %i[table grid kanban].freeze
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Add a class_attribute in the `included do` block:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class_attribute :defined_kanban_block, default: nil, instance_accessor: false
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Add the class method (next to `grid_fields`):
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# Declares a kanban board for this resource and enables the :kanban
|
|
133
|
+
# index view (mirrors how grid_fields enables :grid). The block is the
|
|
134
|
+
# `kanban do…end` DSL, compiled lazily into a Plutonium::Kanban::Board.
|
|
135
|
+
def kanban(&block)
|
|
136
|
+
self.defined_kanban_block = block
|
|
137
|
+
self.defined_index_views = defined_index_views + [:kanban] unless defined_index_views.include?(:kanban)
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Add the instance reader near the others:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
def defined_kanban_block = self.class.defined_kanban_block
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- [ ] **Step 4: Run → PASS.**
|
|
148
|
+
|
|
149
|
+
- [ ] **Step 5: Commit**
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
git add lib/plutonium/definition/index_views.rb test/plutonium/definition/kanban_index_view_test.rb
|
|
153
|
+
git commit -m "feat(kanban): register :kanban index view + kanban DSL entrypoint"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```json:metadata
|
|
157
|
+
{"files": ["lib/plutonium/definition/index_views.rb", "test/plutonium/definition/kanban_index_view_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/kanban_index_view_test.rb", "acceptanceCriteria": ["KNOWN_VIEWS includes :kanban", "kanban{} appends :kanban and stores block", "table not removed"], "requiresUserVerification": false}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Task 1: `Plutonium::Positioning` — standalone decimal ordering concern
|
|
163
|
+
|
|
164
|
+
**Goal:** A model concern providing fractional position: read/order by a decimal column, insert between two neighbors (average; ±1 at ends), column-local rebalance on precision exhaustion, seed on create, and a one-shot backfill. Kanban-independent (§5.1).
|
|
165
|
+
|
|
166
|
+
**Files:**
|
|
167
|
+
- Create: `lib/plutonium/positioning.rb`
|
|
168
|
+
- Modify: `lib/plutonium.rb` (require it)
|
|
169
|
+
- Test: `test/plutonium/positioning_test.rb`
|
|
170
|
+
|
|
171
|
+
**Acceptance Criteria:**
|
|
172
|
+
- [ ] `position_between(prev_val, next_val)` returns the midpoint; `nil` prev → `next - 1`; `nil` next → `prev + 1`; both nil → `0`.
|
|
173
|
+
- [ ] When the gap `(next - prev).abs < EPSILON`, `reposition!` triggers a column-local renumber and still lands the row in order.
|
|
174
|
+
- [ ] Including the concern with `positioned_on :position, scope: :status` sets a `position` on create (append to end of the row's scope group).
|
|
175
|
+
- [ ] `Model.backfill_positions!(order: :created_at)` numbers existing rows per scope group, 1.0, 2.0, ….
|
|
176
|
+
|
|
177
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb` → PASS
|
|
178
|
+
|
|
179
|
+
**Steps:**
|
|
180
|
+
|
|
181
|
+
- [ ] **Step 1: Failing test** — exercises the pure math + the AR integration using a throwaway table.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# test/plutonium/positioning_test.rb
|
|
185
|
+
require "test_helper"
|
|
186
|
+
|
|
187
|
+
class PositioningTest < ActiveSupport::TestCase
|
|
188
|
+
# Pure midpoint math (no DB)
|
|
189
|
+
test "position_between midpoint and ends" do
|
|
190
|
+
calc = Plutonium::Positioning
|
|
191
|
+
assert_equal 1.5, calc.position_between(1.0, 2.0)
|
|
192
|
+
assert_equal 1.0, calc.position_between(2.0, nil) # after last -> +1 ... see note
|
|
193
|
+
assert_equal(-1.0, calc.position_between(nil, 0.0)) # before first -> -1
|
|
194
|
+
assert_equal 0.0, calc.position_between(nil, nil)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
> Implementation note: define ends as `prev + 1` / `next - 1`. Adjust the test literals to match the exact convention you implement; keep them concrete.
|
|
200
|
+
|
|
201
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
202
|
+
|
|
203
|
+
- [ ] **Step 3: Implement** `lib/plutonium/positioning.rb`:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# frozen_string_literal: true
|
|
207
|
+
|
|
208
|
+
module Plutonium
|
|
209
|
+
# Standalone decimal/fractional ordering. Kanban-independent.
|
|
210
|
+
module Positioning
|
|
211
|
+
extend ActiveSupport::Concern
|
|
212
|
+
|
|
213
|
+
EPSILON = 1e-6
|
|
214
|
+
|
|
215
|
+
# Pure midpoint helpers (module functions, no DB).
|
|
216
|
+
module_function
|
|
217
|
+
|
|
218
|
+
def position_between(prev_val, next_val)
|
|
219
|
+
return 0.0 if prev_val.nil? && next_val.nil?
|
|
220
|
+
return next_val - 1 if prev_val.nil?
|
|
221
|
+
return prev_val + 1 if next_val.nil?
|
|
222
|
+
(prev_val + next_val) / 2.0
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def gap_exhausted?(prev_val, next_val)
|
|
226
|
+
return false if prev_val.nil? || next_val.nil?
|
|
227
|
+
(next_val - prev_val).abs < EPSILON
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
included do
|
|
231
|
+
class_attribute :positioning_column, instance_accessor: false, default: :position
|
|
232
|
+
class_attribute :positioning_scope_attr, instance_accessor: false, default: nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
class_methods do
|
|
236
|
+
# @param column [Symbol] decimal attribute holding order
|
|
237
|
+
# @param scope [Symbol, nil] attribute that partitions ordering (e.g. :status)
|
|
238
|
+
def positioned_on(column = :position, scope: nil)
|
|
239
|
+
self.positioning_column = column
|
|
240
|
+
self.positioning_scope_attr = scope
|
|
241
|
+
before_create :assign_initial_position
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# One-shot: number existing rows per scope group by `order`.
|
|
245
|
+
def backfill_positions!(order: :created_at)
|
|
246
|
+
groups = positioning_scope_attr ? all.group_by(&positioning_scope_attr) : {nil => all.to_a}
|
|
247
|
+
groups.each_value do |rows|
|
|
248
|
+
rows.sort_by { |r| r.public_send(order) }.each_with_index do |row, i|
|
|
249
|
+
row.update_column(positioning_column, (i + 1).to_f)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Place this record between two neighbor records (either may be nil) and persist.
|
|
256
|
+
def reposition!(prev_record:, next_record:)
|
|
257
|
+
col = self.class.positioning_column
|
|
258
|
+
prev_val = prev_record&.public_send(col)
|
|
259
|
+
next_val = next_record&.public_send(col)
|
|
260
|
+
if Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
|
|
261
|
+
rebalance_scope_group!
|
|
262
|
+
prev_val = prev_record&.reload&.public_send(col)
|
|
263
|
+
next_val = next_record&.reload&.public_send(col)
|
|
264
|
+
end
|
|
265
|
+
update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def assign_initial_position
|
|
271
|
+
col = self.class.positioning_column
|
|
272
|
+
return if public_send(col).present?
|
|
273
|
+
max = positioning_group_relation.maximum(col) || 0.0
|
|
274
|
+
public_send("#{col}=", max + 1)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def positioning_group_relation
|
|
278
|
+
rel = self.class.all
|
|
279
|
+
attr = self.class.positioning_scope_attr
|
|
280
|
+
attr ? rel.where(attr => public_send(attr)) : rel
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def rebalance_scope_group!
|
|
284
|
+
col = self.class.positioning_column
|
|
285
|
+
positioning_group_relation.order(col).each_with_index do |row, i|
|
|
286
|
+
row.update_column(col, (i + 1).to_f)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Add to `lib/plutonium.rb` (with the other `require`s):
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
require "plutonium/positioning"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
- [ ] **Step 4:** Add DB-backed tests (throwaway table via a migration in the test, or reuse an existing dummy model with a `position` column — prefer the dummy `Task` fixture introduced in Task 15 if ordering of tasks allows; otherwise create an ad-hoc table in setup). Cover: create assigns position; reposition between two rows; rebalance on exhausted gap; backfill. Run → PASS.
|
|
300
|
+
|
|
301
|
+
- [ ] **Step 5: Commit**
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
git add lib/plutonium/positioning.rb lib/plutonium.rb test/plutonium/positioning_test.rb
|
|
305
|
+
git commit -m "feat(positioning): standalone decimal ordering concern"
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
```json:metadata
|
|
309
|
+
{"files": ["lib/plutonium/positioning.rb", "lib/plutonium.rb", "test/plutonium/positioning_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb", "acceptanceCriteria": ["midpoint + ends math", "rebalance on exhausted gap", "seed on create", "backfill_positions!"], "requiresUserVerification": false}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Task 2: Kanban DSL → Board / Column / Action compilation
|
|
315
|
+
|
|
316
|
+
**Goal:** The `kanban do…end` builder compiles to immutable config: a `Board` (ordered columns, card config, per_column, realtime, positioning config, lazy flag), `Column`s (key/label/color/wip/scope/on_drop/behaviours/actions), and `Action`s (key/interaction/on/label/icon/confirmation). Pure data — no request, no DB.
|
|
317
|
+
|
|
318
|
+
**Files:**
|
|
319
|
+
- Create: `lib/plutonium/kanban.rb`, `lib/plutonium/kanban/dsl.rb`, `lib/plutonium/kanban/board.rb`, `lib/plutonium/kanban/column.rb`, `lib/plutonium/kanban/action.rb`
|
|
320
|
+
- Modify: `lib/plutonium.rb`
|
|
321
|
+
- Test: `test/plutonium/kanban/dsl_test.rb`
|
|
322
|
+
|
|
323
|
+
**Acceptance Criteria:**
|
|
324
|
+
- [ ] `Plutonium::Kanban::DSL.build(&block)` returns a `Board`.
|
|
325
|
+
- [ ] `column :k, label:, color:, wip:, scope:, on_drop:` + behaviour opts (`collapsed:`, `add:`, `accepts:`, `locked:`, `role:`) compile to a `Column`; columns keep declaration order.
|
|
326
|
+
- [ ] A column block declaring `action :k, interaction:, on:` compiles to an `Action` on that column.
|
|
327
|
+
- [ ] `role: :backlog` ⇒ `add: true`; `role: :done` ⇒ `color: :green, collapsed: true` (explicit opts override the role).
|
|
328
|
+
- [ ] `scope:`/`on_drop:` accept a Proc **or** a Symbol; stored verbatim (resolution happens at request time).
|
|
329
|
+
- [ ] `card_fields(**slots)`, `per_column n`, `realtime true`, `position_on …` stored on the Board.
|
|
330
|
+
- [ ] `columns do … end` stores a dynamic builder block (mutually exclusive with static `column`s at render time — validated in Task 4).
|
|
331
|
+
|
|
332
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/dsl_test.rb` → PASS
|
|
333
|
+
|
|
334
|
+
**Steps:**
|
|
335
|
+
|
|
336
|
+
- [ ] **Step 1: Failing test**
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
# test/plutonium/kanban/dsl_test.rb
|
|
340
|
+
require "test_helper"
|
|
341
|
+
|
|
342
|
+
class KanbanDslTest < ActiveSupport::TestCase
|
|
343
|
+
def build(&blk) = Plutonium::Kanban::DSL.build(&blk)
|
|
344
|
+
|
|
345
|
+
test "static columns compile in order with options" do
|
|
346
|
+
board = build do
|
|
347
|
+
column :todo, label: "To Do", scope: -> { where(status: :todo) }, on_drop: ->(t) { t.status = :todo }
|
|
348
|
+
column :doing, label: "Doing", wip: 3, scope: :in_progress, on_drop: :start!
|
|
349
|
+
end
|
|
350
|
+
assert_equal %i[todo doing], board.columns.map(&:key)
|
|
351
|
+
assert_equal 3, board.columns[1].wip
|
|
352
|
+
assert_equal :in_progress, board.columns[1].scope # symbol stored verbatim
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
test "role presets apply but are overridable" do
|
|
356
|
+
board = build do
|
|
357
|
+
column :backlog, role: :backlog, scope: -> {}, on_drop: ->(_) {}
|
|
358
|
+
column :done, role: :done, collapsed: false, scope: -> {}, on_drop: ->(_) {}
|
|
359
|
+
end
|
|
360
|
+
assert board.columns[0].add? # from role
|
|
361
|
+
assert_equal :green, board.columns[1].color
|
|
362
|
+
refute board.columns[1].collapsed? # explicit override wins
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
test "column action compiles" do
|
|
366
|
+
board = build do
|
|
367
|
+
column :done, scope: -> {}, on_drop: ->(_) {} do
|
|
368
|
+
action :archive, interaction: :archive_int, on: :all, label: "Archive"
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
act = board.columns[0].actions.first
|
|
372
|
+
assert_equal :archive, act.key
|
|
373
|
+
assert_equal :all, act.on
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
test "board-level config" do
|
|
377
|
+
board = build do
|
|
378
|
+
per_column 25
|
|
379
|
+
realtime true
|
|
380
|
+
card_fields(header: :name)
|
|
381
|
+
end
|
|
382
|
+
assert_equal 25, board.per_column
|
|
383
|
+
assert board.realtime?
|
|
384
|
+
assert_equal({header: :name}, board.card_fields)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
390
|
+
|
|
391
|
+
- [ ] **Step 3: Implement.** `Action` (Struct-like), `Column` (with `role` expansion + behaviour predicates + an `action` collector used when its block runs), `Board`, and `DSL` (an instance_eval target collecting columns + board config). Key code:
|
|
392
|
+
|
|
393
|
+
`lib/plutonium/kanban/action.rb`:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
# frozen_string_literal: true
|
|
397
|
+
module Plutonium
|
|
398
|
+
module Kanban
|
|
399
|
+
Action = Data.define(:key, :interaction, :on, :label, :icon, :confirmation) do
|
|
400
|
+
def initialize(key:, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
|
|
401
|
+
super
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`lib/plutonium/kanban/column.rb`:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
# frozen_string_literal: true
|
|
412
|
+
module Plutonium
|
|
413
|
+
module Kanban
|
|
414
|
+
class Column
|
|
415
|
+
ROLE_PRESETS = {
|
|
416
|
+
backlog: {add: true},
|
|
417
|
+
done: {color: :green, collapsed: true}
|
|
418
|
+
}.freeze
|
|
419
|
+
|
|
420
|
+
attr_reader :key, :label, :color, :wip, :scope, :on_drop, :accepts, :actions
|
|
421
|
+
|
|
422
|
+
def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_drop: nil,
|
|
423
|
+
collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil)
|
|
424
|
+
preset = role ? ROLE_PRESETS.fetch(role, {}) : {}
|
|
425
|
+
@key = key.to_sym
|
|
426
|
+
@label = label || key.to_s.titleize
|
|
427
|
+
@color = color.nil? ? preset[:color] : color
|
|
428
|
+
@wip = wip
|
|
429
|
+
@scope = scope
|
|
430
|
+
@on_drop = on_drop
|
|
431
|
+
@collapsed = collapsed.nil? ? preset[:collapsed] : collapsed
|
|
432
|
+
@add = add.nil? ? preset[:add] : add
|
|
433
|
+
@accepts = accepts.nil? ? true : accepts
|
|
434
|
+
@locked = locked || false
|
|
435
|
+
@actions = []
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Collected when the column's block runs (see DSL#column).
|
|
439
|
+
def action(key, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
|
|
440
|
+
@actions << Action.new(key: key.to_sym, interaction:, on:, label:, icon:, confirmation:)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def collapsed? = !!@collapsed
|
|
444
|
+
def add? = !!@add
|
|
445
|
+
def locked? = @locked
|
|
446
|
+
|
|
447
|
+
# Does this column accept a card dragged from `source_key`?
|
|
448
|
+
# (used by the move action, Task 7). Proc form is evaluated per-card
|
|
449
|
+
# at move time, so here it permits and the handler applies the predicate.
|
|
450
|
+
def accepts?(source_key)
|
|
451
|
+
case @accepts
|
|
452
|
+
when Array then @accepts.include?(source_key)
|
|
453
|
+
when true, false then @accepts
|
|
454
|
+
else true
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`lib/plutonium/kanban/board.rb`:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
# frozen_string_literal: true
|
|
466
|
+
module Plutonium
|
|
467
|
+
module Kanban
|
|
468
|
+
class Board
|
|
469
|
+
attr_reader :columns, :columns_block, :card_fields, :per_column,
|
|
470
|
+
:position_config, :lazy
|
|
471
|
+
|
|
472
|
+
def initialize(columns:, columns_block:, card_fields:, per_column:, realtime:, position_config:, lazy:)
|
|
473
|
+
@columns = columns
|
|
474
|
+
@columns_block = columns_block
|
|
475
|
+
@card_fields = card_fields
|
|
476
|
+
@per_column = per_column
|
|
477
|
+
@realtime = realtime
|
|
478
|
+
@position_config = position_config # see Task 3
|
|
479
|
+
@lazy = lazy
|
|
480
|
+
freeze
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def realtime? = !!@realtime
|
|
484
|
+
def dynamic? = !@columns_block.nil?
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`lib/plutonium/kanban/dsl.rb`:
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
# frozen_string_literal: true
|
|
494
|
+
module Plutonium
|
|
495
|
+
module Kanban
|
|
496
|
+
class DSL
|
|
497
|
+
def self.build(&block)
|
|
498
|
+
dsl = new
|
|
499
|
+
dsl.instance_eval(&block) if block
|
|
500
|
+
dsl.to_board
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def initialize
|
|
504
|
+
@columns = []
|
|
505
|
+
@columns_block = nil
|
|
506
|
+
@card_fields = nil
|
|
507
|
+
@per_column = nil
|
|
508
|
+
@realtime = false
|
|
509
|
+
@position_config = Positioning::Config.default # Task 3
|
|
510
|
+
@lazy = true
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def column(key, **opts, &blk)
|
|
514
|
+
col = Column.new(key, **opts)
|
|
515
|
+
col.instance_eval(&blk) if blk # collects `action ...`
|
|
516
|
+
@columns << col
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def columns(&blk) = @columns_block = blk
|
|
520
|
+
def card_fields(**slots) = @card_fields = slots
|
|
521
|
+
def per_column(n) = @per_column = n
|
|
522
|
+
def realtime(v = true) = @realtime = v
|
|
523
|
+
def lazy(v = true) = @lazy = v
|
|
524
|
+
def position_on(attr = :position, &blk) = @position_config = Positioning::Config.new(attr, false, blk)
|
|
525
|
+
# `position_on false` disables:
|
|
526
|
+
def disable_positioning! = @position_config = Positioning::Config.disabled
|
|
527
|
+
|
|
528
|
+
def to_board
|
|
529
|
+
Board.new(columns: @columns, columns_block: @columns_block, card_fields: @card_fields,
|
|
530
|
+
per_column: @per_column, realtime: @realtime, position_config: @position_config, lazy: @lazy)
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
> Handle `position_on false`: in `position_on`, if the first arg is `false`, set disabled config. Keep the signature `position_on(attr = :position, &blk)` and branch on `attr == false`.
|
|
538
|
+
|
|
539
|
+
`lib/plutonium/kanban.rb`:
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
# frozen_string_literal: true
|
|
543
|
+
require "plutonium/kanban/positioning"
|
|
544
|
+
require "plutonium/kanban/action"
|
|
545
|
+
require "plutonium/kanban/column"
|
|
546
|
+
require "plutonium/kanban/board"
|
|
547
|
+
require "plutonium/kanban/dsl"
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Add `require "plutonium/kanban"` to `lib/plutonium.rb` (after positioning).
|
|
551
|
+
|
|
552
|
+
- [ ] **Step 4: Run → PASS.**
|
|
553
|
+
|
|
554
|
+
- [ ] **Step 5: Commit**
|
|
555
|
+
|
|
556
|
+
```bash
|
|
557
|
+
git add lib/plutonium/kanban.rb lib/plutonium/kanban/ lib/plutonium.rb test/plutonium/kanban/dsl_test.rb
|
|
558
|
+
git commit -m "feat(kanban): compile kanban DSL into Board/Column/Action config"
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
```json:metadata
|
|
562
|
+
{"files": ["lib/plutonium/kanban/dsl.rb", "lib/plutonium/kanban/board.rb", "lib/plutonium/kanban/column.rb", "lib/plutonium/kanban/action.rb", "lib/plutonium/kanban.rb", "lib/plutonium.rb", "test/plutonium/kanban/dsl_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/dsl_test.rb", "acceptanceCriteria": ["columns compile in order", "role presets overridable", "actions compile", "board config stored", "scope/on_drop accept proc or symbol"], "requiresUserVerification": false}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
## Task 3: Positioning strategy resolver (`position_on` Mode A/B/C)
|
|
568
|
+
|
|
569
|
+
**Goal:** Resolve the board's `position_config` to a strategy: Mode A (delegate to `Plutonium::Positioning`), Mode B (author block does the write), Mode C (disabled). Provide `Config` (used in Task 2) and a `Strategy#reposition!(record, prev:, next:, index:, column:)`.
|
|
570
|
+
|
|
571
|
+
**Files:**
|
|
572
|
+
- Create: `lib/plutonium/kanban/positioning.rb`
|
|
573
|
+
- Test: `test/plutonium/kanban/positioning_test.rb`
|
|
574
|
+
|
|
575
|
+
**Acceptance Criteria:**
|
|
576
|
+
- [ ] `Config.default` → Mode A on `:position`; `Config.new(:rank, …)` → Mode A on `:rank`; with a block → Mode B; `Config.disabled` → Mode C.
|
|
577
|
+
- [ ] Mode A `reposition!` calls the record's `Plutonium::Positioning#reposition!` with neighbor records.
|
|
578
|
+
- [ ] Mode B `reposition!` calls the author block with a `move` carrying `record, column, prev, next, index`.
|
|
579
|
+
- [ ] Mode C `reposition!` is a no-op (and the board orders by column scope, asserted in Task 4).
|
|
580
|
+
|
|
581
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb` → PASS
|
|
582
|
+
|
|
583
|
+
**Steps:**
|
|
584
|
+
|
|
585
|
+
- [ ] **Step 1: Failing test** with a fake record capturing calls (Mode A/B) and a no-op assertion (Mode C). Use a `Struct` double exposing `reposition!`.
|
|
586
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
587
|
+
- [ ] **Step 3: Implement** `Plutonium::Kanban::Positioning` with `Config` (Data) + `Strategy` resolving on mode. `move` is a `Data.define(:record, :column, :prev, :next, :index)`.
|
|
588
|
+
- [ ] **Step 4: Run → PASS.**
|
|
589
|
+
- [ ] **Step 5: Commit**
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
git add lib/plutonium/kanban/positioning.rb test/plutonium/kanban/positioning_test.rb
|
|
593
|
+
git commit -m "feat(kanban): position_on strategy resolver (Mode A/B/C)"
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
```json:metadata
|
|
597
|
+
{"files": ["lib/plutonium/kanban/positioning.rb", "test/plutonium/kanban/positioning_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb", "acceptanceCriteria": ["Config default/attr/block/disabled", "Mode A delegates", "Mode B yields move", "Mode C no-op"], "requiresUserVerification": false}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## Task 4: Request binding — `Context` + `Grouping`
|
|
603
|
+
|
|
604
|
+
**Goal:** Bind a `Board` to a request: build the column set (static, or via `columns do…end` in a `Context` exposing `current_user`/`current_scoped_entity`/`params`/helpers), then group the **authorized, un-paginated** relation into ordered, `per_column`-capped buckets — ordering each column by the positioning attribute (overriding any `default_sort`).
|
|
605
|
+
|
|
606
|
+
**Files:**
|
|
607
|
+
- Create: `lib/plutonium/kanban/context.rb`, `lib/plutonium/kanban/grouping.rb`
|
|
608
|
+
- Test: `test/plutonium/kanban/grouping_test.rb`
|
|
609
|
+
|
|
610
|
+
**Acceptance Criteria:**
|
|
611
|
+
- [ ] `Context` is a `SimpleDelegator` over `view_context` exposing `current_user`, `current_scoped_entity`, `params` (mirrors `Plutonium::Action::ConditionContext`).
|
|
612
|
+
- [ ] `Grouping.call(board:, relation:, context:)` returns ordered `[{column:, cards:, total:}]`.
|
|
613
|
+
- [ ] A column's `scope:` Proc is evaluated **against the relation** (`relation.instance_exec(&scope)`); a Symbol calls `relation.public_send(sym)`.
|
|
614
|
+
- [ ] Cards are ordered by the positioning attr (Mode A/B) — overriding `default_sort`; Mode C uses the column scope's own order.
|
|
615
|
+
- [ ] `per_column` caps `cards` and reports `total` (for "+N more").
|
|
616
|
+
- [ ] Dynamic boards (`columns do…end`) build columns from the context.
|
|
617
|
+
|
|
618
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/grouping_test.rb` → PASS
|
|
619
|
+
|
|
620
|
+
**Steps:**
|
|
621
|
+
|
|
622
|
+
- [ ] **Step 1: Failing test** using a dummy model (use the `Task` fixture from Task 15 if available; otherwise an ad-hoc table). Assert ordering-by-position overrides a `default_sort`, scope Proc vs Symbol both work, and `per_column` caps with correct `total`.
|
|
623
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
624
|
+
- [ ] **Step 3: Implement.** `Context` mirrors `ConditionContext` (`SimpleDelegator.new(view_context)`). `Grouping`:
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
# frozen_string_literal: true
|
|
628
|
+
module Plutonium
|
|
629
|
+
module Kanban
|
|
630
|
+
module Grouping
|
|
631
|
+
module_function
|
|
632
|
+
|
|
633
|
+
def call(board:, relation:, context:)
|
|
634
|
+
columns = resolve_columns(board, context)
|
|
635
|
+
pos = board.position_config
|
|
636
|
+
columns.map do |col|
|
|
637
|
+
scoped = apply_scope(relation, col.scope, context)
|
|
638
|
+
ordered = pos.order(scoped) # by position attr, or scope order in Mode C
|
|
639
|
+
total = ordered.count
|
|
640
|
+
cards = board.per_column ? ordered.limit(board.per_column).to_a : ordered.to_a
|
|
641
|
+
{column: col, cards:, total:}
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def resolve_columns(board, context)
|
|
646
|
+
return board.columns unless board.dynamic?
|
|
647
|
+
Array(context.instance_exec(&board.columns_block)).flatten
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def apply_scope(relation, scope, context)
|
|
651
|
+
case scope
|
|
652
|
+
when Symbol then relation.public_send(scope)
|
|
653
|
+
when Proc then relation.instance_exec(&scope)
|
|
654
|
+
when nil then relation
|
|
655
|
+
else relation.merge(scope)
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
> `pos.order(scoped)` lives on the positioning `Strategy` (Task 3): Mode A/B → `scoped.reorder(attr)`; Mode C → `scoped` unchanged. `reorder` (not `order`) is what overrides `default_sort`.
|
|
664
|
+
|
|
665
|
+
- [ ] **Step 4: Run → PASS.**
|
|
666
|
+
- [ ] **Step 5: Commit**
|
|
667
|
+
|
|
668
|
+
```bash
|
|
669
|
+
git add lib/plutonium/kanban/context.rb lib/plutonium/kanban/grouping.rb test/plutonium/kanban/grouping_test.rb
|
|
670
|
+
git commit -m "feat(kanban): request context + relation grouping into ordered columns"
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
```json:metadata
|
|
674
|
+
{"files": ["lib/plutonium/kanban/context.rb", "lib/plutonium/kanban/grouping.rb", "test/plutonium/kanban/grouping_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/grouping_test.rb", "acceptanceCriteria": ["context exposes current_user/entity", "scope proc+symbol", "order overrides default_sort", "per_column caps + total", "dynamic columns"], "requiresUserVerification": false}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
## Task 5: Policy hook `kanban_move?` → `update?`
|
|
680
|
+
|
|
681
|
+
**Goal:** Add a single delegating policy predicate so a move authorizes like an update, and the board is read-only when it returns false.
|
|
682
|
+
|
|
683
|
+
**Files:**
|
|
684
|
+
- Modify: `lib/plutonium/resource/policy.rb`
|
|
685
|
+
- Test: `test/plutonium/resource/kanban_policy_test.rb`
|
|
686
|
+
|
|
687
|
+
**Acceptance Criteria:**
|
|
688
|
+
- [ ] `kanban_move?` returns the same as `update?` by default.
|
|
689
|
+
- [ ] Overriding `update?` to false makes `kanban_move?` false; a subclass can override `kanban_move?` independently.
|
|
690
|
+
|
|
691
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/kanban_policy_test.rb` → PASS
|
|
692
|
+
|
|
693
|
+
**Steps:**
|
|
694
|
+
- [ ] **Step 1: Failing test** (two policy subclasses: one default, one overriding `update?` false; one overriding `kanban_move?` true while `update?` false).
|
|
695
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
696
|
+
- [ ] **Step 3: Implement** near `update?` in `policy.rb`:
|
|
697
|
+
|
|
698
|
+
```ruby
|
|
699
|
+
# Authorizes a kanban move. Delegates to update? by default — override to
|
|
700
|
+
# allow board drags without granting full edit-form access.
|
|
701
|
+
def kanban_move? = update?
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
- [ ] **Step 4: Run → PASS.**
|
|
705
|
+
- [ ] **Step 5: Commit**
|
|
706
|
+
|
|
707
|
+
```bash
|
|
708
|
+
git add lib/plutonium/resource/policy.rb test/plutonium/resource/kanban_policy_test.rb
|
|
709
|
+
git commit -m "feat(kanban): kanban_move? policy predicate delegating to update?"
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
```json:metadata
|
|
713
|
+
{"files": ["lib/plutonium/resource/policy.rb", "test/plutonium/resource/kanban_policy_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/kanban_policy_test.rb", "acceptanceCriteria": ["kanban_move? defaults to update?", "independently overridable"], "requiresUserVerification": false}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## Task 6: `kanban_column` frame endpoint
|
|
719
|
+
|
|
720
|
+
**Goal:** A lightweight controller action rendering ONE column's cards (the frame `src`): resolve the board, build the authorized + query-applied relation (reuse the existing index query pipeline), group, and render just the requested column's body.
|
|
721
|
+
|
|
722
|
+
**Files:**
|
|
723
|
+
- Create: `lib/plutonium/resource/controllers/kanban_actions.rb` (start it here; extended in Tasks 7–8)
|
|
724
|
+
- Modify: `lib/plutonium/resource/controller.rb` (include concern)
|
|
725
|
+
- Test: `test/integration/admin_portal/kanban_column_test.rb`
|
|
726
|
+
|
|
727
|
+
**Acceptance Criteria:**
|
|
728
|
+
- [ ] `GET …?view=kanban&column=<key>` renders that column's cards (turbo-frame body), ordered by position, capped at `per_column`.
|
|
729
|
+
- [ ] The relation is the **authorized + query-applied** scope (search/filters/scopes honored), **not paginated**.
|
|
730
|
+
- [ ] Unknown `column` → 404/empty frame (no crash).
|
|
731
|
+
|
|
732
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_test.rb` → PASS (after Task 15 fixtures exist; if running earlier, stub a board in the dummy).
|
|
733
|
+
|
|
734
|
+
> **Dependency note:** integration tests here rely on the dummy `Task` board from Task 15. If executing strictly in order, write the endpoint + a controller unit test now and add the integration assertions when Task 15 lands. Prefer reordering Task 15 earlier if convenient.
|
|
735
|
+
|
|
736
|
+
**Steps:**
|
|
737
|
+
- [ ] **Step 1: Failing test.**
|
|
738
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
739
|
+
- [ ] **Step 3: Implement** the concern with a `current_kanban_board` memo (compiles `current_definition.defined_kanban_block` via `Kanban::DSL.build`), a `kanban_base_relation` (reuse `current_query_object.apply(authorized_scope(resource_class.all), params)` minus pagination — see `Queryable`), and `kanban_column` rendering `Plutonium::UI::Kanban::Column` (Task 9) for the one column.
|
|
740
|
+
- [ ] **Step 4: Run → PASS.**
|
|
741
|
+
- [ ] **Step 5: Commit**
|
|
742
|
+
|
|
743
|
+
```bash
|
|
744
|
+
git add lib/plutonium/resource/controllers/kanban_actions.rb lib/plutonium/resource/controller.rb test/integration/admin_portal/kanban_column_test.rb
|
|
745
|
+
git commit -m "feat(kanban): lazy per-column frame endpoint"
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
```json:metadata
|
|
749
|
+
{"files": ["lib/plutonium/resource/controllers/kanban_actions.rb", "lib/plutonium/resource/controller.rb", "test/integration/admin_portal/kanban_column_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_test.rb", "acceptanceCriteria": ["renders one column's cards", "authorized+query-applied unpaginated relation", "unknown column safe"], "requiresUserVerification": false}
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
## Task 7: The move action (direct, non-form)
|
|
755
|
+
|
|
756
|
+
**Goal:** Register and handle the move: authorize `kanban_move?`, enforce `accepts:`/`locked:`, apply `on_drop`, compute fractional position, enforce `wip`, save in a transaction, and respond with frame-scoped Turbo Streams re-rendering source + destination columns (snap-back on failure).
|
|
757
|
+
|
|
758
|
+
**Files:**
|
|
759
|
+
- Modify: `lib/plutonium/resource/controllers/kanban_actions.rb`, the resource routing (member route for the move)
|
|
760
|
+
- Test: `test/integration/admin_portal/kanban_move_test.rb`
|
|
761
|
+
|
|
762
|
+
**Acceptance Criteria:**
|
|
763
|
+
- [ ] `POST …/<id>/kanban_move` with `{from_column,to_column,to_index}` moves the card: `on_drop` applied, `position` set between neighbors at `to_index`.
|
|
764
|
+
- [ ] `kanban_move?` false → 403, no mutation.
|
|
765
|
+
- [ ] Destination `accepts:` excluding `from_column` → 422, no mutation, response re-renders the **unchanged** source frame.
|
|
766
|
+
- [ ] `wip` exceeded → 422, no mutation.
|
|
767
|
+
- [ ] Success → Turbo Stream replacing `kanban-col-<from>` and `kanban-col-<to>` frames.
|
|
768
|
+
- [ ] `on_drop` symbol form (`record.public_send(:sym)`) works.
|
|
769
|
+
|
|
770
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_move_test.rb` → PASS
|
|
771
|
+
|
|
772
|
+
**Steps:**
|
|
773
|
+
- [ ] **Step 1: Failing test** covering all six criteria.
|
|
774
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
775
|
+
- [ ] **Step 3: Implement** `kanban_move`:
|
|
776
|
+
|
|
777
|
+
```ruby
|
|
778
|
+
def kanban_move
|
|
779
|
+
board = current_kanban_board
|
|
780
|
+
record = authorized_resource_scope.find(params[:id])
|
|
781
|
+
authorize! record, to: :kanban_move?
|
|
782
|
+
|
|
783
|
+
# Resolve columns via the context so dynamic (columns do…end) boards work too.
|
|
784
|
+
cols = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
|
|
785
|
+
from = cols.find { |c| c.key == params[:from_column].to_sym }
|
|
786
|
+
to = cols.find { |c| c.key == params[:to_column].to_sym }
|
|
787
|
+
raise Plutonium::Kanban::DropRejected unless to.accepts?(from.key) && !from.locked?
|
|
788
|
+
|
|
789
|
+
resource_record_transaction do
|
|
790
|
+
apply_on_drop(to, record) # Proc -> instance_exec(record); Symbol -> record.public_send
|
|
791
|
+
reposition(board, to, record, params[:to_index].to_i)
|
|
792
|
+
enforce_wip!(to, record)
|
|
793
|
+
record.save!
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
render_kanban_frames(from, to)
|
|
797
|
+
rescue Plutonium::Kanban::DropRejected, ActiveRecord::RecordInvalid
|
|
798
|
+
render_kanban_frames(from, to, status: :unprocessable_content) # unchanged source snaps card back
|
|
799
|
+
end
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
Wire the route: register `kanban_move` as a member action (extend the resource route registration the way `wizard_registration`/`mapper_extensions` add custom member routes; or add `member { post :kanban_move }` in the resource route helper). Mark it a direct action excluded from rendered toolbars.
|
|
803
|
+
|
|
804
|
+
- [ ] **Step 4: Run → PASS.**
|
|
805
|
+
- [ ] **Step 5: Commit**
|
|
806
|
+
|
|
807
|
+
```bash
|
|
808
|
+
git add lib/plutonium/resource/controllers/kanban_actions.rb test/integration/admin_portal/kanban_move_test.rb
|
|
809
|
+
git commit -m "feat(kanban): move action — drop policy, on_drop, positioning, wip, frame response"
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
```json:metadata
|
|
813
|
+
{"files": ["lib/plutonium/resource/controllers/kanban_actions.rb", "test/integration/admin_portal/kanban_move_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_move_test.rb", "acceptanceCriteria": ["move applies on_drop+position", "kanban_move? false -> 403", "accepts/locked -> 422 unchanged", "wip -> 422", "success replaces from+to frames", "symbol on_drop"], "requiresUserVerification": false}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
---
|
|
817
|
+
|
|
818
|
+
## Task 8: Column-scoped actions via `interactive_bulk_action`
|
|
819
|
+
|
|
820
|
+
**Goal:** Render a column's actions in its header and route them to the existing `interactive_bulk_action` with the column's card ids (resolved by `on:`).
|
|
821
|
+
|
|
822
|
+
**Files:**
|
|
823
|
+
- Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` (id resolution helper), `lib/plutonium/ui/kanban/column.rb` (header buttons — coordinate with Task 9)
|
|
824
|
+
- Test: `test/integration/admin_portal/kanban_column_action_test.rb`
|
|
825
|
+
|
|
826
|
+
**Acceptance Criteria:**
|
|
827
|
+
- [ ] A column `action … on: :all` resolves ids = column scope ∩ current query (all, beyond `per_column`).
|
|
828
|
+
- [ ] `on: :visible` resolves only the rendered, capped ids.
|
|
829
|
+
- [ ] The action links to `…/bulk_actions/:interaction?ids[]=…` (reuses the existing bulk flow + per-record auth).
|
|
830
|
+
- [ ] Header renders only actions whose policy permits them.
|
|
831
|
+
|
|
832
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_action_test.rb` → PASS
|
|
833
|
+
|
|
834
|
+
**Steps:**
|
|
835
|
+
- [ ] **Step 1: Failing test** asserting id resolution for `:all` vs `:visible` and the generated bulk URL.
|
|
836
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
837
|
+
- [ ] **Step 3: Implement** `column_action_ids(board, column, on:)` and the header rendering (delegating to existing bulk-action URL helpers).
|
|
838
|
+
- [ ] **Step 4: Run → PASS.**
|
|
839
|
+
- [ ] **Step 5: Commit**
|
|
840
|
+
|
|
841
|
+
```bash
|
|
842
|
+
git add lib/plutonium/resource/controllers/kanban_actions.rb lib/plutonium/ui/kanban/column.rb test/integration/admin_portal/kanban_column_action_test.rb
|
|
843
|
+
git commit -m "feat(kanban): column-scoped actions via interactive_bulk_action"
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
```json:metadata
|
|
847
|
+
{"files": ["lib/plutonium/resource/controllers/kanban_actions.rb", "lib/plutonium/ui/kanban/column.rb", "test/integration/admin_portal/kanban_column_action_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_column_action_test.rb", "acceptanceCriteria": ["on: :all ids beyond per_column", "on: :visible capped", "links to bulk_actions", "policy-gated header"], "requiresUserVerification": false}
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
---
|
|
851
|
+
|
|
852
|
+
## Task 9: Kanban view components (shell + column + card)
|
|
853
|
+
|
|
854
|
+
**Goal:** Phlex components: `Kanban::Resource` renders the board shell — a row of lazy `<turbo-frame id="kanban-col-<key>" loading="lazy" src=…>` per column (header + lazy body); `Kanban::Column` renders a column's body (cards via `Kanban::Card`, "+N more", wip badge, action header); `Kanban::Card` wraps the existing grid `Card` with `card_fields` slots.
|
|
855
|
+
|
|
856
|
+
**Files:**
|
|
857
|
+
- Create: `lib/plutonium/ui/kanban/resource.rb`, `lib/plutonium/ui/kanban/column.rb`, `lib/plutonium/ui/kanban/card.rb`
|
|
858
|
+
- Test: `test/plutonium/ui/kanban/resource_test.rb`, `test/plutonium/ui/kanban/column_test.rb`
|
|
859
|
+
|
|
860
|
+
**Acceptance Criteria:**
|
|
861
|
+
- [ ] `Resource` renders N lazy turbo-frames with correct `id`/`src`, column headers, and the board's drag controller data attributes.
|
|
862
|
+
- [ ] `Column` renders cards ordered as grouped, a "+N more" when `total > per_column`, and a wip badge `count/limit` when `wip` set.
|
|
863
|
+
- [ ] `Card` reuses `Plutonium::UI::Grid::Components::Card` with `card_fields` (falls back to `grid_fields`).
|
|
864
|
+
- [ ] Collapsed columns render the count-strip variant.
|
|
865
|
+
|
|
866
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/resource_test.rb test/plutonium/ui/kanban/column_test.rb` → PASS
|
|
867
|
+
|
|
868
|
+
**Steps:**
|
|
869
|
+
- [ ] **Step 1: Failing tests** rendering the components against a built `Board` + grouped data (Phlex `.call`), asserting frame ids/src, "+N more", wip badge, collapsed variant. Mirror existing Phlex component tests under `test/plutonium/ui/`.
|
|
870
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
871
|
+
- [ ] **Step 3: Implement** the three components, mirroring `lib/plutonium/ui/grid/resource.rb` / `grid/components/card.rb` structure and `.pu-*`/token classes. Use `turbo_scoped_dom_id` for frame ids.
|
|
872
|
+
- [ ] **Step 4:** `yarn build` not needed (no JS yet). Run → PASS.
|
|
873
|
+
- [ ] **Step 5: Commit**
|
|
874
|
+
|
|
875
|
+
```bash
|
|
876
|
+
git add lib/plutonium/ui/kanban/ test/plutonium/ui/kanban/
|
|
877
|
+
git commit -m "feat(kanban): Phlex board shell, column, and card components"
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
```json:metadata
|
|
881
|
+
{"files": ["lib/plutonium/ui/kanban/resource.rb", "lib/plutonium/ui/kanban/column.rb", "lib/plutonium/ui/kanban/card.rb", "test/plutonium/ui/kanban/resource_test.rb", "test/plutonium/ui/kanban/column_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/resource_test.rb", "acceptanceCriteria": ["lazy frames with id/src", "+N more", "wip badge", "card reuses grid card", "collapsed variant"], "requiresUserVerification": false}
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
## Task 10: Wire into the index page + view switcher
|
|
887
|
+
|
|
888
|
+
**Goal:** Render the board when `selected_view == :kanban`, and add a `kanban` segment to the view switcher.
|
|
889
|
+
|
|
890
|
+
**Files:**
|
|
891
|
+
- Modify: `lib/plutonium/ui/page/index.rb`, `lib/plutonium/ui/table/components/view_switcher.rb`
|
|
892
|
+
- Test: `test/integration/admin_portal/kanban_index_view_test.rb`
|
|
893
|
+
|
|
894
|
+
**Acceptance Criteria:**
|
|
895
|
+
- [ ] `?view=kanban` renders the board shell (lazy frames), not the table.
|
|
896
|
+
- [ ] The switcher shows a `Kanban` segment (icon + label) when `:kanban` is enabled; clicking sets the cookie and reloads.
|
|
897
|
+
- [ ] Cookie stickiness works (existing mechanism).
|
|
898
|
+
|
|
899
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_index_view_test.rb` → PASS
|
|
900
|
+
|
|
901
|
+
**Steps:**
|
|
902
|
+
- [ ] **Step 1: Failing test.**
|
|
903
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
904
|
+
- [ ] **Step 3: Implement.** In `index.rb#render_default_content`: `when :kanban then render partial("resource_kanban")` (and add the `resource_kanban` partial method building `Plutonium::UI::Kanban::Resource`). In `view_switcher.rb` add to `SEGMENT_LABELS`: `kanban: {label: "Board", icon: Phlex::TablerIcons::LayoutKanban}`.
|
|
905
|
+
- [ ] **Step 4: Run → PASS.**
|
|
906
|
+
- [ ] **Step 5: Commit**
|
|
907
|
+
|
|
908
|
+
```bash
|
|
909
|
+
git add lib/plutonium/ui/page/index.rb lib/plutonium/ui/table/components/view_switcher.rb test/integration/admin_portal/kanban_index_view_test.rb
|
|
910
|
+
git commit -m "feat(kanban): render board on index + view-switcher segment"
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
```json:metadata
|
|
914
|
+
{"files": ["lib/plutonium/ui/page/index.rb", "lib/plutonium/ui/table/components/view_switcher.rb", "test/integration/admin_portal/kanban_index_view_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_index_view_test.rb", "acceptanceCriteria": ["?view=kanban renders board", "switcher segment", "cookie sticky"], "requiresUserVerification": false}
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
## Task 11: Stimulus `kanban_controller.js` — drag + move + reconcile
|
|
920
|
+
|
|
921
|
+
**Goal:** A Stimulus controller wiring cross-frame drag (SortableJS-style), posting the move on drop, and letting the frame-scoped response reconcile (failure re-renders unchanged source = snap-back). Registered + built.
|
|
922
|
+
|
|
923
|
+
**Files:**
|
|
924
|
+
- Create: `src/js/controllers/kanban_controller.js`
|
|
925
|
+
- Modify: `src/js/controllers/register_controllers.js`
|
|
926
|
+
- Test: `test/system/kanban_test.rb` (system/browser test) — or an integration assertion that the controller + data attributes are present if system tests are heavy.
|
|
927
|
+
|
|
928
|
+
**Acceptance Criteria:**
|
|
929
|
+
- [ ] Dragging a card to another column POSTs `{from_column,to_column,to_index}` to the move route.
|
|
930
|
+
- [ ] On success the target/source frames update; on a 4xx the card returns to origin (driven by the response, not client bookkeeping).
|
|
931
|
+
- [ ] Controller registered in `register_controllers.js`; `yarn build` produces updated `app/assets/plutonium.js`.
|
|
932
|
+
|
|
933
|
+
**Verify:** `yarn build` then `bundle exec appraisal rails-8.1 ruby -Itest test/system/kanban_test.rb` → PASS (system tests require the JS bundle built).
|
|
934
|
+
|
|
935
|
+
**Steps:**
|
|
936
|
+
- [ ] **Step 1: Failing system test** (drag a card, assert it lands in the new column and persists across reload). Mirror existing `test/system/` setup.
|
|
937
|
+
- [ ] **Step 2: Run → FAIL.**
|
|
938
|
+
- [ ] **Step 3: Implement** the controller (use the project's existing drag dependency if present; otherwise add a lightweight HTML5 drag handler — check `package.json` before adding deps). Register it. `yarn build`.
|
|
939
|
+
- [ ] **Step 4: Run → PASS.**
|
|
940
|
+
- [ ] **Step 5: Commit**
|
|
941
|
+
|
|
942
|
+
```bash
|
|
943
|
+
git add src/js/controllers/kanban_controller.js src/js/controllers/register_controllers.js app/assets/ test/system/kanban_test.rb
|
|
944
|
+
git commit -m "feat(kanban): drag-to-move Stimulus controller + build"
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
```json:metadata
|
|
948
|
+
{"files": ["src/js/controllers/kanban_controller.js", "src/js/controllers/register_controllers.js", "test/system/kanban_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/system/kanban_test.rb", "acceptanceCriteria": ["drag posts move", "response reconciles + snap-back", "registered + built"], "requiresUserVerification": false}
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
953
|
+
## Task 12: Quick-add (`add: true`)
|
|
954
|
+
|
|
955
|
+
**Goal:** Render an inline "+ Add" on columns with `add: true` that creates a record seeded into the column (apply the column's `on_drop` to a new instance), via the resource's create path; authorized with `create?`.
|
|
956
|
+
|
|
957
|
+
**Files:**
|
|
958
|
+
- Modify: `lib/plutonium/ui/kanban/column.rb`, `lib/plutonium/resource/controllers/kanban_actions.rb`
|
|
959
|
+
- Test: `test/integration/admin_portal/kanban_quick_add_test.rb`
|
|
960
|
+
|
|
961
|
+
**Acceptance Criteria:**
|
|
962
|
+
- [ ] Columns with `add: true` render a "+ Add"; others don't.
|
|
963
|
+
- [ ] Submitting creates a record with the column's `on_drop` applied (e.g. `status: :todo`) and a position at the column end.
|
|
964
|
+
- [ ] `create?` false → no "+ Add", endpoint 403.
|
|
965
|
+
|
|
966
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_quick_add_test.rb` → PASS
|
|
967
|
+
|
|
968
|
+
**Steps:** TDD as above. Reuse the resource new/create form (`turbo_frame` modal) seeded with the column placement, or a minimal inline create. Commit:
|
|
969
|
+
|
|
970
|
+
```bash
|
|
971
|
+
git commit -m "feat(kanban): per-column quick-add seeded via on_drop"
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
```json:metadata
|
|
975
|
+
{"files": ["lib/plutonium/ui/kanban/column.rb", "lib/plutonium/resource/controllers/kanban_actions.rb", "test/integration/admin_portal/kanban_quick_add_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_quick_add_test.rb", "acceptanceCriteria": ["add:true renders +Add", "create seeds on_drop + end position", "create? false gates"], "requiresUserVerification": false}
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
---
|
|
979
|
+
|
|
980
|
+
## Task 13: Column behaviour UI — collapse, drop policy, wip badge
|
|
981
|
+
|
|
982
|
+
**Goal:** Finish the column behaviours in the UI/controller: collapsible toggle (initial `collapsed:`), `accepts:`/`locked:` reflected as drag constraints (client) AND enforced server-side (already in Task 7), and the wip badge/over-limit styling.
|
|
983
|
+
|
|
984
|
+
**Files:**
|
|
985
|
+
- Modify: `lib/plutonium/ui/kanban/column.rb`, `src/js/controllers/kanban_controller.js`
|
|
986
|
+
- Test: `test/plutonium/ui/kanban/behaviours_test.rb` + a system assertion for collapse
|
|
987
|
+
|
|
988
|
+
**Acceptance Criteria:**
|
|
989
|
+
- [ ] `collapsed: true` renders folded; a toggle expands (persists per-column via cookie/localStorage — pick one, document it).
|
|
990
|
+
- [ ] `accepts:`/`locked:` set data attributes the controller uses to block disallowed drops client-side (server still enforces).
|
|
991
|
+
- [ ] `wip` badge shows `count/limit`; over-limit gets a warning class.
|
|
992
|
+
|
|
993
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/behaviours_test.rb` → PASS
|
|
994
|
+
|
|
995
|
+
**Steps:** TDD. `yarn build` after JS edits. Commit:
|
|
996
|
+
|
|
997
|
+
```bash
|
|
998
|
+
git commit -m "feat(kanban): column collapse, drop-policy constraints, wip badge"
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
```json:metadata
|
|
1002
|
+
{"files": ["lib/plutonium/ui/kanban/column.rb", "src/js/controllers/kanban_controller.js", "test/plutonium/ui/kanban/behaviours_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/kanban/behaviours_test.rb", "acceptanceCriteria": ["collapse toggle", "accepts/locked client constraints", "wip badge + over-limit"], "requiresUserVerification": false}
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
---
|
|
1006
|
+
|
|
1007
|
+
## Task 14: Opt-in real-time broadcaster
|
|
1008
|
+
|
|
1009
|
+
**Goal:** When `realtime true`, mirror a successful move's frame updates to other viewers via Turbo Streams, scoped to tenant + board. Off by default; no effect on the mover's own rollback.
|
|
1010
|
+
|
|
1011
|
+
**Files:**
|
|
1012
|
+
- Create: `lib/plutonium/kanban/broadcaster.rb`
|
|
1013
|
+
- Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` (broadcast after a successful move), `lib/plutonium/ui/kanban/resource.rb` (subscribe via `turbo_stream_from` when realtime)
|
|
1014
|
+
- Test: `test/plutonium/kanban/broadcaster_test.rb`
|
|
1015
|
+
|
|
1016
|
+
**Acceptance Criteria:**
|
|
1017
|
+
- [ ] Stream name includes `current_scoped_entity` + resource class (no cross-tenant leakage).
|
|
1018
|
+
- [ ] Broadcast fires only when `board.realtime?`.
|
|
1019
|
+
- [ ] The board subscribes (`turbo_stream_from`) only when realtime.
|
|
1020
|
+
|
|
1021
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/broadcaster_test.rb` → PASS
|
|
1022
|
+
|
|
1023
|
+
**Steps:** TDD; assert broadcast presence/absence and stream-name scoping (use Turbo test helpers / capture). Commit:
|
|
1024
|
+
|
|
1025
|
+
```bash
|
|
1026
|
+
git commit -m "feat(kanban): opt-in tenant-scoped realtime move broadcasting"
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
```json:metadata
|
|
1030
|
+
{"files": ["lib/plutonium/kanban/broadcaster.rb", "lib/plutonium/resource/controllers/kanban_actions.rb", "lib/plutonium/ui/kanban/resource.rb", "test/plutonium/kanban/broadcaster_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/broadcaster_test.rb", "acceptanceCriteria": ["tenant+board scoped stream", "fires only when realtime", "subscribes only when realtime"], "requiresUserVerification": false}
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
---
|
|
1034
|
+
|
|
1035
|
+
## Task 15: Dummy-app fixtures + end-to-end board
|
|
1036
|
+
|
|
1037
|
+
**Goal:** A real board in the dummy app to drive integration/system tests: a `Task`-style resource with `status` + decimal `position`, a definition with a `kanban do…end` (static columns, a wip, a backlog role, a done role + column action), connected to a portal. **Use Plutonium generators** (`pu:res:scaffold`, `pu:res:conn`) — do not hand-write app files.
|
|
1038
|
+
|
|
1039
|
+
**Files:**
|
|
1040
|
+
- Generated under `test/dummy/` (model, migration, definition, policy, controller, routes); then edit the definition to add `kanban do…end`
|
|
1041
|
+
- Test: a small `test/integration/admin_portal/kanban_smoke_test.rb`
|
|
1042
|
+
|
|
1043
|
+
**Acceptance Criteria:**
|
|
1044
|
+
- [ ] `rails g pu:res:scaffold Task title:string status:string position:decimal --dest=…` runs; migration applied; `position` is decimal.
|
|
1045
|
+
- [ ] Definition has a working `kanban` board (static columns + one column action backed by a dummy interaction).
|
|
1046
|
+
- [ ] Smoke test: board renders, a move persists, a column action runs.
|
|
1047
|
+
|
|
1048
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_smoke_test.rb` → PASS
|
|
1049
|
+
|
|
1050
|
+
**Steps:**
|
|
1051
|
+
- [ ] **Step 1:** Generate the resource + connect to the admin portal (quote types; pass `--dest=`, `--force` as needed). Edit the migration to confirm `position` is `decimal` and add `Plutonium::Positioning` (`positioned_on :position, scope: :status`) to the model. `rails db:prepare`.
|
|
1052
|
+
- [ ] **Step 2:** Add the `kanban do…end` block to `TaskDefinition` (mirror the spec's §3 example, scaled down). Add a trivial `ArchiveTasks` interaction for the column action.
|
|
1053
|
+
- [ ] **Step 3:** Write + run the smoke test → PASS.
|
|
1054
|
+
- [ ] **Step 4: Commit**
|
|
1055
|
+
|
|
1056
|
+
```bash
|
|
1057
|
+
git add test/dummy/ test/integration/admin_portal/kanban_smoke_test.rb
|
|
1058
|
+
git commit -m "test(kanban): dummy Task board fixture + smoke test"
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
> **Reorder note:** Tasks 6–13 integration tests depend on this fixture. If using subagent-driven execution, consider running Task 15 right after Task 5 (before the controller/UI tasks). Listed last only to keep the core/lib tasks contiguous.
|
|
1062
|
+
|
|
1063
|
+
```json:metadata
|
|
1064
|
+
{"files": ["test/dummy/app/models/task.rb", "test/dummy/app/definitions/task_definition.rb", "test/integration/admin_portal/kanban_smoke_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_smoke_test.rb", "acceptanceCriteria": ["scaffolded Task w/ decimal position", "kanban board in definition", "smoke: render+move+action"], "requiresUserVerification": false}
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
---
|
|
1068
|
+
|
|
1069
|
+
## Task 16: Documentation (guide + reference)
|
|
1070
|
+
|
|
1071
|
+
**Goal:** A `docs/guides/kanban.md` guide and `docs/reference/kanban/*` reference mirroring the wizard docs, plus nav wiring in `docs/.vitepress/config.ts` and `docs/guides/index.md`/`docs/reference/index.md`.
|
|
1072
|
+
|
|
1073
|
+
**Files:**
|
|
1074
|
+
- Create: `docs/guides/kanban.md`, `docs/reference/kanban/index.md`, `docs/reference/kanban/dsl.md`, `docs/reference/kanban/positioning.md`, `docs/reference/kanban/authorization.md`
|
|
1075
|
+
- Modify: `docs/.vitepress/config.ts`, `docs/guides/index.md`, `docs/reference/index.md`
|
|
1076
|
+
|
|
1077
|
+
**Acceptance Criteria:**
|
|
1078
|
+
- [ ] Guide covers: enabling the board, static vs dynamic columns, positioning modes, behaviours/archetypes, column actions, authorization, realtime.
|
|
1079
|
+
- [ ] `yarn docs:build` succeeds (no broken links).
|
|
1080
|
+
|
|
1081
|
+
**Verify:** `yarn docs:build` → exits 0
|
|
1082
|
+
|
|
1083
|
+
**Steps:** Write docs from the spec (DSL examples are already validated). Run `yarn docs:build`. Commit:
|
|
1084
|
+
|
|
1085
|
+
```bash
|
|
1086
|
+
git commit -m "docs(kanban): guide + reference"
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
```json:metadata
|
|
1090
|
+
{"files": ["docs/guides/kanban.md", "docs/reference/kanban/index.md", "docs/reference/kanban/dsl.md", "docs/.vitepress/config.ts"], "verifyCommand": "yarn docs:build", "acceptanceCriteria": ["guide covers all features", "docs build clean"], "requiresUserVerification": false}
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
---
|
|
1094
|
+
|
|
1095
|
+
## Task 17: `plutonium-kanban` skill + router entry
|
|
1096
|
+
|
|
1097
|
+
**Goal:** A `.claude/skills/plutonium-kanban/SKILL.md` (mirroring `plutonium-wizard`) and a router entry + table row in `.claude/skills/plutonium/SKILL.md`.
|
|
1098
|
+
|
|
1099
|
+
**Files:**
|
|
1100
|
+
- Create: `.claude/skills/plutonium-kanban/SKILL.md`
|
|
1101
|
+
- Modify: `.claude/skills/plutonium/SKILL.md`
|
|
1102
|
+
- Test: none (docs); verify by review.
|
|
1103
|
+
|
|
1104
|
+
**Acceptance Criteria:**
|
|
1105
|
+
- [ ] Skill describes when to use it, the DSL surface, and links to docs/spec.
|
|
1106
|
+
- [ ] Router table in `plutonium/SKILL.md` has a "build a kanban board" → `plutonium-kanban` row.
|
|
1107
|
+
|
|
1108
|
+
**Verify:** Manual review; `git grep -n "plutonium-kanban" .claude/skills/plutonium/SKILL.md` shows the entry.
|
|
1109
|
+
|
|
1110
|
+
**Steps:** Write the skill mirroring the wizard skill's structure. Commit:
|
|
1111
|
+
|
|
1112
|
+
```bash
|
|
1113
|
+
git commit -m "docs(kanban): plutonium-kanban skill + router entry"
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
```json:metadata
|
|
1117
|
+
{"files": [".claude/skills/plutonium-kanban/SKILL.md", ".claude/skills/plutonium/SKILL.md"], "verifyCommand": "git grep -n plutonium-kanban .claude/skills/plutonium/SKILL.md", "acceptanceCriteria": ["skill written", "router entry added"], "requiresUserVerification": false}
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
---
|
|
1121
|
+
|
|
1122
|
+
## Final verification (after all tasks)
|
|
1123
|
+
|
|
1124
|
+
- [ ] `bundle exec appraisal rails-8.1 rake test` → all green
|
|
1125
|
+
- [ ] `bundle exec appraisal rails-7 rake test` and `rails-8.0` → green (version parity)
|
|
1126
|
+
- [ ] `yarn build` clean; `yarn docs:build` clean
|
|
1127
|
+
- [ ] Manually drive the dummy board (see `memory/reference_driving_dummy_app_browser.md`): load `?view=kanban`, drag a card, run a column action, quick-add, toggle collapse.
|
|
1128
|
+
- [ ] Re-read the spec §2 decisions; confirm each has a landing task.
|