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

ruby 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

ruby 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

ruby 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).

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

Attributes are automatically assigned on init:

ruby 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.

```ruby 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).

```ruby 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).

```ruby 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:

```ruby 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

```ruby 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.

```ruby 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):

```ruby 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.

```ruby 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).

ruby 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

ruby 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.

```ruby 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.