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
@@ -1,1223 +0,0 @@
1
- ---
2
- name: plutonium-definition
3
- description: Use BEFORE editing a resource definition — adding fields, inputs, displays, columns, metadata, index views (table/grid), search, filters, scopes, custom actions, modal/slideover behavior, or bulk actions.
4
- ---
5
-
6
- # Plutonium Resource Definitions
7
-
8
- ## 🚨 Critical (read first)
9
- - **Use generators.** `pu:res:scaffold` creates the base definition, `pu:res:conn` creates portal-specific overrides, `pu:field:input` / `pu:field:renderer` create custom components.
10
- - **Let auto-detection work.** Only declare fields/inputs/displays/columns when overriding defaults — Plutonium reads your model.
11
- - **Authorization goes in policies, not `condition:` procs.** Use `condition` for UI state logic (e.g. "only show `published_at` when published"). Use **policy** `permitted_attributes_for_*` for "who can see this field".
12
- - **Custom actions require a policy method** — `action :publish` requires `def publish?` on the policy.
13
- - **Related skills:** `plutonium-policy` (permitted attributes, action permissions), `plutonium-interaction` (business logic for actions), `plutonium-forms` (custom form templates), `plutonium-views` (custom page classes).
14
-
15
- ## Quick checklist
16
-
17
- Editing / extending a definition:
18
-
19
- 1. Confirm the definition was generated by `pu:res:scaffold` or `pu:res:conn`.
20
- 2. Let auto-detection handle fields; only `field`/`input`/`display`/`column` when overriding defaults.
21
- 3. For search/filter/sort, add `search`, `filter :name, with: :text/:select/:date/...`, `scope :name`, `sort :name`.
22
- 4. For custom actions, define an interaction class and register it: `action :name, interaction: MyInteraction`.
23
- 5. For bulk actions, make the interaction accept `attribute :resources` (plural).
24
- 6. Add policy methods matching each custom action (`def publish?`, `def archive?`, etc.).
25
- 7. For per-portal overrides, edit `packages/<portal>/app/definitions/<portal>/<resource>_definition.rb`.
26
- 8. Test the index page, show page, new/edit form, and any actions in the browser.
27
-
28
- ## Contents
29
-
30
- This skill covers three concerns. Jump to the section you need:
31
-
32
- **Fields, inputs, displays, columns** (this top section)
33
- - [Definition Structure](#definition-structure) · [Definition Hierarchy](#definition-hierarchy) · [Core Methods](#core-methods)
34
- - [Available Field Types](#available-field-types) · [Field Options](#field-options) · [Select/Choices](#selectchoices)
35
- - [Conditional Rendering](#conditional-rendering) · [Dynamic Forms (pre_submit)](#dynamic-forms-pre_submit)
36
- - [Custom Rendering](#custom-rendering) · [Column Options](#column-options) · [Nested Inputs](#nested-inputs)
37
- - [File Uploads](#file-uploads) · [Runtime Customization Hooks](#runtime-customization-hooks)
38
- - [Form Configuration](#form-configuration) · [Page Customization](#page-customization)
39
-
40
- **[Query: Search, Filters, Scopes, Sorting](#query-search-filters-scopes-sorting)**
41
- - Search · Filters (text, boolean, date, date_range, select, association) · Custom Filters · Scopes · Sorting · URL Parameters
42
-
43
- **[Actions: Custom and Bulk](#actions-custom-and-bulk)**
44
- - Action Types · Simple Actions · Interactive Actions · Action Options
45
- - Creating an Interaction · Bulk Actions · Resource Actions
46
- - Interaction Responses · Default CRUD Actions · Authorization · Immediate vs Form Actions
47
-
48
- **Definitions are generated automatically** - never create them manually:
49
- - `rails g pu:res:scaffold` creates the base definition
50
- - `rails g pu:res:conn` creates portal-specific definitions
51
- - `rails g pu:field:input NAME` creates custom field input components
52
- - `rails g pu:field:renderer NAME` creates custom field display components
53
-
54
- Resource definitions configure **HOW** resources are rendered and interacted with. They are the central configuration point for UI behavior.
55
-
56
- ## Key Principle
57
-
58
- **All model attributes are auto-detected** - you only declare when overriding defaults.
59
-
60
- Plutonium automatically detects from your model:
61
- - Database columns (string, text, integer, boolean, datetime, etc.)
62
- - Associations (belongs_to, has_many, has_one)
63
- - Active Storage attachments (has_one_attached, has_many_attached)
64
- - Enums
65
- - Virtual attributes (with accessor methods)
66
-
67
- ## File Location
68
-
69
- - Main app: `app/definitions/model_name_definition.rb`
70
- - Packages: `packages/pkg_name/app/definitions/pkg_name/model_name_definition.rb`
71
-
72
- ## Definition Structure
73
-
74
- ```ruby
75
- class PostDefinition < Plutonium::Resource::Definition
76
- # Fields, inputs, displays, columns
77
- field :content, as: :markdown
78
- input :title, hint: "Be descriptive"
79
- display :content, as: :markdown
80
- column :title, align: :center
81
-
82
- # Search, filters, scopes, sorting (see definition-query skill)
83
- search { |scope, q| scope.where("title ILIKE ?", "%#{q}%") }
84
- filter :status, with: Plutonium::Query::Filters::Text, predicate: :eq
85
- scope :published
86
- sort :created_at
87
-
88
- # Actions (see definition-actions skill)
89
- action :publish, interaction: PublishInteraction
90
- end
91
- ```
92
-
93
- ## Definition Hierarchy
94
-
95
- Definitions exist at multiple levels:
96
-
97
- ### Main App (created by generators)
98
-
99
- ```ruby
100
- # app/definitions/resource_definition.rb (base - created during install)
101
- class ResourceDefinition < Plutonium::Resource::Definition
102
- action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
103
- end
104
-
105
- # app/definitions/post_definition.rb (resource-specific - created by scaffold)
106
- class PostDefinition < ResourceDefinition
107
- scope :published
108
- input :content, as: :markdown
109
- end
110
- ```
111
-
112
- ### Portal-Specific Overrides
113
-
114
- ```ruby
115
- # packages/admin_portal/app/definitions/admin_portal/post_definition.rb
116
- class AdminPortal::PostDefinition < ::PostDefinition
117
- input :internal_notes, as: :text # Only admins see this field
118
- scope :pending_review # Admin-specific scope
119
- end
120
- ```
121
-
122
- ## Separation of Concerns
123
-
124
- | Layer | Purpose | Example |
125
- |-------|---------|---------|
126
- | **Definition** | HOW fields render | `input :content, as: :markdown` |
127
- | **Policy** | WHAT is visible/editable | `permitted_attributes_for_read` |
128
- | **Interaction** | Business logic | `resource.update!(state: :archived)` |
129
-
130
- ## Core Methods
131
-
132
- | Method | Applies To | Use When |
133
- |--------|-----------|----------|
134
- | `field` | Forms + Show + Table | Universal type override |
135
- | `input` | Forms only | Form-specific options |
136
- | `display` | Show page only | Display-specific options |
137
- | `column` | Table only | Table-specific options |
138
-
139
- ## Basic Usage
140
-
141
- ```ruby
142
- class PostDefinition < ResourceDefinition
143
- # field - changes type everywhere
144
- field :content, as: :markdown
145
-
146
- # input - form-specific
147
- input :title,
148
- label: "Article Title",
149
- hint: "Enter a descriptive title",
150
- placeholder: "e.g. Getting Started"
151
-
152
- # display - show page specific
153
- display :content,
154
- as: :markdown,
155
- description: "Published content",
156
- wrapper: {class: "col-span-full"}
157
-
158
- # column - table specific
159
- column :title, label: "Article", align: :center
160
- column :view_count, align: :end
161
- end
162
- ```
163
-
164
- ## Available Field Types
165
-
166
- ### Input Types (Forms)
167
-
168
- | Category | Types |
169
- |----------|-------|
170
- | **Text** | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
171
- | **Rich Text** | `:markdown` (EasyMDE editor) |
172
- | **Numeric** | `:number`, `:integer`, `:decimal`, `:range` |
173
- | **Boolean** | `:boolean` |
174
- | **Date/Time** | `:date`, `:time`, `:datetime` |
175
- | **Selection** | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
176
- | **Files** | `:file`, `:uppy`, `:attachment` |
177
- | **Associations** | `:association`, `:secure_association`, `:belongs_to`, `:has_many`, `:has_one` |
178
- | **Special** | `:hidden`, `:color`, `:phone` |
179
-
180
- ### Display Types (Show/Index)
181
-
182
- `:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`
183
-
184
- ## Field Options
185
-
186
- ### Field-Level Options (wrapper)
187
-
188
- ```ruby
189
- input :title,
190
- label: "Custom Label", # Custom label text
191
- hint: "Help text for forms", # Form help text
192
- placeholder: "Enter value", # Input placeholder
193
- description: "For displays" # Display description
194
- ```
195
-
196
- ### Tag-Level Options (HTML element)
197
-
198
- ```ruby
199
- input :title,
200
- class: "custom-class", # CSS class
201
- data: {controller: "custom"}, # Data attributes
202
- required: true, # HTML required
203
- readonly: true, # HTML readonly
204
- disabled: true # HTML disabled
205
- ```
206
-
207
- ### Wrapper Options
208
-
209
- ```ruby
210
- display :content, wrapper: {class: "col-span-full"}
211
- input :notes, wrapper: {class: "bg-gray-50"}
212
- ```
213
-
214
- ## Select/Choices
215
-
216
- ### Static Choices
217
-
218
- ```ruby
219
- input :category, as: :select, choices: %w[Tech Business Lifestyle]
220
- input :status, as: :select, choices: Post.statuses.keys
221
- ```
222
-
223
- ### Dynamic Choices (requires block)
224
-
225
- ```ruby
226
- # Basic dynamic
227
- input :author do |f|
228
- choices = User.active.pluck(:name, :id)
229
- f.select_tag choices: choices
230
- end
231
-
232
- # With context access
233
- input :team_members do |f|
234
- choices = current_user.organization.users.pluck(:name, :id)
235
- f.select_tag choices: choices
236
- end
237
-
238
- # Based on object state
239
- input :related_posts do |f|
240
- choices = if object.persisted?
241
- Post.where.not(id: object.id).published.pluck(:title, :id)
242
- else
243
- []
244
- end
245
- f.select_tag choices: choices
246
- end
247
- ```
248
-
249
- ## Conditional Rendering
250
-
251
- ```ruby
252
- class PostDefinition < ResourceDefinition
253
- # Show based on object state
254
- display :published_at, condition: -> { object.published? }
255
- display :rejection_reason, condition: -> { object.rejected? }
256
-
257
- # Show based on environment
258
- field :debug_info, condition: -> { Rails.env.development? }
259
- end
260
- ```
261
-
262
- **Note:** Use `condition` for UI state logic. Use **policies** for authorization.
263
-
264
- ## Dynamic Forms (pre_submit)
265
-
266
- Use `pre_submit: true` to create forms that dynamically show/hide fields based on other field values. When a `pre_submit` field changes, the form re-renders server-side and conditions are re-evaluated.
267
-
268
- ### Basic Pattern
269
-
270
- ```ruby
271
- class PostDefinition < ResourceDefinition
272
- # Trigger field - causes form to re-render on change
273
- input :send_notifications, pre_submit: true
274
-
275
- # Dependent field - only shown when condition is true
276
- input :notification_channel,
277
- as: :select,
278
- choices: %w[Email SMS],
279
- condition: -> { object.send_notifications? }
280
- end
281
- ```
282
-
283
- ### How It Works
284
-
285
- 1. User changes a `pre_submit: true` field
286
- 2. Form submits via Turbo (no page reload)
287
- 3. Server re-renders the form with updated `object` state
288
- 4. Fields with `condition` procs are re-evaluated
289
- 5. Newly visible fields appear, hidden fields disappear
290
-
291
- ### Multiple Dependent Fields
292
-
293
- ```ruby
294
- class QuestionDefinition < ResourceDefinition
295
- # Primary selector
296
- input :question_type, as: :select,
297
- choices: %w[text choice scale date boolean],
298
- pre_submit: true
299
-
300
- # Conditional fields based on question_type
301
- input :max_length,
302
- as: :integer,
303
- condition: -> { object.question_type == "text" }
304
-
305
- input :choices,
306
- as: :text,
307
- hint: "One choice per line",
308
- condition: -> { object.question_type == "choice" }
309
-
310
- input :min_value,
311
- as: :integer,
312
- condition: -> { object.question_type == "scale" }
313
-
314
- input :max_value,
315
- as: :integer,
316
- condition: -> { object.question_type == "scale" }
317
- end
318
- ```
319
-
320
- ### Cascading Dependencies
321
-
322
- ```ruby
323
- class PropertyDefinition < ResourceDefinition
324
- input :property_type, as: :select,
325
- choices: %w[residential commercial],
326
- pre_submit: true
327
-
328
- input :residential_type, as: :select,
329
- choices: %w[apartment house condo],
330
- condition: -> { object.property_type == "residential" },
331
- pre_submit: true
332
-
333
- input :commercial_type, as: :select,
334
- choices: %w[office retail warehouse],
335
- condition: -> { object.property_type == "commercial" },
336
- pre_submit: true
337
-
338
- input :apartment_floor,
339
- as: :integer,
340
- condition: -> { object.residential_type == "apartment" }
341
- end
342
- ```
343
-
344
- ### Dynamic Choices with pre_submit
345
-
346
- ```ruby
347
- class SurveyResponseDefinition < ResourceDefinition
348
- input :category, as: :select,
349
- choices: Category.pluck(:name, :id),
350
- pre_submit: true
351
-
352
- input :subcategory do |f|
353
- choices = if object.category.present?
354
- Category.find(object.category).subcategories.pluck(:name, :id)
355
- else
356
- []
357
- end
358
- f.select_tag choices: choices
359
- end
360
- end
361
- ```
362
-
363
- ### Tips
364
-
365
- - Only add `pre_submit: true` to fields that control visibility of other fields
366
- - Keep dependencies simple - deeply nested conditions are hard to debug
367
- - The form submits on change, so avoid `pre_submit` on frequently-changed fields
368
-
369
- ## Custom Rendering
370
-
371
- ### Block Syntax
372
-
373
- **For Display (can return any component):**
374
- ```ruby
375
- display :status do |field|
376
- StatusBadgeComponent.new(value: field.value, class: field.dom.css_class)
377
- end
378
-
379
- display :metrics do |field|
380
- if field.value.present?
381
- MetricsChartComponent.new(data: field.value)
382
- else
383
- EmptyStateComponent.new(message: "No metrics")
384
- end
385
- end
386
- ```
387
-
388
- **For Input (must use form builder methods):**
389
- ```ruby
390
- input :birth_date do |f|
391
- case object.age_category
392
- when 'adult'
393
- f.date_tag(min: 18.years.ago.to_date)
394
- when 'minor'
395
- f.date_tag(max: 18.years.ago.to_date)
396
- else
397
- f.date_tag
398
- end
399
- end
400
- ```
401
-
402
- ### phlexi_tag (Advanced Display)
403
-
404
- ```ruby
405
- # With component class
406
- display :status, as: :phlexi_tag, with: StatusBadgeComponent
407
-
408
- # With inline proc
409
- display :priority, as: :phlexi_tag, with: ->(value, attrs) {
410
- case value
411
- when 'high'
412
- span(class: "badge badge-danger") { "High" }
413
- when 'medium'
414
- span(class: "badge badge-warning") { "Medium" }
415
- else
416
- span(class: "badge badge-info") { "Low" }
417
- end
418
- }
419
- ```
420
-
421
- ### Custom Component Class
422
-
423
- ```ruby
424
- input :color_picker, as: ColorPickerComponent
425
- display :chart, as: ChartComponent
426
- ```
427
-
428
- ## Column Options
429
-
430
- ### Alignment
431
-
432
- ```ruby
433
- column :title, align: :start # Left (default)
434
- column :status, align: :center # Center
435
- column :amount, align: :end # Right
436
- ```
437
-
438
- ### Value Formatting
439
-
440
- ```ruby
441
- # Truncate long text
442
- column :description, formatter: ->(value) { value&.truncate(30) }
443
-
444
- # Format numbers
445
- column :price, formatter: ->(value) { "$%.2f" % value if value }
446
-
447
- # Transform values
448
- column :status, formatter: ->(value) { value&.humanize&.upcase }
449
- ```
450
-
451
- **formatter vs block:** Use `formatter` when you only need the value. Use a block when you need the full record:
452
-
453
- ```ruby
454
- # formatter - receives just the value
455
- column :name, formatter: ->(value) { value&.titleize }
456
-
457
- # block - receives the full record
458
- column :full_name do |record|
459
- "#{record.first_name} #{record.last_name}"
460
- end
461
- ```
462
-
463
- ## Nested Inputs
464
-
465
- Render inline forms for associated records. Requires `accepts_nested_attributes_for` on the model.
466
-
467
- ### Model Setup
468
-
469
- ```ruby
470
- class Post < ResourceRecord
471
- has_many :comments
472
- has_one :metadata
473
-
474
- accepts_nested_attributes_for :comments, allow_destroy: true, limit: 10
475
- accepts_nested_attributes_for :metadata, update_only: true
476
- end
477
- ```
478
-
479
- ### Basic Declaration
480
-
481
- ```ruby
482
- class PostDefinition < ResourceDefinition
483
- # Block syntax
484
- nested_input :comments do |n|
485
- n.input :body, as: :text
486
- n.input :author_name
487
- end
488
-
489
- # Using another definition
490
- nested_input :metadata, using: PostMetadataDefinition, fields: %i[seo_title seo_description]
491
- end
492
- ```
493
-
494
- ### Options
495
-
496
- | Option | Description |
497
- |--------|-------------|
498
- | `limit` | Max records (auto-detected from model, default: 10) |
499
- | `allow_destroy` | Show delete checkbox (auto-detected from model) |
500
- | `update_only` | Hide "Add" button, only edit existing |
501
- | `description` | Help text above the section |
502
- | `condition` | Proc to show/hide section |
503
- | `using` | Reference another Definition class |
504
- | `fields` | Which fields to render from the definition |
505
-
506
- ```ruby
507
- nested_input :amenities,
508
- allow_destroy: true,
509
- limit: 20,
510
- description: "Add property amenities" do |n|
511
- n.input :name
512
- n.input :icon, as: :select, choices: ICONS
513
- end
514
- ```
515
-
516
- ### Singular Associations
517
-
518
- For `has_one` and `belongs_to`, limit is automatically 1:
519
-
520
- ```ruby
521
- nested_input :profile do |n| # has_one
522
- n.input :bio
523
- n.input :website
524
- end
525
- ```
526
-
527
- ### Gotchas
528
-
529
- - Model must have `accepts_nested_attributes_for`.
530
- - The `belongs_to` on the child model **must** declare `inverse_of: :parent_assoc`. Without it, in-memory validation of nested children fails with "Parent must exist" because the parent isn't yet saved.
531
- - **Don't put `*_attributes` hashes in the policy's `permitted_attributes_for_*`.** Plutonium extracts nested params via the form definition (`build_form(...).extract_input(...)`), not the policy. Hash entries like `{variants_attributes: [:id, :name, :_destroy]}` get rendered as literal text inputs. The policy should permit just the association name (e.g. `:variants`); the `nested_input :variants` declaration in the definition handles the rest.
532
- - For custom class names, use `class_name:` in both model and `using:` in definition.
533
- - `update_only: true` hides the Add button.
534
- - Limit is enforced in UI (Add button hidden when reached).
535
-
536
- ## File Uploads
537
-
538
- ```ruby
539
- input :avatar, as: :file
540
- input :avatar, as: :uppy
541
-
542
- input :documents, as: :file, multiple: true
543
- input :documents, as: :uppy,
544
- allowed_file_types: ['.pdf', '.doc'],
545
- max_file_size: 5.megabytes
546
- ```
547
-
548
- ## Runtime Customization Hooks
549
-
550
- Override these methods for dynamic behavior:
551
-
552
- ```ruby
553
- class PostDefinition < ResourceDefinition
554
- def customize_fields
555
- field :debug_info if Rails.env.development?
556
- end
557
-
558
- def customize_inputs
559
- # Add/modify inputs at runtime
560
- end
561
-
562
- def customize_displays
563
- # Add/modify displays at runtime
564
- end
565
-
566
- def customize_filters
567
- # Add/modify filters at runtime
568
- end
569
-
570
- def customize_actions
571
- # Add/modify actions at runtime
572
- end
573
- end
574
- ```
575
-
576
- ## Form Configuration
577
-
578
- ```ruby
579
- class PostDefinition < ResourceDefinition
580
- # Controls "Save and add another" / "Update and continue editing" buttons
581
- # nil (default) = auto-detect (hidden for singular resources, shown for plural)
582
- # true = always show
583
- # false = always hide
584
- submit_and_continue false
585
-
586
- # How `:new` / `:edit` render. Default is :slideover.
587
- # :slideover — slide-in panel from the right (default)
588
- # :centered — centered dialog
589
- # false — full standalone pages (no modal)
590
- modal :centered
591
- end
592
- ```
593
-
594
- The `modal` setting only affects the framework-provided `:new` / `:edit`
595
- actions. Custom actions render in their own dialog, controlled by the
596
- per-action `modal:` option (`:centered` default, or `:slideover`).
597
-
598
- ## Page Customization
599
-
600
- ```ruby
601
- class PostDefinition < ResourceDefinition
602
- # Titles (static or dynamic)
603
- index_page_title "All Posts"
604
- show_page_title -> { "#{current_record!.title} - Details" }
605
-
606
- # Breadcrumbs
607
- breadcrumbs true
608
- show_page_breadcrumbs false
609
-
610
- # Custom page classes (inherit from parent's nested class)
611
- class IndexPage < IndexPage
612
- def view_template(&block)
613
- div(class: "custom-header") { h1 { "Custom" } }
614
- super(&block)
615
- end
616
- end
617
-
618
- class Form < Form
619
- def form_template
620
- div(class: "grid grid-cols-2") do
621
- render field(:title).input_tag
622
- render field(:content).easymde_tag
623
- end
624
- render_actions
625
- end
626
- end
627
- end
628
- ```
629
-
630
- ## Metadata Panel (Show Page)
631
-
632
- The `metadata` DSL declares a list of fields rendered in the show page's
633
- right-side aside as label/value rows. The main details card and the
634
- metadata aside share the same field-rendering machinery, so labels and
635
- formatting come from your existing `field` / `display` declarations.
636
-
637
- ```ruby
638
- class PostDefinition < ResourceDefinition
639
- metadata :author, :state, :created_at, :updated_at
640
- end
641
- ```
642
-
643
- Behavior:
644
-
645
- - **Opt-in.** No `metadata` call → the show page renders full-width with
646
- no aside.
647
- - **Policy-aware.** Metadata fields are intersected with the policy's
648
- permitted attributes. Fields the user can't see disappear from the
649
- panel; the panel auto-hides when nothing is permitted.
650
- - **Deduplicated.** Fields listed in `metadata` are removed from the main
651
- details card so the same value never appears twice.
652
- - **Responsive.** Side-by-side at `lg+`, stacked single-column below.
653
-
654
- Use it for chrome that's not the focus of the record — timestamps,
655
- ownership, system flags — keeping the main card focused on the record's
656
- substance.
657
-
658
- ## Index Views (Table & Grid)
659
-
660
- Resources can opt into a card-based **Grid** view alongside the default
661
- **Table** view. Users can switch between the two and the choice is
662
- persisted per-resource via cookie.
663
-
664
- ```ruby
665
- class UserDefinition < ResourceDefinition
666
- views :table, :grid # enable both; user can switch
667
- default_view :grid # initial view if no cookie
668
-
669
- grid_fields(
670
- image: :avatar, # ActiveStorage attachment, Shrine, or URL
671
- header: :name, # falls back to record.to_label
672
- subheader: :email,
673
- body: :bio,
674
- meta: [:role, :status], # rendered as small pills
675
- footer: :last_seen_at # falls back to :created_at
676
- )
677
-
678
- grid_layout :media # :compact (default) or :media
679
- grid_columns 3 # pin to 3 cols on lg+; default is 1/2/3/4 responsive
680
- end
681
- ```
682
-
683
- DSL surface:
684
-
685
- | Method | Purpose |
686
- |--------|---------|
687
- | `views :table, :grid` | Which views are available. Default `[:table]`. |
688
- | `default_view :grid` | Initial view when no cookie. Falls back to first view in `views`. |
689
- | `grid_fields(...)` | Maps card slots to fields. **Implicitly enables `:grid`** if not already in `views`. |
690
- | `grid_layout :media` | `:compact` (image left of content) or `:media` (full-width image on top). |
691
- | `grid_columns 3` | Override responsive column count on lg+. |
692
-
693
- Grid slots are all optional — `:image`, `:header`, `:subheader`, `:body`,
694
- `:meta`, `:footer`. `:meta` accepts an array; the rest are single
695
- fields. Slots that point at fields not permitted by the user's policy
696
- collapse silently.
697
-
698
- ## Context in Blocks
699
-
700
- Inside `condition` procs and `input` blocks:
701
- - `object` - The record being edited/displayed
702
- - `current_user` - The authenticated user
703
- - `current_parent` - Parent record for nested resources
704
- - `request`, `params` - Request information
705
- - All helper methods
706
-
707
- ## When to Declare
708
-
709
- ```ruby
710
- class PostDefinition < ResourceDefinition
711
- # 1. Override auto-detected type
712
- field :content, as: :markdown # text -> rich_text
713
- input :published_at, as: :date # datetime -> date only
714
-
715
- # 2. Add custom options
716
- input :title, hint: "Be descriptive", placeholder: "Enter title"
717
-
718
- # 3. Configure select choices
719
- input :category, as: :select, choices: %w[Tech Business]
720
-
721
- # 4. Add conditional logic
722
- display :published_at, condition: -> { object.published? }
723
-
724
- # 5. Custom rendering
725
- display :status do |field|
726
- StatusBadgeComponent.new(value: field.value)
727
- end
728
- end
729
- ```
730
-
731
- ## Best Practices
732
-
733
- 1. **Let auto-detection work** - Don't declare unless overriding
734
- 2. **Use portal-specific definitions** - Override per-portal when needed
735
- 3. **Keep definitions focused** - Configuration only, no business logic
736
- 4. **Use policies for authorization** - Not `condition` procs
737
- 5. **Group related declarations** - Use comments to organize sections
738
-
739
- ---
740
-
741
- # Query: Search, Filters, Scopes, Sorting
742
-
743
- Configure how users can search, filter, and sort resource collections.
744
-
745
- ### Query Overview
746
-
747
- ```ruby
748
- class PostDefinition < ResourceDefinition
749
- search do |scope, query|
750
- scope.where("title ILIKE ?", "%#{query}%")
751
- end
752
-
753
- filter :title, with: :text, predicate: :contains
754
- filter :status, with: :select, choices: %w[draft published archived]
755
- filter :published, with: :boolean
756
- filter :created_at, with: :date_range
757
- filter :category, with: :association
758
-
759
- scope :published
760
- scope :draft
761
- default_scope :published
762
-
763
- sort :title
764
- sort :created_at
765
- default_sort :created_at, :desc
766
- end
767
- ```
768
-
769
- ### Search
770
-
771
- ```ruby
772
- # Single field
773
- search do |scope, query|
774
- scope.where("title ILIKE ?", "%#{query}%")
775
- end
776
-
777
- # Multiple fields
778
- search do |scope, query|
779
- scope.where(
780
- "title ILIKE :q OR content ILIKE :q OR author_name ILIKE :q",
781
- q: "%#{query}%"
782
- )
783
- end
784
-
785
- # With associations
786
- search do |scope, query|
787
- scope.joins(:author).where(
788
- "posts.title ILIKE :q OR users.name ILIKE :q",
789
- q: "%#{query}%"
790
- ).distinct
791
- end
792
- ```
793
-
794
- ### Filters
795
-
796
- Plutonium provides **6 built-in filter types**. Use shorthand symbols or full class names.
797
-
798
- #### Text Filter
799
-
800
- ```ruby
801
- filter :title, with: :text, predicate: :contains
802
- filter :status, with: :text, predicate: :eq
803
- filter :title, with: Plutonium::Query::Filters::Text, predicate: :contains
804
- ```
805
-
806
- **Predicates:** `:eq`, `:not_eq`, `:contains`, `:not_contains`, `:starts_with`, `:ends_with`, `:matches`, `:not_matches`
807
-
808
- #### Boolean Filter
809
-
810
- ```ruby
811
- filter :active, with: :boolean
812
- filter :published, with: :boolean, true_label: "Published", false_label: "Draft"
813
- ```
814
-
815
- #### Date Filter
816
-
817
- ```ruby
818
- filter :created_at, with: :date, predicate: :gteq
819
- filter :due_date, with: :date, predicate: :lt
820
- filter :published_at, with: :date, predicate: :eq
821
- ```
822
-
823
- **Predicates:** `:eq`, `:not_eq`, `:lt`, `:lteq`, `:gt`, `:gteq`
824
-
825
- #### Date Range Filter
826
-
827
- ```ruby
828
- filter :created_at, with: :date_range
829
- filter :published_at, with: :date_range,
830
- from_label: "Published from",
831
- to_label: "Published to"
832
- ```
833
-
834
- #### Select Filter
835
-
836
- ```ruby
837
- filter :status, with: :select, choices: %w[draft published archived]
838
- filter :category, with: :select, choices: -> { Category.pluck(:name) }
839
- filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
840
- ```
841
-
842
- #### Association Filter
843
-
844
- ```ruby
845
- filter :category, with: :association
846
- filter :author, with: :association, class_name: User
847
- filter :tags, with: :association, class_name: Tag, multiple: true
848
- ```
849
-
850
- #### Custom Filter Class
851
-
852
- ```ruby
853
- class PriceRangeFilter < Plutonium::Query::Filter
854
- def apply(scope, min: nil, max: nil)
855
- scope = scope.where("price >= ?", min) if min.present?
856
- scope = scope.where("price <= ?", max) if max.present?
857
- scope
858
- end
859
-
860
- def customize_inputs
861
- input :min, as: :number
862
- input :max, as: :number
863
- field :min, placeholder: "Min price..."
864
- field :max, placeholder: "Max price..."
865
- end
866
- end
867
-
868
- filter :price, with: PriceRangeFilter
869
- ```
870
-
871
- ### Scopes
872
-
873
- Scopes appear as quick filter buttons.
874
-
875
- ```ruby
876
- class PostDefinition < ResourceDefinition
877
- scope :published # Uses Post.published
878
- scope :draft # Uses Post.draft
879
-
880
- # Inline scopes
881
- scope(:recent) { |scope| scope.where('created_at > ?', 1.week.ago) }
882
- scope(:mine) { |scope| scope.where(author: current_user) }
883
-
884
- default_scope :published # Applied by default
885
- end
886
- ```
887
-
888
- When a default scope is set:
889
- - Applied on initial page load
890
- - Default scope button is highlighted (not "All")
891
- - Clicking "All" shows all records without any scope filter
892
-
893
- ### Sorting
894
-
895
- ```ruby
896
- sort :title
897
- sort :created_at
898
- sorts :title, :created_at, :view_count # multiple at once
899
-
900
- default_sort :created_at, :desc
901
- default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
902
- ```
903
-
904
- ### URL Parameters
905
-
906
- ```
907
- /posts?q[search]=rails
908
- /posts?q[title][query]=widget
909
- /posts?q[status][value]=published
910
- /posts?q[created_at][from]=2024-01-01&q[created_at][to]=2024-12-31
911
- /posts?q[scope]=recent
912
- /posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
913
- ```
914
-
915
- ### Filter Summary Table
916
-
917
- | Type | Symbol | Input Params | Options |
918
- |------|--------|--------------|---------|
919
- | Text | `:text` | `query` | `predicate:` |
920
- | Boolean | `:boolean` | `value` | `true_label:`, `false_label:` |
921
- | Date | `:date` | `value` | `predicate:` |
922
- | Date Range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
923
- | Select | `:select` | `value` | `choices:`, `multiple:` |
924
- | Association | `:association` | `value` | `class_name:`, `multiple:` |
925
-
926
- ### Query Performance Tips
927
-
928
- 1. Add indexes for filtered/sorted columns
929
- 2. Use `.distinct` when joining associations in search
930
- 3. Consider `pg_search` for complex full-text search
931
- 4. Limit search fields to indexed columns
932
- 5. Use scopes instead of filters for common queries
933
-
934
- ---
935
-
936
- # Actions: Custom and Bulk
937
-
938
- Actions define custom operations on resources. They can be simple (navigation) or interactive (with business logic via Interactions).
939
-
940
- ### Action Types
941
-
942
- | Type | Shows In | Use Case |
943
- |------|----------|----------|
944
- | `resource_action` | Index page | Import, Export, Create |
945
- | `record_action` | Show page | Edit, Delete, Archive |
946
- | `collection_record_action` | Table rows | Quick actions per row |
947
- | `bulk_action` | Selected records | Bulk operations |
948
-
949
- ### Simple Actions (Navigation)
950
-
951
- Simple actions link to existing routes. **The target route must already exist.**
952
-
953
- ```ruby
954
- class PostDefinition < ResourceDefinition
955
- # Link to external URL
956
- action :documentation,
957
- label: "Documentation",
958
- route_options: {url: "https://docs.example.com"},
959
- icon: Phlex::TablerIcons::Book,
960
- resource_action: true
961
-
962
- # Link to custom controller action
963
- action :reports,
964
- route_options: {action: :reports},
965
- icon: Phlex::TablerIcons::ChartBar,
966
- resource_action: true
967
- end
968
- ```
969
-
970
- **Important:** When adding custom routes for actions, always use the `as:` option to name them:
971
-
972
- ```ruby
973
- resources :posts do
974
- collection do
975
- get :reports, as: :reports # Named route required!
976
- end
977
- end
978
- ```
979
-
980
- **Note:** For custom operations with business logic, use **Interactive Actions** with an Interaction class instead.
981
-
982
- ### Interactive Actions (with Interaction)
983
-
984
- ```ruby
985
- class PostDefinition < ResourceDefinition
986
- action :publish,
987
- interaction: PublishInteraction,
988
- icon: Phlex::TablerIcons::Send
989
-
990
- action :archive,
991
- interaction: ArchiveInteraction,
992
- color: :danger,
993
- category: :danger,
994
- position: 1000,
995
- confirmation: "Are you sure?"
996
- end
997
- ```
998
-
999
- ### Action Options
1000
-
1001
- ```ruby
1002
- action :name,
1003
- # Display
1004
- label: "Custom Label",
1005
- description: "What it does",
1006
- icon: Phlex::TablerIcons::Star,
1007
- color: :danger, # :primary, :secondary, :danger
1008
-
1009
- # Visibility
1010
- resource_action: true,
1011
- record_action: true,
1012
- collection_record_action: true,
1013
- bulk_action: true,
1014
-
1015
- # Grouping
1016
- category: :primary, # :primary, :secondary, :danger
1017
- position: 50,
1018
-
1019
- # Behavior
1020
- confirmation: "Are you sure?",
1021
- turbo_frame: "_top",
1022
- route_options: {action: :foo},
1023
- modal: :slideover # :centered (default) or :slideover —
1024
- # how the action's interaction form renders
1025
- ```
1026
-
1027
- **`Action#with(...)`** — actions are frozen value objects. To derive a
1028
- variant (typically inside `customize_actions`) call
1029
- `existing_action.with(turbo_frame: nil)` for a new copy with the
1030
- overrides applied.
1031
-
1032
- ### Creating an Interaction
1033
-
1034
- #### Basic Structure
1035
-
1036
- ```ruby
1037
- # app/interactions/resource_interaction.rb (generated during install)
1038
- class ResourceInteraction < Plutonium::Resource::Interaction
1039
- end
1040
-
1041
- # app/interactions/archive_interaction.rb
1042
- class ArchiveInteraction < ResourceInteraction
1043
- presents label: "Archive",
1044
- icon: Phlex::TablerIcons::Archive,
1045
- description: "Archive this record"
1046
-
1047
- attribute :resource
1048
-
1049
- def execute
1050
- resource.archived!
1051
- succeed(resource).with_message("Record archived successfully.")
1052
- rescue ActiveRecord::RecordInvalid => e
1053
- failed(e.record.errors)
1054
- rescue => error
1055
- failed("Archive failed. Please try again.")
1056
- end
1057
- end
1058
- ```
1059
-
1060
- #### With Additional Inputs
1061
-
1062
- ```ruby
1063
- class Company::InviteUserInteraction < Plutonium::Resource::Interaction
1064
- presents label: "Invite User", icon: Phlex::TablerIcons::Mail
1065
-
1066
- attribute :resource
1067
- attribute :email
1068
- attribute :role
1069
-
1070
- input :email, as: :email, hint: "User's email address"
1071
- input :role, as: :select, choices: %w[admin member viewer]
1072
-
1073
- validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
1074
- validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
1075
-
1076
- def execute
1077
- UserInvite.create!(
1078
- company: resource,
1079
- email: email,
1080
- role: role,
1081
- invited_by: current_user
1082
- )
1083
- succeed(resource).with_message("Invitation sent to #{email}.")
1084
- rescue ActiveRecord::RecordInvalid => e
1085
- failed(e.record.errors)
1086
- end
1087
- end
1088
- ```
1089
-
1090
- #### Bulk Action (Multiple Records)
1091
-
1092
- Bulk actions operate on multiple selected records at once. The resource table automatically shows selection checkboxes and a bulk actions toolbar.
1093
-
1094
- ```ruby
1095
- # 1. Create the interaction (note: plural `resources` attribute)
1096
- class BulkArchiveInteraction < Plutonium::Resource::Interaction
1097
- presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
1098
-
1099
- attribute :resources # Array of records (plural)
1100
-
1101
- def execute
1102
- count = 0
1103
- resources.each do |record|
1104
- record.archived!
1105
- count += 1
1106
- end
1107
- succeed(resources).with_message("#{count} records archived.")
1108
- rescue => error
1109
- failed("Bulk archive failed: #{error.message}")
1110
- end
1111
- end
1112
-
1113
- # 2. Register the action in the definition
1114
- class PostDefinition < ResourceDefinition
1115
- action :bulk_archive, interaction: BulkArchiveInteraction
1116
- # bulk_action: true is automatically inferred from `resources` attribute
1117
- end
1118
-
1119
- # 3. Add policy method
1120
- class PostPolicy < ResourcePolicy
1121
- def bulk_archive?
1122
- create?
1123
- end
1124
- end
1125
- ```
1126
-
1127
- **Authorization for bulk actions:**
1128
- - Policy method is checked **per record** — fails the entire request if any record is not authorized
1129
- - Records are fetched via `current_authorized_scope`
1130
- - The UI only shows action buttons that **all** selected records support
1131
-
1132
- #### Resource Action (No Record)
1133
-
1134
- ```ruby
1135
- class ImportInteraction < Plutonium::Resource::Interaction
1136
- presents label: "Import CSV", icon: Phlex::TablerIcons::Upload
1137
-
1138
- # No :resource or :resources attribute = resource action
1139
- attribute :file
1140
-
1141
- input :file, as: :file
1142
- validates :file, presence: true
1143
-
1144
- def execute
1145
- succeed(nil).with_message("Import completed.")
1146
- end
1147
- end
1148
- ```
1149
-
1150
- ### Interaction Responses
1151
-
1152
- ```ruby
1153
- def execute
1154
- succeed(resource).with_message("Done!")
1155
- succeed(resource)
1156
- .with_redirect_response(custom_dashboard_path)
1157
- .with_message("Redirecting...")
1158
- failed(resource.errors)
1159
- failed("Something went wrong")
1160
- failed("Invalid value", :email)
1161
- end
1162
- ```
1163
-
1164
- **Note:** Redirect is automatic on success. Only use `with_redirect_response` for a different destination.
1165
-
1166
- ### Default CRUD Actions
1167
-
1168
- ```ruby
1169
- action :new, resource_action: true, position: 10
1170
- action :show, collection_record_action: true, position: 10
1171
- action :edit, record_action: true, position: 20
1172
- action :destroy, record_action: true, position: 100, category: :danger
1173
- ```
1174
-
1175
- ### Action Authorization
1176
-
1177
- ```ruby
1178
- class PostPolicy < ResourcePolicy
1179
- def publish?
1180
- user.admin? || record.author == user
1181
- end
1182
-
1183
- def archive?
1184
- user.admin?
1185
- end
1186
- end
1187
- ```
1188
-
1189
- The action only appears if the policy method returns `true`.
1190
-
1191
- ### Immediate vs Form Actions
1192
-
1193
- **Immediate** — executes without showing a form (when interaction has no extra inputs beyond `resource`):
1194
-
1195
- ```ruby
1196
- class ArchiveInteraction < Plutonium::Resource::Interaction
1197
- attribute :resource
1198
- def execute
1199
- resource.archived!
1200
- succeed(resource)
1201
- end
1202
- end
1203
- ```
1204
-
1205
- **Form** — shows a form first (when interaction has additional inputs):
1206
-
1207
- ```ruby
1208
- class InviteUserInteraction < Plutonium::Resource::Interaction
1209
- attribute :resource
1210
- attribute :email
1211
- input :email
1212
- # Has inputs = shows form first
1213
- end
1214
- ```
1215
-
1216
- ---
1217
-
1218
- ## Related Skills
1219
-
1220
- - `plutonium-views` - Custom page, form, display, and table classes
1221
- - `plutonium-forms` - Custom form templates and field builders
1222
- - `plutonium-interaction` - Writing interaction classes
1223
- - `plutonium-policy` - Controlling action access