Nested Resource Forms Tutorial with Padrino or Rails


This guide was written with Padrino and Sequel in mind, but should more or less work for Rails with minimal adaptations. Let's get into it!

The Goal

I have two models: Grammar and GrammarTranslation. A Grammar has many GrammarTranslations.

I want to have a form that lets me create a Grammar and one GrammarTranslation at the same time. Then, I want the grammars/edit page to let me edit the Grammar and edit any of the existing GrammarTranslations or create new ones.

Note: you can see a full example application that uses nested form objects here.

Setup

Schema

This is what the migration schema looks like:

Sequel.migration do
  up do
    create_table :grammars do
      primary_key :id
      String :grammar, null: false
      String :alternatives
      String :verb_type, null: false
      DateTime :created_at
    end

    create_table :grammar_translations do
      primary_key :id
      foreign_key :grammar_id, :grammars
      String :language_code, null: false
      String :meanings, null: false
      String :notes
      DateTime :created_at
    end
  end

  down do
    drop_table :grammar_translations
    drop_table :grammars
  end
end

You'll want to run

padrino generate app <app_name> # etc
padrino generate migration AddGrammarsAndTranslations
# you might have to initialize the table manually if this doesn't work
rake sq:create 
rake sq:migrate

Models

The main model Grammar needs a tag, which differs by which ORM you're using. If it's Sequel, use the nested_attributes tag.[0] For ActiveRecord users, instead use accepts_nested_attributes_for.[1] Both accept the allow_destroy: true option. Padrino also needs the plugin to explicitly be enabled.

# models/grammar.rb

class Grammar < Sequel::Model
  one_to_many :translations, class: :GrammarTranslation

  plugin :nested_attributes
  nested_attributes :translations, destroy: true

  # Replace ActiveRecord method.
  # (I had to add this to get padrino to stop complaining)
  def self.find_by_id(id)
    self[id] rescue nil
  end
end
# models/grammar_translation.rb

class GrammarTranslation < Sequel::Model
  many_to_one :grammar

  # Replace ActiveRecord method.
  def self.find_by_id(id)
    self[id] rescue nil
  end
end

Form Views

We use the new and edit templates to initialize the form and pass it as a variable, f.

// app/views/grammar/new.slim

h2
  New Grammar

= form_for @grammar, '/grammar/create' do |f|
  = partial 'grammar/form', :locals => { :f => f }
// app/views/grammar/edit.slim

h2
  Update Grammar

= form_for :grammar, url(:grammar, :update, id: @grammar.id), method: :put do |f|
  = partial 'grammar/form', :locals => { :f => f }

Since we won't always add a new translation, always marking the fields as required won't work. Instead we can mark fields as required only for existing translations, not new ones.

We can iterate over nested resources with the fields_for :model tag now available to us.

Note1: Don't forget to setup the id hidden field! Otherwise the orm won't realize it's an existing nested resource.

Note2: For the destroy checkbox, you must pass :_delete, Not :_destroy. The padrino docs are incorrect here! I had to do a bit of sleuthing to figure out the correct method to send.

// app/views/grammar/_form.slim

= f.label 'Grammar'
= f.text_field :grammar, required: true

= f.label 'Alternatives (comma separated)'
= f.text_field :alternatives, required: true

= f.label "Verb Type"
= f.select :verb_type, options: ["형용사", "동사", "Both"],
  required: true

h3 Translations

= f.fields_for :translations do |af|
  - unless af.object.new?
    = af.hidden_field :id, value: af.object.id

  = af.label "Language Code"
  - if af.object.new?
    = af.text_field :language_code
  - else
    = af.text_field :language_code, required: true

  = af.label "Meanings (comma separated)"
  - if af.object.new?
    = af.text_field :meanings
  - else
    = af.text_field :meanings, required: true

  = af.label "Notes"
  = af.text_area :notes

  - unless af.object.new?
    = af.label "Destroy"
    = af.check_box :_delete

  hr

= submit_tag pat(:save)
= submit_tag pat(:save_and_continue), :name => 'save_and_continue'
= link_to pat(:cancel), url(:grammar, :index)

Controller and Routes

Lastly we need some basic controller and routing code. index is still simple:

# app/controllers/grammar.rb

get :index do
  @grammars = Grammar.all
  render 'grammar/index'
end

When it comes to new/create, an empty GrammarTranslations object needs to be initialized. In Padrino this is accomplished by initializing the <model>_attributes field, which comes from the nested_attributes tag.

# app/controllers/grammar.rb

get :new do
  @grammar = Grammar.new(translations_attributes: [{}])
  render 'new'
end

post :create do
  @grammar = Grammar.new(params[:grammar])

  if (grammar = @grammar.save)
    flash[:success] = 'Successfully saved grammar & translation.'

    if params[:save_and_continue]
      redirect url_for(:grammar, :grammar, id: grammar.id)
    else
      redirect url(:grammar, :new)
    end
  else
    flash[:error] = "Error saving grammar: " +
      @grammar.errors.map(&:message).join(", ")
    render 'new'
  end
end

For the edit route, a new GrammarTranslation is appended because we want to be able to create new GrammarTranslations from a Grammar's edit page.

# app/controllers/grammar.rb

get :edit, with: :id do
  @grammar = Grammar[params[:id]]
  @grammar.translations << GrammarTranslation.new

  if @grammar
    render 'grammar/edit'
  else
    flash[:warning] = pat(
      :create_error,
      model: 'grammar',
      id: params[:id].to_s
    )
    halt 404
  end
end

For update, since we added a blank GrammarTranslation, it's necessary to filter it out if none of the fields were filled out in the form. Otherwise, every single time you upated a Grammar, a new GrammarTranslation would be created.

# app/controllers/grammar.rb

put :update, with: :id do
  @grammar = Grammar[params[:id]]

  # filter out the new translation
  params[:grammar][:translations_attributes]
    .select!{ |_k, v| v[:language_code].present? == true }

  if @grammar.modified! && @grammar.update(params[:grammar])
    flash[:success] = pat(:update_success, model: 'Grammar', id: params[:id].to_s)
    if params[:save_and_continue]
      redirect(url(:grammar, :new))
    else
      redirect(url(:grammar, :edit, id: @grammar.id))
    end
  else
    flash.now[:error] = pat(:update_error, model: 'grammar')
    render 'accounts/edit'
  end
end

Object Views

Nothing complicated here. The nested resource is available under the main object, so we can use @grammar.translations.

// app/views/grammar/index.slim

- @grammars.each do |g|
  = link_to g.grammar, "/grammar/#{g.id}"
  br
// app/views/grammar/show.slim

h2
  = @grammar.grammar
p
  | Alternatives:
  = @grammar.alternatives
h4 Translations
- @translations.each do |t|
  div
    p
      | Lang:
      =< t.language_code
    p
      | Meaning:
      =< t.meanings

Conclusion

I hope this short guide helped you. You should now be able to create a model and a nested object at the same time!

Have any observations, criticisms, or corrections? Feel free to email me. Have a nice day!