In the last episode, we added our first polymorphic schema to support watching
threads. In the future, we'll support watching users as well. However,
there's no UI support for watching a thread. Let's support watches in the UI.
While we're at it, we'll modify the
Notifications
to only notify users that
are watching a thread when a new post is created. Let's get started.
Project
We're starting with the dailydrip/firestorm repo tagged before this episode .
Adding a controller action to handle
Watch
es
Let's add a controller action to handle watching a thread. We'll start with a feature test:
vim test/feature/threads_test.exs
defmodule FirestormWeb.Feature.ThreadsTest do
# ...
test "watching a thread", %{session: session} do
import Page.Thread.Show
{:ok, [elixir]} = create_categories(["Elixir"])
{:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@smoothterminal.com", name: "Josh Adams"})
{:ok, otp_is_cool} = Forums.create_thread(elixir, user, @otp_is_cool_parameters)
session
|> log_in_as(user)
|> visit(category_thread_path(FirestormWeb.Web.Endpoint, :show, elixir, otp_is_cool))
|> refute_has(watched_icon())
|> click(watch_link())
|> assert_has(watched_icon())
end
# ...
end
Here we'd like to assert that the
watched_icon()
doesn't exist - this would be
the eyeball icon with an active state. Then we'll click the
watch_link()
,
which is that same icon regardless of the state. Finally, we'll assert that the
watched_icon()
exists. Let's add these selectors to the
Page.Thread.Show
module.
vim test/support/page/thread/show.ex
defmodule Page.Thread.Show do
# ...
def watched_icon(), do: css(".fa-eye.-highlight")
def watch_link(), do: action_link("watch")
defp action_link(name) do
css("#{first_post_actions_selector()} > li.#{name} > a")
end
defp first_post_actions_selector() do
".first-post > .post-item-actions > .actions"
end
end
If we run the test now, we see that everything works until it looks for the icon
to have been marked
-highlight
ed. Let's next implement a controller action.
vim lib/firestorm_web/web/router.ex
defmodule FirestormWeb.Web.Router do
# ...
scope "/", FirestormWeb.Web do
# ...
resources "/categories", CategoryController do
get "/threads/:id/watch", ThreadController, :watch
get "/threads/:id/unwatch", ThreadController, :unwatch
resources "/threads", ThreadController do
# ...
end
end
end
# ...
end
Here we've added support for "unwatching" as well, which we'll implement as well. Next, let's add the controller actions:
vim lib/firestorm_web/web/controllers/thread_controller.ex
defmodule FirestormWeb.Web.ThreadController do
# ...
plug FirestormWeb.Web.Plugs.RequireUser when action in [:new, :create, :watch, :unwatch]
# ...
def watch(conn, %{"id" => id}, category) do
thread =
Forums.get_thread!(category, id)
current_user(conn)
|> Forums.watch(thread)
conn
|> redirect to: category_thread_path(conn, :show, category.id, id)
end
def unwatch(conn, %{"id" => id}, category) do
# FIXME: Implement this
conn
|> redirect to: category_thread_path(conn, :show, category.id, id)
end
# ...
end
This doesn't make the test pass yet. For that, we need to identify whether or
not the thread is watched. We'll add that as an
assign
on the
show
action:
defmodule FirestormWeb.Web.ThreadController do
# ...
def show(conn, %{"id" => id}, category) do
thread =
Forums.get_thread!(category, id)
|> Repo.preload(:posts)
[ first_post | posts ] = thread.posts
watched =
if current_user(conn) do
thread |> Forums.watched_by?(current_user(conn))
else
false
end
render(conn, "show.html", thread: thread, category: category, first_post: first_post, posts: posts, watched: watched)
end
# ...
end
Now we'll need to pass this assign through to the
_first_post.html.eex
template. We start in the thread's
show.html.eex
template:
<!-- ... -->
<ol class="post-list">
<li><%= render "_first_post.html", post: @first_post, conn: @conn, category: @category, thread: @thread, watched: @watched %></li>
<!-- ... -->
</ol>
<!-- ... -->
Then we'll move on to
_first_post.html.eex
:
<div class="post-item first-post">
<!-- ... -->
<div class="post-item-actions">
<!-- ... -->
<ul class="actions">
<!-- ... -->
<li class="watch">
<a href="<%= category_thread_path(@conn, :watch, @category, @thread) %>">
<i class="fa fa-eye <%= if @watched, do: "-highlight", else: "" %>"></i>
</a>
</li>
<!-- ... -->
</ul>
</div>
</div>
With that, the tests pass. Let's extend the test to ensure we can unwatch by clicking the link again:
test "watching a thread", %{session: session} do
# ...
session
|> log_in_as(user)
|> visit(category_thread_path(FirestormWeb.Web.Endpoint, :show, elixir, otp_is_cool))
|> refute_has(watched_icon())
|> click(watch_link())
|> assert_has(watched_icon())
|> click(watch_link()) # <-- unwatch it now
|> refute_has(watched_icon()) # <-- ensure it unwatches
end
If we run this test, it fails again because we haven't implemented unwatching. Let's do that quickly:
def unwatch(conn, %{"id" => id}, category) do
thread =
Forums.get_thread!(category, id)
current_user(conn)
|> Forums.unwatch(thread)
conn
|> redirect to: category_thread_path(conn, :show, category.id, id)
end
We'll implement
Forums.unwatch
by deleting all watches for this user and
thread:
@doc """
Ensure a user no longer watches a thread:
iex> %User{} |> unwatch(%Thread{})
:ok
"""
def unwatch(%User{} = user, %Thread{} = thread) do
# Here we'll use a table name as a string, rather than a schema, as our
# primary source query. You can do this if you want to interact with a
# database without going through Schemas.
"forums_threads_watches"
|> where(assoc_id: ^thread.id)
|> where(user_id: ^user.id)
|> Repo.delete_all()
:ok
end
Now we need to update the link's
href
depending on whether or not the thread
is being watched:
<!-- ... -->
<li class="watch">
<%= if @watched do %>
<a href="<%= category_thread_path(@conn, :unwatch, @category, @thread) %>">
<i class="fa fa-eye -highlight"></i>
</a>
<% else %>
<a href="<%= category_thread_path(@conn, :watch, @category, @thread) %>">
<i class="fa fa-eye"></i>
</a>
<% end %>
</li>
<!-- ... -->
With that, the tests pass. The only remaining feature for today is to only notify users by email if they're watching a thread. First, we'll update the notifications test:
defmodule FirestormWeb.NotificationsTest do
use FirestormWeb.DataCase
alias FirestormWeb.Forums
use Bamboo.Test, shared: true
test "creating a post in a thread notifies everyone watching the thread" do
{:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@smoothterminal.com", name: "Josh Adams"})
# Create a new user
{:ok, bob} = Forums.create_user(%{username: "bob", email: "bob@smoothterminal.com", name: "Bob Vladbob"})
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
# Have bob create the thread - he would have received a notification since
# he posted in the thread under the old logic.
{:ok, otp_is_cool} = Forums.create_thread(elixir, bob, %{title: "OTP is cool", body: "Don't you think?"})
# Watch the thread from a user that hasn't posted in it.
{:ok, _} = user |> Forums.watch(otp_is_cool)
# Post in the thread as bob again
{:ok, yup} = Forums.create_post(otp_is_cool, bob, %{body: "yup"})
# The watching user receives an email
assert_delivered_email FirestormWeb.Emails.thread_new_post_notification(user, otp_is_cool, yup)
# The involved user doesn't since he's not watching
refute_delivered_email FirestormWeb.Emails.thread_new_post_notification(bob, otp_is_cool, yup)
end
end
If we try to run the test now, it will fail because we need to configure
Bamboo
a bit more if we want to refute delivery:
vim config/test.exs
# ...
config :bamboo, :refute_timeout, 10
Now if we run it, it fails. Let's fix the logic:
defmodule FirestormWeb.Notifications do
# ...
def handle_cast({:post_created, %Post{} = post}, :nostate) do
# 1) Find all users that are watching this thread
with post when not is_nil(post) <- Repo.preload(post, [thread: [:watchers]]),
thread when not is_nil(thread) <- post.thread,
users <- thread.watchers |> Enum.uniq do
# 2) Send each of them an email about it
for user <- users do
Emails.thread_new_post_notification(user, post.thread, post)
|> Mailer.deliver_now
end
else
_ ->
:ok
end
{:noreply, :nostate}
end
end
With this, the test passes, and our users are less likely to get angry with us.
Summary
In today's episode, we added UI support for watching a thread and unwatching it,
we extended the
Forums
context to support
unwatch
, and we modified our
Notifications
to be a bit smarter. See you soon!
Comments for Handling Watched Threads in the Frontend