Scaffolding a Generic Admin - Part 6 - Bean, Form and List Views - The Finale

In this post I'll provide a deeper insight into how Dispatcher works and introduce the three generic views that make up the "generic admin". After generating some code with Illudium, we'll have all the pieces to perform CRUD operations...

The central concept behind the generic admin is to render add/edit form and list pages directly from a TransferObject using transfer metadata. So, instead of generating many scaffold views, we have only 3 "smart" views, TransObject(s) and transfer to do the job.

I was originally going to introduce a bunch of workarounds to make this all happen, but thanks to some great suggestions from Mark Mandel, I've decided to limited the amount of decorated methods within a TransferObject, remove my superfluous "TransferConfig" component, and use transfer within my views to do the heavy lifting. The result is a much cleaner and more cohesive. The downside is that I had to revise my Illudium template (again). The upside is I have less conventions! :)

The steps I use to generate code and setup my application are as follows:

1. Within the Illudium code generator, first select a datasource and table, then type a "package" path such as "model.user.User" (for the user table) in the CFC Path field. This will generate the Service and Decorator with paths "model.user.UserService" and "model.user.User" respectively. Note that at this time I choose to place all my controllers in a "listeners" folder, so the path is currently hard coded to generate a controller as "listener.UserController".

2. Use the browse feature to navigate to the project folder where I would like to save the files, then generate the Service, Controller and Decorator for each table.

3. For Transfer and Coldspring XML, I simply copy and paste the portions of the file I want into the transfer.xml and beans.xml files respectively.

4. When all components are generated, I update the main() method of Application.cfc to add listeners (the generated controllers) to Dispatcher.

Here's what the final version of the main() method looks like.

<cffunction name="main" access="private" output="false" returntype="void">
<!--- parameters used in coldspring or lightwire xml --->
<cfset var params = structNew() />

<cfscript>
params.dsn = "triviagame";
params.datasourcePath = "../config/transfer/datasource.xml";
params.configPath = "../config/transfer/transfer.xml";
params.definitionPath = "/config/transfer/definitions";
</cfscript>
<!--- create coldspring beanFactory--->
<cfset application.beanFactory = createObject("component","coldspring.beans.DefaultXmlBeanFactory").init(structNew(),params) />
<!--- load bean definitions --->
<cfset application.beanFactory.loadBeansFromXmlFile("#expandPath('..')#/config/beans.xml",true)/>

<!--- create event dispatcher --->
<cfset application.dispatcher = application.beanFactory.getBean("Dispatcher") />
<!--- create event listeners --->
<cfset AnswerController = application.beanFactory.getBean("AnswerController") />
<cfset CategoryController = application.beanFactory.getBean("CategoryController") />
<cfset GameController = application.beanFactory.getBean("GameController") />
<cfset QuestionController = application.beanFactory.getBean("QuestionController") />
<cfset ResponseController = application.beanFactory.getBean("ResponseController") />
<cfset ThemeController = application.beanFactory.getBean("ThemeController") />
<cfset UserController = application.beanFactory.getBean("UserController") />
<cfset ViewManager = application.beanFactory.getBean("ViewManager") />
<!--- add listeners to dispatcher --->
<cfset application.dispatcher.addListener(AnswerController) />
<cfset application.dispatcher.addListener(CategoryController) />
<cfset application.dispatcher.addListener(GameController) />
<cfset application.dispatcher.addListener(QuestionController) />
<cfset application.dispatcher.addListener(ResponseController) />
<cfset application.dispatcher.addListener(ThemeController) />
<cfset application.dispatcher.addListener(UserController) />
<cfset application.dispatcher.addListener(ViewManager) />
</cffunction>

If you are following along and want to generate the code, you can grab the latest version of the Illudium template from the Google Groups site.

One thing that I have to say about using Illudium and I know that Brian has stressed this as well, is that it is designed to alleviate the tedium of writing repetitive code. It is not designed to be an end to end solution. So we will have to dig in to add transfer relationships and add some code to parent controllers so that they generate lists when a child object is created or edited.

First, I'll add bit of code to the ViewManager to prepare the correct view based on a url "cmd". The onRequest() method for ViewManager has a three level switch/case block. The first level checks the page that is being called, the second checks the first part of the url cmd (typically an object name) and the third checks the action to be performed. We'll add a defaultcase to the onRequest() method so that it now looks like this.

