Created
July 7, 2022 12:11
-
-
Save bennadel/e6984cde87cea05b93b906bc4cac5b07 to your computer and use it in GitHub Desktop.
Building-Up A Complex Objects Using A Multi-Step Form Workflow In ColdFusion
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment