plutonium 0.50.0 → 0.51.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 +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 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, index views, page customization. The single source for "what is a resource and how do I configure one".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Resources
|
|
7
|
+
|
|
8
|
+
A resource = model + migration + controller + policy + definition. This skill covers all three of: **creating** the resource, the **model** layer, and the **definition** (UI, fields, query, actions).
|
|
9
|
+
|
|
10
|
+
For tenancy / `associated_with` / `relation_scope`, load [[plutonium-tenancy]]. For policy bodies, load [[plutonium-behavior]] (controllers + policies + interactions). For custom Phlex components, load [[plutonium-ui]].
|
|
11
|
+
|
|
12
|
+
## 🚨 Critical (read first)
|
|
13
|
+
|
|
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
|
+
- **Pass `--dest`** on every scaffold: `--dest=main_app` or `--dest=package_name`. Skips the interactive prompt.
|
|
16
|
+
- **Quote field args with `?` or `{}`** to prevent shell expansion: `'field:type?'`, `'field:decimal{10,2}'`.
|
|
17
|
+
- **Run `pu:res:conn` next** — without it the resource has no portal routes and is invisible.
|
|
18
|
+
- **Let auto-detection work.** Plutonium reads your model. Only declare `field`/`input`/`display`/`column` when overriding the default.
|
|
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
|
+
- **Custom actions require a policy method.** `action :publish` needs `def publish?` on the policy.
|
|
21
|
+
- **`has_cents` virtual accessor** — reference `:price`, NEVER `:price_cents`, in policies and definitions.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Part 1 — Creating a Resource
|
|
26
|
+
|
|
27
|
+
## Quick checklist
|
|
28
|
+
|
|
29
|
+
1. Pick destination: `--dest=main_app` or `--dest=package_name`.
|
|
30
|
+
2. Run `rails g pu:res:scaffold ResourceName field:type ... --dest=<dest>`.
|
|
31
|
+
3. Review the generated migration — add cascade deletes, composite indexes, defaults.
|
|
32
|
+
4. `rails db:migrate`.
|
|
33
|
+
5. `rails g pu:res:conn ResourceName --dest=<portal_name>`.
|
|
34
|
+
6. Customize the policy's `permitted_attributes_for_*` as needed.
|
|
35
|
+
7. Open the portal route in the browser.
|
|
36
|
+
|
|
37
|
+
## Command Syntax
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
rails g pu:res:scaffold MODEL_NAME \
|
|
41
|
+
field1:type \
|
|
42
|
+
field2:type \
|
|
43
|
+
--dest=DESTINATION
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Quote any field with `?` or `{}`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
'field:type?' # nullable
|
|
50
|
+
'field:decimal{10,2}' # options
|
|
51
|
+
'field:decimal?{10,2}' # both
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Field Type Syntax
|
|
55
|
+
|
|
56
|
+
Format: `name:type[?][{options}][:index_type]`
|
|
57
|
+
|
|
58
|
+
- `?` after the type → nullable (`null: true` in migration, `optional: true` on `belongs_to`)
|
|
59
|
+
- `{...}` → type options: `{default:X}`, `{10,2}` precision/scale, `{class_name:User}`
|
|
60
|
+
- `:index_type` → `index` (regular) or `uniq` (unique)
|
|
61
|
+
- Quote any field containing `?` or `{}` to prevent shell expansion
|
|
62
|
+
|
|
63
|
+
### Basic Types
|
|
64
|
+
|
|
65
|
+
| Syntax | Result |
|
|
66
|
+
|--------|--------|
|
|
67
|
+
| `name:string` | Required string |
|
|
68
|
+
| `'name:string?'` | Nullable string |
|
|
69
|
+
| `age:integer` | Required integer |
|
|
70
|
+
| `'age:integer?'` | Nullable integer |
|
|
71
|
+
| `active:boolean` | Required boolean |
|
|
72
|
+
| `'active:boolean?'` | Nullable boolean |
|
|
73
|
+
| `content:text` | Required text |
|
|
74
|
+
| `'content:text?'` | Nullable text |
|
|
75
|
+
| `birth_date:date` | Required date |
|
|
76
|
+
| `'anniversary:date?'` | Nullable date |
|
|
77
|
+
| `starts_at:datetime` | Required datetime |
|
|
78
|
+
| `'ends_at:datetime?'` | Nullable datetime |
|
|
79
|
+
| `alarm_time:time` | Required time |
|
|
80
|
+
| `'reminder_time:time?'` | Nullable time |
|
|
81
|
+
| `metadata:json` | JSON field |
|
|
82
|
+
| `settings:jsonb` | JSONB (PostgreSQL + SQLite) |
|
|
83
|
+
| `external_id:uuid` | UUID field |
|
|
84
|
+
|
|
85
|
+
### PostgreSQL-Specific Types
|
|
86
|
+
|
|
87
|
+
Auto-mapped to SQLite equivalents when needed:
|
|
88
|
+
|
|
89
|
+
| Type | PostgreSQL | SQLite |
|
|
90
|
+
|------|------------|--------|
|
|
91
|
+
| `jsonb` | `jsonb` | `json` |
|
|
92
|
+
| `hstore` | `hstore` | `json` |
|
|
93
|
+
| `uuid` | `uuid` | `string` |
|
|
94
|
+
| `inet` | `inet` | `string` |
|
|
95
|
+
| `cidr` | `cidr` | `string` |
|
|
96
|
+
| `macaddr` | `macaddr` | `string` |
|
|
97
|
+
| `ltree` | `ltree` | `string` |
|
|
98
|
+
|
|
99
|
+
### Default Values
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
'status:string{default:draft}'
|
|
103
|
+
'active:boolean{default:true}'
|
|
104
|
+
'priority:integer{default:0}'
|
|
105
|
+
'rating:float{default:4.5}'
|
|
106
|
+
'status:string?{default:pending}'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
JSON/JSONB defaults (parsed as JSON first, then string fallback):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
'metadata:jsonb{default:{}}'
|
|
113
|
+
'tags:jsonb{default:[]}'
|
|
114
|
+
'settings:jsonb{default:{"theme":"dark"}}'
|
|
115
|
+
'config:jsonb?{default:{}}'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Decimal with Precision
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
'amount:decimal{10,2}' # precision: 10, scale: 2
|
|
122
|
+
'price:decimal{10,2,default:0}' # with default
|
|
123
|
+
'balance:decimal?{15,2,default:0}' # nullable + default
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### References / Associations
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
company:belongs_to # required FK
|
|
130
|
+
'parent:belongs_to?' # nullable (null: true + optional: true)
|
|
131
|
+
user:references # same as belongs_to
|
|
132
|
+
blogging/post:belongs_to # cross-package reference
|
|
133
|
+
'author:belongs_to{class_name:User}' # custom class_name
|
|
134
|
+
'reviewer:belongs_to?{class_name:User}' # nullable + class_name
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Index Types (third segment)
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
email:string:index # regular index
|
|
141
|
+
email:string:uniq # unique index
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Special Types
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
password_digest # has_secure_password
|
|
148
|
+
auth_token:token # has_secure_token (auto unique index)
|
|
149
|
+
content:rich_text # has_rich_text
|
|
150
|
+
avatar:attachment # has_one_attached
|
|
151
|
+
photos:attachments # has_many_attached
|
|
152
|
+
price_cents:integer # use with has_cents in model
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Generator Options
|
|
156
|
+
|
|
157
|
+
- `--dest=DESTINATION` — `main_app` or `package_name` (**required**)
|
|
158
|
+
- `--no-model` — skip model file
|
|
159
|
+
- `--no-migration` — skip migration
|
|
160
|
+
|
|
161
|
+
For existing models that already include `Plutonium::Resource::Record`:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
rails g pu:res:scaffold Post --no-migration --dest=main_app
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Run with no fields to auto-import from `model.content_columns` (regenerates the model file — review the diff).
|
|
168
|
+
|
|
169
|
+
## What Gets Generated
|
|
170
|
+
|
|
171
|
+
**Main app:**
|
|
172
|
+
- `app/models/model_name.rb`
|
|
173
|
+
- `db/migrate/xxx_create_model_names.rb`
|
|
174
|
+
- `app/controllers/model_names_controller.rb`
|
|
175
|
+
- `app/policies/model_name_policy.rb`
|
|
176
|
+
- `app/definitions/model_name_definition.rb`
|
|
177
|
+
|
|
178
|
+
**Packaged** (paths nested under `packages/package_name/...` for controller/policy/definition; model and migration stay at app root with namespace).
|
|
179
|
+
|
|
180
|
+
## Migration Customizations
|
|
181
|
+
|
|
182
|
+
Always review before migrating. Per project convention, **inline indexes/FKs in the create_table block**:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
create_table :model_names do |t|
|
|
186
|
+
t.belongs_to :parent, null: false, foreign_key: {on_delete: :cascade}
|
|
187
|
+
t.string :name, null: false
|
|
188
|
+
|
|
189
|
+
t.timestamps
|
|
190
|
+
|
|
191
|
+
t.index :name
|
|
192
|
+
t.index [:parent_id, :name], unique: true
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
For non-trivial defaults, edit the migration directly:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
t.datetime :published_at, default: -> { "CURRENT_TIMESTAMP" }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Examples
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# Main app resource with associations and a nullable text field
|
|
206
|
+
rails g pu:res:scaffold Post \
|
|
207
|
+
user:belongs_to \
|
|
208
|
+
title:string \
|
|
209
|
+
'content:text?' \
|
|
210
|
+
'published_at:datetime?' \
|
|
211
|
+
--dest=main_app
|
|
212
|
+
|
|
213
|
+
# Precision + indexes
|
|
214
|
+
rails g pu:res:scaffold Property \
|
|
215
|
+
company:belongs_to \
|
|
216
|
+
code:string:uniq \
|
|
217
|
+
'latitude:decimal{11,8}' \
|
|
218
|
+
'value:decimal?{15,2}' \
|
|
219
|
+
--dest=main_app
|
|
220
|
+
|
|
221
|
+
# Cross-package reference
|
|
222
|
+
rails g pu:res:scaffold Comment \
|
|
223
|
+
user:belongs_to \
|
|
224
|
+
blogging/post:belongs_to \
|
|
225
|
+
body:text \
|
|
226
|
+
--dest=comments
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
# Part 2 — The Model Layer
|
|
232
|
+
|
|
233
|
+
## What `Plutonium::Resource::Record` provides
|
|
234
|
+
|
|
235
|
+
| Module | Purpose |
|
|
236
|
+
|--------|---------|
|
|
237
|
+
| `HasCents` | Monetary values (cents ↔ decimal) |
|
|
238
|
+
| `Routes` | URL params, `to_param` customization |
|
|
239
|
+
| `Labeling` | `to_label` for human-readable names |
|
|
240
|
+
| `FieldNames` | Field introspection by category |
|
|
241
|
+
| `Associations` | SGID methods on every association |
|
|
242
|
+
| `AssociatedWith` | Multi-tenant scoping (see [[plutonium-tenancy]]) |
|
|
243
|
+
|
|
244
|
+
Standard setup (created by `pu:core:install`):
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
248
|
+
include Plutonium::Resource::Record
|
|
249
|
+
primary_abstract_class
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
class ResourceRecord < ApplicationRecord
|
|
253
|
+
self.abstract_class = true
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Section Order
|
|
258
|
+
|
|
259
|
+
The scaffold lays out resource models in a strict order — keep new code in the right section so files stay scannable:
|
|
260
|
+
|
|
261
|
+
1. Concerns (`include`)
|
|
262
|
+
2. Constants (`TYPES = {...}.freeze`)
|
|
263
|
+
3. Enums
|
|
264
|
+
4. Model configurations (`has_cents`)
|
|
265
|
+
5. `belongs_to`
|
|
266
|
+
6. `has_one`
|
|
267
|
+
7. `has_many`
|
|
268
|
+
8. Attachments (`has_one_attached`, `has_many_attached`)
|
|
269
|
+
9. Scopes
|
|
270
|
+
10. Validations
|
|
271
|
+
11. Callbacks
|
|
272
|
+
12. Delegations
|
|
273
|
+
13. Misc macros (`has_rich_text`, `has_secure_token`, `has_secure_password`)
|
|
274
|
+
14. Public methods, then `private`, then private methods
|
|
275
|
+
|
|
276
|
+
Example:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
class Property < ResourceRecord
|
|
280
|
+
TYPES = {apartment: "Apartment", house: "House"}.freeze
|
|
281
|
+
|
|
282
|
+
enum :state, archived: 0, active: 1
|
|
283
|
+
|
|
284
|
+
has_cents :market_value_cents
|
|
285
|
+
|
|
286
|
+
belongs_to :company
|
|
287
|
+
has_one :address
|
|
288
|
+
has_many :units
|
|
289
|
+
|
|
290
|
+
has_one_attached :photo
|
|
291
|
+
|
|
292
|
+
scope :active, -> { where(state: :active) }
|
|
293
|
+
|
|
294
|
+
validates :name, presence: true
|
|
295
|
+
validates :property_code, presence: true, uniqueness: {scope: :company_id}
|
|
296
|
+
|
|
297
|
+
before_validation :generate_code, on: :create
|
|
298
|
+
|
|
299
|
+
has_rich_text :description
|
|
300
|
+
|
|
301
|
+
def full_address
|
|
302
|
+
address&.to_s
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def generate_code
|
|
308
|
+
self.property_code ||= SecureRandom.hex(4).upcase
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Monetary Handling (`has_cents`)
|
|
314
|
+
|
|
315
|
+
Stores money as integer cents; exposes a decimal virtual accessor.
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
class Product < ResourceRecord
|
|
319
|
+
has_cents :price_cents # virtual :price (default rate 100)
|
|
320
|
+
has_cents :cost_cents, name: :wholesale # custom accessor name
|
|
321
|
+
has_cents :tax_cents, rate: 1000 # 3 decimal places
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
product.price = 19.99
|
|
325
|
+
product.price_cents # => 1999
|
|
326
|
+
product.price # => 19.99
|
|
327
|
+
|
|
328
|
+
# Truncates, doesn't round
|
|
329
|
+
product.price = 10.999
|
|
330
|
+
product.price_cents # => 1099
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**Critical: in policies and definitions, reference the virtual accessor (`:price`), NOT the column (`:price_cents`).** Generators sometimes emit `_cents` in the policy — fix by hand:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# Policy
|
|
337
|
+
permitted_attributes_for_create { %i[name price] } # NOT :price_cents
|
|
338
|
+
|
|
339
|
+
# Definition
|
|
340
|
+
field :price, as: :decimal
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Validation on the cents column propagates a generic error to the virtual:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
validates :price_cents, numericality: {greater_than: 0}
|
|
347
|
+
# product.errors[:price] => ["is invalid"]
|
|
348
|
+
# product.errors[:price_cents] => ["must be greater than 0"]
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## SGID on Associations
|
|
352
|
+
|
|
353
|
+
Every association gets Signed Global ID methods for secure serialization (form params, API payloads, hidden fields).
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
class Post < ResourceRecord
|
|
357
|
+
belongs_to :user
|
|
358
|
+
has_many :tags
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
post.user_sgid # singular: get
|
|
362
|
+
post.user_sgid = "..." # singular: set
|
|
363
|
+
|
|
364
|
+
post.tag_sgids # collection: get array
|
|
365
|
+
post.tag_sgids = [...] # collection: bulk replace
|
|
366
|
+
post.add_tag_sgid("...") # collection: append
|
|
367
|
+
post.remove_tag_sgid("...") # collection: remove
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## URL Routing
|
|
371
|
+
|
|
372
|
+
`path_parameter` and `dynamic_path_parameter` are **class-level macros** (private class methods) — call them in the class body, not as instance methods.
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
# Default: numeric id
|
|
376
|
+
user.to_param # => "1"
|
|
377
|
+
|
|
378
|
+
# Stable, unique field
|
|
379
|
+
class User < ResourceRecord
|
|
380
|
+
path_parameter :username
|
|
381
|
+
end
|
|
382
|
+
# /users/john_doe
|
|
383
|
+
|
|
384
|
+
# SEO-friendly: id + slug
|
|
385
|
+
class Article < ResourceRecord
|
|
386
|
+
dynamic_path_parameter :title
|
|
387
|
+
end
|
|
388
|
+
# /articles/1-my-great-article
|
|
389
|
+
|
|
390
|
+
Article.from_path_param("1-my-great-article") # extracts id, finds by id
|
|
391
|
+
User.from_path_param("john_doe") # finds by username
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Labeling
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
# Auto: tries :name, then :title, then "User #1"
|
|
398
|
+
user.to_label
|
|
399
|
+
|
|
400
|
+
# Override
|
|
401
|
+
class Product < ResourceRecord
|
|
402
|
+
def to_label = "#{name} (#{sku})"
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
## Field Introspection
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
User.resource_field_names # all fields
|
|
410
|
+
User.content_column_field_names # DB columns
|
|
411
|
+
User.belongs_to_association_field_names
|
|
412
|
+
User.has_one_association_field_names
|
|
413
|
+
User.has_many_association_field_names
|
|
414
|
+
User.has_one_attached_field_names
|
|
415
|
+
User.has_many_attached_field_names
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
# Part 3 — The Definition Layer
|
|
421
|
+
|
|
422
|
+
Definitions configure **how** a resource is rendered and interacted with.
|
|
423
|
+
|
|
424
|
+
🚨 **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.
|
|
425
|
+
|
|
426
|
+
File locations:
|
|
427
|
+
|
|
428
|
+
- Main app: `app/definitions/model_name_definition.rb`
|
|
429
|
+
- Packages: `packages/pkg_name/app/definitions/pkg_name/model_name_definition.rb`
|
|
430
|
+
|
|
431
|
+
## Hierarchy
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
# app/definitions/resource_definition.rb (base, created at install)
|
|
435
|
+
class ResourceDefinition < Plutonium::Resource::Definition
|
|
436
|
+
action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# app/definitions/post_definition.rb (scaffold)
|
|
440
|
+
class PostDefinition < ResourceDefinition
|
|
441
|
+
scope :published
|
|
442
|
+
input :content, as: :markdown
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Portal override (per-portal customization)
|
|
446
|
+
class AdminPortal::PostDefinition < ::PostDefinition
|
|
447
|
+
input :internal_notes, as: :text
|
|
448
|
+
scope :pending_review
|
|
449
|
+
end
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## Core Methods
|
|
453
|
+
|
|
454
|
+
| Method | Applies To | Use When |
|
|
455
|
+
|--------|-----------|----------|
|
|
456
|
+
| `field` | Forms + Show + Table | Universal type override |
|
|
457
|
+
| `input` | Forms only | Form-specific options |
|
|
458
|
+
| `display` | Show page only | Display-specific options |
|
|
459
|
+
| `column` | Table only | Table-specific options |
|
|
460
|
+
|
|
461
|
+
```ruby
|
|
462
|
+
class PostDefinition < ResourceDefinition
|
|
463
|
+
field :content, as: :markdown # everywhere
|
|
464
|
+
input :title, hint: "Be descriptive"
|
|
465
|
+
display :content, wrapper: {class: "col-span-full"}
|
|
466
|
+
column :view_count, align: :end
|
|
467
|
+
end
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
## Separation of Concerns
|
|
471
|
+
|
|
472
|
+
| Layer | Purpose | Example |
|
|
473
|
+
|-------|---------|---------|
|
|
474
|
+
| Definition | HOW fields render | `input :content, as: :markdown` |
|
|
475
|
+
| Policy | WHAT is visible/editable | `permitted_attributes_for_read` |
|
|
476
|
+
| Interaction | Business logic | `resource.update!(state: :archived)` |
|
|
477
|
+
|
|
478
|
+
## Available Field Types
|
|
479
|
+
|
|
480
|
+
### Input Types (forms)
|
|
481
|
+
|
|
482
|
+
| Category | Types |
|
|
483
|
+
|----------|-------|
|
|
484
|
+
| Text | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
|
|
485
|
+
| Rich Text | `:markdown` (EasyMDE) |
|
|
486
|
+
| Numeric | `:number`, `:integer`, `:decimal`, `:range` |
|
|
487
|
+
| Boolean | `:boolean` |
|
|
488
|
+
| Date/Time | `:date`, `:time`, `:datetime` |
|
|
489
|
+
| Selection | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
|
|
490
|
+
| Files | `:file`, `:uppy`, `:attachment` |
|
|
491
|
+
| Associations | `:association`, `:secure_association`, `:belongs_to`, `:has_many`, `:has_one` |
|
|
492
|
+
| Special | `:hidden`, `:color`, `:phone` |
|
|
493
|
+
|
|
494
|
+
### Display Types (show / index)
|
|
495
|
+
|
|
496
|
+
`:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`
|
|
497
|
+
|
|
498
|
+
## Field Options
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
input :title,
|
|
502
|
+
label: "Custom Label",
|
|
503
|
+
hint: "Help text",
|
|
504
|
+
placeholder: "Enter value",
|
|
505
|
+
description: "For displays", # appears on show page
|
|
506
|
+
|
|
507
|
+
# tag-level HTML
|
|
508
|
+
class: "custom-class",
|
|
509
|
+
data: {controller: "custom"},
|
|
510
|
+
required: true,
|
|
511
|
+
readonly: true,
|
|
512
|
+
disabled: true,
|
|
513
|
+
|
|
514
|
+
# wrapper
|
|
515
|
+
wrapper: {class: "col-span-full"}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## Select / Choices
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
# Static
|
|
522
|
+
input :category, as: :select, choices: %w[Tech Business Lifestyle]
|
|
523
|
+
input :status, as: :select, choices: Post.statuses.keys
|
|
524
|
+
|
|
525
|
+
# Dynamic — must use a block
|
|
526
|
+
input :author do |f|
|
|
527
|
+
f.select_tag choices: User.active.pluck(:name, :id)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# With context (current_user, object, params, request available in block)
|
|
531
|
+
input :team_members do |f|
|
|
532
|
+
f.select_tag choices: current_user.organization.users.pluck(:name, :id)
|
|
533
|
+
end
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
## Conditional Rendering
|
|
537
|
+
|
|
538
|
+
```ruby
|
|
539
|
+
display :published_at, condition: -> { object.published? }
|
|
540
|
+
display :rejection_reason, condition: -> { object.rejected? }
|
|
541
|
+
field :debug_info, condition: -> { Rails.env.development? }
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Use `condition` for UI state; use the policy for authorization.
|
|
545
|
+
|
|
546
|
+
## Dynamic Forms (`pre_submit`)
|
|
547
|
+
|
|
548
|
+
A `pre_submit: true` field triggers a server re-render on change, re-evaluating `condition:` procs. Use for cascading or context-dependent forms.
|
|
549
|
+
|
|
550
|
+
```ruby
|
|
551
|
+
class QuestionDefinition < ResourceDefinition
|
|
552
|
+
# :select + choices is a real override (model column is just a string)
|
|
553
|
+
input :question_type, as: :select,
|
|
554
|
+
choices: %w[text choice scale],
|
|
555
|
+
pre_submit: true
|
|
556
|
+
|
|
557
|
+
# No `as:` — types are auto-detected from the model. We only declare to add `condition:`.
|
|
558
|
+
input :max_length, condition: -> { object.question_type == "text" }
|
|
559
|
+
input :choices, condition: -> { object.question_type == "choice" }
|
|
560
|
+
input :min_value, condition: -> { object.question_type == "scale" }
|
|
561
|
+
end
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Dynamic choices follow the same pattern:
|
|
565
|
+
|
|
566
|
+
```ruby
|
|
567
|
+
input :category, as: :select,
|
|
568
|
+
choices: Category.pluck(:name, :id),
|
|
569
|
+
pre_submit: true
|
|
570
|
+
|
|
571
|
+
input :subcategory do |f|
|
|
572
|
+
choices = object.category.present? ?
|
|
573
|
+
Category.find(object.category).subcategories.pluck(:name, :id) : []
|
|
574
|
+
f.select_tag choices: choices
|
|
575
|
+
end
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Tips:
|
|
579
|
+
- Only add `pre_submit:` to fields that gate visibility of others.
|
|
580
|
+
- Avoid on frequently-changed fields (every keystroke = submit).
|
|
581
|
+
|
|
582
|
+
## Custom Rendering
|
|
583
|
+
|
|
584
|
+
**Display block — return any component:**
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
display :status do |field|
|
|
588
|
+
StatusBadgeComponent.new(value: field.value, class: field.dom.css_class)
|
|
589
|
+
end
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
**Input block — must use form builder methods:**
|
|
593
|
+
|
|
594
|
+
```ruby
|
|
595
|
+
input :birth_date do |f|
|
|
596
|
+
case object.age_category
|
|
597
|
+
when 'adult' then f.date_tag(min: 18.years.ago.to_date)
|
|
598
|
+
when 'minor' then f.date_tag(max: 18.years.ago.to_date)
|
|
599
|
+
else f.date_tag
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**`phlexi_tag` for declarative custom display.** The `with:` option takes either a Phlex component class, or a proc whose body is **rendered inside a Phlex context** — so HTML tags (`span`, `div`, `a`, …) and Tailwind classes are first-class. The proc receives `(value, attrs)` where `value` is the field value and `attrs` are wrapper attributes.
|
|
605
|
+
|
|
606
|
+
```ruby
|
|
607
|
+
# Component class — preferred for anything reusable
|
|
608
|
+
display :status, as: :phlexi_tag, with: StatusBadgeComponent
|
|
609
|
+
|
|
610
|
+
# Inline Phlex proc — `span` here is a Phlex tag method, not Ruby/Rails
|
|
611
|
+
display :priority, as: :phlexi_tag, with: ->(value, attrs) {
|
|
612
|
+
case value
|
|
613
|
+
when 'high' then span(class: "badge badge-danger") { "High" }
|
|
614
|
+
when 'medium' then span(class: "badge badge-warning") { "Medium" }
|
|
615
|
+
else span(class: "badge badge-info") { "Low" }
|
|
616
|
+
end
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
See [[plutonium-ui]] for writing custom Phlex components.
|
|
621
|
+
|
|
622
|
+
**Custom component classes** (Phlex components — see [[plutonium-ui]]):
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
input :color_picker, as: ColorPickerComponent
|
|
626
|
+
display :chart, as: ChartComponent
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
## Column Options
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
column :title, align: :start # :start (default), :center, :end
|
|
633
|
+
column :amount, align: :end
|
|
634
|
+
|
|
635
|
+
# formatter — receives just the value
|
|
636
|
+
column :price, formatter: ->(v) { "$%.2f" % v if v }
|
|
637
|
+
|
|
638
|
+
# block — receives the full record
|
|
639
|
+
column :full_name do |record|
|
|
640
|
+
"#{record.first_name} #{record.last_name}"
|
|
641
|
+
end
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
## Nested Inputs
|
|
645
|
+
|
|
646
|
+
Inline forms for associated records. Requires `accepts_nested_attributes_for` on the model.
|
|
647
|
+
|
|
648
|
+
```ruby
|
|
649
|
+
class Post < ResourceRecord
|
|
650
|
+
has_many :comments
|
|
651
|
+
has_one :metadata
|
|
652
|
+
|
|
653
|
+
accepts_nested_attributes_for :comments, allow_destroy: true, limit: 10
|
|
654
|
+
accepts_nested_attributes_for :metadata, update_only: true
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
class PostDefinition < ResourceDefinition
|
|
658
|
+
nested_input :comments do |n|
|
|
659
|
+
n.input :body, as: :text
|
|
660
|
+
n.input :author_name
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
nested_input :metadata, using: PostMetadataDefinition, fields: %i[seo_title seo_description]
|
|
664
|
+
end
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Options
|
|
668
|
+
|
|
669
|
+
| Option | Description |
|
|
670
|
+
|--------|-------------|
|
|
671
|
+
| `limit` | Max records (auto-detected from model, default 10) |
|
|
672
|
+
| `allow_destroy` | Show delete checkbox (auto-detected) |
|
|
673
|
+
| `update_only` | Hide "Add" button — only edit existing |
|
|
674
|
+
| `description` | Help text above section |
|
|
675
|
+
| `condition` | Proc to show/hide |
|
|
676
|
+
| `using` | Another Definition class |
|
|
677
|
+
| `fields` | Subset of fields from the referenced definition |
|
|
678
|
+
|
|
679
|
+
### Gotchas
|
|
680
|
+
|
|
681
|
+
- Model needs `accepts_nested_attributes_for`.
|
|
682
|
+
- The child's `belongs_to` **must** declare `inverse_of: :parent_assoc`. Without it, in-memory validation fails with "Parent must exist" because the parent isn't saved yet.
|
|
683
|
+
- **Do NOT put `*_attributes` hashes in `permitted_attributes_for_*`.** Plutonium extracts nested params via the form definition, not the policy. The policy permits just the association name (`:variants`); `nested_input :variants` handles the rest.
|
|
684
|
+
- For custom class names, use `class_name:` in the model and `using:` in the definition.
|
|
685
|
+
- `update_only: true` hides the Add button.
|
|
686
|
+
|
|
687
|
+
## File Uploads
|
|
688
|
+
|
|
689
|
+
```ruby
|
|
690
|
+
input :avatar, as: :file
|
|
691
|
+
input :avatar, as: :uppy
|
|
692
|
+
input :documents, as: :file, multiple: true
|
|
693
|
+
input :documents, as: :uppy,
|
|
694
|
+
allowed_file_types: ['.pdf', '.doc'],
|
|
695
|
+
max_file_size: 5.megabytes
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
## Block Context
|
|
699
|
+
|
|
700
|
+
Inside `condition` procs and block-form `input`/`display`:
|
|
701
|
+
|
|
702
|
+
- `object` — the record
|
|
703
|
+
- `current_user`
|
|
704
|
+
- `current_parent` — for nested resources
|
|
705
|
+
- `request`, `params`
|
|
706
|
+
- All helper methods
|
|
707
|
+
|
|
708
|
+
## Runtime Customization Hooks
|
|
709
|
+
|
|
710
|
+
For dynamic per-request logic, override:
|
|
711
|
+
|
|
712
|
+
```ruby
|
|
713
|
+
def customize_fields # add/modify fields
|
|
714
|
+
def customize_inputs # add/modify inputs
|
|
715
|
+
def customize_displays # add/modify displays
|
|
716
|
+
def customize_filters
|
|
717
|
+
def customize_actions
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
## Form & Page Configuration
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
class PostDefinition < ResourceDefinition
|
|
724
|
+
# "Save and add another" / "Update and continue editing"
|
|
725
|
+
# nil (default) = auto (hidden for singular, shown for plural)
|
|
726
|
+
submit_and_continue false
|
|
727
|
+
|
|
728
|
+
# How :new / :edit render
|
|
729
|
+
# :slideover (default), :centered, or false (full pages)
|
|
730
|
+
modal :centered
|
|
731
|
+
|
|
732
|
+
# Titles
|
|
733
|
+
index_page_title "All Posts"
|
|
734
|
+
show_page_title -> { "#{current_record!.title} - Details" }
|
|
735
|
+
|
|
736
|
+
# Breadcrumbs
|
|
737
|
+
breadcrumbs true
|
|
738
|
+
show_page_breadcrumbs false
|
|
739
|
+
|
|
740
|
+
# Custom page classes — inherit from the parent's nested class
|
|
741
|
+
class IndexPage < IndexPage
|
|
742
|
+
def view_template(&block)
|
|
743
|
+
div(class: "custom-header") { h1 { "Custom" } }
|
|
744
|
+
super(&block)
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
class Form < Form
|
|
749
|
+
def form_template
|
|
750
|
+
div(class: "grid grid-cols-2") do
|
|
751
|
+
render field(:title).input_tag
|
|
752
|
+
render field(:content).easymde_tag
|
|
753
|
+
end
|
|
754
|
+
render_actions
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
`modal:` only affects the framework `:new` / `:edit` actions. Custom actions have their own per-action `modal:` option (default `:centered`).
|
|
761
|
+
|
|
762
|
+
## Metadata Panel (show page)
|
|
763
|
+
|
|
764
|
+
Declares fields rendered in the show page's right-side aside as label/value rows.
|
|
765
|
+
|
|
766
|
+
```ruby
|
|
767
|
+
metadata :author, :state, :created_at, :updated_at
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
- **Opt-in** — no call → show page is full-width with no aside.
|
|
771
|
+
- **Policy-aware** — fields the user can't see disappear; panel auto-hides if nothing's permitted.
|
|
772
|
+
- **Deduplicated** — listed fields are removed from the main details card.
|
|
773
|
+
- **Responsive** — side-by-side at `lg+`, stacked below.
|
|
774
|
+
|
|
775
|
+
Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
|
|
776
|
+
|
|
777
|
+
## Index Views (Table & Grid)
|
|
778
|
+
|
|
779
|
+
Resources can offer both Table and Grid views; user choice persists per-resource via cookie.
|
|
780
|
+
|
|
781
|
+
```ruby
|
|
782
|
+
class UserDefinition < ResourceDefinition
|
|
783
|
+
# No `index_views :table, :grid` needed — `grid_fields` auto-enables :grid alongside the default :table.
|
|
784
|
+
grid_fields(
|
|
785
|
+
image: :avatar, # ActiveStorage, Shrine, or URL
|
|
786
|
+
header: :name, # falls back to to_label
|
|
787
|
+
subheader: :email,
|
|
788
|
+
body: :bio,
|
|
789
|
+
meta: [:role, :status], # rendered as small pills
|
|
790
|
+
footer: :last_seen_at # falls back to :created_at
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
default_index_view :grid # optional — initial view when no cookie
|
|
794
|
+
grid_layout :media # :compact (default) or :media
|
|
795
|
+
grid_columns 3 # pin lg+ cols; default is 1/2/3/4 responsive
|
|
796
|
+
end
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Only declare `index_views` explicitly if you want to **disable** one (e.g. `index_views :grid` to remove the table view).
|
|
800
|
+
|
|
801
|
+
| Method | Purpose |
|
|
802
|
+
|--------|---------|
|
|
803
|
+
| `index_views :table, :grid` | Which views are available. Default `[:table]`. Only declare to disable one. |
|
|
804
|
+
| `default_index_view :grid` | Initial view when no cookie. |
|
|
805
|
+
| `grid_fields(...)` | Map card slots to fields. **Implicitly enables `:grid`**. |
|
|
806
|
+
| `grid_layout :media` | `:compact` (image left) or `:media` (image on top). |
|
|
807
|
+
| `grid_columns 3` | Override responsive column count. |
|
|
808
|
+
|
|
809
|
+
All grid slots are optional; slots pointing at unpermitted fields collapse silently.
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
# Part 4 — Query: Search, Filters, Scopes, Sorting
|
|
814
|
+
|
|
815
|
+
```ruby
|
|
816
|
+
class PostDefinition < ResourceDefinition
|
|
817
|
+
search do |scope, q|
|
|
818
|
+
scope.where("title ILIKE ?", "%#{q}%")
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
filter :title, with: :text, predicate: :contains
|
|
822
|
+
filter :status, with: :select, choices: %w[draft published archived]
|
|
823
|
+
filter :published, with: :boolean
|
|
824
|
+
filter :created_at, with: :date_range
|
|
825
|
+
|
|
826
|
+
scope :published
|
|
827
|
+
default_scope :published
|
|
828
|
+
|
|
829
|
+
sort :title
|
|
830
|
+
sort :created_at
|
|
831
|
+
default_sort :created_at, :desc
|
|
832
|
+
end
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
## Search
|
|
836
|
+
|
|
837
|
+
```ruby
|
|
838
|
+
# Multi-field with associations
|
|
839
|
+
search do |scope, query|
|
|
840
|
+
scope.joins(:author).where(
|
|
841
|
+
"posts.title ILIKE :q OR users.name ILIKE :q",
|
|
842
|
+
q: "%#{query}%"
|
|
843
|
+
).distinct
|
|
844
|
+
end
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
## Filters
|
|
848
|
+
|
|
849
|
+
| Type | Symbol | Params | Options |
|
|
850
|
+
|------|--------|--------|---------|
|
|
851
|
+
| Text | `:text` | `query` | `predicate:` |
|
|
852
|
+
| Boolean | `:boolean` | `value` | `true_label:`, `false_label:` |
|
|
853
|
+
| Date | `:date` | `value` | `predicate:` |
|
|
854
|
+
| Date Range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
|
|
855
|
+
| Select | `:select` | `value` | `choices:`, `multiple:` |
|
|
856
|
+
| Association | `:association` | `value` | `class_name:`, `multiple:` |
|
|
857
|
+
|
|
858
|
+
**Text predicates:** `:eq`, `:not_eq`, `:contains`, `:not_contains`, `:starts_with`, `:ends_with`, `:matches`, `:not_matches`
|
|
859
|
+
**Date predicates:** `:eq`, `:not_eq`, `:lt`, `:lteq`, `:gt`, `:gteq`
|
|
860
|
+
|
|
861
|
+
```ruby
|
|
862
|
+
filter :title, with: :text, predicate: :contains
|
|
863
|
+
filter :active, with: :boolean
|
|
864
|
+
filter :due_date, with: :date, predicate: :lt
|
|
865
|
+
filter :created_at, with: :date_range
|
|
866
|
+
filter :status, with: :select, choices: %w[draft published]
|
|
867
|
+
filter :category, with: :select, choices: -> { Category.pluck(:name) }
|
|
868
|
+
filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
|
|
869
|
+
filter :category, with: :association
|
|
870
|
+
filter :author, with: :association, class_name: User
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
**Custom filter class:**
|
|
874
|
+
|
|
875
|
+
```ruby
|
|
876
|
+
class PriceRangeFilter < Plutonium::Query::Filter
|
|
877
|
+
def apply(scope, min: nil, max: nil)
|
|
878
|
+
scope = scope.where("price >= ?", min) if min.present?
|
|
879
|
+
scope = scope.where("price <= ?", max) if max.present?
|
|
880
|
+
scope
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def customize_inputs
|
|
884
|
+
input :min, as: :number
|
|
885
|
+
input :max, as: :number
|
|
886
|
+
field :min, placeholder: "Min price..."
|
|
887
|
+
field :max, placeholder: "Max price..."
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
filter :price, with: PriceRangeFilter
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
## Scopes
|
|
895
|
+
|
|
896
|
+
Scopes appear as quick filter buttons.
|
|
897
|
+
|
|
898
|
+
```ruby
|
|
899
|
+
scope :published # uses Post.published
|
|
900
|
+
scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
|
|
901
|
+
scope(:mine) { |s| s.where(author: current_user) }
|
|
902
|
+
|
|
903
|
+
default_scope :published # applied on initial load; "All" button clears it
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
## Sorting
|
|
907
|
+
|
|
908
|
+
```ruby
|
|
909
|
+
sort :title
|
|
910
|
+
sort :created_at
|
|
911
|
+
sorts :title, :created_at, :view_count # multiple at once
|
|
912
|
+
|
|
913
|
+
default_sort :created_at, :desc
|
|
914
|
+
default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
## URL Parameters
|
|
918
|
+
|
|
919
|
+
```
|
|
920
|
+
/posts?q[search]=rails
|
|
921
|
+
/posts?q[title][query]=widget
|
|
922
|
+
/posts?q[status][value]=published
|
|
923
|
+
/posts?q[created_at][from]=2024-01-01&q[created_at][to]=2024-12-31
|
|
924
|
+
/posts?q[scope]=recent
|
|
925
|
+
/posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
---
|
|
929
|
+
|
|
930
|
+
# Part 5 — Actions: Custom and Bulk
|
|
931
|
+
|
|
932
|
+
## Action Types
|
|
933
|
+
|
|
934
|
+
| Type flag | Shows In | Use Case |
|
|
935
|
+
|-----------|----------|----------|
|
|
936
|
+
| `resource_action: true` | Index page | Import, Export, Create |
|
|
937
|
+
| `record_action: true` | Show page | Edit, Delete, Archive |
|
|
938
|
+
| `collection_record_action: true` | Table rows | Quick per-row actions |
|
|
939
|
+
| `bulk_action: true` | Selected records | Bulk operations |
|
|
940
|
+
|
|
941
|
+
🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes — don't declare them manually:**
|
|
942
|
+
|
|
943
|
+
| Interaction declares | Inferred flags |
|
|
944
|
+
|---|---|
|
|
945
|
+
| `attribute :resource` | `record_action` + `collection_record_action` |
|
|
946
|
+
| `attribute :resources` (plural) | `bulk_action` |
|
|
947
|
+
| neither | `resource_action` |
|
|
948
|
+
|
|
949
|
+
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:
|
|
950
|
+
|
|
951
|
+
```ruby
|
|
952
|
+
# :resource interaction defaults to record_action + collection_record_action.
|
|
953
|
+
# Hide from the per-row menu, keep it on the show page:
|
|
954
|
+
action :archive, interaction: ArchiveInteraction, collection_record_action: false
|
|
955
|
+
|
|
956
|
+
# Hide from the show page, keep the per-row button:
|
|
957
|
+
action :preview, interaction: PreviewInteraction, record_action: false
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
Declare flags manually for: simple/navigation actions (no `interaction:`), or opting out of an inferred slot.
|
|
961
|
+
|
|
962
|
+
## Action Options
|
|
963
|
+
|
|
964
|
+
```ruby
|
|
965
|
+
action :name,
|
|
966
|
+
# Display
|
|
967
|
+
label: "Custom Label",
|
|
968
|
+
description: "What it does",
|
|
969
|
+
icon: Phlex::TablerIcons::Star,
|
|
970
|
+
color: :danger, # :primary, :secondary, :danger
|
|
971
|
+
|
|
972
|
+
# Visibility (combine as needed)
|
|
973
|
+
resource_action: true,
|
|
974
|
+
record_action: true,
|
|
975
|
+
collection_record_action: true,
|
|
976
|
+
bulk_action: true,
|
|
977
|
+
|
|
978
|
+
# Grouping
|
|
979
|
+
category: :primary, # :primary, :secondary, :danger
|
|
980
|
+
position: 50,
|
|
981
|
+
|
|
982
|
+
# Behavior
|
|
983
|
+
confirmation: "Are you sure?",
|
|
984
|
+
turbo_frame: "_top",
|
|
985
|
+
route_options: {action: :foo},
|
|
986
|
+
modal: :slideover # :centered (default) or :slideover
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
`Action#with(...)` — actions are frozen value objects; clone with overrides:
|
|
990
|
+
|
|
991
|
+
```ruby
|
|
992
|
+
def customize_actions
|
|
993
|
+
base = action(:edit)
|
|
994
|
+
replace_action base.with(turbo_frame: nil)
|
|
995
|
+
end
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
## Simple Actions (Navigation)
|
|
999
|
+
|
|
1000
|
+
Link to existing routes. The target route MUST already exist.
|
|
1001
|
+
|
|
1002
|
+
```ruby
|
|
1003
|
+
action :documentation,
|
|
1004
|
+
label: "Documentation",
|
|
1005
|
+
route_options: {url: "https://docs.example.com"},
|
|
1006
|
+
icon: Phlex::TablerIcons::Book,
|
|
1007
|
+
resource_action: true
|
|
1008
|
+
|
|
1009
|
+
action :reports,
|
|
1010
|
+
route_options: {action: :reports},
|
|
1011
|
+
resource_action: true
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
Named routes are required:
|
|
1015
|
+
|
|
1016
|
+
```ruby
|
|
1017
|
+
resources :posts do
|
|
1018
|
+
collection do
|
|
1019
|
+
get :reports, as: :reports
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
For anything with business logic, use an **Interactive Action** instead.
|
|
1025
|
+
|
|
1026
|
+
## Interactive Actions (Interactions)
|
|
1027
|
+
|
|
1028
|
+
```ruby
|
|
1029
|
+
class PostDefinition < ResourceDefinition
|
|
1030
|
+
action :publish, interaction: PublishInteraction
|
|
1031
|
+
action :archive, interaction: ArchiveInteraction,
|
|
1032
|
+
color: :danger, category: :danger, position: 1000,
|
|
1033
|
+
confirmation: "Are you sure?"
|
|
1034
|
+
end
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
### Single-record interaction
|
|
1038
|
+
|
|
1039
|
+
```ruby
|
|
1040
|
+
class ArchiveInteraction < ResourceInteraction
|
|
1041
|
+
presents label: "Archive",
|
|
1042
|
+
icon: Phlex::TablerIcons::Archive,
|
|
1043
|
+
description: "Archive this record"
|
|
1044
|
+
|
|
1045
|
+
attribute :resource
|
|
1046
|
+
|
|
1047
|
+
def execute
|
|
1048
|
+
resource.archived!
|
|
1049
|
+
succeed(resource).with_message("Record archived successfully.")
|
|
1050
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1051
|
+
failed(e.record.errors)
|
|
1052
|
+
rescue => error
|
|
1053
|
+
failed("Archive failed. Please try again.")
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### With additional inputs (renders a form)
|
|
1059
|
+
|
|
1060
|
+
```ruby
|
|
1061
|
+
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
1062
|
+
presents label: "Invite User", icon: Phlex::TablerIcons::Mail
|
|
1063
|
+
|
|
1064
|
+
attribute :resource
|
|
1065
|
+
attribute :email
|
|
1066
|
+
attribute :role
|
|
1067
|
+
|
|
1068
|
+
input :email, as: :email, hint: "User's email address"
|
|
1069
|
+
input :role, as: :select, choices: %w[admin member viewer]
|
|
1070
|
+
|
|
1071
|
+
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
1072
|
+
validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
|
|
1073
|
+
|
|
1074
|
+
def execute
|
|
1075
|
+
UserInvite.create!(company: resource, email: email, role: role, invited_by: current_user)
|
|
1076
|
+
succeed(resource).with_message("Invitation sent to #{email}.")
|
|
1077
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1078
|
+
failed(e.record.errors)
|
|
1079
|
+
end
|
|
1080
|
+
end
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
### Bulk action
|
|
1084
|
+
|
|
1085
|
+
```ruby
|
|
1086
|
+
class BulkArchiveInteraction < Plutonium::Resource::Interaction
|
|
1087
|
+
presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
|
|
1088
|
+
|
|
1089
|
+
attribute :resources # plural -> bulk
|
|
1090
|
+
|
|
1091
|
+
def execute
|
|
1092
|
+
resources.each(&:archived!)
|
|
1093
|
+
succeed(resources).with_message("#{resources.size} records archived.")
|
|
1094
|
+
rescue => error
|
|
1095
|
+
failed("Bulk archive failed: #{error.message}")
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
# Definition
|
|
1100
|
+
action :bulk_archive, interaction: BulkArchiveInteraction
|
|
1101
|
+
# bulk_action: true inferred from `attribute :resources`
|
|
1102
|
+
|
|
1103
|
+
# Policy — checked per record; fails the request if ANY record is unauthorized
|
|
1104
|
+
class PostPolicy < ResourcePolicy
|
|
1105
|
+
def bulk_archive? = create?
|
|
1106
|
+
end
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
The UI only shows bulk action buttons that ALL selected records support. Records are fetched via `current_authorized_scope`.
|
|
1110
|
+
|
|
1111
|
+
### Resource action (no record)
|
|
1112
|
+
|
|
1113
|
+
```ruby
|
|
1114
|
+
class ImportInteraction < Plutonium::Resource::Interaction
|
|
1115
|
+
presents label: "Import CSV", icon: Phlex::TablerIcons::Upload
|
|
1116
|
+
|
|
1117
|
+
# No :resource or :resources -> resource action
|
|
1118
|
+
attribute :file
|
|
1119
|
+
input :file, as: :file
|
|
1120
|
+
validates :file, presence: true
|
|
1121
|
+
|
|
1122
|
+
def execute
|
|
1123
|
+
succeed(nil).with_message("Import completed.")
|
|
1124
|
+
end
|
|
1125
|
+
end
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
## Interaction Responses
|
|
1129
|
+
|
|
1130
|
+
```ruby
|
|
1131
|
+
def execute
|
|
1132
|
+
succeed(resource).with_message("Done!")
|
|
1133
|
+
succeed(resource)
|
|
1134
|
+
.with_redirect_response(custom_dashboard_path)
|
|
1135
|
+
.with_message("Redirecting...")
|
|
1136
|
+
failed(resource.errors)
|
|
1137
|
+
failed("Something went wrong")
|
|
1138
|
+
failed("Invalid value", :email)
|
|
1139
|
+
end
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
Redirect is automatic on success. Only use `with_redirect_response` for a non-default destination.
|
|
1143
|
+
|
|
1144
|
+
## Default CRUD Actions
|
|
1145
|
+
|
|
1146
|
+
```ruby
|
|
1147
|
+
action :new, resource_action: true, position: 10
|
|
1148
|
+
action :show, collection_record_action: true, position: 10
|
|
1149
|
+
action :edit, record_action: true, position: 20
|
|
1150
|
+
action :destroy, record_action: true, position: 100, category: :danger
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
## Action Authorization
|
|
1154
|
+
|
|
1155
|
+
A custom action only renders if its policy method returns `true`:
|
|
1156
|
+
|
|
1157
|
+
```ruby
|
|
1158
|
+
class PostPolicy < ResourcePolicy
|
|
1159
|
+
def publish? = user.admin? || record.author == user
|
|
1160
|
+
def archive? = user.admin?
|
|
1161
|
+
end
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
## Immediate vs Form
|
|
1165
|
+
|
|
1166
|
+
- **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.
|
|
1167
|
+
- **Form** — interaction declares extra `attribute`/`input` beyond `:resource`/`:resources`. Renders a modal form first; no auto-confirmation (the form itself is the confirmation step).
|
|
1168
|
+
|
|
1169
|
+
---
|
|
1170
|
+
|
|
1171
|
+
## Related Skills
|
|
1172
|
+
|
|
1173
|
+
- [[plutonium-behavior]] — controllers, policies (`permitted_attributes_for_*`, action methods), interactions
|
|
1174
|
+
- [[plutonium-tenancy]] — `associated_with`, `relation_scope`, nested resources
|
|
1175
|
+
- [[plutonium-ui]] — custom Phlex pages, forms, displays, tables
|
|
1176
|
+
- [[plutonium-testing]] — testing resources, definitions, policies, interactions
|