<cffunction name="onRequest" access="public" output="true" returntype="void">
<cfargument name="event" type="com.fancybread.event.RequestEvent" required="true">
<cfif arguments.event.hasErrors()>
<cfset arguments.event.addView("error","/view/ErrorView.cfm") />
</cfif>
<!--- determine command and process action --->
<cfswitch expression="#arguments.event.getThePage()#">
<cfcase value="/index.cfm">
<cfswitch expression="#arguments.event.getRequestObject()#">
<cfcase value="default">
<cfswitch expression="#arguments.event.getRequestAction()#">
<cfcase value="view">
<cfset arguments.event.addView("content","/view/HelloWorld.cfm") />
</cfcase>
</cfswitch>
</cfcase>
<cfdefaultcase>
<cfswitch expression="#arguments.event.getRequestAction()#">
<cfcase value="edit,new">
<cfset arguments.event.addView("content","/view/FormView.cfm") />
</cfcase>
<cfcase value="delete,list">
<cfset arguments.event.addView("content","/view/ListView.cfm") />
</cfcase>
<cfcase value="view">
<cfset arguments.event.addView("content","/view/BeanView.cfm") />
</cfcase>
<cfcase value="save">
<cfif arguments.event.hasErrors()>
<!--- return to form view --->
<cfset arguments.event.addView("content","/view/FormView.cfm") />
<cfelse>
<!--- show list --->
<cfset arguments.event.addView("content","/view/ListView.cfm") />
</cfif>
</cfcase>
</cfswitch>
</cfdefaultcase>
</cfswitch>
</cfcase>
</cfswitch>
</cffunction>

At the top of the method, I've added a test for errors that the request may have and add an "ErrorView" should that be the case. A common design choice is to have a business object "validate" itself prior to saving. Since transfer objects do not have this facility built-in, I have followed Brian Rinaldi's idea to add validation to the TransferObject decorator. My implementation is different as I would prefer to have a method like isValidForSave() return a boolean, rather than a validate() method return an array of errors. So in my decorator I have addError(), getErrors() and isValidForSave() that process object validation.

I've gone ahead and bundled all the pieces together as the enclosure for download to expedite things. Also, Some styles to sample.css to make it a little more friendly to the eyes. To make it a working code base, I have also updated the onRequest() methods for each (parent) controller. For any object that has a parent I have to add a switch/case block to listen for edit and new actions and add a list query to the RequestEvent object. I'm no software guru, but I have included minimal error handling within RequestEvent to throw an error when a requested collection or property of a collection is undefined. For example, without updating the UserController to add a list of users to the RequestEvent when a new Game form is generated, RequestEvent will throw " Property UserList of collection query undefined!" To update the UserController, I added the following to the onRequest() method.

<!--- if request object is Game, add a list of Users to event object --->
<cfcase value="Game">
<cfswitch expression="#arguments.event.getRequestAction()#">
<cfcase value="edit,new">
<cfset getUserList(arguments.event) />
</cfcase>
</cfswitch>
</cfcase>

A critical piece in making it all work is having the correct relationships defined in transfer.xml. The transfer.xml in the enclosed zip file has the complete relationships defined. Below is the objectDefinition for the User object.

<package name="User">
<object name="User" table="trivia_user" decorator="model.user.User">
<id name="Id" type="numeric" />
<property name="FirstName" type="string" column="FirstName" />
<property name="LastName" type="string" column="LastName" />
<property name="Email" type="string" column="Email" />
<property name="Username" type="string" column="Username" />
<property name="Password" type="string" column="Password" />
<property name="CreatedDate" type="date" column="CreatedDate" />
<onetomany name="User_Game" lazy="false">
<link to="Game.Game" column="UserId" />
<collection type="array">
<order property="CreatedDate" order="asc" />
</collection>
</onetomany>
<onetomany name="User_Response">
<link to="Response.Response" column="UserId" />
<collection type="array">
<order property="CreatedDate" order="asc" />
</collection>
</onetomany>
</object>
</package>

In this definition, I have a couple of conventions. First is the "className" which is comprised of the name attributes of the <package/> and <object/> elements which are both "User". The Illudium template will generate the values of the name attributes and the decorator path as well. Currently, the generic ListView expects a className where the package name and object name are the same. Second, the <onetomay/> name is in the format {parent}_{child}, which is similiar to what you might name a foreign key in a database. The generic FormView relies on this naming convention to generate select lists for editing a child object.

