How To: Adding Color & "End Steps" to Process Flow
- Mark as New
- Bookmark
- Subscribe
- Mute
- Subscribe to RSS Feed
- Permalink
- Report Inappropriate Content
4 hours ago
Background
ServiceNow provides the Process Flow application, that allows system admins to configure a visualization of where a record is, in its process lifecycle. This is easily visible on an OOB instance, looking at CHG management:
In a recent project, I wanted to change the color of a certain step of the Process Flow, to better indicate when a step indicated failure, or an 'unexpected' step of the process. We also wanted the ability to 'flag a step,' as being at the end of a process, so that future steps would not be displayed (e.g., for our case, "Reversed," where no more work will be done).
While neither of these were available out of the box, the customizations that it took to achieve these were relatively minimal, and did ultimately function to provide the intended outcome:
Setup of the color from within the Flow Formatter record.
The resultant Process Flow UI, on the target record.
Important Context - UI16 versus Workspace
We (our company), are stuck in the past, and the majority of our users perform their day-to-day work in the UI16 views, still, instead of workspaces. Due to this, the customizations I will go through here, have been tested and vetted against UI16, with minimal checking against workspace functionality.
As will be noted below, there are two UI Macros that appear for flow_formatter - it is theoretically possible that the "other one" may affect workspaces. If I find more information in this regard, I will update this post.
Pre-Getting Started - What's needed?
You will need access to create/update the following tables:
- Flow Formatter [sys_process_flow]
- Business Rule [sys_script]
- UI Macro [sys_ui_macro]
- Script Include [sys_script_include]
Pre-Getting Started - The Backend of Process Flow
If you just want the "how-to," and care less about the "why," you can skip this section and go to the next, however, this should offer some good insight into what we're about to do, and why, at each step.
Foreword: These explanations, again, apply to UI16. Differences may be present when considering Workspaces.
The Tables, and their Uses in Process Flow
ServiceNow makes use of one "main table" for controlling Process Flow, which you will have used in your setup. This is the Flow Formatters [sys_process_flow] table, and defines each step that will be displayed.
The UI Macro [sys_ui_macro] table stores Jelly scripts, that can be re-used across the platform, to transform inputs, into HTML that will render on the client-side. These are used widely by both OOB features, and in custom UI design. For Process Flow, specifically, the macro(s) we are interested in are (both) named process_flow, and are responsible for transforming a list of process flow steps, into the header UI seen on the record.
The Business Rule [sys_script], in our case, is how we "pass our Customization" to the macro. The design of the Business Rule provided below, is based around a set of OOB records, which can be identified using the following filter: nameENDSWITH_ProcessFlowList.
(e.g., https://[your_instance]/sys_script_list.do?sysparm_query=nameENDSWITH_ProcessFlowList)
The Macro - OOB Functionality
As mentioned a couple times now, there are two UI Macros OOB, that both share the same name of process_flow. Both of these macros share the same style of pseudo-code, however, for UI16 specifically, the macro we'll look at has the sys_id of 4bcb4b840a0a0b9f00ae822ff0cef724.
Looking at the macro, there are a few distinct sections to digest, from the Jelly XML that is provided.
<g2:evaluate var="jvar_flows" jelly="true" object="true">
var flows = null;
var functionName = current.getRecordClassName() + "_ProcessFlowList";
var func = GlideController.getGlobal(functionName);
if (typeof func == 'function') {
flows = func();
}
flows;
</g2:evaluate>
This section is the key to how we will pass our own data. At a high-level, this code looks for a Business Rule, of the current record Table that we are "showing Process Flow for" (e.g, change_request, incident, etc.). With that in mind, the full name of the Business Rule it will "look for", would be: [qualified_table_name]_ProcessFlowList. Examples of these can be found from the above provided filter, but include:
sn_customerservice_case_ProcessFlowList
wm_order_ProcessFlowList
Note: For scoped applications, you will need the full name of the table, including scope, which can be found either in the sys_db_object definition for the table, or in the URL while viewing these records.
<j2:if test="$[jvar_flows == null]">
<g2:flow_formatter var="jvar_flows" table="$[${ref_parent}.getRecordClassName()]" current="$[${ref_parent}]"/>
</j2:if>
This is ServiceNow's "fallback" formatter, which is driven by code that, unfortunately, is hard-coded as a part of the shipped Java backend. What this means in practice, is that if we "reach this code," we will be unable to customize the inputs of the Process Flow objects.
<j2:forEach items="$[jvar_flows]" var="jvar_flow" indexVar="jvar_index">
<j2:set var="jvar_flow_step" value="$[jvar_index + 1] ${gs.getMessage('of')} $[jvar_flow_size]"/>
<j2:choose>
<j2:when test="$[jvar_flow.getParameter('state') == 'current']">
<j2:set var="jvar_flow_class" value="active"/>
<j2:set var="jvar_flow_stage" value="${gs.getMessage('Current stage')}"/>
</j2:when>
</j2:when>
<j2:when test="$[jvar_flow.getParameter('state') == 'past']">
<j2:set var="jvar_flow_class" value="completed disabled"/>
<j2:set var="jvar_flow_stage" value="${gs.getMessage('Previous stage')}"/>
</j2:when>
<j2:otherwise>
<j2:set var="jvar_flow_class" value="disabled"/>
<j2:set var="jvar_flow_stage" value="${gs.getMessage('Next stage')}"/>
</j2:otherwise>
</j2:choose>
This is where the magic happens. The j2:forEach iterates over each Process Flow step that the system identifies for the current table. From there, the j2:choose makes some logical determinations, based on the state of the step. These may make more sense later, however from a definition standpoint, a step in the "past" is one whose condition does not match, and is at a lower order than the current step. A step in the future is one whose condition does not match, and is at a higher order than the current step. The current step is the lowest-order step whose condition matches.
<li class="$[jvar_flow_class]" data-state="$[jvar_flow.getParameter('state')]">
<a tabindex="-1" role="presentation" aria-disabled="true"
aria-label="$[jvar_flow_stage] $[jvar_flow.getLabel()] $[jvar_flow_step]">
$[jvar_flow.getLabel()]
</a>
</li>
Finally, this is where the rendering occurs. The <li> (List Item) for each Process Flow step is rendered here, with some labels being pulled from the step.
With all of that in mind, we can now start to get into the dirty work.
Getting Started - Setting up Process Flow
The first step for customizing process flow, is unsurprisingly, to make sure that it's generally setup how you'd like it to appear, outside of the customization. ServiceNow's documentation (linked above), goes fairly well into this step, however, if you're a more hands-on learner, there are some great community resources available:
- Process flow formatter in ServiceNow | Process flow (YouTube)
- Fairly concise, goes through both setup, and some conceptual understandings
- ServiceNow Process Flow explained in detail by Uday Gadiparthi (YouTube)
- Much more in-depth covering of the feature-set.
- The process flow formatter (Community)
- A great guide by @tiagomacul that goes over more in-depth customizations.
Once you have your process flow definitions setup, we're ready to talk about what it will take to customize even further!
Customization - Adding Fields to Process Flow
Note: All configuration here should be done in the Global scope, regardless of whether you plan to use this for tables on scoped apps
First things first, we need to be able to store the data we want to feed into the display. For our use case, and for the purposes of this article, I will be adding two new fields. One for "Color", and one for "End Step".
To that end, navigate to the Flow Formatter [sys_process_flow] table, through one of the following means:
- sys_process_flow.config in the filter navigator
- System Definition > Tables, filter for name=sys_process_flow, open the record
- System Definition > Tables & Columns, find Flow Formatter > Edit Table
Add the respective fields to the dictionary entries/columns, as below. Names and labels are up to you, however if you choose to use other names than below, make sure you update the script(s) accordingly!
- Color - Changes the color of the step, when it is active.
- Type: Color
- Label: Color
- Name: u_color
- End Step - If checked, and the current step, no future steps will be displayed.
- Type: True/False
- Label: End Step
- Name: u_end_step
Once you do this, navigate back to the Process Flow definitions you created earlier, and set your Colors and End Step flags, accordingly.
By default, these fields will be placed at the bottom of the form. You may want to change this, to place them with the other configuration options.
Customization - Creating the Script Include
Note: As above, this should be done in the Global scope, regardless of if you will target a scoped app.
This Script Include will define the abstract behavior that all tables will follow, when fetching their Process Flow steps. It is based largely on the OOB Business Rules that SN defines for process flow (for an example, see the Business Rule with sys_id 62668c348f3231009ac5cb1895e5e094, provided by Case Management).
Name: ProcessFlowUtils
Client callable: False
Mobile callable: False
Sandbox enabled: False
Accessible from: This application scope only
Active: True
Turn on ECMAScript 2021 (ES12) mode: True
Script:
var ProcessFlowUtils = Class.create();
ProcessFlowUtils.prototype = {
initialize: function() { },
/**
* Given a table name as input, lookup the Process Flow steps for the table, if available,
* and convert them into a GlideChoiceList.
*
* @param {string} tableName - the name of the table to get process flow steps for.
* @param {GlideRecord<>} current - the current record. Will be populated 'automatically' by the Business Rule.
* @returns {GlideChoiceList|null} the populated choice list, if Process Flow steps were found for the given table, null otherwise.
*/
getProcessFlowSteps: function(tableName, current) {
// Lookup process flow records for the given table.
var pfGr = new GlideRecord('sys_process_flow');
pfGr.addEncodedQuery(`table=${tableName}^active=true`);
pfGr.orderBy('order');
pfGr.query();
// If none were found, return null
if (!pfGr.hasNext()) return null;
// Build out a new choice list
const choices = new GlideChoiceList();
var foundCurrentStep = false;
var reachedEndStep = false;
// Loop through the Process Flow steps we found,
// until we reach an End Step, or have gone through them all.
while(pfGr.next() && !reachedEndStep) {
// Backend name of the step.
var pfName = pfGr.getValue('name');
// User-facing name of the step.
var pfLabel = pfGr.getDisplayValue('label');
var choice = new GlideChoice(pfName, pfLabel);
// We will assume the step is in the 'past' state, for now.
choice.setParameter('state', 'past');
// The condition string from the step - e.g., "active=true^state=4^EQ"
var pfCondition = pfGr.getValue('condition');
// Check if the current record matches the given condition(s).
var conditionsMatch = GlideFilter.checkRecord(current, pfCondition);
if (!foundCurrentStep && conditionsMatch) {
// This is the current step!
choice.setParameter('state', 'current');
foundCurrentStep = true;
// This is where we add our color!
var customColor = pfGr.getValue('u_color');
if (!gs.nil(customColor)) {
var parsedColorArray = this._parseColor(customColor);
if (!gs.nil(parsedColorArray) && parsedColorArray.length == 3) {
var tripletArrayString = parsedColorArray.join(', ');
choice.setParameter('rgb_triplet', tripletArrayString);
} else {
gs.error(`Could not parse provided custom color: ${customColor}`, 'ProcessFlowUtils');
}
}
// Check if we've reached an End Step
var endStepValue = pfGr.getValue('u_end_step');
reachedEndStep = (reachedEndStep || endStepValue == 1);
} else if (foundCurrentStep) {
// If we've already found the current step, any further steps will be Future.
choice.setParameter('state', 'future');
}
// Add the choice to the GlideChoiceList
choices.add(choice);
}
// Return the GlideChoiceList we built.
return choices;
},
/**
* Given an input, parse out the RGB color format, to be passed as a parameter in the GlideChoiceList
* for a Process Flow step. Note that alpha values, if provided, will be ignored.
*
* NOTE: As written, this function will ONLY parse the following formats:
* - RGB/A (comma agnostic): rgb(0, 122, 255) OR rgba(0 122 255 / 80%)
* - Hex: #f0f OR #ff00ff
*
* If you are using a different color format, you will need to edit this function.
*
* @param {string} input - the input to parse the color from.
* @returns {Array|null} a length-3 array of R,G,B values, if the color could be parsed, null otherwise.
*/
_parseColor: function(input) {
/**
* REGEX-TEST: rgb(0, 0, 0)
* REGEX-TEST: rgba(0, 255, 255, 0.5)
* REGEX-TEST: rgba(255, 99, 71, 1)
* REGEX-TEST: rgba(0 122 255 / 80%)
*/
const rgbaExp = /^rgba?\((\d+)[, ]+(\d+)[, ]+(\d+)(?:[, /]+[\d\.%]+)?\)$/g;
const rgbaRegex = new RegExp(rgbaExp);
const rgbaMatches = rgbaRegex.exec(input);
if (!gs.nil(rgbaMatches) && rgbaMatches.length == 4) {
// Drop the first element from the array.
// Essentially converting from:
// [ 'rgb(0, 0, 0)', '0', '0', '0' ]
// To:
// [ '0', '0', '0' ]
return rgbaMatches.slice(1).map(nStr => parseInt(nStr));
}
/**
* REGEX-TEST: #FFFFFF
* REGEX-TEST: #fff
* REGEX-TEST: #000
*/
const hexExp = /^#(?:[a-f\d]{3}|[a-f\d]{6})$/gi;
const hexRegex = new RegExp(hexExp);
const hexMatches = hexRegex.exec(input);
if (!gs.nil(hexMatches)) {
var hexString = hexMatches[0].replace(/^#/, '');
if (!hexString.length == 6) {
// Join each character with a leading 0 to conform to full hex code.
hexString = hexString.split('').map(s => '0' + s).join('');
}
return [
hexString.slice(0, 2), // Red
hexString.slice(2, 4), // Green
hexString.slice(4, 6), // Blue
].map(hexStr => parseInt(hexStr, 16));
}
// If you want to add additional color format(s) parsing, do so here.
// ...
// No matching format found.
return null;
},
type: 'ProcessFlowUtils'
};Note: As the js-doc on the _parseColor function mentions, for brevity, and due to the fact that they are the most commonly used formats, the function will currently parse the following formats:
- RGB/A (comma agnostic): rgb(0, 122, 255) OR rgba(0 122 255 / 80%)
- Hex: #f0f OR #ff00ff
If you are using another color format in your Process Flows, you will need to modify this function accordingly.
Once the Script Include is created, you may want to test that all is working as you'd expect. To do so, you can use either Xplore, or a background script, to run the following code, keeping in mind, you must use a valid record, of the table type you are configuring. I am once again, using incident as an example:
var testRecord = new GlideRecord('incident');
testRecord.get('21ae13c2fb79365086abf3f17eefdcc3');
var flowSteps = new ProcessFlowUtils().getProcessFlowSteps(testRecord);
if (gs.nil(flowSteps)) {
gs.log('Found no flow steps...');
} else {
gs.log('Flow Step fetching, found ' + flowSteps.getSize() + ' steps!');
var flowStepJsons = [];
for (var i = 0; i < flowSteps.getSize(); i++) {
flowStepJsons.push({
'label': flowSteps.getChoice(i).getLabel(),
'state': flowSteps.getChoice(i).getParameter('state'),
'rgb_triplet': flowSteps.getChoice(i).getParameter('rgb_triplet'),
});
}
gs.print(JSON.stringify(flowStepJsons, null, 2));
}Note: Color will only populate for the singular choice where state = current. This is to be expected.
Customization - Creating the Business Rule
Note: As above, this should be done in the Global scope, regardless of if you will target a scoped app.
Name: (Dependent on "targetting table", see below)
It is imperative that this Business Rule be named exactly following the format. Due to the lookup in the macro, and the functional necessity to use our own code, it must be formatted as follows:
[qualified_table_name]_ProcessFlowListExample: if you were adding Process Flow steps for the incident table, the Business Rule name would be:
incident_ProcessFlowListNote: The system, by default, limits a Business Rule's name to 40 characters. Especially with scoped apps, this may lead to your name being truncated, which will lead to it NOT functioning correctly. If this happens to you, right click the Name field on the Business Rule > Configure Dictionary, and set the Max Length field to a slightly higher value (i.e., 40 -> 64).
Table: Global [global]
Accessible from: This application scope only
Active: True
Advanced: True
(Both of the "When to run" and "Actions" tabs of the new record can be ignored, for this setup)
Advanced Tab > Script:
function [your_script_include_name]() {
return new ProcessFlowUtils().getProcessFlowSteps(current);
} Note: The function name here should exactly match the name of your Script Include. Again for example, if you are adding Process Flow steps to Incident, the script would be:
function incident_ProcessFlowList() {
return new ProcessFlowUtils().getProcessFlowSteps(current);
}
Customization - "Editing" the UI Macro
Note: As above, this should be done in the Global scope, regardless of if you will target a scoped app.
There are two UI Macros provided OOB, that both share the same name. The one we want to clone, is the one which has the Media type of doctype:
(Alternatively, you can look for sys_id = a34bba13c3332100b0449f2974d3aeba).
Editing OOB files sets us up for failure in future upgrades, however, due to limitations of the platform, ServiceNow does NOT allow multiple UI Macros with the same definition, to be in the system at the same time. This limits our ability to "clone" this file, and we will, unfortunately, have to edit it inplace. With that said, it is a good idea to put a note in the description of the Macro, noting why it was updated:
Note: Due to potential differences between SN versions, it is rather difficult to provide a fully functional script that I can guarantee will work with specific release versions. As of writing, we are on Xanadu, and the scripts provided will be based on that versioning. If you are on a higher release of ServiceNow, I would recommend looking through the GitHub resources linked below, to see if you will need to "re-make" a new version of the Macro.
At a high level, there are three distinct additions/changes that we need to make to ServiceNow's code. The first, is a parsing block, to deal with our new color parameters. This can be added right below the
<j2:set var="jvar_flow_stage" value="${gs.getMessage('Next stage')}"/>
</j2:otherwise>
</j2:choose>
<!-- [Code above this line is OOB] -->
<!-- Not OOB section entirely -->
<j2:set var="jvar_rgb_triplet" value="$[jvar_flow.getParameter('rgb_triplet')]"/>
<j2:set var="jvar_a_style" value=""/>
<j2:set var="jvar_li_style" value=""/>
<j2:if test="$[jvar_flow.getParameter('state') == 'current' && jvar_rgb_triplet != null]">
<j2:set var="jvar_a_style" value="background-color: rgb($[jvar_rgb_triplet]) !important;"/>
<j2:set var="jvar_li_style" value="--now-color_selection--primary-2: $[jvar_rgb_triplet];"/>
</j2:if>
<!-- End Not OOB -->This loads the rgb_triplet value, that we added as a parameter, in our Script Include. It then defines two separate style values, which will be used below. To that end, the next section to modify is the <li> definition, right below this point. The OOB line will look something like this:
<li class="$[jvar_flow_class]" data-state="$[jvar_flow.getParameter('state')]">To this, we will want to add the parameter: style="$[jvar_li_style]":
<li class="$[jvar_flow_class]" data-state="$[jvar_flow.getParameter('state')]" style="$[jvar_li_style]">
And similarly, for the <a> definition, which OOB, will resemble:
<a tabindex="-1" role="presentation" aria-disabled="true"
aria-label="$[jvar_flow_stage] $[jvar_flow.getLabel()] $[jvar_flow_step]">We want to add the parameter: style="$[jvar_a_style]":
<a tabindex="-1" role="presentation" aria-disabled="true"
aria-label="$[jvar_flow_stage] $[jvar_flow.getLabel()] $[jvar_flow_step]" style="$[jvar_a_style]">
With that, we're done! The completed Macro should look something like the following.
<?xml version="1.0" encoding="utf-8"?>
<j:jelly xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null" trim="false">
<g2:evaluate var="jvar_flows" jelly="true" object="true">
var flows = null;
var functionName = current.getRecordClassName() + "_ProcessFlowList";
var func = GlideController.getGlobal(functionName);
if (typeof func == 'function') {
flows = func();
}
flows;
</g2:evaluate>
<j2:if test="$[jvar_flows == null]">
<g2:flow_formatter var="jvar_flows" table="$[${ref_parent}.getRecordClassName()]" current="$[${ref_parent}]"/>
</j2:if>
<j2:set var="jvar_flow_size" value="$[jvar_flows.size()]"/>
<tr><td><table class="process-breadcrumb-table"><tr><td>
<ol tabindex="0" class="process-breadcrumb process-breadcrumb-border">
<j2:forEach items="$[jvar_flows]" var="jvar_flow" indexVar="jvar_index">
<j2:set var="jvar_flow_step" value="$[jvar_index + 1] ${gs.getMessage('of')} $[jvar_flow_size]"/>
<j2:choose>
<j2:when test="$[jvar_flow.getParameter('state') == 'current']">
<j2:set var="jvar_flow_class" value="active"/>
<j2:set var="jvar_flow_stage" value="${gs.getMessage('Current stage')}"/>
</j2:when>
<j2:when test="$[jvar_flow.getParameter('state') == 'past']">
<j2:set var="jvar_flow_class" value="completed disabled"/>
<j2:set var="jvar_flow_stage" value="${gs.getMessage('Previous stage')}"/>
</j2:when>
<j2:otherwise>
<j2:set var="jvar_flow_class" value="disabled"/>
<j2:set var="jvar_flow_stage" value="${gs.getMessage('Next stage')}"/>
</j2:otherwise>
</j2:choose>
<!-- Not OOB section entirely -->
<j2:set var="jvar_rgb_triplet" value="$[jvar_flow.getParameter('rgb_triplet')]"/>
<j2:set var="jvar_a_style" value=""/>
<j2:set var="jvar_li_style" value=""/>
<j2:if test="$[jvar_flow.getParameter('state') == 'current' && jvar_rgb_triplet != null]">
<j2:set var="jvar_a_style" value="background-color: rgb($[jvar_rgb_triplet]) !important;"/>
<j2:set var="jvar_li_style" value="--now-color_selection--primary-2: $[jvar_rgb_triplet];"/>
</j2:if>
<!-- End Not OOB -->
<!-- OOB Code:
<li class="$[jvar_flow_class]" data-state="$[jvar_flow.getParameter('state')]">
-->
<li class="$[jvar_flow_class]" data-state="$[jvar_flow.getParameter('state')]" style="$[jvar_li_style]">
<!-- OOB Code:
<a tabindex="-1" role="presentation" aria-disabled="true"
aria-label="$[jvar_flow_stage] $[jvar_flow.getLabel()] $[jvar_flow_step]">
$[jvar_flow.getLabel()]
</a>-->
<a tabindex="-1" role="presentation" aria-disabled="true"
aria-label="$[jvar_flow_stage] $[jvar_flow.getLabel()] $[jvar_flow_step]" style="$[jvar_a_style]">
$[jvar_flow.getLabel()]
</a>
</li>
</j2:forEach>
</ol>
</td></tr></table></td></tr>
</j:jelly>Save the Macro, and you're done!
The Result
When you load a record that in "in" a step, with a custom color, it will be displayed in said color, at the top of the screen. If the current step is marked as an End Step, no "Future" steps will be shown.
Before:
The Flow Formatter, as configured with custom options:
And after:
Additional Resources
I have added all scripts referenced in this post to a GitHub repository, which you can view for reference here:
GitHub - DavidArthurCole/SNColoredProcessFlow