Use a single form_with to create and edit nested resources in Rails
Asked Answered
E

1

9

Background

Inside of my application, a series is composed of many books. A series' Show page allows a user to see all the books in a series and to add a new book to the series using a form.

Every book listed on the Show page has a link to an Edit page for that book. The edit page contains the same form used to initially add a book. When editing a book, the form should auto-fill with the books existing information.

Question

How do I configure my form_with tag so that it can both create a new book and edit an existing book (auto-filling the edit form)? I have tried the following configurations, but they either break the Edit page or break the Show page:

  1. <%= form_with(model: [ @series, @series.books.build ], local: true) do |form| %>
    • Breaks book Edit page
    • Error: No error, but form doesn't auto-fill data
  2. <%= form_with(model: @book, url: series_book_path, local: true) do |form| %>
    • Breaks series Show page
    • Error: No route matches {:action=>"show", :controller=>"books", :id=>"6"}, missing required keys: [series_id]
  3. <%= form_with(model: [:series, @book], local: true) do |form| %>
    • Breaks series Show page
    • Error: Undefined method 'model_name' for nil:NilClass
  4. <%= form_with(model: [@series, @series.books.find(@book.id)], local: true) do |form| %>
    • Breaks series Show page
    • Error: undefined method 'id' for nil:NilClass
  5. <%= form_with(model: @book, url: [@series, @book], local: true) do |form| %>
    • Breaks when submitting new book on series Show page
    • Error: No route matches [POST] "/series/6"

Resources I have consulted:

Existing code

Stripped-down sections of relevant code are below, as well as links to where they exist in my current GitHub repository.

config/routes.rb

resources :series do
  resources :books
end

app/models/book.rb

class Book < ApplicationRecord
  belongs_to :series
end

app/models/series.rb

class Series < ApplicationRecord
  has_many :books, dependent: :destroy
end

db/schema.rb

create_table "books", force: :cascade do |t|
  t.integer "series_number"
  t.integer "year_published"
  t.integer "series_id"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["series_id"], name: "index_books_on_series_id"
end

create_table "series", force: :cascade do |t|
  t.string "title"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

app/views/series/show.html.erb

<%= render @series.books %>
<%= render 'books/form' %>

app/views/books/_book.html.erb

<%= link_to 'Edit', edit_series_book_path(book.series, book) %>

app/views/books/edit.html.erb

<%= render 'form' %>

app/views/books/_form.html.erb

<%= form_with(model: @book, url: [@series, @book], local: true) do |form| %>
  <%= form.label :series_number %>
  <%= form.number_field :series_number %>

  <%= form.label :year_published %>
  <%= form.number_field :year_published %>
<% end %>

app/controllers/books_controller.rb

class BooksController < ApplicationController
  def index
    @books = Book.all
  end

  def show
    @book = Book.find(params[:id])
  end

  def new
    @book = Book.new
  end

  def edit
    @series = Series.find(params[:series_id])
    @book = @series.books.find(params[:id])
  end

  def create
    @series = Series.find(params[:series_id])
    @book = @series.books.create(book_params)
    redirect_to series_path(@series)
  end

  def destroy
    @series = Series.find(params[:series_id])
    @book = @series.books.find(params[:id])
    @book.destroy
    redirect_to series_path(@series)
  end

  private
    def book_params
      params.require(:book).permit(:year_published, :series_number)
    end
end

Routes

          Prefix Verb   URI Pattern                                 Controller#Action
        articles GET    /articles(.:format)                         articles#index
                 POST   /articles(.:format)                         articles#create
     new_article GET    /articles/new(.:format)                     articles#new
    edit_article GET    /articles/:id/edit(.:format)                articles#edit
         article GET    /articles/:id(.:format)                     articles#show
                 PATCH  /articles/:id(.:format)                     articles#update
                 PUT    /articles/:id(.:format)                     articles#update
                 DELETE /articles/:id(.:format)                     articles#destroy
    series_books GET    /series/:series_id/books(.:format)          books#index
                 POST   /series/:series_id/books(.:format)          books#create
 new_series_book GET    /series/:series_id/books/new(.:format)      books#new
edit_series_book GET    /series/:series_id/books/:id/edit(.:format) books#edit
     series_book GET    /series/:series_id/books/:id(.:format)      books#show
                 PATCH  /series/:series_id/books/:id(.:format)      books#update
                 PUT    /series/:series_id/books/:id(.:format)      books#update
                 DELETE /series/:series_id/books/:id(.:format)      books#destroy
    series_index GET    /series(.:format)                           series#index
                 POST   /series(.:format)                           series#create
      new_series GET    /series/new(.:format)                       series#new
     edit_series GET    /series/:id/edit(.:format)                  series#edit
          series GET    /series/:id(.:format)                       series#show
                 PATCH  /series/:id(.:format)                       series#update
                 PUT    /series/:id(.:format)                       series#update
                 DELETE /series/:id(.:format)                       series#destroy
Erdah answered 9/4, 2019 at 15:20 Comment(0)
A
11

You can pass an array to the form to handle both nested and "shallow" routes:

<%= form_with(model: [@series, @book], local: true) do |form| %>

<% end %>

Rails compacts the array (removes nil values) so if @series is nil the form will fall back to book_url(@book) or books_url. However you need to handle setting @series and @book properly from the controller.

class SeriesController < ApplicationController
  def show
    @series = Series.find(params[:id])
    @book = @series.books.new # used by the form
  end
end

You could instead handle this in your views by using local variables:

# app/views/books/_form.html.erb
<%= form_with(model: model, local: true) do |form| %>

<% end %>

# app/views/books/edit.html.erb
<%= render 'form', locals: { model: [@series, @book] } %>

# app/views/series/show.html.erb
<%= render 'books/form', locals: { model: [@series, @series.book.new] } %>

You can also use the shallow: true option in your routes to avoid nesting the member routes (show, edit, update, destroy):

resources :series do
  resources :books, shallow: true
end

This will let you just do:

# app/views/books/edit.html.erb
<%= render 'form', model: @book %>

# app/views/books/_book.html.erb
<%= link_to 'Edit', edit_book_path(book) %>
Aristocrat answered 10/4, 2019 at 10:45 Comment(4)
Note that the local: true option for form_with has nothing to do with local variables. It just sets the data-remote attribute on the form which determines if the form is sent normally or with AJAX.Aristocrat
This is so insanely helpful with fantastically readable examples! It works perfectly now! Regarding the different approaches you outlined - is one better than the other, or more accepted within the community?Erdah
Both are just different ways to solve the same problem. By using locals you are explicitly passing the variables to the partial instead of just relying on external state - this makes it easier to make truly reusable partials as they are more functional in nature.Aristocrat
Just a quick note: I had to change the show.html.erb code to <%= render partial: 'books/form', locals: {model: [@series, @series.books.new]} %> in order for it to work. Seems you need to explicitly state its a partial when using local variables.Erdah

© 2022 - 2024 — McMap. All rights reserved.