plutonium 0.49.1 → 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 (206) 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 +37 -0
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1323 -1184
  14. data/app/assets/plutonium.js.map +4 -4
  15. data/app/assets/plutonium.min.js +50 -49
  16. data/app/assets/plutonium.min.js.map +4 -4
  17. data/app/views/plutonium/_resource_header.html.erb +4 -4
  18. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  19. data/app/views/resource/_resource_grid.html.erb +1 -0
  20. data/config/brakeman.ignore +25 -2
  21. data/docs/.vitepress/config.ts +37 -27
  22. data/docs/getting-started/index.md +22 -29
  23. data/docs/getting-started/installation.md +37 -80
  24. data/docs/getting-started/tutorial/index.md +4 -5
  25. data/docs/guides/adding-resources.md +66 -377
  26. data/docs/guides/authentication.md +94 -463
  27. data/docs/guides/authorization.md +124 -370
  28. data/docs/guides/creating-packages.md +94 -296
  29. data/docs/guides/custom-actions.md +121 -441
  30. data/docs/guides/index.md +22 -42
  31. data/docs/guides/multi-tenancy.md +116 -187
  32. data/docs/guides/nested-resources.md +103 -431
  33. data/docs/guides/search-filtering.md +123 -240
  34. data/docs/guides/testing.md +5 -4
  35. data/docs/guides/theming.md +157 -407
  36. data/docs/guides/troubleshooting.md +5 -3
  37. data/docs/guides/user-invites.md +106 -425
  38. data/docs/guides/user-profile.md +76 -243
  39. data/docs/index.md +1 -1
  40. data/docs/reference/app/generators.md +517 -0
  41. data/docs/reference/app/index.md +158 -0
  42. data/docs/reference/app/packages.md +146 -0
  43. data/docs/reference/app/portals.md +377 -0
  44. data/docs/reference/auth/accounts.md +230 -0
  45. data/docs/reference/auth/index.md +88 -0
  46. data/docs/reference/auth/profile.md +185 -0
  47. data/docs/reference/behavior/controllers.md +395 -0
  48. data/docs/reference/behavior/index.md +22 -0
  49. data/docs/reference/behavior/interactions.md +341 -0
  50. data/docs/reference/behavior/policies.md +417 -0
  51. data/docs/reference/index.md +56 -49
  52. data/docs/reference/resource/actions.md +423 -0
  53. data/docs/reference/resource/definition.md +508 -0
  54. data/docs/reference/resource/index.md +50 -0
  55. data/docs/reference/resource/model.md +348 -0
  56. data/docs/reference/resource/query.md +305 -0
  57. data/docs/reference/tenancy/entity-scoping.md +361 -0
  58. data/docs/reference/tenancy/index.md +36 -0
  59. data/docs/reference/tenancy/invites.md +393 -0
  60. data/docs/reference/tenancy/nested-resources.md +267 -0
  61. data/docs/reference/testing/index.md +287 -0
  62. data/docs/reference/ui/assets.md +400 -0
  63. data/docs/reference/ui/components.md +165 -0
  64. data/docs/reference/ui/displays.md +104 -0
  65. data/docs/reference/ui/forms.md +284 -0
  66. data/docs/reference/ui/index.md +30 -0
  67. data/docs/reference/ui/layouts.md +106 -0
  68. data/docs/reference/ui/pages.md +189 -0
  69. data/docs/reference/ui/tables.md +117 -0
  70. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  71. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  72. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  73. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  74. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  75. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  76. data/gemfiles/rails_7.gemfile.lock +1 -1
  77. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  78. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  79. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  80. data/lib/generators/pu/invites/install_generator.rb +1 -0
  81. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  82. data/lib/plutonium/action/base.rb +44 -1
  83. data/lib/plutonium/action/interactive.rb +1 -1
  84. data/lib/plutonium/configuration.rb +4 -0
  85. data/lib/plutonium/definition/actions.rb +3 -0
  86. data/lib/plutonium/definition/base.rb +8 -0
  87. data/lib/plutonium/definition/index_views.rb +95 -0
  88. data/lib/plutonium/definition/metadata.rb +40 -0
  89. data/lib/plutonium/helpers/turbo_helper.rb +12 -1
  90. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  91. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  92. data/lib/plutonium/query/base.rb +8 -0
  93. data/lib/plutonium/query/filters/association.rb +30 -8
  94. data/lib/plutonium/query/filters/boolean.rb +5 -0
  95. data/lib/plutonium/resource/controller.rb +1 -0
  96. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  97. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  98. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  99. data/lib/plutonium/resource/definition.rb +42 -0
  100. data/lib/plutonium/resource/policy.rb +7 -0
  101. data/lib/plutonium/resource/query_object.rb +64 -6
  102. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  103. data/lib/plutonium/testing/resource_definition.rb +2 -2
  104. data/lib/plutonium/ui/action_button.rb +4 -2
  105. data/lib/plutonium/ui/component/kit.rb +12 -0
  106. data/lib/plutonium/ui/component/methods.rb +4 -0
  107. data/lib/plutonium/ui/display/base.rb +3 -1
  108. data/lib/plutonium/ui/display/resource.rb +109 -25
  109. data/lib/plutonium/ui/display/theme.rb +2 -1
  110. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  111. data/lib/plutonium/ui/empty_card.rb +1 -1
  112. data/lib/plutonium/ui/form/base.rb +35 -3
  113. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  114. data/lib/plutonium/ui/form/components/json.rb +58 -0
  115. data/lib/plutonium/ui/form/components/resource_select.rb +133 -1
  116. data/lib/plutonium/ui/form/components/secure_association.rb +105 -24
  117. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  118. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  119. data/lib/plutonium/ui/form/resource.rb +45 -10
  120. data/lib/plutonium/ui/form/theme.rb +1 -1
  121. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  122. data/lib/plutonium/ui/grid/card.rb +235 -0
  123. data/lib/plutonium/ui/grid/resource.rb +149 -0
  124. data/lib/plutonium/ui/layout/base.rb +38 -1
  125. data/lib/plutonium/ui/layout/header.rb +1 -2
  126. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  127. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  128. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  129. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  130. data/lib/plutonium/ui/modal/base.rb +109 -0
  131. data/lib/plutonium/ui/modal/centered.rb +21 -0
  132. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  133. data/lib/plutonium/ui/page/base.rb +18 -6
  134. data/lib/plutonium/ui/page/edit.rb +13 -1
  135. data/lib/plutonium/ui/page/index.rb +40 -1
  136. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  137. data/lib/plutonium/ui/page/new.rb +13 -1
  138. data/lib/plutonium/ui/page/show.rb +8 -1
  139. data/lib/plutonium/ui/page_header.rb +8 -13
  140. data/lib/plutonium/ui/panel.rb +10 -19
  141. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  142. data/lib/plutonium/ui/tab_list.rb +29 -7
  143. data/lib/plutonium/ui/table/base.rb +106 -0
  144. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  145. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  146. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  147. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  148. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  149. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  150. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  151. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  152. data/lib/plutonium/ui/table/resource.rb +158 -89
  153. data/lib/plutonium/ui/table/theme.rb +14 -5
  154. data/lib/plutonium/version.rb +1 -1
  155. data/lib/plutonium.rb +14 -0
  156. data/lib/tasks/release.rake +15 -1
  157. data/package.json +10 -10
  158. data/src/css/components.css +304 -131
  159. data/src/css/slim_select.css +4 -0
  160. data/src/css/tokens.css +101 -85
  161. data/src/js/controllers/autosubmit_controller.js +24 -0
  162. data/src/js/controllers/bulk_actions_controller.js +15 -16
  163. data/src/js/controllers/capture_url_controller.js +14 -0
  164. data/src/js/controllers/filter_panel_controller.js +77 -19
  165. data/src/js/controllers/frame_navigator_controller.js +34 -6
  166. data/src/js/controllers/icon_rail_controller.js +22 -0
  167. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  168. data/src/js/controllers/register_controllers.js +16 -0
  169. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  170. data/src/js/controllers/row_click_controller.js +21 -0
  171. data/src/js/controllers/slim_select_controller.js +61 -0
  172. data/src/js/controllers/table_column_menu_controller.js +43 -0
  173. data/src/js/controllers/table_header_controller.js +16 -0
  174. data/src/js/controllers/view_switcher_controller.js +29 -0
  175. data/src/js/turbo/turbo_actions.js +33 -0
  176. data/yarn.lock +553 -543
  177. metadata +71 -32
  178. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  179. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  180. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  181. data/.claude/skills/plutonium-definition/SKILL.md +0 -1138
  182. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  183. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  184. data/.claude/skills/plutonium-installation/SKILL.md +0 -325
  185. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  186. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  187. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  188. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  189. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  190. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  191. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  192. data/.claude/skills/plutonium-views/SKILL.md +0 -592
  193. data/docs/reference/assets/index.md +0 -496
  194. data/docs/reference/controller/index.md +0 -412
  195. data/docs/reference/definition/actions.md +0 -449
  196. data/docs/reference/definition/fields.md +0 -383
  197. data/docs/reference/definition/index.md +0 -268
  198. data/docs/reference/definition/query.md +0 -351
  199. data/docs/reference/generators/index.md +0 -648
  200. data/docs/reference/interaction/index.md +0 -449
  201. data/docs/reference/model/features.md +0 -248
  202. data/docs/reference/model/index.md +0 -218
  203. data/docs/reference/policy/index.md +0 -456
  204. data/docs/reference/portal/index.md +0 -379
  205. data/docs/reference/views/forms.md +0 -411
  206. data/docs/reference/views/index.md +0 -501
