CtrlK
BlogDocsLog inGet started
Tessl Logo

form-object-patterns

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.

Install with Tessl CLI

npx tessl i github:fernandezbaptiste/rails_ai_agents --skill form-object-patterns
What are skills?

95

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Form Object Patterns for Rails 8

Overview

Form objects encapsulate complex form logic:

  • Multi-model forms (user + profile + address)
  • Search/filter forms (non-persisted)
  • Wizard/multi-step forms
  • Virtual attributes with validation
  • Decoupled from ActiveRecord models

When to Use Form Objects

ScenarioUse Form Object?
Single model CRUDNo (use model)
Multi-model creationYes
Complex validations across modelsYes
Search/filter formsYes
Wizard/multi-step formsYes
API params transformationYes
Contact forms (no persistence)Yes

TDD Workflow

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

Project Structure

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

Base Form Class

# 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

Pattern 1: Multi-Model Registration Form

Spec First (RED)

# 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

Implementation (GREEN)

# 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

Pattern 2: Search/Filter Form

Spec First

# 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

Implementation

# 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

Pattern 3: Wizard/Multi-Step Form

Base Wizard Form

# 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

Step Forms

# 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

Pattern 4: Contact Form (No Persistence)

# 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

Controller Integration

# 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

View Integration

<%# 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 %>

Search Form View

<%# 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 %>

Checklist

  • Spec written first (RED)
  • Extends ApplicationForm or includes ActiveModel::Model
  • Attributes declared with types
  • Validations defined
  • #save method with transaction (if multi-model)
  • Controller uses form object
  • View uses form_with model: @form
  • Error handling in place
  • All specs GREEN
Repository
fernandezbaptiste/rails_ai_agents
Last updated
Created

Is this your skill?

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.