But wait … there’s more!

“Waiting is one of the great arts.”
Margery Allingham

Every once in a while, I will run into a situation where my code needs to wait for some other asynchronous process to complete. You don’t really want to go anywhere, and you don’t want to do anything. You just want to wait. On the client side, this is easily accomplished with a simple setTimeout. This is usually done with a basic recursive loop that repeats until the conditions are right to proceed.

function waitForSomething() {
	if (something) {
		itIsNowOkToProceed();
	} else {
		setTimeout(waitForSomething, 500);
	}
}

Unfortunately, on the server side, where this need usually arises, the setTimeout function is not available. However, there is a widely known, but poorly documented, GlideSystem function called sleep that you can use in the global scope to provide virtually the same purpose.

function waitForSomething() {
	if (something) {
		itIsNowOkToProceed();
	} else {
		gs.sleep(500);
		waitForSomething();
	}
}

Don’t try this in a scoped application, though, because you will just get a nasty message about how gs.sleep() is reserved for the global scope only. Fortunately, like a lot of things in ServiceNow, if you know the secret, you can get around such restrictions. In this case, all you really need is a Script Include that IS in the global scope, and then you can call that script from your scoped application and it will work just fine. Here is a simple example of just such as script:

var Sleeper = Class.create();
Sleeper.prototype = {
    initialize: function() {
    },

	sleep: function(ms) {
		gs.sleep(ms);
	},

    type: 'Sleeper'
};

Once you have your global-scoped Script Include set up, you can now call it from a scoped app and it will actually work.

function waitForSomething() {
	if (something) {
		itIsNowOkToProceed();
	} else {
		new global.Sleeper().sleep(500);
		waitForSomething();
	}
}

Reference Type System Properties, Part III

“Everything should be made as simple as possible, but not simpler.”
Albert Einstein

Last time, we got far enough along in the System Property value page modifications to demonstrate that we could replace the rendered input element with something else of our own design. Not having a design of our own for an adequate replacement, we implemented the popular creative avoidance strategy by working on all of the other parts and pieces first until we finally ran out of other parts and pieces. Now it is time to come up with a plan and finish this little project up.

I have to admit that I’m not all that proud of what I eventually came up with, but it does satisfy Rule #1, so at this point, I’ll take it and call it good. I tried a number of other things first, but none of those really got me what I wanted, so here we are. The basic plan is pretty simple, and consists of two parts: 1) a hidden input element to replace the text input element so that it can be submitted with the form, and 2) an iframe into which we will put our new input element via a stand-alone page designed for that purpose. I don’t really like the iframe approach, but it does have the benefit of being independently rendered, which gives us the opportunity to leverage the snRecordPicker for our input, which we really cannot do by simply modifying the main page directly after it has been delivered.

So let’s start out with the script that will build the HTML that we will use to replace the original text input element:

function buildHTML(prop) {
	var html = "";
	html += "<input type=\"hidden\" id=\"" + prop.property + "\" name=\"" + prop.property + "\"/>\n";
	html += "<div style=\"width: 100%; height: auto;\">\n";
	html += " <iframe id=\"frame." + prop.property + "\" src=\"/$sp.do?id=reference_properties&sysparm_property=" + prop.property + "&sysparm_table=" + prop.tableName + "&sysparm_column=" + prop.column + "&sysparm_value=" + prop.value + "\" style=\"border: none; height: 65px; width: 100%;\"></iframe>\n";
	html += "</div>\n";
	return html;
}

The hidden input element virtually replaces the original text input element, having the same name and same id. The iframe element is pretty vanilla stuff as well; the only thing of interest really is the src parameter, which points to the Portal Page that we are about to create, and passes along all of the various values needed to make the page do what we want. The Portal Page itself is just a single page with a single container filled with a single widget. The page is not really worth looking at, so let’s just jump right into the widget, as that’s where all of the magic happens. Here is the HTML:

<div id="pickerdiv">
  <sn-record-picker field="field" table="c.data.table" display-field="c.data.column" value-field="'sys_id'" search-fields="c.data.column" page-size="c.data.pageSize"></sn-record-picker>
</div>

Not much to see there, either. It’s just your standard snRecordPicker with pretty much every attribute defined by a variable. We’ll snag the values for those variables off of the URL for the page, which we populated when we constructed the src attribute for the iframe tag. The widget’s client-side script does most of the heavy lifting here:

