Skip to content
Back to skills

Plutonium Resource

ASecurity

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, hidden actions, index views, drag-to-reorder (positioned_on / position_on), page customization. The single source for "what is a resource and how do I configure one".

  • 76 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 26, 2026
developmentgorubyshellbashsqlrailstestinggitapidocumentation

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 21, 2026

npx -y skills add radioactive-labs/plutonium-core --skill plutonium-resource --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Plutonium Resource?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Plutonium Resource
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/radioactive-labs-plutonium-resource/badge)](https://www.skillsdirectory.com/skills/radioactive-labs-plutonium-resource)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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, hidden actions, index views, drag-to-reorder (positioned_on / position_on), 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:

1. **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*.
2. **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.
3. **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.
4. **Referenced associations must already exist.** `category:belongs_to` silently targets a `Category` — if that model isn't there, scaffold it first.
5. **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

1. Pick destination: `--dest=main_app` or `--dest=package_name`.
2. Run `rails g pu:res:scaffold ResourceName field:type ... --dest=<dest>`.
3. Review the generated migration — add cascade deletes, composite indexes, defaults.
4. `rails db:prepare`.
5. `rails g pu:res:conn ResourceName --dest=<portal_name>`.
6. Customize the policy's `permitted_attributes_for_*` as needed.
7. Open the portal route in the browser.

## Command Syntax

```bash
rails g pu:res:scaffold MODEL_NAME \
    field1:type \
    field2:type \
    --dest=DESTINATION
```

Quote any field with `?` or `{}`:

```bash
'field:type?'              # nullable
'field:decimal{10,2}'      # options
'field:decimal?{10,2}'     # both
```

## 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

```bash
'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):

```bash
'metadata:jsonb{default:{}}'
'tags:jsonb{default:[]}'
'settings:jsonb{default:{"theme":"dark"}}'
'config:jsonb?{default:{}}'
```

### Decimal with Precision

```bash
'amount:decimal{10,2}'                   # precision: 10, scale: 2
'price:decimal{10,2,default:0}'          # with default
'balance:decimal?{15,2,default:0}'       # nullable + default
```

### References / Associations

```bash
company:belongs_to                       # required FK
'parent:belongs_to?'                     # nullable (null: true + optional: true)
user:references                          # same as belongs_to
blogging/post:belongs_to                 # cross-package reference
'author:belongs_to{class_name:User}'     # custom class_name
'reviewer:belongs_to?{class_name:User}'  # nullable + class_name
```

### Index Types (third segment)

```bash
email:string:index     # regular index
email:string:uniq      # unique index
```

### Special Types

```bash
password_digest        # has_secure_password
auth_token:token       # has_secure_token (auto unique index)
content:rich_text      # has_rich_text
avatar:attachment      # has_one_attached
photos:attachments     # has_many_attached
price_cents:integer    # use with has_cents in model
```

## 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`:

```bash
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**:

```ruby
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:

```ruby
t.datetime :published_at, default: -> { "CURRENT_TIMESTAMP" }
```

## Examples

```bash
# Main app resource with associations and a nullable text field
rails g pu:res:scaffold Post \
    user:belongs_to \
    title:string \
    'content:text?' \
    'published_at:datetime?' \
    --dest=main_app

# Precision + indexes
rails g pu:res:scaffold Property \
    company:belongs_to \
    code:string:uniq \
    'latitude:decimal{11,8}' \
    'value:decimal?{15,2}' \
    --dest=main_app

# Cross-package reference
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`):

```ruby
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:

1. Concerns (`include`)
2. Constants (`TYPES = {...}.freeze`)
3. Enums
4. Model configurations (`has_cents`)
5. `belongs_to`
6. `has_one`
7. `has_many`
8. Attachments (`has_one_attached`, `has_many_attached`)
9. Scopes
10. Validations
11. Callbacks
12. Delegations
13. Misc macros (`has_rich_text`, `has_secure_token`, `has_secure_password`)
14. Public methods, then `private`, then private methods

Example:

```ruby
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.

```ruby
class Product < ResourceRecord
  has_cents :price_cents                    # virtual :price (default rate 100)
  has_cents :cost_cents, name: :wholesale   # custom accessor name
  has_cents :tax_cents, rate: 1000          # 3 decimal places
end

product.price = 19.99
product.price_cents  # => 1999
product.price        # => 19.99

# Truncates, doesn't round
product.price = 10.999
product.price_cents  # => 1099
```

**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:

```ruby
# Policy
permitted_attributes_for_create { %i[name price] }   # NOT :price_cents

# Definition
field :price, as: :decimal
```

Validation on the cents column propagates a generic error to the virtual:

```ruby
validates :price_cents, numericality: {greater_than: 0}
# product.errors[:price]       => ["is invalid"]
# product.errors[:price_cents] => ["must be greater than 0"]
```

