Grails Cookbook - A collection of tutorials and examples

Grails Forum Application

This tutorial will show how to implement a simple forum application using Grails. Complete source code will be shown at the end

Introduction

Creating a working application is a good way to learn the Grails framework. You will be able to put different concepts together to make something work. Since most sites teaches how to write a blog program, this post will show how to implement a forum program instead.

Here is how the finished program will look like:

The home screen will show the list of sections and topics within a forum. At the side of each topic, the user will see how many discussion threads exists and the number of comments from community members.

We need a login screen before we could allow users to post messages to the forum.

When a user is logged in, the user details is shown on top right together with a logout button.


When a topic is selected, the list of discussion threads are shown in paginated form. The number of comments for each thread is also shown.

When a discussion thread is selected, the user comments in the thread are shown. If the user is logged in, a form is shown where he or she can reply.

After replying, the comment is added to the end. The user can view his last message because he or she is redirected at the last page of the thread.

Spring Security Core Plugin

We need the Spring Security Core Plugin to be able to handle user management easily in the forum application.

Install the plugin - edit grails-app/conf/BuildConfig.groovy and add:

grails.project.dependency.resolution = {
    ...
    plugins {
        ...
        compile ":spring-security-core:1.2.7.3"
    }
}

Then in command line, issue this:

grails s2-quickstart asia.grails.forum SecUser SecRole

The following domains will be automatically created:
  • SecUser - represents a user.
  • SecRole - this represents a role given to a user.
  • SecUser - this is the middle relationship table to relate user to a role (many to many relationship)
Check this post for a more detailed tutorial about Spring Security Core plugin.

Domain Classes

Aside from the domain generated by the plugin above, here are the additional domain classes for the Grails forum application

Section - as shown in the screenshots, this represents the grouping of topics in the forum.
package asia.grails.forum
class Section {
    static hasMany = [topics:Topic]
    String title
}
Topic - from the screenshots, this represents the grouping of discussion threads.
package asia.grails.forum
class Topic {
    static belongsTo = Section
    static hasMany = [threads:DiscussionThread]
    Section section
    String title
    String description
}
DiscussionThread - represents a discussion of community members about a specific subject.
package asia.grails.forum
class DiscussionThread {
    static belongsTo = Topic
    static hasMany = [comments:Comment]
    Topic topic
    String subject
    SecUser opener
    Date createDate = new Date()
}
Comment - represent a single comment made by a user on a discussion thread.
package asia.grails.forum
class Comment {
    static belongsTo = DiscussionThread
    DiscussionThread thread
    SecUser commentBy
    String body
    Date createDate = new Date()
    static constraints = {
        body( maxSize: 8000)
    }
}
The domain classes above uses one to many structure.

Forum Data Blow Up

