Requiring Attachments (& Other Miracles) in Service Portal

Note: Want to download an update set containing a packaged version of this functionality? Skip to the bottom! But don't forget to at least check out the catalog client script for a usage example.

Unless you're somehow still rocking UI15 and do most of your development on a Commodore 64, there's a good chance that by now you're at least vaguely familiar with ServiceNow's new(-ish) Angularized end-user front-end feature: the Service Portal.

If you've tried to implement your Service catalog in the Service Portal, there's also a good chance that you've wept openly over your keyboard, trying to find an effective and non-hackey way to replicate functionality that was readily available using out-of-box APIs in the old Jelly-based CMS and catalog form. Stuff you'd think would be really basic, especially two full major releases later, it just not possible in the Service Portal. g_form APIs like getControl() and getElement() (and gel()) don't exist in Service Portal. Neither does any synchronous GlideRecord query or GlideAjax call, meaning that if you want to check something on the server in an onSubmit() script, you're out of luck. 

One of the biggest annoyances resulting from this, is that there is no good way to check if a catalog item has attachments as the user submits the form. This means that you cannot make attachments required on a specific catalog item.
Oh sure, there are sort of ways to do it, such as modifying the catalog item widget just for that catalog item, so that the client controller and server script work together to check for attachments. Or you could embed the whole CMS or ITIL UI catalog item form in an iframe. Unfortunately, both of those have major downsides. 

I've made this solution, in order to restore some basic functionality, with a mind toward the specific problem of checking for attachments in the Service Portal. 

Here is a basic feature-list of the below functionality: 

  1. Require attachments on submission, using sp_form.getAttachments()

    • Require attachments of a specific type

    • Require a specific number of attachments

    • Require a specific number of attachments of various types

  2. Access DOM element of variable field input box and div element, using sp_form.getElement() and sp_form.getControl()

  3. Access variable names, sys_ids, and question text, using sp_form.variables or sp_form.getVariables().

  4. Iterate over each variable on the page, or otherwise retrieve variable sys_IDs, names, and question text without having prior knowledge of each within your catalog client script.

In order to enable some of the functionality we're used to, we have to make use of a method which you may be familiar with from my previous article on enabling DOM access in the service portal; but first, let's create a Script Include that we can make use of to enable a query we're going to need to run later. 

Script Include

Name: CatItemVariables
Client Callable: True
Script: See below:

var CatItemVariables = Class.create();
CatItemVariables.prototype = Object.extendsObject(AbstractAjaxProcessor, {
    
    getSysIdsForQuery: function(itemSid, fieldName) {
        if (!fieldName) {
            fieldName = 'sys_id';
        }
        var a = [];
        var item = GlideappCatalogItem.get(itemSid);
        var variables = item.getVariables();
        while (variables.next()) {
            a.push(variables[fieldName].toString());
        }
        return a.join(',');
    },
    
    type: 'CatItemVariables'
});

In this client-callable Script Include, we accept two arguments: a catalog item, and (optionally) a field name to retrieve for each one. We then create an array, and populate it with that field from each catalog item variable associated with the indicated catalog item. We're going to use this to build a query string later on. 

Next, we need to build a UI Script that's going to be included as part of a JS Include, which we'll associate with the Theme that's included on the Portal on which our Service Catalog will be used. This UI Script will primarily be responsible for constructing the sp_form object, and its' various API methods. Here's how to construct that UI Script: 

UI Script

Name: sp_form
Global: False
Description: I recommend writing a reasonable description of this script, as with all useful scripts, so future developers will know what it's for
Script: See below:

/*======== UI SCRIPT FOR JS INCLUDE ========*/
var sp_form = {
    /**
     * Get an array of all the attachments associated with the displayed catalog item.
     * FOR USE IN SERVICE PORTAL ONLY.
     * MUST BE ATTACHED TO SERVICE PORTAL THEME, AS A JS INCLUDE.
     * @returns {_CatalogAttachment}
     */
    getAttachments: function() {
        var i, attachmentElement; //init vars for the loop
        var catalogAttachments = []; //This will store the CatalogAttachment records, constructed from the loop over each attachment
        // identified from the angular call
        var attachmentElements = angular.element("#sc_cat_item").scope().attachments; //Use some JS Include scope magic with a nod to
        // angular, to get the list of attachments.
        for (i = 0; i < attachmentElements.length; i++) { //For each attachment document element returned from the angular call
            attachmentElement = attachmentElements[i]; //Grab a single element for each loop
            //Push a constructed CatalogAttachment object into the array, with properties corresponding to the relevant attachment
            // properties.
            catalogAttachments.push(
                //Construct a new CatalogAttachment object, for inclusion in the returned array of attachment data.
                new this._CatalogAttachment(
                    attachmentElement.file_name,
                    attachmentElement.ext,
                    attachmentElement.sys_id,
                    attachmentElement.size
                )
            );
        }
        return catalogAttachments;
    },
    
    /**
     * Constructs a custom CatalogAttachment object
     * @param file_name {string} The name of the file, INCLUDING the extension. e.g.: 'test_file.csv'.
     * @param file_extension {string} The file extension, WITHOUT the dot. e.g.: 'xls'.
     * @param sysID {string} the sys_id of the attachment record (not to be confused with the table_sys_id)
     * @param file_size {string} the size of the attachment, in KB. e.g.: "13.3 KB".
     * @constructor
     */
    _CatalogAttachment: function(file_name, file_extension, sysID, file_size) {
        this.file_name = file_name;
        this.file_extension = file_extension;
        this.sysID = sysID;
        this.file_size = file_size;
    }
};

