public
Description: a sample rails app with a complex, multi-model form using skinny controllers, transactions and valid XHTML
Homepage:
Clone URL: git://github.com/zilkey/complex_forms_demo_app.git
name age message
file .gitignore Thu May 08 10:52:59 -0700 2008 ignored database.yml and log files [zilkey]
file README.rdoc Tue Feb 10 20:09:07 -0800 2009 Starting to deprecate this [zilkey]
file Rakefile Tue Apr 15 15:20:40 -0700 2008 initial import [zilkey]
directory app/ Thu May 15 00:10:41 -0700 2008 updated error messages [zilkey]
directory config/ Thu May 08 10:56:08 -0700 2008 updated docs [zilkey]
directory db/ Thu May 08 10:30:41 -0700 2008 fixed typos in dataset [zilkey]
directory doc/ Tue Apr 15 15:20:40 -0700 2008 initial import [zilkey]
directory log/ Thu May 15 00:10:41 -0700 2008 updated error messages [zilkey]
directory public/ Wed Apr 16 04:22:59 -0700 2008 ready for sharing [zilkey]
directory script/ Thu May 08 06:10:45 -0700 2008 pluginized methods [zilkey]
directory selenium/ Thu May 08 06:10:45 -0700 2008 pluginized methods [zilkey]
directory spec/ Thu May 08 06:10:45 -0700 2008 pluginized methods [zilkey]
directory stories/ Thu May 08 06:10:45 -0700 2008 pluginized methods [zilkey]
directory test/ Thu May 08 07:42:05 -0700 2008 has_one and habtm work [zilkey]
directory vendor/ Thu May 08 10:51:01 -0700 2008 renamed a few things [zilkey]
README.rdoc

THIS APP IS OLD AND NO LONGER APPLIES =

Please see the built-in nested attribute hashes in rails 2.3:

ryandaigle.com/articles/2009/2/1/what-s-new-in-edge-rails-nested-attributes

Welcome to the Complex Forms Demo App

Installation

  git clone git://github.com/zilkey/complex_forms_demo_app.git
  cd complex_forms_demo_app
  cp config/database.yml.example config/database.yml #=> update database.yml to match your settings
  rake db:create
  rake db:migrate
  rake db:dataset:load_fixtures DATASET=nasa
  script/server

Then go to the projects page and click "Edit". Try every combination of adding, updating and deleting, watch the params and the validations and check your database to see when things get committed.

The Goal

The ultimate goal is to collect a number of real-world examples of complex html forms that involve multiple models with various associations and then figure out what, if anything, can be abstracted to a plugin and generator to help make creating these forms easier.

So far the app just shows how to create a complex form that captures 3 kinds of associations using skinny controllers and fat models. The 3 kinds of associations it demos are:

  • has_many
  • has_many :through (with extra attributes on the join table)
  • has_many :through (with checkboxes to select the associated models)

It also includes a reusable class that adds mark_for_deletion to active record models.

Requirements

A form that manages a model and its related model must:

  • Handle validation of the main model as well as all of the associated models
  • Create fully valid XHTML
  • Have completely atomic saves:
    • If you add a child item and there is a form error the child item needs to be redisplayed to the user, but not saved to the database
    • If you update a child item and there is an error the child item must be correctly displayed to the user but not saved in the database
    • If you remove a child item and there is a form error, the child must be hidden from view on the page but remain in the database
    • On a successful save all new objects must be created, existing objects updated and removed object deleted
  • Run updates in a transaction
  • Handle both means of data entry:
    • As subforms that can be added
    • As a list of checkboxes that can be checked
  • Handle all of the rails helpers such as check boxes, date fields and radio buttons
  • Work the same every time a user clicks refresh or submits

Domain Description for this app

Project is the main object here, and is defined as:

  class Project < ActiveRecord::Base
    has_many :tasks
    has_many :assignments
    has_many :employees, :through => :assignments
    has_many :categorizations
    has_many :categories, :through => :categorizations

    validates_presence_of :name
    validates_associated :tasks, :assignments

    after_update :save_tasks
    after_update :save_assignments
    after_update :save_categorizations
  end

Task belongs to Project and requires a name:

  class Task < ActiveRecord::Base
    belongs_to :project
    validates_presence_of :name
  end

