Retina Icon Picker

“This life’s hard, but it’s harder if you’re stupid.”
George V. Higgins

Nothing is ever easy. Oh sure, it all seems easy in the beginning, but then you get into it and you realize that things are just a little more complicated than they first appeared. It’s always something. But then, as my old boss used to explain to me, if it was easy, then anyone could do it!

All I really wanted to do was to create a SELECT statement with an option list of Retina Icons that included the icon image itself in the drop-down selection list. How hard could that be? Something that might come out looking somewhat like this:

SELECT statement with icon images in the option list

Unfortunately, the current SELECT statement doesn’t support that where SIZE is not greater than 1. I have no idea why it is that there is that distinction, but on a single line SELECT statement, it ignores the CSS for the icon. However, once you add SIZE=”2″ or something else other than 1, it works great. That’s really nice, but that’s not what I was after. I didn’t want to take up more than one line on my form for this, so after searching fruitlessly for a way to get around this annoyance, I finally accepted the reality that I was going to need to look for something else.

Eventually, I decided to stick with the SELECT, but put it in a modal pop-up that could show a dozen or so lines when needed, and then just go away when I was done making my selection. That seemed easy enough. How hard could it be?

To start out, I copied all of the values from that master icon page and used them to create an array of all of the options.

data.option = [
	{"label": "accessibility", "value": "accessibility"},
	{"label": "activity-circle", "value": "activity-circle"},
	{"label": "activity-stream", "value": "activity-stream"},
	{"label": "activity", "value": "activity"},
	{"label": "add-circle-empty", "value": "add-circle-empty"},
	{"label": "add-circle", "value": "add-circle"},
	{"label": "add", "value": "add"},
	{"label": "alert-triangle", "value": "alert-triangle"},
	{"label": "alert", "value": "alert"},
	{"label": "align-center", "value": "align-center"},
	{"label": "align-left", "value": "align-left"},
	{"label": "align-right", "value": "align-right"},
	{"label": "all-apps", "value": "all-apps"},
	{"label": "application-generic", "value": "application-generic"},
	{"label": "archive", "value": "archive"},
	{"label": "arrow-down-rounded", "value": "arrow-down-rounded"},
	{"label": "arrow-down-triangle", "value": "arrow-down-triangle"},
	{"label": "arrow-down", "value": "arrow-down"},
	{"label": "arrow-left-rounded", "value": "arrow-left-rounded"},
	{"label": "arrow-left", "value": "arrow-left"},
	{"label": "arrow-right-rounded", "value": "arrow-right-rounded"},
	{"label": "arrow-right", "value": "arrow-right"},
	{"label": "arrow-up-rounded", "value": "arrow-up-rounded"},
	{"label": "arrow-up", "value": "arrow-up"},
	{"label": "article-document", "value": "article-document"},
	{"label": "barcode", "value": "barcode"},
	{"label": "blog", "value": "blog"},
	{"label": "book-open", "value": "book-open"},
	{"label": "book", "value": "book"},
	{"label": "boolean", "value": "boolean"},
	{"label": "bot", "value": "bot"},
	{"label": "brand-mobile", "value": "brand-mobile"},
	{"label": "brand-now", "value": "brand-now"},
	{"label": "brand-service", "value": "brand-service"},
	{"label": "brand-servicenow", "value": "brand-servicenow"},
	{"label": "calendar", "value": "calendar"},
	{"label": "cards", "value": "cards"},
	{"label": "cart-full", "value": "cart-full"},
	{"label": "cart", "value": "cart"},
	{"label": "catalog", "value": "catalog"},
	{"label": "chart-do", "value": "chart-do"},
	{"label": "chart-pi", "value": "chart-pi"},
	{"label": "check-circle", "value": "check-circle"},
	{"label": "check", "value": "check"},
	{"label": "checkbox-checked", "value": "checkbox-checked"},
	{"label": "checkbox-empty", "value": "checkbox-empty"},
	{"label": "chevron-down", "value": "chevron-down"},
	{"label": "chevron-left", "value": "chevron-left"},
	{"label": "chevron-right-circle-solid", "value": "chevron-right-circle-solid"},
	{"label": "chevron-right-circle", "value": "chevron-right-cicle"},
	{"label": "chevron-right", "value": "chevron-right"},
	{"label": "chevron-up", "value": "chevron-up"},
	{"label": "circle-solid", "value": "circle-solid"},
	{"label": "clear-cache", "value": "clear-cache"},
	{"label": "clear", "value": "clear"},
	{"label": "clockwise", "value": "clockwise"},
	{"label": "code", "value": "code"},
	{"label": "cog-changes", "value": "cog-changes"},
	{"label": "cog-selected", "value": "cog-selected"},
	{"label": "cog", "value": "cog"},
	{"label": "collaboration", "value": "collaboration"},
	{"label": "comment-add", "value": "comment-add"},
	{"label": "comment-hollow", "value": "comment-hollow"},
	{"label": "comment", "value": "comment"},
	{"label": "company-feed", "value": "company-feed"},
	{"label": "condition", "value": "condition"},
	{"label": "configuration", "value": "configuration"},
	{"label": "connect-adduser-sm", "value": "connect-adduser-sm"},
	{"label": "connect-adduser", "value": "connect-adduser"},
	{"label": "connect-close-sm", "value": "connect-close-sm"},
	{"label": "connect-close", "value": "connect-close"},
	{"label": "connect-minimize-sm", "value": "connect-minimize-sm"},
	{"label": "connect-minimize", "value": "connect-minimize"},
	{"label": "connect-newwin", "value": "connect-newwin"},
	{"label": "connect-newwindow-sm", "value": "connect-newwindow-sm"},
	{"label": "connect-viewdocument-sm", "value": "connect-viewdocument-sm"},
	{"label": "connect-viewdocument", "value": "connect-viewdocument"},
	{"label": "connection", "value": "connection"},
	{"label": "console", "value": "console"},
	{"label": "copy", "value": "copy"},
	{"label": "counter-clockwise", "value": "counter-clockwise"},
	{"label": "cross-circle", "value": "cross-circle"},
	{"label": "cross", "value": "cross"},
	{"label": "cursor-move", "value": "cursor-move"},
	{"label": "cursor-select", "value": "cursor-select"},
	{"label": "dashboard", "value": "dashboard"},
	{"label": "database-error", "value": "database-error"},
	{"label": "database", "value": "database"},
	{"label": "date-time", "value": "date-time"},
	{"label": "debug", "value": "debug"},
	{"label": "default-knowledge-base", "value": "default-knowledge-base"},
	{"label": "delete-selected", "value": "delete-selected"},
	{"label": "delete", "value": "delete"},
	{"label": "directions", "value": "directions"},
	{"label": "discovery-connection", "value": "discovery-connection"},
	{"label": "discovery-identification", "value": "discovery-identification"},
	{"label": "discovery-pattern", "value": "discovery-pattern"},
	{"label": "discovery-square", "value": "discovery-square"},
	{"label": "discovery-step", "value": "discovery-step"},
	{"label": "document-all-generic", "value": "document-all-generic"},
	{"label": "document-attachment", "value": "document-attachment"},
	{"label": "document-code", "value": "document-code"},
	{"label": "document-doc", "value": "document-doc"},
	{"label": "document-multiple", "value": "document-multiple"},
	{"label": "document-pdf", "value": "document-pdf"},
	{"label": "document-ppt", "value": "document-ppt"},
	{"label": "document-txt", "value": "document-txt"},
	{"label": "document-xls", "value": "document-xls"},
	{"label": "document-zip", "value": "document-zip"},
	{"label": "document", "value": "document"},
	{"label": "double-chevron-left", "value": "double-chevron-left"},
	{"label": "double-chevron-right", "value": "double-chevron-right"},
	{"label": "download-sourcecode", "value": "download-sourcecode"},
	{"label": "download", "value": "download"},
	{"label": "drag-dots", "value": "drag-dots"},
	{"label": "drag", "value": "drag"},
	{"label": "drawer-selected", "value": "drawer-selected"},
	{"label": "drawer", "value": "drawer"},
	{"label": "edit-syntax", "value": "edit-syntax"},
	{"label": "edit", "value": "edit"},
	{"label": "ellipsis-vertical", "value": "ellipsis-vertical"},
	{"label": "ellipsis", "value": "ellipsis"},
	{"label": "empty-circle", "value": "empty-circle"},
	{"label": "endpoint", "value": "endpoint"},
	{"label": "envelope-open", "value": "envelope-open"},
	{"label": "envelope-subscribe", "value": "envelope-subscribe"},
	{"label": "envelope-unsubscribe", "value": "envelope-unsubscribe"},
	{"label": "error-circle", "value": "error-circle"},
	{"label": "error", "value": "error"},
	{"label": "essentials", "value": "essentials"},
	{"label": "export", "value": "export"},
	{"label": "filter", "value": "filter"},
	{"label": "first", "value": "first"},
	{"label": "fit-width", "value": "fit-width"},
	{"label": "floor-plan", "value": "floor-plan"},
	{"label": "folder", "value": "folder"},
	{"label": "form", "value": "form"},
	{"label": "format", "value": "format"},
	{"label": "fullscreen", "value": "fullscreen"},
	{"label": "glasses", "value": "glasses"},
	{"label": "global", "value": "global"},
	{"label": "hardware", "value": "hardware"},
	{"label": "help", "value": "help"},
	{"label": "history", "value": "history"},
	{"label": "home", "value": "home"},
	{"label": "hr", "value": "hr"},
	{"label": "identification", "value": "identification"},
	{"label": "image", "value": "image"},
	{"label": "indent", "value": "indent"},
	{"label": "info", "value": "info"},
	{"label": "insert-table", "value": "insert-table"},
	{"label": "it", "value": "it"},
	{"label": "key", "value": "key"},
	{"label": "keyboard", "value": "keyboard"},
	{"label": "label-dot", "value": "label-dot"},
	{"label": "label", "value": "label"},
	{"label": "last", "value": "last"},
	{"label": "layout", "value": "layout"},
	{"label": "lightbulb", "value": "lightbulb"},
	{"label": "like", "value": "like"},
	{"label": "link", "value": "link"},
	{"label": "list", "value": "list"},
	{"label": "livefeed", "value": "livefeed"},
	{"label": "loading", "value": "loading"},
	{"label": "location", "value": "location"},
	{"label": "locked", "value": "locked"},
	{"label": "loop", "value": "loop"},
	{"label": "mail", "value": "mail"},
	{"label": "marker", "value": "marker"},
	{"label": "maximize", "value": "maximize"},
	{"label": "menu-arrows", "value": "menu-arrows"},
	{"label": "menu", "value": "menu"},
	{"label": "minimize", "value": "minimize"},
	{"label": "mobile", "value": "mobile"},
	{"label": "move", "value": "move"},
	{"label": "my-feed", "value": "my-feed"},
	{"label": "navigator", "value": "navigator"},
	{"label": "new-above", "value": "new-above"},
	{"label": "new-below", "value": "new-below"},
	{"label": "new-ticket", "value": "new-ticket"},
	{"label": "new-window", "value": "new-window"},
	{"label": "not-started-circle", "value": "not-started-circle"},
	{"label": "notification-bell", "value": "notification-bell"},
	{"label": "number", "value": "number"},
	{"label": "open-document-new-tab", "value": "open-document-new-tab"},
	{"label": "or", "value": "or"},
	{"label": "panel-display-bottom", "value": "panel-display-bottom"},
	{"label": "panel-display-popout", "value": "panel-display-popout"},
	{"label": "panel-display-right", "value": "panel-display-right"},
	{"label": "paperclip", "value": "paperclip"},
	{"label": "pause", "value": "pause"},
	{"label": "percent", "value": "percent"},
	{"label": "phone", "value": "phone"},
	{"label": "phonecall-incoming", "value": "phonecall-incoming"},
	{"label": "phonecall-keypad", "value": "phonecall-keypad"},
	{"label": "phonecall-outgoing", "value": "phonecall-outgoing"},
	{"label": "play", "value": "play"},
	{"label": "poll", "value": "poll"},
	{"label": "pop-in", "value": "pop-in"},
	{"label": "pop-out", "value": "pop-out"},
	{"label": "power", "value": "power"},
	{"label": "preview", "value": "preview"},
	{"label": "print", "value": "print"},
	{"label": "queue", "value": "queue"},
	{"label": "radio-numeric-scale", "value": "radio-numeric-scale"},
	{"label": "radio-scale", "value": "radio-scale"},
	{"label": "redo-action", "value": "redo-action"},
	{"label": "refresh", "value": "refresh"},
	{"label": "remove", "value": "remove"},
	{"label": "replace-all", "value": "replace-all"},
	{"label": "replace", "value": "replace"},
	{"label": "required", "value": "required"},
	{"label": "run-command", "value": "run-command"},
	{"label": "save", "value": "save"},
	{"label": "script-check", "value": "script-check"},
	{"label": "script-comment", "value": "script-comment"},
	{"label": "script", "value": "script"},
	{"label": "search-database", "value": "search-database"},
	{"label": "search", "value": "search"},
	{"label": "select", "value": "select"},
	{"label": "server", "value": "server"},
	{"label": "share", "value": "share"},
	{"label": "software", "value": "software"},
	{"label": "sort-ascending", "value": "sort-ascending"},
	{"label": "sort-descending", "value": "sort-descending"},
	{"label": "sp-wishlist-sm", "value": "sp-wishlist-sm"},
	{"label": "sp-wishlist", "value": "sp-wishlist"},
	{"label": "spell-check", "value": "spell-check"},
	{"label": "split-vertical", "value": "split-vertical"},
	{"label": "star-empty", "value": "star-empty"},
	{"label": "star", "value": "star"},
	{"label": "step-in", "value": "step-in"},
	{"label": "step-out", "value": "step-out"},
	{"label": "step-over", "value": "step-over"},
	{"label": "stop-watch", "value": "stop-watch"},
	{"label": "stop", "value": "stop"},
	{"label": "stream-all-input", "value": "stream-all-input"},
	{"label": "stream-one-input", "value": "stream-one-input"},
	{"label": "string", "value": "string"},
	{"label": "sub-elements", "value": "sub-elements"},
	{"label": "subtract", "value": "subtract"},
	{"label": "success-circle", "value": "success-circle"},
	{"label": "success", "value": "success"},
	{"label": "syntax-check", "value": "syntax-check"},
	{"label": "tab", "value": "tab"},
	{"label": "table-sm", "value": "table-sm"},
	{"label": "table", "value": "table"},
	{"label": "tack", "value": "tack"},
	{"label": "target", "value": "target"},
	{"label": "template", "value": "template"},
	{"label": "text-bold", "value": "text-bold"},
	{"label": "text-italic", "value": "text-italic"},
	{"label": "text-style-add", "value": "text-style-add"},
	{"label": "text-style-clear", "value": "text-style-clear"},
	{"label": "text-underlined", "value": "text-underlined"},
	{"label": "text", "value": "text"},
	{"label": "threshold", "value": "threshold"},
	{"label": "timeline", "value": "timeline"},
	{"label": "today", "value": "today"},
	{"label": "translation", "value": "translation"},
	{"label": "trash", "value": "trash"},
	{"label": "tree-right", "value": "tree-right"},
	{"label": "tree", "value": "tree"},
	{"label": "undo-action", "value": "undo-action"},
	{"label": "undo", "value": "undo"},
	{"label": "unindent", "value": "unindent"},
	{"label": "unlink", "value": "unlink"},
	{"label": "unlocked", "value": "unlocked"},
	{"label": "upload", "value": "upload"},
	{"label": "user-add", "value": "user-add"},
	{"label": "user-group", "value": "user-group"},
	{"label": "user-profile", "value": "user-profile"},
	{"label": "user-selected", "value": "user-selected"},
	{"label": "user-subtract", "value": "user-subtract"},
	{"label": "user", "value": "user"},
	{"label": "vcr-down", "value": "vcr-down"},
	{"label": "vcr-left", "value": "vcr-left"},
	{"label": "vcr-right", "value": "vcr-right"},
	{"label": "vcr-up", "value": "vcr-up"},
	{"label": "video", "value": "video"},
	{"label": "view", "value": "view"},
	{"label": "vtb-flexible-outline", "value": "vtb-flexible-outline"},
	{"label": "vtb-flexible-sm", "value": "vtb-flexible-sm"},
	{"label": "vtb-flexible", "value": "vtb-flexible"},
	{"label": "vtb-freeform-sm", "value": "vtb-freeform-sm"},
	{"label": "vtb-freeform", "value": "vtb-freeform"},
	{"label": "vtb-guided-sm", "value": "vtb-guided-sm"},
	{"label": "vtb-guided", "value": "vtb-guided"},
	{"label": "warning-circle", "value": "warning-circle"},
	{"label": "wishlist-sm", "value": "wishlist-sm"},
	{"label": "wishlist", "value": "wishlist"},
	{"label": "work-note", "value": "work-note"},
	{"label": "workflow-active", "value": "workflow-active"},
	{"label": "workflow-approval-action", "value": "workflow-approval-action"},
	{"label": "workflow-approval-rejected", "value": "workflow-approval-rejected"},
	{"label": "workflow-approved", "value": "workflow-approved"},
	{"label": "workflow-check", "value": "workflow-check"},
	{"label": "workflow-complete", "value": "workflow-complete"},
	{"label": "workflow-late", "value": "workflow-late"},
	{"label": "workflow-on-hold", "value": "workflow-on-hold"},
	{"label": "workflow-pending", "value": "workflow-pending"},
	{"label": "workflow-progress", "value": "workflow-progress"},
	{"label": "workflow-rejected", "value": "workflow-rejected"},
	{"label": "workflow-requested", "value": "workflow-requested"},
	{"label": "workflow-skip", "value": "workflow-skip"},
	{"label": "workflow", "value": "workflow"},
	{"label": "zoom-in", "value": "zoom-in"},
	{"label": "zoom-out", "value": "zoom-out"}
];

