Building-Up A Complex Objects Using Form POST-Backs In ColdFusion
Normally, when building forms in ColdFusion, I have simple key-value pairs that map neatly onto individual form fields. Recently, however, I've been working on a form workflow that needs to both construct and populate a complex object before the data can be persisted. To manage this workflow, I'm borrowing an idea from ASP.net wherein the "state" of the page is posted back to the server on each form submission. This "state" can then be acted upon by the ColdFusion server in order to maintain complex data structures across page loads.
At the root of this workflow is a JSON (JavaScript Object Notation) payload that is submitted back to the server as a hidden form field. At the start of each form submission, this JSON payload is deserialized, mutated, and then re-serialized back into the hidden form field. This is our "state". This is the complex object that is being further populated by each form submission.
<cfscript>
param name="form.data" type="string" default="";
pendingData = ( form.data.len() )
? deserializeJson( form.data )
: {}
;
// ... truncated for snippet ...
</cfscript>
<cfoutput>
<form method="post">
<input
type="hidden"
name="data"
value="#encodeForHtmlAttribute( serializeJson( pendingData ) )#"
/>
<!--- truncated for snippet --->
</form>
</cfoutput>
As you can see, the pendingData
object is being posted back to the server on each form submission. It is then being deserialized / hydrated and made available to the server-side portion of the ColdFusion form processing.
Mapping traditional form fields onto this complex data structure isn't possible since the size and shape of the data structure changes with each form submission. As such, I'm using a technique wherein the name of the form field provides an object path, not a literal name. This object path is defined as a dot-delimited list of struct keys and array indices that map the location of a data-point.
This path, for example:
.contacts.2.type
... means:
- Access the struct key
contacts
, which holds an array. - Access the 2nd array index, which holds a struct.
- Access the struct key
type
.
If I wanted to set the value at that deep property to "mobile", I would define this form field:
<input
type="text"
name=".contacts.2.type"
value="mobile"
/>
Setting values on existing properties then becomes relatively straightforward. But, adding and removing array indices requires some additional complexity. To manage these complex mutations, I'm using a native HTML form feature in which I can provide multiple submit buttons in the same form.
When a form has multiple submit buttons, giving each submit button the same name
and a different value
allows us to detect which submit button was used to post the form. As such, we can define submit buttons that add array indices; and, we can define submit buttons that remove array indices.
However, since our complex data structure can be arbitrarily deep, submitting an action alone isn't sufficient - we have to tell the ColdFusion server where within the data structure our array exists. To do this, I'm using submit buttons whose value
can contain an object path.
In fact, the value
can contain both an object path and an array index:
<button
type="submit"
name="action"
value="removeContact : .contacts : 2">
Remove
</button>
This submit button is providing the following colon-delimited information:
- Action:
removeContact
- Object Path:
.contacts
- Object Index:
2
Of course, just because we have a strategy now for locating and updating values within a complex, arbitrarily deep data structure, we still have to translate those object paths into actual ColdFusion references. For this, I'm going to use the .reduce()
method. If you think of the object path as a series of "traversal operations", we can reduce over the dot-delimited list and have each operation return the value located at the current key, starting at the root of the pending data structure:
<cfscript>
// Here, we're using the .reduce() method to walk the dot-delimited segments
// within the key path and traverse down into the data object. Each dot-delimited
// segment represents a step down into a nested structure (or Array).
value = objectPath.listToArray( "." ).reduce(
( reduction, segment ) => {
return( reduction[ segment ] );
},
pendingData // Start at the root of the pending data structure.
);
</cfscript>
Since both ColdFusion Structs and ColdFusion Arrays can use bracket-notation to access nested values, it doesn't matter if our dot-delimited segment is a "key" or an "index" - we can locate the data using a uniform notation. This allows our .reduce()
logic to be a 1-liner.
ASIDE: ColdFusion has a
structGet()
built-in function that provides access to arbitrarily deep struct keys. But, it doesn't play nicely with nested array indices.
To make this .reduce()
-based access pattern easier to consume, I've wrapped some helper functions up in their own ColdFusion component, PendingDataForm.cfc
:
component
output = false
hint = "I provide methods for manipulating the pending data in a form."
{
/**
* I initialize the pending form data with the given, serialized value.
*/
public void function init(
required struct formScope,
required string dataKey,
required struct defaultValue
) {
this.data = ( len( formScope[ dataKey ] ?: "" ) )
? deserializeJson( formScope[ dataKey ] )
: defaultValue
;
// Loop over the form scope and look for OBJECT PATHS. Any key that is an object
// path should have its value stored into the pending data structure.
for ( var key in formScope ) {
if ( key.left( 1 ) == "." ) {
setValue( key, formScope[ key ].trim() );
}
}
}
// ---
// PUBLIC METHODS.
// ---
/**
* I return the serialized value for the current, pending data structure.
*/
public string function getJson() {
return( serializeJson( this.data ) );
}
/**
* I take a dot-delimited object path, like ".contacts.3.number", and return the value
* stored deep within the "data" structure.
*/
public any function getValue( required string objectPath ) {
// Here, we're using the .reduce() method to walk the dot-delimited segments
// within the key path and traverse down into the data object. Each dot-delimited
// segment represents a step down into a nested structure (or Array).
var value = objectPath.listToArray( "." ).reduce(
( reduction, segment ) => {
return( reduction[ segment ] );
},
this.data
);
return( value );
}
/**
* I take a dot-delimited object path, like ".contacts.3.number", and store a new value
* deep within the "data" structure.
*/
public void function setValue(
required string objectPath,
required any value
) {
// Again, we're using the .reduce() method to walk the dot-delimited segments
// within the key path and traverse down into the data object. Only this time,
// once we reach the LAST SEGMENT, we're going to treat it as a WRITE rather than
// a READ.
objectPath.listToArray( "." ).reduce(
( reduction, segment, segmentIndex, segments ) => {
// LAST SEGMENT becomes a write operation, not a read.
if ( segmentIndex == segments.len() ) {
reduction[ segment ] = value;
}
return( reduction[ segment ] );
},
this.data
);
}
}
This ColdFusion component provides getter and setter methods that use object-paths. It also iterates over the form
scope and saves each object-path form-field into the pending data structure.
With this ColdFusion component, I can now create a workflow that allows me to iteratively build-up and populate a complex data structure using nothing but POST
-backs to the server. To demonstrate, I'm going to create a "Contact" data structure that allow for an arbitrary set of nested phone numbers. What you'll see is that there are multiple type="submit"
button that each perform different actions on this complex data structure.
What you'll see in the following code is that I'm using the PendingFormData.cfc
to access the underlying data; but then, I'm still manipulating it in the calling context. I didn't see any need to move more of the logic into the ColdFusion component.
<cfscript>
// With every FORM POST, the current, serialized version of our pending data is going
// to be posted back to the server. The ACTION values will then describe how we want
// to mutate this post-back data on each rendering.
param name="form.data" type="string" default="";
// The PendingFormData() provides helper functions that make it easy to access and
// change properties deep within the pending data structure.
pendingFormData = new PendingFormData(
form,
// Which form field holds our serialized data.
"data",
// The default structure if the data key is empty.
[
name: "",
email: "",
contacts: [],
isFavorite: false
]
);
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Each SUBMIT BUTTON can provide an "action" value that is in the form of:
// --
// {action} : {objectPath} : {index}
// --
// This value will be split into separate values for easier consumption, and provides
// us with a means to locate and mutate values deep within the pending data structure.
param name="form.action" type="string" default="";
param name="form.actionPath" type="string" default="";
param name="form.actionIndex" type="numeric" default="0";
if ( form.action.find( ":" ) ) {
parts = form.action.listToArray( ": " );
form.action = parts[ 1 ];
form.actionPath = parts[ 2 ];
form.actionIndex = val( parts[ 3 ] ?: 0 );
}
switch ( form.action ) {
case "addContact":
pendingFormData.getValue( form.actionPath )
.append([
type: "home",
phone: ""
])
;
break;
case "deleteContact":
pendingFormData.getValue( form.actionPath )
.deleteAt( form.actionIndex )
;
break;
case "setFavorite":
pendingFormData.setValue( form.actionPath, true );
break;
case "clearFavorite":
pendingFormData.setValue( form.actionPath, false );
break;
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
typeOptions = [
{ value: "home", label: "Home" },
{ value: "mobile", label: "Mobile" },
{ value: "work", label: "Work" }
];
// To make it easier to rendering the form inputs, let's get a direct reference to the
// pending data structure. There's no benefit to go through the PendingFormData()
// component if we're not going to be manipulating the values.
data = pendingFormData.data;
</cfscript>
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="./styles.css" />
</head>
<body>
<h1>
Building-Up A Complex Objects Using Form POST-Backs In ColdFusion
</h1>
<form method="post">
<!---
The entire pending form object (JSON version), is posted back to the
server as a hidden field with every action. We'll then use the "action"
values to apply different transformations to the pending form object with
each form submission.
--->
<input
type="hidden"
name="data"
value="#encodeForHtmlAttribute( pendingFormData.getJson() )#"
/>
<!---
When a form is submitted with the ENTER KEY, the browser will implicitly
use the first submit button in the DOM TREE order as the button that
triggered the submit. Since we are using MULTIPLE SUBMIT buttons to drive
object manipulation, let's add a NON-VISIBLE submit as the first element
in the form so that the browser uses this one as the default submit.
--->
<button
type="submit"
name="action"
value="save"
style="position: fixed ; top: -1000px ; left: -1000px ;">
Save Data
</button>
<!---
NOTE that the name of each form field starts with a "." as in ".name".
These are OBJECT PATHS and will automatically be saved into pending data
structure when the PendingFormData() component is initialized.
--->
<div class="entry">
<label class="entry__label">
Name:
</label>
<div class="entry__body">
<input
type="text"
name=".name"
value="#encodeForHtmlAttribute( data.name )#"
size="32"
autocomplete="off"
/>
</div>
</div>
<div class="entry">
<label class="entry__label">
Email:
</label>
<div class="entry__body">
<input
type="email"
name=".email"
value="#encodeForHtmlAttribute( data.email )#"
size="32"
autocomplete="off"
/>
</div>
</div>
<div class="entry">
<label class="entry__label">
Contacts:
</label>
<div class="entry__body">
<div class="entry__actions">
<button type="submit" name="action" value="addContact : .contacts">
Add Contact
</button>
</div>
<cfloop item="contact" index="contactIndex" array="#data.contacts#">
<div class="subitems">
<!---
As you can see here, the OBJECT PATHS aren't just struct
keys - they can include array indices as well.
--->
<select name=".contacts.#contactIndex#.type">
<cfloop item="type" array="#typeOptions#">
<option
value="#encodeForHtmlAttribute( type.value )#"
<cfif ( contact.type == type.value )>selected</cfif>>
#encodeForHtml( type.label )#
</option>
</cfloop>
</select>
<input
type="text"
name=".contacts.#contactIndex#.phone"
value="#encodeForHtmlAttribute( contact.phone )#"
size="20"
/>
<button
type="submit"
name="action"
value="deleteContact : .contacts : #contactIndex#">
Remove
</button>
</div>
</cfloop>
</div>
</div>
<div class="entry">
<div class="entry__label">
Favorite:
</div>
<div class="entry__body">
<cfif data.isFavorite>
Is favorite contact
<button type="submit" name="action" value="clearFavorite : .isFavorite">
Clear favorite
</button>
<cfelse>
Is <em>not</em> favorite contact
<button type="submit" name="action" value="setFavorite : .isFavorite">
Set as favorite
</button>
</cfif>
</div>
</div>
<div class="buttons">
<button type="submit" name="action" value="save" class="primary">
Save Data
</button>
</div>
</form>
<hr />
<h2>
Pending Form Data
</h2>
<cfdump var="#data#" />
</body>
</html>
</cfoutput>
Now, if I render this ColdFusion page and start adding data, we get the following output. These iterative changes are easier to see in the video; but, what you can see in the GIF is that the state of the entire form structure is maintained as the form is posted posted-back to the server. The state is then subsequently modified be each action:
As you can see, I'm able to add, remove, and edit phone numbers "deep" within this pending data structure - all without saving the data back to the persistence layer. This will allow me to build-up an arbitrarily complex payload before eventually saving it to the database.
Most of my ColdFusion forms deal with simple, flat data structures that more-or-less map onto rows in a database. But, some of the work that I've been doing lately uses some NoSQL-like fields; and, I'm feeling pretty good now about manipulating these fields in ColdFusion.
In a perfect world, this page would be fronted by a rich, Angular client that wouldn't need a post-back and could perform all of the data manipulation on the client. But, for now, I'm working on a proof-of-concept and I don't want any JavaScript involved.
Want to use code from this post? Check out the license.
Reader Comments
Brain K. used to have a CFC that did something simular. Look at the buildFormCollections method.
https://github.com/sosensible/Collaboration/blob/master/share/objects/collaboration/FormUtilities.cfc
@John,
I'm just going to have to say that "Great minds think alike" 🤪 but, seriously, I think my code is probably little more than the "more modern" version of what Brian was doing. By that, I just mean that the syntax / build-in methods are lighter-weight and easier to deal with.
That said, I think there is some magic to submitting the whole state as JSON in a hidden field. That really opens up some interesting possibilities - something I'll try to follow-up in my next post.
@Ben, agree. Just was not a good choice of words. In the spirit of Ortus and the community you definitely did modernize the approach.
@All,
As a fast-follow to this post, I wanted to rework the demo into a multi-step wizard. I realized that using JSON to post the pending form state back to the server with each submission actually unlocks some very interesting potential. Specifically, we can break our form up into parts without having to have any data persistence mechanism. Essentially the form
POST
life-cycle acts as its own persistence.www.bennadel.com/blog/4296-building-up-a-complex-objects-using-a-multi-step-form-workflow-in-coldfusion.htm
Of course, you could always persist data to the browser's
SessionStorage
API as a back-up 😉Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →