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