Once I had my array, it was just a matter of throwing a little HTML together to build the SELECT statement and to loop through the array to create all of the OPTION statements.

<div>
  <select ng-model="data.selected" size="10">
    <option ng-repeat="opt in data.option" class="icon icon-{{opt.value}}" value="{{opt.value}}">   {{opt.label}}</option>
  </select>
</div>

That was enough to display the results, so I built myself a little test page to launch the widget in a modal popup, just to see how it looked.

Modal pop-up of icon SELECT statement

Well, I’m not sure how that blank option got in there in that first position, but other than that, it looks pretty good. Now I just have to figure out how to get the selection back to the main widget once the operator makes their choice. Functionally, there are a couple of different ways to go here: you could have user click on their selection and then click on an OK button to complete the process, or you could go with the assumption that clicking on an option was final and just close out the window right then and there and pass the choice back to the primary screen. Personally, I lean towards the second option; it’s a little rude, but it saves you an unnecessary extra click. From my perspective, when you make a selection, you’re done and we’re out of here.

The first thing to do then, is to add an ng-change to the SELECT statement.

<div>
  <select ng-model="data.selected" ng-change="iconSelected();" size="10">
    <option ng-repeat="opt in data.option" class="icon icon-{{opt.value}}" value="{{opt.value}}">   {{opt.label}}</option>
  </select>
