Building blogmate.io: Recursive Phoenix Components
This post is part of a series on building blogmate.io, a rich comment system for static blogs and sites. If you have a static blog, or are thinking of starting one, please go check it out and spread the word!
The main component of blogmate.io is its embeddable comment system, which you can see at the bottom of this page. I made a decision early on that threaded comments are a must and a P0 feature. I wanted to be able to support nested comments of any arbitrary depth, in theory at least, with a simple data model and rendering algorithm. After some cursory reading, I settled on the following schema (simplified).
Comment |
---|
id |
parent_id |
body |
For top-level comments, the parent_id
attribute would be set to nil
. For nested comments, parent_id
would be set to the id
of the child component’s direct parent. If you think of each comment being a node, it will become apparent that this is a graph, a DAG to be more precise.
How would we render such a structure? By using DFS of course! DFS itself is naturally expressed using recursion and because we don’t have to worry about cycles, there’s no need to keep track of visited nodes, making our traversal algorithm super simple.
As you’ve guessed, blogmate.io is built with Elixir and Phoenix LiveView, with pages being composed of reusable Phoenix Components. It may seem tricky at first to recurse inside a component as Phoenix Components are restricted to an arity of one, with the only allowed argument being the assigns
map. However, you can get around this by reassigning before returning the HEEX template as documented here. Technically, these limitations are mostly centered around LiveViews. Traditional views can reference variables outside the assigns
map as change-tracking isn’t needed. However, doing so is considered bad form and the compiler will throw a warning.
With that in mind, let’s take a look at some code to put these concepts into action. We’ll use a traditional controller-based view in case you’re unfamiliar with LiveView.
Code
comment_controller.ex
defmodule DemoWeb.CommentController do
use DemoWeb, :controller
def index(conn, _params) do
comments = [
%{parent_id: nil, id: 0, body: "hello"},
%{parent_id: 0, id: 1, body: "hello to you"},
%{parent_id: 0, id: 3, body: "hi there"},
%{parent_id: 1, id: 2, body: "how do ya do sir"},
%{parent_id: nil, id: 4, body: "i like cheese"}
]
grouped_comments = Enum.group_by(comments, & &1.parent_id)
render(conn, grouped_comments: grouped_comments)
end
end
comment_html.ex
Notice here how we make the root
attr default to nil. This means we won’t have to pass in a value for @root
in our HEEX template. We’ll also color-code the comments by ancestor just to make things a little more clear.
defmodule DemoWeb.CommentHTML do
use DemoWeb, :html
use Phoenix.Component
embed_templates "demo_html/*"
@colors {
"bg-blue-100",
"bg-yellow-100",
"bg-green-100"
}
attr :root, :any, default: nil
attr :grouped_comments, :any, required: true
def dfs(assigns) do
assigns =
assign(
assigns,
:nodes,
Map.get(assigns.grouped_comments, get_key(assigns.root), [])
)
~H"""
<div :if={@root} class={get_class((@root.parent_id || -1) + 1)}>
<p>{"comment id: #{@root.id}"}</p>
<p><strong>{@root.body}</strong></p>
</div>
<div :for={node <- @nodes} class="m-6">
<.dfs root={node} grouped_comments={@grouped_comments} />
</div>
"""
end
defp get_key(nil), do: nil
defp get_key(root), do: root.id
defp get_class(id) do
"#{elem(@colors, id)} h-20 w-96 border border-4 border-black flex flex-col items-center justify-center"
end
end
index.html.heex
<.dfs grouped_comments={@grouped_comments} />
Result
If you try this out yourself, you should see the following.
If you have you any questions, or suggestions, feel free to add a comment below 😉. Happy coding and Merry Christmas!