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!

Resources