</div>

Then we have to create the referenced function in the Client controller. The behavior that we want is for the pop-up to return the selection to the caller and then go away. I wasn’t exactly sure how to do that, but I did a little research and it turns out that there are a couple of options here as well. You can send in a shared object as one of the widget options when you first open the spModal window, which gives you a place to store things that both the opener and the opened can access, or you can emulate a button click, and pass information back as an argument to the click function. The syntax on that last one seems a little bizarre, but I tried it and it worked, so I decided to go that route. My function turned out to simply be this:

$scope.iconSelected = function() {
	$scope.$parent.$parent.buttonClicked({selected: c.data.selected});
};

Passing the value of the selected option in the buttonClicked function both returns the data to the caller and closes the modal window. Sweet! To open the window and grab the selected value, the code in my little test page ended up looking like this:

spModal.open({
	title: 'Select Icon',
	widget: 'icon-picker',
	buttons: [
		{label: '${Cancel}', cancel: true}
	],
	size: 'sm',
}).then(function(response) {
	alert(JSON.stringify(response));
}, function () {
	alert('Cancelled');
});

You will notice that there are two response functions, one for a normal completion of the task and a second one to handle the Cancel button or the forced closing of the modal pop-up. Using this temporary test page, we can launch the modal and click around and see what happens. If we select an option, we should get an alert that contains the option value selected.

Alert showing the option selected

Well, that all seems to work. In my little test page, all I do is pop the alert to prove that things are working. In actual use, I would update the value of the target field, but at this point, I’m just focused on building the pop-up selector. Building out a function that will actually use this pop-up selector is a project for another day.

Using the GlideExcelParser on a Fetched Remote File

“Do not go where the path may lead, go instead where there is no path and leave a trail.”
Ralph Waldo Emerson

When we hacked the REST Message API to fetch a remote file, all we did with the file was to attach it to an existing Incident. But there are times when what you are really after is the contents of the file, not the file itself. When that file happens to be an Excel spreadsheet, the ServiceNow platform comes shipped with a handy tool called the GlideExcelParser that will allow you to dig into that file and pull out the data. By combining this capability with our earlier work on fetching remote files, you can reach out to a URL, pull in a spreadsheet from another site, and then parse the spreadsheet to extract the data that you need.

One example of where this might be useful would be in importing data records where one or more fields on the record is a URL of an associated spreadsheet containing relevant information such a list of items to be ordered or a list of people for which you would like to place an order. Another example might be a mail-in ticket where the details are not included in the body of the email or attached to the email as a file, but accessible via a URL link found in the message body. Basically, this applies to any scenario where you have the URL of a file, and just want the data contained in the file and not the actual file.

To demo this capability, we just need to find a spreadsheet hosted on the Internet that we can use for an example. Something like this:

https://www.servicenow.com/content/dam/servicenow-assets/public/en-us/doc-type/other-document/csc/servicenow-platform-support-calculator-template.xlsx

This document has three sheets, but for our purposes we can just focus on one of the areas of one of the sheets, just to show how things work. Here is the second sheet, labeled Input:

Example spreadsheet to use for demonstration purposes

For our little demo, we will focus on the grey block on rows 20 through 31 so that we can pull out some data and then use it for some other purpose such as creating an Incident. Once we pull out the data, we will no longer need the actual file, so we can delete the attachment that we created to tidy things up when we are done. Since this is just a demo, we can do the whole thing in a Fix Script that we can discard later once our little proof of concept has been completed.

The first thing that we will need to do is to go get our file. For that, we can leverage our earlier Script Include, but we will need to attach it to something while we work with it. For the purposes of this demonstration, let’s just attach it to the Fix Script itself.

var url = 'https://snhackery.com/update_sets/servicenow-platform-support-calculator-template.xlsx';
var table = 'sys_script_fix';
var sys_id = '0bbdf83e2f21d0104425fcecf699b660';
var fileFetcher = new FileFetchUtils();
var attachment = fileFetcher.fetchFileFromUrlAndAttach(table, sys_id, url);

That was simple enough. Now let’s parse the file.

var stream = new GlideSysAttachment().getContentStream(attachment);
var parser = new sn_impex.GlideExcelParser();
parser.setSheetName('Input');
var row = [];
if (parser.parse(stream)) {
	while (parser.next()) {
		row.push(parser.getRow());
	}
} else {
	gs.info("Error: Unable to parse uploaded file");
}

OK, this one is a little more complicated. First, we create a content stream from the file attachment. Then we create a new parser. Then we tell the parser that we want it to parse the second sheet, the one labeled Input. Then we create an empty array to store the rows, and loop through the parsed stream adding rows to our array one at a time until we run out of rows. Now we have an array of row that that we can use for whatever purpose that we want.

