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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. 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