@@ -1,1138 +0,0 @@
1
- ---
2
- name: plutonium-definition
3
- description: Use BEFORE editing a resource definition — adding fields, inputs, displays, columns, search, filters, scopes, custom actions, 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
- end
586
- ```
587
-
588
- ## Page Customization
589
-
590
- ```ruby
591
- class PostDefinition < ResourceDefinition
592
- # Titles (static or dynamic)
593
- index_page_title "All Posts"
594
- show_page_title -> { "#{current_record!.title} - Details" }
595
-
596
- # Breadcrumbs
597
- breadcrumbs true
598
- show_page_breadcrumbs false
599
-
600
- # Custom page classes (inherit from parent's nested class)
601
- class IndexPage < IndexPage
602
- def view_template(&block)
603
- div(class: "custom-header") { h1 { "Custom" } }
604
- super(&block)
605
- end
606
- end
607
-
608
- class Form < Form
609
- def form_template
610
- div(class: "grid grid-cols-2") do
611
- render field(:title).input_tag
612
- render field(:content).easymde_tag
613
- end
614
- render_actions
615
- end
616
- end
617
- end
618
- ```
619
-
620
- ## Context in Blocks
621
-
622
- Inside `condition` procs and `input` blocks:
623
- - `object` - The record being edited/displayed
624
- - `current_user` - The authenticated user
625
- - `current_parent` - Parent record for nested resources
626
- - `request`, `params` - Request information
627
- - All helper methods
628
-
629
- ## When to Declare
630
-
631
- ```ruby
632
- class PostDefinition < ResourceDefinition
633
- # 1. Override auto-detected type
634
- field :content, as: :markdown # text -> rich_text
635
- input :published_at, as: :date # datetime -> date only
636
-
637
- # 2. Add custom options
638
- input :title, hint: "Be descriptive", placeholder: "Enter title"
639
-
640
- # 3. Configure select choices
641
- input :category, as: :select, choices: %w[Tech Business]
642
-
643
- # 4. Add conditional logic
644
- display :published_at, condition: -> { object.published? }
645
-
646
- # 5. Custom rendering
647
- display :status do |field|
648
- StatusBadgeComponent.new(value: field.value)
649
- end
650
- end
651
- ```
652
-
653
- ## Best Practices
654
-
655
- 1. **Let auto-detection work** - Don't declare unless overriding
656
- 2. **Use portal-specific definitions** - Override per-portal when needed
657
- 3. **Keep definitions focused** - Configuration only, no business logic
658
- 4. **Use policies for authorization** - Not `condition` procs
659
- 5. **Group related declarations** - Use comments to organize sections
660
-
661
- ---
662
-
663
- # Query: Search, Filters, Scopes, Sorting
664
-
665
- Configure how users can search, filter, and sort resource collections.
666
-
667
- ### Query Overview
668
-
669
- ```ruby
670
- class PostDefinition < ResourceDefinition
671
- search do |scope, query|
672
- scope.where("title ILIKE ?", "%#{query}%")
673
- end
674
-
675
- filter :title, with: :text, predicate: :contains
676
- filter :status, with: :select, choices: %w[draft published archived]
677
- filter :published, with: :boolean
678
- filter :created_at, with: :date_range
679
- filter :category, with: :association
680
-
681
- scope :published
682
- scope :draft
683
- default_scope :published
684
-
685
- sort :title
686
- sort :created_at
687
- default_sort :created_at, :desc
688
- end
689
- ```
690
-
691
- ### Search
692
-
693
- ```ruby
694
- # Single field
695
- search do |scope, query|
696
- scope.where("title ILIKE ?", "%#{query}%")
697
- end
698
-
699
- # Multiple fields
700
- search do |scope, query|
701
- scope.where(
702
- "title ILIKE :q OR content ILIKE :q OR author_name ILIKE :q",
703
- q: "%#{query}%"
704
- )
705
- end
706
-
707
- # With associations
708
- search do |scope, query|
709
- scope.joins(:author).where(
710
- "posts.title ILIKE :q OR users.name ILIKE :q",
711
- q: "%#{query}%"
712
- ).distinct
713
- end
714
- ```
715
-
716
- ### Filters
717
-
718
- Plutonium provides **6 built-in filter types**. Use shorthand symbols or full class names.
719
-
720
- #### Text Filter
721
-
722
- ```ruby
723
- filter :title, with: :text, predicate: :contains
724
- filter :status, with: :text, predicate: :eq
725
- filter :title, with: Plutonium::Query::Filters::Text, predicate: :contains
726
- ```
727
-
728
- **Predicates:** `:eq`, `:not_eq`, `:contains`, `:not_contains`, `:starts_with`, `:ends_with`, `:matches`, `:not_matches`
729
-
730
- #### Boolean Filter
731
-
732
- ```ruby
733
- filter :active, with: :boolean
734
- filter :published, with: :boolean, true_label: "Published", false_label: "Draft"
735
- ```
736
-
737
- #### Date Filter
738
-
739
- ```ruby
740
- filter :created_at, with: :date, predicate: :gteq
741
- filter :due_date, with: :date, predicate: :lt
742
- filter :published_at, with: :date, predicate: :eq
743
- ```
744
-
745
- **Predicates:** `:eq`, `:not_eq`, `:lt`, `:lteq`, `:gt`, `:gteq`
746
-
747
- #### Date Range Filter
748
-
749
- ```ruby
750
- filter :created_at, with: :date_range
751
- filter :published_at, with: :date_range,
752
- from_label: "Published from",
753
- to_label: "Published to"
754
- ```
755
-
756
- #### Select Filter
757
-
758
- ```ruby
759
- filter :status, with: :select, choices: %w[draft published archived]
760
- filter :category, with: :select, choices: -> { Category.pluck(:name) }
761
- filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
762
- ```
763
-
764
- #### Association Filter
765
-
766
- ```ruby
767
- filter :category, with: :association
768
- filter :author, with: :association, class_name: User
769
- filter :tags, with: :association, class_name: Tag, multiple: true
770
- ```
771
-
772
- #### Custom Filter Class
773
-
774
- ```ruby
775
- class PriceRangeFilter < Plutonium::Query::Filter
776
- def apply(scope, min: nil, max: nil)
777
- scope = scope.where("price >= ?", min) if min.present?
778
- scope = scope.where("price <= ?", max) if max.present?
779
- scope
780
- end
781
-
782
- def customize_inputs
783
- input :min, as: :number
784
- input :max, as: :number
785
- field :min, placeholder: "Min price..."
786
- field :max, placeholder: "Max price..."
787
- end
788
- end
789
-
790
- filter :price, with: PriceRangeFilter
791
- ```
792
-
793
- ### Scopes
794
-
795
- Scopes appear as quick filter buttons.
796
-
797
- ```ruby
798
- class PostDefinition < ResourceDefinition
799
- scope :published # Uses Post.published
800
- scope :draft # Uses Post.draft
801
-
802
- # Inline scopes
803
- scope(:recent) { |scope| scope.where('created_at > ?', 1.week.ago) }
804
- scope(:mine) { |scope| scope.where(author: current_user) }
805
-
806
- default_scope :published # Applied by default
807
- end
808
- ```
809
-
810
- When a default scope is set:
811
- - Applied on initial page load
812
- - Default scope button is highlighted (not "All")
813
- - Clicking "All" shows all records without any scope filter
814
-
815
- ### Sorting
816
-
817
- ```ruby
818
- sort :title
819
- sort :created_at
820
- sorts :title, :created_at, :view_count # multiple at once
821
-
822
- default_sort :created_at, :desc
823
- default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
824
- ```
825
-
826
- ### URL Parameters
827
-
828
- ```
829
- /posts?q[search]=rails
830
- /posts?q[title][query]=widget
831
- /posts?q[status][value]=published
832
- /posts?q[created_at][from]=2024-01-01&q[created_at][to]=2024-12-31
833
- /posts?q[scope]=recent
834
- /posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
835
- ```
836
-
837
- ### Filter Summary Table
838
-
839
- | Type | Symbol | Input Params | Options |
840
- |------|--------|--------------|---------|
841
- | Text | `:text` | `query` | `predicate:` |
842
- | Boolean | `:boolean` | `value` | `true_label:`, `false_label:` |
843
- | Date | `:date` | `value` | `predicate:` |
844
- | Date Range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
845
- | Select | `:select` | `value` | `choices:`, `multiple:` |
846
- | Association | `:association` | `value` | `class_name:`, `multiple:` |
847
-
848
- ### Query Performance Tips
849
-
850
- 1. Add indexes for filtered/sorted columns
851
- 2. Use `.distinct` when joining associations in search
852
- 3. Consider `pg_search` for complex full-text search
853
- 4. Limit search fields to indexed columns
854
- 5. Use scopes instead of filters for common queries
855
-
856
- ---
857
-
858
- # Actions: Custom and Bulk
859
-
860
- Actions define custom operations on resources. They can be simple (navigation) or interactive (with business logic via Interactions).
861
-
862
- ### Action Types
863
-
864
- | Type | Shows In | Use Case |
865
- |------|----------|----------|
866
- | `resource_action` | Index page | Import, Export, Create |
867
- | `record_action` | Show page | Edit, Delete, Archive |
868
- | `collection_record_action` | Table rows | Quick actions per row |
869
- | `bulk_action` | Selected records | Bulk operations |
870
-
871
- ### Simple Actions (Navigation)
872
-
873
- Simple actions link to existing routes. **The target route must already exist.**
874
-
875
- ```ruby
876
- class PostDefinition < ResourceDefinition
877
- # Link to external URL
878
- action :documentation,
879
- label: "Documentation",
880
- route_options: {url: "https://docs.example.com"},
881
- icon: Phlex::TablerIcons::Book,
882
- resource_action: true
883
-
884
- # Link to custom controller action
885
- action :reports,
886
- route_options: {action: :reports},
887
- icon: Phlex::TablerIcons::ChartBar,
888
- resource_action: true
889
- end
890
- ```
891
-
892
- **Important:** When adding custom routes for actions, always use the `as:` option to name them:
893
-
894
- ```ruby
895
- resources :posts do
896
- collection do
897
- get :reports, as: :reports # Named route required!
898
- end
899
- end
900
- ```
901
-
902
- **Note:** For custom operations with business logic, use **Interactive Actions** with an Interaction class instead.
903
-
904
- ### Interactive Actions (with Interaction)
905
-
906
- ```ruby
907
- class PostDefinition < ResourceDefinition
908
- action :publish,
909
- interaction: PublishInteraction,
910
- icon: Phlex::TablerIcons::Send
911
-
912
- action :archive,
913
- interaction: ArchiveInteraction,
914
- color: :danger,
915
- category: :danger,
916
- position: 1000,
917
- confirmation: "Are you sure?"
918
- end
919
- ```
920
-
921
- ### Action Options
922
-
923
- ```ruby
924
- action :name,
925
- # Display
926
- label: "Custom Label",
927
- description: "What it does",
928
- icon: Phlex::TablerIcons::Star,
929
- color: :danger, # :primary, :secondary, :danger
930
-
931
- # Visibility
932
- resource_action: true,
933
- record_action: true,
934
- collection_record_action: true,
935
- bulk_action: true,
936
-
937
- # Grouping
938
- category: :primary, # :primary, :secondary, :danger
939
- position: 50,
940
-
941
- # Behavior
942
- confirmation: "Are you sure?",
943
- turbo_frame: "_top",
944
- route_options: {action: :foo}
945
- ```
946
-
947
- ### Creating an Interaction
948
-
949
- #### Basic Structure
950
-
951
- ```ruby
952
- # app/interactions/resource_interaction.rb (generated during install)
953
- class ResourceInteraction < Plutonium::Resource::Interaction
954
- end
955
-
956
- # app/interactions/archive_interaction.rb
957
- class ArchiveInteraction < ResourceInteraction
958
- presents label: "Archive",
959
- icon: Phlex::TablerIcons::Archive,
960
- description: "Archive this record"
961
-
962
- attribute :resource
963
-
964
- def execute
965
- resource.archived!
966
- succeed(resource).with_message("Record archived successfully.")
967
- rescue ActiveRecord::RecordInvalid => e
968
- failed(e.record.errors)
969
- rescue => error
970
- failed("Archive failed. Please try again.")
971
- end
972
- end
973
- ```
974
-
975
- #### With Additional Inputs
976
-
977
- ```ruby
978
- class Company::InviteUserInteraction < Plutonium::Resource::Interaction
979
- presents label: "Invite User", icon: Phlex::TablerIcons::Mail
980
-
981
- attribute :resource
982
- attribute :email
983
- attribute :role
984
-
985
- input :email, as: :email, hint: "User's email address"
986
- input :role, as: :select, choices: %w[admin member viewer]
987
-
988
- validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
989
- validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
990
-
991
- def execute
992
- UserInvite.create!(
993
- company: resource,
994
- email: email,
995
- role: role,
996
- invited_by: current_user
997
- )
998
- succeed(resource).with_message("Invitation sent to #{email}.")
999
- rescue ActiveRecord::RecordInvalid => e
1000
- failed(e.record.errors)
1001
- end
1002
- end
1003
- ```
1004
-
1005
- #### Bulk Action (Multiple Records)
1006
-
1007
- Bulk actions operate on multiple selected records at once. The resource table automatically shows selection checkboxes and a bulk actions toolbar.
1008
-
1009
- ```ruby
1010
- # 1. Create the interaction (note: plural `resources` attribute)
1011
- class BulkArchiveInteraction < Plutonium::Resource::Interaction
1012
- presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
1013
-
1014
- attribute :resources # Array of records (plural)
1015
-
1016
- def execute
1017
- count = 0
1018
- resources.each do |record|
1019
- record.archived!
1020
- count += 1
1021
- end
1022
- succeed(resources).with_message("#{count} records archived.")
1023
- rescue => error
1024
- failed("Bulk archive failed: #{error.message}")
1025
- end
1026
- end
1027
-
1028
- # 2. Register the action in the definition
1029
- class PostDefinition < ResourceDefinition
1030
- action :bulk_archive, interaction: BulkArchiveInteraction
1031
- # bulk_action: true is automatically inferred from `resources` attribute
1032
- end
1033
-
1034
- # 3. Add policy method
1035
- class PostPolicy < ResourcePolicy
1036
- def bulk_archive?
1037
- create?
1038
- end
1039
- end
1040
- ```
1041
-
1042
- **Authorization for bulk actions:**
1043
- - Policy method is checked **per record** — fails the entire request if any record is not authorized
1044
- - Records are fetched via `current_authorized_scope`
1045
- - The UI only shows action buttons that **all** selected records support
1046
-
1047
- #### Resource Action (No Record)
1048
-
1049
- ```ruby
1050
- class ImportInteraction < Plutonium::Resource::Interaction
1051
- presents label: "Import CSV", icon: Phlex::TablerIcons::Upload
1052
-
1053
- # No :resource or :resources attribute = resource action
1054
- attribute :file
1055
-
1056
- input :file, as: :file
1057
- validates :file, presence: true
1058
-
1059
- def execute
1060
- succeed(nil).with_message("Import completed.")
1061
- end
1062
- end
1063
- ```
1064
-
1065
- ### Interaction Responses
1066
-
1067
- ```ruby
1068
- def execute
1069
- succeed(resource).with_message("Done!")
1070
- succeed(resource)
1071
- .with_redirect_response(custom_dashboard_path)
1072
- .with_message("Redirecting...")
1073
- failed(resource.errors)
1074
- failed("Something went wrong")
1075
- failed("Invalid value", :email)
1076
- end
1077
- ```
1078
-
1079
- **Note:** Redirect is automatic on success. Only use `with_redirect_response` for a different destination.
1080
-
1081
- ### Default CRUD Actions
1082
-
1083
- ```ruby
1084
- action :new, resource_action: true, position: 10
1085
- action :show, collection_record_action: true, position: 10
1086
- action :edit, record_action: true, position: 20
1087
- action :destroy, record_action: true, position: 100, category: :danger
1088
- ```
1089
-
1090
- ### Action Authorization
1091
-
1092
- ```ruby
1093
- class PostPolicy < ResourcePolicy
1094
- def publish?
1095
- user.admin? || record.author == user
1096
- end
1097
-
1098
- def archive?
1099
- user.admin?
1100
- end
1101
- end
1102
- ```
1103
-
1104
- The action only appears if the policy method returns `true`.
1105
-
1106
- ### Immediate vs Form Actions
1107
-
1108
- **Immediate** — executes without showing a form (when interaction has no extra inputs beyond `resource`):
1109
-
1110
- ```ruby
1111
- class ArchiveInteraction < Plutonium::Resource::Interaction
1112
- attribute :resource
1113
- def execute
1114
- resource.archived!
1115
- succeed(resource)
1116
- end
1117
- end
1118
- ```
1119
-
1120
- **Form** — shows a form first (when interaction has additional inputs):
1121
-
1122
- ```ruby
1123
- class InviteUserInteraction < Plutonium::Resource::Interaction
1124
- attribute :resource
1125
- attribute :email
1126
- input :email
1127
- # Has inputs = shows form first
1128
- end
1129
- ```
1130
-
1131
- ---
1132
-
1133
- ## Related Skills
1134
-
1135
- - `plutonium-views` - Custom page, form, display, and table classes
1136
- - `plutonium-forms` - Custom form templates and field builders
1137
- - `plutonium-interaction` - Writing interaction classes
1138
- - `plutonium-policy` - Controlling action access