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

Objectify Your Forms: Beyond Basic User Input

Jul 13, 2015

Download

Technology

Danny Olson
Welcome message from author
This document is posted to help you gain knowledge. Please leave a comment to let me know what you think about it! Share it to your friends and learn new things together.
Transcript
Page 1: Objectify Your Forms: Beyond Basic User Input

Objectify Your Forms:Beyond Basic User Input

Danny Olson

[email protected]

Page 2: Objectify Your Forms: Beyond Basic User Input

Corporate SponsorshipSharethrough

In-feed advertising exchange.

Page 3: Objectify Your Forms: Beyond Basic User Input

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

Page 4: Objectify Your Forms: Beyond Basic User Input

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

Page 5: Objectify Your Forms: Beyond Basic User Input

Ice Cream

Page 6: Objectify Your Forms: Beyond Basic User Input

Memes

Page 7: Objectify Your Forms: Beyond Basic User Input

Online Meme-Based Ice CreamOrdering Service

Page 8: Objectify Your Forms: Beyond Basic User Input

Class Diagram

Page 9: Objectify Your Forms: Beyond Basic User Input

First ImplementationThe Rails Way™

Page 10: Objectify Your Forms: Beyond Basic User Input

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

Page 11: Objectify Your Forms: Beyond Basic User Input

= 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)'

Page 12: Objectify Your Forms: Beyond Basic User Input

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

Page 13: Objectify Your Forms: Beyond Basic User Input

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

Page 14: Objectify Your Forms: Beyond Basic User Input

Responsibilitiesclass IceCream < ActiveRecord::Base before_save :set_price

private

def set_price self.price = scoops * 100 end

Page 15: Objectify Your Forms: Beyond Basic User Input

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

Page 16: Objectify Your Forms: Beyond Basic User Input

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

Page 17: Objectify Your Forms: Beyond Basic User Input

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

Page 18: Objectify Your Forms: Beyond Basic User Input

Concerning...

Page 19: Objectify Your Forms: Beyond Basic User Input

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

Page 20: Objectify Your Forms: Beyond Basic User Input

#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

Page 21: Objectify Your Forms: Beyond Basic User Input

New Feature Request

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

Page 22: Objectify Your Forms: Beyond Basic User Input

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

Page 23: Objectify Your Forms: Beyond Basic User Input

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

Page 24: Objectify Your Forms: Beyond Basic User Input

There's Another Way

Page 25: Objectify Your Forms: Beyond Basic User Input

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

Page 26: Objectify Your Forms: Beyond Basic User Input

Second ImplementationWith a Form Object

Page 27: Objectify Your Forms: Beyond Basic User Input

class NewOrderForm include Virtus.model

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

Page 28: Objectify Your Forms: Beyond Basic User Input

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

Page 29: Objectify Your Forms: Beyond Basic User Input

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

Page 30: Objectify Your Forms: Beyond Basic User Input

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

Page 31: Objectify Your Forms: Beyond Basic User Input

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

Page 32: Objectify Your Forms: Beyond Basic User Input

- 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

Page 33: Objectify Your Forms: Beyond Basic User Input

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

Page 34: Objectify Your Forms: Beyond Basic User Input

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

not just the amount of scoops.

Page 35: Objectify Your Forms: Beyond Basic User Input

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

Page 36: Objectify Your Forms: Beyond Basic User Input

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

Page 37: Objectify Your Forms: Beyond Basic User Input

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

Page 38: Objectify Your Forms: Beyond Basic User Input

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

Use a form object when persistingmultiple ActiveRecord models

Page 39: Objectify Your Forms: Beyond Basic User Input

What's Out There?redtape gemactiveform-rails gem

Page 40: Objectify Your Forms: Beyond Basic User Input
Page 41: Objectify Your Forms: Beyond Basic User Input

Thank You

Page 42: Objectify Your Forms: Beyond Basic User Input

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