Shanghai 2010. Taking a break from the populated insanity of the Shanghai world expo took me to one of the famous water villages at the edge of the city.

Pharchive deserves a better workflow

Goals:

Today we are going to upgrade Pharchive to handle associations within our forms. We are also going to add the ability to bootstrap parent records from child forms

Requirements

  • An hour of your time
  • Having read/worked through part one

Upgrading Pharchive

First off, lets change the default page root for the application by going into the router and changing PageController to CollectionController. Since we are still using the same verb (index), we do not have to change anything else to get our desired result.

file: router.ex

defmodule Pharchive.Router do
  resources "/manufacturers", ManufacturerController
  resources "/films", FilmController
  resources "/collections", CollectionController
  get "/", CollectionController, :index
end

We need a way to assign a Manufacturer to a Film in our form, so we are going to add a plug in our FilmController. This plug is going to place a current list of Manufacturers in the connection for the :new, :create, :edit, and :update actions. From there we will be able to modify our templates to get a select menu in our Film form.

The load_manufacturers function returns nothing, and its sole purpose is to get a current list of Manufacturers from the database, format them into a list that the select form helper can accept, and assign that list to the :manufacturers variable in the connection. Notice that we are only selecting the Manufacturer’s name and its id, so our list will have the structure: [..., {name<string>, id<integer>}, ...].

file: controllers/film_controller.ex

defmodule Pharchive.FilmController do
  use Pharchive.Web, :controller
 
  alias Pharchive.Film
  alias Pharchive.Manufacturer
 
  plug :scrub_params, "film" when action in [:create, :update]
  plug :load_manufacturers when action in [:new, :create, :edit, :update]
 
  def load_manufacturers(conn, _) do
    manufacturers = Repo.all from(m in Pharchive.Manufacturer,
                                     select: {m.name, m.id}
    )
    manufacturers = Enum.into(manufacturers, [{'-- New Manufacturer --', -1}])
    assign(conn, :manufacturers, manufacturers)
  end
 
  ...

Next we need to modify our templates to give our form access to the :manufacturers connection variable. We do this in our new template and our edit template. Then we make sure that our form template makes use of the new variable by adding a new form-group.

file: templates/film/edit.html.eex

<h2>Edit film</h2>
 
<%= render "form.html", changeset: @changeset,
                        manufacturers: @manufacturers,
                        action: film_path(@conn, :update, @film) %>
 
<%= link "Back", to: film_path(@conn, :index) %>

file: templates/film/form.html.eex

...
<div class="form-group">
  <%= label f, :manufacturer_id, "Manufacturer", class: "control-label" %>
  <%= select f, :manufacturer_id, @manufacturers, class: "form-control" %>
</div>
...

file: templates/film/new.html.eex

<h2>New film</h2>
 
<%= render "form.html", changeset: @changeset,
                        manufacturers: @manufacturers,
                        action: film_path(@conn, :create) %>
 
<%= link "Back", to: film_path(@conn, :index) %>

Currently, we have no way of adding a Manufacturer inline. This would make creating records a bit easier (considering I have a couple hundred rolls of film to catalog, easy is my favorite word!); Let’s change this now.

Now we make sure that our models are related in such a way that we can create them independent of one another. By making our _id fields optional, we can create a model without its relation at the same time. This is very valuable to me because I may just want to create all my Collections first, rather than having to create all my Films/Manufacturers first.

file: models/collection.ex

defmodule Pharchive.Collection do
  ...
  @required_fields ~w(physical_id location frame_count description taken_at frame_size)
  @optional_fields ~w(film_id)
  ...
end

file: models/film.ex

defmodule Pharchive.Film do
...
    has_many :collections, Pharchive.Collection
...

   @optional_fields ~w(manufacturer_id)
 

file: models/manufacturer.ex

defmodule Pharchive.Manufacturer do
   schema "manufacturers" do
     ...
     has_many :films, Pharchive.Film
     ...
   end
 end

Now, we need a way to build from the “bottom up” (e.g. building parent models from child models). Since we have two levels of this to work through, we are going to start with the simpler pair of models Manufacturer -> Film. To restate the problem another way, we need a way to build a film whos manufacturer has not been created yet, and to subsequently build that manufacturer after the film has been created, and finally associate the two records. We are going to do this all on the server, no need to do anything fancy with js at this point.

First, we need a way for the film form to pass a special value for “manufacturer” which signifies that a manufacturer will need to be built next. I have opted to do this by adding an additional option to our form’s manufacturer select value. Since the values for each one of our options correspond to manufacturer ids in the database, I think its fair to use a negative value as a flag (-1). This does not break our parsing logic within the create action (which using a string would).