getVariables();

/**
 * This function extends the sp_form object, and adds the 'variables' object, as well as the getElement() and getControl() methods.
 */
function getVariables() {
    var i, varz, vSid, vName, vLabel, catalogItemSid;
    
    var itemVariables = {};
    
    var hostLocation = window.location.host + '';
    var sidBegin = window.location.search.indexOf('sys_id=') + 7;
    var sidEnd = window.location.search.indexOf('&', sidBegin);
    
    if (sidEnd >= 0) {
        catalogItemSid = window.location.search.slice(sidBegin, sidEnd);
    } else {
        catalogItemSid = window.location.search.slice(sidBegin);
    }
    
    var requestBody = "";
    var client = new XMLHttpRequest();
    //Updated to also get variables from variable sets on the
    client.open("get", "https://" + hostLocation +
        "/api/now/table/item_option_new?sysparm_query=sys_idINjavascript%3Anew%20CatItemVariables().getSysIdsForQuery('" +
        catalogItemSid + "', 'sys_id')" +
        "%5Eactive%3Dtrue&sysparm_fields=sys_id%2Cname%2Cquestion_text&sysparm_limit=100");
    client.setRequestHeader('Accept', 'application/json');
    client.setRequestHeader('Content-Type', 'application/json');
    client.setRequestHeader('X-UserToken', g_ck);
    
    client.onreadystatechange = function() {
        if (this.readyState == this.DONE) {
            if (this.status == 200 || this.status == 201) {
                varz = JSON.parse(this.response).result;
                
                for (i = 0; i < varz.length; i++) {
                    vSid = varz[i].sys_id;
                    vName = varz[i].name;
                    vLabel = varz[i].question_text;
                    
                    itemVariables[vName] = new CatalogItemVariable(
                        varz[i].sys_id,
                        varz[i].name,
                        varz[i].question_text
                    );
                }
                
                sp_form.variables = itemVariables;
                sp_form.getElement = function(varName) {
                    return document.getElementsByName('IO:' + sp_form.variables[varName].sid)[0];
                };
                sp_form.getControl = function(varName) {
                    return document.getElementById(sp_form.variables[varName].sid);
                };
                
            } else {
                console.error('Some kind of REST error happened. Error: ' + this.status);
            }
        }
        sp_form.getVariables = function() {
            return sp_form.variables;
        }
    };
    
    client.send(requestBody);
}

/**
 * @description Constructor function for building CatalogItemVariables
 * @param variableName {string} The name (not to be confused with the label/question) of the variable.
 * @param variableSysID {string} The catalog variable record's sys_id.
 * @param variableQuestion {string} The question/label of the variable itself. May contain spaces.
 * @constructor
 */
function CatalogItemVariable(variableName, variableSysID, variableQuestion) {
    this.name = variableName;
    this.sys_id = variableSysID;
    this.question = variableQuestion;
}

This UI Script is a little complicated, but here's a basic run-down of what it's doing: 
First, we construct an object called sp_form. We give it a method called getAttachments(), and a private constructor method for building custom CatalogAttachment objects, which we use in the getAttachments() method. This constructor just builds an object with a known set of properties associated with Catalog Attachments, so we don't have to keep declaring new generic objects for each one. 

After initializing the sp_form object, we then want to build on it to include a list of variables and the methods for accessing them as well as for accessing elements based on variable names. This is accomplished by the getVariables() function. Since DOM elements in the service portal are given IDs and names based on the variable sys_IDs rather than names, we need to get the sys_ID for each variable as well. That is also handled by the getVariables()