function($scope, $location) {
	var c = this;
	var qp = $location.search();
	c.data.property = qp.sysparm_property;
	c.data.table = qp.sysparm_table;
	c.data.column = qp.sysparm_column;
	c.data.pageSize = 20;
	c.data.fieldValue = '';
	c.data.fieldDisplayValue = '';
	if (qp.sysparm_page_size) {
		c.data.pageSize = qp.sysparm_page_size;
	}
	if (qp.sysparm_value) {
		c.data.fieldValue = qp.sysparm_value;
		c.server.update().then(function(response) {
			c.data.fieldDisplayValue = response.fieldDisplayValue;
			$scope.field = {
				displayValue: c.data.fieldDisplayValue,
				value: c.data.fieldValue,
				name: 'field'
			};
		});		
	} else {
		$scope.field = {
			displayValue: '',
			value: '',
			name: 'field'
		};
	}
	$scope.$on('field.change', function(evt, parms) {
		if (parms.field.name == 'field') {
			parent.updateReferenceProperty(c.data.property, parms.field.value);
		}
	});
}

The only reason for the server-side script is to fetch the display value of the property if the property is valued at the time that the page is delivered to the browser.

(function() {
	if (input) {
		if (input.fieldValue) {
			var gr = new GlideRecord(input.table);
			gr.get(input.fieldValue);
			data.fieldDisplayValue = gr.getDisplayValue(input.column);
		} else {
			data.fieldDisplayValue = '';
		}
	}
})();

That’s about all there is to it. For every property on the page where Type=reference, the standard text input element is replaced with a hidden input element and an iframe, and inside the iframe is a ServiceNow Service Portal page that contains a single widget containing a single snRecordPicker. The parameters for the picker are passed from the iframe to the portal page via URL parameters, which are picked up by the widget and used to configure the snRecordPicker. All changes to the snRecordPicker are copied over to the hidden input field, so when the form is submitted, the selected value is sent to the server and posted to the database.

There was a minor problem with this initial version when trying to figure out the optimum height for the iframe. The height of the snRecordPicker depends on whether or not the drop-down list of choices is present, and I couldn’t find a CSS-centric way of having the iframe automatically adjust for the change in height, nor could I find a way to have the drop-down list of selectable choices overlay the content below, which is outside of the iframe. Finally, I resorted to plain old Javascript, and set up a variable called c.data.expanded to indicate whether or not the pick list was present on the screen. With a little view selection source magic, I was able to figure out that the component to watch had an id of select2-drop-mask, and so I modified the widget’s client-side code to check the required iframe height every second and adjust if needed:

function($scope, $location) {
	var c = this;
	var qp = $location.search();
	c.data.property = qp.sysparm_property;
	c.data.table = qp.sysparm_table;
	c.data.column = qp.sysparm_column;
	c.data.pageSize = 20;
	c.data.fieldValue = '';
	c.data.fieldDisplayValue = '';
	c.data.expanded = false;
	if (qp.sysparm_page_size) {
		c.data.pageSize = qp.sysparm_page_size;
	}
	if (qp.sysparm_value) {
		c.data.fieldValue = qp.sysparm_value;
		c.server.update().then(function(response) {
			c.data.fieldDisplayValue = response.fieldDisplayValue;
			$scope.field = {
				displayValue: c.data.fieldDisplayValue,
				value: c.data.fieldValue,
				name: 'field'
			};
		});		
	} else {
		$scope.field = {
			displayValue: '',
			value: '',
			name: 'field'
		};
	}
	$scope.$on('field.change', function(evt, parms) {
		if (parms.field.name == 'field') {
			parent.updateReferenceProperty(c.data.property, parms.field.value);
		}
	});
	checkHeight();
	function checkHeight() {
		var elem = document.getElementById('select2-drop-mask');
		if (elem) {
			if (elem.style.display == 'none') {
				if (c.data.expanded) {
					c.data.expanded = false;
					setHeight('65px');
				}
			} else {
				if (!c.data.expanded) {
					c.data.expanded = true;
					setHeight('300px');
				}
			}
		}
		setTimeout(checkHeight, 1000);
	}
	function setHeight(newHeight) {
		parent.updateFrameHeight(c.data.property, newHeight);
	}
}

Once that code was in place, the unexpanded state looked like this:

Modified input element with choices collapsed

… and the expanded state looked like this:

Modified input element with choices expanded

