Community contributed extensions

Viewing and posting comments

The blog home page is now set, and we will continue by writing the post details page. This page will show all the comments about the current post, and will include a form to post new comments.

Creating the ‘show’ action

To display the post details page, we will need a new action method on the Application controller. Let’s call it show:

def show(id: Long) = {
    Post.byIdWithAuthorAndComments(id).map {
        html.show(_)
    } getOrElse {
        NotFound("No such Post")
    }
}

As you can see this action is pretty simple. We declare the id method parameter to automatically retrieve the HTTP id parameter as a Long Scala value. This parameter will be extracted either from the query string, from the URL path or from the request body.

If we try to send an id HTTP parameter that is not a valid number, Play will automatically add a validation error to the errors container.

This action will display the /yabe/app/views/Application/show.scala.html template:

@(post:(models.Post,models.User,Seq[models.Comment]))
 
@main(title = post._1.title) {
    
    @display(post, mode = "full")
    
}

Because we’ve already written the display function, this page is really simple to write.

Adding links to the details page

In the display tag we’ve left all links empty (using #). It’s now time to make these links point to the Application.show action. With Play you can easily build links in a template using the action(…) helper. It uses the router to ‘reverse’ the URL needed to call the specified action.

Let’s edit the /yabe/app/views/tags/display.scala.html tag:

...
<h2 class="post-title">
    <a href="@action(controllers.Application.show(post._1.id()))">
        @post._1.title
    </a>
</h2>
...

You can new refresh the home page, and click a post title to display the post.

It’s great, but it lacks a link to go back to the home page. Edit the /yabe/app/views/main.scala.html template to complete the title link:

...
<div id="title">
    <span class="about">About this blog</span>
    <h1>
        <a href="@action(controllers.Application.index)">
            @play.Play.configuration.get("blog.title")
        </a>
    </h1>
    <h2>@play.Play.configuration.get("blog.baseline")</h2>
</div>
... 

We can now navigate between the home page and the post detail pages.

Specifying a better URL

As you can see, the post detail page URL looks like:

/application/show?id=1

This is because Play has used the default ‘catch all’ route.

*       /{controller}/{action}                  {controller}.{action}

We can have a better URL by specifying a custom path for the Application.show action. Edit the /yabe/conf/routes file and add this route after the first one:

GET     /posts/{id}                             Application.show

This way the id parameter will be extracted from the URL path.

Refresh the browser and check that it now uses the correct URL.

Adding pagination

To allow users to navigate easily through posts, we will add a pagination mechanism. We’ll extend the Post class to be able to fetch previous and next post as required.

Here is the SQL query we will use to fetch both previous and next post from a particular element:

(
    select *, 'next' as pos from post 
    where postedAt < {date} order by postedAt desc limit 1
)
    union
(
    select *, 'prev' as pos from post 
    where postedAt > {date} order by postedAt asc limit 1
)
 
order by postedAt desc

And we will translate the result as a (Option[Post], Option[Post]) value, where the first element of the tuple is the previous post, and the second element is the next post:

opt('pos.is("prev") ~> Post.on("")) ~ opt('pos.is("next") ~> Post.on(""))

Here is the new prevNext method we need to add to the Post class:

def prevNext = {        
    SQL(
        """
            (
                select *, 'next' as pos from post 
                where postedAt < {date} order by postedAt desc limit 1
            )
                union
            (
                select *, 'prev' as pos from post 
                where postedAt > {date} order by postedAt asc limit 1
            )
 
            order by postedAt desc
            
        """
    ).on("date" -> postedAt).as( 
        opt('pos.is("prev")~>Post.on("")) ~ opt('pos.is("next")~>Post.on("")) 
        ^^ flatten
    )
}

Now, let’s add this information to the show.html template:

def show(id: Long) = {
    Post.byIdWithAuthorAndComments(id).map { post =>
        html.show(post, post._1.prevNext)
    } getOrElse {
        NotFound("No such Post")
    }
}

We will call these methods several times during a request so they could be optimized, but they’re good enough for now. Also, add the pagination links at the top of the show.scala.html template:

@(
    post:(models.Post,models.User,Seq[models.Comment]),
    pagination:(Option[models.Post],Option[models.Post])
)
 
@main(title = post._1.title) {
    
    <ul id="pagination">
        @pagination._1.map { post =>
            <li id="previous">
                <a href="@action(controllers.Application.show(post.id()))">
                    @post.title
                </a>
            </li>
        }
        @pagination._2.map { post =>
            <li id="next">
                <a href="@action(controllers.Application.show(post.id()))">
                    @post.title
                </a>
            </li>
        }
    </ul>
    
    @display(post, mode = "full")
    
}

It’s better now.

Adding the comment form

Now it’s time to set up a comments form. We’ll start by adding the postComment action method to the Application controller.

def postComment(postId:Long) = {
    val author = params.get("author")
    val content = params.get("content")
    Comment.create(Comment(postId, author, content))
    Action(show(postId))
}

As you we return an Action value to indicate that we want to redirect to the show(postId) action.

Let’s write the HTML form in the show.html template (after the #{display /} tag in fact):

<h3>Post a comment</h3>
 
@form(controllers.Application.postComment(post._1.id())) {
    <p>
        <label for="author">Your name: </label>
        <input type="text" name="author" />
    </p>
    <p>
        <label for="content">Your message: </label>
        <textarea name="content"></textarea>
    </p>
    <p>
        <input type="submit" value="Submit your comment" />
    </p>
}

You can now try posting a new comment. It should just work.

Adding validation

Currently we don’t validate the form content before creating the comment. We would like to make both fields required. We can easily use the Play validation mechanism to ensure that the HTTP parameters are correctly filled in. Modify the postComment action to some validation and check that no error occurs:

def postComment(postId:Long) = {
    val author = params.get("author")
    val content = params.get("content")
    Validation.required("author", author)
    Validation.required("content", content)
    if(Validation.hasErrors) {
        show(postId)
    } else {
        Comment.create(Comment(postId, author, content))
        Action(show(postId))
    }
}

Don’t forget to import play.data.validation._ as well.

As you can see, in case of validation errors, we re-display the post detail page. We have to modify the form code to display the error message:

<h3>Post a comment</h3>
 
@form(controllers.Application.postComment(post._1.id())) {
    
    @if(errors) {
        <p class="error">
            All fields are required!
        </p>
    }
    
    <p>
        <label for="author">Your name: </label>
        <input type="text" name="author" value="@params.get("author")">
    </p>
    <p>
        <label for="content">Your message: </label>
        <textarea name="content">@params.get("content")</textarea>
    </p>
    <p>
        <input type="submit" value="Submit your comment" />
    </p>
}

Note that here we reference errors and params. So we need to add them to the template parameters list, and we can even mark them as implicit:

@(
    post:(models.Post,models.User,Seq[models.Comment]),
    pagination:(Option[models.Post],Option[models.Post])
)(
    implicit 
    params:play.mvc.Scope.Params,
    flash:play.mvc.Scope.Flash,
    errors:Map[String,play.data.validation.Error]
)
 
…

To make the UI feedback more pleasant for the poster, we will add a little JavaScript to automatically set focus on the comment form in case of an error. As this script uses JQuery Tools Expose as support libraries, you have to include them. Download these two libraries to the yabe/public/javascripts/ directory and modify the main.html template to include them:

…
<script src="@asset("public/javascripts/jquery-1.5.2.min.js")"></script>
<script src="@asset("public/javascripts/jquery.tools.min.js")"></script>
…

Now you can add this script to the show.scala.html template (add it at the end of the page):

<script type="text/javascript" charset="utf-8">
    $(function() {         
        // Expose the form 
        $('form').click(function() { 
            $('form').expose({api: true}).load(); 
        }); 
        
        // If there is an error, focus to form
        if($('form .error').size()) {
            $('form').expose({api: true, loadSpeed: 0}).load(); 
            $('form input[type=text]').get(0).focus();
        }
    });
</script>

The comment form looks pretty cool now. We will add two more things.

First, we will display a success message after a comment is successfully posted. For that, we use the flash scope that allows us to pass messages from one action call to the next one.

Modify the postComment action to add a success message:

def postComment(postId:Long) = {
    val author = params.get("author")
    val content = params.get("content")
    Validation.required("author", author)
    Validation.required("content", content)
    if(Validation.hasErrors) {
        show(postId)
    } else {
        Comment.create(Comment(postId, author, content))
        flash += "success" -> ("Thanks for posting " + author)
        Action(show(postId))
    }
}

and display the success message in show.html if present (add it at the top the page):

…
@if(flash.get("success")) {
    <p class="success">@flash.get("success")</p>
}
…

The last thing we will adjust in this form is the URL used for the postComment action. As always, it uses the default catch-all route because we didn’t define any specific route. So add this route to the application routes file:

POST    /posts/{postId}/comments                Application.postComment

That’s done.

Next: Setting up a Captcha.