Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Book Search: Book Form

deprecated_book_search_book_form.livemd

Book Search: Book Form

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"},
  {:ecto, "~> 3.9.5"}
])

Navigation

Home Report An Issue Blog: SeedingBlog: Comment

Review Questions

Upon completing this lesson, a student should be able to answer the following questions.

  • How do we create a form in a Phoenix application?
  • What is a form action and how is it triggered?
  • How do we validate data and display errors in a form using changesets?

Overview

Phoenix Forms

Phoenix provides a Phoenix HTML library for working with HTML elements. The library is a thin wrapper around HTML elements with some additional benefits that makes it preferable to working with raw HTML.

In Phoenix, forms are used to create and handle HTML forms in your web application. A form is used to gather input from the user and submit it to the server for processing.

To create a form in Phoenix, you can use the Phoenix.HTML.Form.form_for/3 macro in your template. This function takes a changeset and a URL to submit an HTTP POST request to.

Inside of the form, we use other macros to create inputs such as:

Here’s an example of a simple form in Phoenix:

<%= form_for @changeset, Routes.book_path(@conn, :create), fn f -> %>
  Title: <%= text_input f, :title %>
<% end %>

In the code above, the Phoenix.HTML.Form.form_for/3 macro defines a form. Inside of the form we define a text input with Phoenix.HTML.Form.text_input/3.

@changeset is an Ecto Changeset provided by the controller. Phoenix forms work alongside changesets to provide error handling and data validation.

The Routes.book_path(@conn, :create) call returns the URL to send the HTTP POST request to. For example, we could replace this with "/books".

<%= form_for @changeset, "/books", fn f -> %>
  Title: <%= text_input f, :title %>
<% end %>

We use the helper to raise an error of the URL does not exist, and ensure our code continues to work if the route changes.

Typically, this would correspond to a route defined by our router in router.ex.

post "/books", BookController, :create

This route specifies that when a POST request is sent to the /books URL, the create action in the BookController should be invoked to handle the request.

def create(conn, %{"book" => book_params}) do
  # Call the Books module's `create_book/1` function to create a new book
  case Books.create_book(book_params) do
    # If the book was created successfully, set a flash message and redirect the user to the book's show page
    {:ok, book} ->
      conn
      |> put_flash(:info, "Book created successfully.") # <-- PUT FLASH
      |> redirect(to: Routes.book_path(conn, :show, book))

    # If the book couldn't be created, render the form again with the validation errors displayed
    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

Follow Along: BookSearch: Book Form

To learn more about forms with associations, we’re going to add a book form where we can select an author to our BookSearch applications from the previous lesson. If you need clarification during this reading, you can reference the completed BookSearch/book_form branch of the DockYard Academy example BookSearch project.

Ensure you have completed the BookSearch: Seeding project from the previous lesson. If not, you can clone the BookSearch/seeding branch of the project.

If you have cloned the project, make sure you are on the seeding branch.

git checkout seeding

All tests should pass.

mix test

Ensure you start the server.

mix phx.server

If you encounter any issues with your database you may need to reset it. Reset the database now to ensure you have a clean database.

mix ecto.reset

If you encounter issues with your database in your test environment, you can drop it.

MIX_ENV=test mix ecto.drop

Creating A Book

When we generated the books resource for our BookSearch application, it automatically added a form for creating and updating books in book_search_web/templates/book/form.html.heex.

<.form let={f} for={@changeset} action={@action}>
  <%= if @changeset.action do %>
    
      <p>Oops, something went wrong! Please check the errors below.</p>
    
  <% end %>

  <%= label f, :title %>
  <%= text_input f, :title %>
  <%= error_tag f, :title %>

  
    <%= submit "Save" %>
  

This form is used on the book_search_web/templates/book/new.html.heex template for creating books.

<h1>New Book</h1>

<%= render "form.html", Map.put(assigns, :action, Routes.book_path(@conn, :create)) %>

<span><%= link "Back", to: Routes.book_path(@conn, :index) %></span>

This forms send a HTTP POST request to create a book. These requests are handled in our router in router.ex.

resources "/books", BookController

Remember that resources/2 creates a standard matrix of HTTP actions. It’s simply syntax sugar for the following routes.

get "/books", BookController, :index
get "/books/:id/edit", BookController, :edit
get "/books/new", BookController, :new
get "/books/:id", BookController, :show
post "/books", BookController, :create # Handles HTTP POST request to "/books"
patch "/books/:id", BookController, :update
put "/books/:id", BookController, :update
delete "/books/:id", BookController, :delete

Our router then invokes the BookController.create/2 action.

def create(conn, %{"book" => book_params}) do
  case Books.create_book(book_params) do
    {:ok, book} ->
      conn
      |> put_flash(:info, "Book created successfully.")
      |> redirect(to: Routes.book_path(conn, :show, book))

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

This either creates a book and redirects the user to show page on "books/:id", or re-renders the template with an updated changeset.

Associate A Book With An Author

