Dynamic Service Portal Breadcrumbs, Enhanced, Part II

“There are three kinds of men. The one that learns by reading. The few who learn by observation. The rest of them have to pee on the electric fence for themselves.”
Will Rogers

I meant to do this earlier, but I got a little sidetracked on a completely different issue. Now that that little adventure is behind us, we can circle back to my recent changes to the Dynamic Service Portal Breadcrumbs and test everything out to see if it actually works. For that, I need to build a simple Portal Page, drop the breadcrumbs widget up at the top of the page, and then add a new widget that can listen for the messages coming out of the breadcrumbs widget.

So, let’s start with the new widget. This is just for testing, so we don’t need to get too fancy. Just a couple of test buttons, one to call for the return path and the other a back button that will use the return path. Here is some HTML that should work:

<div class="panel">
  <div class="row" style="text-align: center; padding: 25px;">
    <button class="btn btn-default" ng-click="testFunction();">Test Button</button>
  </div>
  <div class="row" style="text-align: center; padding: 25px;">
    <button class="btn btn-primary" ng-click="goBack();">Back Button</button>
  </div>
</div>

Each button has a function so will need to code those out in the Client Script to handle the clicks:

$scope.testFunction = function() {
	$rootScope.$broadcast('snhShareReturnPath');
};

$scope.goBack = function() {
	if (!c.data.returnPath) {
		$rootScope.$broadcast('snhShareReturnPath');
	}
	while (!c.data.returnPath) {
		pointlessCounter++;
	}
	window.location.href = c.data.returnPath;
};

The first one just broadcasts the message that we missed the return path, so please send it over again. The second one, which is an actual back button, looks to see if we have already obtained the return path, and if not, makes the same call to request it, and the drops into a loop until it shows up. I threw a counter in there just for something to do inside of the loop, but if I display that out, I can also see how long it takes to hear back from the request to rebroadcast the return path. Speaking of receiving the return path, we will need to add a little code to listen for that as well:

$rootScope.$on('snhBreadcrumbs', function(evt, info) {
	c.data.returnPath = info.returnPath;
	alert(pointlessCounter + '; ' + c.data.returnPath);
});

Wrapping all of that together with a little initialization code give us a complete Client Script for our new widget:

function TestWidget($scope, $rootScope) {
	var c = this;
	var pointlessCounter = 0;

	$scope.testFunction = function() {
		$rootScope.$broadcast('snhShareReturnPath');
	};

	$scope.goBack = function() {
		if (!c.data.returnPath) {
			$rootScope.$broadcast('snhShareReturnPath');
		}
		while (!c.data.returnPath) {
			pointlessCounter++;
		}
		window.location.href = c.data.returnPath;
	};

	$rootScope.$on('snhBreadcrumbs', function(evt, info) {
		c.data.returnPath = info.returnPath;
		alert(pointlessCounter + '; ' + c.data.returnPath);
	});
}

There is no server side script needed, so that completes our tester. Now we just have to throw it on a page and see what happens.

Test Page with the breadcrumbs and test widgets

Well, there is our test page containing the two widgets. There is no alert, though, which means that no one was listening when the breadcrumbs widget announced the return path, or something is broken. Let’s try the Test Button to see if we can get the breadcrumbs widget to announce it again.

Alert indicating the test widget has received the broadcast message

OK, that worked. Now, let’s try the back button.

The back button takes you back the previous screen in the breadcrumbs list

.. and that takes us back to the Incident that we were just looking at before we brought up the test page. Nice!

So far, so good. I tried a few more things, like clicking on the back button without first clicking on the test button and clicking on the test button multiple times. Everything seems to work the way in which I had envisioned it. I like it. As far as I can tell, it is good enough for me to put out another Update Set. Although I did get a little sidetracked on this one, it was a quick diversion and I did manage to circle back around and finish it up.

Update: There is a better (corrected) version here.

Dynamic Service Portal Breadcrumbs, Enhanced

“Build your own dreams, or someone else will hire you to build theirs.”
Farrah Gray

While I was playing around with my Configuration Item Icon Assignment widget, it occurred to me that it would be beneficial for my Dynamic Service Portal Breadcrumbs widget to somehow announce its presence to other widgets on the same page, and to provide them with the URL of previous page in case they wanted to set up some kind of Cancel or Done button that returned the user to the page from which they came. I even thought about putting such a button on the breadcrumbs widget itself, just under the row of breadcrumbs, but that seemed a little out of place, the more that I thought about it. So right now, I just want to let the other widgets know that they are sharing the page with the breadcrumbs widget and provide them with the return path.

The easiest way to do that is through some kind of root scope broadcast message, but that does present a little bit of a wrinkle, as you don’t really know which widgets have loaded first, and you could be broadcasting to no one if you are first to arrive on the scene. To solve that problem, you can listen for a different root scope message from other widgets that basically asks, “Please say that again now that I am ready to hear it.” From the listener’s perspective then, when you are ready to utilize the return path, if you haven’t yet received it, you can request it, and then go from there.

But first things first. Before we can announce the return path, we need to know what it is. Using the length of the breadcrumbs array as an index takes you past the end of the array, but subtracting 1 from that value gets you to the last entry, which happens to be the current page. We want the page before that, so we have to subtract 2 from the length. The danger there, of course, is that you might have an empty array or an array with a single element, and subtracting 2 would again put your index outside of the range of the array. So we have to start out with a default value, and then check the length of the array before we proceed any further. My code to do that looks like this:

c.returnPath = '?';
if (c.breadcrumbs.length > 1) {
	c.returnPath = c.breadcrumbs[c.breadcrumbs.length - 2].url;
}

That establishes the return path. Now we have to announce it. I created a function for that, since we will be doing this from multiple places (on load, and then again on request). Here is my function:

function shareReturnPath() {
	$rootScope.$broadcast('snhBreadcrumbs', {
		returnPath: c.returnPath
	});
}

Now that we have created the function, we need to call it as soon as we establish the return path, and then we need to set up a listener for later requests that will call it again on demand. For that, we just need a little more code wedged in between the two blocks above:

shareReturnPath();
$rootScope.$on('snhShareReturnPath', function() {
	shareReturnPath();
});

Well, that wasn’t so bad. Combined with what we had before, the entire Client Controller now looks like this:

function snhBreadcrumbs($scope, $rootScope, $location, spUtil) {
	var c = this;
	c.expanded = !spUtil.isMobile();
	c.expand = function() {
		c.expanded = true;
	};
	c.breadcrumbs = [];
	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};
	
	if (thisPage.id && thisPage.id != $rootScope.portal.homepage_dv) {
		var pageFound = false;
		for (var i=0;i<c.data.breadcrumbs.length && !pageFound; i++) {
			if (c.data.breadcrumbs[i].id == thisPage.id) {
				c.breadcrumbs.push(thisPage);
				pageFound = true;
			} else {
				c.breadcrumbs.push(c.data.breadcrumbs[i]);
			}
		}
		if (!pageFound) {
			c.breadcrumbs.push(thisPage);
		}
	}
	c.data.breadcrumbs = c.breadcrumbs;
	c.server.update();
	c.returnPath = '?';
	if (c.breadcrumbs.length > 1) {
		c.returnPath = c.breadcrumbs[c.breadcrumbs.length - 2].url;
	}
	shareReturnPath();
	$rootScope.$on('snhShareReturnPath', function() {
		shareReturnPath();
	});

	function shareReturnPath() {
		$rootScope.$broadcast('snhBreadcrumbs', {
			returnPath: c.returnPath
		});
	}
}

Yes, we still have to test it, just to make sure that it all works in the way in which we intended, but it should work and it didn’t take all that much to provide this new functionality. In fact, it will probably be more work to test it than it was to create it. I’ll have to find or build another widget to share the page with this one, then create a page to put them both on, and then bring up the page and try things out. Now that I think about it, that seems like a decent amount of effort, so I think I will just save all of that until next time!

Configuration Item Icon Assignment Widget, Part III

When to use iterative development? You should use iterative development only on projects that you want to succeed.”
Martin Fowler

I probably could have wrapped all of this up last time, but I had not really given a whole lot of thought to a couple of critical decisions related to the last two items that needed to be coded out, the Save button and the Cancel button. For the Cancel button, the main question is where are you going to land once you leave the page. For the Save button, which is the bigger question, I need to figure out where I am going to store the information now that I have collected it on the screen. Since that’s a more complicated deliberation, let’s focus on the Cancel button first.

