Animated Loading Message & Collapsible Details on ServiceNow Form or Field (Client-side)

I recently found myself in a bit of a sticky pickle in ServiceNow. I was building a client-side UI Action which needed to call a Script Include via GlideAjax.
The server-side component of that script would then call a REST API and retrieve some data.
The data would be transformed and then returned to the client to be displayed in the form.

ServiceNow animated progress message for long operations with expandable and collapsible details panel

This caused two major issues:

  1. The response could take a while, so it looks for several seconds as though clicking the button did nothing.

  2. When the response did arrive, there could potentially be a lot of data to display in the form message.

As you can see in the gif above, I was able to solve both issues with a fancy bit of code. And now I’m gonna share that code with you!

This article will tell you how I solved them, and provide you with the code I used (made fully modular so you can customize its behavior for your own situation). You can then include those functions in your Client Script in order to achieve similar functionality, with only a couple of lines of code!

Edit 10/10/22: Some folks in the ServiceNow Development community Discord and Slack have asked a very good question that I thought it important enough to answer right at the top of this article:
Doesn’t this ‘lock up’ the form/browser session while the animated loading message is displayed?

The answer to this very reasonable question is, luckily, no!
Even though both methods for displaying animated loading messages require some code to be executing in the background, and JS is single-threaded, this does not lock up the browser session. If you want a more detailed explanation, expand the section below.

Additional details (expand)
How does it not lock up the session?
The user's browser-session does not lock up for the same reason that asynchronous GlideAjax calls don't lock up the browser. Asynchronous code executes only on some condition or timer (in this case, a timer, typically between 130 and 230 milliseconds).
The CSS animation for animated form messages happens using some kinda fancy asynchronous CSS-engine processor code, and the JavaScript Interval method for brute-force-animating field messages are both asynchronous.
This means that the repeating part of the animation code (each “tick” of the animation) happens only about once every 130-230 milliseconds, and takes less than half a millisecond to execute. This translates to basically nothing more than a few “frames” of your monitor’s refresh-rate per second. Human visual acuity is not sufficient to detect delays of such a tiny fraction of a second, especially when broken out into 4-6 tiny chunks within each second. Your browser is constantly doing teensy little things like that in the background all the time.
In other words - none of the code here will result in any kind of browser “session lock”. In fact, the whole purpose of doing these asynchronous processes is to avoid browser session-lock!

TLDR: Just Give me the Code!

Okay, fine, I’ll give you the code — but listen, you really should read the rest of this article to learn some important info about how all of this works, and to better understand what’s going on under the hood.
With that said, here’s the code and how to use it:

  1. Create your Client Script or UI Action (with the "Client" checkbox ticked).

  2. Copy-and-paste all of the code from this gist into the bottom of the main function of your Client Script or client-side UI Action.

    1. Alternatively, you can create a Global UI Script to store the functions in the above gist.

  3. Customize and use one of the below example scripts in your Client Script or client-side UI Action.

Animated "Loading" form message during GlideAjax call (expand)
Animated "Loading" field message during GlideAjax call (expand)
Expandable form message with some text hidden (expand)

Animated "Loading" Message

The problem I was running into, is that this REST API could sometimes take quite some time to respond (anywhere from 5 to 15 seconds).
This meant that after the user clicks the UI Action, nothing would appear to happen for at least several seconds.
This is obviously a terrible user-experience! Users would click the button, wait two seconds, click it again, and then again, and again, all before the first response could be received.

On top of a poor user experience, this can also lead to an unnecessarily high API load.
So, how do we solve this problem?

As you’ve probably guessed from the title of this article, we’re going to show a “loading” message while the request is processing, then clear it and show the results once it’s finished.

But this won’t be just any loading message!
We’re fancy, so we’re gonna make this loading message animated!

Solution 1: Simple CSS Animated Form Message

Luckily, form messages created from client-side scripts in ServiceNow can support HTML and CSS! This means that we can use a simple CSS animation to animate an ellipses at the end of our message (“simple” here meaning “pretty darn complicated because it’s still CSS”).

Follow Tim on LinkedIn