## SGID on Associations

Every association gets Signed Global ID methods for secure serialization (form params, API payloads, hidden fields).

```ruby
class Post < ResourceRecord
  belongs_to :user
  has_many :tags
end

post.user_sgid           # singular: get
post.user_sgid = "..."   # singular: set

post.tag_sgids                # collection: get array
post.tag_sgids = [...]        # collection: bulk replace
post.add_tag_sgid("...")      # collection: append
post.remove_tag_sgid("...")   # collection: remove
```

## 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.

```ruby
# Default: numeric id
user.to_param  # => "1"

# Stable, unique field
class User < ResourceRecord
  path_parameter :username
end
# /users/john_doe

# SEO-friendly: id + slug
class Article < ResourceRecord
  dynamic_path_parameter :title
end
# /articles/1-my-great-article

Article.from_path_param("1-my-great-article")  # extracts id, finds by id
User.from_path_param("john_doe")               # finds by username
```

## Labeling

```ruby
# Auto: tries :name, then :title, then "User #1"
user.to_label

# Override
class Product < ResourceRecord
  def to_label = "#{name} (#{sku})"
end
```

## Field Introspection

```ruby
User.resource_field_names                   # all fields
User.content_column_field_names             # DB columns
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

```ruby
# app/definitions/resource_definition.rb (base, created at install)
class ResourceDefinition < Plutonium::Resource::Definition
  action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
end

# app/definitions/post_definition.rb (scaffold)
class PostDefinition < ResourceDefinition
  scope :published
  input :content, as: :markdown
end

# Portal override (per-portal customization)
class AdminPortal::PostDefinition < ::PostDefinition
  scope :pending_review
  input :internal_notes, hint: "Not shown to the author"
end
```

## Core Methods

| Method | Applies To | Use When |
|--------|-----------|----------|
| `field` | Forms + Show + Table | Universal type override |
| `input` | Forms only | Form-specific options |
| `display` | Show page, and the table unless a `column` renders | Display-specific options |
| `column` | Table only | Table-specific options |

```ruby
class PostDefinition < ResourceDefinition
  field :content, as: :markdown                 # everywhere
  input :title, hint: "Be descriptive"
  display :content, wrapper: {class: "col-span-full"}
  column :view_count, align: :end
end
```

⚠️ **A component option (`colors:`, `unit:`, `true_label:`, `formatter:`, …) must be declared on the surface that renders it (`input`, `display`, or `column`), not on `field`.** A component reads its attributes from the surface declaration. Options on `field` go to the Phlexi field builder instead, which consumes the field-level keys (`:label`, `:description`/`:hint`, `:placeholder`) and ignores the rest. So `field :status, as: :badge, colors: {...}` renders a badge everywhere but silently ignores `colors:` and falls back to auto-coloring. `field` is still the right place for the shared `as:` type and for label/description/placeholder.

Where to put a component option:

- **`display` covers show AND index.** With no `column` declared, the table column inherits the display's `as:` and attributes, so `display :status, as: :badge, colors: {...}` alone colors the badge on both the show page and the table.
- **A `column` that renders, renders alone.** A `column` with an `as:`, a component attribute, or a block replaces the display for the table: its own `as:` and attributes only, even when the type matches. A `column` that only sets `align:`, `label:` or `condition:` keeps the display's rendering, so `column :status, align: :center` under `display :status, as: :badge, colors: {...}` is still a colored badge.
- **`align:` and `label:` can live on `field`.** They configure the column header, not the cell, so `field :amount, align: :end` works and never leaks into a component.
- Use `input` for the form.

The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) are always stripped before a component sees them, so they never leak as HTML attributes wherever you put them. Other unknown keys are not stripped: a stray option on a surface whose component does not consume it still renders as a raw HTML attribute.

## 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:` |

```ruby
display :status, as: :badge, colors: {archived: :neutral, vip: :accent}  # override per-value color
display :price,  as: :currency, unit: "£"                                # literal symbol
display :price,  as: :currency, unit: :currency_symbol                   # Symbol → read off the record (per-row)
display :active, as: :boolean,  true_label: "Live", false_label: "Off"
```

Badge color keys: `:neutral`, `:primary`, `:secondary`, `:success`, `:danger`, `:warning`, `:info`, `:accent`.

## Field Options

```ruby
input :title,
  label: "Custom Label",
  hint: "Help text",
  placeholder: "Enter value",
  description: "For displays",   # appears on show page

  # tag-level HTML
  class: "custom-class",
  data: {controller: "custom"},
  required: true,
  readonly: true,
  disabled: true,

  # wrapper
  wrapper: {class: "col-span-full"}
```

## Select / Choices