Here is a sample Bootstrap code to create example data for the target Grails forum application. This will be helpful for testing.
import asia.grails.forum.Comment
import asia.grails.forum.DiscussionThread
import asia.grails.forum.SecRole
import asia.grails.forum.SecUser
import asia.grails.forum.SecUserSecRole
import asia.grails.forum.Section
import asia.grails.forum.Topic
class BootStrap {
    def random = new Random();
    def words = ("time,person,year,way,day,thing,man,world,life,hand,part,child,eye,woman,place,work,week,case,point," +
                "government,company,number,group,problem,fact,be,have,do,say,get,make,go,know,take,see,come,think,look," +
                "want,give,use,find,tell,ask,work,seem,feel,try,leave,call,good,new,first,last,long,great,little,own," +
                "other,old,right,big,high,different,small,large,next,early,young,important,few,public,bad,same,able,to,of," +
                "in,for,on,with,at,by,from,up,about,into,over,after,beneath,under,above,the,and,a,that,I,it,not,he,as,you," +
                "this,but,his,they,her,she,or,an,will,my,one,all,would,there,their").split(",")
    def init = { servletContext ->
        if (SecUser.count() == 0) {  // no user in db, lets create some
            def defaultRole = new SecRole(authority: 'ROLE_USER').save()
            // create 100 users
            (1..100).each { userNo ->
                String username = "user${userNo}"
                def user = new SecUser(username:username, password: 'secret', enabled: true).save()
                // all users will have default role
                new SecUserSecRole(	secUser:user, secRole: defaultRole).save()
            }
        }
        if ( Section.count() == 0 ) { // create data if no forum data found
            // get all users
            def users = SecUser.list()
            // create 3 sections
            ('A'..'C').each { sectionLetter ->
                def sectionTitle = "Section ${sectionLetter}"
                def section = new Section(title: sectionTitle).save()
                // create 4 topics per section
                (1..4).each { topicNumber ->
                    def topicTitle = "Topic ${sectionLetter}-${topicNumber}"
                    def topicDescription = "Description of ${topicTitle}"
                    def topic = new Topic(section: section, title: topicTitle, description: topicDescription).save()
                    // create 10-20 threads each topic
                    def numberOfThreads = random.nextInt(11)+10
                    (1..numberOfThreads).each { threadNo ->
                        def opener = users[random.nextInt(100)]
                        def subject = "Subject ${sectionLetter}-${topicNumber}-${threadNo} "
                        def thread = new DiscussionThread(topic:topic, subject:subject, opener:opener).save()
                        new Comment(thread:thread, commentBy:opener, body:generateRandomComment()).save()
                        // create 10-35 replies per thread
                        def numberOfReplies = random.nextInt(26)+10
                        numberOfReplies.times {
                            def commentBy = users[random.nextInt(100)]
                            new Comment(thread:thread, commentBy:commentBy, body:generateRandomComment()).save()
                        }
                    }
                }
            }
        }
    }
    private String generateRandomComment() {
        def numberOfWords = random.nextInt(50) + 15
        StringBuilder sb = new StringBuilder()
        numberOfWords.times {
            def randomWord = words[random.nextInt(words.length)]
            sb.append("${randomWord} ")
        }
        return sb.toString()
    }
    def destroy = {
    }
}
The code above will create 100 test users. Then it will create sections, topics, threads, and comments for testing navigation.

Home Page


It is easy to map the home context to a specific controller action. For example, when we want to invoke forum controller's home action when "/" is accessed, update UrlMappings groovy class.
class UrlMappings {
	static mappings = {
		"/$controller/$action?/$id?"{
			constraints {
				// apply constraints here
			}
		}
		"/"(controller: 'forum', action: 'home')
		"500"(view:'/error')
	}
}

home action controller code looks like this:

package asia.grails.forum
class ForumController {
     def home() {
        [sections:Section.listOrderByTitle()]
    }
}

home.gsp is straight forward. We just iterate the sections and child topics. Convenience methods numberOfThreads and numberOfReplies are added to the Topic domain.

<%@ page import="asia.grails.forum.DiscussionThread" %>
<!DOCTYPE html>
<html>
	<head>
		<meta name="layout" content="main">
		<title>Grails Forum</title>
	</head>
	<body>
        <g:each in="${sections}" var="section">
            <div class="section">
                <div class="sectionTitle">${section.title}</div>
                <g:each in="${section.topics.sort{it.title}}" var="topic">
                    <div class="topic">
                        <g:link controller="forum" action="topic" params="[topicId:topic.id]" >
                            ${topic.title}
                        </g:link>
                        <span class="topicDesc">${topic.description}</span>
                        <div class="rightInfo">
                            <b>threads</b>: ${topic.numberOfThreads}
                            <b>replies</b>: ${topic.numberOfReplies}
                        </div>
                    </div>
                </g:each>
            </div>
        </g:each>
	</body>
</html>

Here are the convenience methods in Topic:

class Topic {
...
    public long getNumberOfThreads() {
        DiscussionThread.countByTopic(this)
    }
    public long getNumberOfReplies() {
        Topic.executeQuery("select count(*) from Topic t join t.threads thr join thr.comments c where t.id = :topicId", [topicId:id])[0]
    }
}

View Topic

Here are the relevant code for viewing threads in a topic:

class ForumController {
     def topic(long topicId) {
        Topic topic = Topic.get(topicId)
        params.max = 10
        params.sort = 'createDate'
        params.order = 'desc'
        [threads:DiscussionThread.findAllByTopic(topic, params),
         numberOfThreads:DiscussionThread.countByTopic(topic), topic:topic]
    }
}

Here is topic.gsp

