In the last episode, we introduced
Ecto.Multi
for transactions. I really
wanted you to know about
Ecto.Multi
, but in this particular case there is an
easier path we could take. Today we'll dig a bit further into that, as well as
see how we can best use Ecto from Phoenix for associations. Let's get started.
Project
We're starting with the dailydrip/firestorm repo tagged before this episode .
Right now our Schemas don't know about their associations, because the generators didn't add them. Let's fix that quickly:
vim lib/firestorm_web/forums/category.ex
defmodule FirestormWeb.Forums.Category do
use Ecto.Schema
alias FirestormWeb.Forums.Thread # <--
schema "forums_categories" do
field :title, :string
# Categories have many threads
has_many :threads, Thread # <--
timestamps()
end
end
vim lib/firestorm_web/forums/post.ex
defmodule FirestormWeb.Forums.Post do
use Ecto.Schema
alias FirestormWeb.Forums.{User, Thread} # <--
schema "forums_posts" do
field :body, :string
# Posts belong to threads
belongs_to :thread, Thread # <--
# and users
belongs_to :user, User # <--
timestamps()
end
end
vim lib/firestorm_web/forums/thread.ex
defmodule FirestormWeb.Forums.Thread do
use Ecto.Schema
alias FirestormWeb.Forums.{Category, Post} # <--
schema "forums_threads" do
field :title, :string
# Threads belong to categories
belongs_to :category, Category
# and have many posts
has_many :posts, Post
timestamps()
end
end
vim lib/firestorm_web/forums/user.ex
defmodule FirestormWeb.Forums.User do
use Ecto.Schema
alias FirestormWeb.Forums.Post # <--
schema "forums_users" do
field :email, :string
field :name, :string
field :username, :string
# Users have many posts
has_many :posts, Post # <--
timestamps()
end
end
Now we have our relationships set up properly. From here, we can actually insert
a thread with its first post without resorting to
Ecto.Multi
by including the
associated post in our changeset. We'll add a new changeset function for 'new
threads':
defmodule FirestormWeb.Forums do
# ...
defp new_thread_changeset(%{thread: thread_attrs, post: post_attrs}) do
# First we'll generate a post changeset - we don't require a thread_id here
# because we'll build it momentarily and it's impossible to know.
post_changeset =
%Post{}
|> cast(post_attrs, [:body, :user_id])
|> validate_required([:body, :user_id])
# Then we'll build our thread changeset like before, but we'll put a new
# associated post into the changeset. There's only one post since it's the
# first one, so we make a new list with just our one post in it.
%Thread{}
|> thread_changeset(thread_attrs)
|> put_assoc(:posts, [post_changeset])
end
# ...
end
Then we'll use this function in
create_thread
:
defmodule FirestormWeb.Forums do
# ...
def create_thread(category, user, attrs \\ %{}) do
post_attrs =
attrs
|> Map.take([:body])
|> Map.put(:user_id, user.id)
thread_attrs =
attrs
|> Map.take([:title])
|> Map.put(:category_id, category.id)
%{thread: thread_attrs, post: post_attrs}
|> new_thread_changeset
|> Repo.insert
end
# ...
end
So now this works but all of our tests are broken because we expected to get a 2-tuple back originally. Instead of making you watch me fix all that up I'll use the magic of git to introduce a fix!
git checkout mid_episode_006.4
Boom, now we're good. You can check out the episode script to check my work if you'd like, but it's a lot of bookkeeping. So now we're just using Ecto associations in our changesets. We know that if we send in the right sort of data, we can create a thread with a new post. But we can't actually do that yet because the UI isn't there. Let's visit our categories index page (making sure we're logged in).
We can go into a category, and there's no means to make a thread. Not cool. We'll fix it:
vim lib/firestorm_web/web/templates/category/show.html.eex
<h2>Show Category</h2>
<!-- ... -->
<!-- We'll add this link at the bottom -->
<span><%= link "New Thread", to: category_thread_path(@conn, :new, @category) %></span>
OK, if we click the link we get a form that just asks us for a title. We know we want a body to be sent as well when we create a thread, so let's get to tweaking:
vim lib/firestorm_web/web/templates/thread/new.html.eex
This is just the default generated form. We don't want that junk. Let's write our own 'new thread' form. I'm cheating a bit because I've already written some CSS, but you aren't here for the CSS:
<h2>Start a new thread</h2>
<%= form_for @changeset, category_thread_path(@conn, :create, @category), [class: "new-thread"], fn f -> %>
<%= text_input f, :title, placeholder: "Type a title" %>
<%= textarea f, :body, placeholder: "Type thread content here. Use markdown syntax." %>
<div class="action-bar">
<div class="end">
<%= link "Cancel", to: category_path(@conn, :show, @category), class: "pure-button button-primary -muted cancel" %>
<%= submit "Create Thread", class: "pure-button button-primary reply" %>
</div>
</div>
<% end %>
If we use that, it works...but we aren't showing the errors any longer. We can see this if we create a post with no title. It's super confusing.
Let's show the errors if they exist. Doing it with normal fields is easy:
<h2>Start a new thread</h2>
<%= form_for @changeset, category_thread_path(@conn, :create, @category), [class: "new-thread"], fn f -> %>
<%= text_input f, :title, placeholder: "Type a title" %>
<%= error_tag f, :title %>
<%= textarea f, :body, placeholder: "Type thread content here. Use markdown syntax." %>
<div class="action-bar">
<div class="begin">
</div>
<div class="end">
<%= link "Cancel", to: category_path(@conn, :show, @category), class: "pure-button button-primary -muted cancel" %>
<%= submit "Create Thread", class: "pure-button button-primary reply" %>
</div>
</div>
<% end %>
But creating a new
Thread
is also invalid if we don't pass a body for the
first post. Right now we won't see that error. We can dig in a bit deeper to
make this work:
<h2>Start a new thread</h2>
<%= form_for @changeset, category_thread_path(@conn, :create, @category), [class: "new-thread"], fn f -> %>
<%= text_input f, :title, placeholder: "Type a title" %>
<%= error_tag f, :title %>
<%= textarea f, :body, placeholder: "Type thread content here. Use markdown syntax." %>
<!-- This bit right here is what you want -->
<%= if Map.has_key?(@changeset.changes, :posts) do %>
<%= error_tag hd(@changeset.changes.posts), :body %>
<% end %>
<div class="action-bar">
<div class="begin">
</div>
<div class="end">
<%= link "Cancel", to: category_path(@conn, :show, @category), class: "pure-button button-primary -muted cancel" %>
<%= submit "Create Thread", class: "pure-button button-primary reply" %>
</div>
</div>
<% end %>
So we have a form but it's hideous. Now the css can come in, I'll paste it rather quickly. First, the action bar that I'll use a few times:
vim assets/css/components/_action-bar.scss
.action-bar {
margin-top: 1rem;
display: flex;
flex-direction: row;
> .begin {
display: flex;
flex-direction: row;
}
> .end {
justify-content: flex-end;
flex-grow: 1;
display: flex;
flex-direction: row;
align-self: flex-end;
}
}
And then the
new-thread
component:
vim assets/css/components/_new-thread.scss
.new-thread {
display: flex;
flex-direction: column;
> input, > textarea {
border-width: 0 0 1px 0;
border-color: $tower-gray;
resize: vertical;
margin-top: .1rem;
margin-bottom: .1rem;
&:focus {
border-color: $sky-blue;
outline: none;
box-shadow: none;
}
}
}
And the
button-primary
component:
vim assets/css/components/_button-primary.scss
.button-primary {
background: $punch;
color: $white;
&.-inverted {
background: transparent;
color: $punch;
}
&.-muted {
background: transparent;
color: $tundora;
}
}
Since we added new components, we'll have to restart Phoenix because the webpack library I'm using for glob support doesn't handle new files like you'd want it to . Once that's done, we can see that it looks a bit nicer, and we have errors.
Summary
Today we looked in a bit more detail at how to use Ecto associations with Phoenix. We also saw how error messages work in general. I hope you enjoyed it. See you soon!
Comments for Using Ecto Associations in Phoenix