42
Objectify Your Forms: Beyond Basic User Input Danny Olson [email protected]

Objectify Your Forms: Beyond Basic User Input

Embed Size (px)

Citation preview

Objectify Your Forms:Beyond Basic User Input

Danny Olson

[email protected]

Corporate SponsorshipSharethrough

In-feed advertising exchange.

BackgroundForms are common in web applicationsForms often end up saving data to multiple tablesRails gives us #accepts_nested_attributes_forMuch magic and potential confusion

tl;drWe don't always have to do things The Rails Way™

Ice Cream

Memes

Online Meme-Based Ice CreamOrdering Service

Class Diagram

First ImplementationThe Rails Way™

class IceCreamsController < ApplicationController def new @ice_cream = IceCream.new

respond_with @ice_cream end

def create @ice_cream = IceCream.new(valid_params)

if @ice_cream.save Meme.create_defaults(@ice_cream) redirect_to edit_ice_cream_path(@ice_cream), notice: 'Ice cream was created.' else render :new end end

= form_for ice_cream do |f| -# other fields omitted

- if ice_cream.persisted? = f.fields_for :memes do |fields| = fields.label :name = fields.text_field :name = fields.label :rating = fields.select :rating, options_for_select(10.downto(1), fields.object.rating)

- if fields.object.persisted? = fields.label :_destroy, 'Delete' = fields.check_box :_destroy

= f.submit 'Make It So (Delicious)'

class IceCream < ActiveRecord::Base accepts_nested_attributes_for :memes, reject_if: proc { |attr| attr['name'].blank? || attr['rating'].blank? }, allow_destroy: true

validates :flavor_id, :serving_size_id, presence: true validates :scoops, presence: true, inclusion: { in: [1, 2, 3] } validate :more_scoops_than_toppings

before_save :set_price

def topping_ids=(toppings) filtered_toppings = toppings.reject { |t| !Topping.exists?(t) } super(filtered_toppings) end

private

def more_scoops_than_toppings if scoops.to_i < toppings.size errors.add(:toppings, "can't be more than scoops") end end

def set_price self.price = scoops * 100 end

Responsibilitiesclass IceCream < ActiveRecord::Base reject_if: proc { |attr| attr['name'].blank? || attr['rating'].blank? }, allow_destroy: true

Responsibilitiesclass IceCream < ActiveRecord::Base before_save :set_price

private

def set_price self.price = scoops * 100 end

Responsibilitiesclass IceCream < ActiveRecord::Base def topping_ids=(toppings) filtered_toppings = toppings.reject { |t| !Topping.exists?(t) } super(filtered_toppings) end

Responsibilitiesclass IceCream < ActiveRecord::Base validates :flavor_id, presence: true validates :serving_size_id, presence: true validates :scoops, presence: true, inclusion: { in: [1, 2, 3] } validate :more_scoops_than_toppings

private

def more_scoops_than_toppings if scoops.to_i < toppings.size errors.add(:toppings, "can't be more than scoops") end end

Single Responsibility PrincipleEvery class should have one, and only one,reason to change.

1. format data2. save the data3. check values of associated objects4. validations

Concerning...

Early on, SRP is easy to apply. ActiveRecordclasses handle persistence, associations and notmuch else. But bit-by-bit, they grow. Objectsthat are inherently responsible for persistencebecome the de facto owner of all business logicas well. And a year or two later you have a Userclass with over 500 lines of code, and hundredsof methods in it’s public interface.

- 7 Patterns to Refactor Fat ActiveRecordModels

#accepts_nested_attributes_for isused, in ActiveRecord classes, to reduce theamount of code in Rails applications needed tocreate/update records across multiple tableswith a single HTTP POST/PUT. As with manythings Rails, this is convention-driven...

While this sometimes results in less code, it oftenresults in brittle code.

- #accepts_nested_attributes_for (Often)Considered Harmful

New Feature Request

We need to base the price off of both the memes and the amount of scoops.

From this:# app/models/ice_cream.rbclass IceCream < ActiveRecord::Base def set_price self.price = scoops * 100 end

# app/controllers/ice_creams_controller.rbclass IceCreamsController < ApplicationController def create @ice_cream = IceCream.new(valid_params)

if @ice_cream.save Meme.create_defaults(@ice_cream) redirect_to edit_ice_cream_path(@ice_cream), notice: 'Ice cream was created.' else render :new end end

To this:# app/models/ice_cream.rbclass IceCream < ActiveRecord::Base def ratings_sum memes.reduce(0) { |sum, meme| sum += meme.rating } end

