plutonium 0.61.0 → 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-kanban/SKILL.md +89 -24
- data/CHANGELOG.md +27 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +315 -38
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/docs/.vitepress/config.ts +21 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/kanban.md +128 -18
- data/docs/reference/kanban/authorization.md +25 -5
- data/docs/reference/kanban/dsl.md +49 -8
- data/docs/reference/kanban/index.md +3 -3
- data/docs/reference/kanban/positioning.md +1 -1
- data/docs/reference/resource/definition.md +10 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +41 -0
- data/docs/reference/wizard/dsl.md +5 -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-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/plutonium/action/base.rb +8 -0
- data/lib/plutonium/configuration.rb +12 -0
- data/lib/plutonium/definition/index_views.rb +16 -0
- data/lib/plutonium/kanban/column.rb +80 -27
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/resource/controller.rb +22 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
- data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
- data/lib/plutonium/resource/policy.rb +6 -0
- data/lib/plutonium/routing/mapper_extensions.rb +1 -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/form/base.rb +6 -0
- 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/uppy.rb +20 -2
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
- data/lib/plutonium/ui/form/resource.rb +12 -0
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/grid/card.rb +40 -13
- data/lib/plutonium/ui/kanban/column.rb +111 -24
- data/lib/plutonium/ui/kanban/resource.rb +118 -11
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +1 -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 +7 -2
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +5 -3
- data/src/css/components.css +5 -0
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +442 -55
- data/src/js/controllers/register_controllers.js +2 -0
- data/yarn.lock +674 -4
- metadata +14 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= render Plutonium::UI::Form::KanbanMove.new(@interaction) %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= render Plutonium::UI::Page::KanbanMove.new %>
|
data/config/brakeman.ignore
CHANGED
|
@@ -144,11 +144,11 @@
|
|
|
144
144
|
{
|
|
145
145
|
"warning_type": "Dangerous Eval",
|
|
146
146
|
"warning_code": 13,
|
|
147
|
-
"fingerprint": "
|
|
147
|
+
"fingerprint": "bdacd028907815ba0db0cf99e069d1ea7e94a6e527b7746efd97886c52b2f928",
|
|
148
148
|
"check_name": "Evaluation",
|
|
149
149
|
"message": "Dynamic string evaluated as code",
|
|
150
150
|
"file": "lib/plutonium/models/has_cents.rb",
|
|
151
|
-
"line":
|
|
151
|
+
"line": 162,
|
|
152
152
|
"link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
|
|
153
153
|
"code": "class_eval(\" # Getter method for the decimal representation of the cents value.\\n #\\n # @return [BigDecimal, nil] The decimal value or nil if cents_name is not present.\\n def #{(:\"#{cents_name.to_sym}_#{suffix}\" or cents_name.to_sym.to_s.gsub(/_cents$/, \"\").to_sym)}\\n #{cents_name.to_sym}.to_d / #{rate} if #{cents_name.to_sym}.present?\\n end\\n\\n # Setter method for the decimal representation of the cents value.\\n #\\n # @param value [Numeric, nil] The decimal value to be set.\\n def #{(:\"#{cents_name.to_sym}_#{suffix}\" or cents_name.to_sym.to_s.gsub(/_cents$/, \"\").to_sym)}=(value)\\n self.#{cents_name.to_sym} = begin\\n (BigDecimal(value.to_s) * #{rate}).to_i if value.present?\\n rescue ArgumentError\\n nil\\n end\\n end\\n\\n # Mark decimal field as invalid if cents field is not valid\\n after_validation do\\n next unless errors[#{cents_name.to_sym.inspect}].present?\\n\\n errors.add(#{(:\"#{cents_name.to_sym}_#{suffix}\" or cents_name.to_sym.to_s.gsub(/_cents$/, \"\").to_sym).inspect}, :invalid)\\n end\\n\", \"lib/plutonium/models/has_cents.rb\", 135)",
|
|
154
154
|
"render_path": null,
|
data/docs/.vitepress/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineConfig } from "vitepress"
|
|
2
2
|
import { withMermaid } from "vitepress-plugin-mermaid";
|
|
3
|
+
import llmstxt from "vitepress-plugin-llms";
|
|
3
4
|
|
|
4
5
|
const base = "/plutonium-core/"
|
|
5
6
|
|
|
@@ -22,6 +23,24 @@ export default defineConfig(withMermaid({
|
|
|
22
23
|
],
|
|
23
24
|
ignoreDeadLinks: 'localhostLinks',
|
|
24
25
|
srcExclude: ['superpowers/**'],
|
|
26
|
+
vite: {
|
|
27
|
+
plugins: [
|
|
28
|
+
// Generates llms.txt, llms-full.txt, and a raw .md twin for every page.
|
|
29
|
+
llmstxt({
|
|
30
|
+
// Site base (/plutonium-core/) is appended automatically — domain must not include it.
|
|
31
|
+
domain: "https://radioactive-labs.github.io",
|
|
32
|
+
// public/ is served verbatim (skills live there); superpowers/ is internal.
|
|
33
|
+
// Section landing pages are Vue components with no markdown content.
|
|
34
|
+
ignoreFiles: [
|
|
35
|
+
"superpowers/**",
|
|
36
|
+
"public/**",
|
|
37
|
+
"getting-started/index.md",
|
|
38
|
+
"guides/index.md",
|
|
39
|
+
"reference/index.md",
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
25
44
|
themeConfig: {
|
|
26
45
|
// https://vitepress.dev/reference/default-theme-config
|
|
27
46
|
logo: "/plutonium.png",
|
|
@@ -32,7 +51,8 @@ export default defineConfig(withMermaid({
|
|
|
32
51
|
{ text: "Home", link: "/" },
|
|
33
52
|
{ text: "Getting Started", link: "/getting-started/" },
|
|
34
53
|
{ text: "Guides", link: "/guides/" },
|
|
35
|
-
{ text: "Reference", link: "/reference/" }
|
|
54
|
+
{ text: "Reference", link: "/reference/" },
|
|
55
|
+
{ text: "For AI Agents", link: "/ai" }
|
|
36
56
|
],
|
|
37
57
|
sidebar: {
|
|
38
58
|
'/getting-started/': [
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Syncs .claude/skills/*/SKILL.md into docs/public/skills/ so the docs site
|
|
2
|
+
// serves them as raw, crawlable markdown at /skills/<name>.md.
|
|
3
|
+
// Runs before `docs:dev` and `docs:build` (see package.json). Output is gitignored.
|
|
4
|
+
import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs"
|
|
5
|
+
import { join, dirname } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..")
|
|
9
|
+
const skillsDir = join(root, ".claude", "skills")
|
|
10
|
+
const outDir = join(root, "docs", "public", "skills")
|
|
11
|
+
|
|
12
|
+
rmSync(outDir, { recursive: true, force: true })
|
|
13
|
+
mkdirSync(outDir, { recursive: true })
|
|
14
|
+
|
|
15
|
+
const skills = []
|
|
16
|
+
|
|
17
|
+
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
18
|
+
if (!entry.isDirectory()) continue
|
|
19
|
+
const source = join(skillsDir, entry.name, "SKILL.md")
|
|
20
|
+
if (!existsSync(source)) continue // skip dirs that aren't skills (no SKILL.md)
|
|
21
|
+
let content = readFileSync(source, "utf8")
|
|
22
|
+
|
|
23
|
+
const description = content.match(/^description:\s*(.+)$/m)?.[1] ?? ""
|
|
24
|
+
|
|
25
|
+
// Wiki-style [[skill-name]] cross-links become relative markdown links so
|
|
26
|
+
// crawlers can follow them between the published files.
|
|
27
|
+
content = content.replace(/\[\[([\w-]+)\]\]/g, "[$1]($1.md)")
|
|
28
|
+
|
|
29
|
+
writeFileSync(join(outDir, `${entry.name}.md`), content)
|
|
30
|
+
skills.push({ name: entry.name, description })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
skills.sort((a, b) => (a.name === "plutonium" ? -1 : b.name === "plutonium" ? 1 : a.name.localeCompare(b.name)))
|
|
34
|
+
|
|
35
|
+
const index = `# Plutonium Skills
|
|
36
|
+
|
|
37
|
+
Task-focused guides for AI agents working with the [Plutonium](https://radioactive-labs.github.io/plutonium-core/) Rails RAD framework. Each file is self-contained markdown. Start with \`plutonium.md\` — it routes to the others.
|
|
38
|
+
|
|
39
|
+
These are the same skills the gem installs into projects via \`rails g pu:skills:sync\` (Claude Code loads them automatically from \`.claude/skills/\`). Any agent can fetch them directly from the URLs below.
|
|
40
|
+
|
|
41
|
+
${skills.map((s) => `- [${s.name}](${s.name}.md) — ${s.description}`).join("\n")}
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
writeFileSync(join(outDir, "index.md"), index)
|
|
45
|
+
console.log(`Synced ${skills.length} skills to docs/public/skills/`)
|
data/docs/ai.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Plutonium for AI Agents
|
|
3
|
+
description: Machine-readable entry point for AI coding agents building Rails apps with Plutonium — install commands, conventions, generator catalog, and skill files.
|
|
4
|
+
sidebar: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Plutonium for AI Agents
|
|
8
|
+
|
|
9
|
+
You are reading the agent entry point for **Plutonium**, a Rails RAD framework (Ruby gem) that provides resource-oriented architecture: automatic CRUD, authorization policies, declarative UI definitions, authentication, and multi-tenancy. If a user asked you to build something with Plutonium, this page gives you the golden path and where to read next.
|
|
10
|
+
|
|
11
|
+
## Machine-readable resources
|
|
12
|
+
|
|
13
|
+
| Resource | URL |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Docs index (llms.txt) | `https://radioactive-labs.github.io/plutonium-core/llms.txt` |
|
|
16
|
+
| Full docs, one file | `https://radioactive-labs.github.io/plutonium-core/llms-full.txt` |
|
|
17
|
+
| Agent skills index | `https://radioactive-labs.github.io/plutonium-core/skills/index.md` |
|
|
18
|
+
| Router skill (start here) | `https://radioactive-labs.github.io/plutonium-core/skills/plutonium.md` |
|
|
19
|
+
|
|
20
|
+
Every docs page also has a raw markdown twin: append `.md` to its URL (e.g. `/guides/multi-tenancy.md`).
|
|
21
|
+
|
|
22
|
+
**Prefer the skills over the raw docs.** They are task-focused, deduplicated, and encode the mistakes agents actually make. The router skill maps "about to do X" to the right skill file. If you are Claude Code, run `rails g pu:skills:sync` after installing the gem to install them into `.claude/skills/` so they load automatically.
|
|
23
|
+
|
|
24
|
+
## Rules that prevent expensive mistakes
|
|
25
|
+
|
|
26
|
+
1. **Plutonium is generator-driven.** Nearly every file has a `pu:*` generator. Generate, then edit — never hand-write models, definitions, policies, or packages from scratch. Hand-written files drift from conventions and break future generator runs.
|
|
27
|
+
2. **Inspect before you act.** Check `Gemfile` for `plutonium`, `ls config/packages.rb`, and `ls packages/` before installing or scaffolding anything.
|
|
28
|
+
3. **New app → `plutonium.rb` template. Existing app → `base.rb` template.** Never run `plutonium.rb` on an existing app; it re-runs full bootstrap and clobbers git history.
|
|
29
|
+
4. **Pass flags to avoid interactive prompts**: `--dest=main_app` (or `--dest=<package>`), `--force` when re-running meta-generators, `--auth=<account>` for portals, `--skip-bundle`, `--quiet`.
|
|
30
|
+
5. **Quote field arguments with special characters**: `'title:string?'`, `'price:decimal{10,2}'`.
|
|
31
|
+
6. **Multi-tenancy is structural, not filtered in policies.** The portal declares `scope_to_entity`; the model provides the association path that `associated_with` resolves. Never `where(organization: ...)` in a policy — read the tenancy skill first.
|
|
32
|
+
|
|
33
|
+
## Golden path: new application
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
rails new myapp -a propshaft -j esbuild -c tailwind \
|
|
37
|
+
-m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
|
|
38
|
+
cd myapp
|
|
39
|
+
rails db:prepare
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then for each resource:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
rails g pu:res:scaffold Post 'title:string' 'body:text?' user:references --dest=main_app
|
|
46
|
+
rails db:prepare
|
|
47
|
+
rails g pu:res:conn Post --dest=<portal_package>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Golden path: existing application
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bin/rails app:template \
|
|
54
|
+
LOCATION=https://radioactive-labs.github.io/plutonium-core/templates/base.rb
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or manually: add `gem "plutonium"` to the Gemfile, `bundle install`, then `rails g pu:core:install`.
|
|
58
|
+
|
|
59
|
+
## Architecture in one table
|
|
60
|
+
|
|
61
|
+
A **resource** is four cooperating layers. Plutonium auto-fills defaults from the model; you only declare overrides.
|
|
62
|
+
|
|
63
|
+
| Layer | File | Purpose |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| Model | `app/models/post.rb` | Data, validations, associations |
|
|
66
|
+
| Definition | `app/definitions/post_definition.rb` | UI — fields, filters, actions |
|
|
67
|
+
| Policy | `app/policies/post_policy.rb` | Authorization |
|
|
68
|
+
| Controller | `app/controllers/posts_controller.rb` | Rarely edited — use hooks |
|
|
69
|
+
| Interaction (optional) | `app/interactions/publish_post_interaction.rb` | Business logic for custom actions |
|
|
70
|
+
|
|
71
|
+
Resources live in the main app or in **feature packages**; users access them through **portal packages** (Rails engines with their own auth and optional tenant scoping).
|
|
72
|
+
|
|
73
|
+
## Generator catalog
|
|
74
|
+
|
|
75
|
+
Discover all generators with `rails g --help | grep pu:`. The most used:
|
|
76
|
+
|
|
77
|
+
| Generator | Purpose |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `pu:core:install` | Initial Plutonium setup |
|
|
80
|
+
| `pu:res:scaffold NAME field:type ... --dest=` | New resource (model, migration, policy, definition) |
|
|
81
|
+
| `pu:res:conn RESOURCE --dest=PORTAL` | Connect a resource to a portal |
|
|
82
|
+
| `pu:pkg:package NAME` | Feature package |
|
|
83
|
+
| `pu:pkg:portal NAME --auth=...` | Portal package |
|
|
84
|
+
| `pu:rodauth:install` / `pu:rodauth:account NAME` | Authentication |
|
|
85
|
+
| `pu:saas:setup --user ... --entity ...` | Full SaaS bootstrap: user + tenant + membership + portal |
|
|
86
|
+
| `pu:test:install` / `pu:test:scaffold NAME` | Testing scaffolds |
|
|
87
|
+
| `pu:skills:sync` | Install the agent skill files into the project |
|
|
88
|
+
|
|
89
|
+
## Verify your work
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
rails runner "puts Plutonium::VERSION" # gem installed and loaded
|
|
93
|
+
rails db:prepare # migrations applied
|
|
94
|
+
bin/dev # boot; visit the portal route
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## For humans reading this
|
|
98
|
+
|
|
99
|
+
This page exists so AI coding agents can bootstrap correctly on the first try. Point your agent at it, or just say: *"Read https://radioactive-labs.github.io/plutonium-core/ai.md before starting."* The [Getting Started guide](/getting-started/) covers the same ground for people.
|
data/docs/guides/kanban.md
CHANGED
|
@@ -12,7 +12,7 @@ Turn any resource index into a drag-and-drop kanban board — columns, WIP limit
|
|
|
12
12
|
|
|
13
13
|
- Drag cards between columns; the server persists the column change and the position within the column.
|
|
14
14
|
- Decimal fractional positioning — cards always land exactly where you drop them without renumbering.
|
|
15
|
-
- Per-column `+ Add` button opens the resource's normal new form
|
|
15
|
+
- Per-column `+ Add` button opens the resource's normal new form; the new card is placed in that column (`on_enter` + positioning applied post-create).
|
|
16
16
|
- Column actions run an interaction against all (or visible) cards in a column.
|
|
17
17
|
- WIP limits, locked columns, and cross-column drop restrictions enforced server-side.
|
|
18
18
|
- Opt-in realtime: every connected viewer sees the same board state after any move.
|
|
@@ -66,17 +66,17 @@ class TaskDefinition < ResourceDefinition
|
|
|
66
66
|
|
|
67
67
|
column :todo,
|
|
68
68
|
scope: -> { where(status: "todo") },
|
|
69
|
-
|
|
69
|
+
on_enter: ->(r) { r.update!(status: "todo") },
|
|
70
70
|
role: :backlog # shorthand for add: true
|
|
71
71
|
|
|
72
72
|
column :doing,
|
|
73
73
|
scope: -> { where(status: "doing") },
|
|
74
|
-
|
|
74
|
+
on_enter: ->(r) { r.update!(status: "doing") },
|
|
75
75
|
wip: 3
|
|
76
76
|
|
|
77
77
|
column :done,
|
|
78
78
|
scope: -> { where(status: "done") },
|
|
79
|
-
|
|
79
|
+
on_enter: :mark_done!, # Symbol → record.mark_done!
|
|
80
80
|
accepts: [:doing], # only cards from :doing can land here
|
|
81
81
|
role: :done do # shorthand for color: :green, collapsed: true
|
|
82
82
|
action :archive_all,
|
|
@@ -116,7 +116,7 @@ If a move is refused server-side — the destination is at its `wip:` limit, its
|
|
|
116
116
|
|
|
117
117
|

|
|
118
118
|
|
|
119
|
-
The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check — most commonly a WIP-full column or a
|
|
119
|
+
The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check — most commonly a WIP-full column or a `kanban_move?` denial.
|
|
120
120
|
|
|
121
121
|
### Opening a card
|
|
122
122
|
|
|
@@ -149,15 +149,15 @@ class KitchenSinkDefinition < ResourceDefinition
|
|
|
149
149
|
kanban do
|
|
150
150
|
column :active, label: "Active", role: :backlog,
|
|
151
151
|
scope: -> { where(status: :active) },
|
|
152
|
-
|
|
152
|
+
on_enter: ->(ks) { ks.status = :active }
|
|
153
153
|
|
|
154
154
|
column :pending, label: "Pending", color: :yellow, wip: 5,
|
|
155
155
|
scope: -> { where(status: :pending) },
|
|
156
|
-
|
|
156
|
+
on_enter: ->(ks) { ks.status = :pending }
|
|
157
157
|
|
|
158
158
|
column :archived, label: "Archived", role: :done,
|
|
159
159
|
scope: -> { where(status: :archived) },
|
|
160
|
-
|
|
160
|
+
on_enter: ->(ks) { ks.status = :archived }
|
|
161
161
|
|
|
162
162
|
per_column 10
|
|
163
163
|
end
|
|
@@ -168,7 +168,7 @@ Key points:
|
|
|
168
168
|
- `role: :backlog` enables the `+ Add` button (equivalent to `add: true`).
|
|
169
169
|
- `wip: 5` caps the Pending column; a cross-column drop that would push it past 5 is rejected server-side.
|
|
170
170
|
- `role: :done` collapses the Archived column by default and shows a green header dot.
|
|
171
|
-
- `
|
|
171
|
+
- `on_enter` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns — you do not need to call `update!` explicitly.
|
|
172
172
|
|
|
173
173
|
---
|
|
174
174
|
|
|
@@ -184,7 +184,7 @@ kanban do
|
|
|
184
184
|
label: "Product Backlog", # default: key.to_s.titleize
|
|
185
185
|
color: :blue, # dot color in the column header
|
|
186
186
|
scope: -> { where(stage: 0) }, # 0-arg lambda evaluated on the relation
|
|
187
|
-
|
|
187
|
+
on_enter: ->(r) { r.update!(stage: 0) }
|
|
188
188
|
end
|
|
189
189
|
```
|
|
190
190
|
|
|
@@ -201,7 +201,7 @@ kanban do
|
|
|
201
201
|
:"project_#{project.id}",
|
|
202
202
|
label: project.name,
|
|
203
203
|
scope: -> { where(project_id: project.id) },
|
|
204
|
-
|
|
204
|
+
on_enter: ->(r) { r.update!(project_id: project.id) }
|
|
205
205
|
)
|
|
206
206
|
end
|
|
207
207
|
end
|
|
@@ -227,6 +227,8 @@ class TaskDefinition < ResourceDefinition
|
|
|
227
227
|
end
|
|
228
228
|
end
|
|
229
229
|
```
|
|
230
|
+
|
|
231
|
+
Note that **`enter_interaction:` is not supported on dynamic boards** — its hidden action is registered from the static column list at class-load time, and its key is internal (column-scoped) so it can't be registered manually the way a column action can. A drop into such a column snaps back rather than committing (it doesn't crash). Use a static board if a column needs an `enter_interaction:`.
|
|
230
232
|
:::
|
|
231
233
|
|
|
232
234
|
### Column options
|
|
@@ -236,11 +238,13 @@ end
|
|
|
236
238
|
| `label:` | String | `key.to_s.titleize` | Column header text |
|
|
237
239
|
| `color:` | Symbol or String | `nil` | Dot color in the column header — `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
|
|
238
240
|
| `scope:` | Symbol or Proc | `nil` | Filters the resource relation to this column's cards. Symbol → named scope; Proc → 0-arg lambda called with `instance_exec` on the relation (e.g. `-> { where(status: "todo") }`) |
|
|
239
|
-
| `
|
|
240
|
-
| `
|
|
241
|
+
| `on_enter:` | Symbol or Proc | `nil` | Called when a card lands in this column. Symbol → `record.public_send(sym)`; Proc → 1-arg lambda `->(record) { … }` where `self` is the view context |
|
|
242
|
+
| `on_exit:` | Symbol or Proc | `nil` | Source-side counterpart to `on_enter:` — called when a card **leaves** this column on a cross-column move, before the destination's `on_enter`, in the same transaction. For source-tied side effects (stop a timer, release a slot). Drag-moves only (not destroy/programmatic/quick-add); skipped on same-column reorders |
|
|
243
|
+
| `enter_interaction:` | Class | `nil` | Record-scoped interaction run on a cross-column drop into this column — opens a modal to collect input, then commits atomically. See [Interaction on drop](#interaction-on-drop) |
|
|
244
|
+
| `role:` | `:backlog`, `:done`, `:lost` | `nil` | Preset shorthand (see below) |
|
|
241
245
|
| `collapsed:` | Boolean | `false` | Start collapsed |
|
|
242
246
|
| `add:` | Boolean | `false` | Show `+ Add` quick-add button |
|
|
243
|
-
| `accepts:` | `true`, `false`, Array of keys
|
|
247
|
+
| `accepts:` | `true`, `false`, or Array of keys | `true` | Which source columns may drop here (structural, client-hintable). `true` = all, `false` = none, `[:doing]` = only from `:doing`. Record/user conditions go in `kanban_move?` instead (it sees the record and `from`/`to`); a `Proc` here raises |
|
|
244
248
|
| `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
|
|
245
249
|
| `wip:` | Integer | `nil` | Work-in-progress limit. Cross-column drops that would exceed this count are rejected |
|
|
246
250
|
|
|
@@ -250,6 +254,11 @@ end
|
|
|
250
254
|
|------|---------------|
|
|
251
255
|
| `:backlog` | `add: true` |
|
|
252
256
|
| `:done` | `color: :green, collapsed: true` |
|
|
257
|
+
| `:lost` | `color: :red, collapsed: true` |
|
|
258
|
+
|
|
259
|
+
`:done` and `:lost` are the two terminal roles (both collapsed by default) — the
|
|
260
|
+
won/lost pair for pipelines like leads, deals, or tickets; the colour signals the
|
|
261
|
+
outcome.
|
|
253
262
|
|
|
254
263
|
Explicitly provided options override the preset.
|
|
255
264
|
|
|
@@ -264,7 +273,7 @@ Declare actions inside a column block to run an interaction against that column'
|
|
|
264
273
|
```ruby
|
|
265
274
|
column :done,
|
|
266
275
|
scope: -> { where(status: "done") },
|
|
267
|
-
|
|
276
|
+
on_enter: :mark_done! do
|
|
268
277
|
|
|
269
278
|
action :archive_all,
|
|
270
279
|
interaction: ArchiveTasksInteraction, # must be a bulk interaction (has `attribute :resources`)
|
|
@@ -282,6 +291,101 @@ Column actions are rendered as buttons in the column header. They open the norma
|
|
|
282
291
|
|
|
283
292
|
---
|
|
284
293
|
|
|
294
|
+
## Interaction on drop
|
|
295
|
+
|
|
296
|
+
A column can declare `enter_interaction:` to run an authorization-aware, input-collecting [Interaction](/reference/behavior/interactions) when a card is dropped **into** it from another column. Use it when entering a column needs more than a membership flip — a reason, a notification email, an audit entry.
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
column :lost,
|
|
300
|
+
scope: -> { where(status: "lost") },
|
|
301
|
+
enter_interaction: MarkLostInteraction
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`enter_interaction:` takes an **Interaction class**. It must be **record-scoped** — it declares `attribute :resource` and acts on the single dropped card. A bulk (`attribute :resources`) interaction is not valid here; that shape is for [column actions](#column-actions).
|
|
305
|
+
|
|
306
|
+
The interaction is **auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), so two columns can reuse the same interaction class without colliding. "Hidden" means it does **not** appear as an action button on the show page, table rows, or grid cards — it is reachable only by dropping a card into the column.
|
|
307
|
+
|
|
308
|
+
### The interaction
|
|
309
|
+
|
|
310
|
+
A drop interaction is an ordinary record-scoped interaction — nothing kanban-specific in the class:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
class MarkLostInteraction < ResourceInteraction
|
|
314
|
+
presents label: "Mark Lost",
|
|
315
|
+
icon: Phlex::TablerIcons::X
|
|
316
|
+
|
|
317
|
+
attribute :resource
|
|
318
|
+
attribute :reason, :string
|
|
319
|
+
|
|
320
|
+
input :reason
|
|
321
|
+
|
|
322
|
+
validates :reason, presence: true
|
|
323
|
+
|
|
324
|
+
def execute
|
|
325
|
+
resource.update!(status: "lost", lost_reason: reason)
|
|
326
|
+
succeed(resource).with_message("Marked as lost")
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Authorization
|
|
332
|
+
|
|
333
|
+
The drop is authorized by the single **`kanban_move?`** predicate — the interaction has **no policy method of its own**. To gate this specific transition, branch on the destination column, which `kanban_move?` reads from its authorization context (`kanban_to`):
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
class TaskPolicy < ResourcePolicy
|
|
337
|
+
def kanban_move?
|
|
338
|
+
return update? if kanban_to&.key == :lost # who may mark a task lost
|
|
339
|
+
super
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
This keeps authorization in one place: `kanban_move?` gates every move, and the `to` (and `from`) column context lets it gate a specific transition — no per-interaction predicate, no `condition:` proc. If the check fails the drop is refused and the card stays put. See [Authorization](../reference/kanban/authorization) for the full `from`/`to` context.
|
|
345
|
+
|
|
346
|
+
### Two flows, split by intent
|
|
347
|
+
|
|
348
|
+
- **Move flow (drag a card cross-column).** Dropping into the column opens the interaction's form as a **modal** to collect input (the `reason`). On submit, the membership write (`on_enter`, if any), the interaction, and the repositioning are committed in **one atomic transaction**.
|
|
349
|
+
- **Quick-add (`+ Add`).** The `+ Add` button creates the record, then applies `on_enter` + positioning **post-create** (see [Quick-add](#quick-add)). The `enter_interaction` is **not** involved in quick-add.
|
|
350
|
+
|
|
351
|
+
### Author contract: on_enter owns membership, the interaction owns extras
|
|
352
|
+
|
|
353
|
+
A column can declare `on_enter:` and `enter_interaction:` together. When it does:
|
|
354
|
+
|
|
355
|
+
- `on_enter` owns the **membership attribute** (the column's grouping value, e.g. `status`).
|
|
356
|
+
- `enter_interaction` owns the **extras** — the reason, the mail, the audit trail.
|
|
357
|
+
|
|
358
|
+
If the interaction also writes the membership attribute it **must set the same value** `on_enter` sets (idempotent). In this dummy-app example the `:blocked` column does exactly that — `on_enter` sets `status = "blocked"` and the interaction's `execute` re-asserts `status: "blocked"` while adding the reason:
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
column :blocked,
|
|
362
|
+
scope: -> { where(status: "blocked") },
|
|
363
|
+
on_enter: ->(r) { r.status = "blocked" },
|
|
364
|
+
enter_interaction: BlockTaskInteraction
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
When a column declares **only** a `enter_interaction` (no `on_enter`, like `:lost` above), the interaction owns everything — including the membership write — because there is no `on_enter` to do it.
|
|
368
|
+
|
|
369
|
+
### Same-column drops run positioning only
|
|
370
|
+
|
|
371
|
+
Reordering a card **within** its current column runs positioning only. Neither `on_enter` nor the `enter_interaction` fires — both represent *entering* a column, and a same-column reorder is not an entry. Only cross-column drops trigger them.
|
|
372
|
+
|
|
373
|
+
### Atomicity and failure
|
|
374
|
+
|
|
375
|
+
Interaction validation failure rolls the **whole transaction back** — the membership write included — and re-renders the modal with errors. The move context is preserved, so the user can fix the input and resubmit. Nothing is persisted on failure. Keep side-effects on `deliver_later` (mailers, jobs): a rolled-back failure then sends no stray mail, because the enqueue never commits.
|
|
376
|
+
|
|
377
|
+
### Success feedback and the response limitation
|
|
378
|
+
|
|
379
|
+
On success the board's column frames re-render and the modal closes. The interaction's success **message** (`succeed(resource).with_message("Marked as lost")`) is surfaced as a toast.
|
|
380
|
+
|
|
381
|
+
::: warning Custom success responses are not honored on the drop path
|
|
382
|
+
A drop interaction's custom success *response* — `with_redirect_response`, `with_file_response`, etc. — is **not** honored when it runs from a drop: the board simply re-renders and closes the modal. Keep drop interactions to simple state + extras mutations, and use `.with_message` for feedback.
|
|
383
|
+
:::
|
|
384
|
+
|
|
385
|
+
There is no card "snap-back" to worry about on cancel — native drag never moves the card's DOM node, so canceling the modal just closes it and the card stays where it was.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
285
389
|
## Positioning
|
|
286
390
|
|
|
287
391
|
By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
|
|
@@ -337,11 +441,17 @@ Each column loads at most 25 cards. When the total exceeds the limit, a `+N more
|
|
|
337
441
|
|
|
338
442
|
## Quick-add
|
|
339
443
|
|
|
340
|
-
When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal
|
|
444
|
+
When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal.
|
|
341
445
|
|
|
342
|
-
|
|
446
|
+
The record is created normally, and **then** the column's `on_enter` and positioning are applied to the **saved** record — so the new card lands in the clicked column, appended to the bottom. `on_enter` runs against a real, persisted record (exactly as it does for a drag), so `update!`-style callbacks and any side effects behave identically and fire once, on the actual create.
|
|
343
447
|
|
|
344
|
-
|
|
448
|
+
::: warning Give your grouping column a default
|
|
449
|
+
Because `on_enter` runs **after** the record is saved, the record must be creatable **without** a grouping value. Give your grouping column (e.g. `status`) a database or model default. If it is `NOT NULL` with no default, quick-add create fails validation before `on_enter` can set it.
|
|
450
|
+
:::
|
|
451
|
+
|
|
452
|
+
If `on_enter` (or positioning) raises after the record was created, the create is **not** rolled back: the record is kept in its default column (validly positioned there) and the failure is surfaced as a toast.
|
|
453
|
+
|
|
454
|
+
Authorization: the button is only rendered when `create?` returns `true` in the current policy.
|
|
345
455
|
|
|
346
456
|
---
|
|
347
457
|
|
|
@@ -27,6 +27,25 @@ class TaskPolicy < ResourcePolicy
|
|
|
27
27
|
end
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Gating a specific transition (`from` / `to` context)
|
|
31
|
+
|
|
32
|
+
`kanban_move?` is the **single** authorization for every move — plain moves and `enter_interaction:` columns alike. To gate a *specific* transition, read the source and destination columns from the authorization context. They are exposed as the optional `kanban_from` / `kanban_to` policy readers (the `Plutonium::Kanban::Column` objects), and are `nil` for every non-move authorization:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
class DealPolicy < ResourcePolicy
|
|
36
|
+
# Anyone on the team may shuffle cards, but only a manager may move one
|
|
37
|
+
# INTO "Closed Won".
|
|
38
|
+
def kanban_move?
|
|
39
|
+
return user.manager? if kanban_to&.key == :closed_won
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Rules take no positional arguments in ActionPolicy — the columns arrive via context, which the controller supplies on the `kanban_move?` check (`context: { kanban_from:, kanban_to: }`). This replaces per-column policy methods: an `enter_interaction:` column is authorized by `kanban_move?` too, so there is no separate `mark_lost?`-style predicate to define.
|
|
46
|
+
|
|
47
|
+
Both `kanban_from` and `kanban_to` are **trustworthy** to authorize on. `to` is where the card ends up; and although `from_column` arrives from the client, the move handler **verifies the record actually resides in the claimed source column** before it proceeds (a mismatch snaps the drag back), so a spoofed or stale `from` can never drive a move past a `kanban_from`-based rule.
|
|
48
|
+
|
|
30
49
|
## Read-only board
|
|
31
50
|
|
|
32
51
|
When `kanban_move?` returns `false` for the current user, the board is rendered read-only. Cards are displayed but dragging is disabled — no drag handles appear and the Stimulus controller does not register drop zones.
|
|
@@ -36,14 +55,15 @@ When `kanban_move?` returns `false` for the current user, the board is rendered
|
|
|
36
55
|
When a card is dropped, the server:
|
|
37
56
|
|
|
38
57
|
1. Finds the record within the current authorized scope (the same policy `relation_scope` used by the index action).
|
|
39
|
-
2. Calls `authorize_current!(record, to: :kanban_move
|
|
40
|
-
3.
|
|
41
|
-
4.
|
|
42
|
-
5.
|
|
58
|
+
2. Calls `authorize_current!(record, to: :kanban_move?, context: { kanban_from:, kanban_to: })` — the single authorization for the move (an `enter_interaction:` column rides on this same check, with no policy method of its own). A `false` result halts the action with HTTP 403.
|
|
59
|
+
3. Verifies the record actually resides in the claimed source column (`from_column` is client-supplied). A mismatch responds 422 and snaps the card back — this is what makes `kanban_from` safe to authorize on.
|
|
60
|
+
4. Validates the drop against the destination column's `accepts:` policy and `locked:` flag. A rejected drop responds with HTTP 422 and re-renders the source column (the Stimulus controller snaps the card back).
|
|
61
|
+
5. Enforces the destination column's `wip:` limit (cross-column moves only). Exceeding the WIP cap also responds 422.
|
|
62
|
+
6. Calls `on_enter` and repositions the record inside a transaction.
|
|
43
63
|
|
|
44
64
|
## No permitted attributes for moves
|
|
45
65
|
|
|
46
|
-
Kanban moves do **not** pass through `permitted_attributes_for_update` / `permitted_attributes_for_kanban_move`. The `
|
|
66
|
+
Kanban moves do **not** pass through `permitted_attributes_for_update` / `permitted_attributes_for_kanban_move`. The `on_enter` callback is author code that runs with full model access — it is the responsibility of the `on_enter` implementation to assign only the attributes appropriate for a column transition. This is intentional: the callback is trusted Ruby, not user-supplied form data.
|
|
47
67
|
|
|
48
68
|
## Column-level drop policies
|
|
49
69
|
|