It still disturbs my sense of The Way Things Ought To Be for the left-hand edge of the revised input element not to line up with the left-hand edge of all of the other original input elements, but a fix to that was not readily apparent to me, so I have managed to let that go for now and move on to more important things. One day, though, I am going to figure out a way to fix that!

Just to recap, we modified a choice list and added an additional field to a table to provide the capability to define properties of type reference. We then created a UI Script and a Script Include so that we could replace the original input element on the property UI page, and then we created a Service Portal page and associated widget to provide the replacement for the original input element. As soon as I get a chance, I will see if I can wrap all of that up into a single Update Set and get it posted out here in case anyone wants just grab the whole package. All in all, it was a fun little project, but one that I hope to throw away one day when ServiceNow actually supports reference type properties right out of the box and having this little tweak is no longer needed.

Update: Well, it took a little longer than I had hoped to get around to this, but here is that Update Set finally.

Reference Type System Properties, Part II

“I shall either find a way or make one.”
Hannibal Barca

Last time out, we modified the sys_properties table and associated form so that we could create System Properties with a field type of Reference. This was only half the battle, however, and the easy half at that. The more difficult task will be to figure out how to get the page used to set the value of System Properties to properly render the property for user input.

Even though there is a Value field on the default System Properties form, system property values are generally set using the system_properties_ui.do page, which takes a title and one or more categories as URL parameters. You can see several examples of this in the out-of-the-box left-hand navigation menu, such as the one pictured below from the Live Feed section:

Example System Property value page

You can see from the rendered form that properties of different Types have different input mechanisms. There is a checkbox for the boolean properties and text input for the string and number properties. All we need to do is figure out how to convince it to render the Reference type properties with a selection list from the specified table. How hard can that be?

Up to this point, everything that we have done has been fairly vanilla ServiceNow stuff. Modifying Choice Lists, adding columns to Tables, manipulating Form Layouts, and creating UI Policies are all bread and butter ServiceNow sysadmin activities. There has been no need to resort to any kind of creative hackery to accomplish what we wanted do, which is always a good thing. Unfortunately, that’s all about to change.

As far as I can tell, the system_properties_ui.do page is yet another one of those items that is an integral part of the product and is not defined in the system database in a way that you can alter it in any way. As with the Email Client, we will have to rely on a script to modify the page after it has been delivered to the browser. To get an idea of how we want to do that, let’s define a property of type reference and see what the out-of-the-box functionality does with it when it renders the page. Entering sys_properties.list in the left-hand navigation search box will get us to the full list of System Properties where we can click on the New button to create our new property. At this point, I am just trying to create something that I can use to see how the input screen turns out, so I just enter Test as the Name, Test as the Description, select reference as the Type, and then select the sys_user table as the Table. Once the property has been defined, I can go back in and assign it to the Category Test by scrolling down to the bottom of the form and clicking on the New button in the Category list.

Once assigned to a Category, I can bring up the standard property value maintenance page with the URL https://<instance>.service-now.com/nav_to.do?uri=%2Fsystem_properties_ui.do%3Fsysparm_title%3DTest%2520Properties%26sysparm_category%3DTest. The good news is that the page didn’t choke on the new, unknown property type, and simply rendered the property as if the type were string:

Out-of-the-box rendering of a reference type property

More important than how it looks, though, is what’s under the hood … let’s inspect that HTML:

<tr>
 <td class="tdwrap label_left" oncontextmenu="return showPropertyContext(event, 'Test');">
  <span>
   <label class="label-inline" for="98c30fe6db362300f9699006db961935">Test</label>
   <button type="button" data-toggle="tooltip" aria-label="Property name: Test" title="" class="btn btn-icon-small btn-icon-white icon-help sn-tooltip-basic" data-original-title="Property name: Test"></button>
  </span>
 </td>
</tr>
<tr>
 <td>
  <input name="98c30fe6db362300f9699006db961935" id="98c30fe6db362300f9699006db961935" value="" aria-label="" style="width: 700px" autocomplete="off">
  <br>
  <br>
 </td>
</tr>

Pretty straightforward stuff, just a couple of single cell table rows, with the label in one row and the input box in the other. It looks like both the name and id of the input element are the sys_id of the property, so it would appear that we have everything that we need to have our way with this code, once it has been delivered to the browser.