By calling the below function, we can display a simple loading message that will be followed by an ellipsis (three dots) that will animate from left to right:

/**
 * Display a simple animated loading message using CSS animations.
 * This loading message can be cleared by simply calling g_form.clearMessages() when loading
 *  has finished.
 *
 * @param {String} [loadingMsgText="Loading"] - The loading message text.
 * This text will be followed by an animated ellipses in the message that's shown at the top
 *  of the form.
 * @param {Number} [msAnimationDuration=800] - The number of milliseconds for which the
 *  animation should last.
 * This is the TOTAL duration of the animation, not the interval between each dot in the
 *  ellipses being animated.
 * @param {Number} [steps=5] - The number of "steps" for the animation to take over the
 *  duration specified in msAnimationDuration.
 * 4-5 is recommended. The default value is 5.
 *
 * @example
 * //With no arguments, will show "Loading..." with the ellipses being animated over 800ms.
 * showSimpleLoadingMessage();
 *
 * //With one argument, the message you specify will be shown followed by an animated "...".
 * showSimpleLoadingMessage('Processing request');
 *
 * //With two arguments, you can specify the duration - this animation will be slower.
 * showSimpleLoadingMessage('Processing', 1500);
 *
 * //With three arguments, you can specify the number of "steps" the animation will take over
 * // the specified number of milliseconds. This will be slower and take more animation steps.
 * showSimpleLoadingMessage('Processing', 1200, 8);
 */
function showSimpleLoadingMessage(loadingMsgText, msAnimationDuration, steps) {
    //Set default values
    loadingMsgText = (typeof loadingMsgText === 'undefined') ? 'Loading' : loadingMsgText;
    msAnimationDuration = (typeof msAnimationDuration !== 'number') ? 800 : msAnimationDuration;
    steps = (typeof steps !== 'number') ? 5 : steps;

    var loadingMsg = '<style>\n' +
        '.loading:after {\n' +
        '  overflow: hidden;\n' +
        '  display: inline-block;\n' +
        '  vertical-align: bottom;\n' +
        '  -webkit-animation: ellipsis steps(' + steps + ', end) ' + msAnimationDuration + 'ms infinite;\n' +
        '  animation: ellipsis steps(' + steps + ', end) ' + msAnimationDuration + 'ms infinite;\n' +
        '  content: "\\2026";\n' + //ascii code for the ellipsis character
        '  width: 0px;\n' +
        '}\n' +
        '\n' +
        '@keyframes ellipsis {\n' +
        '  to {\n' +
        '    width: 20px;\n' +
        '  }\n' +
        '}\n' +
        '\n' +
        '@-webkit-keyframes ellipsis {\n' +
        '  to {\n' +
        '    width: 20px;\n' +
        '  }\n' +
        '}\n' +
        '</style>';
    loadingMsg += '<span class="loading">' + loadingMsgText + '</span>';

    g_form.addInfoMessage(loadingMsg);
}

There are a few different ways in which we can call the above code:

//With no arguments, will show "Loading..." with the ellipses being animated over 800ms.
showSimpleLoadingMessage();

//With one argument, the message you specify will be shown followed by an animated "...".
showSimpleLoadingMessage('Processing request');

//With two arguments, you can specify the duration - this animation will be slower.
showSimpleLoadingMessage('Processing', 1500);

//With three arguments, you can specify the number of "steps" the animation will take over 
// the specified number of milliseconds. This will be slower and take more animation steps.
showSimpleLoadingMessage('Processing', 1200, 8);

Tying it all together, we can implement this functionality using something like the below code:

showSimpleLoadingMessage('Processing request');
doSomeVeryLongProcess();
//When the above process is finished, we clear the message:
g_form.clearMessages();

Solution 2: Animated Field Message Using Brute Force

Unfortunately, ServiceNow does not support HTML/CSS in field messages. Therefore, we have to get a bit clever with our implementation to show an animated loading message on a field.
This requires two steps (in two separate functions): First, begin the message animation. Then, after your long process is complete, end the animation.