```ruby
# Static
input :category, as: :select, choices: %w[Tech Business Lifestyle]
input :status, as: :select, choices: Post.statuses.keys

# Dynamic — must use a block
input :author do |f|
  f.select_tag choices: User.active.pluck(:name, :id)
end

# With context (current_user, object, params, request available in block)
input :team_members do |f|
  f.select_tag choices: current_user.organization.users.pluck(:name, :id)
end
```

⚠️ **Don't bind a form field to a bare association name for a multi-select** (say `input :tags` / permit `:tags`). This is by design: param extraction excludes association writers (`submitted_resource_params` keeps a key only when `reflect_on_association(k).nil?`) because `tags=` expects model instances, not param strings, so a bare `tags` param is dropped rather than mis-assigned. Bind to a non-association writer instead:

- `input :tag_ids` uses Rails' collection-ids setter. Permit `:tag_ids`; values are plain ids. Simplest for a scoped checkbox list.
- `input :tags, as: :secure_association` is the native picker. It sets input_param `tag_sgids`, scopes via `choices:` (or the target policy), and validates submitted SGIDs by decoded model-id.

⚠️ Do **not** hand-roll `collection_checkboxes_tag(choices: [[label, record.to_sgid.to_s]])`. SGIDs are non-deterministic (`to_sgid` embeds a timestamp), the component re-derives `choices` at submit and intersects the posted value against the freshly-minted SGIDs, so it never matches and the value is dropped. Use stable ids in custom checkboxes, or `secure_association` (which validates by model-id).

## Conditional Rendering

```ruby
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.

A surface's own `condition:` wins; otherwise the surface falls back to the `field` condition. So a `field` condition runs on the form, the show page AND the table. **The table has no `object`**, so a `field` condition must not reference the record: `-> { object.published? }` works on the form and show page and raises on the index. Keep a `field` condition to context that exists everywhere (`Rails.env`, a feature flag, `current_user`) and put record checks on `input`/`display`/`column`.

## Options That Vary Per Render

Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:

```ruby
input :tier,  as: :select, choices: ->(form) { form.object.account.available_tiers }
input :notes, placeholder: -> { "Updated #{Time.current.year}" }
```

- `-> { … }` is called as-is, keeping its own binding — it means what it reads like where you wrote it; nothing rebinds `self`. That is what makes `choices: -> { reviewer_choices }` work inside an interaction's `customize_inputs` (private helpers included).
- `->(form) { … }` gets the form — `object` (the record), `params`, view helpers.

Same rule on wizard steps — but a step block closes over an internal field recorder, so options there must take the form: `->(form) { form.wizard.anchor.tiers }`. See [[plutonium-wizard]].

**`condition:` is not an option — it follows a different rule, for a reason.** An option asks "what value should this have?", so it may not care about the render and defaults to meaning what it reads like. `condition:` asks "should this render *here, now*?" — a question about the render context by definition. So it always runs **against** that context and reads it with no argument, where "context" is whatever is rendering:

```ruby
input   :notes,     condition: -> { object.published? }        # the form
display :audit_log, condition: -> { current_user.admin? }      # the display component
step    :billing,   condition: -> { data.plan.tier == "pro" }  # the wizard — no form exists yet
```

It cannot take a `form` argument the way an option does: for a `column`/`display`, a step, or an action there is no form.

## Internationalization (labels, placeholders, hints)

**Never hard-code display text a locale file can supply.** Labels come from `activerecord.attributes.<model>.<attr>` already. Placeholders, hints and descriptions are looked up by convention when the definition leaves them blank, so the translatable default is to declare nothing:

```yaml
en:
  plutonium:
    fields:
      blogging/post:
        title: { placeholder: "A short, descriptive title", hint: "Shown in search" }
        body:  { description: "Rendered as Markdown" }
    actions:        { blogging/post: { publish: "Publish now" } }
    scopes:         { blogging/post: { drafts: "Drafts" } }
    filters:        { blogging/post: { author: "Written by" } }
    kanban_columns: { blogging/post: { in_review: "In review" } }
    wizard_steps:   { onboarding_wizard: { billing: "Billing details" } }
    values:         { blogging/post: { status: { archived: "Archived" } } }   # or activerecord.attributes.<model>.status/archived
