plutonium 0.65.0 → 0.67.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 +158 -141
- 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 +161 -98
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +43 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +56 -3
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +32 -32
- data/app/assets/plutonium.min.js.map +4 -4
- data/config/brakeman.ignore +1 -1
- data/config/locales/en/js.yml +4 -0
- data/config/locales/en/ui.yml +16 -0
- 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 +85 -80
- 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 +24 -14
- 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 +32 -15
- data/docs/reference/ui/forms.md +89 -39
- 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_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +13 -3
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +10 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +10 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/auth/rodauth.rb +7 -0
- data/lib/plutonium/core/controller.rb +0 -2
- data/lib/plutonium/helpers/content_helper.rb +0 -3
- data/lib/plutonium/query/filter.rb +2 -1
- data/lib/plutonium/query/filters/tags.rb +36 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
- data/lib/plutonium/ui/display/base.rb +50 -2
- data/lib/plutonium/ui/display/components/badge.rb +1 -1
- data/lib/plutonium/ui/display/components/binary.rb +20 -0
- data/lib/plutonium/ui/display/components/boolean.rb +1 -1
- data/lib/plutonium/ui/display/components/code.rb +23 -0
- data/lib/plutonium/ui/display/components/color.rb +14 -6
- data/lib/plutonium/ui/display/components/duration.rb +31 -0
- data/lib/plutonium/ui/display/components/file_size.rb +22 -0
- data/lib/plutonium/ui/display/components/key_value.rb +37 -0
- data/lib/plutonium/ui/display/components/list.rb +33 -0
- data/lib/plutonium/ui/display/components/number.rb +32 -0
- data/lib/plutonium/ui/display/components/progress.rb +36 -0
- data/lib/plutonium/ui/display/components/rating.rb +36 -0
- data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
- data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
- data/lib/plutonium/ui/display/components/tags.rb +18 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +24 -2
- data/lib/plutonium/ui/form/base.rb +30 -1
- data/lib/plutonium/ui/form/components/binary.rb +40 -0
- data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
- data/lib/plutonium/ui/form/components/list.rb +46 -0
- data/lib/plutonium/ui/form/components/rating.rb +51 -0
- data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
- data/lib/plutonium/ui/form/components/tags.rb +45 -0
- data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
- data/lib/plutonium/ui/form/components/uppy.rb +1 -1
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
- data/lib/plutonium/ui/form/theme.rb +20 -4
- data/lib/plutonium/ui/grid/card.rb +13 -1
- data/lib/plutonium/ui/layout/base.rb +10 -1
- data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
- data/lib/plutonium/ui/options/secret_field.rb +32 -0
- data/lib/plutonium/ui/options/tags_field.rb +23 -0
- data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
- data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
- data/lib/plutonium/ui/table/display_theme.rb +30 -2
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +53 -10
- data/src/css/flatpickr.css +91 -0
- data/src/js/controllers/easymde_controller.js +1 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/slim_select_controller.js +32 -3
- data/src/js/controllers/timeago_controller.js +40 -0
- metadata +25 -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
|
|
|
@@ -532,29 +543,33 @@ The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) ar
|
|
|
532
543
|
|
|
533
544
|
| Category | Types |
|
|
534
545
|
|----------|-------|
|
|
535
|
-
| Text | `:string`, `:text`, `:email`, `:url`, `:
|
|
536
|
-
| Rich Text | `:markdown` (EasyMDE) |
|
|
537
|
-
| Numeric | `:number`, `:integer`, `:decimal`, `:range` |
|
|
538
|
-
| Boolean | `:toggle` / `:switch` (switch
|
|
546
|
+
| Text | `:string`, `:text`, `:email`, `:url`, `:phone`, `:password` (alias `:secret`) |
|
|
547
|
+
| Rich Text | `:rich_text` (Action Text editor: Trix, or Lexxy once installed; **default** for `has_rich_text`), `:markdown` (EasyMDE) |
|
|
548
|
+
| Numeric | `:number`, `:integer`, `:decimal`, `:range`, `:rating` (star picker, `max:`) |
|
|
549
|
+
| Boolean | `:toggle` / `:switch` (switch, **default** for boolean columns), `:boolean` (plain checkbox) |
|
|
539
550
|
| Date/Time | `:date`, `:time`, `:datetime` |
|
|
540
|
-
| Selection | `:select`, `:slim_select`, `:
|
|
541
|
-
|
|
|
551
|
+
| Selection | `:select`, `:slim_select`, `:collection_radio_buttons`, `:collection_checkboxes` |
|
|
552
|
+
| Lists | `:list` (chip input for array values; `suggestions:`, `addable:`, `limit:`), `:tags` (no_fly_list `has_tags` contexts, **auto-detected**) |
|
|
553
|
+
| Files | `:file`, `:uppy`, `:attachment` (Active Storage), `:binary` (bytes in a `binary` column) |
|
|
542
554
|
| Associations | `:association`, `:secure_association`, `:belongs_to`, `:has_many`, `:has_one` |
|
|
543
|
-
| Special | `:hidden`, `:color`, `:
|
|
555
|
+
| Special | `:hidden`, `:color`, `:json`, `:key_value` |
|
|
544
556
|
|
|
545
557
|
### Display Types (show / index)
|
|
546
558
|
|
|
547
|
-
`:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:badge`, `:currency`, `:color`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`
|
|
559
|
+
`:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:badge`, `:currency`, `:color`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`, `:key_value`, `:binary`, `:list`, `:tags`, `:rich_text`, `:relative_time`, `:code`, `:duration`, `:file_size`, `:progress`, `:rating`
|
|
548
560
|
|
|
549
561
|
#### Auto-inferred display formatting
|
|
550
562
|
|
|
551
|
-
These render automatically
|
|
563
|
+
These render automatically: declare an `as:` only to override or pass options:
|
|
552
564
|
|
|
553
565
|
| Column | Renders as | Notes |
|
|
554
566
|
|--------|-----------|-------|
|
|
555
567
|
| `boolean` | Yes/No pill (`:boolean`) | green "Yes" / neutral "No". Override labels: `true_label:`, `false_label:` |
|
|
556
568
|
| `enum` | colored status badge (`:badge`) | known statuses (active, pending, failed…) auto-colored; unknown values get a stable decorative color |
|
|
557
|
-
| `
|
|
569
|
+
| `binary` | size (`:binary`) | `Binary data (12 KB)`, never the bytes |
|
|
570
|
+
| `has_rich_text` | HTML (`:rich_text`) | rendered and sanitized by Action Text |
|
|
571
|
+
| no_fly_list `has_tags` | chips (`:tags`) | declare by context name (`:labels`), not `labels_list` |
|
|
572
|
+
| `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals; symbol from `unit:` (display or `has_cents`), else `config.default_currency_unit` / i18n. `unit: false` drops it |
|
|
558
573
|
|
|
559
574
|
```ruby
|
|
560
575
|
display :status, as: :badge, colors: {archived: :neutral, vip: :accent} # override per-value color
|
|
@@ -592,7 +607,7 @@ input :title,
|
|
|
592
607
|
input :category, as: :select, choices: %w[Tech Business Lifestyle]
|
|
593
608
|
input :status, as: :select, choices: Post.statuses.keys
|
|
594
609
|
|
|
595
|
-
# Dynamic
|
|
610
|
+
# Dynamic: must use a block
|
|
596
611
|
input :author do |f|
|
|
597
612
|
f.select_tag choices: User.active.pluck(:name, :id)
|
|
598
613
|
end
|
|
@@ -624,24 +639,24 @@ A surface's own `condition:` wins; otherwise the surface falls back to the `fiel
|
|
|
624
639
|
|
|
625
640
|
## Options That Vary Per Render
|
|
626
641
|
|
|
627
|
-
Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL
|
|
642
|
+
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
643
|
|
|
629
644
|
```ruby
|
|
630
645
|
input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
|
|
631
646
|
input :notes, placeholder: -> { "Updated #{Time.current.year}" }
|
|
632
647
|
```
|
|
633
648
|
|
|
634
|
-
- `-> { … }` is called as-is, keeping its own binding
|
|
635
|
-
- `->(form) { … }` gets the form
|
|
649
|
+
- `-> { … }` 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).
|
|
650
|
+
- `->(form) { … }` gets the form: `object` (the record), `params`, view helpers.
|
|
636
651
|
|
|
637
|
-
Same rule on wizard steps
|
|
652
|
+
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
653
|
|
|
639
|
-
**`condition:` is not an option
|
|
654
|
+
**`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
655
|
|
|
641
656
|
```ruby
|
|
642
657
|
input :notes, condition: -> { object.published? } # the form
|
|
643
658
|
display :audit_log, condition: -> { current_user.admin? } # the display component
|
|
644
|
-
step :billing, condition: -> { data.plan.tier == "pro" } # the wizard
|
|
659
|
+
step :billing, condition: -> { data.plan.tier == "pro" } # the wizard, no form exists yet
|
|
645
660
|
```
|
|
646
661
|
|
|
647
662
|
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 +704,7 @@ class QuestionDefinition < ResourceDefinition
|
|
|
689
704
|
choices: %w[text choice scale],
|
|
690
705
|
pre_submit: true
|
|
691
706
|
|
|
692
|
-
# No `as
|
|
707
|
+
# No `as:`: types are auto-detected from the model. We only declare to add `condition:`.
|
|
693
708
|
input :max_length, condition: -> { object.question_type == "text" }
|
|
694
709
|
input :choices, condition: -> { object.question_type == "choice" }
|
|
695
710
|
input :min_value, condition: -> { object.question_type == "scale" }
|
|
@@ -716,7 +731,7 @@ Tips:
|
|
|
716
731
|
|
|
717
732
|
## Custom Rendering
|
|
718
733
|
|
|
719
|
-
**Display block
|
|
734
|
+
**Display block: return any component:**
|
|
720
735
|
|
|
721
736
|
```ruby
|
|
722
737
|
display :status do |field|
|
|
@@ -724,7 +739,7 @@ display :status do |field|
|
|
|
724
739
|
end
|
|
725
740
|
```
|
|
726
741
|
|
|
727
|
-
**Input block
|
|
742
|
+
**Input block: must use form builder methods:**
|
|
728
743
|
|
|
729
744
|
```ruby
|
|
730
745
|
input :birth_date do |f|
|
|
@@ -762,14 +777,14 @@ end
|
|
|
762
777
|
|
|
763
778
|
See [[plutonium-ui]] for writing custom Phlex components.
|
|
764
779
|
|
|
765
|
-
**Custom component classes** (Phlex components
|
|
780
|
+
**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
781
|
|
|
767
782
|
```ruby
|
|
768
783
|
input :color_picker, as: ColorPickerComponent
|
|
769
784
|
display :chart, as: ChartComponent
|
|
770
785
|
```
|
|
771
786
|
|
|
772
|
-
🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate
|
|
787
|
+
🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate; it raises `ArgumentError`. Build it in a block instead:
|
|
773
788
|
|
|
774
789
|
```ruby
|
|
775
790
|
display :card do |field|
|
|
@@ -783,10 +798,10 @@ end
|
|
|
783
798
|
column :title, align: :start # :start (default), :center, :end
|
|
784
799
|
column :amount, align: :end
|
|
785
800
|
|
|
786
|
-
# formatter
|
|
801
|
+
# formatter: receives just the value
|
|
787
802
|
column :price, formatter: ->(v) { "$%.2f" % v if v }
|
|
788
803
|
|
|
789
|
-
# block
|
|
804
|
+
# block: receives the full record
|
|
790
805
|
column :full_name do |record|
|
|
791
806
|
"#{record.first_name} #{record.last_name}"
|
|
792
807
|
end
|
|
@@ -841,7 +856,7 @@ end
|
|
|
841
856
|
|--------|-------------|
|
|
842
857
|
| `limit` | Max records (auto-detected from model, default 10) |
|
|
843
858
|
| `allow_destroy` | Show delete checkbox (auto-detected) |
|
|
844
|
-
| `update_only` | Hide "Add" button
|
|
859
|
+
| `update_only` | Hide "Add" button, only edit existing |
|
|
845
860
|
| `description` | Help text above section |
|
|
846
861
|
| `condition` | Proc to show/hide |
|
|
847
862
|
| `using` | Another Definition class |
|
|
@@ -857,7 +872,7 @@ end
|
|
|
857
872
|
|
|
858
873
|
## Structured Inputs
|
|
859
874
|
|
|
860
|
-
`structured_input` collects a **classless** group of fields
|
|
875
|
+
`structured_input` collects a **classless** group of fields: a single hash, or
|
|
861
876
|
(with `repeat:`) an array of hashes. No association or model class is involved.
|
|
862
877
|
On resources the value is stored in a **JSON/jsonb column**; use it when you
|
|
863
878
|
want structured data in a JSON column rather than a real association (which is
|
|
@@ -901,14 +916,14 @@ blank rows are dropped, `_destroy` stripped).
|
|
|
901
916
|
|
|
902
917
|
### Gotchas
|
|
903
918
|
|
|
904
|
-
- The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed
|
|
919
|
+
- The column must be `json`/`jsonb` (or otherwise hold a hash/array). No model macro is needed; the value assigns directly.
|
|
905
920
|
- **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
|
|
921
|
+
- `repeat: 1` is "array, max one row", **not** the single form. Presence of `repeat:` always means an array.
|
|
922
|
+
- Rows are positional plain hashes: **no ids, no per-row class, no type coercion**.
|
|
908
923
|
- **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
924
|
- **`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)
|
|
925
|
+
- 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.
|
|
926
|
+
- Same DSL works on **interactions** (see [[plutonium-behavior]] › Interactions); there it backs an ActiveModel attribute reaching `execute`.
|
|
912
927
|
|
|
913
928
|
## File Uploads
|
|
914
929
|
|
|
@@ -925,9 +940,9 @@ input :documents, as: :uppy,
|
|
|
925
940
|
|
|
926
941
|
Inside `condition` procs and block-form `input`/`display`:
|
|
927
942
|
|
|
928
|
-
- `object
|
|
943
|
+
- `object`: the record
|
|
929
944
|
- `current_user`
|
|
930
|
-
- `current_parent
|
|
945
|
+
- `current_parent`: for nested resources
|
|
931
946
|
- `request`, `params`
|
|
932
947
|
- All helper methods
|
|
933
948
|
|
|
@@ -964,7 +979,7 @@ class PostDefinition < ResourceDefinition
|
|
|
964
979
|
breadcrumbs true
|
|
965
980
|
show_page_breadcrumbs false
|
|
966
981
|
|
|
967
|
-
# Custom page classes
|
|
982
|
+
# Custom page classes: inherit from the parent's nested class
|
|
968
983
|
class IndexPage < IndexPage
|
|
969
984
|
def view_template(&block)
|
|
970
985
|
div(class: "custom-header") { h1 { "Custom" } }
|
|
@@ -988,7 +1003,7 @@ end
|
|
|
988
1003
|
|
|
989
1004
|
## Form Layout (`form_layout`)
|
|
990
1005
|
|
|
991
|
-
Group form fields into sections **declaratively in the definition
|
|
1006
|
+
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
1007
|
|
|
993
1008
|
```ruby
|
|
994
1009
|
class PostDefinition < ResourceDefinition
|
|
@@ -1002,18 +1017,18 @@ class PostDefinition < ResourceDefinition
|
|
|
1002
1017
|
end
|
|
1003
1018
|
```
|
|
1004
1019
|
|
|
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
|
|
1020
|
+
- **Layout references field KEYS only**: all per-field config (`as:`, `hint:`, blocks, per-field `condition:`) stays on `input`. Never duplicated here.
|
|
1021
|
+
- **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
1022
|
- ⚠️ **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`)
|
|
1023
|
+
- **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`.
|
|
1024
|
+
- **🚨 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.
|
|
1025
|
+
- **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
1026
|
|
|
1012
1027
|
Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
|
|
1013
1028
|
|
|
1014
1029
|
## Display Layout (`display_layout`)
|
|
1015
1030
|
|
|
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)
|
|
1031
|
+
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
1032
|
|
|
1018
1033
|
```ruby
|
|
1019
1034
|
class PostDefinition < ResourceDefinition
|
|
@@ -1025,14 +1040,14 @@ class PostDefinition < ResourceDefinition
|
|
|
1025
1040
|
end
|
|
1026
1041
|
```
|
|
1027
1042
|
|
|
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)
|
|
1043
|
+
- **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.
|
|
1044
|
+
- **🚨 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.
|
|
1045
|
+
- **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`.
|
|
1046
|
+
- **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
1047
|
|
|
1033
1048
|
## Page Width (`page_width`)
|
|
1034
1049
|
|
|
1035
|
-
Detail-style pages
|
|
1050
|
+
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
1051
|
|
|
1037
1052
|
```ruby
|
|
1038
1053
|
Plutonium.configure { |c| c.default_page_width = :md } # global default (:md)
|
|
@@ -1045,12 +1060,12 @@ end
|
|
|
1045
1060
|
```
|
|
1046
1061
|
|
|
1047
1062
|
- **Sizes**: `:sm` `:md` `:lg` `:xl` `:full`. `:full` means no constraint. An unknown value **raises** at declaration.
|
|
1048
|
-
- **🚨 Tokens are relative to their surface
|
|
1063
|
+
- **🚨 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
1064
|
- **Resolution**: surface-specific (`form_width` / `display_width`) → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is honoured, not treated as unset.
|
|
1050
1065
|
- **Inherits** to subclasses, so a portal-specific definition keeps the parent's width unless it overrides.
|
|
1051
|
-
- **Modals are unaffected
|
|
1066
|
+
- **Modals are unaffected**: the dialog sets its own width (`modal_size`).
|
|
1052
1067
|
- **Interactions support it too** (`Plutonium::Interaction::Base`), for interactive actions rendered as standalone pages.
|
|
1053
|
-
- **Wizards are on their own axis
|
|
1068
|
+
- **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
1069
|
|
|
1055
1070
|
## Metadata Panel (show page)
|
|
1056
1071
|
|
|
@@ -1060,12 +1075,12 @@ Declares fields rendered in the show page's right-side aside as label/value rows
|
|
|
1060
1075
|
metadata :author, :state, :created_at, :updated_at
|
|
1061
1076
|
```
|
|
1062
1077
|
|
|
1063
|
-
- **Opt-in
|
|
1064
|
-
- **Policy-aware
|
|
1065
|
-
- **Deduplicated
|
|
1066
|
-
- **Responsive
|
|
1078
|
+
- **Opt-in**: no call → show page is full-width with no aside.
|
|
1079
|
+
- **Policy-aware**: fields the user can't see disappear; panel auto-hides if nothing's permitted.
|
|
1080
|
+
- **Deduplicated**: listed fields are removed from the main details card (and from any `display_layout` section).
|
|
1081
|
+
- **Responsive**: side-by-side at `lg+`, stacked below.
|
|
1067
1082
|
- **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
|
|
1083
|
+
- **A kanban card's modal drops metadata entirely**: the fields are hidden, not folded into the main card.
|
|
1069
1084
|
|
|
1070
1085
|
Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
|
|
1071
1086
|
|
|
@@ -1075,7 +1090,7 @@ Resources can offer both Table and Grid views; user choice persists per-resource
|
|
|
1075
1090
|
|
|
1076
1091
|
```ruby
|
|
1077
1092
|
class UserDefinition < ResourceDefinition
|
|
1078
|
-
# No `index_views :table, :grid` needed
|
|
1093
|
+
# No `index_views :table, :grid` needed, `grid_fields` auto-enables :grid alongside the default :table.
|
|
1079
1094
|
grid_fields(
|
|
1080
1095
|
image: :avatar, # ActiveStorage, Shrine, or URL
|
|
1081
1096
|
header: :name, # falls back to to_label
|
|
@@ -1085,7 +1100,7 @@ class UserDefinition < ResourceDefinition
|
|
|
1085
1100
|
footer: :last_seen_at # falls back to :created_at; `false` to omit
|
|
1086
1101
|
)
|
|
1087
1102
|
|
|
1088
|
-
default_index_view :grid # optional
|
|
1103
|
+
default_index_view :grid # optional: initial view when no cookie
|
|
1089
1104
|
grid_layout :media # :compact (default) or :media
|
|
1090
1105
|
grid_columns 3 # pin lg+ cols; default is 1/2/3/4 responsive
|
|
1091
1106
|
end
|
|
@@ -1107,23 +1122,23 @@ All grid slots are optional; slots pointing at unpermitted fields collapse silen
|
|
|
1107
1122
|
|
|
1108
1123
|
## Drag-to-Reorder (`positioned_on` + `position_on`)
|
|
1109
1124
|
|
|
1110
|
-
Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three
|
|
1125
|
+
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
1126
|
|
|
1112
1127
|
```ruby
|
|
1113
|
-
# Migration
|
|
1128
|
+
# Migration: t.position emits decimal(16,8), tuned for fractional ordering.
|
|
1114
1129
|
create_table :tasks do |t|
|
|
1115
1130
|
t.string :status, null: false, default: "todo"
|
|
1116
1131
|
t.position
|
|
1117
1132
|
t.index [:status, :position] # match the scope attribute
|
|
1118
1133
|
end
|
|
1119
1134
|
|
|
1120
|
-
# Model
|
|
1135
|
+
# Model: storage
|
|
1121
1136
|
class Task < ApplicationRecord
|
|
1122
1137
|
include Plutonium::Positioning::Model # NOT Plutonium::Positioning
|
|
1123
1138
|
positioned_on :position, scope: :status # scope: nil = one global ordering
|
|
1124
1139
|
end
|
|
1125
1140
|
|
|
1126
|
-
# Definition
|
|
1141
|
+
# Definition: "this UI can be reordered". Never restates the column or the scope.
|
|
1127
1142
|
class TaskDefinition < ResourceDefinition
|
|
1128
1143
|
position_on
|
|
1129
1144
|
end
|
|
@@ -1132,45 +1147,45 @@ end
|
|
|
1132
1147
|
Task.backfill_positions!(order: :created_at)
|
|
1133
1148
|
```
|
|
1134
1149
|
|
|
1135
|
-
- **`include Plutonium::Positioning::Model
|
|
1136
|
-
- **`positioned_on` is required**, not just the include
|
|
1150
|
+
- **`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`.
|
|
1151
|
+
- **`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
1152
|
|
|
1138
1153
|
### `position_on` forms and modes
|
|
1139
1154
|
|
|
1140
1155
|
| Form | Mode | Notes |
|
|
1141
1156
|
|---|---|---|
|
|
1142
|
-
| `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this
|
|
1157
|
+
| `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this**: it cannot disagree with the model. |
|
|
1143
1158
|
| `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
|
|
1159
|
+
| `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch: another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
|
|
1160
|
+
| `position_on false` | C (disabled) | No ordering, no route, the endpoint 404s |
|
|
1146
1161
|
|
|
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
|
|
1162
|
+
**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
1163
|
|
|
1149
|
-
### Mode B
|
|
1164
|
+
### Mode B: what the framework stops doing
|
|
1150
1165
|
|
|
1151
1166
|
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
1167
|
|
|
1153
1168
|
Because the write is opaque, three Mode A behaviours are **not** provided:
|
|
1154
1169
|
|
|
1155
|
-
- **No hidden boundary resolution
|
|
1156
|
-
- **No server-side foreign-sort rejection
|
|
1157
|
-
- **Always a full repaint
|
|
1170
|
+
- **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.
|
|
1171
|
+
- **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.
|
|
1172
|
+
- **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
1173
|
|
|
1159
1174
|
### Migrating off `acts_as_list` to Mode A
|
|
1160
1175
|
|
|
1161
|
-
1. **Change the column
|
|
1162
|
-
2. **Swap the macro
|
|
1163
|
-
3. **Backfill
|
|
1176
|
+
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`.
|
|
1177
|
+
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).
|
|
1178
|
+
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
1179
|
4. Drop the block from the definition; a bare `position_on` is the whole of Mode A.
|
|
1165
1180
|
|
|
1166
1181
|
### Staying on `acts_as_list` (the harder road)
|
|
1167
1182
|
|
|
1168
1183
|
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
1184
|
|
|
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)
|
|
1185
|
+
🚨 **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
1186
|
|
|
1172
1187
|
```ruby
|
|
1173
|
-
# Keeping acts_as_list. NOTE scope: [:status]
|
|
1188
|
+
# Keeping acts_as_list. NOTE scope: [:status], a bare Symbol scope is run
|
|
1174
1189
|
# through acts_as_list's `idify`, which turns :status into :status_id and makes
|
|
1175
1190
|
# every create raise NoMethodError.
|
|
1176
1191
|
class Task < ApplicationRecord
|
|
@@ -1186,7 +1201,7 @@ class TaskDefinition < ResourceDefinition
|
|
|
1186
1201
|
# Removing the record shifts prev up one when the record was above it.
|
|
1187
1202
|
(record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
|
|
1188
1203
|
elsif move.next
|
|
1189
|
-
# Blank prev means "nothing above me ON MY SCREEN"
|
|
1204
|
+
# Blank prev means "nothing above me ON MY SCREEN"; rows may still sit
|
|
1190
1205
|
# above off-page or behind a filter, so anchor off next rather than 1.
|
|
1191
1206
|
(record.position < move.next.position) ? move.next.position - 1 : move.next.position
|
|
1192
1207
|
else
|
|
@@ -1198,7 +1213,7 @@ class TaskDefinition < ResourceDefinition
|
|
|
1198
1213
|
end
|
|
1199
1214
|
```
|
|
1200
1215
|
|
|
1201
|
-
`insert_at` calls `save`, not `save
|
|
1216
|
+
`insert_at` calls `save`, not `save!`; a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
|
|
1202
1217
|
|
|
1203
1218
|
### 🚨 `position_on` expands to three things
|
|
1204
1219
|
|
|
@@ -1208,21 +1223,21 @@ default_sort <attr>, :asc # ⚠ ONLY when default_sort is still the frame
|
|
|
1208
1223
|
action :reposition, hidden: true # route + policy predicate, no button
|
|
1209
1224
|
```
|
|
1210
1225
|
|
|
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
|
|
1226
|
+
**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
1227
|
|
|
1213
1228
|
### Behavior notes
|
|
1214
1229
|
|
|
1215
1230
|
- **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
1231
|
- **`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
1232
|
- **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
|
|
1233
|
+
- **`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
1234
|
- Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
|
|
1220
1235
|
|
|
1221
1236
|
Full reference: `docs/reference/resource/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
|
|
1222
1237
|
|
|
1223
1238
|
---
|
|
1224
1239
|
|
|
1225
|
-
# Part 4
|
|
1240
|
+
# Part 4: Query: Search, Filters, Scopes, Sorting
|
|
1226
1241
|
|
|
1227
1242
|
```ruby
|
|
1228
1243
|
class PostDefinition < ResourceDefinition
|
|
@@ -1266,6 +1281,7 @@ end
|
|
|
1266
1281
|
| Date Range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
|
|
1267
1282
|
| Select | `:select` | `value` | `choices:`, `multiple:` |
|
|
1268
1283
|
| Association | `:association` | `value` | `class_name:`, `multiple:` |
|
|
1284
|
+
| Tags | `:tags` | `value` | `match:` (`:any` default, `:all`); no_fly_list contexts, options limited to the policy scope |
|
|
1269
1285
|
|
|
1270
1286
|
**Text predicates:** `:eq`, `:not_eq`, `:contains`, `:not_contains`, `:starts_with`, `:ends_with`, `:matches`, `:not_matches`
|
|
1271
1287
|
**Date predicates:** `:eq`, `:not_eq`, `:lt`, `:lteq`, `:gt`, `:gteq`
|
|
@@ -1280,6 +1296,7 @@ filter :category, with: :select, choices: -> { Category.pluck(:name) }
|
|
|
1280
1296
|
filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
|
|
1281
1297
|
filter :category, with: :association
|
|
1282
1298
|
filter :author, with: :association, class_name: User
|
|
1299
|
+
filter :labels, with: :tags # no_fly_list context
|
|
1283
1300
|
```
|
|
1284
1301
|
|
|
1285
1302
|
**Custom filter class:**
|
|
@@ -1317,7 +1334,7 @@ default_scope :published # applied on initial load; "All" button clears it
|
|
|
1317
1334
|
|
|
1318
1335
|
### Conditional scopes (`condition:`)
|
|
1319
1336
|
|
|
1320
|
-
Like `condition:` on actions and fields
|
|
1337
|
+
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
1338
|
|
|
1322
1339
|
```ruby
|
|
1323
1340
|
scope :admin_only, condition: -> { current_user.admin? }
|
|
@@ -1325,7 +1342,7 @@ scope :beta_feature, condition: -> { params[:beta] == "1" }
|
|
|
1325
1342
|
scope :never_shown, condition: -> { false } # hides button but URL still works
|
|
1326
1343
|
```
|
|
1327
1344
|
|
|
1328
|
-
The proc is evaluated against the view context
|
|
1345
|
+
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
1346
|
|
|
1330
1347
|
🚨 **`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
1348
|
|
|
@@ -1353,7 +1370,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
|
1353
1370
|
|
|
1354
1371
|
---
|
|
1355
1372
|
|
|
1356
|
-
# Part 5
|
|
1373
|
+
# Part 5: Actions: Custom and Bulk
|
|
1357
1374
|
|
|
1358
1375
|
## Action Types
|
|
1359
1376
|
|
|
@@ -1365,7 +1382,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
|
1365
1382
|
| `bulk_action: true` | Selected records | Bulk operations |
|
|
1366
1383
|
| `hidden: true` | **Nowhere** | Suppresses all four; route + policy stay live (drag gestures, custom JS) |
|
|
1367
1384
|
|
|
1368
|
-
🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes
|
|
1385
|
+
🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes, so don't declare them manually:**
|
|
1369
1386
|
|
|
1370
1387
|
| Interaction declares | Inferred flags |
|
|
1371
1388
|
|---|---|
|
|
@@ -1373,7 +1390,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
|
1373
1390
|
| `attribute :resources` (plural) | `bulk_action` |
|
|
1374
1391
|
| neither | `resource_action` |
|
|
1375
1392
|
|
|
1376
|
-
User-supplied flags override the inferred ones, but only **opt-out** makes sense for interactive actions
|
|
1393
|
+
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
1394
|
|
|
1378
1395
|
```ruby
|
|
1379
1396
|
# :resource interaction defaults to record_action + collection_record_action.
|
|
@@ -1402,11 +1419,11 @@ action :name,
|
|
|
1402
1419
|
collection_record_action: true,
|
|
1403
1420
|
bulk_action: true,
|
|
1404
1421
|
|
|
1405
|
-
# Conditional visibility
|
|
1422
|
+
# Conditional visibility: display-only toggle, NOT authorization (see below).
|
|
1406
1423
|
# `-> { false }` keeps the route live but hides the button (e.g. API-only).
|
|
1407
1424
|
condition: -> { params[:beta] == "1" },
|
|
1408
1425
|
|
|
1409
|
-
# Never renders anywhere
|
|
1426
|
+
# Never renders anywhere: route + policy stay live. For endpoints reached by
|
|
1410
1427
|
# a gesture rather than a button (see Hidden Actions below). NOT authorization.
|
|
1411
1428
|
hidden: true,
|
|
1412
1429
|
|
|
@@ -1418,17 +1435,17 @@ action :name,
|
|
|
1418
1435
|
confirmation: "Are you sure?",
|
|
1419
1436
|
turbo_frame: "_top",
|
|
1420
1437
|
route_options: {action: :foo},
|
|
1421
|
-
modal: :slideover, # :slideover / :centered
|
|
1422
|
-
size: :lg, # :sm / :md / :lg / :xl / :auto / :full
|
|
1438
|
+
modal: :slideover, # :slideover / :centered, overrides definition's modal mode
|
|
1439
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
|
|
1423
1440
|
|
|
1424
|
-
# HTML attributes
|
|
1441
|
+
# HTML attributes: deep-merged over the framework's, author wins on every key
|
|
1425
1442
|
link: {target: "_blank", rel: "noopener"}, # every <a> rendering: toolbar GET link, dropdown items (any method), bulk links, card show link
|
|
1426
1443
|
button: {data: {analytics: "x"}} # the button_to <form> wrapper (non-GET toolbar rendering), NOT the inner <button>
|
|
1427
1444
|
```
|
|
1428
1445
|
|
|
1429
1446
|
### HTML Attributes (`link:` / `button:`)
|
|
1430
1447
|
|
|
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
|
|
1448
|
+
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
1449
|
|
|
1433
1450
|
```ruby
|
|
1434
1451
|
action :documentation,
|
|
@@ -1441,29 +1458,29 @@ Both bags round-trip through `with(...)`: `defined_actions[:edit].with(link: {ta
|
|
|
1441
1458
|
|
|
1442
1459
|
### Conditional Actions (`condition:`)
|
|
1443
1460
|
|
|
1444
|
-
Like `condition:` on inputs/displays/columns
|
|
1461
|
+
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
1462
|
|
|
1446
|
-
Headline use case: **expose an action's endpoint without a button
|
|
1463
|
+
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
1464
|
|
|
1448
1465
|
```ruby
|
|
1449
1466
|
# Defined and callable (API / programmatic), but no button anywhere:
|
|
1450
1467
|
action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }
|
|
1451
1468
|
|
|
1452
|
-
# Per-record display state
|
|
1469
|
+
# Per-record display state: object is the row/shown record:
|
|
1453
1470
|
action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
|
|
1454
1471
|
|
|
1455
1472
|
# View/request-level toggle (feature flag, beta mode):
|
|
1456
1473
|
action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
|
|
1457
1474
|
```
|
|
1458
1475
|
|
|
1459
|
-
Inside the proc, `object`/`record` is the contextual record
|
|
1476
|
+
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
1477
|
|
|
1461
|
-
🚨 **`condition:` is NOT authorization
|
|
1478
|
+
🚨 **`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
1479
|
|
|
1463
1480
|
```ruby
|
|
1464
|
-
# 🚫 WRONG
|
|
1481
|
+
# 🚫 WRONG: does not stop non-admins; the route is live.
|
|
1465
1482
|
action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
|
|
1466
|
-
# ✅ RIGHT
|
|
1483
|
+
# ✅ RIGHT: authorization in the policy, enforced regardless of condition:
|
|
1467
1484
|
def wipe? = current_user.admin?
|
|
1468
1485
|
```
|
|
1469
1486
|
|
|
@@ -1475,18 +1492,18 @@ The two compose: an action's button shows only when the policy permits **and** t
|
|
|
1475
1492
|
action :reposition, hidden: true
|
|
1476
1493
|
```
|
|
1477
1494
|
|
|
1478
|
-
Renders in **no** toolbar, row dropdown, card, or bulk bar
|
|
1495
|
+
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
1496
|
|
|
1480
|
-
Use it for an endpoint reached by **something other than a button
|
|
1497
|
+
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
1498
|
|
|
1482
1499
|
| | `hidden: true` | `condition: -> { false }` |
|
|
1483
1500
|
|---|---|---|
|
|
1484
1501
|
| Decided | class-load, once | render time, per row/request |
|
|
1485
1502
|
| Says | "never a button" | "a button, just not right now" |
|
|
1486
1503
|
|
|
1487
|
-
🚨 **`hidden:` is a display gate, NOT an authorization boundary
|
|
1504
|
+
🚨 **`hidden:` is a display gate, NOT an authorization boundary**: same trap as `condition:`. The route is live; authorization belongs in the policy.
|
|
1488
1505
|
|
|
1489
|
-
`Action#with(...)
|
|
1506
|
+
`Action#with(...)`: actions are frozen value objects; clone with overrides:
|
|
1490
1507
|
|
|
1491
1508
|
```ruby
|
|
1492
1509
|
def customize_actions
|
|
@@ -1534,7 +1551,7 @@ class PostDefinition < ResourceDefinition
|
|
|
1534
1551
|
end
|
|
1535
1552
|
```
|
|
1536
1553
|
|
|
1537
|
-
⚠️ **An interaction is the button, not the operation.** It's a presentation object
|
|
1554
|
+
⚠️ **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
1555
|
|
|
1539
1556
|
### Single-record interaction
|
|
1540
1557
|
|
|
@@ -1574,7 +1591,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
1574
1591
|
validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
|
|
1575
1592
|
|
|
1576
1593
|
def execute
|
|
1577
|
-
# Company#invite! creates the row AND sends the mail
|
|
1594
|
+
# Company#invite! creates the row AND sends the mail, a seat-provisioning
|
|
1578
1595
|
# job needs both, and has no view_context to build an interaction with.
|
|
1579
1596
|
resource.invite!(email: email, role: role, by: current_user)
|
|
1580
1597
|
succeed(resource).with_message("Invitation sent to #{email}.")
|
|
@@ -1604,7 +1621,7 @@ end
|
|
|
1604
1621
|
action :bulk_archive, interaction: BulkArchiveInteraction
|
|
1605
1622
|
# bulk_action: true inferred from `attribute :resources`
|
|
1606
1623
|
|
|
1607
|
-
# Policy
|
|
1624
|
+
# Policy: checked per record; fails the request if ANY record is unauthorized
|
|
1608
1625
|
class PostPolicy < ResourcePolicy
|
|
1609
1626
|
def bulk_archive? = create?
|
|
1610
1627
|
end
|
|
@@ -1678,13 +1695,13 @@ end
|
|
|
1678
1695
|
|
|
1679
1696
|
## Immediate vs Form
|
|
1680
1697
|
|
|
1681
|
-
- **Immediate
|
|
1682
|
-
- **Form
|
|
1698
|
+
- **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.
|
|
1699
|
+
- **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
1700
|
|
|
1684
1701
|
## CSV Export (built-in)
|
|
1685
1702
|
|
|
1686
1703
|
Every resource has a streamed CSV export, **disabled by default**. It is not declared
|
|
1687
|
-
with `action :export_csv
|
|
1704
|
+
with `action :export_csv`: it's a policy-gated capability with its own split button.
|
|
1688
1705
|
The route (`GET /<resources>/export_csv`) is auto-mounted; the button appears on the
|
|
1689
1706
|
index page once the policy permits it. Enable it by overriding one policy method:
|
|
1690
1707
|
|
|
@@ -1695,9 +1712,9 @@ end
|
|
|
1695
1712
|
```
|
|
1696
1713
|
|
|
1697
1714
|
**Two exports** (split button in the index toolbar, after Filter):
|
|
1698
|
-
- **Export** (primary)
|
|
1715
|
+
- **Export** (primary): the current view: selected scope + filters + search (the
|
|
1699
1716
|
index's `?q`), all matching rows (not just the visible page). File: `posts_<date>.csv`.
|
|
1700
|
-
- **Export all** (dropdown)
|
|
1717
|
+
- **Export all** (dropdown): the entire authorized scope, ignoring scope/filters/
|
|
1701
1718
|
search (`?all=1`). File: `posts_all_<date>.csv`.
|
|
1702
1719
|
|
|
1703
1720
|
Both stream via `find_each` (memory-safe on large tables; primary-key order, so the
|
|
@@ -1710,7 +1727,7 @@ file does not preserve the index sort).
|
|
|
1710
1727
|
def permitted_attributes_for_export = [:title, :author, :total, :created_at]
|
|
1711
1728
|
```
|
|
1712
1729
|
|
|
1713
|
-
- **Per-field output
|
|
1730
|
+
- **Per-field output**: customize a cell's value and header in the definition with
|
|
1714
1731
|
the `export` DSL (parallels `display`/`column`):
|
|
1715
1732
|
|
|
1716
1733
|
```ruby
|
|
@@ -1723,7 +1740,7 @@ end
|
|
|
1723
1740
|
- **Without** an `export` block a column is read off the record: scalars as-is,
|
|
1724
1741
|
associations as their `display_name_of` label (e.g. `User #5`, not `#<User:…>`). A
|
|
1725
1742
|
computed/virtual column with no real method **needs** an `export` block (a `label:`-only
|
|
1726
|
-
`export` doesn't supply a value)
|
|
1743
|
+
`export` doesn't supply a value); otherwise the cell renders `<<invalid column>>`.
|
|
1727
1744
|
- **CSV/formula injection** is neutralized automatically (cells starting with `= + - @` or
|
|
1728
1745
|
tab/CR get a leading `'`).
|
|
1729
1746
|
|
|
@@ -1734,7 +1751,7 @@ The button opens in a new tab (so the streamed download bypasses Turbo). Full re
|
|
|
1734
1751
|
|
|
1735
1752
|
## Related Skills
|
|
1736
1753
|
|
|
1737
|
-
- [[plutonium-behavior]]
|
|
1738
|
-
- [[plutonium-tenancy]]
|
|
1739
|
-
- [[plutonium-ui]]
|
|
1740
|
-
- [[plutonium-testing]]
|
|
1754
|
+
- [[plutonium-behavior]]: controllers, policies (`permitted_attributes_for_*`, action methods), interactions
|
|
1755
|
+
- [[plutonium-tenancy]]: `associated_with`, `relation_scope`, nested resources
|
|
1756
|
+
- [[plutonium-ui]]: custom Phlex pages, forms, displays, tables
|
|
1757
|
+
- [[plutonium-testing]]: testing resources, definitions, policies, interactions
|