plutonium 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -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
|