Here are code blocks for the List, Form and Bean views. A cautionary note that I haven't fully commented the code. Also, in order to pull it off, I make use of ColdFusion's Evaluate() function.

Briefly, each of these views accesses either an object or query based on the url cmd and uses transfer to access the object metadata. The views are then generated from the object, or query and the metadata.

FormView.cfm

<cfsilent>
<!---
FormView.cfm

@author Paul Marcotte
@version 2007-08-24

This view is designed to generate a form by using transfer to obtain a TransferObject's metatdata.
Then build the form from the metadata and object.
I've limited the use of conventions as much as possible.
--->
<!--- local copy of the TransferObject --->
<cfset variables.object = Request.event.getObject(Request.event.getRequestObject())>
<!--- local copy of the object metadata --->
<cfset variables.objectMetadata = Request.event.getObject("transfer").getTransferMetadata(variables.object.getClassName())>
<!--- local copy of the object PropertyIterator --->
<cfset variables.propertyIterator = variables.objectMetadata.getPropertyIterator() />
<!--- if object has parents, create local copy of the object ParentOneToManyIterator --->
<cfif variables.objectMetadata.hasParentOneToMany()>
<cfset variables.parentOneToManyIterator = variables.objectMetadata.getParentOneToManyIterator() />
</cfif>
</cfsilent>
<cfoutput>
<h2>New #Request.event.getRequestObject()#</h2>
<form name="#LCase(ListLast(variables.object.getClassName(),"."))#" action="#Request.event.getThePage()#?cmd=#LCase(ListLast(variables.object.getClassName(),"."))#.save" method="post">
<input type="hidden" name="#variables.objectMetadata.getPrimaryKey().getName()#" id="#variables.objectMetadata.getPrimaryKey().getName()#" value="#variables.object.getProperty(variables.objectMetadata.getPrimaryKey().getName())#"><br clear="all">
<cfif variables.objectMetadata.hasParentOneToMany()>
<!--- if object has parents, create select lsits from ParentOneToMany --->
<cfloop condition="#variables.parentOneToManyIterator.hasNext()#">
<cfset variables.parent = variables.parentOneToManyIterator.next() />
<cfif Request.event.getRequestAction() neq "new">
<cfset variables.parentObj = Evaluate("variables.object.getParent#ListFirst(variables.parent.getName(),"_")#()") />
</cfif>
<cfset variables.parentList = request.event.getQuery(ListFirst(variables.parent.getName(),"_") & "List") />
<cfset variables.parentMetadata = Request.event.getObject("transfer").getTransferMetadata(ListFirst(variables.parent.getName(),"_")&"."&ListFirst(variables.parent.getName(),"_"))>
<label class="formlabel" for="#ListFirst(variables.parent.getName(),"_")#">#ListFirst(variables.parent.getName(),"_")#</label>
<select class="forminput" name="#variables.parent.getLink().getColumn()#">
<cfloop query="variables.parentList">
<option value="#Evaluate("variables.parentList.#variables.parentMetadata.getPrimaryKey().getName()#")#">#Evaluate("variables.parentList.#variables.parentMetadata.getPropertyIterator().next().getName()#")#</option>
</cfloop>
</select><br clear="all">
</cfloop>
</cfif>
<!--- use object PropertyIterator to build any other form fields --->
<cfloop condition="#variables.propertyIterator.hasNext()#">
<cfset variables.property = variables.propertyIterator.next() />
<cfswitch expression="#variables.property.getType()#">
<cfcase value="string" delimiters=",">
<label class="formlabel" for="#variables.property.getName()#">#variables.property.getName()#</label>
<cfif Len(variables.object.getProperty(variables.property.getName())) gt 50>
<textarea class="forminput" name="#variables.property.getName()#" rows="6" cols="30">
#variables.object.getProperty(variables.property.getName())#
</textarea><br clear="all" />
<cfelse>
<input class="forminput" <cfif LCase(variables.property.getName()) eq "password">type="password"<cfelse>type="text"</cfif> name="#variables.property.getName()#" value="#variables.object.getProperty(variables.property.getName())#" size="50"><br clear="all">
</cfif>
</cfcase>
<cfcase value="numeric">
<label class="formlabel" for="#variables.property.getName()#">#variables.property.getName()#</label>
<input class="forminput" type="text" name="#variables.property.getName()#" value="#variables.object.getProperty(variables.property.getName())#" size="8"><br clear="all">
</cfcase>
<cfcase value="date">
<cfif variables.property.getName() eq "CreatedDate">
<input type="hidden" name="#variables.property.getName()#" id="#variables.property.getName()#" value="#variables.object.getProperty(variables.property.getName())#"><br clear="all">
<cfelse>
<input type="text" name="#variables.property.getName()#" id="#variables.property.getName()#" value="#DateFormat(variables.object.getProperty(variables.property.getName()),"yyyy-mm-dd")#"><br clear="all">
</cfif>
</cfcase>
<cfcase value="boolean">
<label class="formlabel" for="#variables.property.getName()#">#property.getName()#</label>
<input class="forminput" type="radio" name="#variables.property.getName()#" value="true"<cfif variables.object.getProperty(property.getName())> checked</cfif>> Yes | <input label="No" class="forminput" type="radio" name="#variables.property.getName()#" value="false"<cfif not variables.object.getProperty(variables.property.getName())> checked</cfif>> No<br clear="all">
</cfcase>
</cfswitch>
</cfloop>
<div class="formlabel"></div><input class="formbutton" type="submit" name="submit" value="submit">
</form>
</cfoutput>

