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

Phoenix Forms

reading/deprecated_book_form.livemd

Phoenix Forms

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

Return Home Report An Issue

Setup

Ensure you type the ea keyboard shortcut to evaluate all Elixir cells before starting. Alternatively you can evaluate the Elixir cells as you read.

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.

We can use the Phoenix.HTML.Form.form_for/3 macro to create a form and other macros for form inputs such as:

Phoenix forms work alongside Ecto Changesets to provide error handling and data validation.

Book Form

To learn more about Phoenix.HTML.Form and Ecto.Changeset we’re going to build a book creation form. This form would connect to a book search site that allows authors to upload their books.

A book will have

  • A required :title field between 3 and 100 characters.
  • An :author string field.
  • A required :content string field.
  • A :published_on field that is a Date.
  • An :has_licence field which must always be true.

Create BookForm Project.

Create, setup, and start your BookForm Phoenix project.

$ mix phx.new book_form
$ mix ecto.create
$ mix phx.server

Create The BookForm Form.

Replace the existing index.html.heex file with the following form content.

<%= form_for :book, "/", fn book_form -> %>
  
    Title
    <%= text_input book_form, :title %>
  

  
    Author
    <%= text_input book_form, :author %>
  

  
    Content
    <%= textarea book_form, :content %>
  

  
    Published On
    <%= date_select book_form, :published_on %>
  

  
    Licenced
    <%= checkbox book_form, :has_licence %>
  

  <%= submit "Submit" %>
<% end %>

This will create a book form on our main page. When we submit the form, it sends a POST request to the "/" route.

Form Action

has_licenceTo connect our form, we need to handle the appropriate HTTP POST action.

Use Phoenix.Router.post/4 to define the route in router.ex.

scope "/", BookFormWeb do
  pipe_through :browser

  get "/", PageController, :index
  # Add the `:create` action.
  post "/", PageController, :create
end

To handle this POST request, we need to define the PageController.create/2 action in page_controller.ex. For now we’ll just redirect the user back to the same page, and inspect the parameters from the form.

  def create(conn, params) do
    IO.inspect(params)
    redirect conn, to: "/"
  end

Press the Submit button and you should see the following params in your terminal.

%{
  "_csrf_token" => "YQUVIyQOOhoMITdzNXAqCQwefxE6DwoQPcvVa9otkoCED3fsED9hiNg_",
  "book" => %{
    "author" => "",
    "content" => "",
    "has_licence" => "false",
    "published_on" => %{"day" => "1", "month" => "1", "year" => "2017"},
    "title" => ""
  }
}

Changeset

We’re going to use Ecto.Changeset to validate our form data.

First, we need to provide a changeset to our form instead of our form name. Here’s how we can create a simple empty changeset.

types = %{
  author: :string,
  title: :string,
  content: :string,
  has_licence: :boolean,
  published_on: :date
}

changeset = Ecto.Changeset.cast({%{}, types}, %{}, Map.keys(types))

Here’s an example of how we can use a changeset to validate our book form data.

book_params = %{
  "author" => "Example Author",
  "content" => "Example Content",
  "has_licence" => "false",
  "published_on" => %{"day" => "1", "month" => "1", "year" => "2017"},
  "title" => "Example Title"
}

types = %{
  author: :string,
  title: :string,
  content: :string,
  has_licence: :boolean,
  published_on: :date
}

changeset =
  Ecto.Changeset.cast({%{}, types}, book_params, Map.keys(types))
  |> Ecto.Changeset.validate_required([:title, :content])
  |> Ecto.Changeset.validate_length(:title, min: 3, max: 100)
  |> Ecto.Changeset.validate_acceptance(:has_licence)

We can use changesets in combination with Phoenix forms to validate the form data. We need to provide the form with this changeset.

First, well create a schemaless changeset for Book. Create a lib/book_form/book.ex file with the following content.

defmodule BookForm.Book do
  defstruct [:title, :author, :content, :has_licence, :published_on]

  @types %{
    title: :string,
    author: :string,
    content: :string,
    has_licence: :boolean,
    published_on: :date
  }

  def changeset(%__MODULE__{} = book, params \\ %{}) do
    {book, @types}
    |> Ecto.Changeset.cast(params, Map.keys(@types))
    |> Ecto.Changeset.validate_required([:title, :content])
    |> Ecto.Changeset.validate_length(:title, min: 3, max: 100)
    |> Ecto.Changeset.validate_acceptance(:has_licence)
  end

  def new(params) do
    %__MODULE__{}
    |> changeset(params)
    |> Ecto.Changeset.apply_action(:update)
  end