We’ve associated books with authors in our database, but currently we’re unable to create or update a book with an author from the browser.

Add The Author Field To Our Book Form

To create a book with an associated author, we’re going to add an author field on our book form that allows us to select an author for our book.

In order to select an author, our BookController needs to provide the list of authors to our template.

Modify our BookController.new/2 function to provide the list of authors to our template.

# Add Alias With Our Other Aliases
alias BookSearch.Authors

# Modify The New/2 Action To Provide `authors` To The `new.html.heex` Template.
def new(conn, _params) do
  changeset = Books.change_book(%Book{})
  authors = Authors.list_authors()
  render(conn, "new.html", changeset: changeset, authors: authors)
end

We’ll add a select input to our book form template file form.html.heex. To avoid issues with other pages that render this form, we’ll check if @authors exists before rendering the select input. Remember that @authors is just shorthand for assigns.authors, so we can use assigns[:authors] to safely retrieve the list of authors from the assigs.

<.form let={f} for={@changeset} action={@action}>
  <%= if @changeset.action do %>
    
      <p>Oops, something went wrong! Please check the errors below.</p>
    
  <% end %>

  <%= label f, :title %>
  <%= text_input f, :title %>
  <%= error_tag f, :title %>

  <%= label f, :author_id %>
  <%= select f, :author_id, Enum.map(@authors, fn author -> {author.name, author.id} end), prompt: "Select an author" %>
  <%= error_tag f, :author_id %>

  
    <%= submit "Save" %>
  

Fix Failing Tests

By adding @authors to our template, we made several tests fail with a (KeyError) key :authors not found. That’s because now any template that renders our form requires the list of authors to be defined in the associated controller.

Any controller action that renders the new.html.heex template or the edit.html.heex file needs to be fixed.

Modify your BooksController to the following to fix tests:

defmodule BookSearchWeb.BookController do
  use BookSearchWeb, :controller

  alias BookSearch.Books
  alias BookSearch.Books.Book
  alias BookSearch.Authors

  def index(conn, _params) do
    books = Books.list_books() |> BookSearch.Repo.preload([:author])
    render(conn, "index.html", books: books)
  end

  def new(conn, _params) do
    changeset = Books.change_book(%Book{})
    authors = Authors.list_authors()
    render(conn, "new.html", changeset: changeset, authors: authors)
  end

  def create(conn, %{"book" => book_params}) do
    case Books.create_book(book_params) do
      {:ok, book} ->
        conn
        |> put_flash(:info, "Book created successfully.")
        |> redirect(to: Routes.book_path(conn, :show, book))

      {:error, %Ecto.Changeset{} = changeset} ->
        # list authors
        authors = Authors.list_authors()
        render(conn, "new.html", changeset: changeset, authors: authors)
    end
  end

  def show(conn, %{"id" => id}) do
    book = Books.get_book!(id) |> BookSearch.Repo.preload([:author])
    render(conn, "show.html", book: book)
  end

  def edit(conn, %{"id" => id}) do
    book = Books.get_book!(id)
    changeset = Books.change_book(book)
    
    # list authors
    authors = Authors.list_authors()
    render(conn, "edit.html", book: book, changeset: changeset, authors: authors)
  end

  def update(conn, %{"id" => id, "book" => book_params}) do
    book = Books.get_book!(id)

    case Books.update_book(book, book_params) do
      {:ok, book} ->
        conn
        |> put_flash(:info, "Book updated successfully.")
        |> redirect(to: Routes.book_path(conn, :show, book))

      {:error, %Ecto.Changeset{} = changeset} ->
        # list authors
        authors = Authors.list_authors()
        render(conn, "edit.html", book: book, changeset: changeset, authors: authors)
    end
  end

  def delete(conn, %{"id" => id}) do
    book = Books.get_book!(id)
    {:ok, _book} = Books.delete_book(book)

    conn
    |> put_flash(:info, "Book deleted successfully.")
    |> redirect(to: Routes.book_path(conn, :index))
  end
end

All tests should pass.

mix test

In the previous BookSearch: Seeds reading, we created two authors in our seed file, so there should already be two authors to choose from in our form.

Currently this form doesn’t actually associate the author with the book. That’s because our BookController and Books context don’t handle associating an author with a book.

Our form sends the "author_id" as part of our book_params in the BookController.create/2 function.

def create(conn, %{"book" => book_params}) do
  case Books.create_book(book_params) do
    {:ok, book} ->
      conn
      |> put_flash(:info, "Book created successfully.")
      |> redirect(to: Routes.book_path(conn, :show, book))

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

These book params are then passed to Books.create_book/1 in books.exs.

def create_book(attrs \\ %{}) do
  %Book{}
  |> Book.changeset(attrs)
  |> Repo.insert()
end

We’ll need to associate the author and book whenever we create a book to make this form work as desired.

We have a number of options for how build the association between books and authors. We’ll keep it simple and put the author_id field in the Book changeset in book.ex.

