Skip to content

Definition

Definitions configure how a resource is rendered and interacted with — which fields appear, how they render, what page chrome looks like. Auto-detection from the model handles the defaults; declare only what you're overriding.

For search/filters/scopes/sorting see Query. For custom actions see Actions.

🚨 Critical

  • Don't declare for completeness. A field :title matching what Plutonium auto-detects is dead code. Declare ONLY when you need a different type, an option (hint:, placeholder:, wrapper:, class:), a condition:, a block, or a custom component.
  • Use condition: for UI state, the policy for authorization. condition: -> { object.published? } is fine. "Only admins see this field" belongs in permitted_attributes_for_*.
  • Custom action ⇒ policy method. action :publish needs def publish? on the policy (see Behavior › Policy).
  • has_cents fields use the virtual name (field :price), never :price_cents.
  • Nested inputs need accepts_nested_attributes_for AND inverse_of: on the child's belongs_to — without inverse_of:, validation fails with "Parent must exist" because the parent isn't saved yet.

File location

app/definitions/post_definition.rb
packages/blogging/app/definitions/blogging/post_definition.rb
ModelDefinition
PostPostDefinition
Blogging::PostBlogging::PostDefinition

Hierarchy

Definitions inherit from each other so portals can override:

ruby
# app/definitions/resource_definition.rb (installed once)
class ResourceDefinition < Plutonium::Resource::Definition
  action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
end

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

# packages/admin_portal/app/definitions/admin_portal/post_definition.rb (per-portal)
class AdminPortal::PostDefinition < ::PostDefinition
  input :internal_notes, as: :text     # admins see this; customers don't
  scope :pending_review
end

Auto-detection

Empty definition = everything auto-detected from the model:

ruby
class PostDefinition < Plutonium::Resource::Definition
end

Plutonium detects, from the model:

  • Database columns (string, text, integer, boolean, datetime, etc.)
  • Associations (belongs_to, has_many, has_one)
  • ActiveStorage attachments (has_one_attached, has_many_attached)
  • Enums
  • Virtual attributes (when they have accessor methods)
Database typeDetected as
string, text:string / :text
integer, bigint:integer
float, decimal:float / :decimal
boolean:boolean
date, datetime, time:date / :datetime / :time
json, jsonb:json

Validations on the model inform the UI too: validates :title, presence: true → required field; validates :role, inclusion: { in: [...] } → select choices.

Core methods

MethodApplies toUse when
fieldForms + Show + TableUniversal type override
inputForms onlyForm-specific options
displayShow page onlyDisplay-specific options
columnTable onlyTable-specific options
ruby
class PostDefinition < Plutonium::Resource::Definition
  field :content, as: :markdown                # everywhere
  input :title, hint: "Be descriptive"
  display :content, wrapper: {class: "col-span-full"}
  column :view_count, align: :end
end

Available field types

Input types (forms)

CategoryTypes
Text:string, :text, :email, :url, :tel, :password
Rich text:markdown (EasyMDE editor)
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, :date, :time, :datetime, :association, :attachment, :color

Auto-inferred display formatting

These render automatically — declare an as: only to override or pass options:

ColumnRenders asNotes
booleanYes/No pill (:boolean)green "Yes" / neutral "No"; override with true_label: / false_label:
enumstatus badge (:badge)known statuses auto-colored; unknown values get a stable decorative color; override per-value with colors:
has_cents decimalcurrency (:currency)delimited, 2 decimals, no symbol unless you pass unit: (a literal "£" or a Symbol read off the record)
ruby
display :status, as: :badge, colors: {archived: :neutral, vip: :accent}
display :price,  as: :currency, unit: "£"
display :active, as: :boolean, true_label: "Live", false_label: "Off"

Field options

ruby
input :title,
  # Wrapper-level (label, hint, placeholder, description)
  label: "Custom Label",
  hint: "Help text",
  placeholder: "Enter value",
  description: "Shown on the show page",

  # Tag-level (HTML attributes)
  class: "custom-class",
  data: {controller: "custom"},
  required: true,
  readonly: true,
  disabled: true,

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

Select / choices

Static

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

Dynamic (block required)

ruby
input :author do |f|
  f.select_tag choices: User.active.pluck(:name, :id)
end

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

# Based on object state
input :related_posts do |f|
  choices = object.persisted? ?
    Post.where.not(id: object.id).published.pluck(:title, :id) : []
  f.select_tag choices: choices
end

Conditional rendering

ruby
display :published_at,     condition: -> { object.published? }
display :rejection_reason, condition: -> { object.rejected? }
field   :debug_info,       condition: -> { Rails.env.development? }

UI state, not authorization

condition: is for UI logic ("show this when published"). For "who can see this", use the policy's permitted_attributes_for_* — see Behavior › Policy.

Dynamic forms (pre_submit)

A field with pre_submit: true triggers a server re-render on change, re-evaluating condition: procs. Use for cascading or context-dependent forms.