end

We need to provide the form with an initial changeset, modify the PageController.index/2 action in page_controller.ex to pass the changeset to the form.

def index(conn, _params) do
  changeset = BookForm.Book.changeset(%BookForm.Book{})
  render(conn, "index.html", changeset: changeset)
end

Now, use the @changeset value in the form in page.html.heex instead of the :book atom.

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

  
    Author
    <%= text_input book_form, :author %>
  

  
    Content
    <%= textarea book_form, :content %>
  

  
    Published On
    <%= date_select book_form, :published_on %>
  

  
    Licenced
    <%= checkbox book_form, :has_licence %>
  

  <%= submit "Submit" %>
<% end %>

When we submit the form, we can use BookForm.Book.new/2 to validate the book data and check if there are any errors.

If the book data is valid it will return {:ok, book}.

BookForm.Book.new(%{
  title: "Example Title",
  author: "Example Author",
  content: "Example Content",
  has_licence: true,
  published_on: Date.utc_today()
})

If the book data is invalid, BookForm.Book.new/2 will return {:error, changeset}.

BookForm.Book.new(%{})

We can pattern match on the return value to display an Error message. Phoenix provides Phoenix.Controller.put_flash/3 to flash a message on the screen. We’ll use this to provide a success or failure message to the user.

Modify PageController.create/2 to be the following.

def create(conn, params) do
  case BookForm.Book.new(params["book"]) do
    {:ok, _book} ->
      conn
      |> put_flash(:info, "Success!")
      |> redirect(to: "/")

    {:error, changeset} ->
      conn
      |> put_flash(:error, "Failure!")
      |> render("index.html", changeset: changeset)
  end
end

Now when we submit the form with invalid data we’ll see an error message. The changeset also preserves previous values from the form.

Error Handling

Instead of providing a simple error message, we can provide error feedback on each form input individually.

We can get all form errors using form.errors Phoenix provides an error_tag helper in error_helpers.ex which uses these errors and the name of the field to provide error feedback.

Modify index.html.heex to include error tags.

<%= form_for @changeset, "/", fn book_form -> %>
  
    Title
    <%= text_input book_form, :title %>
    <%= error_tag book_form, :title %>
  

  
    Author
    <%= text_input book_form, :author %>
    <%= error_tag book_form, :author %>
  

  
    Content
    <%= textarea book_form, :content %>
    <%= error_tag book_form, :content %>
  

  
    Published On
    <%= date_select book_form, :published_on %>
    <%= error_tag book_form, :published_on %>
  

  
    Licenced
    <%= checkbox book_form, :has_licence %>
    <%= error_tag book_form, :has_licence %>
  

  <%= submit "Submit" %>
<% end %>

Failure messages now display on each form input.

Further Reading

For more on Phoenix, consider the following resources.

Mark As Completed

file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")

progress_path = __DIR__ <> "/../progress.json"
existing_progress = File.read!(progress_path) |> Jason.decode!()

default = Map.get(existing_progress, file_name, false)

form =
  Kino.Control.form(
    [
      completed: input = Kino.Input.checkbox("Mark As Completed", default: default)
    ],
    report_changes: true
  )

Task.async(fn ->
  for %{data: %{completed: completed}} <- Kino.Control.stream(form) do
    File.write!(progress_path, Jason.encode!(Map.put(existing_progress, file_name, completed)))
  end
end)

form

Commit Your Progress

Run the following in your command line from the curriculum folder to track and save your progress in a Git commit. Ensure that you do not already have undesired or unrelated changes by running git status or by checking the source control tab in Visual Studio Code.

$ git checkout solutions
$ git checkout -b deprecated-book-form-reading
$ git add .
$ git commit -m "finish deprecated book form reading"
$ git push origin deprecated-book-form-reading

Create a pull request from your deprecated-book-form-reading branch to your solutions branch. Please do not create a pull request to the DockYard Academy repository as this will spam our PR tracker.

DockYard Academy Students Only:

Notify your instructor by including @BrooklinJazz in your PR description to get feedback. You (or your instructor) may merge your PR into your solutions branch after review.

If you are interested in joining the next academy cohort, sign up here to receive more news when it is available.