At this point, we can delete the attachment, since we have no further use for it, but let’s save that for a little later and first see what we can do with our row data. To start with, let’s just make sure that we have some data, and if we do, let’s call a function in which we can encapsulate all of the code to utilize the data.

if (row.length) {
	gs.info('Incident created: ' + createIncident(row));
} else {
	gs.info("Error: No data rows in parsed uploaded file");
}

Now it’s time to deal with the data. The parsed rows are values keyed by the column heading. Where there is no column heading, then the key is the Excel column letter. In our little example, we are interested in the data in the second and third columns, and in the first row, there is a value in the second column (Data Input Form), but not in the third. This means that second column values will be obtained with thisRow[‘Data Input Form’] and third column values will be obtained with thisRow[‘C’] (since C is Excel’s name for the third column).

So to rebuild that little grey block of data, we will need the second column value for row #19, the second and third column values for rows 21 through 26, and then the second and third column values for row 28. Now, that’s assuming that no one adds or removes a line or a column from the spreadsheet when filling out the template, which could be dealt with using a lot more defensive coding strategy, but that’s a little beyond the scope of this little exercise here. For our purposes, we are just going to assume that things are going to be right where they are supposed to be. So here is our function.

function createIncident(row) {
	var full_description = 'The following data was extracted from the fetched spreadsheet:\n\n';
	full_description += row[19]['Data Input Form'] + '\n\n';
	for (var i=21; i<27; i++) {
		full_description += '\t' + row[i]['Data Input Form'] + ':\t' + row[i]['C'] + '\n';
	}
	full_description += '\n' + row[28]['Data Input Form'] + '\t' + row[28]['C'];
	var incidentGR = new GlideRecord('incident');
	incidentGR.caller_id = gs.getUserID();
	incidentGR.short_description = 'Excel parsing example';
	incidentGR.description = full_description;
	incidentGR.assignment_group.setDisplayValue('Service Desk');
	incidentGR.insert();
	return incidentGR.getDisplayValue('number');
}

Now that the Incident has been created, we just need to delete the attachment record to clean things up. That’s just a few more lines of code.

var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.get(attachment);
attachmentGR.deleteRecord();

That’s it. All that is left is to save the script and then click on that little Run Fix Script button to see what happens.

*** Script: Incident created: INC0010019

Let’s go check out our new Incident and see how things came out.

Incident generated from data extracted from Excel spreadsheet

Well, it looks pretty close, but we missed a line of data. The System Administrators line didn’t make it over. Looks like our start index should have been 20 and not 21. Oh well, you get the idea. I would go back and fix it, but this is just a little demo to show how things could work together, and I think we have accomplished that. If you want to play around with this, you will be using your own spreadsheet anyway, so you’ll be doing something completely different. But if you want to fix your own copy of this one, please go right ahead!

Hacking the REST Message API to Fetch a Remote File

“The way to get started is to quit talking and begin doing.”
Walt Disney

The other day I needed to fetch a file that was posted on another web site via its URL and process it. I have processed ServiceNow file attachments before, but I have never attempted to go out to the Internet and pull in a file using an HTTP GET of the URL. There are several ways to do that in Javascript such as XMLHttpRequest or fetch, but none of those seemed to work in server-side code in ServiceNow. But you can open up a URL using the ServiceNow RESTMessageV2 API, so I thought that maybe I would give that a shot. How hard could it be?

I decided to encapsulate everything into a Script Include, mainly so that if I ever needed to do this again, I could call the same function in some other context. My thought was to pass in the URL of the file that I wanted to fetch along with the table name and sys_id of the record to which I wanted the file to be attached, and then have the function fetch the file, attach it, and send back the attachment. Something like this:

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

	fetchFileFromUrlAndAttach: function(table, sys_id, url) {
		...
	},

	type: 'FileFetchUtils'
};

That was idea, anyway. Let’s say that I wanted to attach this file, available on the ServiceNow web site, to some Incident:

https://www.servicenow.com/content/dam/servicenow-assets/public/en-us/doc-type/success/playbook/implementation.pdf

The code to do that would look something like this:

var fileFetcher = new FileFetchUtils();
fileFetcher.fetchFileFromUrlAndAttach('incident', incSysId, 'https://www.servicenow.com/content/dam/servicenow-assets/public/en-us/doc-type/success/playbook/implementation.pdf');

All we need to do now is come up with the code needed to do all of the work of fetching the file, attaching it to the specified record, and returning the attachment. To begin, we will need to extract the name of the file from the URL. Assuming that the file name is the last component of the path on the URL, we can do that by splitting the path into its component parts and grabbing the last part.

var parts = url.split('/');
var fileName = parts[parts.length-1];

Next, we will need to create and configure the request object.

var request  = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setEndpoint(url);

The next thing to do would be to execute the request, but before we do that, we can take advantage of a nice built-in feature that will really simplify this whole operation. There is an available function of the RESTMessageV2 API that allows you to declare your intent to turn the retrieved file into an attachment, which will then handle all of the details of doing that on your behalf when the request is executed. You just need to invoke the function before you execute the request.

request.saveResponseBodyAsAttachment(table, sys_id, fileName);        
response = request.execute();

Although that really makes things super simple, it’s still a good idea to check the HTTP Response Code, just to make sure all went well. If not, it’s a good practice to relay that to the user.

if (response.getStatusCode() == '200') {
	...
} else {
	returnValue = 'Error: Invalid HTTP Response: ' + response.getStatusCode();
}

Now, assuming that things actually did go well and our new attachments was created, we still want to send that back to the calling script as a response to this function. The RESTMessageV2 API saveResponseBodyAsAttachment function does not return the attachment that is created, so we will have to use the table and sys_id to hunt it down. And if we cannot find it for any reason, we will want to report that as well.

var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', table);
attachmentGR.addQuery('table_sys_id', sys_id);
attachmentGR.orderByDesc('sys_created_on');
attachmentGR.query();
if (attachmentGR.next()) {
	returnValue = attachmentGR.getUniqueValue();
} else {
	returnValue = 'Error: Unable to fetch attachment';
}

That should now be everything that we need to fetch the file, attach it, and send back the attachment. Putting it all together, the entire Script Include looks like this:

var FileFetchUtils = Class.create();
FileFetchUtils.prototype = {
	initialize: function() {
		this.REST_MESSAGE = '19bb0cde2fedd4101a75ad2ef699b6da';
	},

	fetchFileFromUrlAndAttach: function(table, sys_id, url) {
		var returnVlaue = '';
		var parts = url.split('/');
		var fileName = parts[parts.length-1];
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setEndpoint(url);
		request.saveResponseBodyAsAttachment(table, sys_id, fileName);        
		response = request.execute();
		if (response.getStatusCode() == '200') {
			var attachmentGR = new GlideRecord('sys_attachment');
			attachmentGR.addQuery('table_name', table);
			attachmentGR.addQuery('table_sys_id', sys_id);
			attachmentGR.orderByDesc('sys_created_on');
			attachmentGR.query();
			if (attachmentGR.next()) {
				returnValue = attachmentGR.getUniqueValue();
			} else {
				returnValue = 'Error: Unable to fetch attachment';
			}
		} else {
			returnValue = 'Error: Invalid HTTP Response: ' + response.getStatusCode();
		}
		return returnValue;
	},

	type: 'FileFetchUtils'
};

Now all we need to do is find an Incident to use for testing and use the background scripts feature to give it a try. First, we’ll need to pull up an Incident and then use the Copy sys_id option of the hamburger drop-down menu to snag the sys_id of the Incident.

Grabbing the sys_id of an Incident

Now we can pop over to the background script processor and enter our code, using the sys_id that we pulled from the selected Incident.

Testing using a background script

After running the script, we can return to the Incident to verify that the file from the specified URL is now attached to the Incident.

The selected test Incident with the remote file attached

Just to make sure that all went well, you can click on the download link to pull down the attachment and look it over, verifying that it came across complete and intact. That pretty much demonstrates that it all works as we had intended. If you would like a copy to play around with on your own, you can pick it up here.

Hacking the Scripted REST API, Part II