One thing that I wanted to do with the Cancel button was to pop up a confirmation that you really did want to bail, but only if you had made any changes that you would be losing by leaving the page. I wasn’t keeping track of whether or not you had made any changes, though, so I needed to fix that first. I created a variable called dataAltered and initialized it to false, and then I set it to true if you added or removed a row or selected an icon. With that in place, I built the function to handle the Cancel button clicks.

$scope.cancel = function() {
	if (dataAltered) {
		spModal.confirm('Discard your recent changes?').then(function(confirmed) {
			if (confirmed) {
				goBack();
			}
		});
	} else {
		goBack();
	}
};

Those of you paying close attention will notice that I still did not address the question of where to go after clicking the Cancel button; I just called a nonexistent function called goBack. That’s basically my way of putting things off until some future time when I am forced to deal with it, and usually I will create the function with a single line of code that pops an alert or write a log record to the let me know that I’ve made it that far. That let’s me test what I have done without having to deal with what I haven’t done. In this particular case, though, that was a short delay, because now it’s time to put real code in the goBack function, which means that it’s time to decide where people are going to land when they hit the Cancel button.

One of my first thoughts was that if this widget was sharing a screen with my Dynamic Service Portal Breadcrumbs widget, I could just go back one step in the chain to the previous screen. In order for that to work, I would need to know if I was sharing the page with that widget, which is not really possible at the moment. Ideally, that widget should announce its presence with some kind rootScope broadcast message that says something like, “Hey, I am here, and oh by the way, here is the URL for the previous page if you want to go back to it.” That actually sounds like a good idea, but it also sounds like a project for another day. I was planning on adding this widget to my existing Tools menu in the main UI, so I didn’t really see a lot of value in investing a lot of effort right now on getting it to work on the portal with breadcrumbs.

Ultimately, I decided to just go to the home page of the main UI, unless of course, we really were running in the portal, and in that case, I would just return to the home page of the portal. Since the main UI pages run inside of an iFrame, it seemed relatively simple enough to detect the difference by looking at the parent window. The final code turned out to be just this:

function goBack() {
	var destination = '?';
	if (window.parent.location.href != window.location.href) {
		destination = '/home.do';
	}
	window.location.href = destination;
}

That takes care of the easy part. Now it is finally time to make a decision on where to store this information. The simplest thing to do seemed to be to just add another column to the sys_db_object table for the name of the icon. That ‘s a fairly foundational table to the whole ServiceNow platform, though, and I really do not like to mess with those if I can avoid it. A preferable alternative would be to create a simple m2m table that had a column for the CI class and another column for the icon name. That’s much better, but there is still a certain amount of work involved in creating the table, saving the data to the table, and then reading the data back in from the table whenever you wanted to use it. What I finally decided to do was to just keep the icon map hard-coded in my Script Include, and then just update the script column of the Script Include whenever the Save button is clicked. Look, Ma — no tables!

My plan was to grab the script, substring off the parts of the script before and after the property list, rebuild the property list from the data on the screen, and then rebuild the script from the before and after pieces and the new property list. There is probably some regex wizardry that would do all of that with a single line of code, but most of that is all voodoo magic as far as I can tell, and I like to build things that can be comprehended by a little bit larger audience. In the end, my approach seemed to have an awful lot of code and variables involved, but it does work, so there you go.

function save() {
	var scriptGR = new GlideRecord('sys_script_include');
	if (scriptGR.get('7b6ebf052f8e18104425fcecf699b6f6')) {
		var original = scriptGR.getValue('script');
		var i = original.indexOf('map: {');
		if (i != -1) {
			var preamble = original.substring(0, i);
			var theRest = original.substring(i);
			i = theRest.indexOf('\n\t},');
			if (i != -1) {
				var postamble = theRest.substring(i);
				var separator = '';
				var newScript = preamble + 'map: {';
				for (i=0; i<data.itemArray.length; i++) {
					newScript += separator + '\n\t\t' + data.itemArray[i].id + ": '" + data.itemArray[i].icon + "'";
					separator = ',';
				}
				newScript += postamble;
				scriptGR.setValue('script', newScript);
				scriptGR.update();
				gs.addInfoMessage('The Configuration Item icon assignments have been saved');
			} else {
				gs.addErrorMessage('The Script Include to update has been corrupted.');
			}
		} else {
			gs.addErrorMessage('The Script Include to update has been corrupted.');
		}
	} else {
		gs.addErrorMessage('The Script Include to update was not found.');
	}
}

