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
`GrammarTranslation`s 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:
class Foo
end
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.\{\{cite(n=0)}}
For ActiveRecord users, instead use
accepts_nested_attributes_for
.\{\{cite(n=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 GrammarTranslation`s 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 GrammarTranslation`s 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!