“If you find a path with no obstacles, it probably doesn’t lead anywhere.”
Frank A. Clark

Last time, we built a Scripted REST API with a single Scripted REST Resource. To finish things up, we just need to add the actual script to the resource. To process our legacy support form, create an Incident, and respond to the user, the script will need to handle the following operations:

  • Obtain the input form field values from the request,
  • Format the data for use in creating the Incident,
  • Locate the user based on the email address, if one exists,
  • Create the Incident, and
  • Return the response page.

Obtain the input form field values from the request

This turned out to be much simpler than I first made it out to be. I knew that the out-of-the-box Scripted REST API was set up to handle JSON, both coming in and going out, and could also support XML, but I never saw anything regarding URL encoded form fields, so I assumed I would have to parse and unencode the request body myself. The problem that I was having, though, was getting the actual request body. I tried request.body, request.body.data, request.body.dataString, and even request.body.dataStream — nothing produced anything but null or errors. Then I read somewhere that that the Scripted REST API treats form fields as if they were URL query string parameters, and lumps them all together in a single map: request.queryParams. Once I learned that little tidbit of useful information, the rest was easy.

// get form data from POST
var formData = request.queryParams;
var name = formData.name[0];
var email = formData.email[0];
var short_description = formData.title[0];
var description = formData.description[0];

It did take me a bit to figure out that the values returned from the map are arrays and not strings, but once that became clear in my testing, I just added an index to the form field name and everything worked beautifully.

Format the data for use in creating the Incident

This was just a matter of formatting a few of the incoming data values with labels so that anything that did not have a field of its own on the Incident could be included in the Incident description.

// format the data
var full_description = 'The following issue was reported via the Legacy Support Form:\n\n';
full_description += 'Name: ' + name + '\n';
full_description += 'Email: ' + email + '\n\n';
full_description += 'Details:\n' + description;

Locate the user based on the email address

This doesn’t really have much to do with hacking the Scripted REST API, but I threw it in just as an example of the kind of thing that you can do once you have some data with which to work. In this case, we are simply using the email address that was entered on the form to search the User table to see if we have a user with that email. If we do, then we can use as the Caller on the Incident.

// see if we have a user on file with that email address
var contact = null;
var userGR = new GlideRecord('sys_user');
if (userGR.get('email', email)) {
	contact = userGR.getUniqueValue();
}

Create the Incident

This part is pretty vanilla as well.

// create incident
var incidentGR = new GlideRecord('incident');
if (contact) {
	incidentGR.caller_id = contact;
} else {
	incidentGR.caller_id.setDisplayValue('Guest');
}
incidentGR.contact_type = 'self-service';
incidentGR.short_description = short_description;
incidentGR.description = full_description;
incidentGR.assignment_group.setDisplayValue('Service Desk');
incidentGR.insert();
var incidentId = incidentGR.getDisplayValue('number');

The last line grabs the Incident number from the inserted Incident so that we can send that back to the user on the response page.

Return the response page

Now that we have complete all of the work, the last thing left to do is to respond to the user. Again, since we are not using the native JSON or XML formats, we are going to have to do some of the work a little differently than the standard Scripted REST API. Here is the working code:

// send response page
response.setContentType('text/html');
response.setStatus(200);
var writer = response.getStreamWriter();
writer.writeString(getResponsePageHTML(incidentId));

The first thing that you have to know is that you must set the content type and status before you get the stream writer from the response. If you don’t do that first, then things will not work. And even though you clicked the override checkbox and specified text/html as the format in the Scripted REST API definition, you still have to set it here as well. But once you do all of that, and do it in the right sequence, it all works great.

The response string itself is just the text of a standard HTML page. I encapsulated my sample page into a function so that it could be easily replaced with something else without disturbing any of the rest of the code. The sample version that I put together looks like this:

function getResponsePageHTML(incidentId) {
	var html = '';

	html += '<html>';
	html += ' <head>';
	html += '  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">';
	html += '  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>';
	html += ' </head>';
	html += ' <body>';
	html += '  <div style="padding: 25px;">';
	html += '   <h2>Thank you for your input</h2>';
	html += '   <p>We appreciate your business and value your feedback. Your satisfaction is of the utmost concern to us.</p>';
	html += '   <p>Your issue has been documented and one of our marginally competent technicians will get back to you within a few months to explain to you what you have been doing wrong.</p>';
	html += '   <p>Your incident number is ' + incidentId + '.</p>';
	html += '  </div>';
	html += ' </body>';
	html += '</html>';

	return html;
}

That’s it for the coding in ServiceNow. To see how it all works, we just need to point our old form to our new form processor after which we can pull up the modified form in a browser and give it a try. To repoint the legacy form, pull it up in a text editor, find the form tag, and enter the path to our Scripted REST API in the action attribute:

<form action="https://<instance>.service-now.com/api/<scope>/legacy/support" method="post">

With that little bit of business handled, we can pull up the form, fill it out, click on the submit button, and if all goes well, be rewarded for our efforts with our HTML response, including the number of the newly created Incident:

HTML response from the Scripted REST API form processor

Just to be sure, let’s pop over to our instance and check out the Incident that we should have created.

Incident created from submitting the legacy form

Well, that’s all there is to that. We built a Scripted REST API that accepted standard form fields as input and responded with a standard HTML web page, and then pointed an old form at it to produce an Incident and a thank you page. All in all, not a bad little perversion of the REST API feature of the tool!

Hacking the Scripted REST API to process a form

“Try something new each day. After all, we’re given life to find it out. It doesn’t last forever.”
Ruth Gordon

One of our older systems includes a form through which you could report issues, and when you filled out the form and submitted it, it would send an e-mail to the support team. Pretty cool stuff back in the day, but the procedure bypasses the Service Desk and hides the activity from our support statistics because no one ever opens up an Incident. They just quietly resolve the issue and move on without a trace. The people who like to have visibility into those kinds of activities are not really too keen on these little side deals that allow certain groups to fly below the radar. So the question arose as to whether or not we could keep the form, with which everyone was comfortable and familiar, but have it create an Incident rather than send an e-mail.

Well, the first thing that came to mind was to just send the existing e-mail to the production instance and then set up an inbound mail processor to turn the e-mail into an Incident. The problem with that approach, though, was the the Incident was created off-line, and by that time, you had no way to inform the user that the Incident was successfully created or to give them the ID or some kind of handle to pull it up and check on the progress. What would really be nice would be to be able to simply POST the form to ServiceNow and have it respond back with an HTML thank you page. ServiceNow is not really set up to be a third-party site forms processor, though, so that really didn’t seem to be a feasible concept.

But, then again …

ServiceNow does have the Scripted REST API, but that is built for Web Services, not user interaction. Still, with a little creative tweaking maybe we could actually fool it into taking a form POST and responding with an HTML page. That would actually be interesting. And as it turns out, it wasn’t all that hard to do.

To make our example relatively simple and easy to follow, let’s just build a nice clean HTML page that contains nothing but our example legacy form:

Simple stand-alone input form for demonstration purposes

This clears out all of the window dressing, headers, footers, and other clutter and just gets down to the form itself. None of that other stuff has any relevance to what we are trying to do here, so we just want a simple clean slate without all of the distractions. Here is the entire HTML code for the page:

<html>
 <head>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
 </head>
 <body>
  <form action="/some/legacy/form/handler" method="post">
   <div style="padding: 25px;">
    <h2>Legacy Support Form</h2>
    <div style="margin-top: 10px;">
     <label for="name">Please enter your name:</label>
    </div>
    <div>
     <input name="name" size="64"/>
    <div style="margin-top: 10px;">
     <label for="email">Please enter your email address:</label>
    </div>
    <div>
     <input name="email" size="64"/>
    </div>
    <div style="margin-top: 10px;">
     <label for="title">Please enter a brief statement describing the issue:</label>
    </div>
    <div>
     <input name="title" size="64"/>
    </div>
    <div style="margin-top: 10px;">
     <label for="description">Please describe the problem in detail:</label>
    </div>
    <div>
     <textarea name="description" cols="62" rows="5"></textarea>
    </div>
    <div style="margin-top: 20px;">
     <input type="submit" value="Submit Problem Report"/>
    </div>
   </div>
  <form>
 </body>
</html>

The idea here is to now take the existing form handler, as specified in the action attribute of the form tag, and replace it with a URL for a ServiceNow “web service” that we will create using the Scripted REST API tools. That is the only change that we want to make on this existing page. Everything else should look and behave exactly as it did before; we are just pointing the form to a new target for processing the user input. So let’s build that target.

To begin, select the Scripted REST APIs option on the left-hand sidebar menu, which will bring up the list of all of the existing Scripted REST APIs. From there, click on the New button, which will take you to a blank form on which you can start entering the details about your new Scripted REST API.

Initial Scripted REST API data entry form

Enter the name of your new Scripted REST API along with the API ID, which will become a component in the eventual URL that will take you to your new web service. Once you have saved these initial values, you will be returned to the list, and your newly created API will appear on the list. Select it, and you will be taken to an expanded form where you can now enter the rest of the information.

Full Scripted REST API data entry form

Here is one of the secrets to our little hack: you need to check both of the override boxes so that you can change the MIME type for the data that will be flowing in both directions. For the data that is coming in, you will want to enter x-www-form-urlencoded. These are are the form fields coming in from the input form. For the data going out, you will want to enter text/html. This is the response page that will go back to the browser and be rendered by the browser for display to the user. Form fields come into our new service and HTML comes back out.

Once you have saved your overrides, you can create your Resource. A Scripted REST API can have one or more Resources, and they appear on the form down at the bottom as a Related List. Open up the Resources tab and click on the New button to create your Resource. This brings up the Resource form.

Scripted REST Resource form

On this form, you want to enter the name of your Resource, the HTTP method (POST), and the relative path, which is another component of the URL for this service. Once you save that, all that is left is my favorite part, the coding. In fact, this is probably a good place to stop, as we are done with all of the forms and form fields. Next time out, we can focus exclusively on the code.

Dynamic Service Portal Breadcrumbs, Perfected

“Perfection is finally attained not when there is no longer anything to add but when there is no longer anything to take away, when a body has been stripped down to its nakedness.”
Antoine de Saint-Exupéry

Years ago, I was taught that there was three kinds of software maintenance, Corrective, Adaptive, and Perfective. There are those that will tell you that there is a fourth kind, called Preventive or Preventative depending on where and when you learned the English language, but in my mind, that doesn’t really apply to software. Preventative maintenance is done to physical assets like a vehicle or an elevator or a machine on the factory floor. The idea behind preventative maintenance is to lubricate or replace parts before they wear out to prevent a breakdown. Preventative maintenance is usually performed on a schedule based on the anticipated lifespan of the parts in question. Having your car serviced by the dealer periodically in accordance with the schedule laid out in the owner’s manual is a form of preventative maintenance. Software, though, is not constructed out of physical parts that wear out, so I can’t imagine what task that you could perform on a piece of software that you could consider preventive. But, I digress …

Today, we’re going to talk about some Perfective Maintenance done on my old friend, the Dynamic Service Portal Breadcrumbs widget. I didn’t want to change its function or features — that wouldn’t be maintenance at all; that would be an Enhancement — and I wasn’t trying to fix anything (Corrective) and I wasn’t trying to conform to a new browser or a new version of ServiceNow (Adaptive). I just wanted to make it better, which is the essence of Perfective Maintenance.

But how could it possibly be any better, you ask? While that is definitely a reasonable question, there is always room for improvement on just about everything. One thing that has always annoyed me was the presence of the portal title in breadcrumbs when just the page title would do. You can see an example of that on this image from an earlier installment on this component:

Breadcrumbs with Page Title/Portal Title as Label

I never liked that. It was redundant, and it made individual elements of the breadcrumbs much wider than they needed to be. So I added this code to search for that and remove it:

var thisTitle = c.data.page || document.title;
var portalSuffix = ' - ' + $rootScope.portal.title.trim();
var cutoff = thisTitle.indexOf(portalSuffix);
if (cutoff != -1) {
	thisTitle = thisTitle.substring(0, cutoff);
}
var thisPage = {url: $location.url(), id: $location.search()['id'], label: thisTitle};

That should work for as long as ServiceNow tacks the portal title on to the end of the page title using a dash surrounded by single spaces as a separator. If that convention ever changes, then this will need to be revisited and adjusted accordingly.

One other thing that always disturbed me was that the breadcrumb trail lasted forever, and if you were off of the site for a few days and then came back, your breadcrumbs from your last session would still be active. It seemed to me that, once you have been gone for some time, your breadcrumbs should start fresh back at the home page again. To make that happen, I added one more User Preference for the session ID, and only used the breadcrumb trail if it came from the same session. That code lives on the server side, and looks like this:

data.breadcrumbs = [];
var snhbc = gs.getPreference('snhbc');
var snhses = gs.getPreference('snhses');
if (snhbc && snhses && snhses == thisSession) {
	data.breadcrumbs = JSON.parse(snhbc);
}

One other thing I did was to look at the generic display value for the current record before searching for specific fields. Many tables already have a display field selected as part of the table definition, so using the getDisplayValue() method without passing any arguments can save all of that hunting around for the right field to use. If there is no defined display field, though, it will return the creation date with a label of ‘Created’, which we don’t want, so we have to check for that as well. That new code now looks like this:

data.page = rec.getDisplayValue();
if (data.page.indexOf('Created') == 0) {
	data.page = null;
}
if (!data.page) {
	data.page = rec.getDisplayValue('number');
}
if (!data.page) {
	data.page = rec.getDisplayValue('name');
}
if (!data.page) {
	data.page = rec.getDisplayValue('short_description');
}
if (!data.page) {
	data.page = rec.getLabel();
}

So, just a few little tweaks here and there to spruce things up a bit. No features added, no features taken away, and no defects repaired. Just a little tidying up to make it even better than it was before. For those of you who like to play along at home, here is the most recent Update Set.

Update: There is an even better version, which you can find here.

@mentions in the Ticket Conversations Widget

“Talk is cheap. Show me the code.”
Linus Torvalds

When I first started playing around with @mentions in the Service Portal, I mainly just wanted to see if I could get it to work. Once I was able to figure all of that out, I wanted to work that feature into my form field tag, so I spent a little time working all of that out. But I never returned to the real reason that I want to investigate this capability in the first place, which was to add the @mention feature to the Ticket Conversations widget in the Service Portal. So, let’s do that now.

Now that I know a little bit more about what it takes to do what I want to do, I am going to attempt to be a bit surgical in my interactions with the existing code and try to disturb the existing artifact as little as possible and still accomplish my goal. I still don’t want to alter the original, though, so the first thing that I did was to clone the existing Ticket Conversations widget and create one of my own called Mention Conversations. That gave me my initial canvas on which to work, and left the original widget intact and unmolested. So let’s work our way through the changes from top to bottom.

First, we’ll tackle the Body HTML Template, which contains all of the HTML. The only thing that we really want to change here is the input element for the journal entry (comments). Even though it is only a single line, they still used a TEXTAREA, and we’ll leave that alone, other than to add a few new attributes. Here are the original attributes out of the box:

<textarea
  ng-keypress="keyPress($event)"
  sn-resize-height="trim"
  rows="1"
  id="post-input"
  class="form-control no-resize overflow-hidden"
  ng-model='data.journalEntry'
  ng-model-options='{debounce: 250}'
  ng-attr-placeholder="{{getPlaceholder()}}"
  aria-label="{{getPlaceholder()}}"
  autocomplete="off"
  ng-change="userTyping(data.journalEntry)"/>

To that we will add all of the ment-io attributes needed to support the @mention feature:

<textarea
  ng-keypress="keyPress($event)"
  sn-resize-height="trim"
  rows="1"
  id="post-input"
  class="form-control no-resize overflow-hidden"
  ng-model='data.journalEntry'
  ng-model-options='{debounce: 250}'
  ng-attr-placeholder="{{getPlaceholder()}}"
  aria-label="{{getPlaceholder()}}"
  autocomplete="off"
  ng-change="userTyping(data.journalEntry)"
  mentio=""
  mentio-macros="macros"
  mentio-trigger-char="'@'"
  mentio-items="members"
  mentio-search="searchMembersAsync(term)"
  mentio-template-url="/at-mentions.tpl"
  mentio-select="selectAtMention(item)"
  mentio-typed-term="typedTerm"
  mentio-id="'post-input'"/>

Although that is the only change that we will need to make to the existing HTML code, we also need to provide the template referenced in the mentio-template-url attribute. To include that, we will drop this guy down at the bottom of the existing HTML, just inside the outer, enclosing DIV:

<script type="text/ng-template" id="/at-mentions.tpl">
    <div class="dropdown-menu sn-widget sn-mention">
        <ul class="sn-widget-list_v2">
            <li ng-if="items.length > 0 && !items[0].termLengthIsZero" mentio-menu-item="person" ng-repeat="person in items">
                <div class="sn-widget-list-content sn-widget-list-content_static">
                    <sn-avatar primary="person" class="avatar-small" show-presence="true"></sn-avatar></div>
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title" ng-bind-html="person.name"></span>
                    <span class="sn-widget-list-subtitle" ng-if="!person.record_is_visible">Cannot see record</span></div></li>
            <li ng-if="items.length === 1 && items[0].termLengthIsZero">
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title sn-widget-list-title_wrap">Enter the name of a person you want to mention</span></div></li>
            <li ng-if="items.length === 0 && items.loading && visible">
                <div class="sn-widget-list-content sn-widget-list-content_static">
                    <span class="sn-widget-list-icon icon-loading"></span></div>
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title">Loading...</span></div></li>
            <li ng-if="items.length === 0 && !items.loading">
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title">No users found</span></div></li></ul>
    </div>
</script>

That takes care of the HTML, so let’s move on to the CSS. All we need to do there is to include one very important additional CCS file, so we add this line right at the top of that section:

@import url("/css_includes_ng.cssx");

That’s it for the CSS section. The next section is the Server Script, but we don’t need to alter that at all, which is cool, so we will just leave that alone and move on to the Client Controller. Down at the very bottom, we will add three new functions:

$scope.searchMembersAsync = function(term) {
	$scope.userSysId = window.NOW.user_id;
	$scope.members = [];
	$scope.members.loading = true;
	clearTimeout($scope.typingTimer);
	if (term.length === 0) {
		$scope.members = [{
			termLengthIsZero: true
		}];
		$scope.members.loading = false;
	} else {
		$scope.typingTimer = setTimeout(function() {
			snMention.retrieveMembers('sys_id', $scope.userSysId, term).then(function(members) {
				$scope.members = members;
				$scope.members.loading = false;
			}, function () {
				$scope.members = [{
					termLengthIsZero: true
				}];
				$scope.members.loading = false;
			});
		}, 500);
	}
};

$scope.selectAtMention = function(item) {
	if (!$scope.mentionMap) {
		$scope.mentionMap = {};
	}
	if (item.termLengthIsZero) {
		return (item.name || "") + "\n";
	}
	$scope.mentionMap[item.name] = item.sys_id;
	return "@[" + item.name + "]";
};

function expandMentions(entryText) {
	return entryText.replace(/@\[(.+?)\]/gi, function (mention) {
		var response = mention;
		var mentionedName = mention.substring(2, mention.length - 1);
		if ($scope.mentionMap[mentionedName]) {
			response = "@[" + $scope.mentionMap[mentionedName] + ":" + mentionedName + "]";
		}
		return response;
	});
}

The first two are referenced in the ment-io attributes that we added to the TEXTAREA in the HTML template. The last one is used just before saving the comment, so we have to hunt down the line that sends that data back to server for posting to the record, and insert a call to that function. That logic is in the existing post function, and out of the box looks like this:

input = input.trim();
$scope.data.journalEntry = input;

We’ll tweak that just a bit to expand the mentions before sending the comments off to the server to be written to the database:

input = expandMentions(input.trim());
$scope.data.journalEntry = input;

One last thing that we will need to do with the client side code is to add the snMention object to the long list of arguments passed into the controller function. This is necessary because that object is referenced by our new searchMembersAsync function. That should wrap things up one the client side, though, which is the last thing that we need to change, so all that is left to do now is to drop this baby onto the page, fire it up, and give it a spin.

First, we need to find an Incident on the Service Portal to bring up on the ticket page, and then we start typing with an @ sign to activate the pop-up pick list from which you can select a person. So far, so good:

Selecting a person to @mention in the comment

Selecting the person puts square brackets around the person’s name while in the input element, which you can see before hitting Send.

@mentions in the input element before saving

Clicking on the Send button triggers the expandMentions function that we added to the controller, which then adds the sys_id of the User inside those square brackets, all of which gets sent over to the server side to be written to the database. A lot of things happen after that which are not a part of this widget, but when all is said and done, the comment comes back out as part of the time line, and both the sys_id and square brackets are long gone at this point.

New comment added to the timeline

In addition to the removal of the square brackets and sys_id, one other thing that happens when you add an @mention to a comment is that the person mentioned gets notified. If you look in the system email logs, you can find a record of this notification, which comes out like this with an out-of-the-box template:

Standard notice to those @mentioned

The cool thing about that was that we didn’t have to add any additional code or do anything special to make that happen — we just had to pass the @mention details in the proper format and things took care of themselves from there. Pretty slick.

Well, that’s about all there is to that. If you want all the parts and pieces needed to make this work, here is an Update Set. I tried my best to have a fairly light touch as far as the existing code goes, but if you have any ideas on how to make it even better, I would love to hear about them.

Formatted Script Search Results, Corrected (again!)

“Creativity is allowing yourself to make mistakes. Art is knowing which ones to keep.”
Scott Adams

In my haste to release my enhanced search tool that I tweaked to search both Script and HTML, I neglected to hunt down all of the places where I used the word Script and fix it up so that it would only say Script when we were searching Script and say HTML when we were searching HTML. I was aware that I had missed that on the button at the time that I published the Update Set, as you could easily see the problem right there on the image that I posted.

The title says HTML, but the button still says Search Scripts

But it turns out that I also had the same problem with the message that comes out if you don’t find anything, and with the help text above the entry field (although, that one I did try to make generic so that it would apply to both). I hate to release a new version just for a couple of bad labels, but it bugs me that it isn’t right, so I wanted to tidy that up and make it right.

To fix it, I swapped my title variable with a more generic label variable and then used that label to build the page title, the button text, and a number of different message. Now everything said Script when we were dealing with script and HTML when we were dealing with HTML. Still, that little change was hardly worth a new version, so I decided to add a couple of features that I thought were missing earlier. I added a count of records above the search results table, and I also sorted the table, since it seemed to be coming out in a quite random fashion. Still minor improvements, but now we had more of a combo Correction/Enhancement release that both fixed a couple of issues and also added a couple of new features.

Search results with consistent labels, row counter, and sorted output

While testing all of that out, I actually uncovered a couple of other minor issues as well, which I also corrected, so this one is actually much improved over the last. I won’t waste space here going into all of the details, but here is the new Update Set, and if you are really interested, you can just do a compare against the last one.

Formatted Script Search Results, Enhanced

“Delivering good software today is often better than perfect software tomorrow, so finish things and ship.”
— David Thomas, The Pragmatic Programmer: From Journeyman to Master

It always seems to happen. The more I play around with some little bauble that I’ve thrown together, the more ideas that pop into my head to make it just a little bit better. I’ve actually found my script search tool to be quite useful now that I have finally gotten it to behave the way in which I would expect it to behave. But the more that I use it, the more that I find that I want add to it.

One thing that I realized after I released the code was that you can also find script in Condition String fields. This blows my whole theory that all column types that contain script have the word script in their name. Still, I can tweak my encoded query just a bit to ensure that these columns are searched as well. I just need to switch from this:

internal_typeCONTAINSscript^active=true^name!=sn_templated_snip_note_template

… to this:

