Creating a Service Portal Process Flow Formatter in ServiceNow

Adding a Process Flow Formatter to the Service Portal in ServiceNow.

Creating a Service Portal Process Flow Formatter in ServiceNow

Overview

UI16 Equivalent

Adding a Process Flow Formatter to records in UI16 is simple enough, you right click the top banner of your table/record, go to Form Design, from there you drag over the Fields Object "Process Flow (Formatter)", save, and you are good to go.

Adding a Process Flow (Formatter) to the Change Request table
Change Request Process Flow Formatter example

Service Portal

But what if we want the same thing on the Service Portal? Unfortunately, in many cases, we have to create it from scratch as there does not appear to be an out of the box version.

In an effort to not have to build this from scratch for every implementation I do, I have made a generalised version below that you can use to get started. It should work on at least any Task extended table and should be easy to extend for any other table you can imagine.

If you are only working with Task extended tables, its as simple as creating the widget once and then dropping that widget into the page design of any Service Portal page you want it to show up on (with the caveat that the page must be using URL navigation and the Parameters "table" and "sys_id", example below).

dev12345.service-now.com/sp?id=ticket&table=incident&sys_id=f12ca184735123002728660c4cf6a7ef

Below is the basic code set up for your widget, I won't dig too deep into widget creation as I have covered that elsewhere, but for those that know how to make and deploy widgets, code and examples below.

Widget

Rundown

The Widget uses very little HTML, a fairly simple Server Script and the rest is CSS. The CSS has been mostly lifted and modified from the out of the box Process Flow Formatter, to try and keep it looking along the same theme as UI16, you can update this as needed to make it suit your environment.

In the Server Script there is an Else block with a simple hardcoded data object that shows a basic example of what is required, that being a list of objects that have a name to display, and a state value for the chevron (either past, active or future).

Above this example block is a code segment that should work for Task extended tables.

Happy coding!

Code

<div>
  <ol class="process-flow-container" id="processFlow">
    <li class="process-step" ng-repeat="state in data.states" ng-class="{'state-past':state.state=='past','state-active':state.state=='active','state-future':state.state=='future'}"><a role="presentation" aria-disabled="true">{{state.name}}</a></li>
  </ol>
</div>

HTML Template

::after {
  box-sizing: border-box;
}

.process-flow-container {
    border: none;
    display: flex;
    height: auto;
    list-style: none;
    margin-block-start: 0;
    margin-block-end: 14px;
    min-height: 46px;
    overflow-x: auto !important;
    overflow-y: hidden !important;
    padding: 0;
    white-space: nowrap;
    width: 100%;
    overflow:visible;
}

.process-flow-container li {
    display: inline-flex;
    position: relative;
    flex: 1 1 auto;
}

.process-flow-container li a {
    align-items: center;
    background-color: rgb(228, 230, 234);
    color: rgb(21, 25, 32);
    display: inline-flex;
    font-size: 14px;
    height: 100%;
    justify-content: center;
    outline: 0;
    overflow: hidden;
    padding: 0;
    padding-inline-start: 28px;
    padding-inline-end: 10px;
    text-decoration: none;
    text-overflow: ellipsis;
    transition: none;
    white-space: nowrap;
    width: 100%;
}

.process-flow-container li:not(:last-child)::after {
    background: LINEAR-GRADIENT(45deg, rgba(0, 0, 0, 0) 50%, rgb(228, 230, 234) 50%);
    border-block-start: 2px solid rgb(255, 255, 255);
    border-inline-end: 2px solid rgb(255, 255, 255);
    border-radius: 4px;
    bottom: 0;
    content: "";
    height: 100%;
    margin-block-start: -23px;
    pointer-events: none;
    position: absolute;
    right: -14px;
    top: 50%;
    transform: rotate(45deg);
    width: 46px;
    z-index: 1;
}

/* Past states */
.state-past {
    cursor: default;
    background-color: rgb(228, 230, 234) !important;
    color: rgb(21, 25, 32) !important;
}

.state-past::after {
    background: linear-gradient(45deg, rgba(0, 0, 0, 0) 50%, rgb(228, 230, 234) 50%) !important;
}

.state-past a::after {
    font-family: now-icons;
    display: inline-flex;
    line-height: 1;
    font-weight: 400;
    font-style: normal;
    speak: none;
    text-decoration: inherit;
    text-transform: none;
    text-rendering: auto;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    content: "\f12e";
    color: rgba(0, 0, 0, 0);
    vertical-align: text-bottom;
    margin-inline-start: 4px;
}

/* Active state */
.state-active a {
   background-color: rgb(0, 123, 88) !important;
   color: rgb(255, 255, 255) !important;
}

.state-active::after {
    background: linear-gradient(45deg, rgba(0, 0, 0, 0) 50%, rgb(0, 123, 88) 50%) !important;
    color: rgb(21, 25, 32) !important;
}

/* Future states */
.state-future {
    cursor: default;
	background-color: rgb(228, 230, 234) !important;
}

.state-future::after {
    background: LINEAR-GRADIENT(45deg, rgba(0, 0, 0, 0) 50%, RGB(var(--now-color_background--tertiary, var(--now-color--neutral-2, 228, 230, 231))) 50%) !important;
}

CSS - SCSS

(function() {
  /* populate the 'data' object */
  /* e.g., data.table = $sp.getValue('table'); */
	data.states = [];

	// Get our data from the URL
	data.table = $sp.getParameter('table');
	data.sys_id = $sp.getParameter('sys_id');

	// Get Record
	var record = new GlideRecord(data.table);
	if(record.get(data.sys_id)){
		data.record = record;
	}
	
	// Only do something if we have a record
	if(data.record){

		// Check if table extends from something
		data.extendedParent = '';
		var dbo = new GlideRecord('sys_db_object');
		if(dbo.get('name',data.table)){
			var superClass = dbo.getValue('super_class');
			// Get the name of our super class, not a sysid
			var sc = new GlideRecord('sys_db_object');
			if(sc.get(superClass)){
				data.extendedParent = sc.name;
			}
		}
		
		// If table extends task, map to state
		if(data.extendedParent == 'task'){
			var states = new GlideRecord('sys_choice');
			states.addQuery('name',data.table); // Use the table specific state values
			states.addQuery('element','state');
			states.addQuery('language','en');
			states.addQuery('inactive',false);
			states.orderBy('sequence');
			states.query();
			while(states.next()){
				// Map state value to formatter states
				var stateValue = '';
				switch(true){
					case data.record.getValue('state') == states.getValue('value'):
						stateValue = 'active';break;
					case data.record.getValue('state') < states.getValue('value'):
						stateValue = 'future';break;
					case data.record.getValue('state') > states.getValue('value'):
						stateValue = 'past';break;
					default:
						stateValue = 'future';break;
				}
				data.states.push({'name':states.getValue('label'),'state':stateValue})
			}
			
		}else{
			// We can expand our mappings from other tables here, example data provided
			data.states = [
				{"name":"Step 1","state":"past"},
				{"name":"Step 2","state":"past"},
				{"name":"Step 3","state":"active"},
				{"name":"Step 4","state":"future"},
				{"name":"Step 5","state":"future"}
			];
		}

	}

})();

Server Script

Examples of the Widget deployed to the Standard Ticket Page

Record in the state of New
Record in the state of In Progress
Record in the state of Canceled