```

Resolution for every slot: explicit option → `plutonium.portals.<portal>.…` → `plutonium.…` → (placeholder only) `helpers.placeholder.<model>.<attr>` → nothing. For actions, an interaction's explicit `presents label:` counts as declared (beats the convention); its class-name default does not. The model segment is `model_name.i18n_key` (`blogging/post`), walked up STI ancestors.

When an explicit value must be translated, use the definition's class-level `t`, which is lazy (resolved per render, in the request locale):

```ruby
input  :email,   placeholder: t("forms.shared.email_placeholder")
action :publish, label: t("plutonium.actions.blogging/post.publish")
```

🚨 Never `I18n.t(...)` in a class body: it freezes the string in whatever locale was active at load. Use `t(...)` or a `-> { I18n.t(...) }` proc.

Full reference: `docs/reference/i18n.md`.

## 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.

```ruby
class QuestionDefinition < ResourceDefinition
  # :select + choices is a real override (model column is just a string)
  input :question_type, as: :select,
    choices: %w[text choice scale],
    pre_submit: true

  # No `as:` — types are auto-detected from the model. We only declare to add `condition:`.
  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:

```ruby
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:**

```ruby
display :status do |field|
  StatusBadgeComponent.new(value: field.value, class: field.dom.css_class)
end
```

**Input block — must use form builder methods:**

```ruby
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
```

**Custom display: use the block form.** `display :x do |f| … end` is `instance_exec`ed in the display component's Phlex context (`render instance_exec(f, &block)`), so you emit markup directly, it runs **once** (no per-item loop), and `f.object` is the real record:

```ruby
# Inline markup. span/div are Phlex tag methods here.
display :priority do |f|
  variant = {"high" => "danger", "medium" => "warning"}.fetch(f.value.to_s, "info")
  span(class: "pu-badge pu-badge-#{variant}") { f.value.to_s.humanize }
end

# Reaches the record, renders once even for a has_many
display :tags do |f|
  div(class: "flex flex-wrap gap-1.5") do
    f.object.tags.each { |t| span(class: "pu-badge") { t.name } }
  end
end

# Reusable markup: hand the block a component
display :card do |f|
  PostCardComponent.new(post: f.object)
end
```

> ⚠️ **`as: :phlexi_render, with:` is deprecated. Use the block form above.** It emits a deprecation warning and will be removed. It was strictly worse than the block form: the `with:` proc is `.call`ed with `self` set to the definition (so `span`/`div` in its body raise, and it must instead *return* a component or proc), its `value` arrives stringified (`DisplaysValue#normalize_value` runs `to_s`, so it never reaches the record), and for a `has_many`/`multiple?` field it fires once per element. The deprecation covers only the `as: :phlexi_render` display option. The internal `phlexi_render` helper is unaffected.

See [[plutonium-ui]] for writing custom Phlex components.

**Custom component classes** (Phlex components — see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)` — subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:

```ruby
input :color_picker, as: ColorPickerComponent
display :chart, as: ChartComponent
```

🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate — it raises `ArgumentError`. Build it in a block instead:

```ruby
display :card do |field|
  PostCardComponent.new(post: field.object)
end
```

## Column Options

```ruby
column :title, align: :start     # :start (default), :center, :end
column :amount, align: :end

# formatter — receives just the value
column :price, formatter: ->(v) { "$%.2f" % v if v }

# block — receives the full record
column :full_name do |record|
  "#{record.first_name} #{record.last_name}"
end
```

**A `column` block runs in a Phlex context** (like a `display` block), so it can emit `span`/`div` directly, or return a String or a component. The block receives the record.

```ruby
column :status do |record|             # emit markup directly
  span(class: "pu-badge") { record.status.humanize }
end

column :full_name do |record|          # or return a String, rendered as text
  "#{record.first_name} #{record.last_name}"
end

column :card do |record|               # or return a component
  StatusBadgeComponent.new(value: record.status)
end

column :status, as: :badge, colors: {...}   # or just declare the type, no block
```

Inside the block `self` is the table page (the same as a `display` block, where `self` is the show page): `current_user`, `helpers` and `resource_definition` work, methods defined on the definition class do not, so reach through the record you are passed. A block that emits markup must end with a tag call (or `nil`), because Phlex renders the return value too; a number or other scalar return renders as text. Most of the time you need no column block at all, because `display :x, as: …` already flows to the table column (type and attributes). Declare it once on `display` and reach for `column` only to override per-table.

## Nested Inputs

Inline forms for associated records. Requires `accepts_nested_attributes_for` on the model.

```ruby
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).

```ruby
class Spec < ResourceRecord
  # t.json :payload   /   t.json :rows  (jsonb in production)
end

class SpecDefinition < ResourceDefinition
  structured_input :payload do |f|       # single → { title:, notes: }
    f.input :title
    f.input :notes
  end

  structured_input :rows, repeat: 5 do |f|  # repeater → [ { key:, value: }, ... ] (max 5)
    f.input :key
    f.input :value
  end
end

class SpecPolicy < ResourcePolicy
  # NOTE: unlike nested_input, you DO permit the column name here.
  # (update inherits permitted_attributes_for_create automatically.)
  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

```ruby
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:

