Building-Up A Complex Objects Using A Multi-Step Form Workflow In ColdFusion
Earlier this week, I looked at using form POST
-backs to build up complex objects in ColdFusion. That technique allowed for deeply-nested data to be seamlessly updated using dot-delimited "object paths". My previous demo used a single page to render the form. As a fast-follow, I wanted to break the demo up into a multi-step form workflow in which each step manages only a portion of the complex object.
As a quick recap of my previous post, here are some key-points to remember:
The entire pending data structure was serialized as JSON (JavaScript Object Notation) and was included as a
type="hidden"
form field. This allowed the "state" of the pending complex object to be submitted back to the server along with each formPOST
.Each form field had a name that was composed of a dot-delimited object path, example:
.contacts.2.type
. The value of each form field was then seamlessly merged into the pending complex object using these dot-delimited paths with each formPOST
.The form used multiple submit buttons, all with
name="action"
. Thevalue
of these submit buttons determined if-and-how the pending complex object was manipulated with each formPOST
.
Accessing and mutating arbitrarily deep struct keys and array indices was facilitated through a ColdFusion component called PendingFormData.cfc
. This component merged the form submission into the complex object using the dot-delimited paths:
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
);
}
}
In my previous demo, I kept a bunch of the logic in the "controller" (ie, the top-level CFML page). But, now that we're breaking this form up into a multi-step process, the "controller" is getting a little busy. As such, I've decided to sub-class the PendingFormData.cfc
component, creating a new ColdFusion component specific to this multi-step form.
In addition to extending (and initializing) the "super" component, this ColdFusion component provides high-level methods for manipulating the complex data structure. For example, it has an addContact()
and a deleteContact()
method. The "controller" is still responsible for invoking these methods; but, the logic for what they do is now collocated with the complex object state.
Part of why I did this is because I wanted to add some lightweight validation to the multi-step workflow as the user progresses from step to step. This is done via the next()
method, which looks at the current step and the current state of the complex object and either updates the state or returns an error message (to be rendered to the user in the current step).
In the following ColdFusion component, note that I now have some "step" information in addition to the core state data:
component
extends = "PendingFormData"
output = false
hint = "I provide processing methods around a particular multi-step form."
{
/**
* I initialize the multi-step form helper with the given form scope.
*/
public void function init( required struct formScope ) {
super.init(
formScope,
// Which form field holds our serialized data.
"data",
// The default structure if the "data" key is empty.
[
// Where we are (and what we've completed) in the multi-step process.
steps: [
current: 1,
completed: 0
],
// The actual form data that we care about (ie, not related to the steps).
name: "",
email: "",
contacts: [],
isFavorite: false
]
);
}
// ---
// PUBLIC METHODS.
// ---
/**
* I add a new, empty contact.
*/
public void function addContact() {
this.data.contacts.append([
type: "home",
phone: ""
]);
}
/**
* I delete the given contact.
*/
public void function deleteContact( required numeric index ) {
this.data.contacts.deleteAt( index );
// If the user has removed all of the contacts AFTER having already completed step
// 2, we can no longer consider step beyond to 2 to be completed. This way, the
// user will have to "next" through step 2, which will kick in the validation for
// step 2 once again.
if ( ! this.data.contacts.len() && ( this.data.steps.completed > 2 ) ) {
this.data.steps.completed = 2;
}
}
/**
* I proceed the multi-step form to the given step.
*/
public void function gotoStep( required numeric step ) {
// The user can't proceed past the highest step that they've already completed.
if ( step > this.data.steps.completed ) {
return;
}
this.data.steps.current = step;
}
/**
* I process the CURRENT step and then proceed to the NEXT step. This involves
* validation of the current step's data and the return of an error message if the
* data is not valid.
*/
public string function next() {
switch ( this.data.steps.current ) {
case 1:
if ( ! this.data.name.len() ) {
return( "Please enter your name." );
}
if ( ! this.data.email.len() ) {
return( "Please enter your email." );
}
// Current step is valid, proceed to the next step.
this.data.steps.completed = max( this.data.steps.current, this.data.steps.completed );
this.data.steps.current = 2;
break;
case 2:
if ( ! this.data.contacts.len() ) {
return( "Please enter at least one contact number." );
}
// Current step is valid, proceed to the next step.
this.data.steps.completed = max( this.data.steps.current, this.data.steps.completed );
this.data.steps.current = 3;
break;
case 3:
// !! Woot woot, you did it !!
break;
}
// Fall-through return, no error detected.
return( "" );
}
/**
* I toggle the favorite status.
*/
public void function setFavorite( required boolean isFavorite ) {
this.data.isFavorite = isFavorite;
}
}
So far, we haven't really changed anything in this version of the demo - we've only moved some of the logic out of the "controller" layer and into this ColdFusion component. Now, let's look at how we can break the data-entry workflow up into multiple steps.
Keep in mind, however, that this is being broken up into multiple steps for the sake of the user experience (UX); but, I'm still considering this all to be "one big form". As such, I'm still processing all the actions at the top of the main "controller" file - the steps only server to render a subset of the form data.
The individual steps are rendered as separate CFInclude
templates that all render within the context for a parent <form>
tag. The parent <form>
takes care of submitting the pending state JSON as a hidden form field, leaving the step-wise templates to do little more than render a few of the form fields. And, since the entire, serialized state of the pending form is being submitted along with each POST
, we don't have to worry about our individual steps submitting any more data than is relevant for that particular step.
Here's my top-level "controller" CFML file:
<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. And, to encapsulate some
// of our controller processing logic, I'm EXTENDING the ColdFusion component with one
// that exposes action method for manipulation of the data.
formProxy = new MyMultiStepForm( form );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// 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 );
}
// NOTE: All steps in this multi-step form process SUBMIT BACK TO THE SAME PAGE. The
// steps are here to make it easier for the user (UX); but, as the developer, I'm
// still considering this a "single process". As such, I'm managing all the actions
// and form data mutations in this top-level page (as opposed to breaking them out
// into the individual steps).
errorMessage = "";
// NOTE: Not all actions have to use the "object path" approach. If we know the keys
// in the data that we're mutating, we can access them directly. There's no benefit to
// adding abstraction for the sake of abstraction.
switch ( form.action ) {
case "addContact":
formProxy.addContact();
break;
case "deleteContact":
formProxy.deleteContact( val( form.actionIndex ) );
break;
case "gotoStep":
formProxy.gotoStep( val( form.actionIndex ) );
break;
case "next":
// As the user proceeds from step to step, we have to validate the form data
// in the current step. Calling next() on our form proxy will return an error
// message if there is a problem.
errorMessage = formProxy.next();
break;
case "setFavorite":
formProxy.setFavorite( true );
break;
case "clearFavorite":
formProxy.setFavorite( false );
break;
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
typeOptions = [
{ value: "home", label: "Home" },
{ value: "mobile", label: "Mobile" },
{ value: "work", label: "Work" }
];
// To make it easier to render the form inputs and manage the multi-step progression,
// let's get a direct reference to the pending data structure. There's no benefit to
// going through the PendingFormData() component if we're not going to be manipulating
// abstract object paths.
data = formProxy.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 A Multi-Step Form Workflow In ColdFusion
</h1>
<form method="post">
<h2>
Multi-Step Form Workflow
</h2>
<!---
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( formProxy.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="next"
style="position: fixed ; top: -1000px ; left: -1000px ;">
Next
</button>
<!---
BREADCRUMBS NAVIGATION. Since our entire form state is being stored as
JSON in a hidden form field, navigation between steps has to be performed
as a POST BACK to the server. As such, our breadcrumb links are actually
unstyled SUBMIT BUTTONs with "goto" actions.
--->
<p>
Steps:
<cfloop index="i" from="1" to="3">
<cfif ( i lte data.steps.completed )>
<!--- Actionable breadcrumb. --->
<button type="submit" name="action" value="gotoStep : _ : #i#" class="text-button">
[ Step #i# ]
</button>
<cfelse>
<!--- Informational breadcrumb. --->
[ Step #i# ]
</cfif>
</cfloop>
</p>
<cfif errorMessage.len()>
<p class="error">
#encodeForHtml( errorMessage )#
</p>
</cfif>
<cfswitch expression="#data.steps.current#">
<cfcase value="1">
<cfinclude template="./multi-step-1.cfm" />
</cfcase>
<cfcase value="2">
<cfinclude template="./multi-step-2.cfm" />
</cfcase>
<cfcase value="3">
<cfinclude template="./multi-step-3.cfm" />
</cfcase>
</cfswitch>
</form>
<h2>
Pending Form Data
</h2>
<cfdump var="#data#" />
</body>
</html>
</cfoutput>
As you can see, we're using CFInclude
to include the rendering for the current step. Here's Step 1 - notice that it doesn't care about the pending form state or any form fields outside of the current step. But, it does have multiple action
buttons - these are what drive the mutation of the pending form data on each form POST
:
<cfoutput>
<h2>
Step 1
</h2>
<!---
NOTE that the name of each form field starts with a "." as in ".name". These are
OBJECT PATHS and will automatically be saved into the pending data structure when
the MyMultiStepForm( PendingFormData() ) components are 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"
/>
</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"
/>
</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">
Clear favorite
</button>
<cfelse>
Is <em>not</em> favorite contact
<button type="submit" name="action" value="setFavorite">
Set as favorite
</button>
</cfif>
</div>
</div>
<div class="buttons">
<button type="submit" name="action" value="next" class="primary">
Next »
</button>
</div>
</cfoutput>
I'm not going to bother showing the other steps because I basically just took the code from the previous demo and broke it up into separate files. The individual steps didn't change - it's the managing of the steps as a multi-step workflow that differentiates this version of the demo.
With that said, if I open the ColdFusion form and start submitting data, we get the following output:
As you can see, as I proceed through each step, our pending, complex data structure is being populated iteratively. Each individual step only cares about its own form fields since the entire pending data structure is being re-submitted as a hidden form field with each action. As such, each form submission is only about updating a subset of the object graph.
The key to all of this, really, is the fact that the entire state of the form is being submitted as JSON in each POST
. That's what makes it possible to manage state across multiple pages without having to persist the data to a data-store of some sort.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →