skills/form-object-patterns/SKILL.md
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.
npx skillsauth add fernandezbaptiste/rails_ai_agents form-object-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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 form
app/
├── 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: "[email protected]",
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: "[email protected]") }
let(:params) do
{
email: "[email protected]",
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: @formdevelopment
Creates ViewComponents for reusable UI elements with TDD. Use when building reusable UI components, extracting complex partials, creating cards/tables/badges/modals, or when user mentions ViewComponent, components, or reusable UI.
development
Guides Test-Driven Development workflow with Red-Green-Refactor cycle. Use when the user wants to implement a feature using TDD, write tests first, follow test-driven practices, or mentions red-green-refactor.
data-ai
Configures Solid Queue for background jobs in Rails 8. Use when setting up background processing, creating background jobs, configuring job queues, or migrating from Sidekiq to Solid Queue.
testing
Creates service objects following single-responsibility principle with comprehensive specs. Use when extracting business logic from controllers, creating complex operations, implementing interactors, or when user mentions service objects or POROs.