Skip to content
This repository has been archived by the owner on Nov 6, 2023. It is now read-only.

nerdgeschoss/muffin

Repository files navigation

Muffin

Why form objects?

Form objects encapsulate logic to modify data (similar to changesets in Elixir or Mutations in GraphQL). Every non trivial form in rails usually has some custom (and conditional) validation, specific behavior (when update x, then remove y) and complex association (e.g. accepts_nested_attributes_for). This is usually spread all over the model leading to hard to maintain code and tons of conditional validation that is hard to understand. Also it’s not possible to have a form for many objects without having a parent object that is doing the nested_attribute dance.

Forms are living in /app/forms. They should work independently from controllers (for unit testing) and can (but doesn’t have to!) handle ActiveRecord objects.

Public API

my_form = MyForm.new(request:, params:, scope:) # scope could be the current_user
my_form.call # 'commits' the form: it validates and calls the internal process method. returns true on success, false when validation fails. Other errors are signaled via Exceptions.
my_form.call! # same as call, but raises a ValidationError if validation fails

Attributes

Attributes specify which attribute in the form can be set via a request

class MyForm < Muffin::Base
  attribute :name # type String is implicit
  attribute :age, Integer # second argument defines type if present
  attribute :accepted?, Boolean # boolean is defined for true or false, converts strings like "on" or "off" (from forms) automatically to their boolean value
  attribute :tags, array: true # array of strings
  attribute :tags, [String] # same as above
end

Forms can contain validations

class MyForm < Muffin::Base
  attribute :name
  validates :name, presence: true
end

And also give a list of required attributes (useful for html validation and marking them in the UI).

my_form.required_attributes # [:name]
my_form.valid? # returns true/false
my_form.errors # returns an error object

Attributes are automatically assigned on init:

my_form = MyForm.new(params: { name: "Superman" }) # assigns the name attribute
my_form.attributes # { name: "Superman" }

Performing Changes

When call is invoked, the form performs validation steps. If those steps are successful, perform is called. perform is invoked inside of a transaction.

class MyForm < Muffin::Base
  attribute :name

  def perform
    Model.find(5).update! name: name
  end
end

The form does not make any assumptions about what perform does except for needing all validations to be successful.

Nesting forms

Attributes can be nested (this replaces accepts_nested_attributes_for and is compatible with its helpers, e.g. in forms).

class WishlistForm < Muffin::Base
  attribute :children_name
  attribute :wishes do
    attribute :name
    validates :name, presence: true
  end
end

WishlistForm.new(params: { children_name: "Klaus", wishes: [{ name: "some cookies}])

Manually assigning parameters

Sometimes it's necessary to manually assign attributes after initialization. In this case assign_attributes can be overriden (a call to super is optional and will invoke the normal behaviour).

class MyForm < Form
  attribute :name

  def assign_attributes
    self.name = params[:name].downcase
  end
end

MyForm.new(params: { name: "Klaus" }).name # "klaus"

Creating / updating active record objects

In the most simple case of a form mapping 1:1 to an active record object, the form object should be as simple as possible:

class Object < ActiveRecord::Base
  # has a :name
end

class ObjectUpdateForm < Muffin::Base
  attribute :id
  attribute :name

  validates :name, presence: true

  def model
    @model ||= Object.find(params[:id])
  end

  private

  def assign_attributes
    self.name = model.name
    super # assigns the params hash
  end

  def perform
    model.update!(attributes.slice(:name))
  end
end

Post.first.name # "My Post" from Post 1
form = ObjectUpdateForm.new(params: { id: 1, name: "Updated Post" })
form.call
Post.first.name # "Updated Post"

Updating nested active record objects

class User < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :user
end

class MyForm < Muffin::Base
  attribute :id, Integer
  attribute :comments do
    attribute :id, Integer
    attribute :_destroy, Boolean
    attribute :text
  end

  def user
    @user ||= User.find(params[:id])
  end

  def assign_attributes
    self.attributes = user.attributes.merge(comments: user.comments.map(&:attributes))
    super
  end

  def perform
    update_nested! user.comments, comments
  end
end

update_nested will create new comments, update existing comments and destroy comments where _destroy is true. If no :id is present, it will create a new object always. If you don’t want to allow deleting, don’t add a :_destroy attribute.

Integrating with policies

Policies are integrated with form objects.

class MyForm < Muffin::Base
  attribute :name
  permitted? { scope.admin? }
end

form = MyForm.new(params: { name: "Klaus"}, scope: normal_user)
form.permitted? # false
form.call # raises NotPermitted

You can also permit single attributes depending on the user (which works as a replacement for strong attributes):

class MyForm < Muffin::Base
  attribute :name, permit: -> { scope.admin? }
end

form = MyForm.new(params: { name: "Klaus"}, scope: normal_user)
form.name # nil
form.permitted? # true
form.attributes # { }, will not include non permitted attributes
form.attribute_permitted?(:name) # false

If permission should happen depending on the actual value of an attribute, this is possible, too.

class MyForm < Muffin::Base
  attribute :role, permitted_values: -> { scope.admin? ? ["user", "admin"] : ["user"] }
end

form = MyForm.new(params: { role: "admin"}, scope: normal_user)
# will raise NotPermitted
form = MyForm.new(params: { role: "user"}, scope: normal_user)
form.attribute_permitted?(:role) # true
form.attribute_value_permitted?(:role, "admin") # false
form.permitted_values(:role) # ["user"]

Integration with controllers

A form object should be easy to create from a controller with a special helper (inspired by Trailblazer).

def create
  @form = prepare MyForm
  if @form.call
    redirect_to @form.model
  else
    render :new
  end
end

This will instantiate a form object, hand over the params and the context (e.g. the currently logged in user or auth scope) and performs depending on the method, which is roughly equivalent to

def prepare(klass)
  scope = try(:form_auth_scope) || try(:current_user)
  processed_params = params[klass.model_name.underscore].permit!.to_h.map {...} # extract params from hash and clean up keys, e.g. comments_attributes -> comments
  klass.new params: processed_params, request: request, scope: scope
end

Integration with Views

Form objects work with Rails' form helpers automatically.

class SurveyForm < Form
  attribute :email
  attribute :answers do
    attribute :question_id, Integer
    attribute :answer

    validates :answer, presence: true
  end

  validates :email, presence: true
end

def new
  @survey = prepare SurveyForm
end


= form_for @survey do |f|
  = f.email_field :email
  = f.fields_for :answers do |ff|
    = ff.hidden_field :question_id
    = ff.text_field :answer
  = f.submit

Development

After checking out the repo, run bin/setup to install dependencies. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/nerdgeschoss/muffin. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.