That’s a server side script, by the way, so we still need a client side script to send things over to the server. That one is pretty simple, though, and looks like this:

$scope.save = function() {
	c.data.action = 'save';
	c.server.update();
};

In fact, the whole client side script, including the code that we just added to detect changes and handle the two buttons now looks like this in its final form:

function CIIconAssignment($scope, spModal) {
	var c = this;
	var dataAltered = false;

	$scope.addSelected = function() {
		c.server.update();
		$('#snrp').select2("val","");
		dataAltered = true;
	};

	$scope.removeItem = function(inx) {
		spModal.confirm('Remove ' + c.data.itemArray[inx].id + ' from the list?').then(function(confirmed) {
			if (confirmed) {
				c.data.itemArray.splice(inx, 1);
				dataAltered = true;
			}
		});
	};

	$scope.selectIcon = function(inx) {
		spModal.open({
			title: 'Select Icon',
			widget: 'icon-picker',
			buttons: [
				{label: '${Cancel}', cancel: true}
			],
			size: 'sm',
		}).then(function(response) {
			c.data.itemArray[inx].icon = response.selected;
			dataAltered = true;
		});
	};

	$scope.save = function() {
		c.data.action = 'save';
		c.server.update();
	};

	$scope.cancel = function() {
		if (dataAltered) {
			spModal.confirm('Discard your recent changes?').then(function(confirmed) {
				if (confirmed) {
					goBack();
				}
			});
		} else {
			goBack();
		}
	};

	function goBack() {
		var destination = '?';
		if (window.parent.location.href != window.location.href) {
			destination = '/home.do';
		}
		window.location.href = destination;
	}
}

The last thing that I did was grab one of the existing sidebar menu options under my Tools section and use it as model for a new menu option to launch this page. After that, I used the new option to bring up the page and test everything out. Everything seems to work, and it came out looking pretty good, all things considered.

Configuration Item Icon Assignment widget and related menu option

One thing that I did realize during my testing is that there is nothing to stop you from deleting the root cmdb_ci entry, which serves as my default. I don’t really want you doing that, so I added an ng-hide to the delete icon that will remove that option when the item.id is cmdb_ci. You can see in the image above that there is no delete option for that row. That’s much better.

After playing around and testing, I ended up saving my Script Include several times over. Right now, it looks like this:

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

	getIcon: function(ciClass) {
		var icon = this.map[ciClass];

		if (!icon) {
			ciClass = this.getParentClass(ciClass);
			if (ciClass) {
				icon = this.getIcon(ciClass);
			}
		}

		return icon;
	},

	getParentClass: function(ciClass) {
		var parentClass = '';

		var tableGR = new GlideRecord('sys_db_object');
		if (tableGR.get('name', ciClass)) {
			parentClass = tableGR.super_class.name;
		}

		return parentClass;
	},

	map: {
		cmdb_ci: 'configuration',
		cmdb_ci_aix_server: 'connect-viewdocument',
		cmdb_ci_computer: 'hardware',
		cmdb_ci_config_file: 'document',
		cmdb_ci_database: 'database',
		cmdb_ci_hardware: 'keyboard',
		cmdb_ci_linux_server: 'sp-wishlist-sm',
		cmdb_ci_server: 'server',
		cmdb_ci_spkg: 'software',
		cmdb_ci_unix_server: 'text-underlined',
		cmdb_ci_win_server: 'vtb-freeform'
	},

	type: 'ConfigurationItemIconMap'
};

Of course, that will change the next time that I use the tool, but I like the fact that I did not end up creating any extra columns or tables to pull this off. For those of you who like to play along at home, here is an Update Set containing all of the parts and pieces.

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.

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.

Formatted Script Search Results

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

So, I came up with a way to search all of the places in which a chunk of code might be hiding, but to get the results, I have to run it as a background script and parse through the resulting JSON object. I need something a little more user-friendly than that, so I am going to build a Service Portal widget that takes a user entered search string, makes the call to the search script, and then formats the results a little nicer. Then I am going to add an item to the Tools menu that I created for my sn-record-picker tool that will bring up this new widget. This way, all I will have to do is click on the menu, enter my search terms, and hit the button to see the results.