So, here’s the plan: given the property categories available from the URL of the page, we should be able to determine all of the properties in the specified category or categories where Type=reference. Looping through that list of properties, we can find the existing input field based on the property’s sys_id, and then replace it with a more appropriate input mechanism to support the selection of records from the specified table. The only question, really, is with what will we replace the input element? If this were a Service Portal widget, we could leverage the snRecordPicker tag, but tags are useless once the page has been delivered to the browser. We could emulate everything that it does, and generate all of the HTML, CSS, and Javascript on our own, but that seems like considerably more work than I care to contemplate right now. We’ll have to give this one a little thought.

In the meantime, let’s jump on that UI Script that pulls the categories off of the URL and makes the Ajax call to the guy that will find all of the properties where Type=reference. That one shouldn’t be much work at all …

if (window.location.pathname == '/system_properties_ui.do') {
	if (window.location.href.indexOf('sysparm_category=') != -1) {
		fetchReferenceProperties();
	}
}

function fetchReferenceProperties() {
	var thisUrl = window.location.href;
	var category = thisUrl.substring(thisUrl.indexOf('sysparm_category=') + 17);
	if (category.indexOf('&') != -1) {
		category = category.substring(0, category.indexOf('&'));
	}
	if (category > '') {
		var ga = new GlideAjax('Reference_Properties');
		ga.addParam('sysparm_name', 'fetchReferenceProperties');
		ga.addParam('sysparm_category', category);
		ga.getXML(processProperties);
	}
}

function processProperties(response) {
	var answer = response.responseXML.documentElement.getAttribute("answer");
	if (answer > '') {
		var property = JSON.parse(answer);
		for (var i=0; i<property.length; i++) {
			var prop = property[i];
			var elem = gel(prop.property);
			elem.parentNode.innerHTML = buildHTML(prop);
		}
	}
}

function buildHTML(prop) {
	// we'll need to figure this out one day ...
}

Well, that wasn’t so bad. While we are thinking about the HTML that we will need to build to replace the original input tag, let’s go ahead and create that Script Include that will gather up all of the properties where Type=reference.

var Reference_Properties = Class.create();
Reference_Properties.prototype = Object.extendsObject(AbstractAjaxProcessor, {

	fetchReferenceProperties: function() {
		var property = [];
		var catList = this.getParameter('sysparm_category');
		if (catList > '') {
			catList = decodeURIComponent(catList);
			var category = catList.split(',');
			for (var i=0; i<category.length; i++) {
				var gr = new GlideRecord('sys_properties_category_m2m');
				gr.addQuery('category.name', category[i]);
				gr.addQuery('category.sys_scope', gs.getCurrentApplicationId());
				gr.addQuery('property.type', 'reference');
				gr.query();
				while (gr.next()) {
					property.push({property: gr.getValue('property')});
				}
			}
		}
		for (var i=0; i<property.length; i++) {
			var sys_id = property[i].property;
			var gr = new GlideRecord('sys_properties');
			gr.get(sys_id);
			property[i].table = gr.getValue('u_table');
			property[i].tableName = gr.getDisplayValue('u_table.name');
			property[i].value = gr.getValue('value');
			property[i].column = this.getDisplayColumn(property[i].tableName);
			property[i].displayValue = this.getDisplayValue(property[i].tableName, property[i].value, property[i].displayColumn);
		}
		return JSON.stringify(property);
	},

	getDisplayColumn: function(table) {
		if (!this.displayColumn[table]) {
			this.displayColumn[table] = this.fetchDisplayColumn(table);
		}
		return this.displayColumn[table];
	},

	fetchDisplayColumn: function(table) {
		var displayColumn = 'sys_id';
		var possibleColumn = ['name','short_description','title','description'];
		for (var i=0; i<possibleColumn.length && displayColumn == 'sys_id'; i++) {
			if (this.columnPresent(table, possibleColumn[i])) {
				displayColumn = possibleColumn[i];
			}
		}
		return displayColumn;
	},

	columnPresent: function(table, column) {
		columnPresent = false;
		var gr = new GlideRecord('sys_dictionary');
		gr.addQuery('name',  table);
		gr.addQuery('element', column);
		gr.query();
		if (gr.next()) {
			columnPresent = true;
		}
		return columnPresent;
	},

	getDisplayValue: function(table, value, column) {
		var displayValue = '';
		if (value > '') {
			var gr = new GlideRecord(table);
			gr.get(value);
			displayValue = gr.getDisplayValue(column);
		}
		return displayValue;
	},

	displayColumn: {},

	type: 'Reference_Properties'
});