The getVariables() function in this script is responsible for making a REST call using vanilla JavaScript (since GlideAjax isn't available to JS Includes). This REST call will retrieve the list of variables associated with the specified Catalog Item, using a query that utilizes the Script Include we defined first in this article. 

Once we've created the necessary UI Script, we'll need to create a JS Include as a wrapper, which we'll then associate with the Theme for our Service Portal. Follow the steps below, to set this up. 

JS Include

  1. Navigate to the sp_js_include table, by entering "sp_js_include.list" into the Application Navigator's filter bar, and pressing Enter. Click New to create a new JS Include record.

    1. Enter sp_form in the Display name field.

    2. Make sure Source is set to UI Script, then select the sp_form UI Script by entering it into the Source field.

    3. Save the JS Include record.

  2. Next, associate the JS Include with a given Theme.

    1. Navigate to Service Portal > Portals (the sp_portal table).

    2. Select the Service Portal you'd like to make your Service Catalog available in.

    3. On the record you select, you'll see a Theme field. Click on the reference icon to navigate to this Theme record.

      • Note that you may need to click the reference icon, then click Open record on the sys_popup view.

    4. Once on the Theme record, scroll down and select the JS Includes related list. If you don't see this list, add it.

    5. Click on the Edit button, and move the sp_form JS Include from the left side, to the right; then click Save.


That's it! Now your JS Include (And thus, your sp_form UI Script) will load on every page on that Service Portal. This JS Include is relatively small, will be optimized by ServiceNow, and uses an asynchronous REST request, so the performance impact will be negligible. 

Now, all that's left is to create a Catalog Client Script that makes use of this new sp_form API. Of course, this will vary greatly based on what you want to accomplish with the UI Script, but I'll provide an example below. Note that for the Catalog Client Script to run on the Service Portal, the UI Type field must be either Mobile / Service Portal, or All. If you select All, your script should work on both the Service Portal, and CMS view. Desktop Catalog Client Scripts will not run on the Service Portal. This is a bit of a misnomer, because of course the Service Portal can run on "desktops" as well.

Catalog Client Script

Name: Require CSV Attachment
UI Type: All
Type: onSubmit
Applies on Catalog Item view: True
Script: See below:

/*======== EXAMPLE CLIENT SCRIPT: REQUIRE CSV ATTACHMENT ON SUBMIT ========*/
function onSubmit() {
    if (document && gel) { //If we're in the CMS view
        var cat_id = gel('sysparm_item_guid').value; //Not idea, but there's no better way at present.
        var gr = new GlideRecord("sys_attachment");
        gr.addQuery("table_name", "sc_cart_item"); //unnecessary, but improves query efficiency
        gr.addQuery("table_sys_id", cat_id);
        gr.addQuery('file_name', 'ENDSWITH', '.csv'); //Optional: require a specific attachment type.
        gr.query(); //synchronous for non-service portal CMS.
        if (!gr.hasNext()) {
            g_form.addErrorMessage("You must attach a file to submit.");
            return false;
        }
    } else if (sp_form.getAttachments) { //If we're in a service portal page, and we can find the sp_form object with the getAttachments()
        // method, which should be included via a JS Include on the service portal we're rendering this in
        var i; //iterator for the below for-loop
        var hasCorrectAttachments = false; //initialized to false, but will be set to true if the loop identifies a valid attachment
        var scAttachments = sp_form.getAttachments(); //This function MUST be exposed via a non-global UI Script included in a JS Include
        //constants for required attachment type and quantity.
        var REQUIREDATTACHMENTCOUNT = 1; //optional: the number of required attachments
        var REQUIREDATTACHMENTTYPE = 'csv'; //optional: the required attachment file extension (no 'dot') attached to the service portal's
        // theme, for this to work in service portal.
        if (scAttachments.length >= REQUIREDATTACHMENTCOUNT) { //If we have at least a sufficient number of attachments
            for (i = 0; i < scAttachments.length; i++) { //Iterate over each returned attachment
                if (scAttachments[i].file_extension.toLowerCase() === REQUIREDATTACHMENTTYPE.toLowerCase()) { //Make sure at least one attachment has the required file-type
                    hasCorrectAttachments = true; //If we have a correct file-type attachment, prevent the error
                    break; //and break out of the loop for efficiency
                }
            }
        }
        
        if (!hasCorrectAttachments) { //If there are not enough attachments, or if there is no attachment of the required type
            g_form.addErrorMessage('Please attach at least ' + REQUIREDATTACHMENTCOUNT + ' ' + REQUIREDATTACHMENTTYPE + ' attachment(s)');
            return false;
        }
    } else {
        console.error(
            'Unable to find either gel, document, or getSCAttachments method. \n' +
            'If you are viewing this in the Service Portal, be sure to add the ClientCatalogUtils UI Script JS Include to the Service Portal\'s theme.' +
            'If you are in the CMS, then an error has occurred and will require troubleshooting.'
        );
        return false;
    }
}

The first thing we're doing in this Catalog Client Script, is checking if we have access to document and gel(). If we have access to those, then we're not in the Service Portal, so we use the normal method of checking for attachments. 

If we do not locate those objects however, then we assume that we are in the Service Portal, and we check to make sure that sp_form and the getAttachments() API are both present (line 14). If not, we throw an error to the console but we don't raise one to the level that the user would see it. You can change this behavior by adding throw new Error(); to a new line below line 40. 

If our sp_form API is present, then we use it to get the list of attachments on the current catalog item. Since this example is looking for at least one CSV attachment, we then iterate over each attachment and check its' extension. If the extension is "CSV", then we break out of the loop and allow the form to be submitted. However, if we do not find any such attachment, or if the required number of attachments are not present (though in this case, only one is required), we return false; thus halting submission. We also display an error to the user, alerting them to the attachment requirement. 


Download Update Set

Don't feel like copy/pasting all that code? No problemo! Simply download the update set below, and follow these instructions to install it into your ServiceNow instance. 

Download latest version