<%@ page import="asia.grails.forum.DiscussionThread" %>
<!DOCTYPE html>
<html>
	<head>
		<meta name="layout" content="main">
		<title>Grails Forum</title>
	</head>
	<body>
        <div class="pagination">
            <g:paginate total="${numberOfThreads}" params="${[topicId:topic.id]}"/>
        </div>
        <div class="section">
            <div class="sectionTitle">
                ${topic.title}
                <span class="topicDesc">${topic.description}</span>
            </div>
            <g:each in="${threads}" var="thread">
                <div class="topic">
                    <g:link controller="forum" action="thread" params="[threadId:thread.id]" >
                        ${thread.subject}
                    </g:link>
                    <div class="rightInfo">
                        <b>replies</b>: ${thread.numberOfReplies}
                    </div>
                    <div>
                        Started by: ${thread.opener.username}
                        on: <g:formatDate date="${thread.createDate}" format="dd MMM yyyy"/>
                    </div>
                </div>
            </g:each>
        </div>
        <div class="pagination">
            <g:paginate total="${numberOfThreads}" params="${[topicId:topic.id]}"/>
        </div>
	</body>
</html>

Here is the code for numberOfReplies in DiscussionThread domain class

class DiscussionThread {
...
    public long getNumberOfReplies() {
        Comment.countByThread(this)
    }
}

View Thread

Here is the code for viewing user discussion on a single thread:

class ForumController {
    def thread(long threadId) {
        DiscussionThread thread = DiscussionThread.get(threadId)
        params.max = 10
        params.sort = 'createDate'
        params.order = 'asc'
        [comments:Comment.findAllByThread(thread, params),
         numberOfComments:Comment.countByThread(thread), thread:thread]
    }
}

Here is thread.gsp

<%@ page import="asia.grails.forum.DiscussionThread" %>
<!DOCTYPE html>
<html>
	<head>
		<meta name="layout" content="main">
		<title>Grails Forum</title>
	</head>
	<body>
        <div class="pagination">
            <g:paginate total="${numberOfComments}" params="${[threadId:thread.id]}"/>
        </div>
        <div class="section">
            <div class="sectionTitle">
                ${thread.subject}
            </div>
            <g:each in="${comments}" var="comment">
                <div class="comment">
                    <div>
                        <b>${comment.commentBy.username}</b>
                        <span class="topicDesc">
                            <g:formatDate date="${comment.createDate}" format="dd MMM yyyy hh:mma"/>
                        </span>
                    </div>
                    ${comment.body}
                </div>
            </g:each>
            <sec:ifLoggedIn>
                <div class="comment">
                    <h2>Reply</h2>
                    <g:form>
                        <g:textArea name="body"></g:textArea>
                        <g:hiddenField name="threadId" value="${thread.id}"/>
                        <fieldset class="buttons">
                            <g:actionSubmit value="Post Comment" action="postReply"/>
                        </fieldset>
                    </g:form>
                </div>
            </sec:ifLoggedIn>
        </div>
        <div class="pagination">
            <g:paginate total="${numberOfComments}" params="${[threadId:thread.id]}"/>
        </div>
	</body>
</html>

Note that the post reply form is only displayed when a user is logged in.

Post Reply

When a user submits a comment, here is the code that will receive the post:

class ForumController {
  def springSecurityService
    @Secured(['ROLE_USER'])
    def postReply(long threadId, String body) {
        def offset = params.offset
        if (body != null && body.trim().length() > 0) {
            DiscussionThread thread = DiscussionThread.get(threadId)
            def commentBy = springSecurityService.currentUser
            new Comment(thread:thread, commentBy:commentBy, body:body).save()
            // go to last page so user can view his comment
            def numberOfComments = Comment.countByThread(thread)
            def lastPageCount = numberOfComments % 10 == 0 ? 10 : numberOfComments % 10
            offset = numberOfComments - lastPageCount
        }
        redirect(action:'thread', params:[threadId:threadId, offset:offset])
    }
}

The Secured annotation means the action is only accessible for logged in users. The action will create a comment object and redirect the user to the last page of the thread.

Remarks

The above code is just to serve as an example on how to start writing a forum application in Grails. It is very rough and not efficient for real world use. It also lacks user registration.
The full source code for this example can be viewed here or can be downloaded here.

List of Example Applications
  • Simple Ajax Chat
  • Simple Document Management System
  • Save Documents To Database
  • Download / Export Excel File
  • Download / Export ZIP File
  • Grails Forum Application