Latest Update: 08/02/2016
Writing a Blog Engine in Phoenix and Elixir: Part 6, Markdown Support_Latest Update: 08/02/2016_medium.com
Our blogging application now supports using a nice little markdown editor to make our posts even prettier, so it’s really starting to look like a full-featured project! One thing we don’t have, though, is any way to receive any sort of feedback on any of the posts we write. The good news is this is a pretty simple thing to add and just builds on a lot of the work with associations that we’ve already built!
We’re going to start very simple on this initial approach. We’re not going to worry about requiring users to register; instead we’re going to have new comments get added into a “pending” state. Comments are not displayed on the Post unless they’ve been approved out of that pending state unless the user visiting our blog checks a “Show Unapproved” button.
Let’s start by adding a model for our comments. We’re expecting comments to have the following:
We don’t want to create the full suite of templates and everything, so we’re going to stick to using mix phoenix.gen.model for this.
mix phoenix.gen.model Comment comments author:string body:text approved:boolean post_id:references:posts
And then we’ll migrate our DB:
mix ecto.migrate
If you open up web/models/comment.ex you can see that Comments are now already associated with Posts, but we haven’t done anything to set that up in reverse.
In web/models/post.ex, add the following to your “posts” schema definition:
has_many :comments, Pxblog.Comment
And now just to make sure everything is working as we expect, run mix test to verify that everything is still green (it should be at this point)! Now let’s modify our tests slightly so that we can test the association between a Post and a Comment.
First, open up test/support/factory.ex and add a Comment factory. Add the following at the top with the rest of our alias statements:
alias Pxblog.Comment
And add the following at the bottom of the project:
def comment_factory do%Comment{author: "Test User",body: "This is a sample comment",approved: false,post: build(:post)}end
Accordingly, we need to add some tests to take advantage of this new factory. Open up test/models/comment_test.exs and add the following to the file:
import Pxblog.Factory
# ...
test "creates a comment associated with a post" docomment = insert(:comment)assert comment.post_idend
Run our tests again and we should remain green!
We’ll start by setting up our initial routes that we’ll be using for comments, which will help us create an initial technical design of sorts. Open up web/router.ex:
resources "/posts", PostController, only: [] doresources "/comments", CommentController, only: [:create, :delete, :update]end
Comments really only make sense in the context of posts, so we’re going to nest comments underneath posts. However, in the other routes, we’ve already defined the Posts resources nested under users! We don’t want to expose a bunch of post-only routes, so we use the only: [] code to lock out any root level post routes. Then we add the comments resources, but for right now, we’re only going to allow :create, :delete, and :update. Create will be what anonymous users use to create comments (that are initially unapproved). Delete will be for the author of the post or admins to delete a post, and update will be used to allow an admin or author to update a comment to approved so it can be seen by the general public.
Now that our models are set up, we need to build a controller with a couple of actions. Most of the showing/viewing comments is going to be via the Posts controller, but we’ll need to be able to delete/update/create comments from this controller in time. We’ll begin by creating web/controllers/comment_controller.ex:
defmodule Pxblog.CommentController douse Pxblog.Web, :controllerend
And we’ll create a view as well to keep Phoenix happy with us. Create web/views/comment_view.ex:
defmodule Pxblog.CommentView douse Pxblog.Web, :viewend
Now we’ll head back to the controller and start off with a very basic structure of our three actions, create, update, and delete.
def create(conn, _), do: conndef update(conn, _), do: conndef delete(conn, _), do: conn
Next, we’ll need a comment form template that we can use on the Show Post page. Create a new directory for us to work with:
$ mkdir web/templates/comment
We’ll start by creating web/templates/comment/form.html.eex:
<%= form_for @changeset, @action, fn f -> %><%= if @changeset.action do %><div class="alert alert-danger"><p>Oops, something went wrong! Please check the errors below.</p></div><% end %>
<div class="form-group"><%= label f, :author, class: "control-label" %><%= text_input f, :author, class: "form-control" %><%= error_tag f, :author %></div>
<div class="form-group"><%= label f, :body, class: "control-label" %><%= textarea f, :body, class: "form-control", id: "body-editor" %><%= error_tag f, :body %></div>
<div class="form-group"><%= submit "Submit", class: "btn btn-primary" %></div><% end %>
Pretty standard form here, so not much we need to discuss. Now let’s head to the show post template web/templates/post/show.html.eex, where we’ll add a reference to the comment form. Please note that in the template above, we define @changeset and @action, so we’ll need to cycle back to web/controllers/post_controller.ex later.
For right now, let’s stick with web/templates/post/show.html.eex. After the list of attributes for the post, add the following line:
<%= render Pxblog.CommentView, "form.html", changeset: @comment_changeset, action: post_comment_path(@conn, :create, @post) %>
We need to reference the “form.html” render inside of our CommentView View, so we specify that as the first argument to the “render” call. We need to pass it a @comment_changeset (which we haven’t set yet, but will soon) and an action, which is where the comments should get posted to.
And now we’ll head to web/controllers/post_controller.ex to make it all work. You’ll need to change the “show” function to resemble the following:
def show(conn, %{"id" => id}) dopost = Repo.get!(assoc(conn.assigns[:user], :posts), id)comment_changeset = post|> build_assoc(:comments)|> Pxblog.Comment.changeset()render(conn, "show.html", post: post, comment_changeset: comment_changeset)end
Now let’s go back to our CommentController (web/controllers/comment_controller.ex) and flesh out the create function a little more. We’ll need some stuff at the top for a standard create function, so we’ll start by adding the following to the top (right before our functions):
alias Pxblog.Commentalias Pxblog.Post
plug :scrub_params, "comment" when action in [:create, :update]
We’re including “update” in the scrub_params call because we’ll need that later anyways. Now, we’re going to hop down to the create function and replace it with the following:
def create(conn, %{"comment" => comment_params, "post_id" => post_id}) dopost = Repo.get!(Post, post_id) |> Repo.preload([:user, :comments])changeset = post|> build_assoc(:comments)|> Comment.changeset(comment_params)
case Repo.insert(changeset) do{:ok, _comment} ->conn|> put_flash(:info, "Comment created successfully!")|> redirect(to: user_post_path(conn, :show, post.user, post)){:error, changeset} ->render(conn, Pxblog.PostView, "show.html", post: post, user: post.user, comment_changeset: changeset)endend
We first only want to try to create a comment when we’ve been passed comment params and the post_id, since without both of those we cannot create a new comment. Next, we fetch the associated post (remember to preload both the user and comments, since our template will start referencing both!) and start creating a new associated changeset. We start off with the post and pipe that into build_assoc, which builds an associated schema by specifying the association via an atom. In this case, we want to build an associated comment. We then pipe that into the Comment.changeset function with the comment_params we pattern matched. The rest of this is business as usual with one exception:
The error condition is a little complicated because again, we’re trying to use another View’s render. We first pass the connection, then the appropriate View to use (Pxblog.PostView in our case), the template we want to render, and then since that template uses @post, @user, and @comment_changeset, we need to supply the three. Now you can test it out: if it posts with errors, you’ll get the error listing for the comment right on the page. If it posts without errors, you’ll get a blue flash message at the top! Great progress!
Next, we need to make it so we can actually see the posts on the template. We’re going to create a shared template that can be used if we ever want to display comments in other places for different things, so we’re going to create web/templates/comment/comment.html.eex and fill it with the following:
<div class="comment"><div class="row"><div class="col-xs-4"><strong><%= @comment.author %></strong></div><div class="col-xs-4"><em><%= @comment.inserted_at %></em></div><div class="col-xs-4 text-right"><%= unless @comment.approved do %><button class="btn btn-xs btn-primary approve">Approve</button><% end %><button class="btn btn-xs btn-danger delete">Delete</button></div></div><div class="row"><div class="col-xs-12"><%= @comment.body %></div></div></div>
There’s not much to explain here. The approve/delete buttons aren’t hooked up yet; we’ll tackle that in the next tutorial in this series. We also need to modify the controller to preload the comments and modify the show template on Posts to include the listing of comments. First, we’ll tackle the controller updates. In web/controllers/post_controller.ex, in the show function, we’ll add one line to the line that fetches the post (I’ve bolded the new line):
post = Repo.get!(assoc(conn.assigns[:user], :posts), id)|> Repo.preload(:comments)
This just ensures that our comments are loaded as part of our post. Finally, we’ll open up web/templates/post/show.html.eex and add the section of our template that displays comments:
<div class="comments"><h2>Comments</h2><%= for comment <- @post.comments do %><%= render Pxblog.CommentView, "comment.html", comment: comment %><% end %></div>
We can’t leave yet, since we still have some conditions not well-covered by tests! We should cover the controller’s create function and we’ll stick to one positive case and one negative case, since those are the paths that our code will travel through.
Create test/controllers/comment_controller_test.exs and let’s begin:
defmodule Pxblog.CommentControllerTest douse Pxblog.ConnCase
import Pxblog.Factory
@valid_attrs %{author: "Some Person", body: "This is a sample comment"}@invalid_attrs %{}
setup douser = insert(:user)post = insert(:post, user: user)
{:ok, conn: build\_conn(), user: user, post: post}
end
test "creates resource and redirects when data is valid", %{conn: conn, post: post} doconn = post conn, post_comment_path(conn, :create, post), comment: @valid_attrsassert redirected_to(conn) == user_post_path(conn, :show, post.user, post)assert Repo.get_by(assoc(post, :comments), @valid_attrs)end
test "does not create resource and renders errors when data is invalid", %{conn: conn, post: post} doconn = post conn, post_comment_path(conn, :create, post), comment: @invalid_attrsassert html_response(conn, 200) =~ "Oops, something went wrong"endend
We’ll continue using Pxblog.Factory since it is so handy. We’ll also set up two module variables, @valid_attrs and @invalid_attrs, same as we do pretty much everywhere else. We also create a setup block that’ll set up a default user and post for us to use to create comments with.
Next, we’ll create our positive test case. We post to the nested post->comment path with valid attributes, assert we get redirected as expected and that the comment was created for that post!
Finally, we’ll do the same, but with invalid data, and verify that we get the “Oops, something went wrong” message that appears in the HTML when something fails validations! Done!
Our current UI!
We have a great foundation for our comments, but we can definitely improve this more. For example, we still cannot approve or delete comments, and we also will fetch every single comment for a post. In the next few posts, we’ll work on refining those a little more before moving on to changing our comment system into a live comment system powered by Phoenix’s channels!
Update 02/29/2016: Apparently I completely left off the section where we add the comments to the actual display of posts! I’ve updated this post to now properly reflect those changes :) Thanks Andrew Benz!
Writing a Blog Engine in Phoenix and Elixir: Part 8, finishing comments_Latest Update: 08/02/2016_medium.com
Hey everyone! If you liked what you read here and want to learn more with me, check out my new book on Elixir and Phoenix web development:
Phoenix Web Development | PACKT Books_Learn to build a high-performance functional prototype of a voting web application from scratch using Elixir and…_www.packtpub.com
I’m really excited to finally be bringing this project to the world! It’s written in the same style as my other tutorials where we will be building the scaffold of a full project from start to finish, even covering some of the trickier topics like file uploads, Twitter/Google OAuth logins, and APIs!