ruby
class QuestionDefinition < ResourceDefinition
  # Trigger field
  input :question_type, as: :select,
    choices: %w[text choice scale],
    pre_submit: true

  # Dependents — no `as:` needed when the model column type matches
  input :max_length, condition: -> { object.question_type == "text" }
  input :choices,    condition: -> { object.question_type == "choice" }
  input :min_value,  condition: -> { object.question_type == "scale" }
end

How it works:

  1. User changes a pre_submit: true field.
  2. Form submits via Turbo (no page reload).
  3. Server re-renders the form with updated object state.
  4. condition: procs are re-evaluated. Newly visible fields appear; newly hidden ones disappear.

Tips:

  • Only add pre_submit: to fields that gate visibility of others.
  • Avoid on frequently-changed fields (every keystroke = submit).

Custom rendering

Block syntax

Display (any return value, can be a component):

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

display :metrics do |field|
  field.value.present? ?
    MetricsChartComponent.new(data: field.value) :
    EmptyStateComponent.new(message: "No metrics")
end

Input (must call 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

phlexi_tag for declarative custom display

with: takes either a Phlex component class OR a proc whose body is rendered inside a Phlex context — HTML tag methods (span, div, a) and Tailwind classes are first-class. The proc receives (value, attrs).

ruby
# Component — preferred for anything reusable
display :status, as: :phlexi_tag, with: StatusBadgeComponent

# Inline proc — `span` here is a Phlex tag method, not a Rails helper
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 UI › Components for writing reusable Phlex components.

Custom component class

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

Column options

ruby
column :title,  align: :start    # default
column :status, align: :center
column :amount, align: :end

Value formatting

formatter: receives just the value. Use a block when you need the full record.

ruby
column :description, formatter: ->(value) { value&.truncate(30) }
column :price,       formatter: ->(value) { "$%.2f" % value if value }
column :status,      formatter: ->(value) { value&.humanize&.upcase }

# Block — full record access
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.

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

  # Or use another definition
  nested_input :metadata, using: PostMetadataDefinition, fields: %i[seo_title seo_description]
end

Options

OptionDescription
limitMax records (auto-detected from model; default 10)
allow_destroyShow delete checkbox (auto-detected)
update_onlyHide "Add" button — only edit existing
descriptionHelp text above the section
conditionProc to show/hide
usingAnother Definition class
fieldsSubset of fields from the referenced definition

Gotchas

  • inverse_of: is required on the child's belongs_to:
    ruby
    class Comment < ResourceRecord
      belongs_to :post, inverse_of: :comments   # ← without this, validation fails with "Parent must exist"
    end
  • Don't put *_attributes hashes in the policy. Plutonium extracts nested params from the form definition, not the policy. The policy permits just the association name (:variants); nested_input :variants handles the rest. Adding {variants_attributes: [...]} to permitted_attributes_for_create renders as a literal text input. See Behavior › Policy.
  • update_only: true hides the Add button — for has_one and "settings"-style associations.
  • Custom class names — use class_name: in the model AND using: in the definition.

Structured inputs

Classless inline fieldsets backed by a JSON/jsonb column. No model associations required — the whole sub-form is serialised into a single column as a hash (single form) or an array of hashes (repeater).

A single structured input (Payload) and a repeater (Rows)

ruby
# model
class Listing < ApplicationRecord
  include Plutonium::Resource::Record
  # columns: address (json), contacts (json)
end

# definition
class ListingDefinition < ResourceDefinition
  # single → stored as a hash
  structured_input :address do |f|
    f.input :street
    f.input :city
  end

  # repeater → stored as an array of hashes (max 5 rows)
  structured_input :contacts, repeat: 5 do |f|
    f.input :label
    f.input :phone_number
  end
end

Options

OptionDescription
repeat:true (default cap of 10) or an integer max-rows cap. Omit for a single-hash form.
using:Another Definition class whose input declarations are used as the fieldset.
fields:Subset of fields to take from the using: definition.

Removing rows

Each repeater row has a Remove button. Removing a row collapses it to a compact Removed — Restore bar and disables its inputs, so the browser omits them from the submission. The server simply rebuilds the JSON column from the rows it receives — there is no _destroy marker. Restore brings the row back before saving.

A removed row collapsed to a Restore bar

Policy

Permit the column name as a plain symbol — Plutonium handles the nested hash params automatically:

ruby
def permitted_attributes_for_create
  super + %i[address contacts]
end

On interactions

structured_input is also available on Plutonium::Interaction::Base. The attribute is declared automatically; execute receives the value as a Hash (single) or Array<Hash> (repeater). nested_input and accepts_nested_attributes_for are not available on interactions.

Validation

Structured inputs are not validated for you

The fields are classless render declarations, so there is nothing for Plutonium to attach validations to (unlike nested_input, whose nested records run their own model validations). Whatever the form submits is stored as-is, after blank rows are dropped — no per-field server-side validation.

Specifically:

  • HTML constraints are client-side only. A field's required: and a select's choices: guide the browser but are not enforced on the server — an API call or a crafted request can submit anything.
  • Selects silently drop unknown values. If a stored value is not among a as: :select field's choices:, the <select> renders blank, and saving the form overwrites the stored value with nil (the option list is the only thing constraining it). This is standard <select> behaviour, but it bites harder here because JSON values aren't constrained by a DB enum and your choices: can drift. Keep choices: a stable superset, or use a free-text input, when values can change over time.

To enforce anything, add the validation yourself — it runs server-side:

ruby
# resource: validate the JSON column on the model
class Listing < ApplicationRecord
  include Plutonium::Resource::Record

  validate :contacts_have_labels
  def contacts_have_labels
    Array(contacts).each_with_index do |row, i|
      errors.add(:contacts, "row #{i + 1} needs a label") if row["label"].blank?
    end
  end
end

# interaction: it's an ActiveModel, validated before `execute`
validate do
  contacts.each { |c| errors.add(:contacts, "label required") if c[:label].blank? }
end

File uploads

ruby
input :avatar, as: :file
input :avatar, as: :uppy

input :documents, as: :file, multiple: true
input :documents, as: :uppy,
  allowed_file_types: %w[.pdf .doc],
  max_file_size: 5.megabytes

Context in blocks

Inside condition: procs and block-form input/display:

  • object — the record being edited or displayed
  • current_user
  • current_parent — parent record for nested resources
  • request, params
  • All view helpers (via the same context as controllers)

Runtime customization hooks

Override these methods for dynamic per-request configuration:

ruby
class PostDefinition < ResourceDefinition
  def customize_fields    # add/modify fields
  def customize_inputs
  def customize_displays
  def customize_columns
  def customize_filters
  def customize_scopes
  def customize_sorts
  def customize_actions
end

Useful when configuration depends on current_user, the environment, or feature flags.

Page configuration

Titles and descriptions

ruby
class PostDefinition < ResourceDefinition
  index_page_title       "All Posts"
  index_page_description "Manage your blog posts"

  new_page_title         "Create Post"
  show_page_title        -> { current_record!.title }    # dynamic
  edit_page_title        -> { "Edit: #{current_record!.title}" }
end
ruby
breadcrumbs              true     # global default
index_page_breadcrumbs   false    # per-page override
show_page_breadcrumbs    true
new_page_breadcrumbs     true
edit_page_breadcrumbs    true
interactive_action_page_breadcrumbs true

Form configuration

ruby
class PostDefinition < ResourceDefinition
  # "Save and add another" / "Update and continue editing"
  #   nil   (default) — auto: hidden for singular resources, shown for plural
  #   true            — always show
  #   false           — always hide
  submit_and_continue false

  # How :new / :edit and interactive actions render
  #   :slideover   (default) — slide-in panel from the right
  #   :centered              — centered dialog
  #   false                  — full standalone pages (no modal)
  # size: optional, one of :sm, :md (default), :lg, :xl, :auto, :full
  modal :centered, size: :lg
end

modal: is the default for framework :new/:edit and every interactive action on this definition. Per-action modal: / size: overrides win — see Actions.

Metadata panel (show page)

A right-side aside on the show page rendering label/value rows. Keeps the main card focused on substance; chrome (timestamps, ownership, system flags) lives in the aside.

ruby
class PostDefinition < ResourceDefinition
  metadata :author, :state, :created_at, :updated_at
end

Behavior:

  • Opt-in. No metadata call → show page renders full-width.
  • Policy-aware. Fields intersect with the policy's permitted attributes. The panel auto-hides when nothing is permitted.
  • Deduplicated. Fields listed in metadata are removed from the main card so values aren't shown twice.
  • Responsive. Side-by-side at lg+, stacked below.
  • Formatting inherits. Field labels and as: declarations propagate — the metadata panel uses the same field-rendering machinery as the main card.

Index views (Table & Grid)

Resources can offer both Table and Grid views. The user switches via the toolbar; the choice persists per-resource via cookie.

ruby
class UserDefinition < ResourceDefinition
  # No `index_views :table, :grid` needed — declaring grid_fields auto-enables :grid.
  grid_fields(
    image:     :avatar,           # ActiveStorage attachment, 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
  )

  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
MethodPurpose
index_views :table, :gridWhich views are available. Default [:table]. Usually unnecessary.
default_index_view :gridInitial view when no cookie. Falls back to first available view.
grid_fields(...)Map card slots to fields. Implicitly enables :grid.
grid_layout :compact | :media:compact puts image left of content; :media stacks image full-width on top.
grid_columns NOverride responsive column count on lg+. Default is 1/2/3/4 at sm/md/lg/xl.

Grid slots — :image, :header, :subheader, :body, :meta, :footer — are all optional. :meta accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.

Only declare index_views explicitly to disable one (e.g. index_views :grid to drop the table view).

Custom page classes

Override the rendered page entirely — full control via Phlex:

ruby
class PostDefinition < ResourceDefinition
  class IndexPage < IndexPage      # inherits the parent's nested class
    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

See UI › Pages and UI › Forms for the full page-class surface.

Released under the MIT License.