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: CommentReview 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:
- Phoenix.HTML.Form.text_input/3
- Phoenix.HTML.Form.textarea/3
- Phoenix.HTML.Form.checkbox/3
- Phoenix.HTML.Form.select/4
- Phoenix.HTML.Form.number_input/3
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.