Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Keeping your Rails forms under control

Keeping your Rails forms under control

A typical Rails app has at least one complex form that creates or updates many different objects all at once. The usual way this is done involves spreading a lot of logic around, in models, controllers, and even in views. Anyone who has spent hours debugging Active Record's nested attributes knows what I'm talking about. Recent versions of Rails have quietly added several new features that help address this problem. I will show an example of how to use Active Model, responders, and form builders to reduce the complexity of your web forms while keeping the user experience nicely decoupled.

Example app at https://github.com/nertzy/store_example

Grant Hutchins

July 17, 2012
Tweet

Transcript

  1. Keeping your Rails forms under control Grant Hutchins, Pivotal Labs

    Tuesday, July 17, 2012 Tuesday, July 17, 12
  2. 1 = form_for product, html: {class: 'well form-horizontal'} do |f|

    2 %fieldset 3 .control-group 4 = f.label :name, class: "control-label" 5 .controls 6 = f.text_field :name 7 .control-group 8 = f.label :photo_url, class: "control-label" 9 .controls 10 = f.text_field :photo_url 11 .control-group 12 = f.label :description, class: "control-label" 13 .controls 14 = f.text_area :description, rows: 8 15 .form-actions 16 %button.btn Save /app/views/product/_form.html.haml Tuesday, July 17, 12
  3. 1 = form_for product, html: {class: 'well form-horizontal'} do |f|

    2 %fieldset 3 .control-group 4 = f.label :name, class: "control-label" 5 .controls 6 = f.text_field :name 7 .control-group 8 = f.label :photo_url, Product.human_attribute_name(:photo_url), class: "control-label" 9 .controls 10 = f.text_field :photo_url 11 .control-group 12 = f.label :description, class: "control-label" 13 .controls 14 = f.text_area :description, rows: 8 15 .form-actions 16 %button.btn Save /app/views/product/_form.html.haml Tuesday, July 17, 12
  4. /app/views/product/_form.html.haml 1 = form_for product, html: {class: 'well form-horizontal'} do

    |f| 2 %fieldset 3 .control-group 4 = f.label :name, class: "control-label" 5 .controls 6 = f.text_field :name 7 .control-group 8 = f.label :photo_url, Product.human_attribute_name(:photo_url), class: "control-label" 9 .controls 10 = f.text_field :photo_url 11 .control-group 12 = f.label :description, class: "control-label" 13 .controls 14 = f.text_area :description, rows: 8 15 .form-actions 16 %button.btn Save Tuesday, July 17, 12
  5. 1 = simple_form_for product, html: {class: 'well form-horizontal'} do |f|

    2 = f.input :name 3 = f.input :photo_url 4 = f.input :description, input_html: {rows: 8} 5 .form-actions 6 = f.button :submit /app/views/product/_form.html.haml Tuesday, July 17, 12
  6. /app/controllers/searches_controller.rb 1 class SearchesController < ApplicationController 2 def new 3

    end 4 5 def show 6 @products = Product.where("name LIKE ?", "%#{params[:query]}%") 7 end 8 end Tuesday, July 17, 12
  7. /app/views/searches/_form.html.haml 1 %form.form-search.well{action: search_path, method: :get} 2 = text_field_tag :query,

    params[:query], class: "search-query" 3 = button_tag "Search", class: "btn" Tuesday, July 17, 12
  8. /app/controllers/searches_controller.rb 1 class SearchesController < ApplicationController 2 def new 3

    end 4 5 def show 6 if params[:query].blank? 7 @missing_query = true 8 @products = [] 9 else 10 @products = Product.where("name LIKE ?", "%#{params[:query]}%") 11 end 12 13 if params[:query] =~ /shoes/i 14 flash.now.alert = "Sorry, we don't sell shoes anymore" 15 end 16 end 17 end Tuesday, July 17, 12
  9. /app/views/searches/_form.html.haml 1 %form.form-search.well{action: search_path, method: :get} 2 %fieldset.control-group{class: ("error" if

    @missing_query)} 3 .controls 4 = text_field_tag :query, params[:query], class: "search-query" 5 = button_tag "Search", class: "btn" 6 - if @missing_query 7 %span.help-inline Please fill out a search query Tuesday, July 17, 12
  10. /app/controllers/searches_controller.rb 1 class SearchesController < ApplicationController 2 def new 3

    end 4 5 def show 6 @search = Search.new(params) 7 end 8 end Tuesday, July 17, 12
  11. /app/views/searches/show.html.haml 1 - unless @search.missing_query? 2 %h1 Search results for

    #{@search.query} 3 4 %p= pluralize @search.count, "result" 5 6 %ul.products 7 - @search.results.each do |result| 8 %li= render result Tuesday, July 17, 12
  12. /app/models/search.rb 1 class Search 2 attr_reader :query 3 4 def

    initialize(params) 5 @query = params[:query] 6 end 7 8 def results 9 return [] if missing_query? 10 Product.where("name LIKE ?", "%#{@query}%") 11 end 12 13 def count 14 results.count 15 end 16 17 def missing_query? 18 @query.blank? 19 end 20 end Tuesday, July 17, 12
  13. /app/views/searches/_form.html.haml 1 %form.form-search.well{action: search_path, method: :get} 2 %fieldset.control-group{class: ("error" if

    @missing_query)} 3 .controls 4 = text_field_tag :query, params[:query], class: "search-query" 5 = button_tag "Search", class: "btn" 6 - if @missing_query 7 %span.help-inline Please fill out a search query Tuesday, July 17, 12
  14. /app/views/searches/_form.html.haml 1 = simple_form_for @search, url: search_path, method: :get, html:

    {class: "form-inline well"} do |f| 2 = f.input :query, class: "search-query" 3 .form-actions 4 = f.button :submit, class: "help-inline" Tuesday, July 17, 12
  15. /app/models/search.rb 1 class Search 2 attr_reader :query 3 4 def

    initialize(params = {}) 5 @query = params[:query] 6 end 7 8 def self.model_name 9 "Search" 10 end 11 12 def results 13 return [] if missing_query? 14 Product.where("name LIKE ?", "%#{@query}%") 15 end 16 17 def count 18 results.count 19 end 20 21 def missing_query? 22 @query.blank? Tuesday, July 17, 12
  16. /app/models/search.rb 1 class Search 2 extend ActiveModel::Naming 3 4 attr_reader

    :query 5 6 def initialize(params = {}) 7 @query = params[:query] 8 end 9 10 def results 11 return [] if missing_query? 12 Product.where("name LIKE ?", "%#{@query}%") 13 end 14 15 def count 16 results.count 17 end 18 19 def missing_query? 20 @query.blank? 21 end 22 end Tuesday, July 17, 12
  17. /app/models/search.rb 1 class Search 2 extend ActiveModel::Naming 3 include ActiveModel::Conversion

    4 5 attr_reader :query 6 7 def initialize(params = {}) 8 @query = params[:query] 9 end 10 11 def persisted? 12 false 13 end 14 15 def results 16 return [] if missing_query? 17 Product.where("name LIKE ?", "%#{@query}%") 18 end 19 20 def count 21 results.count 22 end 23 Tuesday, July 17, 12
  18. /app/views/searches/_form.html.haml 1 = simple_form_for @search, url: search_path, method: :get, html:

    {class: "form-inline well"} do |f| 2 = f.input :query, class: "search-query" 3 .form-actions 4 = f.button :submit Tuesday, July 17, 12
  19. /app/views/searches/_form.html.haml 1 = simple_form_for @search, url: search_path, method: :get, html:

    {class: "form-inline well"} do |f| 2 = f.input :query, label: false do 3 = f.input_field :query 4 = f.button :submit Tuesday, July 17, 12
  20. /app/models/search.rb 1 class Search 2 extend ActiveModel::Naming 3 include ActiveModel::Conversion

    4 include ActiveModel::Validations 5 6 validates :query, presence: {message: "Please fill out a search query"} 7 8 attr_reader :query 9 10 def initialize(params = {}) 11 @query = params[:query] 12 end 13 14 def persisted? 15 false 16 end 17 18 def results 19 return [] if missing_query? 20 Product.where("name LIKE ?", "%#{@query}%") 21 end 22 23 def count 24 results.count 25 end 26 27 def missing_query? 28 @query.blank? 29 end 30 end Tuesday, July 17, 12
  21. /app/controllers/searches_controller.rb 1 class SearchesController < ApplicationController 2 def new 3

    end 4 5 def show 6 @search = Search.new(params).tap(&:valid?) 7 end 8 end Tuesday, July 17, 12
  22. /app/controllers/searches_controller.rb 1 class SearchesController < ApplicationController 2 def new 3

    end 4 5 def show 6 @search = Search.new(params[:search]).tap(&:valid?) 7 end 8 end Tuesday, July 17, 12
  23. /app/models/search.rb 1 class Search 2 extend ActiveModel::Naming 3 include ActiveModel::Conversion

    4 include ActiveModel::Validations 5 6 validates :query, presence: {message: "Please fill out a search query"} 7 8 attr_reader :query 9 10 def initialize(params = {}) 11 @query = params[:query] 12 end 13 14 def persisted? 15 false 16 end 17 18 def results 19 return [] if missing_query? 20 Product.where("name LIKE ?", "%#{@query}%") 21 end 22 23 def count 24 results.count 25 end 26 27 def missing_query? 28 @query.blank? 29 end 30 end Tuesday, July 17, 12
  24. Informal by Josh Susser 1 class Search 2 include Informal::Model

    3 4 validates :query, presence: {message: "Please fill out a search query"} 5 6 attr_accessor :query 7 8 def results 9 return [] if missing_query? 10 Product.where("name LIKE ?", "%#{@query}%") 11 end 12 13 def count 14 results.count 15 end 16 17 def missing_query? 18 @query.blank? 19 end 20 end Tuesday, July 17, 12
  25. Rails 4 1 class Search 2 include ActiveModel::Model 3 4

    validates :query, presence: {message: "Please fill out a search query"} 5 6 attr_accessor :query 7 8 def results 9 return [] if missing_query? 10 Product.where("name LIKE ?", "%#{@query}%") 11 end 12 13 def count 14 results.count 15 end 16 17 def missing_query? 18 @query.blank? 19 end 20 end Tuesday, July 17, 12
  26. /app/controllers/products_controller.rb 1 class ProductsController < ApplicationController 2 def index 3

    @products = Product.scoped 4 end 5 6 def new 7 @product = Product.new 8 end 9 10 def create 11 @product = Product.new(params[:product]) 12 13 if @product.save 14 flash.notice = "Thanks, your new product has been saved!" 15 redirect_to @product 16 else 17 flash.now.alert = "Sorry, unable to save your product, see errors below." 18 render :new 19 end 20 end 21 22 def show 23 @product = Product.find(params[:id]) 24 end 25 26 def edit 27 @product = Product.find(params[:id]) 28 end 29 30 def update 31 @product = Product.find(params[:id]) Tuesday, July 17, 12
  27. 8 end 9 10 def create 11 @product = Product.new(params[:product])

    12 13 if @product.save 14 flash.notice = "Thanks, your new product has been saved!" 15 redirect_to @product 16 else 17 flash.now.alert = "Sorry, unable to save your product, see errors below." 18 render :new 19 end 20 end 21 22 def show 23 @product = Product.find(params[:id]) 24 end 25 26 def edit 27 @product = Product.find(params[:id]) 28 end 29 30 def update 31 @product = Product.find(params[:id]) 32 33 if @product.update_attributes(params[:product]) 34 flash.notice = "Thanks, your new product has been updated!" 35 redirect_to @product 36 else 37 flash.now.alert = "Sorry, unable to save your product, see errors below." 38 render :new 39 end 40 end 41 end Tuesday, July 17, 12
  28. 1 class ProductsController < ApplicationController 2 respond_to :html 3 4

    def index 5 @products = Product.scoped 6 end 7 8 def new 9 @product = Product.new 10 end 11 12 def create 13 @product = Product.create(params[:product]) 14 respond_with @product 15 end 16 17 def show 18 @product = Product.find(params[:id]) 19 end 20 21 def edit 22 @product = Product.find(params[:id]) 23 end 24 25 def update 26 @product = Product.update(params[:id], params[:product]) 27 respond_with @product 28 end 29 end Tuesday, July 17, 12
  29. 1 class ApplicationResponder < ActionController::Responder 2 include Responders::FlashResponder 3 include

    Responders::HttpCacheResponder 4 5 # Uncomment this responder if you want your resources to redirect to the collection 6 # path (index action) instead of the resource path for POST/PUT/DELETE requests. 7 # include Responders::CollectionResponder 8 end /lib/application_responder.rb Tuesday, July 17, 12
  30. /app/controllers/application_controller.rb 1 require "application_responder" 2 3 class ApplicationController < ActionController::Base

    4 self.responder = ApplicationResponder 5 respond_to :html 6 7 protect_from_forgery 8 9 before_filter { @search ||= Search.new } 10 end Tuesday, July 17, 12
  31. /config/locales/responders.en.yml 1 en: 2 flash: 3 actions: 4 create: 5

    notice: '%{resource_name} was successfully created.' 6 update: 7 notice: '%{resource_name} was successfully updated.' 8 destroy: 9 notice: '%{resource_name} was successfully destroyed.' 10 alert: '%{resource_name} could not be destroyed.' Tuesday, July 17, 12
  32. /app/controllers/products_controller.rb 1 class ProductsController < ApplicationController 2 respond_to :html 3

    4 def index 5 @products = Product.scoped 6 end 7 8 def new 9 @product = Product.new 10 end 11 12 def create 13 @product = Product.create(params[:product]) 14 respond_with @product, alert: "Please try again!", notice: "Good work!" 15 end 16 17 def show 18 @product = Product.find(params[:id]) 19 end 20 21 def edit 22 @product = Product.find(params[:id]) 23 end 24 25 def update 26 @product = Product.update(params[:id], params[:product]) 27 respond_with @product 28 end 29 end Tuesday, July 17, 12
  33. /app/controllers/products_controller.rb 1 class ProductsController < ApplicationController 2 respond_to :html 3

    4 def index 5 @products = Product.scoped 6 end 7 8 def new 9 @product = Product.new 10 end 11 12 def create 13 @product = Product.create(params[:product]) 14 respond_with @product, location: root_path 15 end 16 17 def show 18 @product = Product.find(params[:id]) 19 end 20 21 def edit 22 @product = Product.find(params[:id]) 23 end 24 25 def update 26 @product = Product.update(params[:id], params[:product]) 27 respond_with @product, location: root_path 28 end 29 end Tuesday, July 17, 12
  34. /app/controllers/products_controller.rb 1 class ProductsController < ApplicationController 2 respond_to :html, :json,

    :xml 3 4 def index 5 @products = Product.scoped 6 end 7 8 def new 9 @product = Product.new 10 end 11 12 def create 13 @product = Product.create(params[:product]) 14 respond_with @product, location: root_path, only: [:name, :description] 15 end 16 17 def show 18 @product = Product.find(params[:id]) 19 end 20 21 def edit 22 @product = Product.find(params[:id]) 23 end 24 25 def update 26 @product = Product.update(params[:id], params[:product]) 27 respond_with @product, location: root_path, only: [:name, :description] 28 end 29 end Tuesday, July 17, 12
  35. action/lib/action_controller/metal/responder.rb (ActionPack) 186 # This is the common behavior for

    formats associated with browsing, like :html, :iphone and so forth. 187 def navigation_behavior(error) 188 if get? 189 raise error 190 elsif has_errors? && default_action 191 render :action => default_action 192 else 193 redirect_to navigation_location 194 end 195 end 196 197 # This is the common behavior for formats associated with APIs, such as :xml and :json. 198 def api_behavior(error) 199 raise error unless resourceful? 200 201 if get? 202 display resource 203 elsif post? 204 display resource, :status => :created, :location => api_location 205 else 206 head :no_content 207 end 208 end Tuesday, July 17, 12