plutonium 0.58.1 → 0.60.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-auth/SKILL.md +7 -1
- data/.claude/skills/plutonium-behavior/SKILL.md +4 -0
- data/.claude/skills/plutonium-resource/SKILL.md +49 -0
- data/CHANGELOG.md +16 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/config.ts +1 -0
- data/docs/reference/auth/accounts.md +7 -0
- data/docs/reference/resource/actions.md +3 -0
- data/docs/reference/resource/definition.md +129 -0
- data/docs/reference/resource/export.md +94 -0
- data/docs/reference/ui/forms.md +51 -21
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +917 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
- data/docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md +306 -0
- data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +237 -0
- data/gemfiles/rails_7.gemfile.lock +3 -1
- data/gemfiles/rails_8.0.gemfile.lock +3 -1
- data/gemfiles/rails_8.1.gemfile.lock +3 -1
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +3 -3
- data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
- data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
- data/lib/generators/pu/rodauth/views_generator.rb +1 -1
- data/lib/plutonium/definition/base.rb +4 -0
- data/lib/plutonium/definition/form_layout.rb +144 -0
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +17 -7
- data/lib/plutonium/query/filter.rb +4 -1
- data/lib/plutonium/query/filters/association.rb +1 -2
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +162 -0
- data/lib/plutonium/resource/controllers/queryable.rb +1 -0
- data/lib/plutonium/resource/policy.rb +21 -0
- data/lib/plutonium/routing/mapper_extensions.rb +13 -0
- data/lib/plutonium/ui/export_button.rb +86 -0
- data/lib/plutonium/ui/form/components/section.rb +58 -0
- data/lib/plutonium/ui/form/resource.rb +85 -7
- data/lib/plutonium/ui/table/components/toolbar.rb +9 -2
- data/lib/plutonium/ui/table/resource.rb +18 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -0
- data/src/css/slim_select.css +11 -2
- metadata +26 -2
data/docs/.vitepress/config.ts
CHANGED
|
@@ -131,6 +131,7 @@ export default defineConfig(withMermaid({
|
|
|
131
131
|
{ text: "Definition", link: "/reference/resource/definition" },
|
|
132
132
|
{ text: "Query", link: "/reference/resource/query" },
|
|
133
133
|
{ text: "Actions", link: "/reference/resource/actions" },
|
|
134
|
+
{ text: "CSV Export", link: "/reference/resource/export" },
|
|
134
135
|
]
|
|
135
136
|
},
|
|
136
137
|
{
|
|
@@ -80,6 +80,13 @@ rails g pu:rodauth:admin admin --extra-attributes=name:string,department:string
|
|
|
80
80
|
enum :role, super_admin: 0, admin: 1
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
**Invite + resend.** The admin resource gets two actions:
|
|
84
|
+
|
|
85
|
+
- **Invite** — invite a new admin by email; Rodauth sends a verification link and the invitee sets their own password through the verify flow.
|
|
86
|
+
- **Resend invitation** — re-send the verification email. Only shown for admins who haven't verified yet.
|
|
87
|
+
|
|
88
|
+
This uses Rodauth account verification, separate from the [Tenancy › Invites](/reference/tenancy/invites) system.
|
|
89
|
+
|
|
83
90
|
Rake task for direct admin creation (generated alongside the account — namespace is `rodauth`, task name is the account name):
|
|
84
91
|
|
|
85
92
|
```bash
|
|
@@ -360,6 +360,9 @@ class PostDefinition < ResourceDefinition
|
|
|
360
360
|
end
|
|
361
361
|
```
|
|
362
362
|
|
|
363
|
+
> **CSV export is not an action.** It's a built-in, policy-gated capability with its own
|
|
364
|
+
> button — see [CSV Export](./export.md). Don't declare it with `action :export_csv`.
|
|
365
|
+
|
|
363
366
|
## Interaction responses
|
|
364
367
|
|
|
365
368
|
```ruby
|
|
@@ -459,6 +459,135 @@ validate do
|
|
|
459
459
|
end
|
|
460
460
|
```
|
|
461
461
|
|
|
462
|
+
## Form layout
|
|
463
|
+
|
|
464
|
+
Declare a declarative layout for forms without changing per-field configuration. Sections are evaluated against the policy-filtered field list at render time, so a field filtered out by the policy is simply skipped.
|
|
465
|
+
|
|
466
|
+
```ruby
|
|
467
|
+
class PostDefinition < ResourceDefinition
|
|
468
|
+
form_layout do
|
|
469
|
+
section :identity, :title, :slug,
|
|
470
|
+
label: "Post identity", description: "Visible URL and title"
|
|
471
|
+
|
|
472
|
+
section :content, :body, :excerpt,
|
|
473
|
+
collapsible: true, columns: 1
|
|
474
|
+
|
|
475
|
+
section :publishing, :published_at, :category,
|
|
476
|
+
collapsible: true, collapsed: true,
|
|
477
|
+
condition: -> { current_user.publisher? }
|
|
478
|
+
|
|
479
|
+
ungrouped label: "Other details"
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### `form_layout` block
|
|
485
|
+
|
|
486
|
+
The block is evaluated once and stored on the class. Re-declaring `form_layout` in a subclass replaces the parent layout as a unit; per-field `input` config inherits normally.
|
|
487
|
+
|
|
488
|
+
With no `form_layout` declared the form renders unchanged as a single responsive grid — fully backwards-compatible.
|
|
489
|
+
|
|
490
|
+
### `section(key, *fields, **opts)`
|
|
491
|
+
|
|
492
|
+
Groups a set of fields under an optional heading.
|
|
493
|
+
|
|
494
|
+
| Argument | Description |
|
|
495
|
+
|---|---|
|
|
496
|
+
| `key` | Symbol. `:ungrouped` is reserved — use the `ungrouped` macro instead (raises `ArgumentError` otherwise). |
|
|
497
|
+
| `*fields` | Ordered field keys to place in this section. |
|
|
498
|
+
| `label:` | Section heading. Defaults to `key.to_s.humanize` (e.g. `:shipping_address` → `"Shipping address"`). |
|
|
499
|
+
| `description:` | Optional help line rendered below the heading. |
|
|
500
|
+
| `collapsible:` | Boolean (default `false`). Wraps the section in a native `<details>/<summary>` (no JS). |
|
|
501
|
+
| `collapsed:` | Boolean (default `false`). Initial collapsed state when `collapsible: true`. |
|
|
502
|
+
| `columns:` | Positive Integer. Overrides the section grid column count (e.g. `columns: 2`). Omit to use the form's default responsive grid. Must be a positive Integer — any other value raises. (Literal only — not dynamic.) |
|
|
503
|
+
| `condition:` | Lambda evaluated in the form instance context — same semantics as `input ..., condition:`. `object`, `current_user`, helpers etc. are all available. A falsey result hides the entire section and withholds its fields (they do not spill into `ungrouped`). |
|
|
504
|
+
|
|
505
|
+
Every option except `columns:` may be either a literal **or a proc** resolved at render time in the same form instance context as `condition:` (so `object`, `current_user`, `params`, helpers are all available). This makes the layout record-aware — e.g. collapse a section by default only for existing records:
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
section :advanced, :seo_title, :notes,
|
|
509
|
+
collapsible: true,
|
|
510
|
+
collapsed: -> { object.persisted? }, # open for new, collapsed for edits
|
|
511
|
+
label: -> { object.new_record? ? "Set up" : "Advanced" }
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Empty sections (all fields filtered by the policy, or none assigned) are **not** hidden automatically. Use `condition:` to hide a section conditionally.
|
|
515
|
+
|
|
516
|
+
### `ungrouped(**opts)`
|
|
517
|
+
|
|
518
|
+
A macro (not a `section` call) that configures the implicit bucket collecting every permitted field not claimed by any `section`. Takes **no field list** — its fields are computed at render time.
|
|
519
|
+
|
|
520
|
+
- Accepts the same options as `section`: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:`, `condition:`.
|
|
521
|
+
- **Position** — where you call `ungrouped` in the block is where leftovers appear. Omit it entirely and leftovers render **last**, after every declared section, with no heading. (Declaring `ungrouped` at the very end is therefore equivalent to omitting it, except that the explicit form lets you add a `label:` and other options.)
|
|
522
|
+
- Declaring `ungrouped` more than once in a single `form_layout` raises `ArgumentError`.
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
form_layout do
|
|
526
|
+
section :advanced, :seo_title, :seo_description, collapsible: true
|
|
527
|
+
ungrouped label: "Core fields" # leftovers rendered here, with a heading
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# To float leftovers ABOVE your sections, declare `ungrouped` first:
|
|
531
|
+
form_layout do
|
|
532
|
+
ungrouped label: "Core fields"
|
|
533
|
+
section :advanced, :seo_title, :seo_description, collapsible: true
|
|
534
|
+
end
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### Layout references keys; config stays on `input`
|
|
538
|
+
|
|
539
|
+
`form_layout` and `section` carry section-level options only. All per-field rendering config — `as:`, the field's own `label:`, `choices:`, per-field `condition:`, `pre_submit:`, blocks — remains on the `input` declaration. Layout never duplicates field config.
|
|
540
|
+
|
|
541
|
+
This includes a field's **column span**. In a section with `columns:`, fields flow into single grid cells by default; a field that declares its own span via `wrapper: {class: "col-span-..."}` keeps it — a field-level span always wins, so you can opt one field back to full width inside a multi-column section:
|
|
542
|
+
|
|
543
|
+
```ruby
|
|
544
|
+
input :notes, wrapper: {class: "col-span-full"} # spans the whole row...
|
|
545
|
+
|
|
546
|
+
form_layout do
|
|
547
|
+
section :details, :first_name, :last_name, :notes, columns: 2 # ...even here
|
|
548
|
+
end
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
class ArticleDefinition < ResourceDefinition
|
|
553
|
+
# per-field config on input — untouched by form_layout
|
|
554
|
+
input :body, as: :markdown
|
|
555
|
+
input :published_at, hint: "Leave blank to save as draft"
|
|
556
|
+
input :visibility, as: :select, choices: %w[public private unlisted]
|
|
557
|
+
|
|
558
|
+
form_layout do
|
|
559
|
+
section :writing, :title, :body, :excerpt, label: "Content"
|
|
560
|
+
section :meta, :published_at, :visibility, :tags, label: "Publishing settings"
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### Referencing an unknown field raises
|
|
566
|
+
|
|
567
|
+
A `section` that lists a field key that is not a known attribute of the model raises `ArgumentError` at render time — this catches typos early.
|
|
568
|
+
|
|
569
|
+
### On interactions
|
|
570
|
+
|
|
571
|
+
`form_layout` is also available on `Plutonium::Interaction::Base`. The same DSL groups the interaction's `attribute` declarations into sections. Interaction forms (`Plutonium::UI::Form::Interaction`) pick up the layout automatically — no extra wiring needed.
|
|
572
|
+
|
|
573
|
+
Dynamic options and `condition:` work here too, with one difference: in an interaction form `object` is the **interaction instance** (not a record). For a record action, the record is `object.resource` — so e.g. `collapsed: -> { object.resource.archived? }`.
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
class PublishPostInteraction < Plutonium::Interaction::Base
|
|
577
|
+
attribute :publish_at, :datetime
|
|
578
|
+
attribute :notify_subscribers, :boolean, default: false
|
|
579
|
+
attribute :notify_message, :string
|
|
580
|
+
|
|
581
|
+
form_layout do
|
|
582
|
+
section :timing, :publish_at, label: "When to publish"
|
|
583
|
+
section :notifications, :notify_subscribers, :notify_message,
|
|
584
|
+
label: "Subscriber notifications",
|
|
585
|
+
collapsible: true,
|
|
586
|
+
condition: -> { object.has_subscribers? }
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
```
|
|
590
|
+
|
|
462
591
|
## File uploads
|
|
463
592
|
|
|
464
593
|
```ruby
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# CSV Export
|
|
2
|
+
|
|
3
|
+
Every resource ships with a streamed CSV export, **disabled by default**. It is *not* an
|
|
4
|
+
[action](./actions.md) — it streams a file and opens in a new tab — so it is enabled
|
|
5
|
+
through the policy rather than declared with `action :export_csv`. The route
|
|
6
|
+
(`GET /<resources>/export_csv`) is auto-mounted on every resource; a split "Export"
|
|
7
|
+
button appears on the index page once the policy permits it.
|
|
8
|
+
|
|
9
|
+
## Enabling
|
|
10
|
+
|
|
11
|
+
Override one policy method:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class PostPolicy < ResourcePolicy
|
|
15
|
+
def export_csv? = true # or `index?` to mirror list access
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`export_csv?` defaults to `false`, so export is strictly opt-in. While it returns false
|
|
20
|
+
the button is hidden and the route returns `403`.
|
|
21
|
+
|
|
22
|
+
## The two exports
|
|
23
|
+
|
|
24
|
+
The control is a split button with two behaviours:
|
|
25
|
+
|
|
26
|
+
| | Source | Filename |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **Export** (primary) | The current view — selected scope + filters + search (the index's `?q`), **all** matching rows (not just the visible page) | `posts_<date>.csv` |
|
|
29
|
+
| **Export all** (dropdown) | The entire authorized scope — ignores scope, filters, search, and default scope | `posts_all_<date>.csv` |
|
|
30
|
+
|
|
31
|
+
"Export all" always exports everything the user is authorized to read, regardless of the
|
|
32
|
+
current scope/filters.
|
|
33
|
+
|
|
34
|
+
Both stream via `find_each`, so memory stays flat regardless of row count. `find_each`
|
|
35
|
+
iterates in **primary-key order**, so the file does not preserve the index's current
|
|
36
|
+
sort (filters/search/scope still apply to the primary export).
|
|
37
|
+
|
|
38
|
+
## Columns
|
|
39
|
+
|
|
40
|
+
The exported columns come from `permitted_attributes_for_export` on the policy (defaults
|
|
41
|
+
to `permitted_attributes_for_index`), with the **primary key always prepended as the
|
|
42
|
+
first column**.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
class PostPolicy < ResourcePolicy
|
|
46
|
+
def export_csv? = true
|
|
47
|
+
def permitted_attributes_for_export = [:title, :author, :total, :created_at]
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The method is named `_export` (not `_export_csv`) on purpose, so a future export format
|
|
52
|
+
could reuse the same column set.
|
|
53
|
+
|
|
54
|
+
## Customizing a field's output
|
|
55
|
+
|
|
56
|
+
The `export` definition DSL parallels `display` and `column`. The block receives the
|
|
57
|
+
record and returns the cell value; `label:` overrides the header (default: the humanized
|
|
58
|
+
attribute name).
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
class PostDefinition < ResourceDefinition
|
|
62
|
+
export :author, label: "Author email", &->(post) { post.author.email }
|
|
63
|
+
export :total, &->(post) { post.total.format }
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The column *set* still comes from `permitted_attributes_for_export`; `export` only
|
|
68
|
+
customizes how a listed column is rendered.
|
|
69
|
+
|
|
70
|
+
## Value resolution
|
|
71
|
+
|
|
72
|
+
For a column **without** an `export` block, the value is read straight off the record
|
|
73
|
+
(`record.public_send(name)`):
|
|
74
|
+
|
|
75
|
+
- **Scalars** (strings, numbers, booleans, dates) are written as-is.
|
|
76
|
+
- **Associations** render as their display label — the same `display_name_of` the index
|
|
77
|
+
uses (e.g. `User #5`, or the record's `to_label`/`name`/`title` if defined) — never
|
|
78
|
+
`#<User:0x…>`. Add an `export` block to export a specific field instead (e.g. the email).
|
|
79
|
+
- A name that is **neither** an `export` block **nor** a real method on the record renders
|
|
80
|
+
the placeholder `<<invalid column>>` rather than aborting the (already-streaming) download.
|
|
81
|
+
To export a computed or virtual column, give it an `export` block — a `label:`-only
|
|
82
|
+
`export` does **not** supply a value, so it too renders the placeholder.
|
|
83
|
+
|
|
84
|
+
## Notes & limits
|
|
85
|
+
|
|
86
|
+
- Exports run **synchronously** and hold the request (and a DB connection) while
|
|
87
|
+
streaming. A background job that emails a download link is intentionally out of scope
|
|
88
|
+
for now.
|
|
89
|
+
- The export button opens in a new tab (`target="_blank"`), which also keeps Turbo from
|
|
90
|
+
intercepting the streamed download.
|
|
91
|
+
- **CSV/formula injection** is neutralized: any cell whose value begins with `=`, `+`,
|
|
92
|
+
`-`, `@`, or a leading tab/CR is prefixed with a single quote so spreadsheet apps import
|
|
93
|
+
it as literal text instead of executing it as a formula.
|
|
94
|
+
- `csv` is a runtime dependency of Plutonium (it is no longer a Ruby default gem on 3.4+).
|
data/docs/reference/ui/forms.md
CHANGED
|
@@ -47,40 +47,70 @@ end
|
|
|
47
47
|
|
|
48
48
|
## Custom layouts
|
|
49
49
|
|
|
50
|
-
### Sectioned form
|
|
50
|
+
### Sectioned form (declarative — preferred)
|
|
51
|
+
|
|
52
|
+
Declare sections in the **definition** using `form_layout`. The form picks up the layout automatically — no `Form` subclass needed for common cases.
|
|
51
53
|
|
|
52
54
|
```ruby
|
|
53
|
-
class
|
|
54
|
-
|
|
55
|
-
section
|
|
56
|
-
|
|
57
|
-
render_resource_field :slug
|
|
58
|
-
end
|
|
55
|
+
class PostDefinition < ResourceDefinition
|
|
56
|
+
form_layout do
|
|
57
|
+
section :basics, :title, :slug,
|
|
58
|
+
label: "Basic information"
|
|
59
59
|
|
|
60
|
-
section
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
section :content, :body, :excerpt,
|
|
61
|
+
label: "Content", columns: 1
|
|
62
|
+
|
|
63
|
+
section :publishing, :published_at, :category,
|
|
64
|
+
label: "Publishing", collapsible: true, collapsed: true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This handles headings, collapsible panels, per-section column counts, and `condition:`-based visibility — all with no view code. See [Resource › Definition › Form layout](/reference/resource/definition#form-layout) for the full DSL reference, including `ungrouped`, `condition:`, `columns:`, and the "On interactions" note.
|
|
64
70
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
### Full control: override `render_fields`
|
|
72
|
+
|
|
73
|
+
When the declarative DSL doesn't cover your use case — asymmetric multi-column layouts, embedding a panel widget between sections, etc. — override `render_fields` in a nested `Form` class:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class PostDefinition < ResourceDefinition
|
|
77
|
+
class Form < Form
|
|
78
|
+
def form_template
|
|
79
|
+
render_fields # replaced below
|
|
80
|
+
render_actions
|
|
68
81
|
end
|
|
69
82
|
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
def render_fields
|
|
84
|
+
div(class: "mb-8") do
|
|
85
|
+
h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Basic Information" }
|
|
86
|
+
fields_wrapper do
|
|
87
|
+
render_resource_field :title
|
|
88
|
+
render_resource_field :slug
|
|
89
|
+
end
|
|
90
|
+
end
|
|
72
91
|
|
|
73
|
-
|
|
92
|
+
div(class: "mb-8") do
|
|
93
|
+
h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Content" }
|
|
94
|
+
fields_wrapper do
|
|
95
|
+
render_resource_field :content
|
|
96
|
+
render_resource_field :excerpt
|
|
97
|
+
end
|
|
98
|
+
end
|
|
74
99
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
div(class: "mb-8") do
|
|
101
|
+
h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Publishing" }
|
|
102
|
+
fields_wrapper do
|
|
103
|
+
render_resource_field :published_at
|
|
104
|
+
render_resource_field :category
|
|
105
|
+
end
|
|
106
|
+
end
|
|
79
107
|
end
|
|
80
108
|
end
|
|
81
109
|
end
|
|
82
110
|
```
|
|
83
111
|
|
|
112
|
+
Prefer `form_layout` in the definition — it keeps layout config out of view code and works for interactions too.
|
|
113
|
+
|
|
84
114
|
### Two-column layout
|
|
85
115
|
|
|
86
116
|
```ruby
|