active=true^name!=sn_templated_snip_note_template^internal_typeCONTAINSscript^ORinternal_type=condition_string

Another thing that crossed my mind while I was searching for something the other day was that, in addition to scripts, I would really like to have this same capability for searching HTML. At the time, it seemed like it wouldn’t be too difficult to just clone all of the parts and convert them to a second set that was dedicated to HTML instead of Javascript. When I took a look at doing that, though, I realized that with just little bit of extra code, I could make the parts that I had work for both, and not have to create an entirely new second set.

The first thing that I tackled was my Script Include, ScriptUtils, To start out, I renamed it to SearchUtils, since it was now going to handle both searching all of the script columns and searching all of the HTML columns. Then I added a second argument to the function call called type, so that I could differentiate between script search requests and HTML search requests. The only real difference in the code between searching for script and searching for HTML is which columns on which tables will be queried, which means that the only real difference between the two operations is the query used to find the columns and tables. In the current version, that query was hard-coded, so I switched that to a variable and then set the value of the variable to the new version of my encoded query for scripts, and then overrode that value with a new query for HTML if the incoming type argument was set to ‘html’.

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

	findInScript: function(string, type) {
		var table = {};
		var found = [];
		var encodedQuery = 'active=true^name!=sn_templated_snip_note_template^internal_typeCONTAINSscript^ORinternal_type=condition_string';
		if (type == 'html') {
			encodedQuery = 'active=true^name!=sn_templated_snip_note_template^internal_typeCONTAINShtml';
		}
		var columnGR = new GlideRecord('sys_dictionary');
		columnGR.addEncodedQuery(encodedQuery);
...

Beyond that point, everything else remains the same as it was in the earlier version. That takes care of the Script Include. Now we have to tackle the associated Widget. Here we can use the same basic philosophy, defaulting everything to a script search, and then overriding that default if an HTML search is requested. For a Widget, that request can come in the form of an additional URL parameter that we can also call type. We can establish the default values based on a script search, and then if the URL parameter type is equal to ‘html’, we can then change to the values for an HTML search.

(function() {
	data.type = 'script';
	data.title = 'Script Search';
	if ($sp.getParameter('type') && $sp.getParameter('type') == 'html') {
		data.type = 'html';
		data.title = 'HTML Search';
	}
	data.loading = false;
	data.searchFor = '';
	data.result = false;
	if ($sp.getParameter('search')) {
		data.loading = true;
		data.searchFor = $sp.getParameter('search');
		data.result = new SearchUtils().findInScript(data.searchFor, data.type);
		data.loading = false;
	}
})();

That was all there was to adding an HTML version of the script searching capability. To get to it, I pulled my Search Script menu item up in edit mode by clicking on the little pencil to the right of the title, made a few minor modifications and then selected Insert from the context menu to create a new menu item.

Cloning the Search Script menu item to create the Search HTML menu item

With the new menu item added to the sidebar, the only thing left to do was to click on the item and give it a go.

New HTML Search function in action

Well, that didn’t turn out too bad. I should have changed the name of the button to something that didn’t have the word script in it, but other than that one little nit, it all seems to work. I’ll fix up that button one day, but for now, here is an Update Set that includes all of the parts for the current version.

My Delegates Widget, Enhanced

“No matter how good you get you can always get better, and that’s the exciting part.”
Tiger Woods

The other day I was out on the ServiceNow Developers site and noticed that this project was highlighted on the home page of the Share site as an Editor’s Choice.

ServicePortal delegate widget

This project is quite interesting to me because it is basically an alternative approach to something that I had attempted to accomplish with the My Delegates Widget a while back. I’m always curious to see how other folks address things, so I downloaded it and checked it out. As you would assume from the 5 star rating, it’s pretty nice. In fact, it has a feature that I had not even considered back when I had put mine together: in addition to being able to manage your own delegates, you can also see who has delegated authority to you. I like that. In fact, I liked it so much that I decided to see if I could add that feature to my own slant on building this widget.

The first thing I did was to dust off my old widget, bring it up, and refresh my memory as to how it was that I put it together. This one has been out there for a while, so in addition to adding this new feature, I’m also going to have to clean it up a bit. For one thing, this was built before I read in one our HealthScans that using the name gr for variables that represent a GlideRecord is considered bad form. I think I originally copied that technique from some internal ServiceNow code, so I thought it was a little disingenuous for them to be complaining about that at the time. Still, I’ve stopped doing that ever since, and I’ve been fixing it whenever I come across it in my older stuff.

All that aside, it was a pretty simple addition. I copied the block of code that generated the list of delegates (once I cleaned it up a bit) to make a second one to build a list of the delegations of others. The modification to the query was basically to switch from searching for delegates where user is the current user to searching for users where the delegate is the current user. Also, since this data is not editable (delegation is controlled by the person doing the delegating), I decided to format it as plain English rather than put it in a data table. The final product turned out like this:

function fetchList2() {
	var list = [];
	var today = new Date();
	var delegationGR = new GlideRecord('sys_user_delegate');
	delegationGR.addQuery('delegate', data.userID);
	delegationGR.orderBy('user.name');
	delegationGR.query();
	while (delegationGR.next()) {
		var stillActive = true;
		var endDate = '';
		if (delegationGR.getValue('ends')) {
			endDate = new GlideDate();
			endDate.setValue(delegationGR.getValue('ends'));
			endDate = endDate.getByFormat('M/d/yyyy');
			if (today.after(new Date(endDate))) {
				stillActive = false;
			} else {
				if (new Date(endDate).getFullYear() == 2100) {
					endDate = '';
				}
			}
		}
		if (stillActive) {
			var thisDelegation = {};
			var delegations = [];
			if (delegationGR.getValue('approvals') == 1) {
				delegations.push('Approvals');
			}
			if (delegationGR.getValue('assignments') == 1) {
				delegations.push('Assignments');
			}
			if (delegationGR.getValue('notifications') == 1) {
				delegations.push('CC on Notifications');
			}
			if (delegationGR.getValue('invitations') == 1) {
				delegations.push('Meeting Invitations');
			}
			if (delegations.length > 0) {
				thisDelegation.sys_id = delegationGR.getValue('sys_id');
				thisDelegation.id = delegationGR.getValue('user');
				thisDelegation.user = delegationGR.getDisplayValue('user');
				thisDelegation.ends = endDate;
				thisDelegation.delegations = '';
				var separator = '';
				for (var i=0; i<delegations.length; i++) {
					thisDelegation.delegations += separator;
					thisDelegation.delegations += delegations[i];
					if (delegations.length > 2) {
						separator = ', ';
						if (i == (delegations.length - 2)) {
							separator = ', and ';
						}
					} else {
						separator = ' and ';
					}
				}
				list.push(thisDelegation);
			}
		}
	}
	return list;
}

I also built this widget before I created my Dynamic Service Portal Breadcrumbs widget, so at the time I was always hesitant to leave the page without having a way back. I added that widget to the top of my test page, and then I converted all of the user names on the page to links out to the User Profile page for that person, which adds another nice little feature. The final HTML that formatted the delegations turned out like this:

  <div style="width: 100%; padding: 5px 50px;" ng-show="data.list2Items.length>0">
    <b>I am a Delegate for:</b>
    <div ng-repeat="item in c.data.list2Items track by item.id | orderBy: 'user'" style="padding: 5px;">
      <sn-avatar class="avatar-small-medium" primary="item.id" show-presence="true"/>
       
      <a href="?id=user_profile&table=sys_user&sys_id={{item.id}}" title="{{item.user}}">{{item.user}}</a>
      for {{item.delegations}}
      <span ng-show="item.ends"> until {{item.ends}}</span>
    </div>

All in all, putting this together was a fairly straightforward operation that added a nice new feature to the widget, and gave me a chance to do a little tidying up of some older code. Here’s what it ended up looking like in practice:

My Delegates widget with the addition of delegations of others

Much thanks to Fredrik Larsson for the work he did on his version of a Service Portal delegate widget, and for giving me the idea to make my own a little better. For those of you who like to play along at home, here is an Update Set with all of the latest parts and pieces.