This one is pretty self-explanatory, but here is the basic premise for the code: for every category in the list, we search for properties where Type=reference, and then push each one into a single pile of property objects that contain the sys_id of the property record. When we are through creating the pile, we then loop through the pile and fetch the property records using the stored sys_id so that we can add additional data to the objects, including the reference table and the current value. One of the data points that we will undoubtedly need will be the name of the field on the table that contains the “display” value. Although we could have added yet another field to the sys_properties table and had the user provide that information, for now I just hunt for it using a few potential candidates, and then fall back to sys_id if nothing else is available.

At this point, we can actually try out what we have so far, even though we still haven’t figured out what we are going to use to replace the original input element. For now, we can just dump the values onto the screen and make sure that all of the parts and pieces that we have build so far are doing what we would expect them to do. We can do that by adding a little code to the buildHTML function of our UI Script:

function buildHTML(prop) {
	return JSON.stringify(prop);
}

Now we can another little test using our previously defined test property by pulling up the same page that we did at the start.

Modified rendering of a reference type property

What that proves is that we can grab the categories from the URL, use them to find all of the reference properties in those categories, use the list of reference properties to find their corresponding input elements on the page, and then replace those input elements with something else. Now all that we have to figure out is what we really want to use to replace those input elements so that we have a pick list from the records in the specified table.

Right about now, that sounds like an interesting exercise for next time out

OK, that works!

“A good plan, violently executed now, is better than a perfect plan next week.”
General George S. Patton

Well, it’s not the way that I like to do things, but it does work, so there is at least that. If I can ever stumble across a way to do it right, I will. For now, though, this will have to do. I never could find a modifiable source for either the pop-up e-mail form or the drop-down menu, so I eventually gave up on trying to send the right content for the page to the browser from the start. My fallback plan was to modify the page after it was rendered on the browser to achieve my desired results. That’s not really the way that I like to do things, but hey — It works!

I inspected the Email option of the drop-down menu and determined that it had an ID of email_client_open. Armed with that useful tidbit of information, I was able to create the following Client Script linked to the Incident table:

function onLoad() {
	var elem = gel('email_client_open');
	if (elem) {
		elem.onclick = function() {
			var dialog = new GlideDialogWindow('email_recipient_selector');
			dialog.setSize(400,400);
			dialog.setTitle('Select Recipient');
			dialog.render();
		};
	}
}

All in all, it took four independent components to pull this off: 1) the Client Script (above), 2) the UI Page that served as the content of the modal dialog, 3) a UI Script to run on the original Email Client pop-up to pull in the selections passed in the URL, and 4) a Script Include to go get the appropriate email addresses based on the user’s selections on the modal dialog. Now, when you click on the Email menu option, a new interim modal dialog pops up asking you to select the recipients of the email:

The UI Page is the most straightforward component of the lot. It’s just a standard page using standard approaches to collect information from the user and pass it along to the other elements in the chain. Here is the HTML in its entirety:

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
  <div style="padding: 20px;">
    Please select the recipients to whom you would like to send e-mail from the list below:
    <input type="checkbox" style="margin: 0px 8px 5px 30px;" id="caller"/>Caller
    <input type="checkbox" style="margin: 0px 8px 5px 30px;" id="group"/>Assignment Group
    <input type="checkbox" style="margin: 0px 8px 5px 30px;" id="tech"/>Assigned Technician
    <button class="button" onclick="composeEmail();">Compose E-mail</button>
  </div>
</j:jelly>

And here is the associated script:

function composeEmail() {
    var caller = gel('caller').checked;
    var group = gel('group').checked;
    var tech = gel('tech').checked;
    if (caller || group || tech) {
        continueToOriginalEmailPopUp(caller, group, tech);
    } else {
        alert('You must select at least one recipient to send an e-mail');
    }
}
 
function continueToOriginalEmailPopUp(caller, group, tech) {
    var id = g_form.getUniqueValue();
    if (id) {
        var url = new GlideURL("email_client.do");
        url.addParam("sysparm_table", 'incident');
        url.addParam("sysparm_sys_id", id);
        url.addParam("sysparm_target", 'incident');
        if (caller) {
            url.addParam("sysparm_to_caller", "true");
        }
        if (group) {
            url.addParam("sysparm_to_group", "true");
        }
        if (tech) {
            url.addParam("sysparm_to_tech", "true");
        }
        popupOpenEmailClient(url.getURL() + g_form.serializeChangedAll());
        GlideDialogWindow.get().destroy();
    }
}

To read the new parameters added to the URL when the email client page comes up, I needed to attach a script to that page. Here again, the problem is that I have been unable to find any way to modify that page directly. I’m not too proud of the solution that I came up with to address this problem, but again, it does work. To make it happen, I created a global UI Script that will now be included on every single page on this instance, but will look at the current location to see if needs to do anything. If the current page is not the email client pop-up, then it does nothing, but it still has to ask, which is a virtually pointless exercise on any page other than the one that I want. Still, it’s a way to get something to run on that page, and that’s what I was after. Here is the code:

if (window.location.pathname == '/email_client.do') {
    setupRecipients();
}
 
function setupRecipients() {
    var qp = {};
    var qs = window.location.search.substring(1);
    var parts = qs.split('&');
    for (var i=0; i<parts.length; i++) {
        var temp = parts[i].split('=');
        qp[temp[0]] = decodeURIComponent(temp[1]);
    }
 
    if (qp['sysparm_table'] == 'incident') {
        var caller = qp['sysparm_to_caller'];
        var group = qp['sysparm_to_group'];
        var tech = qp['sysparm_to_tech'];
        var ga = new GlideAjax('EmailRecipientTool');
        ga.addParam('sysparm_name', 'getRecipients');
        ga.addParam('sysparm_sys_id', qp['sysparm_sys_id']);
        ga.addParam('sysparm_to_caller', caller);
        ga.addParam('sysparm_to_group', group);
        ga.addParam('sysparm_to_tech', tech);
        ga.getXML(function(response) {
            var string = response.responseXML.documentElement.getAttribute("answer");
            var recipient = JSON.parse(string);
            var spans = $(document.body).select(".address");
            for (var i=0; i<spans.length; i++) {
                spans[i].className = 'addressHighlight';
            }
            deleteHighlightedAddresses();
            for (var i=0; i<recipient.length; i++) {
                var input = {};
                input.value = recipient[i].email;
                addEmailAddressToList('MsgToUI', input);
            }
        });
    }
}

To fetch the requested recipient e-mail addresses from the system database, the above script makes an Ajax call to the last component in the solution, the Script Include developed for this purpose:

var EmailRecipientTool = Class.create();
EmailRecipientTool.prototype = Object.extendsObject(AbstractAjaxProcessor, {
 
    getRecipients: function() {
        var list = [];
        var gr = new GlideRecord('incident');
        gr.get(this.getParameter('sysparm_sys_id'));
        if (this.getParameter('sysparm_to_caller') == 'true') {
            this.addRecipient(list, {sys_id: gr.getDisplayValue('caller_id.sys_id'), name: gr.getDisplayValue('caller_id.name'), email: gr.getDisplayValue('caller_id.email')});
        }
        if (this.getParameter('sysparm_to_group') == 'true') {
            this.addGroup(list, gr.getValue('assignment_group'));
        }
        if (this.getParameter('sysparm_to_tech') == 'true') {
            this.addRecipient(list, {sys_id: gr.getDisplayValue('assigned_to.sys_id'), name: gr.getDisplayValue('assigned_to.name'), email: gr.getDisplayValue('assigned_to.email')});
        }
        return JSON.stringify(list);
    },
 
    addGroup: function(list, group) {
        if (group > '') {
            var gr = new GlideRecord('sys_user_grmember');
            gr.addQuery('group', group);
            gr.query();
            while (gr.next()) {
                this.addRecipient(list, {sys_id: gr.getDisplayValue('user.sys_id'), name: gr.getDisplayValue('user.name'), email: gr.getDisplayValue('user.email')});
            }
        }
    },
 
    addRecipient: function(list, recipient) {
        if (recipient.sys_id > '') {
            var duplicate = false;
            for (var i=0; i<list.length; i++) {
                if (recipient.sys_id == list[i].sys_id) {
                    duplicate = true;
                }
            }
            if (!duplicate) {
                list.push(recipient);
            }
        }
    },
    type: 'EmailRecipientTool'
});

Well, that’s it. If anyone is interested, I bundled all four parts into an Update Set, which you can grab here. I was able to do that, by the way, thanks to this tool, which I cannot recommend highly enough. I can’t even begin to count how many times I’ve used that since I first installed it. The folks that put that together and keep it maintained do very nice work indeed, and have both my admiration and my appreciation.