def set_price unless price_changed? self.price = scoops * 100 end end

# app/controllers/ice_creams_controller.rbclass IceCreamsController < ApplicationController def create @ice_cream = IceCream.new(valid_params)

if @ice_cream.save Meme.create_defaults(@ice_cream) meme_ratings = @ice_cream.ratings_sum @ice_cream.update_attributes!({ price: @ice_cream.price + meme_ratings }) redirect_to edit_ice_cream_path(@ice_cream), notice: 'Ice cream was created.' else render :new end end

There's Another Way

Form ObjectAn object that encapsulates context-specificlogic for user input.

It has only the attributes displayed in the formIt sets up its own dataIt validates that dataIt delegates to persistence but doesn't know specifics

Second ImplementationWith a Form Object

class NewOrderForm include Virtus.model

attribute :flavor_id, Integer attribute :serving_size_id, Integer attribute :scoops, Integer attribute :topping_ids, Array[Integer]end

class NewOrderForm extend ActiveModel::Naming include ActiveModel::Conversion include ActiveModel::Validations

validates :flavor_id, :serving_size_id, presence: true validates :scoops, presence: true, inclusion: { in: [1, 2, 3] } validate :more_scoops_than_toppings

private

def more_scoops_than_toppings if scoops.to_i < topping_ids.delete_if { |attr| attr == '' }.size errors.add(:topping_ids, "can't be more than scoops") end end

class NewOrderForm def save if valid? @model = OrderCreating.call(attributes) true else false end end

class OrderCreating def self.call(attributes) IceCream.transaction do ice_cream = IceCream.create!(flavor_id: flavor_id, serving_size_id: serving_size_id, topping_ids: topping_ids, scoops: scoops, price: scoops * 100)

Meme.create_defaults(ice_cream) ice_cream end endend

class EditOrderForm attribute :memes, Array[EditMemeForm]

class EditMeme attribute :id, Integer attribute :name, String attribute :rating, Integer attribute :_destroy, Boolean, default: false

validates :name, presence: true validates :rating, presence: true, inclusion: { in: 1..10, message: 'must be between 1 and 10' }end

class OrdersController < ApplicationController def new @order = NewOrderForm.new

respond_with @order end

def create @order = NewOrderForm.new(valid_params)

if @order.save redirect_to edit_order_path(@order), notice: 'Your order was created.' else render :new end endend

- if order.persisted? - order.memes.each_with_index do |meme, index| - if meme.id = hidden_field_tag "order[memes][][id]", meme.id

= label_tag "order[memes][][name]", 'Name' = text_field_tag "order[memes][][name]", meme.name = label_tag "order[memes][][rating]", 'Rating' = select_tag "order[memes][][rating]", meme_rating_options

- if meme.id = label_tag "memes_destroy_#{index}" do = check_box_tag "order[memes][][_destroy]" Delete

class IceCream < ActiveRecord::Base belongs_to :flavor belongs_to :serving_size has_and_belongs_to_many :toppings has_many :memes, dependent: :destroyend

New Feature Request ReduxWe need to base the price off of the meme ratings

not just the amount of scoops.

From this:class OrderCreating def self.call(attributes) IceCream.transaction do ice_cream = IceCream.create!(flavor_id: flavor_id, serving_size_id: serving_size_id, topping_ids: topping_ids, scoops: scoops, price: scoops * 100)

Meme.create_defaults(ice_cream) ice_cream end endend

To this:class OrderCreating def self.call(attributes) IceCream.transaction do ice_cream = IceCream.create!(flavor_id: flavor_id, serving_size_id: serving_size_id, topping_ids: topping_ids, scoops: scoops, price: scoops * 100)

Meme.create_defaults(ice_cream) IceCreamPriceUpdating.call(ice_cream) end endend

class IceCreamPriceUpdating def self.call meme_ratings = ice_cream.memes.reduce(0) { |sum, meme| sum += meme.rating } ice_cream.update_attributes!(price: ice_cream.price + meme_ratings) ice_cream end

BenefitsClearer domain modelLess magicSimpler to testOnly need to consider the specific contextEasier to maintain and change

When to Use a Form ObjectA Rule (if you want one):

Use a form object when persistingmultiple ActiveRecord models

What's Out There?redtape gemactiveform-rails gem

Thank You

Links

[email protected]://github.com/dbolson/form-object-presentationhttps://github.com/solnic/virtushttp://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-modelshttp://evan.tiggerpalace.com/articles/2012/11/07/accepts_nested_attributes_for-often-considered-harmful/https://github.com/ClearFit/redtapehttps://github.com/GCorbel/activeform-rails