This post shows how to create Grails tag library for adding and removing identical HTML blocks, which can be used
when you want to give user an option to dynamically add extra inputs for additional information. There are lots of
simple JQuery and pure JavaScript solutions for this feature, but, in general, they are not flexible and clean
enough for a Grails application. Pure tag library approach helps to avoid HTML and JS clutter in a GSP file and
speeds up the development, since the only thing you need is to specify the tag with necessary parameters.
This tag eventually should render the following HTML elements:
HTML blocks to dynamically add and remove (hereinafter called items)
remove button next to each item
add button to add new items
Let's get to the tag library implementation. Assume that you want to allow users to dynamically add inputs to
a form that contains information about customers (text fields for the first and the last name, and a checkbox
for the "account expired" option), and the form may have from 2 to 5 customers. Then the GSP file may look like
this:
1234567891011
<g:formcontroller="customer"action="saveInfo"><dynamic:blockitemId="customerInfo"min="2"max="5"limitReachedMsg="Sorry, you cannot specify more than 5 customers"removeBtnLabel="Delete"><!--theblockofinputswewanttoaddandremove(firstName,lastNameandisExpired)--><g:textFieldname="firstName"/><g:textFieldname="lastName"/><g:checkBoxname="isExpired"/></dynamic:block><g:submitButtonname="submit"value="Submit"/></g:form>
And the dynamic tag library should consist of the following .groovy, .gsp and .js files:
DynamicBlocksTagLib.groovy - tag library with the dynamic:block tag which accepts and processes necessary
parameters and renders a GSP template with HTML elements and JS code
_add.gsp - the GSP template rendered by the dynamic:block tag that provides supporting HTML elements, such
as the add button and item counter
dynamicBlocks.js - JavaScript functions for adding / removing the items
Tag library (DynamicBlocksTagLib.groovy)
Here is a list of the features we want the dynamic:block tag to support:
Function
Parameter
Mandatory
allow to use a few different dynamic:block tags on the same page
itemId - will be used as an id prefix for dynamically added items and as an id suffix for "supporting" HTML elements, such as the add button, item counter, etc.
required
create new item from either tag's body or separate GSP template
template - the name of the GSP template; if missing, the tag's body will be used instead
optional
model - the model passed to the GSP template
optional
use custom add button
addBtnId - if missing, a default add button will be rendered
optional
limit the minimum and maximum number of items on the page
min - the number of items that will be added automatically when the page loads (their remove buttons will be disabled)
optional
max - the maximum number of items that can be added by user
optional
limitReachedMsg - the message that will be displayed if the limit is reached
optional
accept custom label for remove buttons
removeBtnLabel - defaults to "Remove"
optional
execute JS callback function for newly added item
onComplete - the name of JS function to call; functions like this must accept the item index number as a parameter
optional
Eventually, the tag should do the following:
check that the itemId parameter exists
validate the max and min parameters (should be integer numbers, the min must be less than the max)
classDynamicBlocksTagLib{/** * Tag library namespace. */staticnamespace="dynamic"/** * Renders the template that allows to dynamically add and remove identical HTML blocks * (hereinafter "items"). * * @attr itemId REQUIRED The prefix for the item id (every item id consists of * the prefix and the index number) * @attr template OPTIONAL The name of a GSP template that contains HTML code for * every item (if missing, the tag's body will be used) * @attr model OPTIONAL The model passed to the GSP template * @attr addBtnId OPTIONAL The id of the 'add' button, the page must contain * an element with this id to provide custom 'add' button; * if isn't specified, the default 'add' button * will be rendered * @attr removeBtnLabel OPTIONAL The label of the 'remove' button that is rendered * for each item (defaults to 'Remove') * @attr min OPTIONAL The minimum number of items (the number of items that are * rendered by default) * @attr max OPTIONAL The maximum number of items that can be added to the page * @attr limitReachedMsg OPTIONAL The message displayed when the limit is reached * @attr onComplete OPTIONAL The name of a JS function that will be executed right * after a new item is added (must accept * the item index number) */defblock={attrs,body->// checks if the itemId attribute is passed to the tagdefid=attrs.itemIdif(!id)throwTagError("[id] attribute must be specified")// validates the min and max attributesdefmindefmaxtry{min=attrs.min?attrs.minasint:nullmax=attrs.max?attrs.maxasint:null}catch(NumberFormatExceptionnfe){throwTagError("[min] and [max] attributes must be integer numbers")}if(min&&max&&max<min){throwTagError("[min] attribute must be less than [max]")}// prepares template for new itemsdefelem=attrs.template?render(template:attrs.template,model:attrs.model):body()elem=elem.replaceAll('\n','')// makes the template single-lined in order to pass it// as a parameter to JS function that adds new itemselem=elem.encodeAsJavaScript()// makes the template able to pass into a JS function// renders GSP template with auxiliary HTML and JS codeout<<render(template:"/partials/dynamicBlocks/add",model:[id:id,elem:elem,addBtnId:attrs.addBtnId,removeBtnLabel:attrs.removeBtnLabel,min:min?:0,max:max?:0,limitReachedMsg:attrs.limitReachedMsg,onComplete:attrs.onComplete])}}
GSP template rendered by the tag library (_add.gsp)
Let's consider what "supporting" elements should be placed in the GSP template that is rendered by our tag library:
the element that wraps all dynamically added items
the item counter that keeps the total number of items added; every time when user adds a new item,
the counter is increased and its value is appended to the id of the new item
the add button and JavaScript that binds the "click" event handler to it
JavaScript that renders the initial number of items specified by the min attribute
As it was mentioned before, since we should be able to place several dynamic:block tags on the same page,
we need to append itemId as a suffix to ids of the additional elements: item counter, wrapper and add button.
This way, the template _add.gsp should look like the following:
<divid="count_${id}"style="display: none;">0</div><divid="parent_${id}"></div><g:iftest="${!addBtnId}"><inputid="add_${id}"type="button"value="Add"/></g:if><r:script>functioninitializeTag(addButton,id,elem,min,max,onComplete,limitReachedMsg,removeBtnLabel){// binds event handler to the "click" JS event of the "Add" buttonaddButton.click(function(){addItem(id,elem,min,max,onComplete,limitReachedMsg,removeBtnLabel);});// adds the initial number of itemsfor(vari=0;i<min;i++){addButton.click();}}$(function(){// gets the "Add" buttonvaraddButton=${addBtnId?"\$('#$addBtnId')":"\$('#add_$id')"};// imports the dynamicBlocks.js file if it has not been imported yetif(!window["addItem"]){$.getScript("${resource(dir:"js",file:"dynamicBlocks.js")}",function(){initializeTag(addButton,"${id}","${elem}",${min},${max},"${onComplete}","${limitReachedMsg}","${removeBtnLabel}");});}else{initializeTag(addButton,"${id}","${elem}",${min},${max},"${onComplete}","${limitReachedMsg}","${removeBtnLabel}");}});</r:script>
JavaScript functions (dynamicBlocks.js)
To make the above code work, we need to implement the addItem function. This function will be responsible for
adding new items to the page. Also, we need to add an index number to every HTML element inside of the item template
to avoid id duplication and to keep the ability to access the elements via the id HTML attribute:
/* * Adds a new item. */functionaddItem(id,elem,min,max,onComplete,limitMessage,removeBtnLabel){// checks if we have reached maximum number of elementsif(!max||$('[id^='+id+']').length<max){// increments the item countervar$countElem=$('#count_'+id);varnum=parseInt($countElem.html())+1;$countElem.html(num);// creates new item and adds the index number to itvar$newElem=$('<div></div>').html(elem).attr({'id':id+num}).css('margin','5px');// creates the "Remove" buttonvar$removeButton=$('<input type="button"/>').appendTo($newElem);$removeButton.attr({id:'remove_'+id,value:removeBtnLabel?removeBtnLabel:'Remove',disabled:'disabled'});// binds handler to the 'click' JS event of the "Remove" button$removeButton.click(function(){removeItem(id,num,min);});// changes IDs of all elements inside new itemindexItem($newElem,num);// appends new item to the parent element$('#parent_'+id).append($newElem);// enables "Remove" buttons if there are more than minimum number of elements on the pageif($('[id^='+id+']').length>min){$('[id^=remove_'+id+']').removeAttr('disabled');}// executes the 'onComplete' JS function if it existsif(window[onComplete]instanceofFunction){window[onComplete](num);}}else{// displays a message if the maximum limit is reachedalert(limitMessage?limitMessage:'You cannot add more elements.');}}/* * Removes an item. */functionremoveItem(id,num,min){$('#'+id+num).remove();if($('[id^='+id+']').length<=min){$('[id^=remove_'+id+']').attr('disabled','disabled');}}/* * Changes ID of every item's child by adding the index number to it. */functionindexItem($elem,num){$elem.children().each(function(){varnodeId=$(this).attr('id');if(nodeId){$(this).attr('id',nodeId+num);}indexItem($(this),num);});}
Now let's add newly created tag to a GSP file and try out how it works:
Saving values of dynamically added checkboxes and radio inputs
At this point, our tag library works fine and we're able to dynamically add / remove form elements and receive data from
them. But what will happen if we place a checkbox in the dynamic:block tag's body? Let's assume there is a checkbox
named expired in the tag's body. And you have added 3 items to the form and checked the 2nd and the 3rd ones. After
the form submitting, you'll see that some checkboxes are selected in the params.expired array in your controller:
1
[on,on]
But you do not know which particular ones are they, as you have three elements with the same name. One way to fix this
is to use the g:checkBox tag which adds a hidden field before the checkbox (this hidden field has the same name as
the checkbox, but with underscore prefix), and, after adding a new item, update the checkbox and hidden field for this
item by adding a point ('.') and an index number to their names. This will result in the following expired map in
received parameters:
1
[_1:,_2:,_3:,1:on,3:on]
So now we are able to parse it in the controller and transform to an array:
Another issue may occur with radio inputs. For instance, if you add a group of two radio inputs inside the tag's body
and click the add button twice, you will have four radio inputs on the form, but be able to check only one of them
at a time. That's because they all have the same name. Apparently, we must add an index number to the radio input
names inside each newly added item. As a result, we will receive one parameter per radio group: