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
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
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:
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
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
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!