| name | plutonium-resource |
| description | Use BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization. The single source for "what is a resource and how do I configure one". |
Plutonium Resources
A resource = model + migration + controller + policy + definition. This skill covers all three of: creating the resource, the model layer, and the definition (UI, fields, query, actions).
For tenancy / associated_with / relation_scope, load [[plutonium-tenancy]]. For policy bodies, load [[plutonium-behavior]] (controllers + policies + interactions). For custom Phlex components, load [[plutonium-ui]].
🚨 Critical (read first)
- Always use generators.
pu:res:scaffold creates the resource; pu:res:conn connects it to a portal. Never hand-write the model, migration, policy, definition, or controller.
- Pass
--dest on every scaffold: --dest=main_app or --dest=package_name. Skips the interactive prompt.
- Quote field args with
? or {} to prevent shell expansion: 'field:type?', 'field:decimal{10,2}'.
- Run
pu:res:conn next — without it the resource has no portal routes and is invisible.
- Let auto-detection work. Plutonium reads your model. Only declare
field/input/display/column when overriding the default.
- Authorization is in policies, not
condition: procs. Use condition for UI state ("show this when published"). Use the policy's permitted_attributes_for_* for "who can see this".
- Custom actions require a policy method.
action :publish needs def publish? on the policy.
has_cents virtual accessor — reference :price, NEVER :price_cents, in policies and definitions.
🛑 Before you scaffold or edit: confirm the shape (ASK — don't infer)
"Add a Product" / "add a status field" is underspecified. Guess wrong and you scaffold into the wrong package, churn migrations, store money as a lossy float, reference a model that doesn't exist, or clobber files the user has customized. Resolve each — by inspecting (next section), not guessing — then restate the resolved shape and confirm:
- New resource, or editing an existing one? Existing ⇒ NEVER re-run
pu:res:scaffold (it overwrites the customized model/definition/policy/controller). Add an incremental migration + hand-edit. Confirm by reading the files first.
- Destination & portal.
--dest=main_app or a package? Which portal does pu:res:conn wire it to? Both are required and unguessable from the request.
- Field types — and the money question. A monetary field ⇒
has_cents (price_cents:integer + has_cents :price_cents, reference :price), never a bare decimal. Enums, attachments (:attachment/:attachments), rich text, references each have specific syntax (§ Field Type Syntax). Confirm types rather than inventing them.
- Referenced associations must already exist.
category:belongs_to silently targets a Category — if that model isn't there, scaffold it first.
- Beyond columns: does it need search / filters / scopes / custom or bulk actions? Those live in the definition + policy, not the scaffold — name them now so you don't half-build.
Never emit applied scaffold commands from a guessed --dest, portal, or money-shape. Confirm or read them first; fall back to AskUserQuestion only for product choices you can't read off the code (which portal, is price money). The decisions compound: existing+customized ⇒ migration not scaffold; money ⇒ has_cents + :price in the policy; new reference ⇒ target model must exist.
✅ Before you touch files: verify the ground truth (CHECK — read it, don't ask for it)
You have file access — use it. "Paste me the model" is a fallback for when you genuinely can't read the repo, not the default.
| Check | How | Why it matters |
|---|
| Resource already exists | Read app/models/<x>.rb + app/**/definitions/<x>_definition.rb; ls the policy/controller | Existing + customized ⇒ incremental edit, never re-scaffold |
| Model is a resource record | grep Plutonium::Resource::Record / < ResourceRecord | Auto-detection + the field DSL depend on it |
| Name collision | Read db/schema.rb for the column; grep the model for an existing method/enum/assoc of that name | A clashing status silently breaks the enum |
| Referenced association target | ls app/models for the belongs_to target class | x:belongs_to to a missing model fails the scaffold |
has_cents policy leak | grep the policy/definition for _cents | Generator emits :price_cents; fix to the virtual :price |
| Migrations applied | rails db:migrate:status before pu:res:conn | Unmigrated / unconnected ⇒ the resource is invisible |
Inspect with your own tools before proposing commands or edits.
🛠 Use the generator — and don't clobber
Never hand-write the initial model, migration, policy, definition, or controller. Reach for the generator; quote args with ?/{}; pass --dest=.
| Task | Generator | Verify first |
|---|
| New resource | pu:res:scaffold Model field:type … --dest= | --dest confirmed; referenced models exist |
| Connect to a portal | pu:res:conn Model --dest=portal | Migrations are run |
| Regenerate model from columns | pu:res:scaffold Model --no-migration | ⚠ regenerates the model file — review the diff; overwrites customizations |
| Add a field to an existing, customized resource | rails g migration AddXToYs … + hand-edit model/definition/policy | This resource was already scaffolded — re-scaffolding clobbers it |
Part 1 — Creating a Resource
Quick checklist
- Pick destination:
--dest=main_app or --dest=package_name.
- Run
rails g pu:res:scaffold ResourceName field:type ... --dest=<dest>.
- Review the generated migration — add cascade deletes, composite indexes, defaults.
rails db:prepare.
rails g pu:res:conn ResourceName --dest=<portal_name>.
- Customize the policy's
permitted_attributes_for_* as needed.
- Open the portal route in the browser.
Command Syntax
rails g pu:res:scaffold MODEL_NAME \
field1:type \
field2:type \
--dest=DESTINATION
Quote any field with ? or {}:
'field:type?'
'field:decimal{10,2}'
'field:decimal?{10,2}'
Field Type Syntax
Format: name:type[?][{options}][:index_type]
? after the type → nullable (null: true in migration, optional: true on belongs_to)
{...} → type options: {default:X}, {10,2} precision/scale, {class_name:User}
:index_type → index (regular) or uniq (unique)
- Quote any field containing
? or {} to prevent shell expansion
Basic Types
| Syntax | Result |
|---|
name:string | Required string |
'name:string?' | Nullable string |
age:integer | Required integer |
'age:integer?' | Nullable integer |
active:boolean | Required boolean |
'active:boolean?' | Nullable boolean |
content:text | Required text |
'content:text?' | Nullable text |
birth_date:date | Required date |
'anniversary:date?' | Nullable date |
starts_at:datetime | Required datetime |
'ends_at:datetime?' | Nullable datetime |
alarm_time:time | Required time |
'reminder_time:time?' | Nullable time |
metadata:json | JSON field |
settings:jsonb | JSONB (PostgreSQL + SQLite) |
external_id:uuid | UUID field |
PostgreSQL-Specific Types
Auto-mapped to SQLite equivalents when needed:
| Type | PostgreSQL | SQLite |
|---|
jsonb | jsonb | json |
hstore | hstore | json |
uuid | uuid | string |
inet | inet | string |
cidr | cidr | string |
macaddr | macaddr | string |
ltree | ltree | string |
Default Values
'status:string{default:draft}'
'active:boolean{default:true}'
'priority:integer{default:0}'
'rating:float{default:4.5}'
'status:string?{default:pending}'
JSON/JSONB defaults (parsed as JSON first, then string fallback):
'metadata:jsonb{default:{}}'
'tags:jsonb{default:[]}'
'settings:jsonb{default:{"theme":"dark"}}'
'config:jsonb?{default:{}}'
Decimal with Precision
'amount:decimal{10,2}'
'price:decimal{10,2,default:0}'
'balance:decimal?{15,2,default:0}'
References / Associations
company:belongs_to
'parent:belongs_to?'
user:references
blogging/post:belongs_to
'author:belongs_to{class_name:User}'
'reviewer:belongs_to?{class_name:User}'
Index Types (third segment)
email:string:index
email:string:uniq
Special Types
password_digest
auth_token:token
content:rich_text
avatar:attachment
photos:attachments
price_cents:integer
Generator Options
--dest=DESTINATION — main_app or package_name (required)
--no-model — skip model file
--no-migration — skip migration
For existing models that already include Plutonium::Resource::Record:
rails g pu:res:scaffold Post --no-migration --dest=main_app
Run with no fields to auto-import from model.content_columns (regenerates the model file — review the diff).
What Gets Generated
Main app:
app/models/model_name.rb
db/migrate/xxx_create_model_names.rb
app/controllers/model_names_controller.rb
app/policies/model_name_policy.rb
app/definitions/model_name_definition.rb
Packaged (paths nested under packages/package_name/... for controller/policy/definition; model and migration stay at app root with namespace).
Migration Customizations
Always review before migrating. Per project convention, inline indexes/FKs in the create_table block:
create_table :model_names do |t|
t.belongs_to :parent, null: false, foreign_key: {on_delete: :cascade}
t.string :name, null: false
t.timestamps
t.index :name
t.index [:parent_id, :name], unique: true
end
For non-trivial defaults, edit the migration directly:
t.datetime :published_at, default: -> { "CURRENT_TIMESTAMP" }
Examples
rails g pu:res:scaffold Post \
user:belongs_to \
title:string \
'content:text?' \
'published_at:datetime?' \
--dest=main_app
rails g pu:res:scaffold Property \
company:belongs_to \
code:string:uniq \
'latitude:decimal{11,8}' \
'value:decimal?{15,2}' \
--dest=main_app
rails g pu:res:scaffold Comment \
user:belongs_to \
blogging/post:belongs_to \
body:text \
--dest=comments
Part 2 — The Model Layer
What Plutonium::Resource::Record provides
| Module | Purpose |
|---|
HasCents | Monetary values (cents ↔ decimal) |
Routes | URL params, to_param customization |
Labeling | to_label for human-readable names |
FieldNames | Field introspection by category |
Associations | SGID methods on every association |
AssociatedWith | Multi-tenant scoping (see [[plutonium-tenancy]]) |
Standard setup (created by pu:core:install):
class ApplicationRecord < ActiveRecord::Base
include Plutonium::Resource::Record
primary_abstract_class
end
class ResourceRecord < ApplicationRecord
self.abstract_class = true
end
Section Order
The scaffold lays out resource models in a strict order — keep new code in the right section so files stay scannable:
- Concerns (
include)
- Constants (
TYPES = {...}.freeze)
- Enums
- Model configurations (
has_cents)
belongs_to
has_one
has_many
- Attachments (
has_one_attached, has_many_attached)
- Scopes
- Validations
- Callbacks
- Delegations
- Misc macros (
has_rich_text, has_secure_token, has_secure_password)
- Public methods, then
private, then private methods
Example:
class Property < ResourceRecord
TYPES = {apartment: "Apartment", house: "House"}.freeze
enum :state, archived: 0, active: 1
has_cents :market_value_cents
belongs_to :company
has_one :address
has_many :units
has_one_attached :photo
scope :active, -> { where(state: :active) }
validates :name, presence: true
validates :property_code, presence: true, uniqueness: {scope: :company_id}
before_validation :generate_code, on: :create
has_rich_text :description
def full_address
address&.to_s
end
private
def generate_code
self.property_code ||= SecureRandom.hex(4).upcase
end
end
Monetary Handling (has_cents)
Stores money as integer cents; exposes a decimal virtual accessor.
class Product < ResourceRecord
has_cents :price_cents
has_cents :cost_cents, name: :wholesale
has_cents :tax_cents, rate: 1000
end
product.price = 19.99
product.price_cents
product.price
product.price = 10.999
product.price_cents
Critical: in policies and definitions, reference the virtual accessor (:price), NOT the column (:price_cents). Generators sometimes emit _cents in the policy — fix by hand:
permitted_attributes_for_create { %i[name price] }
field :price, as: :decimal
Validation on the cents column propagates a generic error to the virtual:
validates :price_cents, numericality: {greater_than: 0}
SGID on Associations
Every association gets Signed Global ID methods for secure serialization (form params, API payloads, hidden fields).
class Post < ResourceRecord
belongs_to :user
has_many :tags
end
post.user_sgid
post.user_sgid = "..."
post.tag_sgids
post.tag_sgids = [...]
post.add_tag_sgid("...")
post.remove_tag_sgid("...")
URL Routing
path_parameter and dynamic_path_parameter are class-level macros (private class methods) — call them in the class body, not as instance methods.
user.to_param
class User < ResourceRecord
path_parameter :username
end
class Article < ResourceRecord
dynamic_path_parameter :title
end
Article.from_path_param("1-my-great-article")
User.from_path_param("john_doe")
Labeling
user.to_label
class Product < ResourceRecord
def to_label = "#{name} (#{sku})"
end
Field Introspection
User.resource_field_names
User.content_column_field_names
User.belongs_to_association_field_names
User.has_one_association_field_names
User.has_many_association_field_names
User.has_one_attached_field_names
User.has_many_attached_field_names
Part 3 — The Definition Layer
Definitions configure how a resource is rendered and interacted with.
🚨 Do NOT declare a field / input / display / column unless you are overriding an auto-detected default. Plutonium reads the model and renders every attribute automatically — type, label, form widget, display formatter, column. Declaring it again with no new options is dead code; declaring it with the same as: Plutonium already inferred is dead code; listing every field "for completeness" is dead code. If the only reason you're adding a line is "so the field shows up", delete it — it already shows up. Declare ONLY when you need: a different type (as: :markdown), a custom option (hint:, placeholder:, wrapper:), a condition:, a custom block, or a custom component.
File locations:
- Main app:
app/definitions/model_name_definition.rb
- Packages:
packages/pkg_name/app/definitions/pkg_name/model_name_definition.rb
Hierarchy
class ResourceDefinition < Plutonium::Resource::Definition
action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
end
class PostDefinition < ResourceDefinition
scope :published
input :content, as: :markdown
end
class AdminPortal::PostDefinition < ::PostDefinition
input :internal_notes, as: :text
scope :pending_review
end
Core Methods
| Method | Applies To | Use When |
|---|
field | Forms + Show + Table | Universal type override |
input | Forms only | Form-specific options |
display | Show page only | Display-specific options |
column | Table only | Table-specific options |
class PostDefinition < ResourceDefinition
field :content, as: :markdown
input :title, hint: "Be descriptive"
display :content, wrapper: {class: "col-span-full"}
column :view_count, align: :end
end
Separation of Concerns
| Layer | Purpose | Example |
|---|
| Definition | HOW fields render | input :content, as: :markdown |
| Policy | WHAT is visible/editable | permitted_attributes_for_read |
| Interaction | Business logic | resource.update!(state: :archived) |
Available Field Types
Input Types (forms)
| Category | Types |
|---|
| Text | :string, :text, :email, :url, :tel, :password |
| Rich Text | :markdown (EasyMDE) |
| Numeric | :number, :integer, :decimal, :range |
| Boolean | :toggle / :switch (switch — default for boolean columns), :boolean (plain checkbox) |
| Date/Time | :date, :time, :datetime |
| Selection | :select, :slim_select, :radio_buttons, :check_boxes |
| Files | :file, :uppy, :attachment |
| Associations | :association, :secure_association, :belongs_to, :has_many, :has_one |
| Special | :hidden, :color, :phone |
Display Types (show / index)
:string, :text, :email, :url, :phone, :markdown, :number, :integer, :decimal, :boolean, :badge, :currency, :color, :date, :time, :datetime, :association, :attachment
Auto-inferred display formatting
These render automatically — declare an as: only to override or pass options:
| Column | Renders as | Notes |
|---|
boolean | Yes/No pill (:boolean) | green "Yes" / neutral "No". Override labels: true_label:, false_label: |
enum | colored status badge (:badge) | known statuses (active, pending, failed…) auto-colored; unknown values get a stable decorative color |
has_cents decimal | currency (:currency) | delimited, 2 decimals, no symbol unless you add unit: |
display :status, as: :badge, colors: {archived: :neutral, vip: :accent}
display :price, as: :currency, unit: "£"
display :price, as: :currency, unit: :currency_symbol
display :active, as: :boolean, true_label: "Live", false_label: "Off"
Badge color keys: :neutral, :primary, :secondary, :success, :danger, :warning, :info, :accent.
Field Options
input :title,
label: "Custom Label",
hint: "Help text",
placeholder: "Enter value",
description: "For displays",
class: "custom-class",
data: {controller: "custom"},
required: true,
readonly: true,
disabled: true,
wrapper: {class: "col-span-full"}
Select / Choices
input :category, as: :select, choices: %w[Tech Business Lifestyle]
input :status, as: :select, choices: Post.statuses.keys
input :author do |f|
f.select_tag choices: User.active.pluck(:name, :id)
end
input :team_members do |f|
f.select_tag choices: current_user.organization.users.pluck(:name, :id)
end
Conditional Rendering
display :published_at, condition: -> { object.published? }
display :rejection_reason, condition: -> { object.rejected? }
field :debug_info, condition: -> { Rails.env.development? }
Use condition for UI state; use the policy for authorization.
Dynamic Forms (pre_submit)
A pre_submit: true field triggers a server re-render on change, re-evaluating condition: procs. Use for cascading or context-dependent forms.
class QuestionDefinition < ResourceDefinition
input :question_type, as: :select,
choices: %w[text choice scale],
pre_submit: true
input :max_length, condition: -> { object.question_type == "text" }
input :choices, condition: -> { object.question_type == "choice" }
input :min_value, condition: -> { object.question_type == "scale" }
end
Dynamic choices follow the same pattern:
input :category, as: :select,
choices: Category.pluck(:name, :id),
pre_submit: true
input :subcategory do |f|
choices = object.category.present? ?
Category.find(object.category).subcategories.pluck(:name, :id) : []
f.select_tag choices: choices
end
Tips:
- Only add
pre_submit: to fields that gate visibility of others.
- Avoid on frequently-changed fields (every keystroke = submit).
Custom Rendering
Display block — return any component:
display :status do |field|
StatusBadgeComponent.new(value: field.value, class: field.dom.css_class)
end
Input block — must use form builder methods:
input :birth_date do |f|
case object.age_category
when 'adult' then f.date_tag(min: 18.years.ago.to_date)
when 'minor' then f.date_tag(max: 18.years.ago.to_date)
else f.date_tag
end
end
phlexi_tag for declarative custom display. The with: option takes either a Phlex component class, or a proc whose body is rendered inside a Phlex context — so HTML tags (span, div, a, …) and Tailwind classes are first-class. The proc receives (value, attrs) where value is the field value and attrs are wrapper attributes.
display :status, as: :phlexi_tag, with: StatusBadgeComponent
display :priority, as: :phlexi_tag, with: ->(value, attrs) {
case value
when 'high' then span(class: "badge badge-danger") { "High" }
when 'medium' then span(class: "badge badge-warning") { "Medium" }
else span(class: "badge badge-info") { "Low" }
end
}
See [[plutonium-ui]] for writing custom Phlex components.
Custom component classes (Phlex components — see [[plutonium-ui]]):
input :color_picker, as: ColorPickerComponent
display :chart, as: ChartComponent
Column Options
column :title, align: :start
column :amount, align: :end
column :price, formatter: ->(v) { "$%.2f" % v if v }
column :full_name do |record|
"#{record.first_name} #{record.last_name}"
end
Nested Inputs
Inline forms for associated records. Requires accepts_nested_attributes_for on the model.
class Post < ResourceRecord
has_many :comments
has_one :metadata
accepts_nested_attributes_for :comments, allow_destroy: true, limit: 10
accepts_nested_attributes_for :metadata, update_only: true
end
class PostDefinition < ResourceDefinition
nested_input :comments do |n|
n.input :body, as: :text
n.input :author_name
end
nested_input :metadata, using: PostMetadataDefinition, fields: %i[seo_title seo_description]
end
Options
| Option | Description |
|---|
limit | Max records (auto-detected from model, default 10) |
allow_destroy | Show delete checkbox (auto-detected) |
update_only | Hide "Add" button — only edit existing |
description | Help text above section |
condition | Proc to show/hide |
using | Another Definition class |
fields | Subset of fields from the referenced definition |
Gotchas
- Model needs
accepts_nested_attributes_for.
- The child's
belongs_to must declare inverse_of: :parent_assoc. Without it, in-memory validation fails with "Parent must exist" because the parent isn't saved yet.
- Do NOT put
*_attributes hashes in permitted_attributes_for_*. Plutonium extracts nested params via the form definition, not the policy. The policy permits just the association name (:variants); nested_input :variants handles the rest.
- For custom class names, use
class_name: in the model and using: in the definition.
update_only: true hides the Add button.
Structured Inputs
structured_input collects a classless group of fields — a single hash, or
(with repeat:) an array of hashes. No association or model class is involved.
On resources the value is stored in a JSON/jsonb column; use it when you
want structured data in a JSON column rather than a real association (which is
nested_input's job).
class Spec < ResourceRecord
end
class SpecDefinition < ResourceDefinition
structured_input :payload do |f|
f.input :title
f.input :notes
end
structured_input :rows, repeat: 5 do |f|
f.input :key
f.input :value
end
end
class SpecPolicy < ResourcePolicy
def permitted_attributes_for_create = [:payload, :rows]
end
execute/the record sees payload => { "title" => …, "notes" => … } and
rows => [ { "key" => …, "value" => … }, … ] (string keys from the JSON column;
blank rows are dropped, _destroy stripped).
Options
| Option | Description |
|---|
repeat | Presence ⇒ array (repeater). Integer = max rows; true = default cap (10); absent = single hash |
using | A fields definition class instead of a block |
fields | Subset of fields from the referenced definition |
Gotchas
- The column must be
json/jsonb (or otherwise hold a hash/array). No model macro is needed — the value assigns directly.
- Unlike
nested_input, you DO permit the column name in permitted_attributes_for_* (it's a regular attribute on a JSON column).
repeat: 1 is "array, max one row" — not the single form. Presence of repeat: always means an array.
- Rows are positional plain hashes — no ids, no per-row class, no type coercion.
- No automatic validation. Classless ⇒ nothing to attach
validates to. required: and a select's choices: are client-side only, not enforced on the server. To enforce, add a model validate (resource) or a validate on the interaction (ActiveModel, checked before execute).
as: :select drops unknown values. If a stored value isn't in choices:, the <select> renders blank and saving overwrites it with nil (standard <select> behaviour). Keep choices: a stable superset or use free text when values can drift.
- Inside repeater rows, prefer native field types (string, number, text, native
select, checkbox). JS-enhanced inputs (slim-select, flatpickr, easymde, uppy, intl-tel) transform the DOM and may not survive the repeater's clone-by-innerHTML — verify before relying on them.
- Same DSL works on interactions (see [[plutonium-behavior]] › Interactions) — there it backs an ActiveModel attribute reaching
execute.
File Uploads
input :avatar, as: :file
input :avatar, as: :uppy
input :documents, as: :file, multiple: true
input :documents, as: :uppy,
allowed_file_types: ['.pdf', '.doc'],
max_file_size: 5.megabytes
Block Context
Inside condition procs and block-form input/display:
object — the record
current_user
current_parent — for nested resources
request, params
- All helper methods
Runtime Customization Hooks
For dynamic per-request logic, override:
def customize_fields
def customize_inputs
def customize_displays
def customize_filters
def customize_actions
Form & Page Configuration
class PostDefinition < ResourceDefinition
submit_and_continue false
modal :centered, size: :lg
index_page_title "All Posts"
show_page_title -> { "#{current_record!.title} - Details" }
breadcrumbs true
show_page_breadcrumbs false
class IndexPage < IndexPage
def view_template(&block)
div(class: "custom-header") { h1 { "Custom" } }
super(&block)
end
end
class Form < Form
def form_template
div(class: "grid grid-cols-2") do
render field(:title).input_tag
render field(:content).easymde_tag
end
render_actions
end
end
end
modal: is the default for framework :new / :edit and every interactive action on this definition. Per-action modal: / size: overrides win.
Form Layout (form_layout)
Group form fields into sections declaratively in the definition — no Form subclass, no view code. Prefer this over hand-rolling a section helper in a custom form_template.
class PostDefinition < ResourceDefinition
form_layout do
section :identity, :name, :email, label: "Identity", description: "Who this is"
section :address, :street, :city,
collapsible: true, collapsed: -> { object.persisted? }, columns: 2,
condition: -> { object.requires_address? }
ungrouped label: "Other"
end
end
- Layout references field KEYS only — all per-field config (
as:, hint:, blocks, per-field condition:) stays on input. Never duplicated here.
- Options:
label:, description:, collapsible:, collapsed:, columns: (positive Integer, literal only), condition:. Every option except columns: may be a proc resolved at render in the form context (object, current_user, params, helpers).
- Absent fields are skipped. A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped — never an error. The same layout serves a richly-permitted
edit and a minimal new.
- 🚨 Zero-field sections drop entirely — no heading, no grid. So
+ New (fewer permitted attributes) won't sprout empty headings. This checks field presence only; per-field condition: runs later, so to hide a whole section by state, gate it with the section's own condition:, not by hiding every field inside it.
- Works on interactions too (
Plutonium::Interaction::Base) — groups attribute declarations. There object is the interaction instance; for record actions the record is object.resource.
Full DSL reference: Resource › Definition › Form layout.
Metadata Panel (show page)
Declares fields rendered in the show page's right-side aside as label/value rows.
metadata :author, :state, :created_at, :updated_at
- Opt-in — no call → show page is full-width with no aside.
- Policy-aware — fields the user can't see disappear; panel auto-hides if nothing's permitted.
- Deduplicated — listed fields are removed from the main details card.
- Responsive — side-by-side at
lg+, stacked below.
Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
Index Views (Table & Grid)
Resources can offer both Table and Grid views; user choice persists per-resource via cookie.
class UserDefinition < ResourceDefinition
grid_fields(
image: :avatar,
header: :name,
subheader: :email,
body: :bio,
meta: [:role, :status],
footer: :last_seen_at
)
default_index_view :grid
grid_layout :media
grid_columns 3
end
Only declare index_views explicitly if you want to disable one (e.g. index_views :grid to remove the table view).
| Method | Purpose |
|---|
index_views :table, :grid | Which views are available. Default [:table]. Only declare to disable one. |
default_index_view :grid | Initial view when no cookie. |
grid_fields(...) | Map card slots to fields. Implicitly enables :grid. |
grid_layout :media | :compact (image left) or :media (image on top). |
grid_columns 3 | Override responsive column count. |
All grid slots are optional; slots pointing at unpermitted fields collapse silently.
Part 4 — Query: Search, Filters, Scopes, Sorting
class PostDefinition < ResourceDefinition
search do |scope, q|
scope.where("title ILIKE ?", "%#{q}%")
end
filter :title, with: :text, predicate: :contains
filter :status, with: :select, choices: %w[draft published archived]
filter :published, with: :boolean
filter :created_at, with: :date_range
scope :published
default_scope :published
sort :title
sort :created_at
default_sort :created_at, :desc
end
Search
search do |scope, query|
scope.joins(:author).where(
"posts.title ILIKE :q OR users.name ILIKE :q",
q: "%#{query}%"
).distinct
end
Filters
| Type | Symbol | Params | Options |
|---|
| Text | :text | query | predicate: |
| Boolean | :boolean | value | true_label:, false_label: |
| Date | :date | value | predicate: |
| Date Range | :date_range | from, to | from_label:, to_label: |
| Select | :select | value | choices:, multiple: |
| Association | :association | value | class_name:, multiple: |
Text predicates: :eq, :not_eq, :contains, :not_contains, :starts_with, :ends_with, :matches, :not_matches
Date predicates: :eq, :not_eq, :lt, :lteq, :gt, :gteq
filter :title, with: :text, predicate: :contains
filter :active, with: :boolean
filter :due_date, with: :date, predicate: :lt
filter :created_at, with: :date_range
filter :status, with: :select, choices: %w[draft published]
filter :category, with: :select, choices: -> { Category.pluck(:name) }
filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
filter :category, with: :association
filter :author, with: :association, class_name: User
Custom filter class:
class PriceRangeFilter < Plutonium::Query::Filter
def apply(scope, min: nil, max: nil)
scope = scope.where("price >= ?", min) if min.present?
scope = scope.where("price <= ?", max) if max.present?
scope
end
def customize_inputs
input :min, as: :number
input :max, as: :number
field :min, placeholder: "Min price..."
field :max, placeholder: "Max price..."
end
end
filter :price, with: PriceRangeFilter
Scopes
Scopes appear as quick filter buttons.
scope :published
scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
scope(:mine) { |s| s.where(author: current_user) }
default_scope :published
Conditional scopes (condition:)
Like condition: on actions and fields — define a scope but only render its button when a proc is truthy. The scope itself (and its URL) stays live; condition: only controls UI visibility.
scope :admin_only, condition: -> { current_user.admin? }
scope :beta_feature, condition: -> { params[:beta] == "1" }
scope :never_shown, condition: -> { false }
The proc is evaluated against the view context — current_user, params, request, allowed_to? are all available directly. There is no object/record (scopes have no single-record context).
🚨 condition: is NOT authorization. A hidden scope button still has a live URL. Use condition: for UI relevance ("show admins only this tab"). Use the policy's relation_scope for "who can see these records at all".
Sorting
sort :title
sort :created_at
sorts :title, :created_at, :view_count
default_sort :created_at, :desc
default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
URL Parameters
/posts?q[search]=rails
/posts?q[title][query]=widget
/posts?q[status][value]=published
/posts?q[created_at][from]=2024-01-01&q[created_at][to]=2024-12-31
/posts?q[scope]=recent
/posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
Part 5 — Actions: Custom and Bulk
Action Types
| Type flag | Shows In | Use Case |
|---|
resource_action: true | Index page | Import, Export, Create |
record_action: true | Show page | Edit, Delete, Archive |
collection_record_action: true | Table rows | Quick per-row actions |
bulk_action: true | Selected records | Bulk operations |
🚨 For interactive actions (interaction:), all four flags are inferred from the interaction's attributes — don't declare them manually:
| Interaction declares | Inferred flags |
|---|
attribute :resource | record_action + collection_record_action |
attribute :resources (plural) | bulk_action |
| neither | resource_action |
User-supplied flags override the inferred ones, but only opt-out makes sense for interactive actions — the interaction's attribute :resource / attribute :resources already fixes the action's semantic shape. Use opt-out to narrow where the button appears:
action :archive, interaction: ArchiveInteraction, collection_record_action: false
action :preview, interaction: PreviewInteraction, record_action: false
Declare flags manually for: simple/navigation actions (no interaction:), or opting out of an inferred slot.
Action Options
action :name,
label: "Custom Label",
description: "What it does",
icon: Phlex::TablerIcons::Star,
color: :danger,
resource_action: true,
record_action: true,
collection_record_action: true,
bulk_action: true,
condition: -> { params[:beta] == "1" },
category: :primary,
position: 50,
confirmation: "Are you sure?",
turbo_frame: "_top",
route_options: {action: :foo},
modal: :slideover,
size: :lg,
link: {target: "_blank", rel: "noopener"},
button: {data: {analytics: "x"}}
HTML Attributes (link: / button:)
Per-element attribute bags for an action's rendered control. link: lands on every anchor the action renders as (dropdown items are anchors even for non-GET actions); button: lands on the button_to <form> element. The author wins on collisions — including class: (replaces, no token append) and turbo_frame. Pass data: as a hash: a scalar data: replaces the framework's data wholesale, dropping turbo_confirm/turbo_frame.
action :documentation,
route_options: {url: "https://docs.example.com"},
resource_action: true,
link: {target: "_blank", rel: "noopener noreferrer"}
Both bags round-trip through with(...): defined_actions[:edit].with(link: {target: "_blank"}).
Conditional Actions (condition:)
Like condition: on inputs/displays/columns — define an action but render its button only when a runtime proc is truthy. The action and its route stay live either way; condition: only toggles the UI.
Headline use case: expose an action's endpoint without a button — one you call from the API, a webhook, or another service. Hide it with an always-falsy condition; the route still works:
action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }
action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
Inside the proc, object/record is the contextual record — the row/shown record for record and collection-record actions, nil for resource and bulk actions (guard with object&.… if shared). Every other call delegates to the view context: current_user, current_parent, params, request, allowed_to?, resource_record!, etc. object is evaluated per row in tables/grids, so per-record show/hide works there.
🚨 condition: is NOT authorization — it only hides the button. A hidden action still has a live route; anyone with the URL can trigger it. "Who may run this" belongs in the policy:
action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
def wipe? = current_user.admin?
The two compose: an action's button shows only when the policy permits and the condition is truthy; execution is gated by the policy alone. Use object in condition: for per-record display; use the policy for per-record authorization.
Action#with(...) — actions are frozen value objects; clone with overrides:
def customize_actions
base = action(:edit)
replace_action base.with(turbo_frame: nil)
end
Simple Actions (Navigation)
Link to existing routes. The target route MUST already exist.
action :documentation,
label: "Documentation",
route_options: {url: "https://docs.example.com"},
icon: Phlex::TablerIcons::Book,
resource_action: true
action :reports,
route_options: {action: :reports},
resource_action: true
Named routes are required:
resources :posts do
collection do
get :reports, as: :reports
end
end
For anything with business logic, use an Interactive Action instead.
Interactive Actions (Interactions)
class PostDefinition < ResourceDefinition
action :publish, interaction: PublishInteraction
action :archive, interaction: ArchiveInteraction,
color: :danger, category: :danger, position: 1000,
confirmation: "Are you sure?"
end
Single-record interaction
class ArchiveInteraction < ResourceInteraction
presents label: "Archive",
icon: Phlex::TablerIcons::Archive,
description: "Archive this record"
attribute :resource
def execute
resource.archived!
succeed(resource).with_message("Record archived successfully.")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
rescue => error
failed("Archive failed. Please try again.")
end
end
With additional inputs (renders a form)
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
presents label: "Invite User", icon: Phlex::TablerIcons::Mail
attribute :resource
attribute :email
attribute :role
input :email, as: :email, hint: "User's email address"
input :role, as: :select, choices: %w[admin member viewer]
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
def execute
UserInvite.create!(company: resource, email: email, role: role, invited_by: current_user)
succeed(resource).with_message("Invitation sent to #{email}.")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
end
Bulk action
class BulkArchiveInteraction < Plutonium::Resource::Interaction
presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
attribute :resources
def execute
resources.each(&:archived!)
succeed(resources).with_message("#{resources.size} records archived.")
rescue => error
failed("Bulk archive failed: #{error.message}")
end
end
action :bulk_archive, interaction: BulkArchiveInteraction
class PostPolicy < ResourcePolicy
def bulk_archive? = create?
end
The UI only shows bulk action buttons that ALL selected records support. Records are fetched via current_authorized_scope.
Resource action (no record)
class ImportInteraction < Plutonium::Resource::Interaction
presents label: "Import CSV", icon: Phlex::TablerIcons::Upload
attribute :file
input :file, as: :file
validates :file, presence: true
def execute
succeed(nil).with_message("Import completed.")
end
end
Interaction Responses
def execute
succeed(resource).with_message("Done!")
succeed(resource)
.with_redirect_response(custom_dashboard_path)
.with_message("Redirecting...")
failed(resource.errors)
failed("Something went wrong")
failed("Invalid value", :email)
end
Redirect is automatic on success. Only use with_redirect_response for a non-default destination.
Default CRUD Actions
action :new, resource_action: true, position: 10
action :show, collection_record_action: true, position: 10
action :edit, record_action: true, position: 20
action :destroy, record_action: true, position: 100, category: :danger
Action Authorization
A custom action only renders if its policy method returns true:
class PostPolicy < ResourcePolicy
def publish? = user.admin? || record.author == user
def archive? = user.admin?
end
Immediate vs Form
- Immediate — interaction has only
:resource (or :resources) and no other inputs. Shows an auto-generated browser confirmation ("#{label}?", e.g. "Archive?") on click, then runs. Pass confirmation: "Custom message" to override, or confirmation: false to skip.
- Form — interaction declares extra
attribute/input beyond :resource/:resources. Renders a modal form first; no auto-confirmation (the form itself is the confirmation step).
CSV Export (built-in)
Every resource has a streamed CSV export, disabled by default. It is not declared
with action :export_csv — it's a policy-gated capability with its own split button.
The route (GET /<resources>/export_csv) is auto-mounted; the button appears on the
index page once the policy permits it. Enable it by overriding one policy method:
class PostPolicy < ResourcePolicy
def export_csv? = true
end
Two exports (split button in the index toolbar, after Filter):
- Export (primary) — the current view: selected scope + filters + search (the
index's
?q), all matching rows (not just the visible page). File: posts_<date>.csv.
- Export all (dropdown) — the entire authorized scope, ignoring scope/filters/
search (
?all=1). File: posts_all_<date>.csv.
Both stream via find_each (memory-safe on large tables; primary-key order, so the
file does not preserve the index sort).
- Columns =
permitted_attributes_for_export (defaults to the index columns),
with the primary key always first. Override to tailor:
def permitted_attributes_for_export = [:title, :author, :total, :created_at]
- Per-field output — customize a cell's value and header in the definition with
the
export DSL (parallels display/column):
class PostDefinition < ResourceDefinition
export :author, label: "Author email", &->(post) { post.author.email }
export :total, &->(post) { post.total.format }
end
- Without an
export block a column is read off the record: scalars as-is,
associations as their display_name_of label (e.g. User #5, not #<User:…>). A
computed/virtual column with no real method needs an export block (a label:-only
export doesn't supply a value) — otherwise the cell renders <<invalid column>>.
- CSV/formula injection is neutralized automatically (cells starting with
= + - @ or
tab/CR get a leading ').
The button opens in a new tab (so the streamed download bypasses Turbo). Full reference:
docs/reference/resource/export.md.
Related Skills
- [[plutonium-behavior]] — controllers, policies (
permitted_attributes_for_*, action methods), interactions
- [[plutonium-tenancy]] —
associated_with, relation_scope, nested resources
- [[plutonium-ui]] — custom Phlex pages, forms, displays, tables
- [[plutonium-testing]] — testing resources, definitions, policies, interactions