def changeset(book, attrs) do
  book
  |> cast(attrs, [:title, :author_id])
  |> validate_required([:title])
end

Now visit http://localhost:4000/books/new and create a book with an associated author!

Your Turn: Adding Tests

Test this feature. Write a test for:

  • The BooksController create action with an associated author.
  • The BooksController update action with an associated author.
  • The Books.create_book/1 context function with an associated author.
  • The Books.update_book/2 context function with an associated author.

Then, compare your tests with the examples below.

Context create_book/1 Test

Add the following test in books_test.exs in the "books" describe block with the other create_book/1 tests.

# Add To Existing Imports To Get Access To The `author_fixture` For Creating Authors.
import BookSearch.AuthorsFixtures

test "create_book/1 with author creates a book with an associated author" do
  # Create an author record using the author_fixture function
  author = author_fixture()
  # Define valid attributes for a new book, including the author's id
  valid_attrs = %{title: "some title", author_id: author.id}

  # Call the create_book function with the valid attributes
  assert {:ok, %Book{} = book} = Books.create_book(valid_attrs)

  # Assert that the returned book has the expected title and author_id
  assert book.title == "some title"
  assert book.author_id == author.id
end

Context update_book/2 Test

Add the following test in books_test.exs in the "books" describe block with the other update_book/2 tests.

test "update_book/2 with author updates book's associated author" do
  # Create an author record using the author_fixture function
  original_author = author_fixture()
  
  # Create another author record using the author_fixture function
  updated_author = author_fixture()
  
  # Create a book record with the original author using the book_fixture function
  book = book_fixture(author_id: original_author.id)

  # Define update attributes for the book, including the new author's id
  update_attrs = %{title: "some updated title", author_id: updated_author.id}

  # Call the update_book function with the book and update attributes as arguments
  assert {:ok, %Book{} = book} = Books.update_book(book, update_attrs)

  # Assert that the returned book has the expected updated title and author_id
  assert book.title == "some updated title"
  assert book.author_id == updated_author.id
end

Controller create Test

Add the following test inside of the "create book" describe block in book_controller_test.exs.

# Add With Our Existing Imports So We Get Access To The Author_fixture Function
import BookSearch.AuthorsFixtures

test "create a book with an associated author", %{conn: conn} do
  # Create an author record using the author_fixture function
  author = author_fixture()

  # Add the author's id to the @create_attrs map
  create_attrs_with_author = Map.put(@create_attrs, :author_id, author.id)

  # Send a POST request to create a new book, including the author's id in the request body
  conn = post(conn, Routes.book_path(conn, :create), book: create_attrs_with_author)

  # Assert that the response is a redirect, and extract the id from the response
  assert %{id: id} = redirected_params(conn)

  # Assert that the redirect is to the show page for the new book
  assert redirected_to(conn) == Routes.book_path(conn, :show, id)

  # Send a GET request to the show page for the new book
  conn = get(conn, Routes.book_path(conn, :show, id))

  # Assert that the response is an HTML page with a 200 status code
  response = html_response(conn, 200)

  # Assert that the HTML page contains the expected text and the author's name
  assert response =~ "Show Book"
  assert response =~ author.name
end

Controller update Test

Add the following test inside of the "update book" describe block in book_controller_test.exs.

test "update a book with an associated author", %{conn: conn, book: book} do
  # Create an author record using the author_fixture function
  author = author_fixture()

   # Add the author's id to the @update_attrs map
  update_attrs_with_author = Map.put(@update_attrs, :author_id, author.id)

  # Send a PUT request to the update action for the book with the updated attributes
  conn = put(conn, Routes.book_path(conn, :update, book), book: update_attrs_with_author)

  # Assert that the request is redirected to the show action for the book
  assert redirected_to(conn) == Routes.book_path(conn, :show, book)

  # Send a GET request to the show action for the book
  conn = get(conn, Routes.book_path(conn, :show, book))

  # Get the response from the show action
  response = html_response(conn, 200)

  # Assert that the response includes the updated title of the book
  assert response =~ "some updated title"

  # Assert that the response includes the name of the associated author
  assert response =~ author.name
end

Push To GitHub

Ensure all of your tests continue to pass.

mix test

ONLY If you cloned the book_search project: you’ll have to re-initialize it as a git project so you have ownership over the project. The following command removes the git folder and re-initializes it. You’ll then have to create a repository on GitHub and follow the instructions to connect the project.

$ rm -rf .git
$ git init

Then stage and commit your changes to GitHub from the book_search folder.

git add .
git commit -m "associate books with authors"
git push

Further Reading

For more on Phoenix, consider the following resources.

Commit Your Progress

DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.

Run git status to ensure there are no undesirable changes. Then run the following in your command line from the curriculum folder to commit your progress.

$ git add .
$ git commit -m "finish Book Search: Book Form reading"
$ git push

We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.

We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.

Navigation

Home Report An Issue Blog: SeedingBlog: Comment