```ruby
def customize_fields    # add/modify fields
def customize_inputs    # add/modify inputs
def customize_displays  # add/modify displays
def customize_filters
def customize_actions
```

## Form & Page Configuration

```ruby
class PostDefinition < ResourceDefinition
  # "Save and add another" / "Update and continue editing"
  # nil (default) = auto (hidden for singular, shown for plural)
  submit_and_continue false

  # How :new / :edit + interactive actions render
  #   :slideover (default), :centered, or false (full pages)
  #   size: :sm / :md (default) / :lg / :xl / :auto / :full
  modal :centered, size: :lg

  # Titles
  index_page_title "All Posts"
  show_page_title -> { "#{current_record!.title} - Details" }

  # Breadcrumbs
  breadcrumbs true
  show_page_breadcrumbs false

  # Custom page classes — inherit from the parent's nested class
  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`.

```ruby
class PostDefinition < ResourceDefinition
  form_layout do
    section :identity, :name, :email, label: "Identity", description: "Who this is"
    section :address, :street, :city,
      collapsible: true, collapsed: ->(form) { form.object.persisted? }, columns: 2,
      condition: -> { object.requires_address? }   # hide the whole section as a unit
    ungrouped label: "Other"                        # bucket for unlisted fields; position = where it renders
  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 under the same arity rule as any other option — take a `form` argument to read the render context.
- ⚠️ **Breaking in 0.63**: section options used to take a zero-arg proc run *against* the form. They now follow the shared rule, and a `form_layout` block is evaluated against the layout builder, so a bare `object` is a `NameError`. Migrate `collapsed: -> { object.persisted? }` → `collapsed: ->(form) { form.object.persisted? }`. `condition:` is unchanged (still form-evaluated, still reads `object` with no argument).
- **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](/reference/resource/definition#form-layout).

## Display Layout (`display_layout`)

The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field sections dropped) — applied to the show page's fields instead of the form's.

```ruby
class PostDefinition < ResourceDefinition
  display_layout do
    section :profile, :name, :author, label: "Profile", description: "Identity and owner"
    section :presentation, :cover, :body, collapsible: true
    ungrouped label: "Other details"
  end
end
```

- **Declare both independently.** `form_layout` and `display_layout` are separate registries — a resource can group its form one way and its show page another, or declare only one. Neither inherits from the other.
- **🚨 No `columns:`** — unlike `form_layout`, it **raises**. Every display section shares one responsive grid; field width is a per-field concern: `display :x, wrapper: {class: "col-span-2"}` (works identically inside a section and outside one). Raising rather than ignoring means a copied `form_layout` block fails loudly instead of silently doing nothing.
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:` — the same set as `form_layout` minus `columns:`. `collapsible: true, collapsed: true` works exactly as it does on forms. Every option except `condition:` may be a **proc**, resolved at render under the same arity rule as the form: take a `display` argument to read `object`.
- **Each section renders as its own card**, so the sectioned show page has no single outer card. Fields declared in `metadata` are excluded (they render in the metadata panel) — see below.

## Page Width (`page_width`)

Detail-style pages — the show page and resource forms — are width-constrained by default. Inputs and values stretch to their container, so at full content width you get ~1200px-long lines. Index/table pages are NOT affected.

```ruby
Plutonium.configure { |c| c.default_page_width = :md }   # global default (:md)

class PostDefinition < ResourceDefinition
  page_width    :lg      # form AND show page
  display_width :full    # ...but the show page opts out
  form_width    :sm      # ...and the form goes narrow
end
```

- **Sizes**: `:sm` `:md` `:lg` `:xl` `:full`. `:full` means no constraint. An unknown value **raises** at declaration.
- **🚨 Tokens are relative to their surface** — the same *names* modals use, but NOT the same widths. Page `:md` is 896px; a centered modal's `:md` is 576px and a slideover's is 480px. A "small page" is deliberately larger than a "small dialog". Modals also have `:auto`; pages don't (nothing to hug).
- **Resolution**: surface-specific (`form_width` / `display_width`) → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is honoured, not treated as unset.
- **Inherits** to subclasses, so a portal-specific definition keeps the parent's width unless it overrides.
- **Modals are unaffected** — the dialog sets its own width (`modal_size`).
- **Interactions support it too** (`Plutonium::Interaction::Base`), for interactive actions rendered as standalone pages.
- **Wizards are on their own axis** — `Plutonium.configuration.wizards.width` (default `:md`), overridden per wizard with `width`. It does NOT follow `default_page_width`, so changing resource page width leaves wizards untouched.

## Metadata Panel (show page)

Declares fields rendered in the show page's right-side aside as label/value rows.

