文辉居士

one-to-many relationships in Grails forms

Here’s a scenario we see fairly often in our Grails applications.

  • Parent object has a collection of Child objects
  • We want the Parent’s create and edit GSPs to allow us to add/remove/update associated Child objects
  • The controller should correctly persist changes to the collection of Child objects, including maintaining Child object ids so any other objects referencing them don’t get screwed up

I found a really nice solution that avoids adding a lot of code to the controller to sift out added/changed/deleted collection members. The original page seems to have disappeared, so here are copies from archive.org (easier to read) and Google cache (PDF).

I was disappointed that the original page is gone, and I found some small errors in the sample code, so I thought it would be nice to document here.

Here’s a sample project I created to go through this. Source code: one-many.tar.gz

The original example used Quest objects that can hold many Task objects. I’ll follow the Grails docs and use Author objects that can hold many Book objects.

First, create the Author class.

01.import org.apache.commons.collections.list.LazyList;
02.import org.apache.commons.collections.FactoryUtils;
03. 
04.class Author {
05. 
06.static constraints = {
07.}
08. 
09.String name
10.List books = new ArrayList()
11.static hasMany = [ books:Book ]
12. 
13.static mapping = {
14.books cascade:"all,delete-orphan"
15.}
16. 
17.def getExpandableBookList() {
18.return LazyList.decorate(books,FactoryUtils.instantiateFactory(Book.class))
19.}
20. 
21.}

(Here’s a minor correction I had to make to the original document’s code. They declared getExpandableBookList as returning a List, but that gave unknown property errors. Using a plain def fixed that.)

This adds a bunch of useful behaviour right away. The mapping block declares that books will be deleted when they’re removed from the Author.books collection, so we don’t need to clean up anything manually. By initializing books to an empty ArrayList when an Author object is created, and by using the getExpandableBookList() method, we can easily add and remove Book objects to the Author.books collection.

Next, the Book class is pretty simple.

01.class Book {
02. 
03.static constraints = {
04.}
05. 
06.String title
07.boolean _deleted
08. 
09.static transients = [ '_deleted' ]
10. 
11.static belongsTo = [ author:Author ]
12. 
13.def String toString() {
14.return title
15.}
16. 
17.}

Nothing too fancy here, but pay attention to the _deleted property. That’s what we’ll be using to filter out Book objects that need to be removed from the Author.book collection on updates.

For the views, I like to combine the guts of the create and edit GSPs into a template that they can both render.

01.<div class="dialog">
02.<table>
03.<tbody>
04.<tr class="prop">
05.<td valign="top" class="name"><label for="name">Name:</label></td>
06.<td valign="top" class="value ${hasErrors(bean:authorInstance,field:'name','errors')}">
07.<input type="text" id="name" name="name" value="${fieldValue(bean:authorInstance,field:'name')}"/>
08.</td>
09.</tr>
10.<tr class="prop">
11.<td valign="top" class="name"><label for="books">Books:</label></td>
12.<td valign="top" class="value ${hasErrors(bean:authorInstance,field:'books','errors')}">
13.<g:render template="books" model="['authorInstance':authorInstance]" />
14.</td>
15.</tr>
16.</tbody>
17.</table>
18.</div>

That uses _books.gsp to render the editable list of books.

01.<script type="text/javascript">
02.var childCount = ${authorInstance?.books.size()} + 0;
03. 
04.function addChild() {
05.var htmlId = "book" + childCount;
06.var deleteIcon = "${resource(dir:'images/skin', file:'database_delete.png')}";
07.var templateHtml = "<div id='" + htmlId + "' name='" + htmlId + "'>\n";
08.templateHtml += "<input type='text' id='expandableBookList[" + childCount + "].title' name='expandableBookList[" + childCount + "].title' />\n";
09.templateHtml += "<span onClick='$(\"#" + htmlId + "\").remove();'><img src='" + deleteIcon + "' /></span>\n";
10.templateHtml += "</div>\n";
11.$("#childList").append(templateHtml);
12.childCount++;
13.}
14.</script>
15. 
16.<div id="childList">
17.<g:each var="book" in="${authorInstance.books}" status="i">
18.<g:render template='book' model="['book':book,'i':i]"/>
19.</g:each>
20.</div>
21.<input type="button" value="Add Book" onclick="addChild();" />

And that uses _book.gsp to render the individual records. It’s a bit overkill to call out to another template for only a few lines of HTML, but that’s how the original example did it and I’ll do the same for consistency.

1.<div id="book${i}">
2.<g:hiddenField name='expandableBookList[${i}].id' value='${book.id}'/>
3.<g:textField name='expandableBookList[${i}].title' value='${book.title}'/>
4.<input type="hidden" name='expandableBookList[${i}]._deleted' id='expandableBookList[${i}]._deleted' value='false'/>
5.<span onClick="$('#expandableBookList\\[${i}\\]\\._deleted').val('true'); $('#book${i}').hide()"><img src="${resource(dir:'images/skin', file:'database_delete.png')}" /></span>
6.</div>

Here’s where I changed a bit more from the original example. I used jQuery because the selectors make things easy. Basically we render the books from the already-persisted author object, and keep track (using the _deleted field) of any that the user wants to remove. We also keep track of new objects to add.

One of the reasons I really liked this technique was how little impact there is on the controller. We just need to add this to the update method in AuthorController.

01.def update = {
02.def authorInstance = Author.get( params.id )
03.if(authorInstance) {
04.if(params.version) {
05.// ... version locking stuff
06.}
07.authorInstance.properties = params
08.def _toBeDeleted = authorInstance.books.findAll {it._deleted}
09.if (_toBeDeleted) {
10.authorInstance.books.removeAll(_toBeDeleted)
11.}
12.// ... etc.

The original example added similar code to the save method, but I don’t think it’s required for new objects (since they don’t have any already-persisted books to delete, only new books to create) so I only put it in the update method. I also changed it from find{} to findAll{} to guarantee that we get a list, and checked that we have objects to remove before calling the removeAll().

And it works great! Let’s look at some screenshots of the application in action.

First, we can create a new author and add some books right here instead of creating them separately and then matching them up.

Create Author

Hit “Create” and it creates the Author and Book objects.

Show Author

Edit the author we just created and see how we get a form that looks the same.

Edit Author

However, it’s worth noting that the books displayed here are the already-persisted ones, so the form is keeping track of their ids and whether we should keep them or delete them on update. Let’s delete the first one and add two more new books.

Edit Author

Now when we hit “Update” the controller has to be smart enough to remove that first book from the Author.books collection, then create two new Book objects and add them to the collection. And naturally, it is.

Show Author

In addition to creating and destroying Book objects, we can update them. For example, let’s change the title of that first book to be the long version.

Edit Author

No problem!

Edit Author

So that’s one-to-many relationships in Grails forms. I hope it’s useful.

posted on 2012-05-04 16:24  restService  阅读(541)  评论(0编辑  收藏  举报

导航


我是有底线的赠送场