ListView.cfm

<cfsilent>
<!---
ListView.cfm

@author Paul Marcotte
@version 2007-08-24

This view is designed to display a list table using transfer to obtain a TransferObject's metatdata.
Then build the table from the metadata and query.
Table lists with many attributes will look funky
--->
<cfset variables.q = Request.event.getQuery(Request.event.getRequestObject() & "List") />
<!--- local copy of the object metadata --->
<cfset variables.objectMetadata = Request.event.getObject("transfer").getTransferMetadata(Request.event.getRequestObject()&"."&Request.event.getRequestObject())>
<!--- local copy of the object PropertyIterator --->
<cfset variables.propertyIterator = variables.objectMetadata.getPropertyIterator() />
</cfsilent>
<cfoutput>
<h2>#Request.event.getRequestObject()# List</h2>
<p><a href="#Request.event.getThePage()#?cmd=#Request.event.getRequestObject()#.new">New #Request.event.getRequestObject()#</a></p>
<cfif variables.q.recordCount gt 0>
<table width="100%" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="adminHead">#variables.objectMetadata.getPrimaryKey().getName()#</td>
<cfloop condition="#variables.propertyIterator.hasNext()#">
<cfset variables.property = variables.propertyIterator.next()>
<td class="adminHead">#variables.property.getName()#</td>
</cfloop>
<td class="adminHead" colspan="3">Action</td>
</tr>
<cfloop query="variables.q">
<cfif variables.q.currentRow mod 2 eq 1>
<cfset variables.rowClass = "rowOdd">
<cfelse>
<cfset variables.rowClass = "rowEven">
</cfif>
<tr>
<td class="#variables.rowClass#">#Evaluate("variables.q.#variables.objectMetadata.getPrimaryKey().getName()#")#</td>
<!--- reset property iterator --->
<cfset variables.propertyIterator = variables.objectMetadata.getPropertyIterator() />
<cfloop condition="#variables.propertyIterator.hasNext()#">
<cfset variables.property = variables.propertyIterator.next()>
<td class="#variables.rowClass#"><cfif IsDate(#Evaluate("variables.q.#variables.property.getName()#")#)>#DateFormat(Evaluate("variables.q.#variables.property.getName()#"),"yyyy-mm-dd")#<cfelse>#Evaluate("variables.q.#variables.property.getName()#")#</cfif></td>
</cfloop>
<td class="#variables.rowClass#"><a href="#Request.event.getThePage()#?cmd=#Request.event.getRequestObject()#.delete&id=#Evaluate("variables.q.#variables.objectMetadata.getPrimaryKey().getName()#")#" onclick="return confirm('Deleting a #Request.event.getRequestObject()# cannot be undone.\n\nAre you sure you want to continue?');"><img src="images/icons/16/trash.png" border="0" title="delete" alt="delete"></a></td>
<td class="#variables.rowClass#"><a href="#Request.event.getThePage()#?cmd=#Request.event.getRequestObject()#.edit&id=#Evaluate("variables.q.#variables.objectMetadata.getPrimaryKey().getName()#")#"><img src="images/icons/16/edit.png" border="0" title="edit" alt="edit"></a></td>
<td class="#variables.rowClass#"><a href="#Request.event.getThePage()#?cmd=#Request.event.getRequestObject()#.view&id=#Evaluate("variables.q.#variables.objectMetadata.getPrimaryKey().getName()#")#"><img src="images/icons/16/computer.png" border="0" title="view" alt="view"></a></td>
</tr>
</cfloop>
</table>
<cfelse>
<p>There are no records to display.</p>
</cfif>
</cfoutput>

BeanView.cfm

<cfsilent>
<!---
BeanView.cfm

@author Paul Marcotte
@version 2007-08-24

This view is designed to generate a simple view of an object by using transfer to obtain a TransferObject's metatdata.
--->
<!--- local copy of the TransferObject --->
<cfset variables.object = Request.event.getObject(Request.event.getRequestObject())>
<!--- local copy of the object metadata --->
<cfset variables.objectMetadata = Request.event.getObject("transfer").getTransferMetadata(variables.object.getClassName())>
<!--- local copy of the object PropertyIterator --->
<cfset variables.propertyIterator = variables.objectMetadata.getPropertyIterator() />
<!--- if object has parents, create local copy of the object ParentOneToManyIterator --->
<cfif variables.objectMetadata.hasParentOneToMany()>
<cfset variables.parentOneToManyIterator = variables.objectMetadata.getParentOneToManyIterator() />
</cfif>
</cfsilent>
<cfoutput>
<h2>#Request.event.getRequestObject()# Detail</h2>
<p><a href="#Request.event.getThePage()#?cmd=#Request.event.getRequestObject()#.edit&id=#variables.object.getProperty(variables.objectMetadata.getPrimaryKey().getName())#">edit</a></p>
<cfif variables.objectMetadata.hasParentOneToMany()>
<cfloop condition="variables.parentOneToManyIterator.hasNext()">
<cfset variables.parent = variables.parentOneToManyIterator.next() />
<cfset variables.parentObj = Evaluate("variables.object.getParent#ListFirst(variables.parent.getName(),"_")#()") />
<cfset variables.parentMetadata = Request.event.getObject("transfer").getTransferMetadata(ListFirst(variables.parent.getName(),"_")&"."&ListFirst(variables.parent.getName(),"_"))>
<p><strong>#ListFirst(variables.parent.getName(),"_")#:</strong> #variables.parent.getProperty(variables.parentMetadata.getPropertyIterator().next().getName())#<br />
</cfloop>
</cfif>
<cfloop condition="#variables.propertyIterator.hasNext()#">
<cfset variables.property = variables.propertyIterator.next() />
<label class="formlabel" for="#variables.property.getName()#">#variables.property.getName()#</label>
<span style="padding: 4px; margin: 0px 0px 0px 12px;">#variables.object.getProperty(variables.property.getName())#</span><br clear="all">
</cfloop>
</cfoutput>

Many to many relationships are not covered yet, so for now it is a solution for simple a one-to-many relationship model.

I'm going to continue revising this code and will eventually add a nice gloss with Ext JS.

Questions and comments welcome. I realize this represents more hands on approach than other frameworks, but if you work with transfer and Illudium, it's relatively easy process to build a Dispatcher application and generate the code to implement a "generic admin".

Finally, I'd like to thank both Brian Rinaldi and Mark Mandel for their mentorship and encouragement to throw this project together. :)

Related Blog Entries

Comments
Dan O'Keefe's Gravatar Paul,
Excellent series - I just happen to find it while googling a coldspring issue. I find parts 2-5 on your blog but when searching I do not find part 1. Do you have a link for that?
Thanks much,
Dan
# Posted By Dan O'Keefe | 4/21/08 3:48 AM
Dan O'Keefe's Gravatar jeesh - since I added a comment to part 6, I meant I have found parts 2-6.

Dan
# Posted By Dan O'Keefe | 4/21/08 3:58 AM
Paul Marcotte's Gravatar Hi Dan,

The first entry in the series can be found at http://www.fancybread.com/blog/index.cfm/2007/8/12...

Note to self: title your blog posts consistently... :)
# Posted By Paul Marcotte | 4/21/08 1:49 PM
Dan O'Keefe's Gravatar Thanks much - as a friendly suggestion for blog posts that are part of a series, I have seen others have links for the other 5 parts at the bottom of the post.

Good stuff-thanks,

Dan
# Posted By Dan O'Keefe | 4/23/08 7:35 AM
BlogCFC was created by Raymond Camden. This blog is running version 5.9. Contact Blog Owner