Employee is joined to Project through an Assignment and requires both an employee and a title:

  class Employee < ActiveRecord::Base
    has_many :assignments
    has_many :projects, :through => :assignments
  end

  class Assignment < ActiveRecord::Base
    belongs_to :project
    belongs_to :employee
    validates_presence_of :title
    validates_presence_of :employee_id
  end

The project-employee-assignment part of the domain requires additional fields on the join table.

Category is joined to Project through a Categorization:

  class Category < ActiveRecord::Base
    has_many :categorizations
    has_many :projects, :through => :categorizations
    validates_presence_of :name
  end

  class Categorization < ActiveRecord::Base
    belongs_to :project
    belongs_to :category
  end

The project-categorization-category part of the domain is very similar to a has_and_belongs_to_many relationship. Handling validation

Instead of using the rails default error_messages_for helper, just use a partial.

Creating fully valid XHMTL

To output valid XHTML you must:

    * Set an index on every child field
    * Have the javascript that dynamically adds fields set the index correctly as well

This requires a complex interaction between the partial and the javascript code. The necessary parts are spread across multiple files:

public/javascripts/application.js contains a generic class that you can use for any subform, and add multiple subforms to a page:

        var Subform = Class.create({
          lineIndex: 1,
          parentElement: "",

          // rawHTML contains the html to add using the "add" link
          // lineIndex should be the length of the original array
          // parentElement is the id of the div that the subforms attach to
          initialize: function(rawHTML, lineIndex, parentElement) {
            this.rawHTML        = rawHTML;
            this.lineIndex      = lineIndex;
            this.parentElement  = parentElement;
          },

          // parses the rawHTML and replaces all instances of the word
          // INDEX with the line index
          // So the HTML on that rails outputs will be INDEX, but when this
          // is added to the dom it has the correct id
          parsedHTML: function() {
            return this.rawHTML.replace(/INDEX/g, this.lineIndex++);
          },

          // handles the inserting of the child form
          add: function() {
            new Insertion.Bottom($(this.parentElement), this.parsedHTML());
          }
        });

Each child form’s partial must instantiate the javascript - I use a content_for block and inject that code into the head of the document

            <%- content_for :head do -%>
              <script type="text/javascript" charset="utf-8">
               //<![CDATA[
                 taskForm = new Subform('<%= escape_javascript(render(:partial => "task", :object => Task.new, :locals => {:index => "INDEX"})) %>',<%= @project.tasks.length %>,'tasks');
                 assignmentForm = new Subform('<%= escape_javascript(render(:partial => "assignment", :object => Assignment.new, :locals => {:index => "INDEX"})) %>',<%= @project.assignments.length %>,'assignments');
               //]]>
              </script>
            <%- end -%>

If you are using content_for like I am, then the app/views/application.html.erb file must have a corresponding yield statement

Rails makes setting the index properly a breeze with it’s :index option and the partial_counter local variable in partials that are fed collections.

The index does not work with radio buttons (this may be fixed in edge) and the standard rails labels output invalid XHTML, so those have to be coded manually. Marking objects for deletion

This means being able to mark an object for deletion before actually deleting it. I’ve accomplished this in a site-wide way with config/initializers/marked_for_deletion.rb Performing the save in a transaction

Rails doesn’t include the after_update callbacks in it’s default transactions, so you have to do that yourself. It has 3 parts:

A method in the project model that performs the save in a transaction and re-raises the error:

        class << self
          def save(project,project_attributes = nil)
            project.attributes = project_attributes unless project_attributes.nil?
            updated = false
            Project.transaction do
              updated = project.save
            end
            updated
          rescue Exception => e
            throw e
          end
        end

Make sure the child objects raise errors when they are saved:

        def save_assignments
          assignments.each do |assignment|
            assignment.save!
          end
        end

Update the controller code to call the correct method:

        def create
          @project = Project.new(params[:project])
          respond_to do |format|
            if Project.save(@project)
              ...
            else
              ...
            end
          end
        end

        def update
          @project = Project.find(params[:id])
          params[:project][:existing_task_attributes] ||= {}

          respond_to do |format|
            if Project.save(@project,params[:project])
              ...
            else
              ...
            end
          end
        end