Forms โ
Built on Phlexi::Form. Override the Form nested class in your definition to customize templates, layouts, and field rendering.
๐จ Critical โ
render_actionsis mandatory in customform_template: without it, the form has no submit button.- Configure inputs in the definition, render them with
render_resource_fieldin the form template. Don't reimplement field widgets from scratch. - Override via nested classes (
class Form < Form; end) inside the definition. Don't replace the rootPlutonium::UI::Form::Resourceclass.
Hierarchy โ
Phlexi::Form::Base
โโโ Plutonium::UI::Form::Base
โโโ Plutonium::UI::Form::Resource # CRUD
โ โโโ Plutonium::UI::Form::Interaction # action forms
โโโ Plutonium::UI::Form::Query # search/filterOverride the form โ
class PostDefinition < ResourceDefinition
class Form < Form
def form_template
render_fields # render every permitted field
render_actions # submit buttons, REQUIRED
end
end
endForm methods โ
| Method | Purpose |
|---|---|
form_template | Main override point |
render_fields | All permitted fields in default layout |
render_resource_field(name) | One field, using the definition's input config |
render_actions | Submit + secondary buttons |
fields_wrapper { ... } | Grid wrapper div (themeable) |
actions_wrapper { ... } | Button wrapper div (themeable) |
object / record | The form record |
resource_fields | Array of permitted field names |
resource_definition | The definition instance |
Custom layouts โ
Sectioned form (declarative: preferred) โ
Declare sections in the definition using form_layout. The form picks up the layout automatically, no Form subclass needed for common cases.
class PostDefinition < ResourceDefinition
form_layout do
section :basics, :title, :slug,
label: "Basic information"
section :content, :body, :excerpt,
label: "Content", columns: 1
section :publishing, :published_at, :category,
label: "Publishing", collapsible: true, collapsed: true
end
endThis handles headings, collapsible panels, per-section column counts, and condition:-based visibility, all with no view code. See Resource โบ Definition โบ Form layout for the full DSL reference, including ungrouped, condition:, columns:, and the "On interactions" note.
Full control: override render_fields โ
When the declarative DSL doesn't cover your use case (asymmetric multi-column layouts, embedding a panel widget between sections, etc.) override render_fields in a nested Form class:
class PostDefinition < ResourceDefinition
class Form < Form
def form_template
render_fields # replaced below
render_actions
end
def render_fields
div(class: "mb-8") do
h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Basic Information" }
fields_wrapper do
render_resource_field :title
render_resource_field :slug
end
end
div(class: "mb-8") do
h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Content" }
fields_wrapper do
render_resource_field :content
render_resource_field :excerpt
end
end
div(class: "mb-8") do
h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { "Publishing" }
fields_wrapper do
render_resource_field :published_at
render_resource_field :category
end
end
end
end
endPrefer form_layout in the definition; it keeps layout config out of view code and works for interactions too.
Two-column layout โ
def form_template
div(class: "grid grid-cols-1 lg:grid-cols-3 gap-6") do
div(class: "lg:col-span-2") do
fields_wrapper do
render_resource_field :title
render_resource_field :content
end
end
div(class: "space-y-4") do
Panel do
h4(class: "font-medium mb-2") { "Settings" }
render_resource_field :status
render_resource_field :visibility
end
end
end
render_actions
endField builder (field(:foo).input_tag) โ
render_resource_field uses the input config from the definition. For ad-hoc rendering (when you want fine-grained control over a specific field) use field(...) directly:
render field(:title).wrapped { |f| f.input_tag } # wrapped: label + hint + errors
render field(:title).input_tag # bare element only
render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }Tag methods (standard) โ
| Tag | Input |
|---|---|
input_tag | text (auto-detected type) |
string_tag, text_tag, number_tag, email_tag, password_tag, url_tag, hidden_tag | standard HTML inputs |
checkbox_tag, select_tag, radio_button_tag, collection_radio_buttons_tag, collection_checkboxes_tag | standard |
toggle_tag / switch_tag | switch-styled boolean (as: :toggle / :switch), the default for boolean columns; same behavior as a checkbox. Use checkbox_tag (as: :boolean) for a plain checkbox. |
Plutonium-enhanced tags โ
| Tag | Component |
|---|---|
easymde_tag / markdown_tag | EasyMDE markdown editor |
slim_select_tag | Slim Select (enhanced dropdown) |
flatpickr_tag | Flatpickr date/time picker |
phone_tag / int_tel_input_tag | intl-tel-input phone field |
currency_tag | Money input (number field + optional unit prefix) |
uppy_tag / file_tag | Uppy file upload |
binary_tag | Plain file input whose bytes go into a binary column |
rich_text_tag | Action Text editor (Trix, or Lexxy once installed) |
list_tag | Chip input for array values |
tags_tag | Chip input for a no_fly_list has_tags context |
rating_tag | Star picker (radio buttons, no JS); an optional rating gets a Clear option |
secure_association_tag | Association with policy-checked options (inline + add, typeahead) |
belongs_to_tag / has_many_tag / has_one_tag | Association selects |
association_checkboxes_tag / association_toggles_tag | has_many / habtm as a list of checkboxes or switches (see Association inputs) |
association_radio_buttons_tag | belongs_to / has_one as a radio group |
key_value_store_tag | Key/value pairs editor |
render field(:published_at).wrapped { |f| f.flatpickr_tag }
render field(:avatar).wrapped do |f|
f.uppy_tag(allowed_file_types: %w[.jpg .png], max_file_size: 5.megabytes)
endPhone fields (intl-tel-input) โ
as: :phone renders an intl-tel-input field. Forward library options two ways:
initial_country:: a convenient shortcut for the library'sinitialCountry(ISO2, e.g."gh"). This preselects a country so the widget doesn't show "No country selected" and a bare local number validates.intl_options:: any other library option, using the library's own camelCase names. Merged over the shortcut, so it wins on conflict.
input :phone, as: :phone, initial_country: "gh"
input :phone, as: :phone, intl_options: {separateDialCode: true, strictMode: false}When a field sets no country, it falls back to Plutonium.configuration.default_phone_country (an ISO2 code, or nil to leave it to the library):
Plutonium.configure do |config|
config.default_phone_country = "gh"
endThe field defaults to strictMode: true; override it via intl_options: {strictMode: false}.
Currency fields โ
as: :currency renders a number input (inputmode="decimal", step="0.01") with an optional currency-unit prefix overlaid at its left edge. The unit resolves by the same chain as the currency display (Currency.resolve_unit), so the form, show page, index, and wizard summary all show the same symbol:
explicit unit: โ the record's has_cents unit โ config.default_currency_unit โ the i18n number.currency.format.unit.
input :price, as: :currency # unit from has_cents / config / i18n
input :price, as: :currency, unit: "ยฃ" # a literal symbol
input :price, as: :currency, unit: false # no prefix, a plain number inputWhen nothing resolves (or unit: false), the prefix is omitted and it's an ordinary number input.
has_cents fields infer it automatically: just like the display. A bare input :price on a has_cents attribute renders the currency input with the unit read off has_cents; no as: :currency needed. Use the explicit as: :currency for a non-has_cents decimal, or to override the unit.
In a wizard step the data snapshot has no has_cents reflection, so there's nothing to infer from; declare as: :currency and pass unit: explicitly (input :price, as: :currency, unit: "$"); the review summary reads it back and formats the value as currency.
Password & secret fields โ
password_tag renders a masking input that never emits the stored value into the DOM. A stored secret renders a fixed sentinel (masking both the value and its length); on submit:
| field state | result |
|---|---|
| untouched (sentinel) | kept, the stored secret is left unchanged |
| edited to a new value, then failed re-render | comes back blank + required so the user re-types it (a submitted secret is never echoed back) |
| cleared, then failed re-render | comes back blank, not required; the clear may be intentional, so it's allowed to stand |
| emptied | explicit clear (clear-by-blank), the required guard only prevents an accidental blank submit |
| typed | set as the new value |
The sentinel is guarded client-side by the password-sentinel Stimulus controller: the first edit (a keystroke, paste, or backspace) wipes the whole field, so a partial edit can't corrupt the sentinel into a literal new password. New records and interaction forms (set-password, reset-password) render an honest empty field.
Automatic detection. A field is masked automatically when its name:
- equals
password,token, orsalt; - starts with
encrypted_; - ends with
_password,_digest,_hash,_token,_key, or_salt; - contains
secret.
The same names are masked everywhere the value would otherwise print: the show page, table cells, the wizard summary, and grid and kanban card slots all render โขโขโขโขโขโขโขโข instead of the value. A field/display as: overrides the heuristic on those surfaces too.
This is a naming convenience, not a security guarantee, tune it per field:
# Opt OUT: render the value as a normal, readable text input
field :api_token, as: :string # a token the admin needs to copy (shown on the show page and cards too)
field :content_hash, as: :string # a checksum, not a secret
field :public_key, as: :string # *_key matches, but a public key is not secret
# Opt IN: mask a secret the heuristic still misses (e.g. no telltale name)
field :recovery_phrase, as: :password
field :pin, as: :secret # alias of :passwordWARNING
The heuristic is name-based and best-effort. A secret column with an unconventional name (e.g. recovery_phrase, pin) still renders its value into the page unless you set as: :password. Audit secret-bearing columns explicitly.
Rich text (Action Text and Lexxy) โ
A has_rich_text field renders Action Text's own editor tag, so it gets whichever editor the app has set up: Trix by default, or Lexxy once the lexxy gem is in the bundle (it takes over Action Text's helpers on Rails 8.0/8.1 and registers itself as the editor on 8.2). Lexxy needs Rails 8.0.2 or later. The editor's JavaScript and CSS come from the app's bundle, as Action Text's or Lexxy's install instructions describe. The show page renders the HTML through Action Text, which sanitizes it.
Lists and tags โ
as: :list edits an array value (a JSON or array column) as chips. Options go on input:
field :nicknames, as: :list
input :nicknames, suggestions: %w[Basin Tub], limit: 5 # addable: false allows only suggestionsRemoving every chip saves an empty array.
no_fly_list has_tags contexts are detected automatically. Permit them by context name (labels, not labels_list) and they render as chips on the form, the show page and the table:
class Article < ResourceRecord
include NoFlyList::TaggableRecord
has_tags :labels, scope: :organization # per-tenant vocabulary
has_tags :topics, restrict_to_existing: true, limit: 3
end
# policy
def permitted_attributes_for_create = %i[title labels topics]- The input suggests tags that already exist in the record's tag scope.
restrict_to_existingonly allows picking existing tags. Plutonium doesn't decide where that vocabulary is managed: create the tags however suits the app (register the tag model as a resource, seed them, or add them in an interaction).limitcaps how many tags can be chosen.- Add
filter :labels, with: :tagsfor a table filter. - The table reads tags through the context's association, so preload it (
includes(:labels)) to avoid a query per row.
Binary columns โ
A binary column renders binary_tag: a plain file input. The uploaded file's bytes are written to the column, and the show page and table print its size (Binary data (12 KB)), never the bytes.
- Saving without choosing a file keeps the stored bytes. There is no control to clear the column.
- Browsers can't refill a file input, so after a failed validation the user has to choose the file again.
- The whole file is read into memory and stored in the row. Use Active Storage (
has_one_attached+:uppy) for anything that isn't small.
Wrapped vs unwrapped โ
wrapped: includes label, hint, and error rendering. Use for normal form fields.- Bare tag, just the input element. Use when you're laying out custom wrappers.
wrapped(class: "..."): pass classes to the wrapper div.
Association inputs (secure_association_tag) โ
Association inputs render with two affordances out of the box:
- Inline
+add: a button next to the select opens the target resource's:newaction. Inherits the target's modal mode. If the parent form is already in a modal, the+opens a stacked secondary modal (see Pages โบ Stacked modals) so the in-progress form isn't lost; on success the secondary closes and the parent reloads. - Typeahead: server-side autocomplete is on by default. Uses the target's
searchblock if defined; otherwise falls back to aLIKEon the input'slabel_method:column or the first match from[name, title, label, slug, display_name, email]. See Resource โบ Query โบ Search for the typeahead fallback details.
# Opt out of the + button
input :author, add_action: false
# Custom add URL
input :author, add_action: "/internal/users/new"
# Opt out of typeahead (use slim-select's client filter only)
input :author, typeahead: false
# Pick a non-default searchable column
input :author, label_method: :emailLarge association tables
For large target tables, write an explicit search block on the target resource definition; the fallback's leading-wildcard LIKE can't use a b-tree index.
List variants: checkboxes, toggles, radio buttons โ
For a short list of options, render one control per record instead of a dropdown:
input :tags, as: :association_checkboxes # has_many / habtm
input :tags, as: :association_toggles # same, styled as switches
input :category, as: :association_radio_buttons # belongs_to / has_oneThe variants share the select's security: options come from the target's policy scope (or choices:), values are signed global ids, and submitted ids are checked against the authorized relation. Like the select, they always render the records the field holds now, even past choice_limit: or outside the viewer's scope, and accept them back on submit, so saving a form never drops a value the viewer did not touch. The inline + add button renders next to the list, and add_action:, skip_authorization:, choices: and label_method: work the same way.
How they differ from the select:
- The list is capped like the select.
choice_limit:defaults to the select's cap. There is no typeahead, so a record past the cap can't be picked: use them for short lists, or raisechoice_limit:. - Unchecking everything clears the association. The checkbox list submits a blank value, so an empty selection saves as no records.
- Optional radio groups get a "None" choice. A radio group can't be cleared otherwise. Picking it clears the association. On a has_one, Active Record detaches the old record (or destroys it, with
dependent: :destroy) as soon as the value is assigned, before the form validates, the same as picking a different record. It is left out when the field is required (a checked "None" would satisfy the browser'srequired), andinclude_blank: falsedrops it on an optional one. Its label is the field'splaceholder:if set. - The macro has to fit. Checkboxes and toggles take a has_many or habtm; radio buttons take a belongs_to or has_one. A mismatch raises
ArgumentErrorwhen the input is built, on render and on submit, as do a polymorphic association andgroup_method:.
The list and its controls are themed by association_checkboxes, association_toggles, association_radio_buttons (the list), association_checkbox, association_toggle, association_radio_button (each control) and association_choice_label.
Submit buttons โ
Default render_actions produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
Control the secondary button via the definition:
class PostDefinition < ResourceDefinition
submit_and_continue false # nil (default, auto), true (always show), false (always hide)
endSingular resources auto-hide it (creating "another" doesn't make sense for /profile).
Custom action strip โ
def render_actions
actions_wrapper do
a(href: resource_url_for(resource_class), class: "pu-btn pu-btn-md pu-btn-secondary") { "Cancel" }
button(type: :submit, name: "draft", value: "1", class: "pu-btn pu-btn-md") { "Save Draft" }
render submit_button
end
endPre-submit, nested inputs, interaction forms โ
These all live in the definition layer:
- Pre-submit / dynamic forms: see Resource โบ Definition โบ Dynamic forms
- Nested inputs (
nested_input :variants), see Resource โบ Definition โบ Nested inputs - Interaction forms: interactions define their own
attribute/inputand inheritPlutonium::UI::Form::Interaction; see Behavior โบ Interactions
Theming โ
Forms use a theme system for consistent styling. Override per-resource by nesting a Theme class inside Form:
class PostDefinition < ResourceDefinition
class Form < Form
class Theme < Plutonium::UI::Form::Theme
def self.theme
super.merge(
base: "bg-[var(--pu-card-bg)] shadow-md rounded-lg p-6",
fields_wrapper: "grid grid-cols-2 gap-6",
actions_wrapper: "flex justify-end mt-6 space-x-2",
label: "block mb-2 text-base font-bold",
input: "pu-input",
error: "pu-error",
button: "pu-btn pu-btn-md pu-btn-primary"
)
end
end
end
endAlways super.merge(...)
Don't replace the theme wholesale, Plutonium's defaults handle invalid states, focus rings, and dark mode. super.merge keeps them.
Theme keys โ
base, fields_wrapper, actions_wrapper, wrapper, inner_wrapper, label, invalid_label, valid_label, neutral_label, input, invalid_input, valid_input, neutral_input, hint, error, button, checkbox, toggle, select.
See Assets โบ Phlexi component themes for the underlying theme system.
Context inside form templates โ
class Form < Form
def form_template
# Form object
object # the record
record # alias for object
object.new_record? # check if creating
# Request context
current_user
current_parent
current_scoped_entity
request
params
# Definition
resource_definition
resource_fields # permitted fields
# URL helpers
resource_url_for(object)
resource_url_for(Post, action: :new)
# Rails helpers
helpers.link_to(...)
end
endRelated โ
- Pages:
NewPage/EditPagepage hooks - Components: building reusable Phlex components for forms
- Assets:
.pu-*classes, design tokens, dark mode - Resource โบ Definition: input configuration (
as:,hint:,condition:, blocks) - Behavior โบ Interactions: interaction forms (
Plutonium::UI::Form::Interaction) - Tenancy โบ Nested resources: parent fields hidden by URL
