plutonium 0.65.0 → 0.66.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 +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-resource
|
|
3
|
-
description: Use BEFORE creating, scaffolding, or editing any Plutonium resource
|
|
3
|
+
description: 'Use BEFORE creating, scaffolding, or editing any Plutonium resource: model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, hidden actions, index views, drag-to-reorder (positioned_on / position_on), page customization. The single source for "what is a resource and how do I configure one".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Resources
|
|
@@ -14,29 +14,29 @@ For tenancy / `associated_with` / `relation_scope`, load [[plutonium-tenancy]].
|
|
|
14
14
|
- **Always use generators.** `pu:res:scaffold` creates the resource; `pu:res:conn` connects it to a portal. Never hand-write the model, migration, policy, definition, or controller.
|
|
15
15
|
- **Pass `--dest`** on every scaffold: `--dest=main_app` or `--dest=package_name`. Skips the interactive prompt.
|
|
16
16
|
- **Quote field args with `?` or `{}`** to prevent shell expansion: `'field:type?'`, `'field:decimal{10,2}'`.
|
|
17
|
-
- **
|
|
17
|
+
- **Migrate, then run `pu:res:conn`.** Without `conn` the resource has no portal routes and is invisible. When `conn` writes a policy with attribute lists (no base policy to inherit from), it reads them off the table's columns; on an unmigrated table it logs an error and writes empty lists.
|
|
18
18
|
- **Let auto-detection work.** Plutonium reads your model. Only declare `field`/`input`/`display`/`column` when overriding the default.
|
|
19
19
|
- **Authorization is in policies, not `condition:` procs.** Use `condition` for UI state ("show this when published"). Use the policy's `permitted_attributes_for_*` for "who can see this".
|
|
20
20
|
- **Custom actions require a policy method.** `action :publish` needs `def publish?` on the policy.
|
|
21
|
-
- **`has_cents` virtual accessor
|
|
21
|
+
- **`has_cents` virtual accessor**: reference `:price`, NEVER `:price_cents`, in policies and definitions.
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
-
## 🛑 Before you scaffold or edit: confirm the shape (ASK
|
|
25
|
+
## 🛑 Before you scaffold or edit: confirm the shape (ASK: don't infer)
|
|
26
26
|
|
|
27
|
-
"Add a Product" / "add a status field" is underspecified. Guess wrong and you scaffold into the wrong package, churn migrations, store money as a lossy float, reference a model that doesn't exist, or **clobber files the user has customized**. Resolve each
|
|
27
|
+
"Add a Product" / "add a status field" is underspecified. Guess wrong and you scaffold into the wrong package, churn migrations, store money as a lossy float, reference a model that doesn't exist, or **clobber files the user has customized**. Resolve each, **by inspecting (next section), not guessing**, then restate the resolved shape and confirm:
|
|
28
28
|
|
|
29
29
|
1. **New resource, or editing an existing one?** Existing ⇒ **NEVER re-run `pu:res:scaffold`** (it overwrites the customized model/definition/policy/controller). Add an incremental migration + hand-edit. Confirm by reading the files *first*.
|
|
30
30
|
2. **Destination & portal.** `--dest=main_app` or a package? Which portal does `pu:res:conn` wire it to? Both are **required** and unguessable from the request.
|
|
31
|
-
3. **Field types
|
|
32
|
-
4. **Referenced associations must already exist.** `category:belongs_to` silently targets a `Category
|
|
33
|
-
5. **Beyond columns:** does it need search / filters / scopes / custom or bulk actions? Those live in the definition + policy, not the scaffold
|
|
31
|
+
3. **Field types, and the money question.** A monetary field ⇒ `has_cents` (`price_cents:integer` + `has_cents :price_cents`, reference `:price`), **never a bare `decimal`**. Enums, attachments (`:attachment`/`:attachments`), rich text, references each have specific syntax (§ Field Type Syntax). Confirm types rather than inventing them.
|
|
32
|
+
4. **Referenced associations must already exist.** `category:belongs_to` silently targets a `Category`; if that model isn't there, scaffold it first.
|
|
33
|
+
5. **Beyond columns:** does it need search / filters / scopes / custom or bulk actions? Those live in the definition + policy, not the scaffold; name them now so you don't half-build.
|
|
34
34
|
|
|
35
35
|
**Never emit applied scaffold commands from a guessed `--dest`, portal, or money-shape.** Confirm or read them first; fall back to `AskUserQuestion` only for product choices you can't read off the code (which portal, is `price` money). The decisions compound: *existing+customized ⇒ migration not scaffold*; *money ⇒ `has_cents` + `:price` in the policy*; *new reference ⇒ target model must exist*.
|
|
36
36
|
|
|
37
|
-
## ✅ Before you touch files: verify the ground truth (CHECK
|
|
37
|
+
## ✅ Before you touch files: verify the ground truth (CHECK: read it, don't ask for it)
|
|
38
38
|
|
|
39
|
-
You have file access
|
|
39
|
+
You have file access: **use it.** "Paste me the model" is a fallback for when you genuinely can't read the repo, not the default.
|
|
40
40
|
|
|
41
41
|
| Check | How | Why it matters |
|
|
42
42
|
|---|---|---|
|
|
@@ -49,7 +49,7 @@ You have file access — **use it.** "Paste me the model" is a fallback for when
|
|
|
49
49
|
|
|
50
50
|
Inspect with your own tools **before** proposing commands or edits.
|
|
51
51
|
|
|
52
|
-
## 🛠 Use the generator
|
|
52
|
+
## 🛠 Use the generator, and don't clobber
|
|
53
53
|
|
|
54
54
|
Never hand-write the initial model, migration, policy, definition, or controller. Reach for the generator; quote args with `?`/`{}`; pass `--dest=`.
|
|
55
55
|
|
|
@@ -57,23 +57,32 @@ Never hand-write the initial model, migration, policy, definition, or controller
|
|
|
57
57
|
|---|---|---|
|
|
58
58
|
| New resource | `pu:res:scaffold Model field:type … --dest=` | `--dest` confirmed; referenced models exist |
|
|
59
59
|
| Connect to a portal | `pu:res:conn Model --dest=portal` | Migrations are run |
|
|
60
|
-
|
|
|
61
|
-
|
|
|
60
|
+
| Portal-specific policy or definition | `pu:res:conn Model --dest=portal --policy` (and/or `--definition`) | Base policy/definition exists; the override subclasses it |
|
|
61
|
+
| Regenerate model from columns | `pu:res:scaffold Model --no-migration` | ⚠ **regenerates the model file**: review the diff; overwrites customizations |
|
|
62
|
+
| Add a field to an **existing, customized** resource | `rails g migration AddXToYs …` + hand-edit model/definition/policy | This resource was already scaffolded; re-scaffolding clobbers it |
|
|
62
63
|
|
|
63
64
|
---
|
|
64
65
|
|
|
65
|
-
# Part 1
|
|
66
|
+
# Part 1: Creating a Resource
|
|
66
67
|
|
|
67
68
|
## Quick checklist
|
|
68
69
|
|
|
69
70
|
1. Pick destination: `--dest=main_app` or `--dest=package_name`.
|
|
70
71
|
2. Run `rails g pu:res:scaffold ResourceName field:type ... --dest=<dest>`.
|
|
71
|
-
3. Review the generated migration
|
|
72
|
+
3. Review the generated migration: add cascade deletes, composite indexes, defaults.
|
|
72
73
|
4. `rails db:prepare`.
|
|
73
74
|
5. `rails g pu:res:conn ResourceName --dest=<portal_name>`.
|
|
74
75
|
6. Customize the policy's `permitted_attributes_for_*` as needed.
|
|
75
76
|
7. Open the portal route in the browser.
|
|
76
77
|
|
|
78
|
+
**`permitted_associations` is checked per portal.** Each association listed there becomes a show-page tab, and the child model must be a registered resource in the portal rendering the page (`registered_resources` is the current engine's register). A base policy shared by several portals that names a child only one of them registers breaks the show page in the others:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
ArgumentError: Catalog::Product#reviews defined in #permitted_associations, but Catalog::Review is not a registered resource
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Register the child in every portal that uses the policy, or move the association into a portal-specific policy (`pu:res:conn Model --dest=portal --policy`, then `def permitted_associations = [*super, :reviews]`). See [[plutonium-behavior]].
|
|
85
|
+
|
|
77
86
|
## Command Syntax
|
|
78
87
|
|
|
79
88
|
```bash
|
|
@@ -194,9 +203,9 @@ price_cents:integer # use with has_cents in model
|
|
|
194
203
|
|
|
195
204
|
## Generator Options
|
|
196
205
|
|
|
197
|
-
- `--dest=DESTINATION
|
|
198
|
-
- `--no-model
|
|
199
|
-
- `--no-migration
|
|
206
|
+
- `--dest=DESTINATION`: `main_app` or `package_name` (**required**)
|
|
207
|
+
- `--no-model`: skip model file
|
|
208
|
+
- `--no-migration`: skip migration
|
|
200
209
|
|
|
201
210
|
For existing models that already include `Plutonium::Resource::Record`:
|
|
202
211
|
|
|
@@ -204,7 +213,7 @@ For existing models that already include `Plutonium::Resource::Record`:
|
|
|
204
213
|
rails g pu:res:scaffold Post --no-migration --dest=main_app
|
|
205
214
|
```
|
|
206
215
|
|
|
207
|
-
Run with no fields to auto-import from `model.content_columns` (regenerates the model file
|
|
216
|
+
Run with no fields to auto-import from `model.content_columns` (regenerates the model file; review the diff).
|
|
208
217
|
|
|
209
218
|
## What Gets Generated
|
|
210
219
|
|
|
@@ -239,6 +248,8 @@ For non-trivial defaults, edit the migration directly:
|
|
|
239
248
|
t.datetime :published_at, default: -> { "CURRENT_TIMESTAMP" }
|
|
240
249
|
```
|
|
241
250
|
|
|
251
|
+
**Migration version: copy the app's form.** The generators (and `rails g migration`) stamp `ActiveRecord::Migration[<current version>]`, the Rails version that ran them. When you hand-write or edit a migration, open a neighbouring one and match its superclass rather than typing a version. An engine or gem whose test app runs under several Rails versions (Appraisal) often uses `ActiveRecord::Migration[[Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join(".").to_f]` or a pinned older version, and a hard-coded `[8.1]` raises on load under Rails 7.x or 8.0.
|
|
252
|
+
|
|
242
253
|
## Examples
|
|
243
254
|
|
|
244
255
|
```bash
|
|
@@ -268,7 +279,7 @@ rails g pu:res:scaffold Comment \
|
|
|
268
279
|
|
|
269
280
|
---
|
|
270
281
|
|
|
271
|
-
# Part 2
|
|
282
|
+
# Part 2: The Model Layer
|
|
272
283
|
|
|
273
284
|
## What `Plutonium::Resource::Record` provides
|
|
274
285
|
|
|
@@ -296,7 +307,7 @@ end
|
|
|
296
307
|
|
|
297
308
|
## Section Order
|
|
298
309
|
|
|
299
|
-
The scaffold lays out resource models in a strict order
|
|
310
|
+
The scaffold lays out resource models in a strict order: keep new code in the right section so files stay scannable:
|
|
300
311
|
|
|
301
312
|
1. Concerns (`include`)
|
|
302
313
|
2. Constants (`TYPES = {...}.freeze`)
|
|
@@ -370,7 +381,7 @@ product.price = 10.999
|
|
|
370
381
|
product.price_cents # => 1099
|
|
371
382
|
```
|
|
372
383
|
|
|
373
|
-
**Critical: in policies and definitions, reference the virtual accessor (`:price`), NOT the column (`:price_cents`).** Generators sometimes emit `_cents` in the policy
|
|
384
|
+
**Critical: in policies and definitions, reference the virtual accessor (`:price`), NOT the column (`:price_cents`).** Generators sometimes emit `_cents` in the policy, so fix by hand:
|
|
374
385
|
|
|
375
386
|
```ruby
|
|
376
387
|
# Policy
|
|
@@ -409,7 +420,7 @@ post.remove_tag_sgid("...") # collection: remove
|
|
|
409
420
|
|
|
410
421
|
## URL Routing
|
|
411
422
|
|
|
412
|
-
`path_parameter` and `dynamic_path_parameter` are **class-level macros** (private class methods)
|
|
423
|
+
`path_parameter` and `dynamic_path_parameter` are **class-level macros** (private class methods): call them in the class body, not as instance methods.
|
|
413
424
|
|
|
414
425
|
```ruby
|
|
415
426
|
# Default: numeric id
|
|
@@ -457,11 +468,11 @@ User.has_many_attached_field_names
|
|
|
457
468
|
|
|
458
469
|
---
|
|
459
470
|
|
|
460
|
-
# Part 3
|
|
471
|
+
# Part 3: The Definition Layer
|
|
461
472
|
|
|
462
473
|
Definitions configure **how** a resource is rendered and interacted with.
|
|
463
474
|
|
|
464
|
-
🚨 **Do NOT declare a `field` / `input` / `display` / `column` unless you are overriding an auto-detected default.** Plutonium reads the model and renders every attribute automatically
|
|
475
|
+
🚨 **Do NOT declare a `field` / `input` / `display` / `column` unless you are overriding an auto-detected default.** Plutonium reads the model and renders every attribute automatically: type, label, form widget, display formatter, column. Declaring it again with no new options is dead code; declaring it with the same `as:` Plutonium already inferred is dead code; listing every field "for completeness" is dead code. If the only reason you're adding a line is "so the field shows up", delete it; it already shows up. Declare ONLY when you need: a different type (`as: :markdown`), a custom option (`hint:`, `placeholder:`, `wrapper:`), a `condition:`, a custom block, or a custom component.
|
|
465
476
|
|
|
466
477
|
File locations:
|
|
467
478
|
|
|
@@ -535,7 +546,7 @@ The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) ar
|
|
|
535
546
|
| Text | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
|
|
536
547
|
| Rich Text | `:markdown` (EasyMDE) |
|
|
537
548
|
| Numeric | `:number`, `:integer`, `:decimal`, `:range` |
|
|
538
|
-
| Boolean | `:toggle` / `:switch` (switch
|
|
549
|
+
| Boolean | `:toggle` / `:switch` (switch, **default** for boolean columns), `:boolean` (plain checkbox) |
|
|
539
550
|
| Date/Time | `:date`, `:time`, `:datetime` |
|
|
540
551
|
| Selection | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
|
|
541
552
|
| Files | `:file`, `:uppy`, `:attachment` |
|
|
@@ -548,7 +559,7 @@ The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) ar
|
|
|
548
559
|
|
|
549
560
|
#### Auto-inferred display formatting
|
|
550
561
|
|
|
551
|
-
These render automatically
|
|
562
|
+
These render automatically: declare an `as:` only to override or pass options:
|
|
552
563
|
|
|
553
564
|
| Column | Renders as | Notes |
|
|
554
565
|
|--------|-----------|-------|
|
|
@@ -592,7 +603,7 @@ input :title,
|
|
|
592
603
|
input :category, as: :select, choices: %w[Tech Business Lifestyle]
|
|
593
604
|
input :status, as: :select, choices: Post.statuses.keys
|
|
594
605
|
|
|
595
|
-
# Dynamic
|
|
606
|
+
# Dynamic: must use a block
|
|
596
607
|
input :author do |f|
|
|
597
608
|
f.select_tag choices: User.active.pluck(:name, :id)
|
|
598
609
|
end
|
|
@@ -624,24 +635,24 @@ A surface's own `condition:` wins; otherwise the surface falls back to the `fiel
|
|
|
624
635
|
|
|
625
636
|
## Options That Vary Per Render
|
|
626
637
|
|
|
627
|
-
Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL
|
|
638
|
+
Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL: `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:
|
|
628
639
|
|
|
629
640
|
```ruby
|
|
630
641
|
input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
|
|
631
642
|
input :notes, placeholder: -> { "Updated #{Time.current.year}" }
|
|
632
643
|
```
|
|
633
644
|
|
|
634
|
-
- `-> { … }` is called as-is, keeping its own binding
|
|
635
|
-
- `->(form) { … }` gets the form
|
|
645
|
+
- `-> { … }` is called as-is, keeping its own binding: it means what it reads like where you wrote it; nothing rebinds `self`. That is what makes `choices: -> { reviewer_choices }` work inside an interaction's `customize_inputs` (private helpers included).
|
|
646
|
+
- `->(form) { … }` gets the form: `object` (the record), `params`, view helpers.
|
|
636
647
|
|
|
637
|
-
Same rule on wizard steps
|
|
648
|
+
Same rule on wizard steps, but a step block closes over an internal field recorder, so options there must take the form: `->(form) { form.wizard.anchor.tiers }`. See [[plutonium-wizard]].
|
|
638
649
|
|
|
639
|
-
**`condition:` is not an option
|
|
650
|
+
**`condition:` is not an option: it follows a different rule, for a reason.** An option asks "what value should this have?", so it may not care about the render and defaults to meaning what it reads like. `condition:` asks "should this render *here, now*?", a question about the render context by definition. So it always runs **against** that context and reads it with no argument, where "context" is whatever is rendering:
|
|
640
651
|
|
|
641
652
|
```ruby
|
|
642
653
|
input :notes, condition: -> { object.published? } # the form
|
|
643
654
|
display :audit_log, condition: -> { current_user.admin? } # the display component
|
|
644
|
-
step :billing, condition: -> { data.plan.tier == "pro" } # the wizard
|
|
655
|
+
step :billing, condition: -> { data.plan.tier == "pro" } # the wizard, no form exists yet
|
|
645
656
|
```
|
|
646
657
|
|
|
647
658
|
It cannot take a `form` argument the way an option does: for a `column`/`display`, a step, or an action there is no form.
|
|
@@ -689,7 +700,7 @@ class QuestionDefinition < ResourceDefinition
|
|
|
689
700
|
choices: %w[text choice scale],
|
|
690
701
|
pre_submit: true
|
|
691
702
|
|
|
692
|
-
# No `as
|
|
703
|
+
# No `as:`: types are auto-detected from the model. We only declare to add `condition:`.
|
|
693
704
|
input :max_length, condition: -> { object.question_type == "text" }
|
|
694
705
|
input :choices, condition: -> { object.question_type == "choice" }
|
|
695
706
|
input :min_value, condition: -> { object.question_type == "scale" }
|
|
@@ -716,7 +727,7 @@ Tips:
|
|
|
716
727
|
|
|
717
728
|
## Custom Rendering
|
|
718
729
|
|
|
719
|
-
**Display block
|
|
730
|
+
**Display block: return any component:**
|
|
720
731
|
|
|
721
732
|
```ruby
|
|
722
733
|
display :status do |field|
|
|
@@ -724,7 +735,7 @@ display :status do |field|
|
|
|
724
735
|
end
|
|
725
736
|
```
|
|
726
737
|
|
|
727
|
-
**Input block
|
|
738
|
+
**Input block: must use form builder methods:**
|
|
728
739
|
|
|
729
740
|
```ruby
|
|
730
741
|
input :birth_date do |f|
|
|
@@ -762,14 +773,14 @@ end
|
|
|
762
773
|
|
|
763
774
|
See [[plutonium-ui]] for writing custom Phlex components.
|
|
764
775
|
|
|
765
|
-
**Custom component classes** (Phlex components
|
|
776
|
+
**Custom component classes** (Phlex components, see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)`: subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:
|
|
766
777
|
|
|
767
778
|
```ruby
|
|
768
779
|
input :color_picker, as: ColorPickerComponent
|
|
769
780
|
display :chart, as: ChartComponent
|
|
770
781
|
```
|
|
771
782
|
|
|
772
|
-
🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate
|
|
783
|
+
🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate; it raises `ArgumentError`. Build it in a block instead:
|
|
773
784
|
|
|
774
785
|
```ruby
|
|
775
786
|
display :card do |field|
|
|
@@ -783,10 +794,10 @@ end
|
|
|
783
794
|
column :title, align: :start # :start (default), :center, :end
|
|
784
795
|
column :amount, align: :end
|
|
785
796
|
|
|
786
|
-
# formatter
|
|
797
|
+
# formatter: receives just the value
|
|
787
798
|
column :price, formatter: ->(v) { "$%.2f" % v if v }
|
|
788
799
|
|
|
789
|
-
# block
|
|
800
|
+
# block: receives the full record
|
|
790
801
|
column :full_name do |record|
|
|
791
802
|
"#{record.first_name} #{record.last_name}"
|
|
792
803
|
end
|
|
@@ -841,7 +852,7 @@ end
|
|
|
841
852
|
|--------|-------------|
|
|
842
853
|
| `limit` | Max records (auto-detected from model, default 10) |
|
|
843
854
|
| `allow_destroy` | Show delete checkbox (auto-detected) |
|
|
844
|
-
| `update_only` | Hide "Add" button
|
|
855
|
+
| `update_only` | Hide "Add" button, only edit existing |
|
|
845
856
|
| `description` | Help text above section |
|
|
846
857
|
| `condition` | Proc to show/hide |
|
|
847
858
|
| `using` | Another Definition class |
|
|
@@ -857,7 +868,7 @@ end
|
|
|
857
868
|
|
|
858
869
|
## Structured Inputs
|
|
859
870
|
|
|
860
|
-
`structured_input` collects a **classless** group of fields
|
|
871
|
+
`structured_input` collects a **classless** group of fields: a single hash, or
|
|
861
872
|
(with `repeat:`) an array of hashes. No association or model class is involved.
|
|
862
873
|
On resources the value is stored in a **JSON/jsonb column**; use it when you
|
|
863
874
|
want structured data in a JSON column rather than a real association (which is
|
|
@@ -901,14 +912,14 @@ blank rows are dropped, `_destroy` stripped).
|
|
|
901
912
|
|
|
902
913
|
### Gotchas
|
|
903
914
|
|
|
904
|
-
- The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed
|
|
915
|
+
- The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed; the value assigns directly.
|
|
905
916
|
- **Unlike `nested_input`, you DO permit the column name** in `permitted_attributes_for_*` (it's a regular attribute on a JSON column).
|
|
906
|
-
- `repeat: 1` is "array, max one row"
|
|
907
|
-
- Rows are positional plain hashes
|
|
917
|
+
- `repeat: 1` is "array, max one row", **not** the single form. Presence of `repeat:` always means an array.
|
|
918
|
+
- Rows are positional plain hashes: **no ids, no per-row class, no type coercion**.
|
|
908
919
|
- **No automatic validation.** Classless ⇒ nothing to attach `validates` to. `required:` and a select's `choices:` are **client-side only**, not enforced on the server. To enforce, add a model `validate` (resource) or a `validate` on the interaction (ActiveModel, checked before `execute`).
|
|
909
920
|
- **`as: :select` drops unknown values.** If a stored value isn't in `choices:`, the `<select>` renders blank and **saving overwrites it with `nil`** (standard `<select>` behaviour). Keep `choices:` a stable superset or use free text when values can drift.
|
|
910
|
-
- Inside repeater rows, prefer **native** field types (string, number, text, native `select`, checkbox). JS-enhanced inputs (slim-select, flatpickr, easymde, uppy, intl-tel) transform the DOM and may not survive the repeater's clone-by-innerHTML
|
|
911
|
-
- Same DSL works on **interactions** (see [[plutonium-behavior]] › Interactions)
|
|
921
|
+
- Inside repeater rows, prefer **native** field types (string, number, text, native `select`, checkbox). JS-enhanced inputs (slim-select, flatpickr, easymde, uppy, intl-tel) transform the DOM and may not survive the repeater's clone-by-innerHTML; verify before relying on them.
|
|
922
|
+
- Same DSL works on **interactions** (see [[plutonium-behavior]] › Interactions); there it backs an ActiveModel attribute reaching `execute`.
|
|
912
923
|
|
|
913
924
|
## File Uploads
|
|
914
925
|
|
|
@@ -925,9 +936,9 @@ input :documents, as: :uppy,
|
|
|
925
936
|
|
|
926
937
|
Inside `condition` procs and block-form `input`/`display`:
|
|
927
938
|
|
|
928
|
-
- `object
|
|
939
|
+
- `object`: the record
|
|
929
940
|
- `current_user`
|
|
930
|
-
- `current_parent
|
|
941
|
+
- `current_parent`: for nested resources
|
|
931
942
|
- `request`, `params`
|
|
932
943
|
- All helper methods
|
|
933
944
|
|
|
@@ -964,7 +975,7 @@ class PostDefinition < ResourceDefinition
|
|
|
964
975
|
breadcrumbs true
|
|
965
976
|
show_page_breadcrumbs false
|
|
966
977
|
|
|
967
|
-
# Custom page classes
|
|
978
|
+
# Custom page classes: inherit from the parent's nested class
|
|
968
979
|
class IndexPage < IndexPage
|
|
969
980
|
def view_template(&block)
|
|
970
981
|
div(class: "custom-header") { h1 { "Custom" } }
|
|
@@ -988,7 +999,7 @@ end
|
|
|
988
999
|
|
|
989
1000
|
## Form Layout (`form_layout`)
|
|
990
1001
|
|
|
991
|
-
Group form fields into sections **declaratively in the definition
|
|
1002
|
+
Group form fields into sections **declaratively in the definition**, no `Form` subclass, no view code. Prefer this over hand-rolling a `section` helper in a custom `form_template`.
|
|
992
1003
|
|
|
993
1004
|
```ruby
|
|
994
1005
|
class PostDefinition < ResourceDefinition
|
|
@@ -1002,18 +1013,18 @@ class PostDefinition < ResourceDefinition
|
|
|
1002
1013
|
end
|
|
1003
1014
|
```
|
|
1004
1015
|
|
|
1005
|
-
- **Layout references field KEYS only
|
|
1006
|
-
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option
|
|
1016
|
+
- **Layout references field KEYS only**: all per-field config (`as:`, `hint:`, blocks, per-field `condition:`) stays on `input`. Never duplicated here.
|
|
1017
|
+
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option: take a `form` argument to read the render context.
|
|
1007
1018
|
- ⚠️ **Breaking in 0.63**: section options used to take a zero-arg proc run *against* the form. They now follow the shared rule, and a `form_layout` block is evaluated against the layout builder, so a bare `object` is a `NameError`. Migrate `collapsed: -> { object.persisted? }` → `collapsed: ->(form) { form.object.persisted? }`. `condition:` is unchanged (still form-evaluated, still reads `object` with no argument).
|
|
1008
|
-
- **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped
|
|
1009
|
-
- **🚨 Zero-field sections drop entirely
|
|
1010
|
-
- **Works on interactions too** (`Plutonium::Interaction::Base`)
|
|
1019
|
+
- **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped, never an error. The same layout serves a richly-permitted `edit` and a minimal `new`.
|
|
1020
|
+
- **🚨 Zero-field sections drop entirely**: no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. A section whose fields are **all** hidden by their own `condition:` on this render drops too (the hidden fields are still recorded on the form). A section's own `condition:` hides it as a unit regardless of its fields.
|
|
1021
|
+
- **Works on interactions too** (`Plutonium::Interaction::Base`): groups `attribute` declarations. There `object` is the interaction instance; for record actions the record is `object.resource`.
|
|
1011
1022
|
|
|
1012
1023
|
Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
|
|
1013
1024
|
|
|
1014
1025
|
## Display Layout (`display_layout`)
|
|
1015
1026
|
|
|
1016
|
-
The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field and all-condition-hidden sections dropped)
|
|
1027
|
+
The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field and all-condition-hidden sections dropped), applied to the show page's fields instead of the form's.
|
|
1017
1028
|
|
|
1018
1029
|
```ruby
|
|
1019
1030
|
class PostDefinition < ResourceDefinition
|
|
@@ -1025,14 +1036,14 @@ class PostDefinition < ResourceDefinition
|
|
|
1025
1036
|
end
|
|
1026
1037
|
```
|
|
1027
1038
|
|
|
1028
|
-
- **Declare both independently.** `form_layout` and `display_layout` are separate registries
|
|
1029
|
-
- **🚨 No `columns
|
|
1030
|
-
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `condition
|
|
1031
|
-
- **Each section renders as its own card**, so the sectioned show page has no single outer card. Fields declared in `metadata` are excluded (they render in the metadata panel)
|
|
1039
|
+
- **Declare both independently.** `form_layout` and `display_layout` are separate registries: a resource can group its form one way and its show page another, or declare only one. Neither inherits from the other.
|
|
1040
|
+
- **🚨 No `columns:`.** Unlike `form_layout`, it **raises**. Every display section shares one responsive grid; field width is a per-field concern: `display :x, wrapper: {class: "col-span-2"}` (works identically inside a section and outside one). Raising rather than ignoring means a copied `form_layout` block fails loudly instead of silently doing nothing.
|
|
1041
|
+
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:`, the same set as `form_layout` minus `columns:`. `collapsible: true, collapsed: true` works exactly as it does on forms. Every option except `condition:` may be a **proc**, resolved at render under the same arity rule as the form: take a `display` argument to read `object`.
|
|
1042
|
+
- **Each section renders as its own card**, so the sectioned show page has no single outer card. Fields declared in `metadata` are excluded (they render in the metadata panel); see below.
|
|
1032
1043
|
|
|
1033
1044
|
## Page Width (`page_width`)
|
|
1034
1045
|
|
|
1035
|
-
Detail-style pages
|
|
1046
|
+
Detail-style pages (the show page and resource forms) are width-constrained by default. Inputs and values stretch to their container, so at full content width you get ~1200px-long lines. Index/table pages are NOT affected.
|
|
1036
1047
|
|
|
1037
1048
|
```ruby
|
|
1038
1049
|
Plutonium.configure { |c| c.default_page_width = :md } # global default (:md)
|
|
@@ -1045,12 +1056,12 @@ end
|
|
|
1045
1056
|
```
|
|
1046
1057
|
|
|
1047
1058
|
- **Sizes**: `:sm` `:md` `:lg` `:xl` `:full`. `:full` means no constraint. An unknown value **raises** at declaration.
|
|
1048
|
-
- **🚨 Tokens are relative to their surface
|
|
1059
|
+
- **🚨 Tokens are relative to their surface**: the same *names* modals use, but NOT the same widths. Page `:md` is 896px; a centered modal's `:md` is 576px and a slideover's is 480px. A "small page" is deliberately larger than a "small dialog". Modals also have `:auto`; pages don't (nothing to hug).
|
|
1049
1060
|
- **Resolution**: surface-specific (`form_width` / `display_width`) → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is honoured, not treated as unset.
|
|
1050
1061
|
- **Inherits** to subclasses, so a portal-specific definition keeps the parent's width unless it overrides.
|
|
1051
|
-
- **Modals are unaffected
|
|
1062
|
+
- **Modals are unaffected**: the dialog sets its own width (`modal_size`).
|
|
1052
1063
|
- **Interactions support it too** (`Plutonium::Interaction::Base`), for interactive actions rendered as standalone pages.
|
|
1053
|
-
- **Wizards are on their own axis
|
|
1064
|
+
- **Wizards are on their own axis**: `Plutonium.configuration.wizards.width` (default `:md`), overridden per wizard with `width`. It does NOT follow `default_page_width`, so changing resource page width leaves wizards untouched.
|
|
1054
1065
|
|
|
1055
1066
|
## Metadata Panel (show page)
|
|
1056
1067
|
|
|
@@ -1060,12 +1071,12 @@ Declares fields rendered in the show page's right-side aside as label/value rows
|
|
|
1060
1071
|
metadata :author, :state, :created_at, :updated_at
|
|
1061
1072
|
```
|
|
1062
1073
|
|
|
1063
|
-
- **Opt-in
|
|
1064
|
-
- **Policy-aware
|
|
1065
|
-
- **Deduplicated
|
|
1066
|
-
- **Responsive
|
|
1074
|
+
- **Opt-in**: no call → show page is full-width with no aside.
|
|
1075
|
+
- **Policy-aware**: fields the user can't see disappear; panel auto-hides if nothing's permitted.
|
|
1076
|
+
- **Deduplicated**: listed fields are removed from the main details card (and from any `display_layout` section).
|
|
1077
|
+
- **Responsive**: side-by-side at `lg+`, stacked below.
|
|
1067
1078
|
- **In a modal it stacks below the details**, not beside them: the rail is a fixed-width column on a *viewport* breakpoint, so in a dialog it would split regardless of how narrow the dialog is and crush the main column.
|
|
1068
|
-
- **A kanban card's modal drops metadata entirely
|
|
1079
|
+
- **A kanban card's modal drops metadata entirely**: the fields are hidden, not folded into the main card.
|
|
1069
1080
|
|
|
1070
1081
|
Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
|
|
1071
1082
|
|
|
@@ -1075,7 +1086,7 @@ Resources can offer both Table and Grid views; user choice persists per-resource
|
|
|
1075
1086
|
|
|
1076
1087
|
```ruby
|
|
1077
1088
|
class UserDefinition < ResourceDefinition
|
|
1078
|
-
# No `index_views :table, :grid` needed
|
|
1089
|
+
# No `index_views :table, :grid` needed, `grid_fields` auto-enables :grid alongside the default :table.
|
|
1079
1090
|
grid_fields(
|
|
1080
1091
|
image: :avatar, # ActiveStorage, Shrine, or URL
|
|
1081
1092
|
header: :name, # falls back to to_label
|
|
@@ -1085,7 +1096,7 @@ class UserDefinition < ResourceDefinition
|
|
|
1085
1096
|
footer: :last_seen_at # falls back to :created_at; `false` to omit
|
|
1086
1097
|
)
|
|
1087
1098
|
|
|
1088
|
-
default_index_view :grid # optional
|
|
1099
|
+
default_index_view :grid # optional: initial view when no cookie
|
|
1089
1100
|
grid_layout :media # :compact (default) or :media
|
|
1090
1101
|
grid_columns 3 # pin lg+ cols; default is 1/2/3/4 responsive
|
|
1091
1102
|
end
|
|
@@ -1107,23 +1118,23 @@ All grid slots are optional; slots pointing at unpermitted fields collapse silen
|
|
|
1107
1118
|
|
|
1108
1119
|
## Drag-to-Reorder (`positioned_on` + `position_on`)
|
|
1109
1120
|
|
|
1110
|
-
Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three
|
|
1121
|
+
Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three**: the model says how positions are stored, the definition (and a kanban board) says the UI is orderable:
|
|
1111
1122
|
|
|
1112
1123
|
```ruby
|
|
1113
|
-
# Migration
|
|
1124
|
+
# Migration: t.position emits decimal(16,8), tuned for fractional ordering.
|
|
1114
1125
|
create_table :tasks do |t|
|
|
1115
1126
|
t.string :status, null: false, default: "todo"
|
|
1116
1127
|
t.position
|
|
1117
1128
|
t.index [:status, :position] # match the scope attribute
|
|
1118
1129
|
end
|
|
1119
1130
|
|
|
1120
|
-
# Model
|
|
1131
|
+
# Model: storage
|
|
1121
1132
|
class Task < ApplicationRecord
|
|
1122
1133
|
include Plutonium::Positioning::Model # NOT Plutonium::Positioning
|
|
1123
1134
|
positioned_on :position, scope: :status # scope: nil = one global ordering
|
|
1124
1135
|
end
|
|
1125
1136
|
|
|
1126
|
-
# Definition
|
|
1137
|
+
# Definition: "this UI can be reordered". Never restates the column or the scope.
|
|
1127
1138
|
class TaskDefinition < ResourceDefinition
|
|
1128
1139
|
position_on
|
|
1129
1140
|
end
|
|
@@ -1132,45 +1143,45 @@ end
|
|
|
1132
1143
|
Task.backfill_positions!(order: :created_at)
|
|
1133
1144
|
```
|
|
1134
1145
|
|
|
1135
|
-
- **`include Plutonium::Positioning::Model
|
|
1136
|
-
- **`positioned_on` is required**, not just the include
|
|
1146
|
+
- **`include Plutonium::Positioning::Model`**: the concern used to be `Plutonium::Positioning` itself. A bare `include Plutonium::Positioning` is now wrong (it's a pure namespace). Constants nested in an included concern join the model's constant lookup, so the old form let `Plutonium::Positioning::Config` shadow an app's own `::Config`.
|
|
1147
|
+
- **`positioned_on` is required**, not just the include; without it there's no `before_create`, so every row is created with a `NULL` position. `position_on` raises at class-load if you forget.
|
|
1137
1148
|
|
|
1138
1149
|
### `position_on` forms and modes
|
|
1139
1150
|
|
|
1140
1151
|
| Form | Mode | Notes |
|
|
1141
1152
|
|---|---|---|
|
|
1142
|
-
| `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this
|
|
1153
|
+
| `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this**: it cannot disagree with the model. |
|
|
1143
1154
|
| `position_on :sort_order` | A | Must **match** the model's column, else `ArgumentError` at class-load |
|
|
1144
|
-
| `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch
|
|
1145
|
-
| `position_on false` | C (disabled) | No ordering, no route
|
|
1155
|
+
| `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch: another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
|
|
1156
|
+
| `position_on false` | C (disabled) | No ordering, no route, the endpoint 404s |
|
|
1146
1157
|
|
|
1147
|
-
**Default to Mode A.** It is one word in the definition. A *correct* Mode B block is ~15 lines of rank arithmetic, and getting it right requires knowing three non-obvious things: `move.index` is page-relative; removing a record shifts its neighbours' ranks by one, in a direction that depends on where it started; and a blank `move.prev` means "nothing above me *on screen*", not "top of the list". These docs got two of the three wrong until they were tested. Mode B is legitimate and tested
|
|
1158
|
+
**Default to Mode A.** It is one word in the definition. A *correct* Mode B block is ~15 lines of rank arithmetic, and getting it right requires knowing three non-obvious things: `move.index` is page-relative; removing a record shifts its neighbours' ranks by one, in a direction that depends on where it started; and a blank `move.prev` means "nothing above me *on screen*", not "top of the list". These docs got two of the three wrong until they were tested. Mode B is legitimate and tested; it just costs you semantics Mode A handles.
|
|
1148
1159
|
|
|
1149
|
-
### Mode B
|
|
1160
|
+
### Mode B: what the framework stops doing
|
|
1150
1161
|
|
|
1151
1162
|
Mode B block receives a `Plutonium::Positioning::Move`: `record`, `prev`, `next`, `index` (0-based, **relative to the visible page**), `column` (kanban only, `nil` on tables/grids). Called with `call`, not `instance_exec`.
|
|
1152
1163
|
|
|
1153
1164
|
Because the write is opaque, three Mode A behaviours are **not** provided:
|
|
1154
1165
|
|
|
1155
|
-
- **No hidden boundary resolution
|
|
1156
|
-
- **No server-side foreign-sort rejection
|
|
1157
|
-
- **Always a full repaint
|
|
1166
|
+
- **No hidden boundary resolution**: `resolve_position_boundaries` returns early unless the config delegates, so the block gets the client's viewport verbatim, `nil`s and all.
|
|
1167
|
+
- **No server-side foreign-sort rejection**: Mode A rejects a drop under a foreign sort with 422 before writing; Mode B relies on the client-side gate only.
|
|
1168
|
+
- **Always a full repaint**: never 204. Gems like `acts_as_list` renumber the whole group on every move, so the client's optimistic DOM is stale by definition.
|
|
1158
1169
|
|
|
1159
1170
|
### Migrating off `acts_as_list` to Mode A
|
|
1160
1171
|
|
|
1161
|
-
1. **Change the column
|
|
1162
|
-
2. **Swap the macro
|
|
1163
|
-
3. **Backfill
|
|
1172
|
+
1. **Change the column**: `acts_as_list` uses contiguous integers; Plutonium uses fractional decimals (`t.position` emits `decimal(16,8)`; an integer column would round every midpoint onto a neighbour). `t.position` *adds* a column, so an existing one needs `change_column :tasks, :position, :decimal, precision: 16, scale: 8`.
|
|
1173
|
+
2. **Swap the macro**: drop `acts_as_list scope: [:status]`, add `include Plutonium::Positioning::Model` + `positioned_on :position, scope: :status` (bare Symbol; the Array trap is gem-specific).
|
|
1174
|
+
3. **Backfill**: `Task.backfill_positions!(order: :position)` numbers each scope group `1.0, 2.0, …` in the gem's existing order. `update_column`, no callbacks/validations/`updated_at`; run it once from a migration or `rails runner`.
|
|
1164
1175
|
4. Drop the block from the definition; a bare `position_on` is the whole of Mode A.
|
|
1165
1176
|
|
|
1166
1177
|
### Staying on `acts_as_list` (the harder road)
|
|
1167
1178
|
|
|
1168
1179
|
For when the gem is not yours to remove. This recipe is correct and tested against the real gem (`test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb`).
|
|
1169
1180
|
|
|
1170
|
-
🚨 **Anchor off `move.prev` / `move.next`, never off `move.index`.** `move.index` counts the visible page; a positioning gem's `insert_at` addresses the whole group. `insert_at(move.index + 1)` is wrong on any list past 20 rows (Plutonium's default page size)
|
|
1181
|
+
🚨 **Anchor off `move.prev` / `move.next`, never off `move.index`.** `move.index` counts the visible page; a positioning gem's `insert_at` addresses the whole group. `insert_at(move.index + 1)` is wrong on any list past 20 rows (Plutonium's default page size). Measured: dragging rank 25 into the middle of page 2 lands it at **rank 2**, and a page-2 top drop lands at **rank 1**. Same failures on a filtered list.
|
|
1171
1182
|
|
|
1172
1183
|
```ruby
|
|
1173
|
-
# Keeping acts_as_list. NOTE scope: [:status]
|
|
1184
|
+
# Keeping acts_as_list. NOTE scope: [:status], a bare Symbol scope is run
|
|
1174
1185
|
# through acts_as_list's `idify`, which turns :status into :status_id and makes
|
|
1175
1186
|
# every create raise NoMethodError.
|
|
1176
1187
|
class Task < ApplicationRecord
|
|
@@ -1186,7 +1197,7 @@ class TaskDefinition < ResourceDefinition
|
|
|
1186
1197
|
# Removing the record shifts prev up one when the record was above it.
|
|
1187
1198
|
(record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
|
|
1188
1199
|
elsif move.next
|
|
1189
|
-
# Blank prev means "nothing above me ON MY SCREEN"
|
|
1200
|
+
# Blank prev means "nothing above me ON MY SCREEN"; rows may still sit
|
|
1190
1201
|
# above off-page or behind a filter, so anchor off next rather than 1.
|
|
1191
1202
|
(record.position < move.next.position) ? move.next.position - 1 : move.next.position
|
|
1192
1203
|
else
|
|
@@ -1198,7 +1209,7 @@ class TaskDefinition < ResourceDefinition
|
|
|
1198
1209
|
end
|
|
1199
1210
|
```
|
|
1200
1211
|
|
|
1201
|
-
`insert_at` calls `save`, not `save
|
|
1212
|
+
`insert_at` calls `save`, not `save!`; a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
|
|
1202
1213
|
|
|
1203
1214
|
### 🚨 `position_on` expands to three things
|
|
1204
1215
|
|
|
@@ -1208,21 +1219,21 @@ default_sort <attr>, :asc # ⚠ ONLY when default_sort is still the frame
|
|
|
1208
1219
|
action :reposition, hidden: true # route + policy predicate, no button
|
|
1209
1220
|
```
|
|
1210
1221
|
|
|
1211
|
-
**The `default_sort` claim is implicit.** A resource that listed newest-first will list in position order after you add `position_on`. Declare your own `default_sort` (above OR below `position_on
|
|
1222
|
+
**The `default_sort` claim is implicit.** A resource that listed newest-first will list in position order after you add `position_on`. Declare your own `default_sort` (above OR below `position_on`: resolution is order-independent) to keep it; note the list then opens **not** draggable.
|
|
1212
1223
|
|
|
1213
1224
|
### Behavior notes
|
|
1214
1225
|
|
|
1215
1226
|
- **Dragging is offered only while the collection is sorted ascending by the position attribute** (and nothing else). Under any other sort the grip renders as a **link that applies that sort**, and the server rejects the drop with 422 before writing.
|
|
1216
1227
|
- **`reposition?` policy predicate**, defaulting to `update?`. Gates both the drop and whether the grip renders per row. `index?` is also required (you must be able to see a list to reorder it).
|
|
1217
1228
|
- **A kanban board inherits the definition's `position_on`** (lazily, so order in the class body doesn't matter); a `position_on` inside `kanban do…end` overrides it.
|
|
1218
|
-
- **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent
|
|
1229
|
+
- **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent, but a rebalance renumbers every row in the table, not just that parent's.
|
|
1219
1230
|
- Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
|
|
1220
1231
|
|
|
1221
1232
|
Full reference: `docs/reference/resource/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
|
|
1222
1233
|
|
|
1223
1234
|
---
|
|
1224
1235
|
|
|
1225
|
-
# Part 4
|
|
1236
|
+
# Part 4: Query: Search, Filters, Scopes, Sorting
|
|
1226
1237
|
|
|
1227
1238
|
```ruby
|
|
1228
1239
|
class PostDefinition < ResourceDefinition
|
|
@@ -1317,7 +1328,7 @@ default_scope :published # applied on initial load; "All" button clears it
|
|
|
1317
1328
|
|
|
1318
1329
|
### Conditional scopes (`condition:`)
|
|
1319
1330
|
|
|
1320
|
-
Like `condition:` on actions and fields
|
|
1331
|
+
Like `condition:` on actions and fields: define a scope but only **render its button** when a proc is truthy. The scope itself (and its URL) stays live; `condition:` only controls UI visibility.
|
|
1321
1332
|
|
|
1322
1333
|
```ruby
|
|
1323
1334
|
scope :admin_only, condition: -> { current_user.admin? }
|
|
@@ -1325,7 +1336,7 @@ scope :beta_feature, condition: -> { params[:beta] == "1" }
|
|
|
1325
1336
|
scope :never_shown, condition: -> { false } # hides button but URL still works
|
|
1326
1337
|
```
|
|
1327
1338
|
|
|
1328
|
-
The proc is evaluated against the view context
|
|
1339
|
+
The proc is evaluated against the view context: `current_user`, `params`, `request`, `allowed_to?` are all available directly. There is no `object`/`record` (scopes have no single-record context).
|
|
1329
1340
|
|
|
1330
1341
|
🚨 **`condition:` is NOT authorization.** A hidden scope button still has a live URL. Use `condition:` for UI relevance ("show admins only this tab"). Use the policy's `relation_scope` for "who can see these records at all".
|
|
1331
1342
|
|
|
@@ -1353,7 +1364,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
|
1353
1364
|
|
|
1354
1365
|
---
|
|
1355
1366
|
|
|
1356
|
-
# Part 5
|
|
1367
|
+
# Part 5: Actions: Custom and Bulk
|
|
1357
1368
|
|
|
1358
1369
|
## Action Types
|
|
1359
1370
|
|
|
@@ -1365,7 +1376,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
|
1365
1376
|
| `bulk_action: true` | Selected records | Bulk operations |
|
|
1366
1377
|
| `hidden: true` | **Nowhere** | Suppresses all four; route + policy stay live (drag gestures, custom JS) |
|
|
1367
1378
|
|
|
1368
|
-
🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes
|
|
1379
|
+
🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes, so don't declare them manually:**
|
|
1369
1380
|
|
|
1370
1381
|
| Interaction declares | Inferred flags |
|
|
1371
1382
|
|---|---|
|
|
@@ -1373,7 +1384,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
|
1373
1384
|
| `attribute :resources` (plural) | `bulk_action` |
|
|
1374
1385
|
| neither | `resource_action` |
|
|
1375
1386
|
|
|
1376
|
-
User-supplied flags override the inferred ones, but only **opt-out** makes sense for interactive actions
|
|
1387
|
+
User-supplied flags override the inferred ones, but only **opt-out** makes sense for interactive actions: the interaction's `attribute :resource` / `attribute :resources` already fixes the action's semantic shape. Use opt-out to narrow where the button appears:
|
|
1377
1388
|
|
|
1378
1389
|
```ruby
|
|
1379
1390
|
# :resource interaction defaults to record_action + collection_record_action.
|
|
@@ -1402,11 +1413,11 @@ action :name,
|
|
|
1402
1413
|
collection_record_action: true,
|
|
1403
1414
|
bulk_action: true,
|
|
1404
1415
|
|
|
1405
|
-
# Conditional visibility
|
|
1416
|
+
# Conditional visibility: display-only toggle, NOT authorization (see below).
|
|
1406
1417
|
# `-> { false }` keeps the route live but hides the button (e.g. API-only).
|
|
1407
1418
|
condition: -> { params[:beta] == "1" },
|
|
1408
1419
|
|
|
1409
|
-
# Never renders anywhere
|
|
1420
|
+
# Never renders anywhere: route + policy stay live. For endpoints reached by
|
|
1410
1421
|
# a gesture rather than a button (see Hidden Actions below). NOT authorization.
|
|
1411
1422
|
hidden: true,
|
|
1412
1423
|
|
|
@@ -1418,17 +1429,17 @@ action :name,
|
|
|
1418
1429
|
confirmation: "Are you sure?",
|
|
1419
1430
|
turbo_frame: "_top",
|
|
1420
1431
|
route_options: {action: :foo},
|
|
1421
|
-
modal: :slideover, # :slideover / :centered
|
|
1422
|
-
size: :lg, # :sm / :md / :lg / :xl / :auto / :full
|
|
1432
|
+
modal: :slideover, # :slideover / :centered, overrides definition's modal mode
|
|
1433
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
|
|
1423
1434
|
|
|
1424
|
-
# HTML attributes
|
|
1435
|
+
# HTML attributes: deep-merged over the framework's, author wins on every key
|
|
1425
1436
|
link: {target: "_blank", rel: "noopener"}, # every <a> rendering: toolbar GET link, dropdown items (any method), bulk links, card show link
|
|
1426
1437
|
button: {data: {analytics: "x"}} # the button_to <form> wrapper (non-GET toolbar rendering), NOT the inner <button>
|
|
1427
1438
|
```
|
|
1428
1439
|
|
|
1429
1440
|
### HTML Attributes (`link:` / `button:`)
|
|
1430
1441
|
|
|
1431
|
-
Per-element attribute bags for an action's rendered control. `link:` lands on every anchor the action renders as (dropdown items are anchors even for non-GET actions); `button:` lands on the `button_to` `<form>` element. The author wins on collisions
|
|
1442
|
+
Per-element attribute bags for an action's rendered control. `link:` lands on every anchor the action renders as (dropdown items are anchors even for non-GET actions); `button:` lands on the `button_to` `<form>` element. The author wins on collisions, including `class:` (replaces, no token append) and `turbo_frame`. Pass `data:` as a hash: a scalar `data:` replaces the framework's data wholesale, dropping `turbo_confirm`/`turbo_frame`.
|
|
1432
1443
|
|
|
1433
1444
|
```ruby
|
|
1434
1445
|
action :documentation,
|
|
@@ -1441,29 +1452,29 @@ Both bags round-trip through `with(...)`: `defined_actions[:edit].with(link: {ta
|
|
|
1441
1452
|
|
|
1442
1453
|
### Conditional Actions (`condition:`)
|
|
1443
1454
|
|
|
1444
|
-
Like `condition:` on inputs/displays/columns
|
|
1455
|
+
Like `condition:` on inputs/displays/columns: define an action but render its **button** only when a runtime proc is truthy. The action and its route stay live either way; `condition:` only toggles the UI.
|
|
1445
1456
|
|
|
1446
|
-
Headline use case: **expose an action's endpoint without a button
|
|
1457
|
+
Headline use case: **expose an action's endpoint without a button**, one you call from the API, a webhook, or another service. Hide it with an always-falsy condition; the route still works:
|
|
1447
1458
|
|
|
1448
1459
|
```ruby
|
|
1449
1460
|
# Defined and callable (API / programmatic), but no button anywhere:
|
|
1450
1461
|
action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }
|
|
1451
1462
|
|
|
1452
|
-
# Per-record display state
|
|
1463
|
+
# Per-record display state: object is the row/shown record:
|
|
1453
1464
|
action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
|
|
1454
1465
|
|
|
1455
1466
|
# View/request-level toggle (feature flag, beta mode):
|
|
1456
1467
|
action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
|
|
1457
1468
|
```
|
|
1458
1469
|
|
|
1459
|
-
Inside the proc, `object`/`record` is the contextual record
|
|
1470
|
+
Inside the proc, `object`/`record` is the contextual record: the row/shown record for **record** and **collection-record** actions, **nil** for **resource** and **bulk** actions (guard with `object&.…` if shared). Every other call delegates to the **view context**: `current_user`, `current_parent`, `params`, `request`, `allowed_to?`, `resource_record!`, etc. `object` is evaluated per row in tables/grids, so per-record show/hide works there.
|
|
1460
1471
|
|
|
1461
|
-
🚨 **`condition:` is NOT authorization
|
|
1472
|
+
🚨 **`condition:` is NOT authorization: it only hides the button.** A hidden action still has a live route; anyone with the URL can trigger it. "Who may run this" belongs in the policy:
|
|
1462
1473
|
|
|
1463
1474
|
```ruby
|
|
1464
|
-
# 🚫 WRONG
|
|
1475
|
+
# 🚫 WRONG: does not stop non-admins; the route is live.
|
|
1465
1476
|
action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
|
|
1466
|
-
# ✅ RIGHT
|
|
1477
|
+
# ✅ RIGHT: authorization in the policy, enforced regardless of condition:
|
|
1467
1478
|
def wipe? = current_user.admin?
|
|
1468
1479
|
```
|
|
1469
1480
|
|
|
@@ -1475,18 +1486,18 @@ The two compose: an action's button shows only when the policy permits **and** t
|
|
|
1475
1486
|
action :reposition, hidden: true
|
|
1476
1487
|
```
|
|
1477
1488
|
|
|
1478
|
-
Renders in **no** toolbar, row dropdown, card, or bulk bar
|
|
1489
|
+
Renders in **no** toolbar, row dropdown, card, or bulk bar, regardless of visibility flags, policy, or `condition:`. Everything else stays live: the route, the policy predicate (`def reposition?`), and (for `interaction:` actions) the form + permitted-params machinery.
|
|
1479
1490
|
|
|
1480
|
-
Use it for an endpoint reached by **something other than a button
|
|
1491
|
+
Use it for an endpoint reached by **something other than a button**: a drag gesture, a custom Stimulus controller. The framework uses it for exactly that: `position_on` expands to `action :reposition, hidden: true`, and the kanban drop endpoint is declared the same way.
|
|
1481
1492
|
|
|
1482
1493
|
| | `hidden: true` | `condition: -> { false }` |
|
|
1483
1494
|
|---|---|---|
|
|
1484
1495
|
| Decided | class-load, once | render time, per row/request |
|
|
1485
1496
|
| Says | "never a button" | "a button, just not right now" |
|
|
1486
1497
|
|
|
1487
|
-
🚨 **`hidden:` is a display gate, NOT an authorization boundary
|
|
1498
|
+
🚨 **`hidden:` is a display gate, NOT an authorization boundary**: same trap as `condition:`. The route is live; authorization belongs in the policy.
|
|
1488
1499
|
|
|
1489
|
-
`Action#with(...)
|
|
1500
|
+
`Action#with(...)`: actions are frozen value objects; clone with overrides:
|
|
1490
1501
|
|
|
1491
1502
|
```ruby
|
|
1492
1503
|
def customize_actions
|
|
@@ -1534,7 +1545,7 @@ class PostDefinition < ResourceDefinition
|
|
|
1534
1545
|
end
|
|
1535
1546
|
```
|
|
1536
1547
|
|
|
1537
|
-
⚠️ **An interaction is the button, not the operation.** It's a presentation object
|
|
1548
|
+
⚠️ **An interaction is the button, not the operation.** It's a presentation object: it can only be built with a `view_context`, so anything reachable only through one is reachable only from a Plutonium page. Logic may *start* in `execute` (a one-off with a single caller is fine; don't pre-extract). The **second caller** (a job, an API controller, a rake task, the console) is the trigger to move it onto the **model**, in domain language (`publish!`, `archive!`, `register!`). Not a service layer. Full rule + the validation split: [[plutonium-behavior]] › Part 3 › Where the logic goes.
|
|
1538
1549
|
|
|
1539
1550
|
### Single-record interaction
|
|
1540
1551
|
|
|
@@ -1574,7 +1585,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
1574
1585
|
validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
|
|
1575
1586
|
|
|
1576
1587
|
def execute
|
|
1577
|
-
# Company#invite! creates the row AND sends the mail
|
|
1588
|
+
# Company#invite! creates the row AND sends the mail, a seat-provisioning
|
|
1578
1589
|
# job needs both, and has no view_context to build an interaction with.
|
|
1579
1590
|
resource.invite!(email: email, role: role, by: current_user)
|
|
1580
1591
|
succeed(resource).with_message("Invitation sent to #{email}.")
|
|
@@ -1604,7 +1615,7 @@ end
|
|
|
1604
1615
|
action :bulk_archive, interaction: BulkArchiveInteraction
|
|
1605
1616
|
# bulk_action: true inferred from `attribute :resources`
|
|
1606
1617
|
|
|
1607
|
-
# Policy
|
|
1618
|
+
# Policy: checked per record; fails the request if ANY record is unauthorized
|
|
1608
1619
|
class PostPolicy < ResourcePolicy
|
|
1609
1620
|
def bulk_archive? = create?
|
|
1610
1621
|
end
|
|
@@ -1678,13 +1689,13 @@ end
|
|
|
1678
1689
|
|
|
1679
1690
|
## Immediate vs Form
|
|
1680
1691
|
|
|
1681
|
-
- **Immediate
|
|
1682
|
-
- **Form
|
|
1692
|
+
- **Immediate**: interaction has only `:resource` (or `:resources`) and no other inputs. Shows an auto-generated browser confirmation (`"#{label}?"`, e.g. `"Archive?"`) on click, then runs. Pass `confirmation: "Custom message"` to override, or `confirmation: false` to skip.
|
|
1693
|
+
- **Form**: interaction declares extra `attribute`/`input` beyond `:resource`/`:resources`. Renders a modal form first; no auto-confirmation (the form itself is the confirmation step).
|
|
1683
1694
|
|
|
1684
1695
|
## CSV Export (built-in)
|
|
1685
1696
|
|
|
1686
1697
|
Every resource has a streamed CSV export, **disabled by default**. It is not declared
|
|
1687
|
-
with `action :export_csv
|
|
1698
|
+
with `action :export_csv`: it's a policy-gated capability with its own split button.
|
|
1688
1699
|
The route (`GET /<resources>/export_csv`) is auto-mounted; the button appears on the
|
|
1689
1700
|
index page once the policy permits it. Enable it by overriding one policy method:
|
|
1690
1701
|
|
|
@@ -1695,9 +1706,9 @@ end
|
|
|
1695
1706
|
```
|
|
1696
1707
|
|
|
1697
1708
|
**Two exports** (split button in the index toolbar, after Filter):
|
|
1698
|
-
- **Export** (primary)
|
|
1709
|
+
- **Export** (primary): the current view: selected scope + filters + search (the
|
|
1699
1710
|
index's `?q`), all matching rows (not just the visible page). File: `posts_<date>.csv`.
|
|
1700
|
-
- **Export all** (dropdown)
|
|
1711
|
+
- **Export all** (dropdown): the entire authorized scope, ignoring scope/filters/
|
|
1701
1712
|
search (`?all=1`). File: `posts_all_<date>.csv`.
|
|
1702
1713
|
|
|
1703
1714
|
Both stream via `find_each` (memory-safe on large tables; primary-key order, so the
|
|
@@ -1710,7 +1721,7 @@ file does not preserve the index sort).
|
|
|
1710
1721
|
def permitted_attributes_for_export = [:title, :author, :total, :created_at]
|
|
1711
1722
|
```
|
|
1712
1723
|
|
|
1713
|
-
- **Per-field output
|
|
1724
|
+
- **Per-field output**: customize a cell's value and header in the definition with
|
|
1714
1725
|
the `export` DSL (parallels `display`/`column`):
|
|
1715
1726
|
|
|
1716
1727
|
```ruby
|
|
@@ -1723,7 +1734,7 @@ end
|
|
|
1723
1734
|
- **Without** an `export` block a column is read off the record: scalars as-is,
|
|
1724
1735
|
associations as their `display_name_of` label (e.g. `User #5`, not `#<User:…>`). A
|
|
1725
1736
|
computed/virtual column with no real method **needs** an `export` block (a `label:`-only
|
|
1726
|
-
`export` doesn't supply a value)
|
|
1737
|
+
`export` doesn't supply a value); otherwise the cell renders `<<invalid column>>`.
|
|
1727
1738
|
- **CSV/formula injection** is neutralized automatically (cells starting with `= + - @` or
|
|
1728
1739
|
tab/CR get a leading `'`).
|
|
1729
1740
|
|
|
@@ -1734,7 +1745,7 @@ The button opens in a new tab (so the streamed download bypasses Turbo). Full re
|
|
|
1734
1745
|
|
|
1735
1746
|
## Related Skills
|
|
1736
1747
|
|
|
1737
|
-
- [[plutonium-behavior]]
|
|
1738
|
-
- [[plutonium-tenancy]]
|
|
1739
|
-
- [[plutonium-ui]]
|
|
1740
|
-
- [[plutonium-testing]]
|
|
1748
|
+
- [[plutonium-behavior]]: controllers, policies (`permitted_attributes_for_*`, action methods), interactions
|
|
1749
|
+
- [[plutonium-tenancy]]: `associated_with`, `relation_scope`, nested resources
|
|
1750
|
+
- [[plutonium-ui]]: custom Phlex pages, forms, displays, tables
|
|
1751
|
+
- [[plutonium-testing]]: testing resources, definitions, policies, interactions
|