plutonium 0.60.5 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +378 -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 +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- 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/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -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 +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -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/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -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/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -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/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -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 +70 -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 +122 -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/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- 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 +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- 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/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- 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/table/resource.rb +1 -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 +92 -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 +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -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 +945 -286
- metadata +113 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62c3179e6fc69beaae9e39a5892a478bd41c546fcf0fb580dea6a4aa9680e296
|
|
4
|
+
data.tar.gz: 94c2ce8570a323536961196170f8cb7187efe724834485edfd210a783147cbe8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b9dc172a16b74c0f313606cefe964a661842aacd63c21df3ed3cb9ca20179439d2f302b53658305a375ee2b752aacf056244eb0a6875e2a31e20276b8055a2c
|
|
7
|
+
data.tar.gz: e1f47b1429392d82253f27e8268309e129d33e5d62716b04c669c13e24cc4e3b34d74728d154189c116124319c7155911e49000be008ea72a2bbdd64fecc3dbd
|
|
@@ -18,8 +18,22 @@ Entry point for all Plutonium work. Does three things:
|
|
|
18
18
|
- **For targeted edits** — use the **router table** to jump to the right skill.
|
|
19
19
|
- **For anything touching tenant scoping** — load `plutonium-tenancy`. Don't reach for `where(organization: ...)` in a policy; fix the model instead.
|
|
20
20
|
- **Unattended execution:** always pass `--dest=`, `--force` (when re-running meta-generators), `--auth=`, `--skip-bundle`, `--quiet` so generators don't block on prompts. See [Unattended execution](#unattended-execution).
|
|
21
|
+
- **Inspect before you act.** Every targeted skill now opens with a CHECK gate — read the relevant files yourself before scaffolding or editing. Don't ask the user to describe their app when you can read it.
|
|
21
22
|
|
|
22
|
-
##
|
|
23
|
+
## ✅ Orient before you route (CHECK — read the app, don't assume)
|
|
24
|
+
|
|
25
|
+
A one-line request rarely says whether this is a new app, a half-built one, or a multi-tenant one — and those change which path you take. Spend 30 seconds reading the app **before** loading a bundle or running anything:
|
|
26
|
+
|
|
27
|
+
| Read | Tells you |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `git log --oneline \| head`; is there a populated `Gemfile` + `app/`? | Greenfield vs existing → install path (`plutonium-app`: `base.rb`, **never** `plutonium.rb` on an existing app) |
|
|
30
|
+
| grep `Gemfile` for `plutonium`; `ls config/packages.rb` | Already installed? → skip install |
|
|
31
|
+
| `ls packages/` | What portals / feature packages already exist |
|
|
32
|
+
| Does any model `belongs_to` an org/team/tenant? | Multi-tenant → load `plutonium-tenancy` **before** declaring scoping |
|
|
33
|
+
|
|
34
|
+
This is the global "look before you leap"; each targeted skill carries its own ASK/CHECK gate for the specifics. **Never run an installer or scaffold from a one-line request without first reading what's already there.**
|
|
35
|
+
|
|
36
|
+
## The skills
|
|
23
37
|
|
|
24
38
|
| Skill | Covers |
|
|
25
39
|
|---|---|
|
|
@@ -27,9 +41,11 @@ Entry point for all Plutonium work. Does three things:
|
|
|
27
41
|
| **[[plutonium-resource]]** | The resource itself — `pu:res:scaffold`, field types, model layer (`Plutonium::Resource::Record`, `has_cents`, SGID, routing), definition layer (fields/inputs/displays/columns, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization) |
|
|
28
42
|
| **[[plutonium-behavior]]** | Controllers (hooks, key methods, presentation), policies (action methods, `permitted_attributes_for_*`, `permitted_associations`), interactions (structure, outcomes, chaining, URL generation) |
|
|
29
43
|
| **[[plutonium-ui]]** | Page classes, forms, displays, tables, custom Phlex components, layouts, modals & tabs, Tailwind config, Stimulus, design tokens, `.pu-*` classes, Phlexi themes |
|
|
44
|
+
| **[[plutonium-kanban]]** | `kanban do…end` DSL in a Definition — columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy, quick-add, static vs dynamic boards |
|
|
30
45
|
| **[[plutonium-auth]]** | Rodauth install, account types (basic / admin / SaaS), profile resource, security section |
|
|
31
46
|
| **[[plutonium-tenancy]]** | Entity scoping (`associated_with`, `default_relation_scope`, three model shapes), nested resources, invites |
|
|
32
47
|
| **[[plutonium-testing]]** | `pu:test:install`, `pu:test:scaffold`, `ResourceCrud`/`ResourcePolicy`/`ResourceDefinition`/`ResourceModel`/`NestedResource`/`PortalAccess`/`ResourceInteraction`, `AuthHelpers` |
|
|
48
|
+
| **[[plutonium-wizard]]** | Multi-step flows — the wizard DSL (`step`/`review`/`using:`/`condition:`, per-step `on_submit`/`persist`/`on_rollback`, `execute`), anchoring & resume, one-time wizards + gate, registration (`wizard` macro + `register_wizard`), storage/config + SweepJob |
|
|
33
49
|
|
|
34
50
|
## Greenfield bootstrap bundle
|
|
35
51
|
|
|
@@ -61,10 +77,12 @@ Add when relevant:
|
|
|
61
77
|
| Scope a model to a tenant, write `associated_with`, set portal entity strategy | **[[plutonium-tenancy]]** |
|
|
62
78
|
| Configure parent/child nested routes, custom parent resolution | **[[plutonium-tenancy]]** |
|
|
63
79
|
| Set up user invitations or entity membership | **[[plutonium-tenancy]]** |
|
|
80
|
+
| Build or customize a kanban board view — `kanban do…end`, columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy | **[[plutonium-kanban]]** |
|
|
64
81
|
| Build a custom page (override `ShowPage`/`IndexPage`/`NewPage`/`EditPage`), custom form, custom display, custom table, custom Phlex component | **[[plutonium-ui]]** |
|
|
65
82
|
| Configure Tailwind, register Stimulus controllers, edit design tokens, theme forms/displays/tables, write a custom layout | **[[plutonium-ui]]** |
|
|
66
83
|
| Install Rodauth, set up accounts, configure login flow, add the profile resource | **[[plutonium-auth]]** |
|
|
67
84
|
| Write tests for a resource, run `pu:test:scaffold`, include `Plutonium::Testing::*` concerns | **[[plutonium-testing]]** |
|
|
85
|
+
| Build a multi-step flow — onboarding, checkout, branching create — register a `wizard` / `register_wizard`, gate a one-time wizard | **[[plutonium-wizard]]** |
|
|
68
86
|
|
|
69
87
|
## Resource architecture at a glance
|
|
70
88
|
|
|
@@ -21,6 +21,47 @@ For the resources themselves (model + definition + scaffold options), see [[plut
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## 🛑 Before you install or scaffold structure: confirm the shape (ASK — don't infer)
|
|
25
|
+
|
|
26
|
+
"Set up Plutonium" / "make an admin area" / "create a billing package" each hide a high-blast-radius decision. Get one wrong and you **clobber a year of git history**, build the wrong kind of package, or ship a portal nobody can log into. Resolve each — confirming by inspection (next section), not assumption:
|
|
27
|
+
|
|
28
|
+
1. **Fresh app or existing one?** Existing ⇒ `bundle add plutonium` + `pu:core:install` (the `base.rb` path). **NEVER the `plutonium.rb` fresh-app template on an existing app** — it re-bootstraps (dotenv/annotate/solid_*/assets) and drops "initial commit" commits that clobber history. This is the single most dangerous mistake in this skill — confirm it's greenfield *before* reaching for `plutonium.rb`.
|
|
29
|
+
2. **Feature package or portal package?** Business logic (models/policies/definitions/interactions) ⇒ `pu:pkg:package` (feature, no UI). A web surface (controllers/views/routes/auth) ⇒ `pu:pkg:portal`. Hard split — "billing" is a *feature*; "admin area" is a *portal*. A feature package is invisible until its resources are `pu:res:conn`'d into a portal.
|
|
30
|
+
3. **Auth per portal.** `--auth=<account>` / `--public` / `--byo` / `--scope=<Entity>` (multi-tenant). Unguessable from "admin area" — decide, don't default silently.
|
|
31
|
+
4. **Don't stop half-wired.** A resource reaches the browser only after: scaffold → migrate → `pu:res:conn --dest=portal` → portal engine `mount`ed in `config/routes.rb` → registered (conn does the last). Name the whole chain before you start.
|
|
32
|
+
|
|
33
|
+
**Never ship a guessed schema, portal name, or auth flag as applied commands** — read them off the app first; fall back to `AskUserQuestion` only for genuine product choices (separate staff accounts vs shared, which payment backend). The decisions compound: *existing app ⇒ base.rb path*; *feature package ⇒ needs a portal to be visible*; *new portal ⇒ pick auth + mount it*.
|
|
34
|
+
|
|
35
|
+
## ✅ Before you run a generator: verify the ground truth (CHECK — read it, don't ask for it)
|
|
36
|
+
|
|
37
|
+
You have file access — **inspect**; don't ask the user to describe their app.
|
|
38
|
+
|
|
39
|
+
| Check | How | Why it matters |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Greenfield vs existing | `git log --oneline \| head`; is there a populated `Gemfile`/`app/`? | An existing app must use the `base.rb` path — **never** `plutonium.rb` |
|
|
42
|
+
| Plutonium already installed | grep `Gemfile` for `plutonium`; `ls config/packages.rb app/controllers/resource_controller.rb` | Avoid re-installing / double bootstrap |
|
|
43
|
+
| Package/portal already exists | `ls packages/<name>` | Don't duplicate — connect to / extend the existing one |
|
|
44
|
+
| Existing auth | grep `Gemfile`/`app/models` for `rodauth`/`devise`/`has_secure_password` | Drives `--auth` vs `--byo` |
|
|
45
|
+
| Portal engine mounted | grep `config/routes.rb` for `mount <Portal>::Engine` | An unmounted portal 404s |
|
|
46
|
+
| Resource registered | grep the portal's `config/routes.rb` for `register_resource ::<X>` | Unregistered ⇒ no URLs (`resource_url_for` fails) |
|
|
47
|
+
| Migrations applied | `rails db:migrate:status` before `pu:res:conn` | `conn` seeds the policy from columns |
|
|
48
|
+
|
|
49
|
+
Inspect with your own tools **before** running any generator.
|
|
50
|
+
|
|
51
|
+
## 🛠 Use the generator — pick the right install path
|
|
52
|
+
|
|
53
|
+
Never hand-write base controllers, engine files, layouts, or route registration. Pass `--dest`/`--auth`/`--force`/`--skip-bundle` for unattended runs.
|
|
54
|
+
|
|
55
|
+
| Task | Generator | Verify first |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| Install — **existing** app | `bundle add plutonium` + `pu:core:install` | It's an existing app (use `base.rb`, **not** `plutonium.rb`) |
|
|
58
|
+
| Install — **fresh** app | `rails new … -m …/plutonium.rb` | Brand-new app **only** |
|
|
59
|
+
| Feature package | `pu:pkg:package <name>` | Not already present |
|
|
60
|
+
| Portal package | `pu:pkg:portal <name> --auth=…/--public/--byo/--scope=…` | Auth strategy decided; then `mount` the engine by hand |
|
|
61
|
+
| Connect a resource | `pu:res:conn <Res> --dest=portal` | Migrated; target portal exists |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
24
65
|
# Part 1 — Installation
|
|
25
66
|
|
|
26
67
|
## Fresh Rails app (recommended)
|
|
@@ -21,6 +21,46 @@ For multi-tenant invitations and membership, see [[plutonium-tenancy]] › Invit
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## 🛑 Before you set up auth: confirm the shape (ASK — don't infer)
|
|
25
|
+
|
|
26
|
+
"Set up login" hides the one decision that determines everything: **is this multi-tenant SaaS or a single auth surface?** Pick wrong and you either hand-assemble what a meta-generator does in one shot, or scaffold a SaaS spine an app doesn't need. Resolve each — confirming by inspection (next section):
|
|
27
|
+
|
|
28
|
+
1. **Single auth or multi-tenant SaaS?** "Each user belongs to / manages an org/team" ⇒ SaaS ⇒ **`pu:saas:setup`** (the meta-generator: user + entity + membership + portal + profile + welcome + invites in one). A plain login with no tenant ⇒ `pu:rodauth:install` + `pu:rodauth:account`.
|
|
29
|
+
2. **Account type.** Basic user, **hardened admin** (`pu:rodauth:admin` — 2FA/lockout/audit, no public signup), or **API** (`--api_only --jwt`)? They're different generators.
|
|
30
|
+
3. **Public signup allowed?** Default yes for `account`; admin accounts are invite-only.
|
|
31
|
+
4. **Profile / account-settings page?** Needs `pu:profile:install` **and** `pu:profile:conn` (without conn there's no `/profile` route).
|
|
32
|
+
5. **Roles.** Index 0 is most privileged (`owner`/`super_admin`); invites default new members to `roles[1]`. `pu:saas:setup` prepends `owner` — don't list it.
|
|
33
|
+
|
|
34
|
+
**Never ship a guessed account-type, model name, or `--roles` as applied commands.** Read them off the app first; fall back to `AskUserQuestion` only for product choices (separate staff accounts vs shared, is signup open).
|
|
35
|
+
|
|
36
|
+
## ✅ Before you run a generator: verify the ground truth (CHECK — read it, don't ask for it)
|
|
37
|
+
|
|
38
|
+
You have file access — **inspect**; don't ask the user to describe their app.
|
|
39
|
+
|
|
40
|
+
| Check | How | Why it matters |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Rodauth already installed | `ls app/rodauth/rodauth_app.rb`; grep `Gemfile` for `rodauth` | Re-running `pu:rodauth:install` clobbers config |
|
|
43
|
+
| Existing account models | grep `app/models` for `Rodauth::Rails.model` | Which account type exists / don't duplicate |
|
|
44
|
+
| SaaS spine already run | `ls` for the entity portal + membership model | **`pu:saas:setup` chains 4 generators — don't re-run them separately** |
|
|
45
|
+
| Profile wired | grep the user model for `has_one :profile` + `after_create`; is `profile_url` defined? | Else `current_user.profile` is nil / no route (`pu:profile:conn` missing) |
|
|
46
|
+
| Role ordering | Read the membership `enum :role` | Index 0 = most privileged; invites default to `roles[1]` |
|
|
47
|
+
|
|
48
|
+
Inspect with your own tools **before** running any generator.
|
|
49
|
+
|
|
50
|
+
## 🛠 Use the generator — never hand-write Rodauth
|
|
51
|
+
|
|
52
|
+
Never hand-write Rodauth plugin files, account models, or profile resources.
|
|
53
|
+
|
|
54
|
+
| Task | Generator | Verify first |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| Multi-tenant SaaS spine | `pu:saas:setup --user U --entity E --roles=…` | Not already run (don't re-run the 4 sub-generators it chains) |
|
|
57
|
+
| Rodauth base | `pu:rodauth:install` | Not already installed |
|
|
58
|
+
| Basic account | `pu:rodauth:account NAME --defaults` | Rodauth installed |
|
|
59
|
+
| Hardened admin | `pu:rodauth:admin NAME --roles=…` | Rodauth installed |
|
|
60
|
+
| Profile page | `pu:profile:install …` + `pu:profile:conn --dest=portal` | Migrated; portal exists |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
24
64
|
## Install
|
|
25
65
|
|
|
26
66
|
```bash
|
|
@@ -25,6 +25,52 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
|
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
+
## 🛑 Before you write behavior: place it in the right layer (ASK — don't infer)
|
|
29
|
+
|
|
30
|
+
"Make X happen" doesn't say **where** X lives. Put it in the wrong layer and you get authorization that doesn't authorize, a 500 on the happy path, or a CRUD override that breaks params/auth. First place the requirement, then confirm names against the real code (next section):
|
|
31
|
+
|
|
32
|
+
| The requirement (in plain words) | Goes in | **NOT** in |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| "only \<role/owner\> may do X" — *who is allowed* | **Policy** `def x?` | a `condition:` proc — that only hides the button; the route stays live and callable |
|
|
35
|
+
| "doing X changes state / sends mail / charges a card" — *the work* | **Interaction** `execute`, registered as an action | a hand-written controller action; an override of `create`/`update` |
|
|
36
|
+
| "after create/update go to Y" · "munge a param" · "reshape the index query" | **Controller hook** (`redirect_url_after_submit`, `resource_params`, `filtered_resource_collection`) | overriding `create`/`update`/`index` |
|
|
37
|
+
| "which fields are visible / editable" | **Policy** `permitted_attributes_for_*` | the definition — that only controls *how* a field renders |
|
|
38
|
+
|
|
39
|
+
Then resolve the specifics:
|
|
40
|
+
|
|
41
|
+
1. **A custom action needs BOTH:** an interaction (the work) **and** a policy `def <action>?` (the authorization). Miss the policy method ⇒ the action silently returns `false` (dead button). Put the role check in `condition:` ⇒ it isn't enforced — a direct POST still runs.
|
|
42
|
+
2. **`create?`/`read?` default to `false`** — override explicitly; derived methods (`update?`/`show?`/…) inherit.
|
|
43
|
+
3. **Any `create!`/`update!`/`save!` in `execute`** ⇒ rescue `ActiveRecord::RecordInvalid` → `failed(e.record.errors)`. Not auto-rescued — otherwise a validation failure 500s.
|
|
44
|
+
4. **`has_cents`** ⇒ permit `:price`, never `:price_cents`.
|
|
45
|
+
5. **New vs editing** — never re-scaffold a controller/policy/interaction that's been customized.
|
|
46
|
+
|
|
47
|
+
**Never ship a guessed role method, column, enum value, or association as applied code.** `user.finance?`, `record.status_approved?`, `expense.submitted_by` either exist in the app or they don't — confirm them before writing, don't assume. Fall back to `AskUserQuestion` only for genuine product choices (what the rule *should* be), never for facts you can read.
|
|
48
|
+
|
|
49
|
+
## ✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
|
|
50
|
+
|
|
51
|
+
You have file access — **inspect**; don't ask the user to describe their own app.
|
|
52
|
+
|
|
53
|
+
| Check | How | Why it matters |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| File already customized | Read `app/policies/<x>_policy.rb`, the controller, `app/interactions/*` | Edit incrementally — re-scaffolding clobbers customizations |
|
|
56
|
+
| The role/method you authorize on exists | grep the user model for `def finance?` / `enum :role` / `has_role?` | `user.finance?` 500s (or is silently `false`) if absent |
|
|
57
|
+
| The columns/enum your interaction writes | Read the model + `db/schema.rb` for the enum value, `approved_by`/`approved_at`, the submitter assoc | `update!(status: :approved)` raises if the value/column is missing |
|
|
58
|
+
| Action not already wired | grep the definition for `action :<x>`; grep the policy for `def <x>?` | Avoids duplicate or dead actions |
|
|
59
|
+
| Cross-resource access | Use `authorized_resource_scope` / `allowed_to?`, never raw `where`/`find` | Raw queries bypass the other resource's tenancy + visibility |
|
|
60
|
+
|
|
61
|
+
Inspect with your own tools **before** proposing code.
|
|
62
|
+
|
|
63
|
+
## 🛠 Use the generator — and know what's hand-authored
|
|
64
|
+
|
|
65
|
+
| Task | How | Verify first |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Base trio (controller + policy + interaction-base) | `pu:res:scaffold` | New resource |
|
|
68
|
+
| Portal-specific controller/policy | `pu:res:conn … --dest=portal` | Resource exists |
|
|
69
|
+
| **A custom-action interaction** | **Hand-author** in `app/interactions/<name>_interaction.rb` (subclass `ResourceInteraction`) — **there is NO `pu:res:interaction` generator; don't invent one** | — |
|
|
70
|
+
| Edit an existing customized policy/controller/interaction | Hand-edit the file | It was already generated — re-scaffolding clobbers it |
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
28
74
|
# Part 1 — Controllers
|
|
29
75
|
|
|
30
76
|
Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.
|
|
@@ -494,7 +540,7 @@ def permitted_associations
|
|
|
494
540
|
end
|
|
495
541
|
```
|
|
496
542
|
|
|
497
|
-
Declares which associations get their own **tab on the show page**. When `permitted_associations` is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs.
|
|
543
|
+
Declares which associations get their own **tab on the show page**. When `permitted_associations` is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
|
|
498
544
|
|
|
499
545
|
Each named association must:
|
|
500
546
|
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plutonium-kanban
|
|
3
|
+
description: Use BEFORE building or customizing a kanban board view for any Plutonium resource — the kanban do…end DSL, column declarations, card_fields, position_on modes, realtime, column actions, kanban_move? policy, and quick-add. The single source for "how do I add a kanban board to a resource".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Kanban
|
|
7
|
+
|
|
8
|
+
Turn any resource index into a drag-and-drop kanban board with a single `kanban do…end` block in the resource Definition. This skill covers the full DSL surface, model setup, authorization, and the caveats.
|
|
9
|
+
|
|
10
|
+
For field-level rendering on cards (card_fields slots), see [[plutonium-resource]] › Index Views. For policy structure, see [[plutonium-behavior]]. For custom Phlex components on cards, see [[plutonium-ui]].
|
|
11
|
+
|
|
12
|
+
## 🚨 Critical (read first)
|
|
13
|
+
|
|
14
|
+
- **`kanban do…end` in the Definition auto-enables `:kanban`** in `defined_index_views` — exactly like `grid_fields` enables `:grid`. You do not need to call `index_views :kanban` separately unless you want to remove the table view.
|
|
15
|
+
- **The model needs `include Plutonium::Positioning`** (and a decimal `position` column + `positioned_on` call) for drag ordering to work. Without it, cards render unordered and moves raise an error. Use `position_on false` to explicitly opt out.
|
|
16
|
+
- **Static column actions are auto-registered** as interactive resource actions at class-load time. Dynamic boards (`columns do…end`) cannot introspect their columns at load time — declare any column-action interactions separately with top-level `action` calls.
|
|
17
|
+
- **Moves bypass `permitted_attributes_for_update`** — the `on_enter` callback runs with full model access. Gate the move itself with `kanban_move?` in the policy.
|
|
18
|
+
- **Quick-add (`add: true`) only appears when `create?` is true** in the policy.
|
|
19
|
+
- **Same-column drops = positioning only** — a reorder within a column fires neither `on_exit`, `on_enter`, nor an `enter_interaction`; they represent *leaving*/*entering* a column, so only cross-column drops trigger them.
|
|
20
|
+
- **`on_exit:` is the source-side hook** — fired when a card LEAVES a column (before the destination's `on_enter`, in the same transaction). Use it for source-tied side effects (stop a timer, release a slot) the destination can't own. It fires only on drag-moves via `kanban_move`, NOT on destroy/programmatic changes/quick-add — for those, use an ActiveRecord callback.
|
|
21
|
+
- **Use `on_enter:` / `enter_interaction:`, NOT `on_drop:` / `drop_interaction:`.** The old names were renamed. They still exist as deprecated aliases but **raise in development/test** (and only warn-and-map in production), so a definition using them fails your test suite. Always write the new names.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Model setup
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class Task < ApplicationRecord
|
|
29
|
+
include Plutonium::Positioning
|
|
30
|
+
|
|
31
|
+
# position_on :position (default attr) scoped to the grouping column
|
|
32
|
+
positioned_on :position, scope: :status
|
|
33
|
+
|
|
34
|
+
def mark_done!
|
|
35
|
+
update!(status: "done")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Migration — add the position column with the `t.position` helper (a tuned `decimal(16,8)`; works in `create_table` and `change_table`). Don't hand-roll a small scale — `scale: 6` exactly matches the `1e-6` rebalance threshold and can round to a duplicate. Use `t.position` (scale 8) or ≥ 8 if hand-written:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
create_table :tasks do |t|
|
|
44
|
+
t.string :title, null: false
|
|
45
|
+
t.string :status, null: false, default: "todo"
|
|
46
|
+
t.position # decimal :position, precision: 16, scale: 8
|
|
47
|
+
t.timestamps
|
|
48
|
+
|
|
49
|
+
t.index [:status, :position]
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Minimal definition
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class TaskDefinition < ResourceDefinition
|
|
59
|
+
kanban do
|
|
60
|
+
column :todo,
|
|
61
|
+
scope: -> { where(status: "todo") },
|
|
62
|
+
on_enter: ->(r) { r.update!(status: "todo") }
|
|
63
|
+
|
|
64
|
+
column :doing,
|
|
65
|
+
scope: -> { where(status: "doing") },
|
|
66
|
+
on_enter: ->(r) { r.update!(status: "doing") }
|
|
67
|
+
|
|
68
|
+
column :done,
|
|
69
|
+
scope: -> { where(status: "done") },
|
|
70
|
+
on_enter: :mark_done! # Symbol → record.mark_done!
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Set it as the default or only view:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
default_index_view :kanban # still shows view switcher with :table
|
|
79
|
+
index_views :kanban # remove table; kanban is the only view
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Board-level options
|
|
85
|
+
|
|
86
|
+
| DSL call | Purpose | Default |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `per_column N` | Cap cards rendered per column; `+N more` footer when exceeded | unlimited |
|
|
89
|
+
| `position_on :attr` | Custom attribute name for ordering (Mode A) | `:position` |
|
|
90
|
+
| `position_on :attr do \|move\| … end` | BYO positioning block (Mode B) | — |
|
|
91
|
+
| `position_on false` | No ordering or repositioning (Mode C) | — |
|
|
92
|
+
| `card_fields(**slots)` | Override grid slot layout for cards; same slot keys as `grid_fields` | inherits `grid_fields` |
|
|
93
|
+
| `realtime true` | ActionCable broadcast after every move | false |
|
|
94
|
+
| `lazy false` | Eager-load all column frames on the initial request | `true` (lazy) |
|
|
95
|
+
| `show_in :modal` / `:page` | Open a card's show page in a centered modal (`:modal`) or full-page (`:page`). Overrides the definition's `show_in` for this board | inherits definition (`:page`) |
|
|
96
|
+
| `columns do … end` | Dynamic columns evaluated at request time with view context | — |
|
|
97
|
+
|
|
98
|
+
### `card_fields`
|
|
99
|
+
|
|
100
|
+
Overrides the grid card layout for kanban cards. Uses the same slot keys as `grid_fields`:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
card_fields header: :title, meta: [:status, :priority], footer: :due_at
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `position_on` modes
|
|
107
|
+
|
|
108
|
+
- **Mode A (default)** — delegates to `record.reposition!(prev_record:, next_record:)` from `Plutonium::Positioning`. Requires the model concern and a decimal column.
|
|
109
|
+
- **Mode B (block)** — you write the persistence. Plutonium still orders by the attribute; the block only persists the new value. Block receives a `Plutonium::Kanban::Positioning::Move` (fields: `record`, `column`, `prev`, `next`, `index`).
|
|
110
|
+
- **Mode C (`false`)** — no ordering, no repositioning. `on_enter` still fires.
|
|
111
|
+
|
|
112
|
+
### `realtime`
|
|
113
|
+
|
|
114
|
+
Broadcasts refreshed column turbo-frames to all board subscribers after every successful move. Requires ActionCable. Opt in per-board:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
realtime true
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `show_in`
|
|
121
|
+
|
|
122
|
+
Where a card click opens the record's show page. `:modal` renders the show page in a **centered** dialog; `:page` is a full-page navigation. The show modal is always centered — deliberately NOT the definition's `modal_mode` (which styles `new`/`edit`). No per-card wiring: the `Show` page detects the modal frame (`in_modal?`) and wraps its details in the centered modal chrome.
|
|
123
|
+
|
|
124
|
+
`show_in` also exists **on the definition** (`show_in :modal` / `:page`, default `:page`), where it governs the table and grid show links too. The kanban board inherits the definition's value unless it sets its own — so set it once on the definition for everywhere, or on the board to override just the board.
|
|
125
|
+
|
|
126
|
+
From inside the show modal, an expand icon (or ⌘/Ctrl/middle-click on the card) opens the full page in a new tab.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class TaskDefinition < ResourceDefinition
|
|
130
|
+
show_in :modal # table + grid + board open show in a centered modal
|
|
131
|
+
kanban do
|
|
132
|
+
# show_in :page # override: this board navigates full-page
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Dynamic columns
|
|
138
|
+
|
|
139
|
+
Evaluates the block at request time with the view context as `self` (`current_user`, `params`, `current_scoped_entity`, helpers all available). The block must return an Array of `Plutonium::Kanban::Column` objects — `column` is a DSL method only available outside the `columns` block. Declare any column-action interactions as top-level definition `action` calls — the block is not introspectable at class-load time.
|
|
140
|
+
|
|
141
|
+
> **`enter_interaction:` is NOT supported on dynamic boards.** Its hidden action is registered from the static column list at class-load time, which a `columns do…end` board doesn't have, and the key is column-scoped/internal so there's no manual-registration escape hatch (unlike column actions). A drop into such a column is rejected with a snap-back — it does not crash. Use a static board if you need `enter_interaction:`.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
kanban do
|
|
145
|
+
columns do
|
|
146
|
+
# `self` is the view context here — use Plutonium::Kanban::Column.new, NOT `column`.
|
|
147
|
+
current_user.teams.map do |team|
|
|
148
|
+
Plutonium::Kanban::Column.new(
|
|
149
|
+
:"team_#{team.id}",
|
|
150
|
+
label: team.name,
|
|
151
|
+
scope: -> { where(team_id: team.id) },
|
|
152
|
+
on_enter: ->(r) { r.update!(team_id: team.id) }
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Column options
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
column :key,
|
|
165
|
+
label: "Custom Label", # defaults to key.to_s.titleize
|
|
166
|
+
color: :green, # Tailwind-mapped color hint
|
|
167
|
+
wip: 3, # max cross-column moves into this column
|
|
168
|
+
scope: -> { where(…) }, # 0-arg lambda or Symbol (sent to relation)
|
|
169
|
+
on_enter: ->(r) { … }, # 1-arg lambda or Symbol → record.method! (card ENTERS)
|
|
170
|
+
on_exit: ->(r) { … }, # 1-arg lambda or Symbol → runs when a card LEAVES this column
|
|
171
|
+
enter_interaction: MarkLostInteraction, # record-scoped interaction run on cross-column drop (see below)
|
|
172
|
+
collapsed: true, # starts collapsed (Stimulus persists toggle to localStorage)
|
|
173
|
+
add: true, # show "+ Add" button (requires create?)
|
|
174
|
+
accepts: true, # true (default), false, or Array of source keys (Proc raises)
|
|
175
|
+
locked: false, # reject all incoming drops (server-enforced)
|
|
176
|
+
role: :backlog # :backlog, :done or :lost (see presets below)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Column role presets
|
|
180
|
+
|
|
181
|
+
| Role | Preset behaviour |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `:backlog` | `add: true` |
|
|
184
|
+
| `:done` | `color: :green`, `collapsed: true` |
|
|
185
|
+
| `:lost` | `color: :red`, `collapsed: true` |
|
|
186
|
+
|
|
187
|
+
`:done` and `:lost` are the two terminal roles — collapsed by default, colour
|
|
188
|
+
signalling the outcome (`:done` = positive close, `:lost` = negative close). The
|
|
189
|
+
natural pair for won/lost pipelines (leads, deals, tickets).
|
|
190
|
+
|
|
191
|
+
Explicit options override the preset (e.g. `role: :done, collapsed: false`).
|
|
192
|
+
|
|
193
|
+
### `accepts:`
|
|
194
|
+
|
|
195
|
+
Structural drop topology — which **source columns** may drop cards here:
|
|
196
|
+
|
|
197
|
+
- `true` (default) — any source allowed
|
|
198
|
+
- `false` — column is a drop target but refuses everything (snap-back)
|
|
199
|
+
- `Array` — list of source column keys allowed: `accepts: [:doing]`
|
|
200
|
+
|
|
201
|
+
Checked server-side; client-side visual hints read `data-kanban-accepts` (so the drag UI can grey out disallowed sources). **No Proc form** — a `Proc` raises `ArgumentError`. Record- or user-conditional rules belong in `kanban_move?`, which sees the record and the `from`/`to` columns (see Authorization below).
|
|
202
|
+
|
|
203
|
+
### `on_enter:`
|
|
204
|
+
|
|
205
|
+
Runs inside a transaction after authorization and before repositioning. Receives the record for lambda form:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
on_enter: ->(r) { r.update!(status: "done") } # update! directly
|
|
209
|
+
on_enter: ->(r) { r.status = "done" } # attribute assignment — saved automatically
|
|
210
|
+
on_enter: :mark_done! # dispatched as record.mark_done!
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
If `on_enter` only assigns attributes without calling `save!`/`update!`, the controller calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns.
|
|
214
|
+
|
|
215
|
+
### `on_exit:`
|
|
216
|
+
|
|
217
|
+
The source-side counterpart to `on_enter:`. Runs on the column a card **leaves** during a cross-column move, **before** the destination's `on_enter`, in the same transaction (so it sees the pre-move state and rolls back if the move fails). Same Symbol/Proc dispatch and auto-save behaviour as `on_enter`.
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
column :doing,
|
|
221
|
+
scope: -> { where(status: "doing") },
|
|
222
|
+
on_enter: ->(r) { r.start_timer! }, # entering Doing
|
|
223
|
+
on_exit: ->(r) { r.stop_timer! } # leaving Doing (wherever it goes)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Use it for side effects tied to the column being **left** — the destination's `on_enter` doesn't know where a card came from, so source concerns (stop a timer, release a WIP/lock, un-assign) belong here.
|
|
227
|
+
|
|
228
|
+
⚠️ It fires **only** on a drag-move through `kanban_move` — not on `destroy`, a programmatic `status` change elsewhere, or quick-add. For "whenever this leaves, no matter how", use an ActiveRecord callback. Skipped on same-column reorders.
|
|
229
|
+
|
|
230
|
+
### `enter_interaction:`
|
|
231
|
+
|
|
232
|
+
Run an input-collecting interaction when a card is dropped **into** this column from another column — for entries that need more than a membership flip (a reason, a mail, an audit entry).
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
column :lost, scope: -> { where(status: "lost") }, enter_interaction: MarkLostInteraction
|
|
236
|
+
|
|
237
|
+
class MarkLostInteraction < ResourceInteraction
|
|
238
|
+
attribute :resource # MUST be record-scoped (singular), not :resources
|
|
239
|
+
attribute :reason, :string
|
|
240
|
+
input :reason
|
|
241
|
+
validates :reason, presence: true
|
|
242
|
+
def execute
|
|
243
|
+
resource.update!(status: "lost", lost_reason: reason)
|
|
244
|
+
succeed(resource).with_message("Marked as lost") # message → toast
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- **Auto-registered as a HIDDEN record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`) — unique by construction, so two columns can reuse the same interaction class. No button on show/table/grid; reachable only by dropping. **No policy method of its own** — authorized by `kanban_move?` (see Authorization).
|
|
250
|
+
- **Move flow:** cross-column drop opens the interaction's form as a modal; on submit `on_enter` + interaction + repositioning commit in **one atomic transaction**. Validation failure rolls it all back (membership write included) and re-renders the modal with errors — nothing persists. Put side-effects on `deliver_later` so a rollback sends no stray mail.
|
|
251
|
+
- **Same-column reorder = positioning only** — neither `on_enter` nor the interaction fires (both = *entering* a column).
|
|
252
|
+
- **Quick-add (`+ Add`)** applies `on_enter` + positioning post-create; the interaction is not involved.
|
|
253
|
+
- **Author contract:** with both present, `on_enter` owns the membership attribute (`status`) and the interaction owns extras. If the interaction also writes membership it must set the **same** value (idempotent). With no `on_enter`, the interaction owns everything (like `:lost`).
|
|
254
|
+
- **Limitation:** custom success *responses* (`with_redirect_response`, `with_file_response`, …) are NOT honored on the drop path — board re-renders + modal closes. Use `.with_message` for feedback.
|
|
255
|
+
|
|
256
|
+
### Column actions
|
|
257
|
+
|
|
258
|
+
Declared inside the column block. Auto-registered as interactive resource actions:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
column :done, … do
|
|
262
|
+
action :archive_all,
|
|
263
|
+
interaction: ArchiveTasksInteraction,
|
|
264
|
+
on: :all, # :all or :visible
|
|
265
|
+
label: "Archive all",
|
|
266
|
+
icon: Phlex::TablerIcons::Archive,
|
|
267
|
+
confirmation: "Archive all done tasks?"
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`on: :all` — passes every record in the column scope. `on: :visible` — passes only the currently rendered subset (respects `per_column`).
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Authorization
|
|
276
|
+
|
|
277
|
+
### `kanban_move?` → `update?`
|
|
278
|
+
|
|
279
|
+
Every drag-and-drop is authorized via `kanban_move?` in the policy. Default:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
def kanban_move?
|
|
283
|
+
update?
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Override for finer control:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
class TaskPolicy < ResourcePolicy
|
|
291
|
+
def kanban_move? = user.member? # members can drag; only admins edit
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
When `kanban_move?` returns `false`, the board renders read-only — no drag handles, no drop zones.
|
|
296
|
+
|
|
297
|
+
**`kanban_move?` is the ONLY move authorization** — plain moves and `enter_interaction:` columns alike (the interaction has no policy method of its own). To gate a *specific* transition, read the destination (and source) column from the authorization context via the optional `kanban_to` / `kanban_from` policy readers (the `Column` objects; `nil` for every non-move check):
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
def kanban_move?
|
|
301
|
+
return user.manager? if kanban_to&.key == :closed_won
|
|
302
|
+
super
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Rules take no positional args in ActionPolicy — the columns arrive as declared optional context (`authorize :kanban_from/:kanban_to, optional: true` on the base policy), supplied by the controller on the move check.
|
|
307
|
+
|
|
308
|
+
### Move authorization flow
|
|
309
|
+
|
|
310
|
+
1. Record loaded via current `relation_scope` (same as index).
|
|
311
|
+
2. `kanban_move?` checked (with `kanban_from`/`kanban_to` in context) — HTTP 403 on failure. This is the sole authorization; an `enter_interaction` rides on it.
|
|
312
|
+
3. Column `accepts:` / `locked:` checked — HTTP 422 + card snap-back on failure.
|
|
313
|
+
4. `wip:` limit checked for cross-column moves — HTTP 422 on failure.
|
|
314
|
+
5. `on_enter` fires + record repositioned, all in a transaction.
|
|
315
|
+
|
|
316
|
+
On a 422 rejection (steps 3–4) the response re-renders the source column (snap-back) **and** appends a dismissable warning toast naming the reason (e.g. `“Pending” is at its WIP limit (5).`) to the board's `#kanban-flash` region — so the snap-back is never silent. The toast renders the shared `plutonium/toast` partial directly (not via `flash`), so a stale undisplayed flash can't leak into the turbo-stream response.
|
|
317
|
+
|
|
318
|
+
### No permitted-attributes gate
|
|
319
|
+
|
|
320
|
+
Moves do not pass through `permitted_attributes_for_update`. `on_enter` is trusted author code; it is responsible for assigning only the appropriate attributes.
|
|
321
|
+
|
|
322
|
+
### Quick-add
|
|
323
|
+
|
|
324
|
+
The `+ Add` button (column `add: true`) only renders when the policy's `create?` is true. The opened form is the standard new-resource form.
|
|
325
|
+
|
|
326
|
+
The record is created normally, **then** the column's `on_enter` + positioning are applied to the **saved** record (it lands in the clicked column, appended to the bottom) — `on_enter` runs against a real record, exactly as on a drag. **Your grouping column must have a default** (DB or model), because `on_enter` runs after save; a `NOT NULL` grouping column with no default fails quick-add create. A raising `on_enter` keeps the created record in its default column and toasts the error (the create is not rolled back).
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Worked example (full)
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
class TaskDefinition < ResourceDefinition
|
|
334
|
+
kanban do
|
|
335
|
+
per_column 25
|
|
336
|
+
card_fields header: :title, meta: [:status]
|
|
337
|
+
|
|
338
|
+
column :todo,
|
|
339
|
+
scope: -> { where(status: "todo") },
|
|
340
|
+
on_enter: ->(r) { r.update!(status: "todo") },
|
|
341
|
+
role: :backlog # add: true
|
|
342
|
+
|
|
343
|
+
column :doing,
|
|
344
|
+
scope: -> { where(status: "doing") },
|
|
345
|
+
on_enter: ->(r) { r.update!(status: "doing") },
|
|
346
|
+
wip: 3
|
|
347
|
+
|
|
348
|
+
column :done,
|
|
349
|
+
scope: -> { where(status: "done") },
|
|
350
|
+
on_enter: :mark_done!,
|
|
351
|
+
accepts: [:doing], # only cards coming from :doing
|
|
352
|
+
role: :done do # color: :green, collapsed: true
|
|
353
|
+
action :archive_all,
|
|
354
|
+
interaction: ArchiveTasksInteraction,
|
|
355
|
+
on: :all,
|
|
356
|
+
label: "Archive all"
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Full docs
|
|
365
|
+
|
|
366
|
+
- Guide: `docs/guides/kanban.md`
|
|
367
|
+
- DSL reference: `docs/reference/kanban/dsl.md`
|
|
368
|
+
- Positioning reference: `docs/reference/kanban/positioning.md`
|
|
369
|
+
- Authorization reference: `docs/reference/kanban/authorization.md`
|
|
370
|
+
- Working example: `test/dummy/app/definitions/task_definition.rb`
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## Related skills
|
|
375
|
+
|
|
376
|
+
- [[plutonium-resource]] — Definition layer, `grid_fields`, index views, actions
|
|
377
|
+
- [[plutonium-behavior]] — Policy methods, `kanban_move?`, interactions
|
|
378
|
+
- [[plutonium-ui]] — Custom Phlex components for card rendering
|
|
@@ -22,6 +22,46 @@ For tenancy / `associated_with` / `relation_scope`, load [[plutonium-tenancy]].
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
## 🛑 Before you scaffold or edit: confirm the shape (ASK — don't infer)
|
|
26
|
+
|
|
27
|
+
"Add a Product" / "add a status field" is underspecified. Guess wrong and you scaffold into the wrong package, churn migrations, store money as a lossy float, reference a model that doesn't exist, or **clobber files the user has customized**. Resolve each — **by inspecting (next section), not guessing** — then restate the resolved shape and confirm:
|
|
28
|
+
|
|
29
|
+
1. **New resource, or editing an existing one?** Existing ⇒ **NEVER re-run `pu:res:scaffold`** (it overwrites the customized model/definition/policy/controller). Add an incremental migration + hand-edit. Confirm by reading the files *first*.
|
|
30
|
+
2. **Destination & portal.** `--dest=main_app` or a package? Which portal does `pu:res:conn` wire it to? Both are **required** and unguessable from the request.
|
|
31
|
+
3. **Field types — and the money question.** A monetary field ⇒ `has_cents` (`price_cents:integer` + `has_cents :price_cents`, reference `:price`), **never a bare `decimal`**. Enums, attachments (`:attachment`/`:attachments`), rich text, references each have specific syntax (§ Field Type Syntax). Confirm types rather than inventing them.
|
|
32
|
+
4. **Referenced associations must already exist.** `category:belongs_to` silently targets a `Category` — if that model isn't there, scaffold it first.
|
|
33
|
+
5. **Beyond columns:** does it need search / filters / scopes / custom or bulk actions? Those live in the definition + policy, not the scaffold — name them now so you don't half-build.
|
|
34
|
+
|
|
35
|
+
**Never emit applied scaffold commands from a guessed `--dest`, portal, or money-shape.** Confirm or read them first; fall back to `AskUserQuestion` only for product choices you can't read off the code (which portal, is `price` money). The decisions compound: *existing+customized ⇒ migration not scaffold*; *money ⇒ `has_cents` + `:price` in the policy*; *new reference ⇒ target model must exist*.
|
|
36
|
+
|
|
37
|
+
## ✅ Before you touch files: verify the ground truth (CHECK — read it, don't ask for it)
|
|
38
|
+
|
|
39
|
+
You have file access — **use it.** "Paste me the model" is a fallback for when you genuinely can't read the repo, not the default.
|
|
40
|
+
|
|
41
|
+
| Check | How | Why it matters |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Resource already exists | Read `app/models/<x>.rb` + `app/**/definitions/<x>_definition.rb`; `ls` the policy/controller | Existing + customized ⇒ **incremental edit, never re-scaffold** |
|
|
44
|
+
| Model is a resource record | grep `Plutonium::Resource::Record` / `< ResourceRecord` | Auto-detection + the field DSL depend on it |
|
|
45
|
+
| Name collision | Read `db/schema.rb` for the column; grep the model for an existing method/enum/assoc of that name | A clashing `status` silently breaks the enum |
|
|
46
|
+
| Referenced association target | `ls app/models` for the `belongs_to` target class | `x:belongs_to` to a missing model fails the scaffold |
|
|
47
|
+
| `has_cents` policy leak | grep the policy/definition for `_cents` | Generator emits `:price_cents`; fix to the virtual `:price` |
|
|
48
|
+
| Migrations applied | `rails db:migrate:status` before `pu:res:conn` | Unmigrated / unconnected ⇒ the resource is invisible |
|
|
49
|
+
|
|
50
|
+
Inspect with your own tools **before** proposing commands or edits.
|
|
51
|
+
|
|
52
|
+
## 🛠 Use the generator — and don't clobber
|
|
53
|
+
|
|
54
|
+
Never hand-write the initial model, migration, policy, definition, or controller. Reach for the generator; quote args with `?`/`{}`; pass `--dest=`.
|
|
55
|
+
|
|
56
|
+
| Task | Generator | Verify first |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| New resource | `pu:res:scaffold Model field:type … --dest=` | `--dest` confirmed; referenced models exist |
|
|
59
|
+
| Connect to a portal | `pu:res:conn Model --dest=portal` | Migrations are run |
|
|
60
|
+
| Regenerate model from columns | `pu:res:scaffold Model --no-migration` | ⚠ **regenerates the model file** — review the diff; overwrites customizations |
|
|
61
|
+
| Add a field to an **existing, customized** resource | `rails g migration AddXToYs …` + hand-edit model/definition/policy | This resource was already scaffolded — re-scaffolding clobbers it |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
25
65
|
# Part 1 — Creating a Resource
|
|
26
66
|
|
|
27
67
|
## Quick checklist
|