Asynchronous onSubmit Catalog/Client Scripts in ServiceNow

I often get questions that go something like this:

When a user submits a Catalog Item, I need to be able to do some validation that requires client-server communication (either a GlideAjax script, a GlideRecord query, or a getRefRecord() query). How can I make this happen?

As anyone who’s read my article on requiring attachments in the Service Portal knows, ServiceNow has been (for better or worse) making some changes to how you are supposed to, and able to, do certain things within the platform. What’s relevant to this question, is that ServiceNow does not allow synchronous client-server communication in the portal; which means that your catalog client scripts should not use synchronous versions of GlideAjax, GlideRecord, or getRefRecord().
This is fine, and generally good advice anyway. Synchronous client-server operations lock up the user’s browser, and typically make for a poor user-experience. However - onSubmit Client and Catalog Client Scripts need to run synchronously, because if they don’t return false, the form will submit, reload, and stop any scripts that are currently running (or waiting to run; such as your callback function).


The problem

This is a silly example that you probably wouldn’t ever want to use in real life, but consider the following code as an example of this issue.

function onSubmit() {
    var grUser = new GlideRecord('sys_user');
    grUser.addQuery('sys_id', g_user.getUserID());
    grUser.setLimit(1);
    grUser.query(validateUser);
}
//Callback function
function validateUser(grUser) {
    //This function could be one line but
    // I've written it this way for clarity
    if (grUser.getValue('title').indexOf('director') >= 0) {
        //Allow submission
        return true;
    } else {
        //disallow submission
        return false;
    }
}

You might be inclined to think that this will work, but consider how it executes…
First, the variable grUser is declared and instantiated to a GlideRecord object. Then we add a query to it, set a limit, and send it on its way.
Once that query is complete, the callback function (which we passed into the .query() method) will be called. However, the onSubmit script is done. The next line is the close-brace that ends the onSubmit function, which essentially tells it to return; in this case, returning undefined.

The callback function is passed into the .query() method, but the script is finished running before the callback function is ever invoked, because once the onSubmit script is done running, the form submits and the page reloads! Even if the validateUser() function were to run and return true or false, it isn’t the same thing as the onSubmit script returning true or false, so it would have no bearing on whether the form can submit or not.

To put it simply: A callback function or other asynchronous operation cannot stop the form from submitting once the process has begun.


The solution

So how do we get around this problem, if we can’t use synchronous queries?

Well, one popular option is to add a hidden field on the catalog item, set the value of that field based on a separate onChange script, then have your onSubmit script read that field to determine if the form can be submitted. I don’t personally like this option, because there are a lot of moving parts, and it requires an additional unnecessary field which is always hidden.

A better option might be to use client-data, and a self-submitting callback function!

EDIT: ServiceNow has recently made a change which causes the original solution here, to no longer work on the Service Portal. This is because the g_user.setClientData() function is no longer available there… for some reason.
I’ve made some tweaks to the scripts below so that they should work with the Service Portal as well as the “classic view” no matter your version of ServiceNow.
The new functions, which you can implement in your client script to work in both CMS and portal UIs, are setClientData(), and getClientData(). The rest of the example has also been updated to work correctly in both UIs.

function onSubmit() {
    //Hide any existing messages from previous validation attempts
    g_form.clearMessages();
    //If the user was validated, stop here and submit the form.
    if (getClientData('user_validated')) { //getClientData implemented below
        return true;
    } //If the user has not yet been validated, do so
    var grUser = new GlideRecord('sys_user');
    grUser.addQuery('sys_id', g_user.getUserID());
    grUser.setLimit(1);
    //Query and pass in the callback function
    grUser.query(validateUser);
    //then return false to halt submission (for now)
    return false;
}

function validateUser(grUser) {
    //Perform validation
    if (grUser.getValue('title').indexOf('director') >= 0) {
        //If the user is valid, set client data user_validated to true
        setClientData('user_validated', true);
        //then re-submit
        g_form.orderNow(); //(This will trigger line 6 above, and submit the form!)
    } else { //If the validation failed...
        //set the client data user_validated element to false
        setClientData('user_validated', false);
        //and warn the user, but do not re-submit the form.
        g_form.addErrorMessage('Some message about not being a valid user');
    }
}

/**
 * Sets a client data variable. Works on both classic UI (using legacy g_user.setClientData() method) and portal UI (using this.client_data). 
 * @param {string} key - The key to store the data in. Use this with getClientData() to retrieve the value stored here. 
 * @param {string} val - The value to store in the specified key.
 */
function setClientData(key, val) {
    if (typeof g_user.setClientData != 'undefined') {
        g_user.setClientData(key, val);
    } else {
        if (typeof this.client_data == 'undefined') {
            this.client_data = {};
        }
        this.client_data[key] = val;
    }
}

/**
 * Gets a client data variable, stored using setClientData(). Works on both classic UI (using legacy g_user.getClientData() method) and portal UI (using this.client_data).
 * @param {string} key - The key to the value you'd like to retrieve.
 * @returns {string}
 */
function getClientData(key) {
    if (typeof g_user.getClientData != 'undefined') {
        return g_user.getClientData(key);
    } else {
        try {
            return (typeof this.client_data[key] == 'undefined' ? '' : this.client_data[key]);
        } catch(ex) {
            console.error('Error retrieving client data value ' + key + ': ' + ex.message);
        }
    }
    return '';
}

The code comments walk you through exactly what’s happening, but here’s the gist of what the script above is doing.

  1. The user clicks the save/submit button. The onSubmit script is triggered.

  2. Clear any existing messages at the top of the form (line 3).

  3. Check if there is a “client data” element that indicates that we have already performed our validation checks, and that it is set to true, indicating that the validation passed (ln 5).

  4. If the validation has already passed, simply return true and allow the form to submit (ln 6).
    Otherwise, continue to the next step.

  5. Query the sys_user table for the current user record. Pass in our callback function to the .query() method (ln 12)

  6. Return false, thus preventing submission (ln 14).
    Note that this step comes before any of the code in the callback function runs. This is because the callback function executes asynchronously.

  7. Once the query is complete, our callback function (validateUser()) executes.

  8. Perform the validation in the callback function (ln 19).
    This specific validation is not something you’d do in real life, it’s just an example

  9. If the validation passes, set the client data user_validated property to true (ln 21) and re-submit the form (ln 23). This returns you to step 2 in this list.

    1. Otherwise if the validation failed, set user_validated to false (ln 26) and add an error message to the top of the form (ln 28).
      Do not re-submit the form if the validation fails.


Conclusion

Use this method; it’s way cooler.
Huzzah; now I have a link that I can send people when they ask me this question. :-D