Rails 7 Dynamic Nested Forms with hotwire/turbo frames? - ruby-on-rails

I'm very new to the rails. I've started right from rails7 so there is still very little information regarding my problem.
Here is what i have:
app/models/cocktail.rb
class Cocktail < ApplicationRecord
has_many :cocktail_ingredients, dependent: :destroy
has_many :ingredients, through: :cocktail_ingredients
accepts_nested_attributes_for :cocktail_ingredients
end
app/models/ingredient.rb
class Ingredient < ApplicationRecord
has_many :cocktail_ingredients
has_many :cocktails, :through => :cocktail_ingredients
end
app/models/cocktail_ingredient.rb
class CocktailIngredient < ApplicationRecord
belongs_to :cocktail
belongs_to :ingredient
end
app/controllers/cocktails_controller.rb
def new
#cocktail = Cocktail.new
#cocktail.cocktail_ingredients.build
#cocktail.ingredients.build
end
def create
#cocktail = Cocktail.new(cocktail_params)
respond_to do |format|
if #cocktail.save
format.html { redirect_to cocktail_url(#cocktail), notice: "Cocktail was successfully created." }
format.json { render :show, status: :created, location: #cocktail }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: #cocktail.errors, status: :unprocessable_entity }
end
end
end
def cocktail_params
params.require(:cocktail).permit(:name, :recipe, cocktail_ingredients_attributes: [:quantity, ingredient_id: []])
end
...
db/seeds.rb
Ingredient.create([ {name: "rum"}, {name: "gin"} ,{name: "coke"}])
relevant tables from schema
create_table "cocktail_ingredients", force: :cascade do |t|
t.float "quantity"
t.bigint "ingredient_id", null: false
t.bigint "cocktail_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["cocktail_id"], name: "index_cocktail_ingredients_on_cocktail_id"
t.index ["ingredient_id"], name: "index_cocktail_ingredients_on_ingredient_id"
end
create_table "cocktails", force: :cascade do |t|
t.string "name"
t.text "recipe"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "ingredients", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
...
add_foreign_key "cocktail_ingredients", "cocktails"
add_foreign_key "cocktail_ingredients", "ingredients"
app/views/cocktails/_form.html.erb
<%= form_for #cocktail do |form| %>
<% if cocktail.errors.any? %>
<% cocktail.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
<% end %>
<div>
<%= form.label :name, style: "display: block" %>
<%= form.text_field :name, value: "aa"%>
</div>
<div>
<%= form.label :recipe, style: "display: block" %>
<%= form.text_area :recipe, value: "nn" %>
</div>
<%= form.simple_fields_for :cocktail_ingredients do |ci| %>
<%= ci.collection_check_boxes(:ingredient_id, Ingredient.all, :id, :name) %>
<%= ci.text_field :quantity, value: "1"%>
<% end %>
<div>
<%= form.submit %>
</div>
<% end %>
Current error:
Cocktail ingredients ingredient must exist
What I'm trying to achieve:
I want a partial where I can pick one of the 3 ingredients and enter its quantity. There should be added/remove buttons to add/remove ingredients.
What do i use? Turbo Frames? Hotwire? How do i do that?
Im still super confused with everything in rails so would really appreciate in-depth answer.

1. Controller & Form - set it up as if you have no javascript,
2. Turbo Frame - then wrap it in a frame.
3. TLDR - if you don't need a long explanation.
4. Turbo Stream - you can skip Turbo Frame and do this instead.
5. Bonus - make a custom form field
6. Frame + Stream - i didn't know you can do that
Controller & Form
To start, we need a form that can be submitted and then re-rendered without creating a new cocktail.
Using accepts_nested_attributes_for does change the behavior of the form, which is not obvious and it'll drive you insane when you don't understand it.
First, lets fix the form. I'll use the default rails form builder, but it is the same setup with simple_form as well:
<!-- form_for or form_tag: https://guides.rubyonrails.org/form_helpers.html#using-form-tag-and-form-for
form_with does it all -->
<%= form_with model: cocktail do |f| %>
<%= (errors = safe_join(cocktail.errors.map(&:full_message).map(&tag.method(:li))).presence) ? tag.div(tag.ul(errors), class: "prose text-red-500") : "" %>
<%= f.text_field :name, placeholder: "Name" %>
<%= f.text_area :recipe, placeholder: "Recipe" %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
<div class="flex gap-2">
<div class="text-sm text-right"> <%= ff.object.id || "New ingredient" %> </div>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
</div>
<% end %>
<!-- NOTE: Form has to be submitted, but with a different button,
that way we can add different functionality in the controller
see `CocktailsController#create` -->
<%= f.submit "Add ingredient", name: :add_ingredient %>
<div class="flex justify-end p-4 border-t bg-gray-50"> <%= f.submit %> </div>
<% end %>
<style type="text/css" media="screen">
input[type], textarea, select { display: block; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; width: 100%; border: 1px solid rgba(0,0,0,0.15); border-radius: .375rem; box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px }
input[type="checkbox"] { width: auto; padding: 0.75rem; }
input[type="submit"] { width: auto; cursor: pointer; color: white; background-color: rgb(37, 99, 235); font-weight: 500; }
</style>
https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for
We need a single ingredient per cocktail_ingredient as indicated by belongs_to :ingredient. Single select is an obvious choice; collection_radio_buttons also applicable.
fields_for helper will output a hidden field with an id of cocktail_ingredient if that particular record has been persisted in the database. That's how rails knows to update existing records (with id) and create new records (without id).
Because we're using accepts_nested_attributes_for, fields_for appends "_attributes" to the input name. In other words, if you have this in your model:
accepts_nested_attributes_for :cocktail_ingredients
that means
f.fields_for :cocktail_ingredients
will prefix input names with cocktail[cocktail_ingredients_attributes].
(WARN: source code incoming) The reason is because accepts_nested_attributes_for has defined a new method cocktail_ingredients_attributes=(params) in Cocktail model, which does a lot of work for you. This is where nested parameters are handled, CocktailIngredient objects are created and assigned to corresponding cocktail_ingredients association and also marked to be destroyed if _destroy parameter is present and because autosave is set to true, you get automatic validations. This is just an FYI, in case you want to define your own cocktail_ingredients_attributes= method and you can and f.fields_for will pick it up automatically.
In CocktailsController, new and create actions need a tiny update:
# GET /cocktails/new
def new
#cocktail = Cocktail.new
# NOTE: Because we're using `accepts_nested_attributes_for`, nested fields
# are tied to the nested model now, a new object has to be added to
# `cocktail_ingredients` association, otherwise `fields_for` will not
# render anything; (zero nested objects = zero nested fields).
#cocktail.cocktail_ingredients.build
end
# POST /cocktails
def create
#cocktail = Cocktail.new(cocktail_params)
respond_to do |format|
# NOTE: Catch when form is submitted by "add_ingredient" button;
# `params` will have { add_ingredient: "Add ingredient" }.
if params[:add_ingredient]
# NOTE: Build another cocktail_ingredient to be rendered by
# `fields_for` helper.
#cocktail.cocktail_ingredients.build
# NOTE: Rails 7 submits as TURBO_STREAM format. It expects a form to
# redirect when valid, so we have to use some kind of invalid
# status. (this is temporary, for educational purposes only).
# https://stackoverflow.com/a/71762032/207090
# NOTE: Render the form again. TADA! You're done.
format.html { render :new, status: :unprocessable_entity }
else
if #cocktail.save
format.html { redirect_to cocktail_url(#cocktail), notice: "Cocktail was successfully created." }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
end
In Cocktail model allow the use of _destroy form field to delete record when saving:
accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true
That's it, the form can be submitted to create a cocktail or submitted to add another ingredient.
Turbo Frame
Right now, when new ingredient is added the entire page is re-rendered by turbo. To make the form a little more dynamic, we can add turbo-frame tag to only update ingredients part of the form:
<!-- doesn't matter how you get the "id" attribute
it just has to be unique and repeatable across page reloads -->
<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
<%= f.fields_for :cocktail_ingredients do |ff| %>
<div class="flex gap-2">
<div class="text-sm text-right"> <%= ff.object&.id || "New ingredient" %> </div>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
</div>
<% end %>
</turbo-frame>
Change "Add ingredient" button to let turbo know that we only want the frame part of the submitted page. A regular link, doesn't need this, we would just put that link inside of the frame tag, but an input button needs extra attention.
<!-- same `id` as <turbo-frame>; repeatable, remember. -->
<%= f.submit "Add ingredient",
data: { turbo_frame: f.field_id(:ingredients)},
name: "add_ingredient" %>
Turbo frame id has to match the button's data-turbo-frame attribute:
<turbo-frame id="has_to_match">
<input data-turbo-frame="has_to_match" ...>
Now, when clicking "Add ingredient" button it still goes to the same controller, it still renders the entire page on the server, but instead of re-rendering the entire page (frame #1), only the content inside the turbo-frame is updated (frame #2). Which means, page scroll stays the same, form state outside of turbo-frame tag is unchanged. For all intents and purposes this is now a dynamic form.
Possible improvement could be to stop messing with create action and add ingredients through a different controller action, like add_ingredient:
# config/routes.rb
resources :cocktails do
post :add_ingredient, on: :collection
end
<%= f.submit "Add ingredient",
formmethod: "post",
formaction: add_ingredient_cocktails_path(id: f.object),
data: { turbo_frame: f.field_id(:ingredients)} %>
Add add_ingredient action to CocktailsController:
def add_ingredient
#cocktail = Cocktail.new(cocktail_params.merge({id: params[:id]}))
#cocktail.cocktail_ingredients.build # add another ingredient
# NOTE: Even though we are submitting a form, there is no
# need for "status: :unprocessable_entity".
# Turbo is not expecting a full page response that has
# to be compatible with the browser behavior
# (that's why all the status shenanigans; 422, 303)
# it is expecting to find the <turbo-frame> with `id`
# matching `data-turbo-frame` from the button we clicked.
render :new
end
create action can be reverted back to default now.
You could also reuse new action instead of adding add_ingredient:
resources :cocktails do
post :new, on: :new # add POST /cocktails/new
end
Full controller set up:
https://stackoverflow.com/a/72890584/207090
Then adjust the form to post to new instead of add_ingredient.
TLDR - Put it all together
I think this is as simple as I can make it. Here is the short version (about 10ish extra lines of code to add dynamic fields, and no javascript)
# config/routes.rb
resources :cocktails do
post :add_ingredient, on: :collection
end
# app/controllers/cocktails_controller.rb
# the other actions are the usual default scaffold
def add_ingredient
#cocktail = Cocktail.new(cocktail_params.merge({id: params[:id]}))
#cocktail.cocktail_ingredients.build
render :new
end
# app/views/cocktails/new.html.erb
<%= form_with model: cocktail do |f| %>
<%= (errors = safe_join(cocktail.errors.map(&:full_message).map(&tag.method(:li))).presence) ? tag.div(tag.ul(errors), class: "prose text-red-500") : "" %>
<%= f.text_field :name, placeholder: "Name" %>
<%= f.text_area :recipe, placeholder: "Recipe" %>
<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
<%= f.fields_for :cocktail_ingredients do |ff| %>
<div class="flex gap-2">
<div class="text-sm text-right"> <%= ff.object&.id || "New ingredient" %> </div>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
</div>
<% end %>
</turbo-frame>
<%= f.button "Add ingredient", formmethod: "post", formaction: add_ingredient_cocktails_path(id: f.object), data: { turbo_frame: f.field_id(:ingredients)} %>
<div class="flex justify-end p-4 border-t bg-gray-50"> <%= f.submit %> </div>
<% end %>
# app/models/*
class Cocktail < ApplicationRecord
has_many :cocktail_ingredients, dependent: :destroy
has_many :ingredients, through: :cocktail_ingredients
accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true
end
class Ingredient < ApplicationRecord
has_many :cocktail_ingredients
has_many :cocktails, through: :cocktail_ingredients
end
class CocktailIngredient < ApplicationRecord
belongs_to :cocktail
belongs_to :ingredient
end
Turbo Stream
Turbo stream is as dynamic as we can get with this form without touching any javascript. The form has to be changed to let us render a single cocktail ingredient:
# NOTE: remove `f.submit "Add ingredient"` button
# and <turbo-frame> with nested fields
# NOTE: this `id` will be the target of the turbo stream
<%= tag.div id: :cocktail_ingredients do %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
# put nested fields into a partial
<%= render "ingredient_fields", f: ff %>
<% end %>
<% end %>
# NOTE: `f.submit` is no longer needed, because there is no need to
# submit the form anymore just to add an ingredient.
<%= link_to "Add ingredient",
add_ingredient_cocktails_path,
class: "text-blue-500 hover:underline",
data: { turbo_method: :post } %>
# ^
# NOTE: still has to be a POST request
<!-- app/views/cocktails/_ingredient_fields.html.erb -->
<div class="flex gap-2">
<div class="text-sm text-right"> <%= f.object&.id || "New" %> </div>
<%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= f.text_field :quantity, placeholder: "Qty" %>
<%= f.check_box :_destroy, title: "Check to delete ingredient" %>
</div>
Update add_ingredient action to render a turbo_stream response:
# it should be in your routes, see previous section above.
def add_ingredient
# NOTE: get a form builder but skip the <form> tag, `form_with` would work
# here too. however, we'd have to use `fields` if we were in a template.
helpers.fields model: Cocktail.new do |f|
# NOTE: instead of letting `fields_for` helper loop through `cocktail_ingredients`
# we can pass a new object explicitly.
# v
f.fields_for :cocktail_ingredients, CocktailIngredient.new, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
# ^ ^ Time.now.to_f also works
# NOTE: one caveat is that we need a unique key when we render this
# partial otherwise it would always be 0, which would override
# previous inputs. just look at the generated input `name` attribute:
# cocktail[cocktail_ingredients_attributes][0][ingredient_id]
# ^
# we need a different number for each set of fields
render turbo_stream: turbo_stream.append(
"cocktail_ingredients",
partial: "ingredient_fields",
locals: { f: ff }
)
end
end
end
# NOTE: `fields_for` does output an `id` field for persisted records
# which would be outside of the rendered html and turbo_stream.
# not an issue here since we only render new records and there is no `id`.
Bonus - Custom Form Builder
Making a custom field helper simplifies the task down to one line:
# config/routes.rb
# NOTE: I'm not using `:id` for anything, but just in case you need it.
post "/fields/:model(/:id)/build/:association(/:partial)", to: "fields#build", as: :build_fields
# app/controllers/fields_controller.rb
class FieldsController < ApplicationController
# POST /fields/:model(/:id)/build/:association(/:partial)
def build
resource_class = params[:model].classify.constantize # => Cocktail
association_class = resource_class.reflect_on_association(params[:association]).klass # => CocktailIngredient
fields_partial_path = params[:partial] || "#{association_class.model_name.collection}/fields" # => "cocktail_ingredients/fields"
render locals: { resource_class:, association_class:, fields_partial_path: }
end
end
# app/views/fields/build.turbo_stream.erb
<%=
fields model: resource_class.new do |f|
turbo_stream.append f.field_id(params[:association]) do
f.fields_for params[:association], association_class.new, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
render fields_partial_path, f: ff
end
end
end
%>
# app/models/dynamic_form_builder.rb
class DynamicFormBuilder < ActionView::Helpers::FormBuilder
def dynamic_fields_for association, name = nil, partial: nil, path: nil
association_class = object.class.reflect_on_association(association).klass
partial ||= "#{association_class.model_name.collection}/fields"
name ||= "Add #{association_class.model_name.human.downcase}"
path ||= #template.build_fields_path(object.model_name.name, association:, partial:)
#template.tag.div id: field_id(association) do
fields_for association do |ff|
#template.render(partial, f: ff)
end
end.concat(
#template.link_to(name, path, class: "text-blue-500 hover:underline", data: { turbo_method: :post })
)
end
end
This new helper requires "#{association_name}/_fields" partial:
# app/views/cocktail_ingredients/_fields.html.erb
<%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= f.text_field :quantity, placeholder: "Qty" %>
<%= f.check_box :_destroy, title: "Check to delete ingredient" %>
Override the default form builder and now you should have dynamic_fields_for input:
# app/views/cocktails/_form.html.erb
<%= form_with model: cocktail, builder: DynamicFormBuilder do |f| %>
<%= f.dynamic_fields_for :cocktail_ingredients %>
<%# f.dynamic_fields_for :other_things, "Add a thing", partial: "override/partial/path" %>
# or without dynamic form builder, just using the new controller
<%= tag.div id: f.field_id(:cocktail_ingredients) %>
<%= link_to "Add ingredient", build_fields_path(:cocktail, :cocktail_ingredients), class: "text-blue-500 hover:underline", data: { turbo_method: :post } %>
<% end %>
Frame + Stream
You can render turbo_stream tag on the current page and it will work. Pretty useless to render something just to move it somewhere else on the same page. But, if we put it inside a turbo_frame, we can move things outside of the frame for safekeeping while getting updates inside the turbo_frame.
# app/controllers/cocktails_controller.rb
# GET /cocktails/new
def new
#cocktail = Cocktail.new
#cocktail.cocktail_ingredients.build
# turbo_frame_request? # => true
# request.headers["Turbo-Frame"] # => "add_ingredient"
# skip `new.html.erb` rendering if you want
render ("_form" if turbo_frame_request?), locals: { cocktail: #cocktail }
end
# app/views/cocktails/_form.html.erb
<%= tag.div id: :ingredients %>
<%= turbo_frame_tag :add_ingredient do %>
# NOTE: render all ingredients and move them out of the frame.
<%= turbo_stream.append :ingredients do %>
# NOTE: just need to take extra care of that `:child_index` and pass it as a proc, so it would be different for each object
<%= f.fields_for :cocktail_ingredients, child_index: -> { Process.clock_gettime(Process::CLOCK_REALTIME, :microsecond) } do |ff| %>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
<% end %>
<% end %>
# NOTE: this link is inside `turbo_frame`, so if we navigate to `new` action
# we get a single set of new ingredient fields and `turbo_stream`
# moves them out again.
<%= link_to "Add ingredient", new_cocktail_path, class: "text-blue-500 hover:underline" %>
<% end %>
No extra actions, controllers, routes, partials or responses. Just a GET request with Html response, and only a single set of fields gets appended. I didn't see this explained anywhere, sure hope that's the expected behavior.

Related

Rails error messages not appearing on website but showing in Chrome DevTools Network Preview

I'm trying to show error messages when creating a new instance of a model in my Rails app when a field doesn't pass validation. For some reason, the errors never actually show up on the website next to the fields like they're supposed to. However, the errors appear in the 'Preview' section of the Network tab of Chrome DevTools. So the errors are generating properly. In the terminal it says that new.html.erb is rendered but I don't think it actually does? Any help would be greatly appreciated - I haven't found much about this online. I'm using Tailwind CSS for styling the front end if that's helpful.
Here's my code:
occasion.rb
class Occasion < ApplicationRecord
belongs_to :user
has_many :registries
has_many :charities, :through => :registries
validates :occasion_name, presence: true
validates :occasion_date, presence: true
validates :url, presence: true, format: { without: /\s/ }, uniqueness: true
validates_uniqueness_of :user_id
end
occasions_controller.rb
class OccasionsController < ApplicationController
load_and_authorize_resource only: [:new, :create, :edit, :update, :destroy]
def index
#occasions = Occasion.all
end
def show
#occasion = Occasion.where(url: params[:url]).first
end
def new
#occasion = Occasion.new
end
def create
#occasion = Occasion.new(occasion_params)
#occasion.user = current_user
if #occasion.save
respond_to do |format|
format.html { redirect_to new_registry_path }
format.js { render :js => "window.location='#{ new_registry_path }'" }
end
else
render :new
end
end
def edit
#occasion = Occasion.find(params[:id])
end
def update
#occasion = Occasion.find(params[:id])
if #occasion.update(occasion_params)
redirect_to #occasion
return
else
render :edit
end
end
def destroy
#occasion = Occasion.find(params[:id])
#occasion.destroy
redirect_to occasions_path
end
private
def occasion_params
params.require(:occasion).permit(:user_id, :occasion_name, :occasion_date, :url)
end
# user authentication is not required for show
skip_before_action :authenticate_user!, :only => [:show]
end
new.html.erb
<%= form_with model: #occasion do |form| %>
<div class="text-center">
<%= form.label :occasion_name, "Occasion Name", class: "text-red-400 font-semibold px-8" %><br>
<%= form.text_field :occasion_name, class: "rounded w-2/5" %>
<% #occasion.errors.full_messages_for(:occasion_name).each do |message| %>
<div><%= message %></div>
<% end %>
</div>
<div class="text-center py-2">
<%= form.label :occasion_date, "Occasion Date", class: "text-red-400 font-semibold px-8" %><br>
<%= form.date_field :occasion_date, type: "date", class: "rounded" %>
<% #occasion.errors.full_messages_for(:occasion_date).each do |message| %>
<div><%= message %></div>
<% end %>
</div>
<div class="text-center py-2">
<%= form.label :url, 'URL', class: "text-red-400 font-semibold px-8" %><br>
<%= form.text_field :url, class: "rounded" %>
<% #occasion.errors.full_messages_for(:url).each do |message| %>
<div><%= message %></div>
<% end %>
<em><div class="text-sm">domainname.com/yourURLhere</div></em>
</div>
<div class="text-center py-2">
<%= form.submit occasion.persisted? ? 'Update' : 'Save', class: "rounded-full bg-red-400 text-white px-3" %>
</div>
<% end %>
From the provided information, it looks like the form gets submitted as an AJAX request. Since you're not passing local: false to the form_with call, there must be a configuration set to use AJAX form submissions by default.
From the docs,
:local - By default form submits via typical HTTP requests. Enable remote and unobtrusive XHRs submits with local: false. Remote forms may be enabled by default by setting config.action_view.form_with_generates_remote_forms = true.
Pass local: true to submit the request via a normal HTTP request.
<%= form_with model: #occasion, local: true do |form| %>
<%#= ... %>
<% end %>

Nested Attributes Child model don't save

I'm having a problem in the model saving with nested attributes.
In the app, there's a Customer, that have 1..n Contacts witch in turn have 1..n Telephones.
I've searched a lot before asking here, and decided to make it save only the Contact first. Well, at first the Customer is stored, but Contact is not. From what I read there's no need to repeat the ... contacts.build from new function in the create, and that the line "#customer = Customer.new(customer_params)" would create and store them both.
Why it's not working? (That's the first question.)
After some modifications and debugging, I found that when I set a second line building Contact (...contacts.build(customer_params[:contacts_attributes])) it's not saved because of an error of 'unknown attribute'. That's because between the hash :contacts_attribute and the content of it, it's added another hash, called ':0' (?). The structure of the hash that comes from the form is this :
":contacts_attribute[:0[:name, :department, :email]]"
I imagine that this hash :0 is for adding more than one Contact instance, that will come in hashes :1, :2 etc.
There's a way to store the Contact instance by getting this :0 hash? (How do I access this hash? Is it "... :contacts_attribute[0]"?)
Below is the relevant code.
Thanks for the attention!
customer.rb
class Customer < ActiveRecord::Base
...
has_many :contacts
accepts_nested_attributes_for :contacts, reject_if: lambda {|attributes| attributes['kind'].blank?}
...
def change_by(user_id)
update_attributes(changed_by: user_id, deleted_at: Time.now, updated_at: Time.now)
end
def delete(user_id)
update_attributes(status: false, changed_by: user_id, deleted_at: Time.now, updated_at: Time.now)
end
private
...
end
customers_controller.rb
class CustomersController < ApplicationController
def new
#customer = Customer.new
#customer.contacts.new
end
def create
user_id = session[:user_id]
#customer = Customer.new(customer_params)
if #customer.save
#customer.change_by(user_id)
flash[:success] = "Cliente cadastrado com sucesso!"
redirect_to customers_url
else
render 'new'
end
end
private
def customer_params
params.require(:customer).permit(:razao_social, :nome, :CPF_CNPJ,
:adress_id, :email_nota, :transporter_id, :observacao,
contacts_attributes: [:nome, :setor, :email])
end
Form
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for #customer do |f| %>
<%= f.label "Dados Básicos" %>
<div class="well">
<%= f.label :razao_social, "Razão Social" %>
<%= f.text_field :razao_social %>
<%= f.label :nome, "Nome" %>
<%= f.text_field :nome %>
<%= f.label :CPF_CNPJ, "CPF/CNPJ" %>
<%= f.text_field :CPF_CNPJ %>
<%= f.label :email_nota, "Email para nota" %>
<%= f.email_field :email_nota %>
<%= f.label :observacao, "Observações" %>
<%= f.text_area :observacao %>
</div>
<%= f.fields_for :contacts do |k| %>
<%= k.label "Contato" %>
<div class="well">
<%= k.label :nome, "Nome" %>
<%= k.text_field :nome %>
<%= k.label :setor, "Setor" %>
<%= k.text_field :setor %>
<%= k.label :email, "Email" %>
<%= k.email_field :email %>
</div>
<% end %>
<%= f.submit "Cadastrar Cliente", class: "btn btn-primary" %>
<% end %>
</div>
reject_if: lambda {|attributes| attributes['kind'].blank?}
No sign of :kind in your form or your customer_params
This might have something to do with it.
Other than that, if you need an add/remove relationship for contacts, check out the cocoon gem. If you only need one, then build that into your fields for:
<%= f.fields_for :contacts, #customer.contacts.first || #customer.contacts.build do |k| %>
The form will then be specific to a single instance of contact.
There's a way to store the Contact instance by getting this :0 hash?
(How do I access this hash? Is it "... :contacts_attribute[0]"?)
You don't need to access it, that's what the accepts_nested_attributes is for. The rest of your code looks ok so sort out the rejection issue at the top and come back if there are still problems, and post the log output - specifically the params hash for the request!

Rails 4 - create associates records but not parent record in nested forms

In Rails 4 nested form - I want to create new records for licenses(company has_many licenses) when your company is already existing. How do I achieve it?
Model - Company.rb
class Company < ActiveRecord::Base
has_many :licenses
end
Model License.rb
class License < ActiveRecord::Base
belongs_to :company
end
license_controller.rb
def new
#company = Company.new
Role.user_role_names.each { |role|
#company.licenses.build(license_type: role)
}
#licenses = #company.licenses
end
licenses/views/new.html.erb
<%= form_for #company, url: licenses_path, method: "post" do |f| %>
<%= f.select :id, Company.get_all_companies, :include_blank =>
"Select Company", required: true %><br/><br/>
<% #licenses.each do |license|%>
<%= f.fields_for :licenses, license do |lic| %>
<div style="border:1px solid; border-radius:10px;width:300px">
<%= lic.hidden_field :license_type %>
<%= lic.label :total_licenses, license.license_type.split("_").join(" ").capitalize + " License number"%><br/>
<%= lic.text_field :total_licenses %><br/><br/>
<%= lic.label :duration, 'Duration Validity' %><br/>
<%= lic.text_field :duration %>days<br/><br/>
</div>
<% end %>
<% end %>
<br/><%= f.submit 'Assign'%>
<%= link_to :Back, users_super_admin_index_path %>
<% end %>
If you can help me to know how to create licenses record for the existing company that is selected and company not get created?
#company = Company.new should be written in companies_controller and not in license_controller.
When the company is selected from the select box set its value in hidden variable, through js, on select of company from dropdown. So when the form is submitted it has #company, so statement: #licenses = #company.licenses will work fine, as it has #company value existing.

Using jQuery Tokeninput within a nested form partial

I'm using jQuery Tokeninput as shown in this Railscast. I'd like to combine this functionality in a nested form but get the error
undefined method `artists' for #<SimpleForm::FormBuilder:0x007febe0883988>
For some reason its not recognizing the track parameter in my form builder which is stopping me to get a hold of albums I have on record.
<div class="input">
<%= f.input :artist_tokens, label: 'Featured Artists', input_html: {"data-pre" => f.artists.map(&:attributes).to_json} %>
</div>
Keep in mind this works in my track form but just not in my album form since its nested. What should I do to get this to work?
class ArtistsController < ApplicationController
def index
#artists = Artist.order(:name)
respond_to do |format|
format.html
format.json {render json: #artists.tokens(params[:q])}
end
end
end
Models
class Artist < ActiveRecord::Base
has_many :album_ownerships
has_many :albums, through: :album_ownerships
has_many :featured_artists
has_many :tracks, through: :featured_artists
def self.tokens(query)
artists = where("name like ?", "%#{query}%")
if artists.empty?
[{id: "<<<#{query}>>>", name: "Add New Artist: \"#{query}\""}]
else
artists
end
end
def self.ids_from_tokens(tokens)
tokens.gsub!(/<<<(.+?)>>>/) {create!(name: $1).id}
tokens.split(',')
end
end
class Albums < ActiveRecord::Base
attr_reader :artist_tokens
accepts_nested_attributes_for :tracks, :reject_if => :all_blank, :allow_destroy => true
has_many :albums_ownerships
has_many :artists, through: :albums_ownerships
def artist_tokens=(ids)
self.artist_ids = Artist.ids_from_tokens(ids)
end
end
class Track < ActiveRecord::Base
attr_reader :artist_tokens
belongs_to :album
has_many :featured_artists
has_many :artists, through: :featured_artists
def artist_tokens=(ids)
self.artist_ids = Artist.ids_from_tokens(ids)
end
end
class AlbumOwnership < ActiveRecord::Base
belongs_to :artist
belongs_to :album
end
class FeaturedArtist < ActiveRecord::Base
belongs_to :artist
belongs_to :track
end
Album Form
<%= simple_form_for(#album) do |f| %>
<div class="field">
<%= f.label :name %><br>
<%= f.text_field :name %>
</div>
<h1>Tracks</h1>
<%= f.simple_fields_for :tracks do |track| %>
<%= render 'track_fields', :f => track %>
<% end %>
<div id='links'>
<%= link_to_add_association 'Add Field', f, :tracks %>
</div>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
Track Partial
<div class="field">
<%= f.input :name %><br>
</div>
<div class="input">
<%= f.input :artist_tokens, label: 'Featured Artists', input_html: {"data-pre" => f.artists.map(&:attributes).to_json} %>
</div>
JS
$(function() {
$('#track_artist_tokens').tokenInput('/artists.json', {
prePopulate: $("#track_artist_tokens").data("pre"),
theme: 'facebook',
resultsLimit: 5
});
});
UPDATE
As mentioned by nathanvda, I needed to use f.object in order for the artists to be recognized. So in my partial I now have:
<%= f.input :artist_tokens, label: 'Featured Artists', input_html: {"data-pre" => f.object.artists.map(&:attributes).to_json, class: 'test_class'} %>
In my js I also needed to call the token input method before/after insertion:
$(function() {
$('.test_class').tokenInput('/artists.json', {
prePopulate: $(".test_class").data("pre"),
theme: 'facebook',
resultsLimit: 5
});
$('form').bind('cocoon:after-insert', function(e, inserted_item) {
inserted_item.find('.test_class').tokenInput('/artists.json', {
prePopulate: $(".test_class").data("pre"),
theme: 'facebook',
resultsLimit: 5
});
});
});
The only remaining issue I have is the the tracks_attributes not being saved. I ran into an issue similar to this in the past in this post but the two main difference is the second level of nesting involved and that I used a join table within my nested form. I'm not entirely sure if or how any of that code would translate over but I believe this is most likely problem. As far as the permitted params of my albums_controller here's what they looks like.
def album_params
params.require(:album).permit(:name, :artist_tokens, tracks_attributes: [:id, :name, :_destroy, :track_id])
end
If you need to acces the object of a form, you need to write f.object, so I think you should just write f.object.artists.
Your "data-pre" => f.artists... is calling the artists method on f which is the form builder and doesn't have an #artists method.
Try this instead:
In the album form, change the render partial line to this:
<%= render 'track_fields', :f => track, :artists => #artists %>
And then use this in the track partial:
<%= f.input :artist_tokens, label: 'Featured Artists', input_html: {"data-pre" => artists.map(&:attributes).to_json} %>
UPDATED
Let's back up a step. From your code it looks like you need to populate a data-pre attribute with the attributes of a collection of artists.
The problem is you're calling f.artists where f is the FormBuilder and doesn't know anything about artists. This is why you're getting undefined method 'artists'...
The solution is make a collection of artists available to the view and its partials. One way to do this:
class AlbumsController < ApplicationController
...
def new
#album = Album.new
#artists = Artist.order(:name) # or some other subset of artists
end
...
def edit
#album = Album.find params[:id]
#artists = Artist.order(:name) # or perhaps "#artists = #album.artists", or some other subset of artists
end
end
and then in new.html.erb and edit.html.erb, pass #artists to the form partial:
... # other view code
<%= render 'form', album: #album %>
... # other view code
and then in your form partial:
... # other view code
<%= f.simple_fields_for :tracks do |track_form| %>
<%= render 'track_fields', :f => track_form %>
<% end %>
... # other view code
finally, in your track partial:
... # other view code
<div class="input">
<%= f.input :artist_tokens, label: 'Featured Artists', input_html: {"data-pre" => #artists.map(&:attributes).to_json} %>
</div>
... # other view code
Does that make sense?

NoMethodError within nested model form

The project is a simple workout creator where you can add exercises to a workout plan.
I've been following the Railscast covering nested model forms to allow dynamically adding and deleting exercises, but have run into an error and need a second opinion as a new developer.
The error I am continually receiving is: NoMethodError in Plans#show
This is the extracted code, with starred line the highlighted error:
<fieldset>
**<%= link_to_add_fields "Add Exercise", f, :exercise %>**
<%= f.text_field :name %>
<%= f.number_field :weight %>
<%= f.number_field :reps %>
Note: I have the exercise model created but not an exercise controller. An exercise can only exist in a plan but I was unsure if I still needed a create action in an exercise controller for an exercise to be added?
I followed the Railscast almost verbatim (the _exercise_fields partial I slightly deviated) so you're able to view my files against the ones he has shared in the notes.
My schema.rb
create_table "exercises", force: true do |t|
t.string "name"
t.integer "weight"
t.integer "reps"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "plan_id"
end
create_table "plans", force: true do |t|
t.string "title"
t.datetime "created_at"
t.datetime "updated_at"
end
My Plan model:
class Plan < ActiveRecord::Base
has_many :exercises
accepts_nested_attributes_for :exercises, allow_destroy: true
end
My Exercise model:
class Exercise < ActiveRecord::Base
belongs_to :plan
end
My _form.html.erb
<%= form_for #plan do |f| %>
<% if #plan.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(#plan.errors.count, "error") %> prohibited this plan from being saved:</h2>
<ul>
<% #plan.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :title %><br>
<%= f.text_field :title %>
</div>
<%= f.fields_for :exercises do |builder| %>
<%= render 'exercise_fields', f: builder %>
<% end %>
<%= link_to_add_fields "Add Exercise", f, :exercises %>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
My _exercise_fields.html.erb
<fieldset>
<%= link_to_add_fields "Add Exercise", f, :exercise %>
<%= f.text_field :name %>
<%= f.number_field :weight %>
<%= f.number_field :reps %>
<%= f.hidden_field :_destroy %>
<%= link_to "remove", '#', class: "remove_fields" %>
</fieldset>
My plans.js.coffee
jQuery ->
$('form').on 'click', '.remove_fields', (event) ->
$(this).prev('input[type=hidden]').val('1')
$(this).closest('fieldset').hide()
event.preventDefault()
$('form').on 'click', '.add_fields', (event) ->
time = new Date().getTime()
regexp = new RegExp($(this).data('id'), 'g')
$(this).before($(this).data('fields').replace(regexp, time))
event.preventDefault()
My application_helper.rb
module ApplicationHelper
def link_to_add_fields(name, f, association)
new_object = f.object.send(association).klass.new
id = new_object.object_id
fields = f.fields_for(association, new_object, child_index: id) do |builder|
render(association.to_s.singularize + "_fields", f: builder)
end
link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")})
end
end
I'm relatively new to programming so I apologize in advance if I have easily overlooked something. Any help, suggestions, or leads for sources to read up on my issue are greatly appreciated.
Thanks!
Having implemented the functionality you seek, I'll give some ideas:
Accepts Nested Attributes For
As you already know, you can pass attributes from a parent to nested model by using the accepts_nested_attributes_for function
Although relatively simple, it's got a learning curve. So I'll explain how to use it here:
#app/models/plan.rb
Class Plan < ActiveRecord::Base
has_many :exercises
accepts_nested_attributes_for :exercises, allow_destroy: true
end
This gives the plan model the "command" to send through any extra data, if presented correctly
To send the data correctly (in Rails 4), there are several important steps:
1. Build the ActiveRecord Object
2. Use `f.fields_for` To Display The Nested Fields
3. Handle The Data With Strong Params
Build The ActiveRecord Object
#app/controllers/plans_controller.rb
def new
#plan = Plan.new
#plan.exericses.build
end
Use f.fields_for To Display Nested Fields
#app/views/plans/new.html.erb
<%= form_for #plans do |f| %>
<%= f.fields_for :exercises do |builder| %>
<%= builder.text_field :example_field %>
<% end %>
<% end %>
Handle The Data With Strong Params
#app/controllers/plans_controller.rb
def create
#plan = Plan.new(plans_params)
#plan.save
end
private
def plans_params
params.require(:plan).permit(:fields, exerices_attributes: [:extra_fields])
end
This should pass the required data to your nested model. Without this, you'll not pass the data, and your nested forms won't work at all
Appending Extra Fields
Appending extra fields is the tricky part
The problem is that generating new f.fields_for objects on the fly is only possible within a form object (which only exists in an instance)
Ryan Bates gets around this by sending the current form object through to a helper, but this causes a problem because the helper then appends the entire source code for the new field into a links' on click event (inefficient)
We found this tutorial more apt
It works like this:
Create 2 partials: f.fields_for & form partial (for ajax)
Create new route (ajax endpoint)
Create new controller action (to add extra field)
Create JS to add extra field
Create 2 Partials
#app/views/plans/add_exercise.html.erb
<%= form_for #plan, :url => plans_path, :authenticity_token => false do |f| %>
<%= render :partial => "plans/exercises_fields", locals: {f: f, child_index: Time.now.to_i} %>
<% end %>
#app/views/plans/_exercise_fields.html.erb
<%= f.fields_for :exercises, :child_index => child_index do |builder| %>
<%= builder.text_field :example %>
<% end %>
Create New Route
#config/routes.rb
resources :plans do
collection do
get :add_exercise
end
end
Create Controller Action
#app/controllers/plans_controller.rb
def add_exercise
#plan = Plan.new
#plan.exercises.build
render "add_exericse", :layout => false
end
Create JS to Add The Extra Field
#app/assets/javascripts/plans.js.coffee
$ ->
$(document).on "click", "#add_exercise", (e) ->
e.preventDefault();
#Ajax
$.ajax
url: '/messages/add_exercise'
success: (data) ->
el_to_add = $(data).html()
$('#exercises').append(el_to_add)
error: (data) ->
alert "Sorry, There Was An Error!"
Apologies for the mammoth post, but it should work & help show you more info

Resources