/**
 * Show an animated loading message such as "Loading...", where the dots will be
 *  animated with the interval specified in msInterval; first showing "Loading.", then
 *  "Loading..", then "Loading...", up to the number of dots indicated in maxDots.
 * Once maxDots is reached, the message will be reset to "Loading." and the animation
 *  will repeat until stopAnimatedLoadingMessage() is called.
 *
 * @param {String} fieldName - The name of the field on which to show the loading message.
 * @param {String} messageText - The loading message to be shown, which will be followed
 *  by animated dots (or whatever character is specified in dotChar, if specified).
 * @param {"info"|"warning"|"error"} [messageType="info"] - The message type
 *  ('info', 'warning', or 'error').
 * @param {Number} [maxDots=3] - The maximum number of "dots" to show before resetting to
 *  1, and repeating the animation.
 * @param {Number} [msInterval=180] - The number of milliseconds between animation increments.
 * In the example code shown below for example, the string "Loading." will be shown in the
 *  form message for 170 milliseconds, after which the message will be cleared and
 *  "Loading.." will be shown for a further 170 seconds, then "Loading...", and then - because
 *  maxDots is set to 3, the message will be reset and "Loading." will be shown again for 170ms.
 * @param {String} [dotChar="."] - The character to add to the end of messageText and animate
 *  up to the number of characters indicated in maxDots (or up to 3, if maxDots is not
 *  specified).
 * @returns {number} - The interval ID for the animation process. You can save this in a
 *  variable and pass that variable into stopAnimatedLoadingMessage(), but that isn't strictly
 *  necessary since this interval ID is also stored in the client data object.
 *
 * @example
 * showAnimatedLoadingFieldMessage(
 *  'name', //Field name
 *  'Loading',
 *  'info',
 *  3,
 *  170,
 *  '.'
 * );
 *
 * //Do some stuff that may take a while...
 *
 * stopAnimatedLoadingFieldMessage('name');
 * showExpandingFormMessage(
 *     'This text is shown immediately.',
 *     'This text is hidden until the expand link is clicked.',
 *     'Show more',
 *     'Hide details'
 * );
 */
function showAnimatedLoadingFieldMessage(
    fieldName,
    messageText,
    messageType,
    maxDots,
    msInterval,
    dotChar
) {
    /*
        Storing this in the closure scope as an object and accessing it by reference.
        This is to preserve the state (the number of dots following the message text)
         across executions of the below anonymous function throughout each interval.
    */
    var intervalDots = {'count' : 1};
    var originalMessageTest = messageText;

    messageType = (typeof messageType === 'undefined') ? 'info' : messageType;
    msInterval = (typeof msInterval === 'undefined') ? 180 : msInterval;
    maxDots = (typeof maxDots === 'undefined') ? 3 : maxDots;
    dotChar = (typeof dotChar === 'undefined') ? '.' : dotChar;

    var intervalID = setInterval(function() {
        var i;
        var messageText = originalMessageTest;

        if (intervalDots.count > maxDots) {
            intervalDots.count = 1;
        } else {
            intervalDots.count++;
        }

        //Starting i at 1 since the first loop execution will add one dot.
        for (i = 1; i < intervalDots.count; i++) {
            messageText += dotChar;
        }

        g_form.hideFieldMsg(fieldName);
        g_form.showFieldMsg(fieldName, messageText, messageType);
    }, msInterval, intervalDots);

    g_user.setClientData('loading_message_interval_id', intervalID);
    return intervalID;
}

/**
 * Stop showing an animated loading message that was initiated by calling the
 *  showAnimatedLoadingMessage() function.
 * This function will stop the animation, and clear all form messages.
 * Unfortunately, ServiceNow does not provide us with the ability to clear only a
 *  specific form message, so *all* messages will be cleared when this function executes.
 *
 * @param {String} fieldName - The name of the field on which the animated loading message is
 *  currently being shown. Any existing messages on this field will be cleared.
 * @param {Number} [intervalID=g_user.getClientData('loading_message_interval_id')] -
 * Optionally specify the interval ID returned from the showAnimatedLoadingMessage() function.
 * The showAnimatedLoadingMessage() method stores the interval ID in the client data object
 *  so if this argument is not specified, then the interval ID will be retrieved from there.
 *
 * @example
 * showAnimatedLoadingFieldMessage('some_field', 'Loading', 'info', 3, 170, '.');
 * //Do some stuff that may take a while...
 * stopAnimatedLoadingFieldMessage('some_field');
 */