```ruby
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 (and from any `display_layout` section).
- **Responsive** — side-by-side at `lg+`, stacked below.
- **In a modal it stacks below the details**, not beside them: the rail is a fixed-width column on a *viewport* breakpoint, so in a dialog it would split regardless of how narrow the dialog is and crush the main column.
- **A kanban card's modal drops metadata entirely** — the fields are hidden, not folded into the main card.

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.

```ruby
class UserDefinition < ResourceDefinition
  # No `index_views :table, :grid` needed — `grid_fields` auto-enables :grid alongside the default :table.
  grid_fields(
    image:     :avatar,           # ActiveStorage, Shrine, or URL
    header:    :name,             # falls back to to_label
    subheader: :email,
    body:      :bio,
    meta:      [:role, :status],  # rendered as small pills
    footer:    :last_seen_at      # falls back to :created_at; `false` to omit
  )

  default_index_view :grid        # optional — initial view when no cookie
  grid_layout :media              # :compact (default) or :media
  grid_columns 3                  # pin lg+ cols; default is 1/2/3/4 responsive
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.

---

## Drag-to-Reorder (`positioned_on` + `position_on`)

Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three** — the model says how positions are stored, the definition (and a kanban board) says the UI is orderable:

```ruby
# Migration — t.position emits decimal(16,8), tuned for fractional ordering.
create_table :tasks do |t|
  t.string :status, null: false, default: "todo"
  t.position
  t.index [:status, :position]     # match the scope attribute
end

# Model — storage
class Task < ApplicationRecord
  include Plutonium::Positioning::Model    # NOT Plutonium::Positioning
  positioned_on :position, scope: :status  # scope: nil = one global ordering
end

# Definition — "this UI can be reordered". Never restates the column or the scope.
class TaskDefinition < ResourceDefinition
  position_on
end

# Existing rows need positions:
Task.backfill_positions!(order: :created_at)
```

- **`include Plutonium::Positioning::Model`** — the concern used to be `Plutonium::Positioning` itself. A bare `include Plutonium::Positioning` is now wrong (it's a pure namespace). Constants nested in an included concern join the model's constant lookup, so the old form let `Plutonium::Positioning::Config` shadow an app's own `::Config`.
- **`positioned_on` is required**, not just the include — without it there's no `before_create`, so every row is created with a `NULL` position. `position_on` raises at class-load if you forget.

### `position_on` forms and modes

| Form | Mode | Notes |
|---|---|---|
| `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this** — it cannot disagree with the model. |
| `position_on :sort_order` | A | Must **match** the model's column, else `ArgumentError` at class-load |
| `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch — another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
| `position_on false` | C (disabled) | No ordering, no route — the endpoint 404s |

**Default to Mode A.** It is one word in the definition. A *correct* Mode B block is ~15 lines of rank arithmetic, and getting it right requires knowing three non-obvious things: `move.index` is page-relative; removing a record shifts its neighbours' ranks by one, in a direction that depends on where it started; and a blank `move.prev` means "nothing above me *on screen*", not "top of the list". These docs got two of the three wrong until they were tested. Mode B is legitimate and tested — it just costs you semantics Mode A handles.

### Mode B — what the framework stops doing

Mode B block receives a `Plutonium::Positioning::Move`: `record`, `prev`, `next`, `index` (0-based, **relative to the visible page**), `column` (kanban only, `nil` on tables/grids). Called with `call`, not `instance_exec`.

Because the write is opaque, three Mode A behaviours are **not** provided:

- **No hidden boundary resolution** — `resolve_position_boundaries` returns early unless the config delegates, so the block gets the client's viewport verbatim, `nil`s and all.
- **No server-side foreign-sort rejection** — Mode A rejects a drop under a foreign sort with 422 before writing; Mode B relies on the client-side gate only.
- **Always a full repaint** — never 204. Gems like `acts_as_list` renumber the whole group on every move, so the client's optimistic DOM is stale by definition.

### Migrating off `acts_as_list` to Mode A

1. **Change the column** — `acts_as_list` uses contiguous integers; Plutonium uses fractional decimals (`t.position` emits `decimal(16,8)`; an integer column would round every midpoint onto a neighbour). `t.position` *adds* a column, so an existing one needs `change_column :tasks, :position, :decimal, precision: 16, scale: 8`.
2. **Swap the macro** — drop `acts_as_list scope: [:status]`, add `include Plutonium::Positioning::Model` + `positioned_on :position, scope: :status` (bare Symbol; the Array trap is gem-specific).
3. **Backfill** — `Task.backfill_positions!(order: :position)` numbers each scope group `1.0, 2.0, …` in the gem's existing order. `update_column`, no callbacks/validations/`updated_at` — run it once from a migration or `rails runner`.
4. Drop the block from the definition; a bare `position_on` is the whole of Mode A.

### Staying on `acts_as_list` (the harder road)

For when the gem is not yours to remove. This recipe is correct and tested against the real gem (`test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb`).

🚨 **Anchor off `move.prev` / `move.next`, never off `move.index`.** `move.index` counts the visible page; a positioning gem's `insert_at` addresses the whole group. `insert_at(move.index + 1)` is wrong on any list past 20 rows (Plutonium's default page size) — measured: dragging rank 25 into the middle of page 2 lands it at **rank 2**, and a page-2 top drop lands at **rank 1**. Same failures on a filtered list.

