5

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
Taylor Liss
  • 533
  • 4
  • 24

1 Answers1

9

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) %>
max
  • 76,662
  • 13
  • 84
  • 137
  • 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. – max Apr 10 '19 at 11:09
  • 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? – Taylor Liss Apr 10 '19 at 19:01
  • 1
    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. – max Apr 10 '19 at 19:11
  • Just a quick note: I had to change the `show.html.erb` code to `` in order for it to work. Seems you need to explicitly state its a partial when using local variables. – Taylor Liss Apr 17 '19 at 04:06