function stopAnimatedLoadingFieldMessage(fieldName, intervalID) {
    intervalID = (typeof intervalID === 'undefined') ?
        g_user.getClientData('loading_message_interval_id') :
        intervalID;

    if (!intervalID) {
        throw new Error(
            'Unable to stop interval. Invalid interval ID specified, and previous ' +
            'interval ID cannot be found in client data object.'
        );
    }

    clearInterval(intervalID);
    g_form.hideFieldMsg(fieldName);
}

Despite the fact that that’s an inordinate amount of code (a significant percentage of which, though, is documentation), implementation is actually quite simple:

//Show an animated "Processing request..." message on the "name" field. 
showAnimatedLoadingFieldMessage('name', 'Processing request', 'info');
doSomeVeryLongProcess();
//When the above process is finished, stop the animation and clear the field message:
stopAnimatedLoadingFieldMessage('name');

Enjoying this article? Don’t forget to subscribe to SN Pro Tips!

We never spam you, never sell your information to marketing firms, and never send you things that aren’t relevant to ServiceNow.
We typically only send out 1-4 newsletters per year, but trust me - you don't want to miss them!

Message with Collapsible Details Panel

Since ServiceNow’s client-side form messages support HTML, we can do some additional wizardry as well. One thing that I’ve found particularly useful, is to show a collapsible message with some details hidden until the user expands the message.

This can be quite useful when you want to present a lot of information in a form message without overwhelming the user and taking up half their screen with the message.

Solution

This one only requires a single function-call, but this one - like the showAnimatedLoadingFieldMessage() function - has a lot of customizability in how it functions. Most of this is superfluous, however, as it has reasonable defaults for most arguments.

By calling the below function, we can show a short form message, but have additional details available if the user expands the message to show more details:

/**
 * Display an expandable form message. This message will be shown at the top of whatever form
 *  this code is executed on. The text in firstLine will be shown, but the text in flyoutText
 *  will be hidden until the user clicks the 'expand' link.
 *
 * @param {String} firstLine - The first line of text in the message, which will be shown
 *  immediately when this code executes. Unlike the text in flyoutText, this text will not
 *  be hidden.
 * @param {String|HTML_TEXT} flyoutText - This text will be hidden by default, but will be shown
 *  once the user clicks the 'expand' link (which you can customize by setting expandLinkText).
 * @param {String} [expandLinkText="Show more"] - Optionally specify the text to be shown as
 *  a clickable link, which will cause the form message to expand and display the text
 *  specified in flyoutText.
 * @param {String} [collapseLinkText="Hide details"] - Optionally specify the text to be shown
 *  after the user clicks the 'expand' link text (specified in expandLinkText).
 * This text will be shown when the message is expanded and the text specified in flyoutText
 *  is shown. Upon clicking this text, the message will be collapsed, flyoutText will be hidden,
 *  and the link text will revert back to that specified in expandLinkText.
 *
 * @example
 * showExpandingFormMessage(
 *  'This message expands',
 *  flyoutListHTML,
 *  'Show more',
 *  'Hide details'
 * );
 */
function showExpandingFormMessage(firstLine, flyoutText, expandLinkText, collapseLinkText) {
    var formMsg = firstLine;

    expandLinkText = (typeof expandLinkText !== 'string') ? 'Show more' : expandLinkText;
    collapseLinkText = (typeof collapseLinkText !== 'string') ? 'Hide details' : collapseLinkText;

    formMsg += '<div>';
    formMsg += '<p><a href="#" onclick="javascript:jQuery(this.parentNode).next().toggle(200); ' +
        'this.innerText = ((this.innerText === \'' + expandLinkText + '\')?\'' + collapseLinkText +
        '\':\'' + expandLinkText + '\');">' + expandLinkText + '</a></p>';
    formMsg += '<div style="display: none;">';
    formMsg += flyoutText;
    formMsg += '</div>';
    formMsg += '</div>';

    g_form.addInfoMessage(formMsg);
}