```ruby
# Keeping acts_as_list. NOTE scope: [:status] — a bare Symbol scope is run
# through acts_as_list's `idify`, which turns :status into :status_id and makes
# every create raise NoMethodError.
class Task < ApplicationRecord
  acts_as_list scope: [:status]
end

class TaskDefinition < ResourceDefinition
  position_on :position do |move|
    record = move.record

    target =
      if move.prev
        # Removing the record shifts prev up one when the record was above it.
        (record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
      elsif move.next
        # Blank prev means "nothing above me ON MY SCREEN" — rows may still sit
        # above off-page or behind a filter, so anchor off next rather than 1.
        (record.position < move.next.position) ? move.next.position - 1 : move.next.position
      else
        1 # the only row in the list
      end

    record.insert_at(target)
  end
end
```

`insert_at` calls `save`, not `save!` — a failed move silently no-ops. Use `insert_at!` to surface it as a 422.

### 🚨 `position_on` expands to three things

```ruby
sort <attr>                      # load-bearing: the only way back to position order
default_sort <attr>, :asc        # ⚠ ONLY when default_sort is still the framework default
action :reposition, hidden: true # route + policy predicate, no button
```

**The `default_sort` claim is implicit.** A resource that listed newest-first will list in position order after you add `position_on`. Declare your own `default_sort` (above OR below `position_on` — resolution is order-independent) to keep it; note the list then opens **not** draggable.

### Behavior notes

- **Dragging is offered only while the collection is sorted ascending by the position attribute** (and nothing else). Under any other sort the grip renders as a **link that applies that sort**, and the server rejects the drop with 422 before writing.
- **`reposition?` policy predicate**, defaulting to `update?`. Gates both the drop and whether the grip renders per row. `index?` is also required (you must be able to see a list to reorder it).
- **A kanban board inherits the definition's `position_on`** (lazily, so order in the class body doesn't matter); a `position_on` inside `kanban do…end` overrides it.
- **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent — but a rebalance renumbers every row in the table, not just that parent's.
- Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.

Full reference: `docs/reference/resource/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.

---

# Part 4 — Query: Search, Filters, Scopes, Sorting

```ruby
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

```ruby
# Multi-field with associations
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`

```ruby
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:**

```ruby
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.

```ruby
scope :published                                  # uses Post.published
scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
scope(:mine)   { |s| s.where(author: current_user) }

default_scope :published   # applied on initial load; "All" button clears it
```

### 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.

```ruby
scope :admin_only,   condition: -> { current_user.admin? }
scope :beta_feature, condition: -> { params[:beta] == "1" }
scope :never_shown,  condition: -> { false }  # hides button but URL still works
```

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

```ruby
sort :title
sort :created_at
sorts :title, :created_at, :view_count   # multiple at once

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 |
| `hidden: true` | **Nowhere** | Suppresses all four; route + policy stay live (drag gestures, custom JS) |

🚨 **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:

```ruby
# :resource interaction defaults to record_action + collection_record_action.
# Hide from the per-row menu, keep it on the show page:
action :archive, interaction: ArchiveInteraction, collection_record_action: false

# Hide from the show page, keep the per-row button:
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

```ruby
action :name,
  # Display
  label: "Custom Label",
  description: "What it does",
  icon: Phlex::TablerIcons::Star,
  color: :danger,                   # :primary, :secondary, :danger

  # Visibility (combine as needed)
  resource_action: true,
  record_action: true,
  collection_record_action: true,
  bulk_action: true,

  # Conditional visibility — display-only toggle, NOT authorization (see below).
  # `-> { false }` keeps the route live but hides the button (e.g. API-only).
  condition: -> { params[:beta] == "1" },

  # Never renders anywhere — route + policy stay live. For endpoints reached by
  # a gesture rather than a button (see Hidden Actions below). NOT authorization.
  hidden: true,

  # Grouping
  category: :primary,               # :primary, :secondary, :danger
  position: 50,

  # Behavior
  confirmation: "Are you sure?",
  turbo_frame: "_top",
  route_options: {action: :foo},
  modal: :slideover,                # :slideover / :centered — overrides definition's modal mode
  size:  :lg,                       # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size

  # HTML attributes — deep-merged over the framework's, author wins on every key
  link:   {target: "_blank", rel: "noopener"},  # every <a> rendering: toolbar GET link, dropdown items (any method), bulk links, card show link
  button: {data: {analytics: "x"}}              # the button_to <form> wrapper (non-GET toolbar rendering), NOT the inner <button>
```

