Skip to content

Forms โ€‹

Built on Phlexi::Form. Override the Form nested class in your definition to customize templates, layouts, and field rendering.

๐Ÿšจ Critical โ€‹

  • render_actions is mandatory in custom form_template: without it, the form has no submit button.
  • Configure inputs in the definition, render them with render_resource_field in 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 root Plutonium::UI::Form::Resource class.

Hierarchy โ€‹

Phlexi::Form::Base
โ””โ”€โ”€ Plutonium::UI::Form::Base
    โ”œโ”€โ”€ Plutonium::UI::Form::Resource          # CRUD
    โ”‚   โ””โ”€โ”€ Plutonium::UI::Form::Interaction   # action forms
    โ””โ”€โ”€ Plutonium::UI::Form::Query             # search/filter

Override the form โ€‹

ruby
class PostDefinition < ResourceDefinition
  class Form < Form
    def form_template
      render_fields       # render every permitted field
      render_actions      # submit buttons, REQUIRED
    end
  end
end

Form methods โ€‹

MethodPurpose
form_templateMain override point
render_fieldsAll permitted fields in default layout
render_resource_field(name)One field, using the definition's input config
render_actionsSubmit + secondary buttons
fields_wrapper { ... }Grid wrapper div (themeable)
actions_wrapper { ... }Button wrapper div (themeable)
object / recordThe form record
resource_fieldsArray of permitted field names
resource_definitionThe 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.

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

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

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

Prefer form_layout in the definition; it keeps layout config out of view code and works for interactions too.

Two-column layout โ€‹

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

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

ruby
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) โ€‹

TagInput
input_tagtext (auto-detected type)
string_tag, text_tag, number_tag, email_tag, password_tag, url_tag, hidden_tagstandard HTML inputs
checkbox_tag, select_tag, radio_button_tag, collection_radio_buttons_tag, collection_checkboxes_tagstandard
toggle_tag / switch_tagswitch-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 โ€‹

TagComponent
easymde_tag / markdown_tagEasyMDE markdown editor
slim_select_tagSlim Select (enhanced dropdown)
flatpickr_tagFlatpickr date/time picker
phone_tag / int_tel_input_tagintl-tel-input phone field
currency_tagMoney input (number field + optional unit prefix)
uppy_tag / file_tagUppy file upload
binary_tagPlain file input whose bytes go into a binary column
rich_text_tagAction Text editor (Trix, or Lexxy once installed)
list_tagChip input for array values
tags_tagChip input for a no_fly_list has_tags context
rating_tagStar picker (radio buttons, no JS); an optional rating gets a Clear option
secure_association_tagAssociation with policy-checked options (inline + add, typeahead)
belongs_to_tag / has_many_tag / has_one_tagAssociation selects
association_checkboxes_tag / association_toggles_taghas_many / habtm as a list of checkboxes or switches (see Association inputs)
association_radio_buttons_tagbelongs_to / has_one as a radio group
key_value_store_tagKey/value pairs editor
ruby
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)
end

Phone 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's initialCountry (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.
ruby
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):

ruby
Plutonium.configure do |config|
  config.default_phone_country = "gh"
end

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

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

When 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 stateresult
untouched (sentinel)kept, the stored secret is left unchanged
edited to a new value, then failed re-rendercomes back blank + required so the user re-types it (a submitted secret is never echoed back)
cleared, then failed re-rendercomes back blank, not required; the clear may be intentional, so it's allowed to stand
emptiedexplicit clear (clear-by-blank), the required guard only prevents an accidental blank submit
typedset 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, or salt;
  • 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:

ruby
# 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 :password

WARNING

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:

ruby
field :nicknames, as: :list
input :nicknames, suggestions: %w[Basin Tub], limit: 5   # addable: false allows only suggestions

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

ruby
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_existing only 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).
  • limit caps how many tags can be chosen.
  • Add filter :labels, with: :tags for 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 :new action. 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 search block if defined; otherwise falls back to a LIKE on the input's label_method: column or the first match from [name, title, label, slug, display_name, email]. See Resource โ€บ Query โ€บ Search for the typeahead fallback details.
ruby
# 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: :email

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

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

The 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 raise choice_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's required), and include_blank: false drops it on an optional one. Its label is the field's placeholder: 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 ArgumentError when the input is built, on render and on submit, as do a polymorphic association and group_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:

ruby
class PostDefinition < ResourceDefinition
  submit_and_continue false   # nil (default, auto), true (always show), false (always hide)
end

Singular resources auto-hide it (creating "another" doesn't make sense for /profile).

Custom action strip โ€‹

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

Pre-submit, nested inputs, interaction forms โ€‹

These all live in the definition layer:

Theming โ€‹

Forms use a theme system for consistent styling. Override per-resource by nesting a Theme class inside Form:

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

Always 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 โ€‹

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

Released under the MIT License.