To call this function, we have a few options:

showExpandingFormMessage(
    'This message can be expanded!', //The message to be shown initially.
    'A whole bunch of info can be included here without cluttering the screen, as this text will only be shown when the user expands the message.',
    'Show more', //The clickable text that will expand the message.
    'Hide details' //The clickable text to collapse the message, once expanded.
);

//Or we can call the function with only two arguments:
showExpandingFormMessage(
    'This message can be expanded!', //The message to be shown initially.
    'Longer text to show when expanded',
);

Convert Array to HTML List

Sometimes you might want to display a larger set of data (ideally in a collapsible form message), but you want to make it look pretty and HTML-ized for the user. You might, for example, have an array of data that you want to show in an HTML list.

Using the below function, we can display an array of data to the user in a meaningful and attractive way, and - using the method above - keep that data hidden until the user expands the message.

/**
 * Convert an array into an HTML list, using the specified list style-type.
 *
 * @param {Array} msgArray - An array of strings to return as HTML list elements.
 * @param {String} [listStyle=disc] - Optionally specify the list-style-type
 * Valid style-types can be found at the following link:
 *  https://www.w3schools.com/cssref/playdemo.asp?filename=playcss_list-style-type
 * For example, for a simple bulleted list, you can this to 'disc' (or not specify a value, as
 *  disc is the default value). For an ordered numerical list, you can set this to 'decimal'.
 *  For roman numerals, you can set this to 'upper-roman' or 'lower-roman'.
 * You can even have no list bullet type, which just shows the elements in a list without a
 *  bullet, by setting this value to the string 'none'.
 * @param {String} [listElementPrefixStr=] - Optionally specify a string to show at the beginning
 *  of each element in the array, in the returned HTML.
 * For example, if you have an element like "Element one", and you set this value to the string
 *  "- ", then the returned HTML list will contain an element with the string "- Element one".
 * @returns {string} - An HTML list of all the elements in msgArray.
 * @example
 * var arrListElements, flyoutListHTML;
 * showSimpleLoadingMessage('Loading');
 *
 * //Do some stuff that may take a while...
 *
 * arrListElements = ['First list element', 'Second list element', 'Third list element'];
 * flyoutListHTML = getHTMLListFromArray(
 *     arrListElements,
 *     'disc',
 *     'Element: '
 * );
 * g_form.clearMessages();
 * showExpandingFormMessage(
 *  'This message expands',
 *  flyoutListHTML,
 *  'Show more',
 *  'Hide details'
 * );
 */
function getHTMLListFromArray(msgArray, listStyle, listElementPrefixStr) {
    var i, msgText;

    listStyle = (typeof listStyle !== 'string') ? 'disc' : listStyle;
    listElementPrefixStr = (typeof listElementPrefixStr !== 'string') ? '' : listElementPrefixStr;

    if (msgArray.length <= 0) {
        return '';
    }

    msgText = '<ul style="list-style: ' + listStyle + '">';
    for (i = 0; i < msgArray.length; i++) {
        msgText += '<li>' + listElementPrefixStr + msgArray[i] + '</li>';
    }
    msgText += '</ul>';

    return msgText;
}

Usage is as easy as:

var flyoutListHTML = getHTMLListFromArray(
    arrListElements,
    'disc'
);

Putting it all Together!

Now that we know how to do all of that, we can include the functions above (or just copy and paste everything in this gist at the bottom of our Client Script or client-side UI Action script’s primary function or into a UI Script) and get the results shown in the following gif using something like the below code!

var arrListElements, flyoutListHTML;

showSimpleLoadingMessage('Retrieving data');

arrListElements = slowFunctionThatReturnsAnArrayOfStrings();
flyoutListHTML = getHTMLListFromArray(
    arrListElements,
    'disc',
    'Element: '
);

g_form.clearMessages();
showExpandingFormMessage(
    'This message expands',
    flyoutListHTML,
    'Show more',
    'Hide details'
);