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:

  1. Nested properties
  2. 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
);
}
}
view raw Contact.cfc hosted with ❤ by GitHub

models/Address.cfc:

component extends="Model" {
function init() {
belongsTo("contact");
}
}
view raw Address.cfc hosted with ❤ by GitHub

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");
}
}
}
view raw Contacts.cfc hosted with ❤ by GitHub

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>
view raw new.cfm hosted with ❤ by GitHub

<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>
view raw edit.cfm hosted with ❤ by GitHub

<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>
view raw _form.cfm hosted with ❤ by GitHub

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>
view raw _address.cfm hosted with ❤ by GitHub

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 using includePartial 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 and position arguments to denote which associated object is being referenced and our current position in the array of objects.
  • We’re including hiddenFields for id and position. 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 take association or position arguments, so you need to manually tell it which object to reference via the objectName 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 positions 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 to true, which will then be picked up and handled by CFWheels when the form is submitted.
  • addRemovalNotice() is called by the addRemoveAddressHandler() 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.

About Chris Peters

With over 20 years of experience, I help plan, execute, and optimize digital experiences.

Leave a comment