Form Builder
The model-bound FormBuilder: form_with(builder:) + f.input for one-call fields - label, value, errors, aria, and validation attributes all derived from the object; type inference into Poetry's vocabulary, f.association, and the poetry_form -> simple_form i18n chain.
Coming from Simple Form?
Keep your views. The poetry-simple_form
bridge maps every Simple Form input type onto this builder with one
initializer, so existing f.input calls
render the same fields documented here, and the poetry-only controls are one
as: away. Migrate to
form_with(model:, builder: Poetry::Ui::FormBuilder)
form by form, at your own pace.
Default
Open Default standalone
<%# The whole story in one form: form_with(builder:) + one f.input per
attribute. Label from i18n, value from the object, aria-required from
the presence validation, maxlength from the length validation, the
boolean as a setting-row switch, the collection as a Select, and
f.submit's label from Rails' own i18n - nothing hand-wired. %>
<% profile_class = Class.new do
include ActiveModel::Model
include ActiveModel::Attributes
attribute :name, :string
attribute :email, :string
attribute :email_alerts, :boolean
attribute :role, :string
validates :name, presence: true, length: { maximum: 50 }
def self.name = "Profile"
end
profile = profile_class.new(name: "Ada Lovelace", role: "editor", email_alerts: true) %>
<%= form_with(model: profile, url: "/forms", method: :get,
builder: Poetry::Ui::FormBuilder,
class: "flex w-full max-w-md flex-col gap-6") do |f| %>
<%= f.input(:name, hint: "Shown on your public profile.") %>
<%= f.input(:email) %>
<%= f.input(:email_alerts, switch: true, hint: "Applied the moment it flips.") %>
<%= f.input(:role, collection: [["Admin", "admin"], ["Editor", "editor"], ["Viewer", "viewer"]]) %>
<div><%= f.submit %></div>
<% end %>
Errors
Open Errors standalone
<%# The error quartet from model truth: errors.add flows into the Field -
red label, message with an id, aria-describedby error-before-hint,
aria-invalid on the control, data-invalid on the root. The dual-key
lookup also surfaces association errors (:company) on the _id field. %>
<% signup_class = Class.new do
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
validates :email, presence: true
def self.name = "Signup"
end
signup = signup_class.new
signup.errors.add(:email, "can't be blank")
signup.errors.add(:password, "is too short (minimum is 12 characters)") %>
<%= form_with(model: signup, url: "/forms", method: :get,
builder: Poetry::Ui::FormBuilder,
class: "flex w-full max-w-md flex-col gap-6") do |f| %>
<%= f.input(:email, hint: "We never share it.") %>
<%= f.input(:password) %>
<% end %>
Inference
Open Inference standalone
<%# What f.input resolves without as:: text columns -> Textarea, booleans
-> the horizontal Field checkbox, integers with numericality -> a
NumberField carrying min/max/step, date columns -> DateField, and
name heuristics type the string inputs (email/url/tel/password -
password never round-trips). as: overrides any of it. %>
<% article_class = Class.new do
include ActiveModel::Model
include ActiveModel::Attributes
attribute :summary, :string
attribute :website_url, :string
attribute :quantity, :integer
attribute :published_on, :date
validates :quantity, numericality: { greater_than_or_equal_to: 1,
less_than_or_equal_to: 99, only_integer: true }
def self.name = "Listing"
def self.type_for_attribute(name)
name == "summary" ? Struct.new(:type).new(:text) : super
end
end %>
<%= form_with(model: article_class.new, url: "/forms", method: :get,
builder: Poetry::Ui::FormBuilder,
class: "flex w-full max-w-md flex-col gap-6") do |f| %>
<%= f.input(:summary, hint: "A text column renders the Textarea.") %>
<%= f.input(:website_url, hint: "The _url name renders type=url.") %>
<%= f.input(:quantity, hint: "numericality becomes min=1 max=99 step=1.") %>
<%= f.input(:published_on, hint: "A date column renders the DateField.") %>
<% end %>
Layout
Open Layout standalone
<%# Sections without leaving the builder: f.fieldset(legend:) frames a
group and yields the builder; orientation:/hint_position: pass
through to the Field (the setting-row and hint-above surfaces). %>
<% account_class = Class.new do
include ActiveModel::Model
include ActiveModel::Attributes
attribute :display_name, :string
attribute :bio, :string
def self.name = "Account"
def self.type_for_attribute(name)
name == "bio" ? Struct.new(:type).new(:text) : super
end
end %>
<%= form_with(model: account_class.new(display_name: "Ada"), url: "/forms", method: :get,
builder: Poetry::Ui::FormBuilder,
class: "flex w-full max-w-md flex-col gap-6") do |f| %>
<%= f.fieldset(legend: "Identity") do |section| %>
<%= section.input(:display_name) %>
<%= section.input(:bio, hint: "Markdown is fine.", hint_position: :above) %>
<% end %>
<div><%= f.submit("Save changes") %></div>
<% end %>