Creates form objects for complex form handling with TDD. Use when building multi-model forms, search forms, wizard forms, or when user mentions form objects, complex forms, virtual models, or non-persisted forms.
91
89%
Does it follow best practices?
Impact
100%
1.31xAverage score across 3 eval scenarios
Passed
No findings from the security scan
Form objects encapsulate complex form logic:
| Scenario | Use Form Object? |
|---|---|
| Single model CRUD | No (use model) |
| Multi-model creation | Yes |
| Complex validations across models | Yes |
| Search/filter forms | Yes |
| Wizard/multi-step forms | Yes |
| API params transformation | Yes |
| Contact forms (no persistence) | Yes |
Form Object Progress:
- [ ] Step 1: Define form requirements
- [ ] Step 2: Write form object spec (RED)
- [ ] Step 3: Run spec (fails)
- [ ] Step 4: Create form object
- [ ] Step 5: Run spec (GREEN)
- [ ] Step 6: Wire up controller
- [ ] Step 7: Create view formapp/
├── forms/
│ ├── application_form.rb # Base class
│ ├── registration_form.rb # Multi-model
│ ├── search_form.rb # Non-persisted
│ └── wizard/
│ ├── base_form.rb
│ ├── step_one_form.rb
│ └── step_two_form.rb
spec/forms/
├── registration_form_spec.rb
└── search_form_spec.rb# app/forms/application_form.rb
class ApplicationForm
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
def self.model_name
ActiveModel::Name.new(self, nil, name.chomp("Form"))
end
def persisted?
false
end
# Override in subclasses
def save
return false unless valid?
persist!
true
rescue ActiveRecord::RecordInvalid => e
errors.add(:base, e.message)
false
end
private
def persist!
raise NotImplementedError
end
end# spec/forms/registration_form_spec.rb
require 'rails_helper'
RSpec.describe RegistrationForm do
describe "validations" do
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_presence_of(:password) }
it { is_expected.to validate_presence_of(:company_name) }
it { is_expected.to validate_length_of(:password).is_at_least(8) }
end
describe "#save" do
subject(:form) { described_class.new(params) }
context "with valid params" do
let(:params) do
{
email: "user@example.com",
password: "password123",
password_confirmation: "password123",
company_name: "Acme Inc",
phone: "0123456789"
}
end
it "returns true" do
expect(form.save).to be true
end
it "creates a user" do
expect { form.save }.to change(User, :count).by(1)
end
it "creates an account" do
expect { form.save }.to change(Account, :count).by(1)
end
it "associates user with account" do
form.save
expect(form.user.account).to eq(form.account)
end
it "exposes created records" do
form.save
expect(form.user).to be_persisted
expect(form.account).to be_persisted
end
end
context "with invalid params" do
let(:params) { { email: "", password: "short" } }
it "returns false" do
expect(form.save).to be false
end
it "does not create records" do
expect { form.save }.not_to change(User, :count)
end
it "has errors" do
form.save
expect(form.errors).not_to be_empty
end
end
context "with duplicate email" do
let!(:existing_user) { create(:user, email_address: "taken@example.com") }
let(:params) do
{
email: "taken@example.com",
password: "password123",
password_confirmation: "password123",
company_name: "Acme Inc"
}
end
it "returns false" do
expect(form.save).to be false
end
it "adds error to email" do
form.save
expect(form.errors[:email]).to include("has already been taken")
end
end
end
end# app/forms/registration_form.rb
class RegistrationForm < ApplicationForm
attribute :email, :string
attribute :password, :string
attribute :password_confirmation, :string
attribute :company_name, :string
attribute :phone, :string
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true, length: { minimum: 8 }
validates :password_confirmation, presence: true
validates :company_name, presence: true
validate :passwords_match
validate :email_unique
attr_reader :user, :account
private
def persist!
ActiveRecord::Base.transaction do
@account = Account.create!(name: company_name)
@user = User.create!(
email_address: email,
password: password,
account: @account,
phone: phone
)
end
end
def passwords_match
return if password == password_confirmation
errors.add(:password_confirmation, "doesn't match password")
end
def email_unique
return unless User.exists?(email_address: email&.downcase)
errors.add(:email, "has already been taken")
end
end# spec/forms/event_search_form_spec.rb
require 'rails_helper'
RSpec.describe EventSearchForm do
let(:account) { create(:account) }
let(:form) { described_class.new(account: account, params: params) }
describe "#results" do
let!(:wedding) { create(:event, account: account, event_type: :wedding, name: "Smith Wedding") }
let!(:corporate) { create(:event, account: account, event_type: :corporate, name: "Tech Conference") }
let!(:other_event) { create(:event, name: "Other") } # Different account
context "without filters" do
let(:params) { {} }
it "returns all account events" do
expect(form.results).to contain_exactly(wedding, corporate)
end
it "excludes other account events" do
expect(form.results).not_to include(other_event)
end
end
context "with type filter" do
let(:params) { { event_type: "wedding" } }
it "filters by type" do
expect(form.results).to contain_exactly(wedding)
end
end
context "with search query" do
let(:params) { { query: "smith" } }
it "searches by name" do
expect(form.results).to contain_exactly(wedding)
end
end
context "with date range" do
let(:params) { { start_date: Date.today, end_date: 1.month.from_now } }
let!(:upcoming) { create(:event, account: account, event_date: 2.weeks.from_now) }
let!(:past) { create(:event, account: account, event_date: 1.week.ago) }
it "filters by date range" do
expect(form.results).to include(upcoming)
expect(form.results).not_to include(past)
end
end
end
describe "#any_filters?" do
context "with filters" do
let(:params) { { query: "test" } }
it "returns true" do
expect(form.any_filters?).to be true
end
end
context "without filters" do
let(:params) { {} }
it "returns false" do
expect(form.any_filters?).to be false
end
end
end
end# app/forms/event_search_form.rb
class EventSearchForm < ApplicationForm
attribute :query, :string
attribute :event_type, :string
attribute :status, :string
attribute :start_date, :date
attribute :end_date, :date
attr_reader :account
def initialize(account:, params: {})
@account = account
super(params)
end
def results
scope = account.events
scope = apply_search(scope)
scope = apply_type_filter(scope)
scope = apply_status_filter(scope)
scope = apply_date_filter(scope)
scope.order(event_date: :desc)
end
def any_filters?
[query, event_type, status, start_date, end_date].any?(&:present?)
end
# For form select options
def event_type_options
Event.event_types.keys.map { |t| [t.humanize, t] }
end
def status_options
Event.statuses.keys.map { |s| [s.humanize, s] }
end
private
def apply_search(scope)
return scope if query.blank?
scope.where("name ILIKE :q OR description ILIKE :q", q: "%#{sanitize_like(query)}%")
end
def apply_type_filter(scope)
return scope if event_type.blank?
scope.where(event_type: event_type)
end
def apply_status_filter(scope)
return scope if status.blank?
scope.where(status: status)
end
def apply_date_filter(scope)
scope = scope.where("event_date >= ?", start_date) if start_date.present?
scope = scope.where("event_date <= ?", end_date) if end_date.present?
scope
end
def sanitize_like(term)
term.gsub(/[%_]/) { |x| "\\#{x}" }
end
end# app/forms/wizard/base_form.rb
module Wizard
class BaseForm < ApplicationForm
attribute :wizard_data, :string # JSON storage
def self.steps
raise NotImplementedError
end
def current_step
raise NotImplementedError
end
def next_step
steps = self.class.steps
current_index = steps.index(current_step)
steps[current_index + 1]
end
def previous_step
steps = self.class.steps
current_index = steps.index(current_step)
return nil if current_index.zero?
steps[current_index - 1]
end
def first_step?
current_step == self.class.steps.first
end
def last_step?
current_step == self.class.steps.last
end
def progress_percentage
steps = self.class.steps
((steps.index(current_step) + 1).to_f / steps.size * 100).round
end
end
end# app/forms/wizard/event_step_one_form.rb
module Wizard
class EventStepOneForm < BaseForm
attribute :name, :string
attribute :event_type, :string
attribute :event_date, :date
validates :name, presence: true
validates :event_type, presence: true
validates :event_date, presence: true
def self.steps
[:basics, :details, :vendors, :confirmation]
end
def current_step
:basics
end
end
end
# app/forms/wizard/event_step_two_form.rb
module Wizard
class EventStepTwoForm < BaseForm
attribute :description, :string
attribute :guest_count, :integer
attribute :budget_cents, :integer
validates :guest_count, numericality: { greater_than: 0 }, allow_nil: true
def self.steps
[:basics, :details, :vendors, :confirmation]
end
def current_step
:details
end
end
end# app/forms/contact_form.rb
class ContactForm < ApplicationForm
attribute :name, :string
attribute :email, :string
attribute :subject, :string
attribute :message, :string
validates :name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :subject, presence: true
validates :message, presence: true, length: { minimum: 10 }
def save
return false unless valid?
deliver_email
true
end
private
def deliver_email
ContactMailer.inquiry(
name: name,
email: email,
subject: subject,
message: message
).deliver_later
end
end# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
allow_unauthenticated_access
def new
@form = RegistrationForm.new
end
def create
@form = RegistrationForm.new(registration_params)
if @form.save
start_new_session_for(@form.user)
redirect_to dashboard_path, notice: t(".success")
else
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:registration).permit(
:email, :password, :password_confirmation,
:company_name, :phone
)
end
end<%# app/views/registrations/new.html.erb %>
<%= form_with model: @form, url: registrations_path do |f| %>
<% if @form.errors.any? %>
<div class="alert alert-error">
<ul>
<% @form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :email %>
<%= f.email_field :email, autofocus: true %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password %>
</div>
<div class="field">
<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation %>
</div>
<div class="field">
<%= f.label :company_name %>
<%= f.text_field :company_name %>
</div>
<%= f.submit "Register" %>
<% end %><%# app/views/events/_search_form.html.erb %>
<%= form_with model: @search_form, url: events_path, method: :get, local: true do |f| %>
<div class="flex gap-4">
<%= f.search_field :query, placeholder: "Search events..." %>
<%= f.select :event_type, @search_form.event_type_options, include_blank: "All types" %>
<%= f.select :status, @search_form.status_options, include_blank: "All statuses" %>
<%= f.date_field :start_date %>
<%= f.date_field :end_date %>
<%= f.submit "Search" %>
<% if @search_form.any_filters? %>
<%= link_to "Clear", events_path, class: "btn-secondary" %>
<% end %>
</div>
<% end %>ApplicationForm or includes ActiveModel::Model#save method with transaction (if multi-model)form_with model: @formfernandezbaptiste/rails_ai_agents
15fdeaf
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.