Progressively enhancing your CFWheels form with nested properties and jQuery
December 5, 2016 · Chris Peters
Want to code a form that contains a "main" record and a collection of "nested" records? This post will cover a fairly standard CFWheels solution using nested properties and a sprinkling of jQuery—progressively enhanced.
We all find ourselves in this situation from time to time: we want to code a form that contains a “main” record and a collection of “nested” records. We want some JavaScript-powered form controls to add to and remove from that collection of nested records. Clicking the submit button then saves the whole thing.
There are 2 ways to approach this:
- Nested properties
- A tableless model with its own custom persistence logic
I’ll cover the first approach in this blog post.
When we’re working with a typical CFWheels application with server-rendered HTML, we have the advantage of making the form work with and without JavaScript present. You’ve probably heard this referred to as progressive enhancement. So let’s build it without JavaScript first and then enhance the experience with some jQuery love.
Model setup
Here are a couple models.
models/Contact.cfc
:
component extends="Model" { | |
function init() { | |
hasMany(name: "addresses", joinType: "outer"); | |
nestedProperties( | |
association: "addresses", | |
sortProperty: "position", | |
allowDelete: true | |
); | |
} | |
} |
models/Address.cfc
:
component extends="Model" { | |
function init() { | |
belongsTo("contact"); | |
} | |
} |
The gist: a contact
has many addresses
. Contact has nested properties for
the addresses
association, which means that a contact
form can accept fields
for zero or many address
records. The address
model has an integer
position
property that denotes what order the addresses should be loaded in.
(This is important with a nested hasMany
association!)
Initial controller setup
Now we’ll code a form for creating and editing a contact
record and its
associated addresses
.
The first step is to define our objects to bind in the form within the
controller. I’ll setup a fairly standard controller at
controllers/Contacts.cfc
with new
, create
, edit
, and update
actions.
Nothing special here yet.
component extends="Controller" { | |
function init() { | |
verifies(params: "key", paramsTypes: "integer", only: "edit,update"); | |
verifies(params: "contact", paramsTypes: "struct", only: "create,update"); | |
filters(through: "findContact", only: "edit,update"); | |
} | |
function new() { | |
contact = model("contact").new(addresses: []); | |
} | |
function create() { | |
contact = model("contact").new(params.contact); | |
if (contact.save()) { | |
flashInsert(success: "Contact created."); | |
redirectTo(route: "contact", key: contact.key()); | |
} | |
else { | |
flashInsert(error: "There was an error creating the contact."); | |
renderPage(action: "new"); | |
} | |
} | |
function edit() { | |
} | |
function update() { | |
if (contact.update(params.contact)) { | |
flashInsert(success: "Contact updated."); | |
redirectTo(route: "contact", key: contact.key()); | |
} | |
else { | |
flashInsert(error: "There was an error updating the contact."); | |
renderPage(action: "edit"); | |
} | |
} | |
/** | |
* Finds contact for form by `params.key`. | |
*/ | |
private function findContact() { | |
contact = model("contact").findByKey( | |
key: params.key, | |
include: "addresses", | |
order: "position" | |
); | |
if (!IsObject(contact)) { | |
Throw(type: "MyApp.RecordNotFound"); | |
} | |
} | |
} |
However, note that our new
action is specifying that the form for a new record
should not load with any addresses. The user will be forced to add them to the
form before filling in any fields for them.
Initial view setup
Then the form views at views/contacts/new.cfm
and views/contacts/edit.cfm
share the _form.cfm
partial:
<cfset contentFor(title: "New Contact")> | |
<cfoutput> | |
<h1>New Contact</h1> | |
#startFormTag(route: "contacts", id: "contact-form")# | |
#includePartial("form")# | |
<p> | |
#submitTag("Create Contact")# | |
</p> | |
#endFormTag()# | |
</cfoutput> |
<cfset contentFor( | |
title: EncodeForHtml("Edit Contact: #contact.firstNameChangedFrom() #contact.lastNameChangedFrom()#") | |
)> | |
<cfoutput> | |
<h1>Edit Contact</h1> | |
#startFormTag(route: "contact", key: contact.key(), method: "put", id: "contact-form")# | |
#includePartial("form")# | |
<p> | |
#submitTag("Update Contact")# | |
</p> | |
#endFormTag()# | |
</cfoutput> |
<cfoutput> | |
<fieldset> | |
<legend>Contact</legend> | |
#textField(objectName: "contact", property: "firstName")# | |
#errorMessageOn(objectName: "contact", property: "firstName")# | |
#textField(objectName: "contact", property: "lastName")# | |
#errorMessageOn(objectName: "contact", property: "lastName")# | |
</fieldset> | |
<fieldset> | |
<legend>Addresses</legend> | |
<div id="contact-addresses"> | |
#includePartial(contact.addresses)# | |
</div> | |
<p> | |
<button id="new-address-button" type="submit" name="newAddress" value="true"> | |
+ New Address | |
</button> | |
</p> | |
</fieldset> | |
</cfoutput> |
The partial at _form.cfm
has some interesting elements.
First, you’ll see a call to #includePartial(contact.addresses)#
. Because we’re
passing it an array, CFWheels will loop over the array and run a file at
views/contacts/_address.cfm
for each address in the present in loop.
We can code up that file at views/contacts/_address.cfm
:
<cfoutput> | |
<div id="address-#EncodeForHtml(arguments.current)#"> | |
<cfif not contact.addresses[arguments.current].isNew()> | |
#hiddenField( | |
objectName: "contact", | |
association: "addresses", | |
position: arguments.current, | |
property: "id" | |
)# | |
</cfif> | |
#hiddenField( | |
objectName: "contact", | |
association: "addresses", | |
position: arguments.current, | |
property: "position" | |
)# | |
#hiddenField( | |
objectName: "contact", | |
association: "addresses", | |
position: arguments.current, | |
property: "_delete", | |
data_delete: true | |
)# | |
#textField( | |
objectName: "contact", | |
association: "addresses", | |
position: arguments.current, | |
property: "street" | |
)# | |
#errorMessageOn( | |
objectName: "contact['addresses'][#arguments.current#]", | |
property: "street" | |
)# | |
#textField( | |
objectName: "contact", | |
association: "addresses", | |
position: arguments.current, | |
property: "city" | |
)# | |
#errorMessageOn( | |
objectName: "contact['addresses'][#arguments.current#]", | |
property: "city" | |
)# | |
<button | |
type="submit" | |
name="removeAddress" | |
value="#EncodeForHtml(arguments.current)#" | |
data-remove-contact-address | |
> | |
Remove Address | |
</button> | |
</div> | |
</cfoutput> |
There are quite a few things going on in this file! Let’s break it down:
- Notice that
arguments.current
is referenced several times. CFWheels will provide that value whenever you loop through a collection usingincludePartial
like we’re doing here. This basically tells the form helpers and your own custom code which element in the query or array you’re currently on. - The form helpers also use the
association
andposition
arguments to denote which associated object is being referenced and our current position in the array of objects. - We’re including
hiddenField
s forid
andposition
. Nested properties will punish you severely if you don’t include these properties in the form on updates. Be warned. - The code is pretty ugly for
errorMessageOn
. Unfortunately, it doesn’t takeassociation
orposition
arguments, so you need to manually tell it which object to reference via theobjectName
argument.
When you run this as is, you’ll get a form with no addresses loaded. You may have noticed that there is a “New Address” button in there. That’s how we’ll get an address onto the form.
Adding a new address
record without JavaScript
We can code this so that it’ll work without JavaScript but also won’t get in the way if we want to progressively enhance with some JavaScript later.
When you click the “New Address” button, it will post the entire form to the
create
or update
action, depending on what form you’re posting.
What we want to do is add a filter to the controller that will intercept
requests to the create
and update
actions and add the new address record to
the form if the newAddress
button was clicked.
component extends="Controller" { | |
function init() { | |
verifies(params: "key", paramsTypes: "integer", only: "edit,update"); | |
verifies(params: "contact", paramsTypes: "struct", only: "create,update"); | |
filters(through: "findContact", only: "edit,update"); | |
filters(through: "addAddress", only: "create,update"); | |
} | |
// | |
// Actions omitted for brevity | |
// | |
/** | |
* Adds a new address record to the contact and loads new or edit form if | |
* requested. | |
*/ | |
private function addAddress() { | |
// Only run this logic if the "New Address" button was clicked. | |
if (StructKeyExists(params, "newAddress")) { | |
// On update, we have an existing contact record from the `findContact` | |
// filter that we can load the properties into. | |
if (StructKeyExists(variables, "contact")) { | |
contact.setProperties(params.contact); | |
} | |
// If we're working on a new contact, then the `findContact` filter didn't | |
// run before this. So we need a new contact record. | |
else { | |
contact = model("contact").new(params.contact); | |
} | |
// Make sure we have an array of addresses to work with. | |
if (!StructKeyExists(contact, "addresses") || !IsArray(contact.addresses)) { | |
contact.addresses = []; | |
} | |
// Now let's add the new address with its position populated. | |
ArrayAppend( | |
contact.addresses, | |
model("address").new( | |
position: ArrayLen(contact.addresses) + 1 | |
) | |
); | |
// Lastly, load the form with the new address populated. | |
renderPage(action: params.action == "create" ? "new" : "edit"); | |
} | |
} | |
} |
You can see that the addAddress
filter’s job is to take over if
params.newAddress
is present. (This is passed to the request by clicking the
button in the _form.cfm
partial listed above.) If the button was clicked, the
filter loads the entire form post into either a new or fetched record, adds an
address
object to contact.addresses
, and tells CFWheels to load the form
again.
Calling renderPage
like that at the end stops processing in the controller, so
the create
and update
actions won’t run. If the addAddress
button wasn’t
clicked, then the create
or update
actions will run as usual.
Now when you click the button, you should see the entire page refresh with a new set of fields for an address record. You can click it 100 times if you’d like in order to add 100 more addresses. It’ll even preserve information that you’ve typed into other fields. You can then submit the form with your addresses and watch it save both the contact and the addresses.
Adding another filter to handle the “Remove Address” button
Before we get into JavaScript, we have one more server-side piece of functionality to build: the “Remove Address” button. This will work similarly to how the “Add Address” button works, but there is another catch that we’ll cover.
So let’s code up a new removeAddress
controller filter in
controllers/Contacts.cfc
:
component extends="Controller" { | |
function init() { | |
verifies(params: "key", paramsTypes: "integer", only: "edit,update"); | |
verifies(params: "contact", paramsTypes: "struct", only: "create,update"); | |
filters(through: "findContact", only: "edit,update"); | |
filters(through: "addAddress", only: "create,update"); | |
filters(through: "removeAddress", only: "create,update"); | |
} | |
// | |
// Actions and `addAddress` filter omitted for brevity | |
// | |
/** | |
* Removes an address record or marks it for destruction and loads new or edit form if | |
* requested. | |
*/ | |
private function removeAddress() { | |
// Only run this logic if the "Remove Address" button was clicked. | |
if (StructKeyExists(params, "removeAddress") && IsNumeric(params.removeAddress)) { | |
// On update, we have an existing contact record from the `findContact` | |
// filter that we can load the properties into. | |
if (StructKeyExists(variables, "contact")) { | |
contact.setProperties(params.contact); | |
} | |
// If we're working on a new contact, then the `findContact` filter didn't | |
// run before this. So we need a new contact record. | |
else { | |
contact = model("contact").new(params.contact); | |
} | |
// Now let's remove the address by position. | |
contact.removeAddressAt(params.removeAddress); | |
// Lastly, load the form with the new address populated. | |
renderPage(action: params.action == "create" ? "new" : "edit"); | |
} | |
} | |
} |
Because the logic is fairly specialized, I decided to move the functionality
into an instance method in the Contact
model, called removeAddressAt
:
component extends="Model" { | |
function init() { | |
hasMany(name: "addresses", joinType: "outer"); | |
nestedProperties( | |
association: "addresses", | |
sortProperty: "position", | |
allowDelete: true | |
); | |
} | |
/** | |
* Removes an address at a given position. | |
*/ | |
function removeAddressAt(required numeric position) { | |
if (arguments.position >= ArrayLen(this.addresses)) { | |
// Delete record from database if it's persisted. | |
if (!this.addresses[arguments.position].isNew()) { | |
this.addresses[arguments.position].delete(); | |
} | |
// Either way, also remove from the array. | |
ArrayDeleteAt(this.addresses, arguments.position); | |
// Readjust address positions, or else we'll get some fun Java `null` | |
// errors later. | |
for (local.i = 1; local.i <= ArrayLen(this.addresses); local.i++) { | |
this.addresses[local.i].position = local.i; | |
} | |
} | |
} | |
} |
The new instance method deletes the selected Address
record if it’s already
saved in the database. Then the address is removed from the array.
The fun part is that CFWheels pretty much forces you to have a sortProperty
defined on nested properties for hasMany
associations (position
in our
case), but it doesn’t help you manage it. And if you have any position
s out of
sequence (e.g., 1, 2, 3, 5), it will cause bad things to happen when CFWheels
tries to load the association for you. You basically get an array where slot 4
in our example is a Java null
value, which causes pretty bad things to happen
in ColdFusion in general.
So anyway, be sure to manage your sortProperty
values whenever you’re
manipulating records in a nested hasMany
like this.
With that in place, you should be able to click the “Remove Address” button near any of the address fields and see them vanish after the form post loads. You should then still be able to submit the form and see your changes reflected in the database.
Congratulations! You now have much of the functionality that you need to get the form working. If you’re working in an agile manner, I say ship it!
Enhancing the “New Address” button with jQuery
Of course, we’re never done. We can always improve. So let’s take a moment to add more address fieldsets to the form using JavaScript.
(function($) { | |
$('#new-address-button').on('click', function(e) { | |
e.preventDefault(); | |
var $this = $(this), | |
$contactForm = $('#contact-form'), | |
// Here, we're adding the add button to the form post | |
formData = $contactForm.serialize() + '&' + $this.attr('name') + '=' $this.val(), | |
responseData = ""; | |
$this.prop('disabled', true); | |
// This is up to you to implement. Try something like Spin.js | |
$loader.show(); | |
// Submit the entire form via AJAX. | |
$.ajax({ | |
url: contactForm.attr('action'), | |
type: 'post', | |
data: formData, | |
cache: false, | |
success: function(data, textStatus, jqXHR) { | |
responseData = $(data); | |
$('#contact-addresses').append(data); | |
}, | |
error: function(jqXHR, textStatus, errorThrown) { | |
alert('There was an error adding the address.'); | |
}, | |
complete: function(jqXHR, textStatus) { | |
$this.prop('disabled', false); | |
$loader.hide(); | |
} | |
}); | |
}); | |
}(jQuery)); |
In essence, this JavaScript does what the full form post is doing, except at the
end of the form post, it intercepts the response and inserts it into the
#contact-addresses
container.
This does require that we slightly change how the server responds to the
request. Here is a modified addAddress
filter in the controller:
component extends="Controller" { | |
// | |
// Constructor and actions omitted for brevity | |
// | |
/** | |
* Adds a new address record to the contact and loads new or edit form if | |
* requested. | |
*/ | |
private function addAddress() { | |
// Only run this logic if the "New Address" button was clicked. | |
if (StructKeyExists(params, "newAddress")) { | |
// On update, we have an existing contact record from the `findContact` | |
// filter that we can load the properties into. | |
if (StructKeyExists(variables, "contact")) { | |
contact.setProperties(params.contact); | |
} | |
// If we're working on a new contact, then the `findContact` filter didn't | |
// run before this. So we need a new contact record. | |
else { | |
contact = model("contact").new(params.contact); | |
} | |
// Make sure we have an array of addresses to work with. | |
if (!StructKeyExists(contact, "addresses") || !IsArray(contact.addresses)) { | |
contact.addresses = []; | |
} | |
// Now let's add the new address with its position populated. | |
ArrayAppend( | |
contact.addresses, | |
model("address").new( | |
position: ArrayLen(contact.addresses) + 1 | |
) | |
); | |
// For an AJAX request, we need to only return the `_address` | |
// partial with the new address record. | |
if (isAjax()) { | |
local.address = contact.addresses[ArrayLen(contact.addresses)]; | |
renderText( | |
includePartial( | |
partial: "address", | |
object: local.address, | |
current: local.address.position | |
) | |
); | |
} | |
// ...or render the full page if it's not AJAX. | |
else { | |
// Lastly, load the form with the new address populated. | |
renderPage(action: params.action == "create" ? "new" : "edit"); | |
} | |
} | |
} | |
} |
Here, we use CFWheels’s isAjax
method to detect if the form was posted with
AJAX. If so, we render the _address
partial with the new object and hardcode
the current
argument to the position of the new address object.
This will force CFWheels to respond only with the contents of the partial for the newly-added object, and it will generate all of the crazy form variable naming and such for you. jQuery will then insert that into the appropriate part of the form. Pretty cool, huh?
Enhancing the “Remove Address” button with jQuery
When adding the ability to remove nested records on a form, I like to flag a record for deletion along with the form post and let the user know that the delete will happen on form submission.
Here is an example from an application that I maintain:
The example above has 2 “funding request” records, one marked for deletion. When someone clicks the delete button, it marks the record as “to be deleted,” replaces the set of fields with a message telling them that they need to save to persist the change, and even offers the user the opportunity to undo that before saving the record.
This is how I typically implement the JavaScript to make this sort of thing happen:
(function($) { | |
// Functionality for adding a new address goes here, but I'm omitting it for brevity. | |
// ... | |
// Handler for removing an address. | |
function addRemoveAddressHandler($element, event) { | |
var $container = $element.parents('div'), | |
deletionField = $container.find('input[data-delete]'); | |
$container.fadeOut('normal', function() { | |
// If this isn't a new address, mark it for deletion and add deletion row with undo | |
if (deletionField.length) { | |
deletionField.val(true); | |
addRemovalNotice($container); | |
} | |
// If this is a new address, it can just be removed from the DOM | |
else { | |
$element.remove(); | |
} | |
}); | |
event.preventDefault(); | |
} | |
// Adds a notice indicating that the record will be deleted on save. | |
// Also adds an undo link and handler. | |
function addRemovalNotice($container) { | |
var containerId = $container.attr("id"); | |
$container.hide(); | |
$container.after( | |
'<div id="address-deletion-notice-' + containerId + '">' + | |
'This address will be deleted when you click the Save Changes button below. ' + | |
'<a href="#" data-address-deletion-undo>Undo</a>' + | |
'</tr>' | |
); | |
// Add undo link handler | |
$container.find('a[data-address-deletion-undo]').on('click', function(e) { | |
var $this = $(this), | |
$noticeContainer = $this.parents('div'), | |
containerId = $noticeContainer.attr("id").replace('address-deletion-notice-', ''), | |
$removedContainer = $("#" + containerId); | |
// Fade out notice container, remove it, unflag record for deletion, | |
// and fade in the removed container. | |
$noticeContainer.fadeOut('slow', function() { | |
$(this).remove(); | |
$container.find('input[data-delete]').val(false); | |
// Fade in the removed container | |
$container.fadeIn('slow'); | |
}); | |
e.preventDefault(); | |
}); | |
} | |
// Initialize click behavior for button | |
$("button[data-remove-contact-address]").on("click", function(e) { | |
addRemoveAddressHandler($(this), e); | |
}); | |
}(jQuery)); |
This implements a couple functions and ties it all together with a click
handler way at the end:
addRemoveAddressHandler()
is split out into a separate function so we can reuse it later. It basically accepts the address fields from the user and marks it for deletion. This is accomplished by setting the address record’s_delete
property totrue
, which will then be picked up and handled by CFWheels when the form is submitted.addRemovalNotice()
is called by theaddRemoveAddressHandler()
function just mentioned. Its job is to add the removal notice and Undo link when the user clicks the delete button.- At the bottom is a click handler that binds the “Remove Address” button to
the
addRemoveAddressHandler()
handler.
So I mention above that addRemoveAddressHandler()
is split out intentionally
so it can be used elsewhere. Well, when the user clicks the “New Address”
button, the new address added needs to have its “Remove Address” button bound to
this new handler. Here is the updated ajax
call:
(function($) { | |
$('#new-address-button').on('click', function(e) { | |
// Stuff from other example left out for brevity. | |
// ... | |
// Submit the entire form via AJAX. | |
$.ajax({ | |
url: contactForm.attr('action'), | |
type: 'post', | |
data: formData, | |
cache: false, | |
success: function(data, textStatus, jqXHR) { | |
var $responseData = $(data); | |
$('#contact-addresses').append($responseData); | |
$responseData.find('button[data-remove-contact-address]').on("click", function(e) { | |
addRemoveAddressHandler($(this), e); | |
}); | |
}, | |
error: function(jqXHR, textStatus, errorThrown) { | |
alert('There was an error adding the address.'); | |
}, | |
complete: function(jqXHR, textStatus) { | |
$this.prop('disabled', false); | |
$loader.hide(); | |
} | |
}); | |
}); | |
}(jQuery)); |
With the success
callback updated, when a new addresses is added, its “Remove
Address” button should work correctly.
Progressively enhanced
There you have it. If you implement the server side logic first, you have functionality that works even without JavaScript. But then with the JavaScript handling added, you’ve enhanced the user experience without skipping much of the functionality that was built on the server.
The code examples in this post were copied, pasted, and modified from existing production code, but I was unable to run it. If you find yourself having trouble running any of it, let me know in the comments.