Skip to content
Permalink
Browse files

Add comment / reply functionality

  • Loading branch information
snewcomer committed Nov 8, 2019
1 parent 792eea5 commit 206aa5f62e8d6dc2737e0c8b20c6efec8d3c72b6
@@ -62,17 +62,18 @@
}

.comment-reply {
background-color: var(--blue-100);
border-radius: 3px;
}
.comment-reply:hover {
background-color: var(--blue-200);
}

.comment-reply a {
text-decoration: none;
padding: 10px 4px;
color: var(--gray-600);
padding: 0 6px;
color: var(--gray-700);
}

.comment-reply a:hover {
background-color: var(--blue-300);
color: var(--gray-300);
}

.comment-footer {
@@ -0,0 +1,38 @@
import autosize from 'autosize';

/**
* The comment class is manages dynamic UI elements related to comments
*
* - submit form and throw event up to live_view
* - autosize comment boxes to fit text
*
* @module Comment
*/
export default class Comment {
constructor(container) {
this.container = container;
this.attach();
this.autosize();
}

attach() {
this.replyForm = this.container;
this.replyTextArea = this.container.querySelector('textarea');

if (this.replyForm) {
this.replyForm.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.key == 'Enter') {
this.replyForm.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true, detail: undefined })
);
}
});
}
}

autosize() {
setTimeout(function() {
autosize(this.replyTextArea);
}, 50);
}
}
@@ -0,0 +1,18 @@
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import Comment from './modules/comment';

function buildComments(el) {
new Comment(el);
}

let hooks = {};

hooks.Comment = {
mounted() {
buildComments(this.el);
}
}

let liveSocket = new LiveSocket("/live", Socket, {hooks: hooks});
liveSocket.connect();

Some generated files are not rendered by default. Learn more.