### 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`.

```ruby
action :documentation,
  route_options: {url: "https://docs.example.com"},
  resource_action: true,
  link: {target: "_blank", rel: "noopener noreferrer"}   # open in a new tab
```

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:

```ruby
# Defined and callable (API / programmatic), but no button anywhere:
action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }

# Per-record display state — object is the row/shown record:
action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }

# View/request-level toggle (feature flag, beta mode):
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:

```ruby
# 🚫 WRONG — does not stop non-admins; the route is live.
action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
# ✅ RIGHT — authorization in the policy, enforced regardless of condition:
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*.

### Hidden Actions (`hidden: true`)

```ruby
action :reposition, hidden: true
```

Renders in **no** toolbar, row dropdown, card, or bulk bar — regardless of visibility flags, policy, or `condition:`. Everything else stays live: the route, the policy predicate (`def reposition?`), and (for `interaction:` actions) the form + permitted-params machinery.

Use it for an endpoint reached by **something other than a button** — a drag gesture, a custom Stimulus controller. The framework uses it for exactly that: `position_on` expands to `action :reposition, hidden: true`, and the kanban drop endpoint is declared the same way.

| | `hidden: true` | `condition: -> { false }` |
|---|---|---|
| Decided | class-load, once | render time, per row/request |
| Says | "never a button" | "a button, just not right now" |

🚨 **`hidden:` is a display gate, NOT an authorization boundary** — same trap as `condition:`. The route is live; authorization belongs in the policy.

`Action#with(...)` — actions are frozen value objects; clone with overrides:

```ruby
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.

```ruby
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:

```ruby
resources :posts do
  collection do
    get :reports, as: :reports
  end
end
```

For anything with business logic, use an **Interactive Action** instead.

## Interactive Actions (Interactions)

```ruby
class PostDefinition < ResourceDefinition
  action :publish, interaction: PublishInteraction
  action :archive, interaction: ArchiveInteraction,
    color: :danger, category: :danger, position: 1000,
    confirmation: "Are you sure?"
end
```

⚠️ **An interaction is the button, not the operation.** It's a presentation object — it can only be built with a `view_context`, so anything reachable only through one is reachable only from a Plutonium page. Logic may *start* in `execute` (a one-off with a single caller is fine; don't pre-extract). The **second caller** — a job, an API controller, a rake task, the console — is the trigger to move it onto the **model**, in domain language (`publish!`, `archive!`, `register!`). Not a service layer. Full rule + the validation split: [[plutonium-behavior]] › Part 3 › Where the logic goes.

### Single-record interaction

```ruby
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)

```ruby
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
    # Company#invite! creates the row AND sends the mail — a seat-provisioning
    # job needs both, and has no view_context to build an interaction with.
    resource.invite!(email: email, role: role, by: current_user)
    succeed(resource).with_message("Invitation sent to #{email}.")
  rescue ActiveRecord::RecordInvalid => e
    failed(e.record.errors)
  end
end
```

### Bulk action

```ruby
class BulkArchiveInteraction < Plutonium::Resource::Interaction
  presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive

  attribute :resources   # plural -> bulk

  def execute
    resources.each(&:archived!)
    succeed(resources).with_message("#{resources.size} records archived.")
  rescue => error
    failed("Bulk archive failed: #{error.message}")
  end
end

# Definition
action :bulk_archive, interaction: BulkArchiveInteraction
# bulk_action: true inferred from `attribute :resources`

# Policy — checked per record; fails the request if ANY record is unauthorized
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`.

**Bulk over more than a screenful runs in the request and will time out.** Swap `execute` for `async` and the same interaction dispatches a background run instead, with its declaration and policy unchanged:

```ruby
async do
  on_failure :continue   # :halt (default) | :continue | :transactional
  def perform_on(record) = record.archived!
end
```

The block is the run's class body, not `execute`: it runs later in a job with no controller, so its inputs arrive through `options`. Load [[plutonium-async-interactions]] before building one.

### Resource action (no record)

```ruby
class ImportInteraction < Plutonium::Resource::Interaction
  presents label: "Import CSV", icon: Phlex::TablerIcons::Upload

  # No :resource or :resources -> resource action
  attribute :file
  input :file, as: :file
  validates :file, presence: true

  def execute
    succeed(nil).with_message("Import completed.")
  end
end
```

## Interaction Responses

```ruby
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

```ruby
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`:

```ruby
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:

```ruby
class PostPolicy < ResourcePolicy
  def export_csv? = true          # or `index?` to mirror list access
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:

```ruby
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`):

```ruby
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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.