Now in our create action we have two possible workflows:

  1. The case when a film needs to be associated with an existing manufacturer
  2. The case when a film needs to be created and then redirect to the new manufacturer form.

For case 1, we can leverage Ecto’s build_assoc function, which allows us to bootstrap a Film changeset from a Manufacturer record. For case 2, we need to do a bit more. First we need to created a film changeset which does not include manufacturer_id (our manufacturer_id is -1, this won’t fly). Then we need to add a conditional to our insert logic, and in the case of a new manufacturer, redirect to the new manufacturer path with our new film_id as a query string parameter.

file: controllers/film_controller.ex

   def create(conn, %{"film" => film_params}) do
     {manufacturer_id, _} = Integer.parse(film_params["manufacturer_id"])
 
     if manufacturer_id == -1 do
       changeset = Film.changeset(%Film{}, Dict.delete(film_params, "manufacturer_id"))
     else
       changeset = Repo.get!(Manufacturer, film_params["manufacturer_id"])
         |> Ecto.build_assoc(:films)
         |> Film.changeset(film_params)
     end
 
     case Repo.insert(changeset) do
       {:ok, _film} ->
         conn
         |> put_flash(:info, "Film created successfully.")
         |> redirect(to: film_path(conn, :index))
       {:ok, film} ->
         if manufacturer_id == -1 do
           conn
           |> put_flash(:info, "Film created successfully. Please build the manufacturer")
           |> redirect(to: manufacturer_path(conn, :new, film: film))
         else
           conn
           |> put_flash(:info, "Film created successfully.")
           |> redirect(to: film_path(conn, :index))
         end
       {:error, changeset} ->
         render(conn, "new.html", changeset: changeset)
     end

Now that we have everything set up on the Film side, it is time to do the same on the Manufacturer’s side.

First we need to provide a place in our manufacturer form to hold a film_id if it’s provided. We do this by adding a hidden_input to our form and referencing the @film_id assigns variable which we set in the ManufacturerController. Since we now have two possible ways to hit new, we can take advantage of Elixir’s pattern matching and desugaring to conditionally set film_id in the connection. Finally, we update our create logic to handle updating a Film if a film is provided.

file: controllers/manufacturer_controller.ex

defmodule Pharchive.ManufacturerController do
  ...
  alias Pharchive.Film
  ...

  def new(conn, %{"film" => film_id}) do
    changeset = Manufacturer.changeset(%Manufacturer{})
    conn = assign(conn, :film_id, film_id)
    render(conn, "new.html", changeset: changeset)
  end

  def new(conn, _params) do
    changeset = Manufacturer.changeset(%Manufacturer{})
    conn = assign(conn, :film_id, "")
    render(conn, "new.html", changeset: changeset)
  end
 
  def create(conn, %{"manufacturer" => manufacturer_params}) do
    changeset = Manufacturer.changeset(%Manufacturer{}, Dict.delete(manufacturer_params, "film_id"))
    case Repo.insert(changeset) do
      {:ok, manufacturer} ->
        if manufacturer_params["film_id"] do
          {film_id, _} = Integer.parse(manufacturer_params["film_id"])
          film_changeset = Film.changeset(Repo.get!(Film, film_id), %{manufacturer_id: manufacturer.id})
          Repo.update!(film_changeset)
        end
      ...
    end

file: templates/manufacturer/form.html.eex

...
<%= hidden_input f, :film_id, value: @film_id, class: "form-control" %>
...

file: templates/manufacturer/new.html.eex

<h2>New manufacturer</h2>

<%= render "form.html", changeset: @changeset,
                        action: manufacturer_path(@conn, :create),
                        film_id: @film_id%>

<%= link "Back", to: manufacturer_path(@conn, :index) %>

It would be a nice touch to make manufacturer names show up in the Film index and in Film show; lets do that now. Keep in mind that we need to preload our associated records in order to work with them.

file: controllers/film_controller.ex

defmodule Pharchive.FilmController do
  ... 
  def index(conn, _params) do
    films = Repo.all from(f in Film,
                          preload: [:manufacturer]
    )
    render(conn, "index.html", films: films)
  end

  ...
  def show(conn, %{"id" => id}) do
    film = Repo.preload(Repo.get!(Film, id), :manufacturer)
    render(conn, "show.html", film: film)
  end
  ... 
end

file: templates/film/index.html.eex

...
<td><%= film.speed %></td>
<td><%= film.name %></td>
<td><%= film.short_name %></td>
<%= if film.manufacturer do %>
  <td><%= film.manufacturer.name %></td>
