Process Steppers
Getting Started
Create an HTML file and include the following code. This is the minimum required code to get a locked process stepper working.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="HomeSeer Technologies">
{{includefile '/bootstrap/css/page_common.css'}}
<link href="../bootstrap/css/addons-pro/steppers.min.css" rel="stylesheet">
<style>
div.step-title {
cursor: default !important;
}
div.log-detail {
display: block;
position: relative;
overflow: auto;
max-height: 15rem;
}
</style>
<title>Locked Stepper Sample</title>
</head>
<body class="body homeseer-skin">
{{includefile 'header.html'}}
{{includefile 'navbar.html'}}
<div class="container">
<div class="row mt-3">
<h3>Locked Stepper Sample</h3>
</div>
<div class="row">
<p>This is a sample for a "locked" stepper.</p>
</div>
<div class="w-responsive">
<ul id="process-stepper" class="stepper parallel formless">
<li class="step active locked" id="step1">
<div data-step-label="Step 1" class="step-title waves-effect waves-dark">Step 1</div>
<div class="step-new-content">
<div>Step 1</div>
<div class="step-actions pt-4">
<button class="waves-effect waves-dark btn btn-sm btn-default custom-step" onclick="goToNextStep()">CONTINUE</button>
</div>
</div>
</li>
<li class="step locked" id="step2">
<div data-step-label="Step 2" class="step-title waves-effect waves-dark">Step 2</div>
<div class="step-new-content">
<div>Step 2</div>
<div class="step-actions pt-4">
<button class="waves-effect waves-dark btn btn-sm btn-danger custom-step" onclick="goToPrevStep()">BACK</button>
<button id="step2-start-btn" class="waves-effect waves-dark btn btn-sm btn-default custom-step" onclick="goToNextStep()">CONTINUE</button>
</div>
</div>
</li>
<li class="step locked" id="step4">
<div data-step-label="Step 4" class="step-title waves-effect waves-dark">Step 4</div>
<div class="step-new-content">
<div id="step4-summary-txt">Summary of the process goes here.</div>
<div class="step-actions pt-4">
<button class="waves-effect waves-dark btn btn-sm btn-primary custom-step" onclick="retryProcess()">DO THIS AGAIN</button>
<button class="waves-effect waves-dark btn btn-sm btn-default custom-step" onclick="goToDevicesPage()">FINISH</button>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
{{includefile 'bootstrap/js/page_common.js'}}
<script type="text/javascript" src="../bootstrap/js/addons-pro/steppers.min.js"></script>
<script type="text/javascript" src="../js/jquery.validate.min.js"></script>
<script type="text/javascript">
$(document).ready(function () {
$('.stepper').mdbStepper();
});
function goToPrevStep() {
$('#process-stepper').destroyFeedback();
$('#process-stepper').prevStep();
}
function goToNextStep() {
$('#process-stepper').destroyFeedback();
$('#process-stepper').nextStep();
}
function goToDevicesPage() {
var devicesPage = window.location.origin + "/devices.html";
window.location.assign(devicesPage);
}
function retryProcess() {
window.location.reload()
}
</script>
</body>
</html>
Pay close attention to the .formless
class on the main ul, the .locked
class on each li, and the .custom-step
class on each of the buttons. These are responsible for circumventing an auto-form POST error using the default implementation and restricting the users ability to freely navigate around the steps.
Executing a long running process
Process steppers are commonly used to execute a long running process as part of one of the steps. While executing this process, it is important to communicate to the user what is happening. This can easily be done with a spinner. Add the following step between the last two steps:
<li class="step locked" id="step3">
<div data-step-label="Wait for process to complete" class="step-title waves-effect waves-dark">Step 3</div>
<div class="step-new-content">
<div>Please wait...</div>
<div class="preloader-wrapper small active my-2">
<div class="spinner-layer spinner-blue-only">
<div class="circle-clipper left"><div class="circle"></div></div>
<div class="gap-patch"><div class="circle"></div></div>
<div class="circle-clipper right"><div class="circle"></div></div>
</div>
</div>
<div class="pt-4">
<button id="step4-cancel-btn" class="waves-effect waves-dark btn btn-sm btn-danger custom-step" onclick="cancelProc()">CANCEL</button>
</div>
</div>
</li>
Include the following javascript:
var gIsProcessCanceled = false;
var gCheckProc = null;
//Start Process
function startLongProcess() {
gIsProcessCanceled = false;
$.ajax({
type: "POST",
async: "true",
url: '',
data: '',
timeout: 10000,
success: onStartLongProcessSuccess,
error: onStartLongProcessError
});
}
function onStartLongProcessSuccess(response) {
if (response == null || response === "") {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (1: NULL RESPONSE ON START)");
return;
}
try {
//TODO process the response
//You can use JSON throughout this process
//var responseObj = JSON.parse(response);
waitForLongProcessFinish();
} catch (e) {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (2: EXCEPTION ON START : " + e.message + ")");
}
}
function onStartLongProcessError() {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (3: POST ERROR ON START)");
}
//Wait for process finish
function waitForLongProcessFinish() {
goToNextStep();
if (gCheckProc != null) {
//There is a process already running
return;
}
gCheckProc = setInterval(getLongProcessStatus, 1000);
}
//Get status POST
function getLongProcessStatus() {
//TODO call to your plugin to get the latest status for this process
// PS Make sure you are aware of possible multiple client sessions
//This is run in-sync with the timer thread to prevent multiple calls overlapping
$.ajax({
type: "POST",
async: "false",
url: '',
data: '',
timeout: 2000,
success: onGetLongProcessStatusSuccess,
error: onGetLongProcessStatusError
});
}
function onGetLongProcessStatusSuccess(response) {
if (response == null || response === "") {
goToFinishStep("There was an unexpected error while getting the status");
return;
}
try {
//TODO Update detail logs if you would like
// using updateLogTables(table-body-html)
/*EG
rows of "<tr><td>process log entry</td></tr>"
*/
//TODO analyze the response to determine if the process is still running or not
/*EG
if (!responseObj.isrunning) {
//The process is not running anymore
goToFinishStep("The include process has been stopped.");
return;
}
*/
//The process is still running
goToFinishStep("The process has completed.");
} catch (e) {
goToFinishStep("There was an unexpected error while trying to get the status of the process. Please try again. (4: EXCEPTION ON STATUS CHECK : " + e.message + ")", "<tr><td>" + response +"</td></tr>");
}
}
function onGetLongProcessStatusError() {
goToFinishStep("There was an unexpected error while trying to get the status of the process. Please try again. (5: POST ERROR ON STATUS CHECK)");
}
//Cancel
function cancelProc() {
gIsProcessCanceled = true;
// cancel without waiting for a response
//TODO Call to your plugin to cancel
/*$.ajax({
type: "POST",
async: "true",
url: '',
data: ''
});*/
}
//Finish
function goToFinishStep(summaryText, log) {
stopStatusCheck();
if (summaryText != null) {
var step5Summary = document.getElementById("step4-summary-txt");
if (step5Summary != null) {
step5Summary.innerText = summaryText;
}
}
if (log != null && log !== "") {
var step5Log = document.getElementById("step4-log-table-body");
if (step5Log != null) {
step5Log.innerHTML = log;
}
}
//Set the current step to 4
$('#process-stepper').destroyFeedback();
$('#process-stepper').openStep(4);
}
And then replace the function being called by the onclick
attribute on the step 2 button
with a call to the startLongProcess()
function.
<button id="step2-start-btn" class="waves-effect waves-dark btn btn-sm btn-default custom-step" onclick="startLongProcess()">CONTINUE</button>
Running this will automatically jump the user to the last step because the ajax calls are currently incomplete. Let's go over each of the pieces involved in this to integrate it with your custom plugin process.
Start the process
The startLongProcess()
function uses an AJAX POST to call through to your plugin's implementation of PostBackProc. Return any data that you need to indicate whether or not the process started successfully. If the process started make sure to call the waitForLongProcessFinish()
function to start checking on the process until it is complete, or, if it did not start, you can jump right to the end with the goToFinishStep()
function.
//Start Process
function startLongProcess() {
gIsProcessCanceled = false;
$.ajax({
type: "POST",
async: "true",
url: '',
data: '',
timeout: 10000,
success: onStartLongProcessSuccess,
error: onStartLongProcessError
});
}
function onStartLongProcessSuccess(response) {
if (response == null || response === "") {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (1: NULL RESPONSE ON START)");
return;
}
try {
//TODO process the response
//You can use JSON throughout this process
//var responseObj = JSON.parse(response);
waitForLongProcessFinish();
} catch (e) {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (2: EXCEPTION ON START : " + e.message + ")");
}
}
function onStartLongProcessError() {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (3: POST ERROR ON START)");
}
Wait for the process to finish
The waitForLongProcessFinish()
function starts a timer thread that makes an AJAX POST every second to get the latest status of the process through your plugin's implementation of PostBackProc. If the process has stopped, either because it failed or completed successfully, you can jump to the last step to display the final results using the goToFinishStep()
method. Include whatever data you need in your response like a JSON serialized object.
//Wait for process finish
function waitForLongProcessFinish() {
goToNextStep();
if (gCheckProc != null) {
//There is a process already running
return;
}
gCheckProc = setInterval(getLongProcessStatus, 1000);
}
//Get status POST
function getLongProcessStatus() {
//TODO call to your plugin to get the latest status for this process
// PS Make sure you are aware of possible multiple client sessions
//This is run in-sync with the timer thread to prevent multiple calls overlapping
$.ajax({
type: "POST",
async: "false",
url: '',
data: '',
timeout: 2000,
success: onGetLongProcessStatusSuccess,
error: onGetLongProcessStatusError
});
}
function onGetLongProcessStatusSuccess(response) {
if (response == null || response === "") {
goToFinishStep("There was an unexpected error while getting the status");
return;
}
try {
//TODO process the response
//You can use JSON throughout this process
//var responseObj = JSON.parse(response);
//TODO analyze the response to determine if the process is still running or not
/*EG
if (!responseObj.isrunning) {
//The process is not running anymore
goToFinishStep("The include process has been stopped.");
return;
}
*/
goToFinishStep("The process has completed.");
} catch (e) {
goToFinishStep("There was an unexpected error while trying to get the status of the process. Please try again. (4: EXCEPTION ON STATUS CHECK : " + e.message + ")", "<tr><td>" + response +"</td></tr>");
}
}
function onGetLongProcessStatusError() {
goToFinishStep("There was an unexpected error while trying to get the status of the process. Please try again. (5: POST ERROR ON STATUS CHECK)");
}
Cancel the process
To make sure the user can cancel the process before it finishes, the AJAX POST needs to be completed in the cancelProc() function should be completed. The check to ensure the process has been canceled should be completed by the currently running status check thread.
//Cancel
function cancelProc() {
gIsProcessCanceled = true;
// cancel without waiting for a response
//TODO Call to your plugin to cancel
/*$.ajax({
type: "POST",
async: "true",
url: '',
data: ''
});*/
}
Include process details
It can be useful to include a collapsible region for details so some users can look at what is going on with the process if they would like to both while it is running and at the end to see the whole process log. Add the following HTML inside step 3 after the spinner div and before the div containing the cancel button at the end.
<button id="step3-log-expand-btn" class="btn btn-sm btn-primary mt-2" type="button" data-toggle="collapse" data-target="#step3-log-expand-content"
aria-expanded="false" aria-controls="step3-log-expand-content">Details</button>
<!-- Collapsible content -->
<div class="collapse pt-3" id="step3-log-expand-content">
<div id="step3-log-expand-content-body" class="rounded-lg border log-detail">
<table class="table table-sm table-striped">
<tbody id="step3-log-table-body">
<tr><td>No log data available...</td></tr>
</tbody>
</table>
</div>
</div>
<!--/ Collapsible content -->
Add a similar region to the last step.
<button id="step4-log-expand-btn" class="btn btn-primary" type="button" data-toggle="collapse" data-target="#step4-log-expand-content"
aria-expanded="false" aria-controls="step4-log-expand-content">Details</button>
<!-- Collapsible content -->
<div class="collapse pt-3" id="step4-log-expand-content">
<div id="step4-log-expand-content-body" class="rounded-lg border log-detail">
<table class="table table-sm table-striped">
<tbody id="step4-log-table-body">
<tr><td>No log data available...</td></tr>
</tbody>
</table>
</div>
</div>
<!--/ Collapsible content -->
Add the necessary javascript function.
function updateLogTables(tableContent) {
if (tableContent == null || tableContent === "") {
return;
}
//Log in the stepper auto scrolls to the bottom
var step3Log = document.getElementById("step3-log-table-body");
if (step3Log != null) {
step3Log.innerHTML = tableContent;
document.getElementById("step3-log-expand-content-body").scrollTop = step3Log.scrollHeight;
}
//Log at the end
var step4Log = document.getElementById("step4-log-table-body");
if (step4Log != null) {
step4Log.innerHTML = tableContent;
}
}
And call it to update the details section within the get status process thread with something like...
updateLogTables(responseObj.details);
/*EG rows of "<tr><td>process log entry</td></tr>" */
Complete page example
After following along with this article, you should end up with an HTML page that looks something like this.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--This maintains the scale of the page based on the scale of the screen-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="HomeSeer Technologies">
<!--This liquid tag loads all of the necessary css files for HomeSeer-->
{{includefile '/bootstrap/css/page_common.css'}}
<link href="../bootstrap/css/addons-pro/steppers.min.css" rel="stylesheet">
<style>
div.step-title {
cursor: default !important;
}
div.log-detail {
display: block;
position: relative;
overflow: auto;
max-height: 15rem;
}
</style>
<title>Locked Stepper Sample</title>
</head>
<body class="body homeseer-skin">
{{includefile 'header.html'}}
{{includefile 'navbar.html'}}
<div class="container">
<!--Intro-->
<div class="row mt-3">
<h3>Locked Stepper Sample</h3>
</div>
<div class="row">
<p>This is a sample for a "locked" stepper.
This stepper further restricts the user's ability to freely navigate the steps from the mdbootstrap linear stepper.
Also demonstrated is the .formless class for steppers and the .custom-step for buttons that circumvents the
automatically generated mdbootstrap form that can sometimes interfere with other ajax calls.
</p>
</div>
<div class="w-responsive">
<ul id="process-stepper" class="stepper parallel formless">
<!--Select interface-->
<li class="step active locked" id="step1">
<div data-step-label="Step 1" class="step-title waves-effect waves-dark">Step 1</div>
<div class="step-new-content">
Step 1
<div class="step-actions pt-4">
<button class="waves-effect waves-dark btn btn-sm btn-default custom-step" onclick="goToNextStep()">CONTINUE</button>
</div>
</div>
</li>
<li class="step locked" id="step2">
<div data-step-label="Start process" class="step-title waves-effect waves-dark">Step 2</div>
<div class="step-new-content">
<div>Tap START to run a long process on your plugin.</div>
<div class="step-actions pt-4">
<button class="waves-effect waves-dark btn btn-sm btn-danger custom-step" onclick="goToPrevStep()">BACK</button>
<button id="step2-start-btn" class="waves-effect waves-dark btn btn-sm btn-default custom-step" onclick="startLongProcess()">START</button>
</div>
</div>
</li>
<!--Device was detected and is being added to the system-->
<li class="step locked" id="step3">
<div data-step-label="Wait for process to complete" class="step-title waves-effect waves-dark">Step 3</div>
<div class="step-new-content">
<div>Please wait...</div>
<div class="preloader-wrapper small active my-2">
<div class="spinner-layer spinner-blue-only">
<div class="circle-clipper left"><div class="circle"></div></div>
<div class="gap-patch"><div class="circle"></div></div>
<div class="circle-clipper right"><div class="circle"></div></div>
</div>
</div>
<button id="step3-log-expand-btn" class="btn btn-sm btn-primary mt-2" type="button" data-toggle="collapse" data-target="#step3-log-expand-content"
aria-expanded="false" aria-controls="step3-log-expand-content">Details</button>
<!-- Collapsible content -->
<div class="collapse pt-3" id="step3-log-expand-content">
<div id="step3-log-expand-content-body" class="rounded-lg border log-detail">
<table class="table table-sm table-striped">
<tbody id="step3-log-table-body">
<tr><td>No log data available...</td></tr>
</tbody>
</table>
</div>
</div>
<!--/ Collapsible content -->
<div class="pt-4">
<button id="step4-cancel-btn" class="waves-effect waves-dark btn btn-sm btn-danger custom-step" onclick="cancelProc()">CANCEL</button>
</div>
</div>
</li>
<!--All done-->
<li class="step locked" id="step4">
<div data-step-label="Review the results" class="step-title waves-effect waves-dark">Step 4</div>
<div class="step-new-content">
<div id="step4-summary-txt">Summary of the process goes here.</div>
<button id="step4-log-expand-btn" class="btn btn-primary" type="button" data-toggle="collapse" data-target="#step4-log-expand-content"
aria-expanded="false" aria-controls="step4-log-expand-content">Details</button>
<!-- Collapsible content -->
<div class="collapse pt-3" id="step4-log-expand-content">
<div id="step4-log-expand-content-body" class="rounded-lg border log-detail">
<table class="table table-sm table-striped">
<tbody id="step4-log-table-body">
<tr><td>No log data available...</td></tr>
</tbody>
</table>
</div>
</div>
<!--/ Collapsible content -->
<div class="step-actions pt-4">
<button class="waves-effect waves-dark btn btn-sm btn-primary custom-step" onclick="retryProcess()">DO THIS AGAIN</button>
<button class="waves-effect waves-dark btn btn-sm btn-default custom-step" onclick="goToDevicesPage()">FINISH</button>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
{{includefile 'bootstrap/js/page_common.js'}}
<script type="text/javascript" src="../bootstrap/js/addons-pro/steppers.min.js"></script>
<script type="text/javascript" src="../js/jquery.validate.min.js"></script>
<script type="text/javascript">
$(document).ready(function () {
$('.stepper').mdbStepper();
});
var gIsProcessCanceled = false;
var gCheckProc = null;
function goToPrevStep() {
$('#process-stepper').destroyFeedback();
$('#process-stepper').prevStep();
}
function goToNextStep() {
$('#process-stepper').destroyFeedback();
$('#process-stepper').nextStep();
}
//Start Process
function startLongProcess() {
gIsProcessCanceled = false;
$.ajax({
type: "POST",
async: "true",
url: '',
data: '',
timeout: 10000,
success: onStartLongProcessSuccess,
error: onStartLongProcessError
});
}
function onStartLongProcessSuccess(response) {
if (response == null || response === "") {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (1: NULL RESPONSE ON START)");
return;
}
try {
//TODO process the response
//You can use JSON throughout this process
//var responseObj = JSON.parse(response);
waitForLongProcessFinish();
} catch (e) {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (2: EXCEPTION ON START : " + e.message + ")");
}
}
function onStartLongProcessError() {
goToFinishStep("There was an unexpected error while trying to initiate the process. Please try again. (3: POST ERROR ON START)");
}
//Wait for process finish
function waitForLongProcessFinish() {
goToNextStep();
if (gCheckProc != null) {
//There is a process already running
return;
}
gCheckProc = setInterval(getLongProcessStatus, 1000);
}
//Get status POST
function getLongProcessStatus() {
//TODO call to your plugin to get the latest status for this process
// PS Make sure you are aware of possible multiple client sessions
//This is run in-sync with the timer thread to prevent multiple calls overlapping
$.ajax({
type: "POST",
async: "false",
url: '',
data: '',
timeout: 2000,
success: onGetLongProcessStatusSuccess,
error: onGetLongProcessStatusError
});
}
function onGetLongProcessStatusSuccess(response) {
if (response == null || response === "") {
goToFinishStep("There was an unexpected error while getting the status");
return;
}
try {
//TODO Update detail logs if you would like
// using updateLogTables(table-body-html)
/*EG
rows of "<tr><td>process log entry</td></tr>"
*/
//TODO analyze the response to determine if the process is still running or not
/*EG
if (!responseObj.isrunning) {
//The process is not running anymore
goToFinishStep("The include process has been stopped.");
return;
}
*/
//The process is still running
goToFinishStep("The process has completed.");
} catch (e) {
goToFinishStep("There was an unexpected error while trying to get the status of the process. Please try again. (4: EXCEPTION ON STATUS CHECK : " + e.message + ")", "<tr><td>" + response +"</td></tr>");
}
}
function onGetLongProcessStatusError() {
goToFinishStep("There was an unexpected error while trying to get the status of the process. Please try again. (5: POST ERROR ON STATUS CHECK)");
}
function updateLogTables(tableContent) {
if (tableContent == null || tableContent === "") {
return;
}
//Log in the stepper auto scrolls to the bottom
var step3Log = document.getElementById("step3-log-table-body");
if (step3Log != null) {
step3Log.innerHTML = tableContent;
document.getElementById("step3-log-expand-content-body").scrollTop = step3Log.scrollHeight;
}
//Log at the end
var step4Log = document.getElementById("step4-log-table-body");
if (step4Log != null) {
step4Log.innerHTML = tableContent;
}
}
//Cancel
function cancelProc() {
gIsProcessCanceled = true;
// cancel without waiting for a response
//TODO Call to your plugin to cancel
/*$.ajax({
type: "POST",
async: "true",
url: '',
data: ''
});*/
}
//Finish
function goToFinishStep(summaryText, log) {
stopStatusCheck();
if (summaryText != null) {
var step5Summary = document.getElementById("step4-summary-txt");
if (step5Summary != null) {
step5Summary.innerText = summaryText;
}
}
if (log != null && log !== "") {
var step5Log = document.getElementById("step4-log-table-body");
if (step5Log != null) {
step5Log.innerHTML = log;
}
}
//Set the current step to 4
$('#process-stepper').destroyFeedback();
$('#process-stepper').openStep(4);
}
function goToDevicesPage() {
stopStatusCheck();
var devicesPage = window.location.origin + "/devices.html";
window.location.assign(devicesPage);
}
function retryProcess() {
stopStatusCheck();
window.location.reload()
}
function stopStatusCheck() {
if (gCheckProc != null) {
clearInterval(gCheckProc);
gCheckProc = null;
}
}
//Back
window.onbeforeunload = onConfirmLeavePage;
window.addEventListener("beforeunload", onConfirmLeavePage);
window.onunload = onLeavePage;
window.addEventListener("unload", onLeavePage);
function onConfirmLeavePage(event) {
if (gCheckProc == null) {
//delete event['returnValue'];
return;
}
event.preventDefault();
event.returnValue = "The include process is still running. Press OK to cancel the current process before leaving.";
return "The include process is still running. Press OK to cancel the current process before leaving.";
}
function onLeavePage(event) {
if (gCheckProc == null) {
return;
}
cancelInclude(null);
}
</script>
</body>
</html>