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 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 SecRoleThe following domains will be automatically created:
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.
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.
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] } }
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) } }
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.
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.