plutonium 0.62.2 → 0.63.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 +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- 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/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +29 -11
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -220,9 +220,11 @@ end
|
|
|
220
220
|
|
|
221
221
|
## Custom layouts
|
|
222
222
|
|
|
223
|
-
### Sectioned — prefer the `form_layout` DSL
|
|
223
|
+
### Sectioned — prefer the `form_layout` / `display_layout` DSL
|
|
224
224
|
|
|
225
|
-
**For grouping fields into sections, don't hand-roll a `Form` subclass — declare `form_layout` in the definition.**
|
|
225
|
+
**For grouping fields into sections, don't hand-roll a `Form` or `Display` subclass — declare `form_layout` (forms) or `display_layout` (show page) in the definition.** They handle headings, descriptions, collapsible `<details>`, `condition:`-based visibility, and **auto-drop sections that resolve to zero fields** (so `+ New` doesn't sprout empty headings). `columns:` is form-only — `display_layout` raises on it. See [[plutonium-resource]] › Form Layout / Display Layout.
|
|
226
|
+
|
|
227
|
+
**Each section renders as its own card** (`Plutonium::UI::Block`), so a sectioned form or show page has **no single outer card** — the form drops its own `pu-card` and the sections supply it. Don't add a card wrapper of your own around them.
|
|
226
228
|
|
|
227
229
|
```ruby
|
|
228
230
|
class PostDefinition < ResourceDefinition
|
|
@@ -387,13 +389,15 @@ class PostDefinition < ResourceDefinition
|
|
|
387
389
|
p(class: "mt-2 opacity-90") { object.excerpt }
|
|
388
390
|
end
|
|
389
391
|
|
|
390
|
-
Block
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
392
|
+
# `fields_wrapper` is ALREADY a card (it renders a Block internally),
|
|
393
|
+
# so do not wrap it in another one — that stacks two cards and doubles
|
|
394
|
+
# the border and shadow.
|
|
395
|
+
fields_wrapper do
|
|
396
|
+
render_resource_field :author
|
|
397
|
+
render_resource_field :published_at
|
|
395
398
|
end
|
|
396
399
|
|
|
400
|
+
# `Block` is the card primitive: use it for your own content.
|
|
397
401
|
Block do
|
|
398
402
|
div(class: "prose max-w-none") { raw object.content }
|
|
399
403
|
end
|
|
@@ -409,9 +413,28 @@ end
|
|
|
409
413
|
| `render_fields` | All permitted fields |
|
|
410
414
|
| `render_resource_field(name)` | One field |
|
|
411
415
|
| `render_associations` | Association tabs (driven by `permitted_associations` — see [[plutonium-behavior]]) |
|
|
416
|
+
| `render_before_fields` / `render_after_fields` | Hooks around the fields — **Details tab only** |
|
|
412
417
|
| `object` | The record |
|
|
413
418
|
| `resource_fields`, `resource_associations` | Permitted lists |
|
|
414
419
|
|
|
420
|
+
### Details-tab-only content
|
|
421
|
+
|
|
422
|
+
To add a banner or extra section that shows on the **Details** tab and not the association tabs, override `render_before_fields` / `render_after_fields` on the **Display** — not the ShowPage. The page-level `render_before_content` / `render_after_content` hooks wrap the whole content block, and the tablist lives inside it, so anything added there shows on every tab.
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
class PostDefinition < ResourceDefinition
|
|
426
|
+
class Display < Display
|
|
427
|
+
private
|
|
428
|
+
|
|
429
|
+
def render_before_fields
|
|
430
|
+
div(class: "pu-card pu-card-body mb-4") { plain "Only on the Details tab" }
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Both hooks are no-ops by default. `render_fields` is the Details tab body when the record has associations and the entire display when it doesn't, so the hooks fire in the Details context either way.
|
|
437
|
+
|
|
415
438
|
## Custom Table
|
|
416
439
|
|
|
417
440
|
```ruby
|
|
@@ -447,6 +470,40 @@ end
|
|
|
447
470
|
|
|
448
471
|
---
|
|
449
472
|
|
|
473
|
+
## Drag-to-Reorder Affordance (`position_on`)
|
|
474
|
+
|
|
475
|
+
When a definition declares `position_on` (see [[plutonium-resource]]) the index **table**, the **card grid**, and **nested association tables** render a drag grip. Configuration is entirely in the definition — there is no UI-layer switch.
|
|
476
|
+
|
|
477
|
+
**What renders where:**
|
|
478
|
+
|
|
479
|
+
| Surface | Draggable element | Grip placement | Axis |
|
|
480
|
+
|---|---|---|---|
|
|
481
|
+
| Index / nested table | the **grip only**, never the `<tr>` | inside the first cell's existing left padding (content does not shift) | vertical |
|
|
482
|
+
| Card grid | the **grip only** | floated over the card's top-left corner | horizontal, wrap-aware |
|
|
483
|
+
| Kanban board | the **whole card** | — | both (cross-column) |
|
|
484
|
+
|
|
485
|
+
🚨 **Never make a `<tr>` draggable.** Two silent regressions: `draggable="true"` disables text selection inside the element in every major browser (you lose copy-a-cell-value), and it fights `row_click_controller` — a drag that starts and ends in place still fires a click and navigates the user away. Kanban keeps whole-card dragging because neither applies to a kanban card; a **grid** card gets a grip because it *does* carry a row-click show affordance.
|
|
486
|
+
|
|
487
|
+
**Enabled state.** The grip is live only while the collection is sorted **ascending, by the position attribute, and nothing else**. Otherwise "drop me between these two rows" describes nothing. Under a foreign sort the Stimulus controller isn't attached at all and the grip renders as a **link that applies the position sort** — the disabled state is the way out of the disabled state, which is why `position_on` registers `sort <attr>`. Per record, the grip also requires `reposition?`.
|
|
488
|
+
|
|
489
|
+
**DOM contract** (relevant if you eject a table/grid or write a custom collection component):
|
|
490
|
+
|
|
491
|
+
```
|
|
492
|
+
wrapper data-controller="positioned"
|
|
493
|
+
data-positioned-url-template-value="/things/__ID__/reposition"
|
|
494
|
+
data-positioned-axis-value="horizontal" # grid only
|
|
495
|
+
row/card data-positioned-row-id="<id>" # single source of truth for the record id
|
|
496
|
+
grip data-positioned-grip # a real <button> — tabbable
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
The URL template is built off `current_page_path` (not `request.path`) so a post-rebalance re-render doesn't wire subsequent drops to `/things/5/reposition`. The controller POSTs `{prev_id, next_id, to_index}` plus `window.location.search` — the query string is load-bearing, since the endpoint re-renders through the ordinary index pipeline.
|
|
500
|
+
|
|
501
|
+
**Accessibility.** Focus the grip and use <kbd>↑</kbd>/<kbd>↓</kbd> — deliberately linear even on a wrapped grid, since one position attribute stores a 1-D order. Focus is restored onto the same record's grip after a stream replaces the collection. ⚠️ Native HTML5 drag does **not** fire on touch devices (inherited from kanban); there is no automatic fallback.
|
|
502
|
+
|
|
503
|
+
Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/positioning.md`.
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
450
507
|
# Part 4 — Component Kit & Custom Components
|
|
451
508
|
|
|
452
509
|
## Built-in shorthand kit
|
|
@@ -509,17 +566,47 @@ class PostCardComponent < Plutonium::UI::Component::Base
|
|
|
509
566
|
end
|
|
510
567
|
```
|
|
511
568
|
|
|
512
|
-
Use in a definition
|
|
569
|
+
Use in a definition. A component with its **own constructor** (like the one above)
|
|
570
|
+
must use the **block form** — you build it:
|
|
513
571
|
|
|
514
572
|
```ruby
|
|
515
|
-
display :card
|
|
516
|
-
|
|
573
|
+
display :card do |field|
|
|
574
|
+
PostCardComponent.new(post: field.object)
|
|
575
|
+
end
|
|
517
576
|
|
|
518
577
|
display :metrics do |field|
|
|
519
578
|
MetricsChartComponent.new(data: field.value)
|
|
520
579
|
end
|
|
521
580
|
```
|
|
522
581
|
|
|
582
|
+
🚨 `as: SomeComponent` takes a **field component**, NOT a keyword-argument
|
|
583
|
+
component: Plutonium constructs it as `SomeComponent.new(field, **attributes)`, so
|
|
584
|
+
`display :card, as: PostCardComponent` raises `ArgumentError: wrong number of
|
|
585
|
+
arguments`. A field component subclasses the Phlexi base for its surface and reads
|
|
586
|
+
`field` (`field.value`, `field.object`, `field.dom`, `attributes`):
|
|
587
|
+
|
|
588
|
+
```ruby
|
|
589
|
+
class ColorPickerComponent < Phlexi::Form::Components::Base
|
|
590
|
+
include Phlexi::Form::Components::Concerns::HandlesInput # name/id/value plumbing
|
|
591
|
+
include Plutonium::UI::Component::Behaviour # optional: kit + resource helpers
|
|
592
|
+
|
|
593
|
+
def view_template
|
|
594
|
+
input(**attributes, type: "color", value: field.value)
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
class ChartComponent < Phlexi::Display::Components::Base
|
|
599
|
+
def view_template
|
|
600
|
+
div(class: "h-40", data: {controller: "chart", chart_series_value: field.value.to_json})
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
```ruby
|
|
606
|
+
input :color, as: ColorPickerComponent # every surface: form + filter panel
|
|
607
|
+
display :chart, as: ChartComponent # every surface: show + index column
|
|
608
|
+
```
|
|
609
|
+
|
|
523
610
|
## `DynaFrameContent` pattern
|
|
524
611
|
|
|
525
612
|
Enables frame-aware rendering: regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
|
|
@@ -747,7 +834,7 @@ import CustomController from "./custom_controller"
|
|
|
747
834
|
application.register("custom", CustomController)
|
|
748
835
|
```
|
|
749
836
|
|
|
750
|
-
Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fields`, `slim-select`, `flatpickr`, `easymde`, plus various internal UI controllers.
|
|
837
|
+
Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fields`, `slim-select`, `flatpickr`, `easymde`, `kanban`, `positioned` (drag-to-reorder), `row-click`, plus various internal UI controllers.
|
|
751
838
|
|
|
752
839
|
Custom controller — standard Stimulus:
|
|
753
840
|
|
|
@@ -815,6 +902,8 @@ Plutonium uses CSS custom properties for surfaces, text, borders, forms, cards,
|
|
|
815
902
|
}
|
|
816
903
|
```
|
|
817
904
|
|
|
905
|
+
🚨 **Mirror every `:root` override in `.dark`.** The app stylesheet loads after Plutonium's and `:root`/`.dark` have equal specificity, so a `:root`-only override beats Plutonium's `.dark` value even in dark mode — your light color ships into dark mode, often unreadably (e.g. translucent navy `--pu-text-subtle` is invisible on a dark surface). Every color token customized in `:root` MUST be re-asserted with a dark value in `.dark`.
|
|
906
|
+
|
|
818
907
|
## `.pu-*` component classes
|
|
819
908
|
|
|
820
909
|
Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs.**
|
|
@@ -928,7 +1017,11 @@ end
|
|
|
928
1017
|
|
|
929
1018
|
### Form theme keys
|
|
930
1019
|
|
|
931
|
-
`base`, `fields_wrapper`, `actions_wrapper`, `wrapper`, `inner_wrapper`, `label`, `invalid_label`, `valid_label`, `neutral_label`, `input`, `invalid_input`, `valid_input`, `neutral_input`, `hint`, `error`, `button`, `checkbox`, `select
|
|
1020
|
+
`base`, `sectioned_base`, `fields_wrapper`, `sections_wrapper`, `actions_wrapper`, `wrapper`, `inner_wrapper`, `label`, `invalid_label`, `valid_label`, `neutral_label`, `input`, `invalid_input`, `valid_input`, `neutral_input`, `hint`, `error`, `button`, `checkbox`, `select`, plus the shared section keys below.
|
|
1021
|
+
|
|
1022
|
+
`sectioned_base` replaces `base` when the definition declares a `form_layout` — the sections are cards, so the form itself stops being one.
|
|
1023
|
+
|
|
1024
|
+
⚠️ **Width is NOT a theme key.** It's configuration (`page_width` / `form_width` on the definition, `Plutonium.configuration.default_page_width` globally) and is appended by `Form::Resource`/`Page::Show`, so overriding `base` or `fields_wrapper` restyles a surface without silently pinning its width. See [[plutonium-resource]] › Page Width.
|
|
932
1025
|
|
|
933
1026
|
## Display theme
|
|
934
1027
|
|
|
@@ -937,7 +1030,7 @@ class Display < Display
|
|
|
937
1030
|
class Theme < Plutonium::UI::Display::Theme
|
|
938
1031
|
def self.theme
|
|
939
1032
|
super.merge(
|
|
940
|
-
|
|
1033
|
+
fields_inner: "pu-card-body grid grid-cols-3 gap-8", # the GRID
|
|
941
1034
|
label: "text-sm font-bold text-[var(--pu-text-muted)] mb-1",
|
|
942
1035
|
string: "text-lg text-[var(--pu-text)]",
|
|
943
1036
|
markdown: "prose dark:prose-invert max-w-none"
|
|
@@ -949,7 +1042,15 @@ end
|
|
|
949
1042
|
|
|
950
1043
|
### Display theme keys
|
|
951
1044
|
|
|
952
|
-
`fields_wrapper`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`.
|
|
1045
|
+
`fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`.
|
|
1046
|
+
|
|
1047
|
+
⚠️ **`fields_wrapper` is the CARD, `fields_inner` is the grid.** `fields_wrapper` is merged into a `Plutonium::UI::Block` (which supplies `pu-card` itself), so putting grid classes there styles the card, not the fields. Override `fields_inner` to change the unsectioned grid, and `section_grid` to change the grid inside a `display_layout` section.
|
|
1048
|
+
|
|
1049
|
+
### Section theme keys (forms *and* displays)
|
|
1050
|
+
|
|
1051
|
+
Section chrome is shared: `Plutonium::UI::Component::Section::DEFAULT_THEME` is merged into **both** `Form::Theme` and `Display::Theme`, so the two read identically by default while staying independently overridable.
|
|
1052
|
+
|
|
1053
|
+
`section_wrapper` (merged into the section's Block — Block already supplies `pu-card`), `section_header`, `section_summary` (the collapsible header row), `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`, plus `sections_wrapper` (the container that stacks sections).
|
|
953
1054
|
|
|
954
1055
|
## Table theme
|
|
955
1056
|
|
|
@@ -11,15 +11,16 @@ For the field/input vocabulary used inside a step, load [[plutonium-resource]].
|
|
|
11
11
|
|
|
12
12
|
## 🚨 Critical (read first)
|
|
13
13
|
|
|
14
|
-
- **Enable the subsystem first.** `config.wizards.enabled = true`
|
|
14
|
+
- **Enable the subsystem first.** `rails g pu:wizards:install` flips `config.wizards.enabled = true` and schedules `SweepJob`; then `rails db:migrate`. (By hand: the flag in `config/initializers/plutonium.rb`.) It's `false` by default — without it there's no `plutonium_wizard_sessions` table.
|
|
15
15
|
- **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a **raised exception** — a non-bang `false` advances the wizard and silently loses data. Or call `fail!("msg")`.
|
|
16
16
|
- **`data` is step-keyed:** `data.<step>.<field>` (e.g. `data.company.name`, `data.plan.plan`). Each step has its own typed sub-object, so two steps may share a field name without colliding. Read a field through its owning step everywhere (`condition:`/`on_submit`/`execute`).
|
|
17
17
|
- **`condition:` lambdas must be nil-safe.** They run against `data` at every transition, including before their deciding step is filled (value is `nil`). `-> { data.plan.plan == "pro" }` ✓; `-> { data.plan.plan.upcase == "PRO" }` raises on nil ✗.
|
|
18
18
|
- **`review` must be the LAST step.** A step declared after `review` raises at load.
|
|
19
19
|
- **`using:` targets a MODEL only** — not an interaction, not a bare definition. Selectors `fields:`/`only:`/`except:`.
|
|
20
20
|
- **No generator.** Author wizards by hand, like interactions. They live in `app/wizards/`.
|
|
21
|
+
- **A wizard is a presentation object** — it's built with `view_context:`, so anything reachable only through `execute`/`on_submit` is reachable only from a wizard run. Logic may *start* there; the **second caller** (a job, an API, a rake task, the console) is the trigger to move it onto the **model**. See [[plutonium-behavior]] › Part 3 › Where the logic goes.
|
|
21
22
|
- **Wizards are portal- *or* main-app-hosted.** A `register_wizard` mount inside a portal inherits the portal's auth/scoping/layout. A `register_wizard` mount on the **main app** runs standalone — for an **authenticated** main-app wizard you MUST define your own `::WizardsController` (include `Plutonium::Wizard::Controller` + your auth concern); the synthesized fallback is **bare (no auth)**. Resource-anchored (`wizard` macro) wizards always run embedded on the resource controller.
|
|
22
|
-
- **Schedule `SweepJob`**
|
|
23
|
+
- **Schedule `SweepJob`** — `pu:wizards:install` does it for you when Solid Queue is in the bundle; otherwise add a periodic job/cron yourself. It reaps abandoned/expired sessions — always good hygiene (stale `in_progress` rows pile up otherwise), and **load-bearing** for `on_submit`/`persist` wizards: it's the only thing that rolls back the partial domain records an abandoned save-as-you-go run leaves behind.
|
|
23
24
|
|
|
24
25
|
---
|
|
25
26
|
|
|
@@ -53,7 +54,7 @@ The ASK gate resolves the *design*; this confirms the app can actually *run* it.
|
|
|
53
54
|
| Anchor model exists & reachable | Read the model an `anchored` wizard runs against | Missing/unreadable anchor ⇒ 404 / `NotAnchoredError` |
|
|
54
55
|
| Host portal exists & its scoping | Read the portal engine (`scope_to_entity`?) + its real module name | Tenant folds into run identity; a guessed portal name breaks `register_wizard` |
|
|
55
56
|
| Guest-flow prereqs | AR encryption keys if `encrypt_data`; no `concurrency_key`/`one_time` with `anonymous` | First write raises otherwise |
|
|
56
|
-
| `on_submit` ⇒ SweepJob scheduled |
|
|
57
|
+
| `on_submit` ⇒ SweepJob scheduled | grep `config/recurring.yml` for `sweep_abandoned_wizards` | Abandoned mid-flow records pile up forever |
|
|
57
58
|
|
|
58
59
|
**Don't author the class until `config.wizards.enabled` is confirmed and the anchor/target model + portal are read.** Until then, any class you show is provisional — say so; don't present a guessed field/column mapping as final.
|
|
59
60
|
|
|
@@ -97,6 +98,8 @@ end
|
|
|
97
98
|
- `review` — built-in terminal step: auto-summary + gated Finish. Must be last.
|
|
98
99
|
- `execute` — runs once at the end in one transaction; returns `succeed(...)` / `failed(...)`.
|
|
99
100
|
|
|
101
|
+
⚠️ **`execute` is a presentation boundary, same as an interaction's.** A wizard is built with `view_context:` too, so anything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow* — which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it (the API signup that skips onboarding, an admin backfill, an importer): `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. Same for `on_submit`/`on_rollback` — they're flow hooks, not a home for domain logic. Full rule: [[plutonium-behavior]] › Part 3 › Where the logic goes.
|
|
102
|
+
|
|
100
103
|
## Wizard-level macros
|
|
101
104
|
|
|
102
105
|
| Macro | Meaning |
|
|
@@ -111,6 +114,7 @@ end
|
|
|
111
114
|
| `one_time` | Retain the completed row at the `concurrency_key` → run once (gate-able). **Requires `concurrency_key`.** Omit → row deleted on complete (repeatable). |
|
|
112
115
|
| `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). |
|
|
113
116
|
| `encrypt_data` | Encrypt the staged `data` column at rest via ActiveRecord's encryption keys (PII flows). Requires `active_record.encryption` keys — first write raises (naming the wizard) if unconfigured. Unset inherits `config.wizards.encrypt_data` (global default, off); `encrypt_data false` opts out when that default is on. |
|
|
117
|
+
| `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full` (`:full` = unconstrained). Unset inherits `config.wizards.width` (default `:md`). Independent of `config.default_page_width` — resource page width does not move wizards. |
|
|
114
118
|
| `anonymous` | Opt into **guest (unauthenticated) access.** Default = auth required. A guest wizard may authenticate only at its terminal `execute`; never mid-flow. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`** — a guest's identity is its session token (already session-keyed/repeatable); whichever macro is declared last raises. |
|
|
115
119
|
|
|
116
120
|
## Branching — `condition:`
|
|
@@ -127,6 +131,26 @@ end
|
|
|
127
131
|
|
|
128
132
|
`condition:` can also read `anchor`. Branch-hidden steps' data is pruned before `execute`. **Must be nil-safe** (see Critical).
|
|
129
133
|
|
|
134
|
+
Conditions are re-evaluated against the submission that was just staged, so a step (including `review`) may be gated on an answer from the step immediately before it — the revealed steps become reachable on that same POST. When a step's answer reveals nothing after it, that POST ends the flow and runs `execute`.
|
|
135
|
+
|
|
136
|
+
### Revealing a field within a step — `pre_submit:`
|
|
137
|
+
|
|
138
|
+
A step-level `condition:` branches whole steps against *stored* data. To show or hide a field as the user edits a **sibling field on the same step**, mark the deciding input `pre_submit: true` — changing it re-renders the step form from the **just-submitted** values (same mechanism as resource forms and interactive actions):
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
step :details do
|
|
142
|
+
attribute :contact_pref, :string
|
|
143
|
+
input :contact_pref, as: :select, pre_submit: true,
|
|
144
|
+
choices: {none: "Don't contact me", email: "Email me"}
|
|
145
|
+
|
|
146
|
+
attribute :contact_email, :string
|
|
147
|
+
# `object` here is the step's data, seeded from what was just typed.
|
|
148
|
+
input :contact_email, condition: -> { object.contact_pref == "email" }
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A `pre_submit` is **render-only**: it never persists, never marks the step submitted, and never moves the cursor — abandon the page and nothing durable is left. **Attachment fields are exempt** from the re-render's seeding: a file input doesn't re-post on a sibling's `change`, so an already-staged upload survives untouched instead of being blanked out of the form. Uploads stage only on a real submit.
|
|
153
|
+
|
|
130
154
|
## Field reuse — `using:` a model
|
|
131
155
|
|
|
132
156
|
`using:` is a **step option** (not a block method) and targets a **model only**.
|
|
@@ -179,6 +203,31 @@ Repeater rows rehydrate from staged `data` on GET (resume / back re-renders fill
|
|
|
179
203
|
|
|
180
204
|
Validations drive the form's field affordances just like a resource form: `presence` → the required marker (`*`); `length`/`numericality`/`format`/`inclusion` → `maxlength`/`min`/`max`/`pattern`/auto-choices. This holds for validations imported via `using:` too. (Structured-input sub-fields are the exception — they carry no validators, so no markers there.)
|
|
181
205
|
|
|
206
|
+
### Options that depend on the run
|
|
207
|
+
|
|
208
|
+
The step block runs **once, when the class loads**, so a literal option is frozen for every run. A **proc-valued** field/input option is resolved on **every render**. It must **take the form**: `form.wizard` is the run (so `anchor`, `data`, `persisted`, `current_user` are all reachable) and `form.object` is that step's staged data.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
step :plan do
|
|
212
|
+
attribute :tier
|
|
213
|
+
input :tier, as: :select, choices: -> { anchor.available_tiers }
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
It is also how a custom component receives per-run configuration:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
input :answers, as: MyManifestComponent, config: -> { anchor.manifest }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
A one-argument proc still takes the form, as on any other form, for `object` (this step's staged data), `params` and helpers: `choices: ->(form) { form.object.region_tiers }`.
|
|
224
|
+
|
|
225
|
+
Three limits worth knowing:
|
|
226
|
+
|
|
227
|
+
- The **field set** is still fixed at class load — a proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup pass a **block** to `input` instead; it renders in the form's context with the field yielded.
|
|
228
|
+
- A **step's** `condition:` runs against the wizard; a **field's** `condition:` runs against the form (`object` = that step's staged data). Only the field-level one is excluded from the resolution above.
|
|
229
|
+
- **Proc options resolve on every form** ([[plutonium-resource]]), by one rule with no wizard exception: a zero-argument proc keeps its own binding, a one-argument one gets the form. A step block is `instance_exec`'d against an internal field recorder, so `-> { anchor.x }` raises `NameError` — take the form and use `form.wizard`. Same trade a `form_layout` section option makes; an `input` line keeps its meaning when moved between a definition and a step.
|
|
230
|
+
|
|
182
231
|
## Attachment fields (file uploads)
|
|
183
232
|
|
|
184
233
|
A step can collect a file. Declare it like any field — a **`:string`** attribute (it holds the upload **token**, not the bytes) + a file input:
|
|
@@ -241,6 +290,8 @@ Always lists invalid/unvisited steps as fix-this jump links; Finish disabled unt
|
|
|
241
290
|
|
|
242
291
|
The custom block runs **in the Phlex view context** (`self` is the component), so it may return a String, emit Phlex (`div`, `render Component.new(...)`), and reach helpers via `helpers.*`; it's yielded the `wizard` (`data`/`anchor`/`persisted`/`current_user`). Don't both emit markup and return a String — Phlex renders the returned String too, double-rendering it.
|
|
243
292
|
|
|
293
|
+
**The summary resolves choice labels.** A field declared with the `choices:` option summarises as the label its `<option>` carried, not the stored value — `42` reads as "Alice", `"cash"` as "Cash". Every collection shape the input accepts works (pair arrays, `{value => label}` hashes, ranges, sets, AR relations, procs returning any of those), because resolution goes through the same `Phlexi::Form::SimpleChoicesMapper` the input uses. **Caveat:** choices supplied inside a *block* (`input(:x) { |f| f.select_tag choices: … }`) are computed at render time and aren't visible to the summary — those fields still show the raw value. Use the declarative `choices:` option when you want the review page to read well.
|
|
294
|
+
|
|
244
295
|
## Per-step writes — `on_submit` / `persist` / `on_rollback`
|
|
245
296
|
|
|
246
297
|
`execute` is the default (atomic). Use `on_submit` **only** when a real record must exist mid-flow (external handoff, reviewer sees partials, payload too large for the row).
|
|
@@ -268,6 +319,8 @@ end
|
|
|
268
319
|
|
|
269
320
|
`on_submit` is not atomic across steps (HTTP), which is why `cleanup_after` + `SweepJob` exist.
|
|
270
321
|
|
|
322
|
+
**Keep the hook to flow, not domain.** `on_submit`/`on_rollback` belong to one wizard step and can't be called from anywhere else, so they should say *when* and *what gets tracked* — one call to a model, as above. Once "authorize a card and record the billing row" is something the API does too, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
|
|
323
|
+
|
|
271
324
|
## Accessors
|
|
272
325
|
|
|
273
326
|
| Accessor | Returns |
|
|
@@ -278,6 +331,8 @@ end
|
|
|
278
331
|
| `succeed(v)` / `failed(errs)` | Outcome helpers (alias `success`). `.with_message`, `.with_redirect_response` chainable. |
|
|
279
332
|
| `fail!(msg)` / `fail!(:field, msg)` | Raise a `StepError` from `on_submit`/`execute`. |
|
|
280
333
|
|
|
334
|
+
Available inside steps, `condition:`, `on_submit`, `on_rollback` and `execute` — and, via `form.wizard`, inside a step's **proc-valued field/input options** (see **Step internals → Options that depend on the run**).
|
|
335
|
+
|
|
281
336
|
## Anchoring
|
|
282
337
|
|
|
283
338
|
```ruby
|
|
@@ -419,7 +474,20 @@ The guest run-id lives in the **Rails session** (`session["plutonium_wizards"][<
|
|
|
419
474
|
|
|
420
475
|
`Plutonium::Wizard.in_progress_for(view_context)` (→ `Resume.entries_for(view_context)`) takes the `view_context` (as interactions do) and derives the run owner (`current_user`), tenant scope, and **portal** from it — returning that user's in-progress runs **for the current portal**, newest-first, for a "continue where you left off" dashboard. A run is only ever listed (and linked) by the portal it was launched in: a non-scoped portal lists only unscoped runs, a scoped portal narrows to the current tenant. (Two portals can share an entity scope, so the launching portal — the `engine` — is recorded per-run; scope alone can't identify it.)
|
|
421
476
|
|
|
422
|
-
Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes — `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount,
|
|
477
|
+
Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes — the named route for a `register_wizard` mount, `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, and for a **non-anchored** `wizard`-macro run the resource whose definition registers that wizard class; `nil` + `resume_unresolved_reason` when the row can't be resolved here.
|
|
478
|
+
|
|
479
|
+
Each entry also exposes a **`cancel_url`** — the `DELETE` target that abandons the run — resolved from the *same* mount through its own named cancel route. Never derive it by string-munging `resume_url`: that drops query params and mis-resolves for a run with no `current_step` (whose resume URL is the bare launch path). The two resolve **independently**, so an unresumable row is still cancellable rather than stranded in the list. Render it as a **form**, not a link — cancelling runs every step's `on_rollback`, destroys its `persist`'d records, and deletes the row:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
form(action: entry.cancel_url, method: "post") do
|
|
483
|
+
input(type: "hidden", name: "_method", value: "delete")
|
|
484
|
+
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
|
|
485
|
+
# `turbo_confirm`, NOT `confirm` — `data-confirm` is Rails UJS and never fires under Turbo.
|
|
486
|
+
button(type: "submit", data: {turbo_confirm: "Discard this draft? This can't be undone."}) { "Cancel" }
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
**Narrowing.** For the per-record / per-wizard resume widget ("does this record have an unfinished draft of wizard X?"), pass the optional `anchor:`/`wizard:` filters — they narrow **in the query, before enrichment**, so discarded rows are never URL-resolved or anchor-loaded (cheaper than `select`-ing the array, which enriches every row first). They compose, and the `wizard + anchor` pair is index-covered: `…in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first`. Don't reach into `e.session.anchor` to filter (a polymorphic load per row). For ad-hoc post-filtering the array still works — `e.wizard_class` is already on each entry.
|
|
423
491
|
|
|
424
492
|
## Storage & config
|
|
425
493
|
|
|
@@ -431,6 +499,7 @@ Plutonium.configure do |config|
|
|
|
431
499
|
config.wizards.encrypt_data = false # encrypt every wizard's data at rest (needs AR encryption keys)
|
|
432
500
|
config.wizards.database = :primary # reserved — v1 supports :primary only (else raises at boot)
|
|
433
501
|
config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect active_shrine/AS)
|
|
502
|
+
config.wizards.width = :md # default step page width (:sm/:md/:lg/:xl/:full) — NOT tied to default_page_width
|
|
434
503
|
end
|
|
435
504
|
```
|
|
436
505
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.63.0] - 2026-08-22
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Normalize default_phone_country to lowercase ISO2
|
|
10
|
+
- Stop formatter: leaking into typed display components
|
|
11
|
+
- Stop copied input classes from restyling the slim select panel
|
|
12
|
+
- Explain the draft-only Publish menu with both action policies
|
|
13
|
+
- Show the PublishPost interaction and unclip long file captions
|
|
14
|
+
- Drop the redundant is_a? guard from the tour policy snippet
|
|
15
|
+
- Nest litestream and job roles under servers: in generated deploy.yml
|
|
16
|
+
- Let action dropdowns size to their longest label ([#70](https://github.com/radioactive-labs/plutonium-core/issues/70))
|
|
17
|
+
- [**breaking**] Isolate Rodauth session state per configuration ([#73](https://github.com/radioactive-labs/plutonium-core/issues/73))
|
|
18
|
+
- Stop a nil entity_scope persisting in the policy context ([#74](https://github.com/radioactive-labs/plutonium-core/issues/74))
|
|
19
|
+
- Stop a component-class `as:` crashing on `to_sym` ([#75](https://github.com/radioactive-labs/plutonium-core/issues/75))
|
|
20
|
+
- Honour `as: :hidden` when declared on input ([#83](https://github.com/radioactive-labs/plutonium-core/issues/83))
|
|
21
|
+
- Resolve the parent on routes nested under a singular resource ([#81](https://github.com/radioactive-labs/plutonium-core/issues/81))
|
|
22
|
+
- [**breaking**] Decide wizard finalize after staging the submission ([#89](https://github.com/radioactive-labs/plutonium-core/issues/89))
|
|
23
|
+
- Size layouts to the visible viewport, not 100vh ([#90](https://github.com/radioactive-labs/plutonium-core/issues/90))
|
|
24
|
+
- Call to_s on attachment filename before Phlex title attribute ([#92](https://github.com/radioactive-labs/plutonium-core/issues/92))
|
|
25
|
+
- Relation_scope written as an instance method silently does nothing ([#96](https://github.com/radioactive-labs/plutonium-core/issues/96))
|
|
26
|
+
- Stop Turbo's cross-document node swap failing system tests ([#98](https://github.com/radioactive-labs/plutonium-core/issues/98))
|
|
27
|
+
- Retry slim-select instead of waiting on it ([#105](https://github.com/radioactive-labs/plutonium-core/issues/105))
|
|
28
|
+
- Apply an association's scope when creating through a nested route ([#106](https://github.com/radioactive-labs/plutonium-core/issues/106))
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- Add homepage depth & proof upgrade design spec
|
|
33
|
+
- Warn that :root token overrides must be mirrored in .dark
|
|
34
|
+
|
|
35
|
+
### Features
|
|
36
|
+
|
|
37
|
+
- Allow opting out of the grid card footer with `footer: false`
|
|
38
|
+
- Add render_before_fields/render_after_fields display hooks
|
|
39
|
+
- Hide show-page metadata rail in a kanban card's modal
|
|
40
|
+
- Add link:/button: HTML attribute bags to actions
|
|
41
|
+
- Homepage depth & proof upgrade ([#69](https://github.com/radioactive-labs/plutonium-core/issues/69))
|
|
42
|
+
- Collapse overflowing breadcrumbs, fix singular-route segments ([#72](https://github.com/radioactive-labs/plutonium-core/issues/72))
|
|
43
|
+
- Generate a Rodauth ManagementConstraint when an admin account exists
|
|
44
|
+
- Resolve proc-valued input options on every form ([#87](https://github.com/radioactive-labs/plutonium-core/issues/87))
|
|
45
|
+
- Eager-load what a collection is about to render ([#91](https://github.com/radioactive-labs/plutonium-core/issues/91))
|
|
46
|
+
- Native drag-and-drop reordering for tables, nested tables and grids ([#77](https://github.com/radioactive-labs/plutonium-core/issues/77))
|
|
47
|
+
- Add display_layout and render sections as cards ([#93](https://github.com/radioactive-labs/plutonium-core/issues/93))
|
|
48
|
+
- Constrain standalone form width ([#94](https://github.com/radioactive-labs/plutonium-core/issues/94))
|
|
49
|
+
- Per-wizard width override, independent of resource pages ([#97](https://github.com/radioactive-labs/plutonium-core/issues/97))
|
|
50
|
+
- Resume/cancel flow, pre_submit, and Turbo Stream completion ([#68](https://github.com/radioactive-labs/plutonium-core/issues/68))
|
|
51
|
+
- Run interactions out of band on a persisted, resumable run ([#99](https://github.com/radioactive-labs/plutonium-core/issues/99))
|
|
52
|
+
- Carry files and typed attributes into the run ([#100](https://github.com/radioactive-labs/plutonium-core/issues/100))
|
|
53
|
+
- Install generator, sweep concurrency, and an experimental template ([#101](https://github.com/radioactive-labs/plutonium-core/issues/101))
|
|
54
|
+
- Add a blog with RSS and the Plutonium launch post ([#102](https://github.com/radioactive-labs/plutonium-core/issues/102))
|
|
55
|
+
- Refresh the runs index while work is outstanding ([#104](https://github.com/radioactive-labs/plutonium-core/issues/104))
|
|
56
|
+
- Declare which associations get nested routes ([#107](https://github.com/radioactive-labs/plutonium-core/issues/107))
|
|
57
|
+
|
|
58
|
+
### Miscellaneous Tasks
|
|
59
|
+
|
|
60
|
+
- Patch pagy CVE, five npm advisories, refresh appraisal locks ([#78](https://github.com/radioactive-labs/plutonium-core/issues/78))
|
|
61
|
+
- Prune the 0.49.0 notice from post_install_message ([#80](https://github.com/radioactive-labs/plutonium-core/issues/80))
|
|
62
|
+
- Clear 11 npm advisories via lockfile updates
|
|
63
|
+
|
|
64
|
+
### Refactoring
|
|
65
|
+
|
|
66
|
+
- Drop is_a? guards from record-action policy methods
|
|
67
|
+
|
|
68
|
+
### Testing
|
|
69
|
+
|
|
70
|
+
- Update the nested-association tests to the route-config lookup ([#88](https://github.com/radioactive-labs/plutonium-core/issues/88))
|
|
71
|
+
|
|
5
72
|
## [0.62.2] - 2026-07-09
|
|
6
73
|
|
|
7
74
|
### Bug Fixes
|
data/CLAUDE.md
CHANGED
|
@@ -159,6 +159,93 @@ yarn docs:build # Build for production
|
|
|
159
159
|
2. Add helper method in `Plutonium::Interaction::Outcome::Success`
|
|
160
160
|
3. Document usage
|
|
161
161
|
|
|
162
|
+
## Writing Docs and Blog Posts
|
|
163
|
+
|
|
164
|
+
Docs and posts in this repo are read by two audiences: developers, and AI assistants
|
|
165
|
+
(the skills in `.claude/skills/` are synced to `docs/public/skills/` and shipped).
|
|
166
|
+
A wrong example does not just mislead a reader. It gets reproduced as code.
|
|
167
|
+
|
|
168
|
+
### Verify every code sample
|
|
169
|
+
|
|
170
|
+
**Never publish a snippet you have not checked against this codebase.** Reading the
|
|
171
|
+
docs is not enough; the docs have been wrong. Two real cases:
|
|
172
|
+
|
|
173
|
+
- `as: :phlexi_tag` was in three docs and a skill. `ResolvesTags#component_for`
|
|
174
|
+
dispatches via `send(:"#{tag}_tag")`, so it resolved to `phlexi_tag_tag` and
|
|
175
|
+
raised `NoMethodError`. The working values are `:phlexi_render` and `:phlexi`.
|
|
176
|
+
- A per-portal override example declared `as: :text` on a text column (dead code)
|
|
177
|
+
and commented that the definition decides which fields admins see (it does not).
|
|
178
|
+
|
|
179
|
+
When a claim is mechanical, prove it at runtime rather than by reading:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
cd test/dummy && RAILS_ENV=test bin/rails runner 'puts SomeClass.instance_methods.grep(/x/)'
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Invariants a sample must not violate
|
|
186
|
+
|
|
187
|
+
- **Don't declare what is auto-detected.** `field :title` matching the detected type
|
|
188
|
+
is dead code (`reference/resource/definition.md`).
|
|
189
|
+
- **Definition = how a field renders. Policy = whether it appears.** "Only admins see
|
|
190
|
+
this" is `permitted_attributes_for_*`, never a definition line or a `condition:`.
|
|
191
|
+
- **Pages expose the record as `object`**, not `resource`.
|
|
192
|
+
- **Interactions:** `attribute :resource` (no `class:`); rescue
|
|
193
|
+
`ActiveRecord::RecordInvalid` around any `create!`/`update!`/`save!`.
|
|
194
|
+
- **Policies:** never put `*_attributes` hashes in `permitted_attributes_for_*`.
|
|
195
|
+
|
|
196
|
+
### Voice
|
|
197
|
+
|
|
198
|
+
Match the reference docs: direct, concrete, no throat-clearing. Specifically avoid
|
|
199
|
+
the tells that make writing read as machine-generated:
|
|
200
|
+
|
|
201
|
+
- **No em dashes.** Use commas, colons, parentheses, or two sentences.
|
|
202
|
+
- No "It's not X, it's Y" or "The pitch isn't… The pitch is…" constructions.
|
|
203
|
+
- No tidy-summary line after every example ("That's the Rails bargain in miniature").
|
|
204
|
+
- No stacked negation before the real claim, no "critically", no "genuinely".
|
|
205
|
+
- Cut a sentence that restates the one before it.
|
|
206
|
+
|
|
207
|
+
Deliberate parallelism and a stated opinion are fine. The tell is *unearned*
|
|
208
|
+
rhetoric, not rhetoric itself.
|
|
209
|
+
|
|
210
|
+
### Blog mechanics
|
|
211
|
+
|
|
212
|
+
Posts are markdown in `docs/blog/`. Frontmatter:
|
|
213
|
+
|
|
214
|
+
```yaml
|
|
215
|
+
---
|
|
216
|
+
title: "Introducing Plutonium: Rails conventions, past CRUD" # must read cold
|
|
217
|
+
titleTemplate: "Plutonium Blog"
|
|
218
|
+
date: 2026-08-19
|
|
219
|
+
description: One sentence. Becomes the RSS description and the index subtitle.
|
|
220
|
+
author: Stefan Froelich
|
|
221
|
+
tags: [announcement, rails]
|
|
222
|
+
draft: true # omit to publish
|
|
223
|
+
---
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- Put `<BlogMeta />` directly under the `# ` heading; it renders date, author and
|
|
227
|
+
the back link from frontmatter.
|
|
228
|
+
- **A title must be legible with no context.** It travels via RSS, social and pasted
|
|
229
|
+
links, where the site name is absent, so include both "Plutonium" and "Rails".
|
|
230
|
+
"Plutonium" alone collides with the element and other projects.
|
|
231
|
+
- **The slug must match the title.** Renaming the title means renaming the file;
|
|
232
|
+
URLs are permanent once published.
|
|
233
|
+
- A post is distinguished from the section index by having a `date`. `draft: true`
|
|
234
|
+
hides it from the index and the feed.
|
|
235
|
+
- `yarn docs:build` must pass. Dead internal links fail the build.
|
|
236
|
+
|
|
237
|
+
### Screenshots
|
|
238
|
+
|
|
239
|
+
Match the existing ones or they look foreign next to each other:
|
|
240
|
+
|
|
241
|
+
- **2480px wide** (1240 CSS at `deviceScaleFactor: 2`); height varies with content.
|
|
242
|
+
- **Light mode, icon rail collapsed.** In `test/dummy`, set
|
|
243
|
+
`localStorage.theme = 'light'` and `localStorage.pu_rail_pinned = 'false'`.
|
|
244
|
+
- Capture from the running dummy app with realistic, **distinct** data. Generated
|
|
245
|
+
titles that repeat across rows read as broken data.
|
|
246
|
+
- Crop dead space: `magick shot.png -crop 2480x1290+0+0 +repage shot.png`.
|
|
247
|
+
- Live under `docs/public/images/<section>/`.
|
|
248
|
+
|
|
162
249
|
## Skills System
|
|
163
250
|
|
|
164
251
|
The `.claude/skills/` directory contains documentation for AI assistants:
|
data/Rakefile
CHANGED
|
@@ -35,12 +35,46 @@ end
|
|
|
35
35
|
task :test_generators do
|
|
36
36
|
failures = []
|
|
37
37
|
|
|
38
|
+
# This task DESTROYS uncommitted work under test/dummy — `git checkout --` plus
|
|
39
|
+
# `git clean -fd`, unconditionally, after every file. That is required (see
|
|
40
|
+
# below) but it is not something to discover afterwards, so refuse to start on
|
|
41
|
+
# a dirty tree. Set FORCE=1 to run anyway; CI checkouts are clean and never
|
|
42
|
+
# trip this.
|
|
43
|
+
dirty = `git status --porcelain -- test/dummy`.strip
|
|
44
|
+
if !dirty.empty? && ENV["FORCE"] != "1"
|
|
45
|
+
abort <<~MSG
|
|
46
|
+
test/dummy has uncommitted changes:
|
|
47
|
+
|
|
48
|
+
#{dirty.lines.map { |l| " #{l}" }.join}
|
|
49
|
+
This task git-restores and git-cleans test/dummy between generator test
|
|
50
|
+
files, which would discard them. Commit or stash first, or re-run with
|
|
51
|
+
FORCE=1 to accept the loss.
|
|
52
|
+
MSG
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Between files, not just inside them. A generator that shells out — e.g.
|
|
56
|
+
# `generate "pu:pkg:package"` — goes through Rails' `generate` action, which
|
|
57
|
+
# passes abort_on_failure: true, so Thor calls `abort` in the test process
|
|
58
|
+
# when the child fails. SystemExit skips minitest's teardown, so anything the
|
|
59
|
+
# test wrote into test/dummy survives; since every generator test file boots
|
|
60
|
+
# the dummy app at require time, one aborted file would take down every file
|
|
61
|
+
# after it. Cleaning here can't be skipped by an aborting child.
|
|
62
|
+
#
|
|
63
|
+
# An at_exit hook inside the tests cannot do this job: minitest runs the whole
|
|
64
|
+
# suite from its own at_exit, and handlers fire LIFO, so a hook registered
|
|
65
|
+
# later runs before any test does.
|
|
66
|
+
restore_dummy_app = lambda do
|
|
67
|
+
system("git", "checkout", "--", "test/dummy", out: File::NULL, err: File::NULL)
|
|
68
|
+
system("git", "clean", "-fd", "test/dummy", out: File::NULL, err: File::NULL)
|
|
69
|
+
end
|
|
70
|
+
|
|
38
71
|
FileList["test/generators/**/*_test.rb"].sort.each do |test_file|
|
|
39
72
|
puts "\n=== #{test_file} ==="
|
|
40
73
|
# in: File::NULL — prevents a stray sub-generator prompt from hanging on the inherited TTY.
|
|
41
74
|
unless system(Gem.ruby, "-w", "-Ilib:test", test_file, in: File::NULL)
|
|
42
75
|
failures << test_file
|
|
43
76
|
end
|
|
77
|
+
restore_dummy_app.call
|
|
44
78
|
end
|
|
45
79
|
|
|
46
80
|
if failures.any?
|
data/SECURITY.md
CHANGED
|
@@ -12,7 +12,7 @@ please upgrade before reporting an issue to confirm it still reproduces.
|
|
|
12
12
|
|
|
13
13
|
| Version | Supported |
|
|
14
14
|
| ------- | ------------------ |
|
|
15
|
-
| Latest release (`0.
|
|
15
|
+
| Latest release (`0.63.x`) | :white_check_mark: |
|
|
16
16
|
| Older releases | :x: |
|
|
17
17
|
|
|
18
18
|
## Reporting a Vulnerability
|