The first thing that we will need to do is to lay out the page. Nothing exciting here: just an snh-form-field text element for capturing the input, a submit button, and a place to display the results, all wrapped up in an snh-panel. It’s a relatively small snippet of HTML, so I can reproduce the entire thing here:

<snh-panel class="panel panel-primary" rect="rect" title="'${Script Search}'">
  <form id="form1" name="form1" novalidate>
    <div class="row">
      <div class="col-sm-12">
        <snh-form-field
          snh-model="c.data.searchFor"
          snh-name="searchFor"
          snh-label="What are you searching for?"
          snh-help="Enter the text that you would like to find in a script somewhere in the system"
          snh-required="true"
          snh-messages='{"required":"Please enter what you would like to find"}'/>
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12" style="text-align: center;">
        <button class="btn btn-primary" ng-disabled="!(form1.$valid)" ng-click="findIt()">${Search all scripts}</button>
      </div>
    </div>
  </form>
  <div class="row" ng-show="c.data.result">
    <div class="col-sm-12" ng-show="c.data.result.length==0">
      <p>${No scripts were found to contain the text} <b>{{c.data.searchFor}}</b></p>
    </div>
    <div class="col-sm-12" ng-show="c.data.result.length>0">
      <table class="table table-hover table-condensed">
        <thead>
          <tr>
            <th style="text-align: center;">${Artifact}</th>
            <th style="text-align: center;">${Table Name}</th>
            <th style="text-align: center;">${Table}</th>
          </tr>
        </thead>
        <tbody>
          <tr ng-repeat="item in c.data.result">
            <td data-th="Record"><a href="{{item.table}}.do?sys_id={{item.sys_id}}" title="Open {{item.name}}">{{item.name}}</a></td>
            <td data-th="Table Name">{{item.tableName}}</td>
            <td data-th="Table">{{item.table}}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</snh-panel>

There is an ng-click on the submit button that will call a function on the client-side script, so now is as good a time as any to build that out. Again, there really isn’t all that much code to see here:

function ScriptSearch($scope, $location) {
	var c = this;

	$scope.findIt = function() {
		$location.search('search', c.data.searchFor);
	};
}

You may notice that we don’t actually search for the script at this point; we just branch to a new location. This is a little trick that I stole from my Configurable Data Table Widget Content Selector, which also just takes user input and uses it to build a new URL, and then takes you to that URL where the search actually takes place. The reason for that is so that all of the user input is a part of the page URL, which means that if you drill down and follow any links on the page, when you come back, all of your original search criteria is still intact and the page comes back just the way that you left it. This works really slick in the Service Portal; it’s just really too bad that I didn’t have the same experience when I launched this widget inside of the main UI. But, we’ll deal with that later.

Right now, it’s time to tackle server-side script, and once again, you aren’t going to see much here in the way of actual lines of code:

(function() {
	data.searchFor = '';
	data.result = false;
	if ($sp.getParameter('search')) {
		data.searchFor = $sp.getParameter('search');
		data.result = new ScriptUtils().findInScript(data.searchFor);
		gs.setReturnURL('/$sp.do?id=script_search&search=' + data.searchFor);
	}
})();

Basically, we start out by setting the default values for our two data properties, and then if there is a search parameter on the URL, we override those values with the search term and the results of running that search term through our script searcher. That last line was my attempt to preserve the URL so that when I clicked on an item to go look at it, my results would still be displayed when I returned. Unfortunately, that didn’t work, either. One day, I will figure out how to fix that, but for now, each time I leave the page, I have to search all over again to get my results back. Not the way that I want it to work, but at this point, I can live with it.

That’s about it for all of the various parts. Pretty simple stuff, really. Here’s what it looks like in action:

Script searcher in action

All in all, I like the way that it turned out, although it really annoys my sense of The Way Things Ought To Be when I click on one of those items and then come back and find that my search results are all gone. That’s just not right. One day I am going to fix that, but until that day comes, here is an Update Set for anyone who wants to play along at home, and maybe even take care of that little annoyance for me!