@@ -9,7 +9,7 @@
"autosize": "^4.0.2",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../../phoenix_live_view"
"phoenix_live_view": "file:../deps/phoenix_live_view"
},
"devDependencies": {
"@babel/core": "^7.0.0",
@@ -0,0 +1,9 @@
defmodule LiveComment do
@moduledoc """
LiveComment keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end
@@ -0,0 +1,99 @@
defmodule LiveComment.Managed do
@moduledoc """
Managed the comments
"""

import Ecto.Query, warn: false
alias LiveComment.Repo

alias LiveComment.Managed.Comment

@pubsub LiveComment.PubSub
@topic "comments"

def subscribe(content_id) do
Phoenix.PubSub.subscribe(@pubsub, topic(content_id))
end

defp topic(content_id), do: "#{@topic}:#{content_id}"

@doc """
Returns the list of root comments.
## Examples
iex> list_root_comments()
[%Comment{}, ...]
"""
def list_root_comments do
from(c in Comment, where: is_nil(c.parent_id), order_by: [asc: :inserted_at])
|> Repo.all()
end

@doc """
Creates a comment.
## Examples
iex> create_comment(%{field: value})
{:ok, %Comment{}}
iex> create_comment(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_comment(attrs \\ %{}) do
%Comment{}
|> Comment.changeset(attrs)
|> Repo.insert()
|> preload()
|> broadcast_new_comment()
end
defp preload({:ok, %Comment{} = comment}), do: {:ok, Repo.preload(comment, :children)}
defp preload({:error, _reason} = err), do: err

defp broadcast_new_comment({:ok, comment}) do
Phoenix.PubSub.broadcast_from!(@pubsub, self(), topic("lobby"), {__MODULE__, :new_comment, comment})
{:ok, comment}
end
defp broadcast_new_comment({:error, _} = err), do: err

@doc """
Fetches all child comments for a list of parent comment IDs.
Returns results as a map grouped by parent_id.
"""
def fetch_child_comments(parent_ids) do
from(c in Comment, where: c.parent_id in ^parent_ids)
|> Repo.all()
|> Enum.group_by(&(&1.parent_id))
end

@doc """
## Examples
iex> nested_comments(comment)
{:ok, %Comment{}}
iex> nested_comments(comment)
{:error, %Ecto.Changeset{}}
"""
def nested_comments(comments) do
Comment.nested(comments)
end

@doc """
Returns an `%Ecto.Changeset{}` for tracking comment changes.
## Examples
iex> change_comment(comment)
%Ecto.Changeset{source: %Comment{}}
"""
def change_comment(comment \\ %Comment{}) do
Comment.changeset(comment, %{})
end
end
@@ -0,0 +1,44 @@
defmodule LiveComment.Managed.Comment do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query, only: [from: 2]

alias LiveComment.Repo

schema "comments" do
field :body, :string

belongs_to :parent, __MODULE__, foreign_key: :parent_id
has_many :children, __MODULE__, foreign_key: :parent_id

timestamps()
end

@doc false
def changeset(comment, attrs) do
comment
|> cast(attrs, [:body, :parent_id])
|> validate_required([:body])
end

def nested(nil), do: []
def nested([]), do: []
def nested(comments) do
comments
|> Enum.map(&Map.put(&1, :children, []))
|> Enum.reduce(%{}, fn comment, map ->
comment = %{comment | children: Map.get(map, comment.id, [])}
Map.update(map, comment.parent_id, [comment], fn comments -> [comment | comments] end)
end)
|> Map.get(nil)
end

def newest_last(query, field \\ :inserted_at) do
from(q in query, order_by: [desc: ^field])
end

def human_date(naive) do
[naive.year, naive.month, naive.day]
|> Enum.join("/")
end
end
@@ -0,0 +1,58 @@
defmodule LiveCommentWeb.CommentLive.Index do
use Phoenix.LiveView
use Phoenix.HTML

alias LiveComment.Managed
alias LiveCommentWeb.CommentLive

def render(assigns) do
~L"""
<div class="main">
<div class="js-comment">
<%= f = form_for @changeset, "#", class: "comment_form", phx_submit: "save", phx_hook: "Comment" %>
<%= textarea f, :body, rows: 2, required: true,
placeholder: "Cool beans..." %>
<div class="comment_form-footer">
<button type="submit">Comment</button>
</div>
</form>
</div>
<div class="comment_list" id="root-comments" phx-update="append">
<%= for comment <- @comments do %>
<%= live_component @socket, CommentLive.Show, id: comment.id, comment: comment, kind: :parent %>
<% end %>
</div>
</div>
"""
end

def mount(_session, socket) do
comments = Managed.list_root_comments()
changeset = Managed.change_comment()
socket = assign(socket, [changeset: changeset, comments: comments])
if connected?(socket), do: Managed.subscribe("lobby")

{:ok, socket, temporary_assigns: [comments: []]}
end

def handle_event("save", %{"comment" => comment_params}, socket) do
case Managed.create_comment(comment_params) do
{:ok, comment} ->
{:noreply, assign(socket, comments: [comment], changeset: Managed.change_comment())}

{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end

def handle_info({Managed, :new_comment, comment}, socket) do
if comment.parent_id do
send_update(CommentLive.Show, id: comment.parent_id, children: [comment])
{:noreply, socket}
else
{:noreply, assign(socket, comments: [comment])}
end
end
end

@@ -0,0 +1,77 @@
defmodule LiveCommentWeb.CommentLive.Show do
use Phoenix.LiveComponent
use Phoenix.HTML

alias LiveComment.Managed
alias LiveComment.Managed.Comment
alias LiveCommentWeb.CommentLive
alias LiveCommentWeb.CommentView

def render(assigns) do
~L"""
<a id="comment-anchor-<%= @comment.id %>" class="anchor"></a>
<article class="comment comment--<%= @kind %>" id="comment-<%= @comment.id %>">
<div class="comment-body">
<%= @comment.body %>
</div>
<div class="comment-footer">
<p class="comment-reply">
<a href="javascript:;" phx-click="toggle-reply" title="Reply to this comment">reply</a>
</p>
<p class="comment-permalink">
<%= link CommentView.format_time(@comment.inserted_at), to: "#comment#{@id}" %>
</p>
</div>
<%= if @form_visible do %>
<%= f = form_for @changeset, "#", [class: "comment_form", phx_submit: :save, phx_hook: "Comment"] %>
<%= textarea f, :body, rows: 2, required: true,
placeholder: "Your reply..." %>
<div class="comment_form-footer">
<button type="submit">Reply</button>
</div>
</form>
<% end %>
<section class="comment-replies" id="replies-<%= @id %>" phx-update="append">
<%= for child <- @children do %>
<%= live_component @socket, CommentLive.Show, id: child.id, comment: child, kind: :child %>
<% end %>
</section>
</article>
"""
end

def mount(socket) do
{:ok, assign(socket, form_visible: false, changeset: Managed.change_comment()),
temporary_assigns: [comment: nil, children: []]}
end

def handle_event("toggle-reply", _, socket) do
{:noreply, update(socket, :form_visible, &(!&1))}
end

def preload(list_of_assigns) do
parent_ids = Enum.map(list_of_assigns, & &1.id)
children = Managed.fetch_child_comments(parent_ids)

Enum.map(list_of_assigns, fn assigns ->
Map.put(assigns, :children, Map.get(children, assigns.id, []))
end)
end

def handle_event("save", %{"comment" => comment_params}, socket) do
comment_params
|> Map.put("parent_id", socket.assigns.id)
|> Managed.create_comment()
|> case do
{:ok, new_comment} ->
{:noreply, assign(socket, form_visible: false, children: [new_comment])}

{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end

0 comments on commit 206aa5f

Please sign in to comment.
You can’t perform that action at this time.