<% else %>
  <td>Manufacturer not Assigned</td>
<% end %>
... 

file: templates/film/show.html.eex

... 
  <li>
     <strong>Manufacturer:</strong>
    <%= if @film.manufacturer do %>
      <td><%= @film.manufacturer.name %></td>
    <% else %>
      <td>Manufacturer not Assigned</td>
    <% end %>
  </li>
...

As a first step for creating a bottom up workflow for collections, we need to add a dropdown select to the collection form in the same way that we did with manufacturers for the film form.

file: controllers/collection_controller.ex

defmodule Pharchive.CollectionController do
  ...
  alias Pharchive.Film

  plug :load_films when action in [:new, :create, :edit, :update]

  def load_films(conn, _) do
    films = Repo.all from(m in Pharchive.Film,
                          select: {m.name, m.id}
    )

    films = Enum.into(films, [{'-- New Film --', -1}])
    assign(conn, :films, Enum.reverse(films))
  end
  ...

file: templates/collection/edit.html.eex

<h2>Edit collection</h2>

<%= render "form.html", changeset: @changeset,
                        films: @films,
                        action: collection_path(@conn, :update, @collection) %>

<%= link "Back", to: collection_path(@conn, :index) %>

file: templates/collection/form.html.eex

... 
  <div class="form-group">
    <%= label f, :film_id, "Films", class: "control-label" %>
    <%= select f, :film_id, @films, class: "form-control" %>
  </div>
...

file: templates/collection/new.html.eex

<h2>New collection</h2>

<%= render "form.html", changeset: @changeset,
                        films: @films,
                        action: collection_path(@conn, :create) %>

<%= link "Back", to: collection_path(@conn, :index) %>

Next we follow the same pattern as we did with Film. On successful insertion of our Collection, if the ‘New Film’ flag is present, we kick forward a query parameter to the new film form.

file: controllers/collection_controller.ex

defmodule Pharchive.CollectionController do
  ...
  def create(conn, %{"collection" => params}) do
    {film_id, _} = Integer.parse(params["film_id"])

    if film_id == -1 do
      changeset = Collection.changeset(%Collection{}, Dict.delete(params, "film_id"))
    else
      changeset = Repo.get!(Film, params["film_id"])
        |> Ecto.build_assoc(:collections)
        |> Collection.changeset(params)
    end
 
    case Repo.insert(changeset) do
     {:ok, collection} ->
       if film_id == -1 do
         conn
         |> put_flash(:info, "Collection created successfully. Please build the Film")
         |> redirect(to: film_path(conn, :new, collection: collection))
       else
         conn
         |> put_flash(:info, "Collection created successfully.")
         |> redirect(to: collection_path(conn, :index))
       end

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

Now we repeat the same process we did for Manufacturers on Film, with Collections. The code is effectively copypasta so I will not discuss it in depth.

file: controllers/film_controller.ex

defmodule Pharchive.FilmController do
  ... 
  alias Pharchive.Collection
  ... 

  def new(conn, %{"collection" => collection_id}) do
    changeset = Film.changeset(%Film{})
    conn = assign(conn, :collection_id, collection_id)
    render(conn, "new.html", changeset: changeset)
  end

  def new(conn, _params) do
    changeset = Film.changeset(%Film{})
    conn = assign(conn, :collection_id, "")
    render(conn, "new.html", changeset: changeset)
  end

  ... 
  def create(conn, %{"film" => film_params}) do
    ...
    case Repo.insert(changeset) do
      {:ok, film} ->
        if film_params["collection_id"] do
          {collection_id, _} = Integer.parse(film_params["collection_id"])
          collection_changeset = Collection.changeset(Repo.get!(Collection, collection_id), %{film_id: film.id})
          Repo.update!(collection_changeset)
        end
      ...
    end
  end
  ...
end

form: templates/film/form.html.eex

... 
<%= hidden_input f, :collection_id, value: @collection_id, class: "form-control" %>

file: templates/film/new.html.eex

<%= render "form.html", changeset: @changeset,
                        manufacturers: @manufacturers,
                        collection_id: @collection_id,
                        action: film_path(@conn, :create) %>
 
 <%= link "Back", to: film_path(@conn, :index) %>

And that is all for today folks! We have made sure that our associations are working as expected, as well as creating a wizard like process for building parent associations from child records.

Check out the working code here.

In the next post I will be providing some fixtures for our developing application, and we will take on the hydra im sure some of you are wondering about “What about testing?”. Stay tuned loyal viewers, the Phoenix will rise again!