Customizing the Data Table Widget

“The reasonable man adapts himself to the world: the unreasonable one persists in trying to adapt the world to himself. Therefore, all progress depends on the unreasonable man.”
George Bernard Shaw

After spending some time playing around with my Data Table Widget Content Selector, I realized that there were a few things that I wanted out of the Data Table widget that just weren’t on its list of features. For one thing, the entire row was the source of the link to further details. In the standard ServiceNow UI, the first column on any given list is the link to further details on the subject of the the row, and links in other columns took you to details related to that specific column. On a list of Incidents, for example, clicking on the link on the first column will take you to the Incident, but clicking on a link in any other column, say Assignment Group or Location, will bring up information on the Assignment Group or Location. I wanted to have a Service Portal list that behaved in the same manner. I started rooting around in the code for the Data Table and realized that this would be more than a simple hack, so I decided to clone the widget and create my own SNH Data Table.

One thing that I discovered while playing around with my copy of the Data Table widget was that there was a minor bug in widget related to the fields option, which was the subject of my earlier hack of the Data Table from URL definition widget. Throughout the widget, this option is referenced as fields, but in the actual widget options, it is named field_list. The statement that copied fields from the options didn’t really copy anything, as the actual data was stored under the variable name field_list. So the first thing that I ended up doing with my copy was to change this:

if (!data.fields) {
	if (data.view)
		data.fields = $sp.getListColumns(data.table, data.view);
	else
		data.fields = $sp.getListColumns(data.table);
}

… to this:

if (!data.fields) {
	if (data.field_list) {
		data.fields = data.field_list;
	} else if (data.view) {
		data.fields = $sp.getListColumns(data.table, data.view);
	} else {
		data.fields = $sp.getListColumns(data.table);
	}
}

That seemed to have rectified that little shortcoming, so now back to my intended purpose, which was to provide column-level links rather than the current row-level link. First things first: I needed to find out if we had enough information on hand to provide the links, or if we needed to add some code to gather up more details for columns that could contain a link. Unfortunately, the code the code that gathers up the row data doesn’t reveal much detail on what, exactly, is gathered up for each field in each row:

while (gr._next()) {
	var record = {};
	$sp.getRecordElements(record, gr, data.fields);
	. . .
}

I would have to dig out the code for $sp.getRecordElements() to know what data was being pulled for each field, which sounded a lot like a major hunting expedition. Instead, I took the lazy way out (my favorite!), and just dumped out the result with a gs.info(JSON.Stringify(record)); statement right after the $sp.getRecordElements(). After that, all I had to do was to bring up a list using the widget and then dig through syslog.list. What I learned was that each field in the record object contained a type, a value, and a display_value, but not the name of the table for the reference fields, which I would need if I was going to create a link. So, I needed to add a little code to pick up that extra bit of info for each field where type=reference. There was actually some similar code right above where I needed to insert my own, so what I ended up adding turned out to be a bastardized copy of the preceeding logic:

for (var f in data.fields_array) {
	var fld = data.fields_array[f];
	if (gr.isValidField(fld)) {
		if (record[fld].type == 'reference') {
			record[fld].table = gr.getElement(fld).getED().getReference();
		}
	}
}

Basically, we just loop through all of the fields in the record, look for those where type=reference and then go fetch the reference table from the source GlideRecord. Now that we have everything that we need in the underlying data, the last thing that we need to do will be to alter the HTML to provide individual column links instead of a single link for the entire row. That seems like a good place to start, next time out

Service Portal Form Fields, Part X

“No matter how far you have traveled down the wrong road, turn back.”
— Turkish Proverb

Recently, it was brought to my attention that my little form field tag experiment did not provide the same functionality for a “choice list” that you get with the out-of-the-box sn-choice-list tag that is shipped with ServiceNow. I went ahead and dug into the source code behind the sn-choice-list, and it definitely does a lot more and provides quite a bit more flexibility. It was definitely far superior to my own feeble offering. I wanted mine to be able to do all of that.

My first thought was to just grab all of the code and stuff it into my own directive, tweaking the attribute names to conform to the snh- prefix that I had been using with all of the others. Unfortunately, that turned out to be quite a bit more of an adventure than I had originally anticipated. After further review, I made the cowardly decision to revert my code back to it’s pre-adventure state, and determined that it was time for a different approach.

My second thought was that my first thought was rather ill considered, particularly since it suddenly occurred to me that I could just wrap the existing sn-choice-list tag just exactly the way that was already wrapping the existing sn-record-picker tag. Why copy the code when you can just reference it in place and leave the future maintenance to someone else? If I were a smarter guy, that would have been my first thought and I would have saved myself a lot of pointless work that I just ended up throwing out the window. Oh, well.

I still wanted to keep my own simple choicelist option, though, so I ended up renaming that one to select, and then creating a new version of choicelist. As you can see, the new choicelist is pretty much a letter for letter copy of reference, which is my implementation of the sn-record-picker tag.

if (type == 'radio' || type == 'inlineradio') {
	htmlText += buildRadioTypes(attrs, name, model, required, type);
} else if (type == 'select') {
	htmlText += buildSelect(attrs, name, model, required);
} else if (SPECIAL_TYPE[type]) {
	htmlText += buildSpecialTypes(attrs, name, model, required, type, fullName, label);
} else if (type == 'reference') {
	htmlText += "      <sn-record-picker field=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "></sn-record-picker>\n";
} else if (type == 'choicelist') {
	htmlText += "      <sn-choice-list sn-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "></sn-choice-list>\n";
} else if (type == 'textarea') {
	htmlText += "      <textarea class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "></textarea>\n";
} else {
	htmlText += "      <input class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\" type=\"" + type + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "/>\n";
}

I basically copied two lines of code and then made a few edits on the copied lines. That’s it! That was definitely easier than the path that I had started on when I first set out to solve this problem. Now, I just needed to test it. I kept my original choicelist option under its new name (select), so I wanted to be able to test both. I pulled up my little test widget and copied the line that I used to test the original choicelist and made a copy. Then I tweaked one to test the select option and the other to test out the new choicelist option.

<snh-form-field
  snh-model="c.data.select"
  snh-name="select"
  snh-label="Select"
  snh-type="select"
  snh-required="true"
  snh-choices='[{"value":"1", "label":"Choice #1"},{"value":"2", "label":"Choice #2"},{"value":"3", "label":"Choice #3"},{"value":"4", "label":"Choice #4"}]'/>
<snh-form-field
  snh-model="c.data.choicelist"
  snh-name="choicelist"
  snh-label="Choice List"
  snh-type="choicelist"
  snh-required="true"
  sn-value-field="value"
  sn-text-field="label"
  sn-items="c.data.choicelistchoices"/>

Since the whole purpose of bringing in the stock sn-choice-list in the first place was to allow for the use of a variable for the choices, I went ahead and defined a variable for that purpose and populated it in the server-side code:

data.choicelistchoices = [{"value":"1", "label":"Choice #1"},{"value":"2", "label":"Choice #2"},{"value":"3", "label":"Choice #3"},{"value":"4", "label":"Choice #4"}];

All that was left now was to bring up the test page and see how things turned out.

Select option and Choice List option rendered on the page

Well, it all seems to work. I guess it’s time to post yet another Update Set.

Configurable Data Table Widget Content Selector, Part II

“Code reuse is the Holy Grail of Software Engineering.”
Douglas Crockford

So far, I’ve hacked up one of the stock Data Table widgets and created one example of a configuration script for my proposed Data Table Content Selector. Now it’s time to actually build the widget itself. Here is how I envision it appearing based on the sample configuration that I put together earlier:

List Content Selector based on earlier configuration

Basically, it contains individual sections for each of the three configurable selections: Perspective, State, and Table. Different configurations would, of course, produce different results, but the concept remains the same regardless of your particular configuration: you make a selection using the widget and then the widget will construct the appropriate URL based on your selection and then take you to that URL. The hacked Data Table from URL Definition widget, which shares the page, will then pick up those URL parameters, which will in turn drive the records that appear in the Data Table. Here is one possible arrangement where the Data Table widget consumes 75% of the screen and the companion selector widget occupies the remaining 25% on the right hand side.

One potential arrangement of the two widgets on the page

Where you place your new widget is entirely up to you. The example above happens to use a 9/3 split to put the selector on the right, but you could also use a 3/9 split to put it on the left, or if you did not want to take away from the width of the Date Table, you could place it above, or below (not recommended), or even put it into some kind of modal pop-up box accessed via a simple link or button. The point is, you can put it wherever you want and it won’t affect the way that it works. Although the two widgets do share the same portal page, they don’t actually communicate with each other directly. There is no broadcasting or listening for messages between the two. The selector simply constructs the appropriate URL and then both widgets draw from the URL parameters to determine what ends up on the screen. Here is another example with a different arrangement and a different configuration:

Another potential arrangement of the two widgets on the page

In order to handle all possible situations, there has to be some default behavior in the event that no URL parameters are provided. Based on the configuration file, default values can be determined for all three selections (perspective, state, and table), and there can even be a default response for an invalid configuration. No matter what the circumstances, you want the page to do something so things are not left broken. To retain the order, configuration parameters are specified in arrays rather than objects. The default then, can always be the first item in the array. For the perspective, which is role based, it would be the first item in the array for which the current user has an applicable role. For tables, which can be different for different perspectives, it would be the first item on the list for the selected perspective. If defaults cannot be determined based on the configuration and the current user, then the appropriate action would be to leave the page entirely and go to some other page such as the home page of the portal. This is preferable to leaving the page up in a broken state.

The client side code, which handles the construction of the new URL, includes all of the code necessary to process both the default responses and the user’s selections:

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

	if (!c.data.table) {
		if (c.data.config.defaults.table) {
			refreshPage(c.data.config.defaults.table, c.data.config.defaults.perspective, c.data.config.defaults.state);
		} else {
			window.location.search = '';
		}
	}

	$scope.selectTable = function(selection) {
		refreshPage(selection, c.data.perspective, c.data.state);
	};

	$scope.selectPerspective = function(selection) {
		refreshPage(c.data.config.table[selection][0].name, selection, c.data.config.state[0].name);
	};

	$scope.selectState = function(selection) {
		refreshPage(c.data.table, c.data.perspective, selection);
	};

	function refreshPage(table, perspective, state) {
		var tableInfo = getTableInfo(table, perspective);
		var newSearch = '?id=' + $location.search()['id'];
		newSearch += '&table=' + tableInfo.name;
		newSearch += '&filter=' + tableInfo[state].filter;
		newSearch += '&fields=' + tableInfo[state].fields;
		newSearch += '&px=' + perspective;
		newSearch += '&sx=' + state;
		window.location.search = newSearch;
	}

	function getTableInfo(table, perspective) {
		var tableInfo = {};
		for (var i=0; i<c.data.config.table[perspective].length; i++) {
			if (c.data.config.table[perspective][i].name == table) {
				tableInfo = c.data.config.table[perspective][i];
			}
		}
		return tableInfo;
	}
}

At the top of the script, we verify that a table has been selected. If not, then we check to make sure that there is a default table. If there is, then we select the default table; otherwise, we return to the home page of the Portal. If a table has already been selected, then we just wait for the user to make another selection, at which time we refresh the page with the selected options.

The server side code is a little more involved. The first thing that we need to do is go out and get the configuration information. Once we have that in hand, we can determine the authorized perspectives, and once those have been established, we can then determine the defaults. After that, it’s just a matter of pulling in the URL parameters and verifying them against the configuration. The final action on the server side is to use the defined filter for the active state to get a row count for every applicable table.

(function() {
	var mdc = new MyDataConfig();
	data.config = mdc.getConfig($sp);
	data.config.authorizedPerspective = getAuthorizedPerspectives();
	establsihDefaults();
	
	if (!input) {
		data.list = [];
		if (data.config.defaults.perspective && $sp.getParameter('table')) {
			data.perspective = data.config.defaults.perspective;
			data.state = data.config.defaults.state;
			data.table = data.config.defaults.table;
			if ($sp.getParameter('px')) {
				for (var i=0; i<data.config.authorizedPerspective.length; i++) {
					if ($sp.getParameter('px') == data.config.authorizedPerspective[i].name) {
						data.perspective = $sp.getParameter('px');
					}
				}
			}
			for (var i=0; i<data.config.table[data.perspective].length; i++) {
				if ($sp.getParameter('table') == data.config.table[data.perspective][i].name) {
					data.table = $sp.getParameter('table');
				}
			}
			if ($sp.getParameter('sx')) {
				for (var i=0; i<data.config.state.length; i++) {
					if ($sp.getParameter('sx') == data.config.state[i].name) {
						data.state = $sp.getParameter('sx');
					}
				}
			}
			for (var i in data.config.table[data.perspective]) {
				getValues(data.config.table[data.perspective][i]);
			}
		}
	}
	
	function getAuthorizedPerspectives() {
		var authorizedPerspective = [];
		for (var i in data.config.perspective) {
			var p = data.config.perspective[i];
			if (p.roles) {
				var role = p.roles.split(',');
				var authorized = false;
				for (var i=0; i<role.length; i++) {
					if (gs.hasRole(role[i])) {
						authorized = true;
					}
				}
				if (authorized) {
					authorizedPerspective.push(p);
				}
			} else {
				authorizedPerspective.push(p);
			}
		}
		return authorizedPerspective;
	}
	
	function establsihDefaults() {
		data.config.defaults = {};
		if (data.config.authorizedPerspective[0]) {
			data.config.defaults.perspective = data.config.authorizedPerspective[0].name;
			for (var i in data.config.state) {
				var s = data.config.state[i];
				if (!data.config.defaults.state) {
					data.config.defaults.state = s.name;
				}
			}
			for (var i in data.config.table[data.config.defaults.perspective]) {
				var t = data.config.table[data.config.defaults.perspective][i];
				if (!data.config.defaults.table) {
					data.config.defaults.table = t.name;
				}
			}
		}
	}
	
	function getValues(tableInfo) {
		var gr = new GlideRecord(tableInfo.name);
		gr.addEncodedQuery(tableInfo[data.state].filter);
		gr.query();
		if (gr.getRowCount() > 0 || tableInfo.name == data.table) {
			data.list.push({
				name: tableInfo.name,
				label: gr.getLabel(),
				value: gr.getRowCount()
			});
		}
	}
})();

That’s all there is to it. For those of you who like to follow along at home, I’ve bundled all of the parts, including the hacked Data Table from URL Definition widget, into an Update Set so that you can give things a spin in your own environment. If you have any ideas for improvements, please feel free to leave them in the Comments — thanks!

Configurable Data Table Widget Content Selector

“I like being a beginner. I like the moment where I look at everyone and say, ‘I have no idea how to do this, let’s figure it out.'”
— Jon Acuff, Do Over: Rescue Monday, Reinvent Your Work, and Never Get Stuck

The reason that I needed to hack the out-of-the-box Data Table from URL Definition widget was because I had this vision of creating a small companion widget that could share the same page and allow a user to select what data they wanted to be displayed in the data table. To achieve my vision, I needed to be able to pass three elements in via the URL: the table, the filter, and the columns to display. The stock widget already supported the table and the filter, but the columns are controlled by the view, which also works to some extent, but for every desired selection/arrangement of columns, you would have to define a new view. That seemed like a little more work than I wanted to get into, so I tweaked the stock widget just a tad to allow me to simply pass in the names of the columns that I wanted to display in the order that I wanted to display them.

The Concept

The basic idea for my new widget is that it would allow the user to view their personal data in a variety of contexts by selecting one or more configurable parameters, and based on those parameters, produce a URL that would be consumed by the Data Table from URL Definition widget, which would do the heavy lifting of displaying the information. This companion widget could sit above or to the side of the main data table and provide a means for the user to control what information is displayed in the main content area. My hope was that I could create something completely driven off of an external configuration file, and customization and personalization could be handled entirely by altering the configuration file without having to crack open the widget itself. I saw my widget providing the user selectable options in three distinct areas: perspective, state, and table.

Perspective

The first option would be perspective, and would be the highest order in the hierarchy of choices. Other available options would be based on the selected perspective. The perspective represents the vantage point of the user. For example, in my little demonstration configuration, I have provided two perspective options for viewing data related to Incidents and Requests: Requester and Fulfiller. This is just one example. If you were looking at Project data, you might want perspectives such as Executive Sponsor, Project Manager, and Project Team. If you were looking at Change data, you might want something like Change Coordinator, Change Manager, and Change Implementer. Or if you wanted to look at work queues, your perspectives might be something like My Work and My Group’s Work. The point is that the widget should be designed in such a way that you can come up with whatever perspectives your particular situation might require, and all you should have to do is provide the appropriate configuration.

Each perspective will have three properties: name, label, and roles. The name and label properties are pretty self-explanatory; the roles property provides a means to limit the exposure of certain perspectives to just those folks for whom that perspective would be appropriate. In my example, everyone has access to the requester perspective, so there are no roles associated with that one. The fulfiller perspective, however, is only appropriate for those individuals involved in request fulfillment, so that perspective is linked to the itil role. Individuals who have the itil role will see the option for the fulfiller perspective and those that do not will not. Here is my example perspective configuration:

perspective: [{
	name: 'requester',
	label: 'Requester',
	roles: ''
},{
	name: 'fulfiller',
	label: 'Fulfiller',
	roles: 'itil'
}]

State

The second option would be state, which provides a means to classify the records from the selected table. For my example, I have defined 4 unique states: Open, Closed, Recent, and All. As with the example perspectives, the example configuration that I have chosen for my demonstration configuration represents just one possibility. You might prefer states such as Submitted, Approved, Assigned, and Completed. The intent is to set things up in such a way that you can configure any state options that are appropriate for your use case. The whole point is to create a widget in such a way that you can configure it in accordance with your own tastes and desires, and do so without having to alter the actual widget itself. Here is my example state configuration:

state: [{
	name: 'open',
	label: 'Open'
},{
	name: 'closed',
	label: 'Closed'
},{
	name: 'recent',
	label: 'Recent'
},{
	name: 'all',
	label: 'All'
}]

Table

The third option would be table, which is simply the name of table from which the records will be retrieved. Unlike the perspective and state options, the table option is actually shared with the Data Table from URL Definition widget. With our recent modification, the Data Table from URL Definition widget will now accept three URL parameters: table, filter, and fields. The table value will come directly from the table selected on our new companion widget. The filter and fields value will come from the filter and fields values defined in the configuration for the selected perspective, state, and table selected. For each defined perspective, there will be a configuration for one or more tables. For each of the specified tables, there will be filter and fields values for each defined state. To build the URL for our new page containing both the Data Table from URL Definition widget and our new content controller widget, we will use the selected perspective, state, and table to add URL parameters for perspective, state, and table directly from the selections, plus filter and fields, determined based on the values in the configuration file for the selected perspective, state, and table.

All together, including a couple of table definitions for the Requester and another for the Fulfiller, the complete example configuration looks like this:

var MyDataConfig = Class.create();
MyDataConfig.prototype = {

	initialize: function() {
	},

	perspective: [{
		name: 'requester',
		label: 'Requester',
		roles: ''
	},{
		name: 'fulfiller',
		label: 'Fulfiller',
		roles: 'itil'
	}],

	state: [{
		name: 'open',
		label: 'Open'
	},{
		name: 'closed',
		label: 'Closed'
	},{
		name: 'recent',
		label: 'Recent'
	},{
		name: 'all',
		label: 'All'
	}],

	table: {
		requester: [{
			name: 'incident',
			open: {
				filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dtrue',
				fields: 'number,opened_by,opened_at,short_description'
			},
			closed: {
				filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dfalse',
				fields: 'number,opened_by,opened_at,closed_at,short_description'
			},
			recent: {
				filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Esys_created_on%3E%3Djavascript%3Ags.beginningOfLast30Days()',
				fields: 'number,state,opened_by,opened_at,closed_at,short_description'
			},
			all: {
				filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe',
				fields: 'number,state,opened_by,opened_at,closed_at,short_description'
			}
		},{
			name: 'sc_request',
			open: {
				filter: 'requested_forDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dtrue',
				fields: 'number,opened_by,opened_at,short_description'
			},
			closed: {
				filter: 'requested_forDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dfalse',
				fields: 'number,opened_by,opened_at,closed_at,short_description'
			},
			recent: {
				filter: 'requested_forDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Esys_created_on3E%3Djavascript%3Ags.beginningOfLast30Days()',
				fields: 'number,request_state,opened_by,opened_at,closed_at,short_description'
			},
			all: {
				filter: 'requested_forDYNAMIC90d1921e5f510100a9ad2572f2b477fe',
				fields: 'number,request_state,opened_by,opened_at,closed_at,short_description'
			}
		}],
		fulfiller: [{
			name: 'incident',
			open: {
				filter: 'assigned_toDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dtrue',
				fields: 'number,caller_id,opened_at,short_description'
			},
			closed: {
				filter: 'assigned_toDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dfalse',
				fields: 'number,caller_id,opened_at,closed_at,short_description'
			},
			recent: {
				filter: 'assigned_toDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Esys_created_on3E%3Djavascript%3Ags.beginningOfLast30Days()',
				fields: 'number,state,caller_id,opened_at,closed_at,short_description'
			},
			all: {
				filter: 'assigned_toDYNAMIC90d1921e5f510100a9ad2572f2b477fe',
				fields: 'number,state,caller_id,opened_at,closed_at,short_description'
			}
		}]
	},

	getConfig: function(sp) {
		return {
			perspective: this.perspective,
			state: this.state,
			table: this.table
		};
	},

	type: 'MyDataConfig'
};

Now that we have the configuration all figured out, all we have to do is actually build the widget! Well, that seems like a good exercise for the next time out

Fun with the Service Portal Data List Widget

“I can’t do it never yet accomplished anything. I will try has performed miracles.”
George Burnham

Straight out of the box, the ServiceNow Service Portal comes bundled with a trio of widgets for displaying rows of a table: a primary data table widget and two additional wrapper widgets that provide different means to pass configuration parameters to the primary widget.

Service Portal data table widgets

One of the wrapper widgets is the Data Table from URL Definition widget, which was almost exactly what I was looking for. The problem, of course, was the almost. I needed something that was exactly what I was looking for. So close, but no cigar. The problem was that it took most, but not all, of the parameters that I wanted pass via the URL. You can pass in the name of the table, the name of the view, a query filter, and a number of other, related options, but you cannot pass in a list of columns that you want to have displayed in the list. There is a property called fields, which is set up for that very purpose, but its value is hard-coded rather than being pulled from the URL.

Well, that won’t work!

Here is the line in question:

data.fields = $sp.getListColumns(data.table, data.view);

Here is what I would like see on that line:

data.fields = $sp.getParameter('fields') || $sp.getListColumns(data.table, data.view);

That really shouldn’t hurt anything at all. All that would do would be to take a quick peek at the URL, and if someone provided a list of fields, then it would use that list; otherwise, it would revert to what it is currently doing right now. It would simply add what I wanted without taking away anything that it is already set up to do. Unfortunately, this particular widget is one of those provided in read-only mode and you are not allowed to modify it, even if you are an admin. Well, isn’t that restrictive!

The recommended course of action in these cases is to make a clone or copy of the protected widget under a new name and then modify that one, leaving the original intact. I thought about doing just that, but I’m not really one to blindly follow the recommended course of action at every turn. I just wanted to make this one small change to this one and leave it at that. Fortunately, there is a way to do just that. First, you have to export the widget to XML.

Exporting the widget to XML

Next, make whatever modifications that you want to make to the exported XML, being careful not to disturb anything else, and the save the updated XML. Now, go back to the list of widgets and use the hamburger menu on one of the list columns to select Import XML.

Importing the widget XML back into ServiceNow

Browse for your XML file, upload it, and now the modified widget is back where it belongs with your modification in place. Voila! Easy, peasy. Now, I can get back to doing what I wanted to do with this widget in the first place.

Service Portal Form Fields, Part IX

“I do one thing, I do it very well. And then I move on.”
Charles Emerson Winchester III

Generally speaking, I try not to tinker. Once an idea has been turned into an actual functioning reality, I like to set that aside and move on to other ideas. But there’s always that one little thing that you realize that you could have done better, or just differently, and there is this constantly nagging temptation to go back and fine tune things just to make them a wee bit better. I prefer not to give in to that temptation and just press forward towards other aspirations, but occasionally there are some things that you just can’t let go. So here we are …

I started out thinking about drafting some actual instructions, and identifying which attributes are mandatory and which are optional. Then I realized that there really isn’t any code that requires you to include any attribute at all. Things just won’t work if you don’t get it right. I don’t really like that, so I added a few lines to put out a configuration error instead of making a failed attempt to render HTML in the event that you left out a critical attribute. That turned out to be a relatively simple thing to do:

if (!name) {
	htmlText = configurationError('Attribute snh-name is required');
} else if (!model) {
	htmlText = configurationError('Attribute snh-model is required');
} else {
	...

Of course, once you crack open the code, you’ve now opened the door to squeezing in all of those other little tweaks and tidbits that you thought about doing earlier but never got around to actually putting into place. I don’t like having to specify things if it isn’t absolutely necessary, which is why I kept looking for a way to avoid having to specify the form name. It is also the reason why I default the the type value to text. The label is another thing I didn’t think you should have to specify, as you could use the name for that purpose, if no label was provided. I never put that in there, though, so since I was already opening up the code for another version, I went ahead and threw that in there as well.

var label = attrs.snhLabel;
if (!label) {
	label = nameToLabel(name);
}

Something else that I had been contemplating was throwing in an SMS link icon for cell phone numbers in addition to the link icon that I had put in to place a call. A couple of teeny little wrinkles popped up in that exercise, but nothing too awfully exciting really. For one, I didn’t want that showing up for land lines, so I ended up basing its presence on the label, and only had it show up if the label contained the word cell or mobile. The other thing that cropped up was that AngularJS converted the sms: protocol to unsafe:sms: for some reason, and it took a little while to figure out how to stop that from happening. Apparently, you have to add this line of script outside of your widgets and providers:

angular.module('sn.$sp').config(['$compileProvider',function( $compileProvider ){ 
        $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|mailto|file|sms|tel):/) ;
}]);

I’m not all that clear on how all of that works, but it does work, so I’m good. All together, it’s not all that much of an improvement, but it’s different, so I guess we’ll call this one v1.2. Now I just need to put it all together into another Update Set for anyone who might want play around with it on their own.

Incident Email Hack Revisited

“The greatest performance improvement of all is when a system goes from not working to working.”
John Ousterhout

The other day I was showing off my Incident email hack, and to my surprise, the thing did not work. I was reminded of something my old boss used to tell me whenever we had blown a demonstration to a potential customer. “There are only two kinds of demos,” he would say, “Those that don’t count and those that don’t work.” But my email hack had been working flawlessly for quite some time, so I couldn’t imagine why it wasn’t working that day. Then I realized that I couldn’t remember trying it since I upgraded my instance to Madrid. Something was different now, and I needed to figure out what that was.

It didn’t take much of an investigation to locate the failing line of code. As it turns out, it wasn’t in anything that I had written, but in an existing function that I had leveraged to populate the selected email addresses. That’s not to suggest that the source of the problem was not my fault; it just meant that I had to do a little more digging to get down to the heart of the issue. The function, addEmailAddressToList, required an INPUT element as one of the arguments, but for my usage, there was no such INPUT element. But when I looked at the code inside the function, the only reference to the INPUT element was to access the value property. So, I just created a simple object and set the value to the email address that I wanted to add, and then passed that in to the function. That worked just fine at the time, but that was the old version of this function.

In the updated version that comes with Madrid, there is new code to access the ac property of the INPUT element and run a function of the ac object called getAddressFilterIds. My little fake INPUT element had no ac property, and thus, no getAddressFilterIds function, so that’s where things broke down. No problem, though. If I can make a fake INPUT element, I can add a fake ac object to it, and give that fake ac object a fake getAddressFilterIds function. I would need to know what the function does, or more importantly, what it is supposed to return, but that was easy enough to figure out as well. In the end, all I really needed to do to get past that error was add these lines to the incident_email_client_hack UI Script:

input.ac = {};
input.ac.getAddressFilterIds = function() {return '';};

Unfortunately, things still didn’t work after that. Once I got past that first error, I ran right into another similar error, as it was trying to run yet another missing function of the ac object called resetInputField. So, I added yet another line of code:

input.ac.resetInputField = function() {return '';};

Voila! Now we were back in action. I did a little more testing, just to be sure, but as far as I can tell, that solved the issue for this version, and since all I did was add bloat to the fake INPUT element that would never be referenced in the old version, it would be backwards compatible as well, and work just fine now in either version. Still, now that all the parts were laid out, I decided that I could clean the whole mess up a little bit by defining the fake INPUT element all in a single line of code:

var input = {
	value: recipient[i].email,
	ac: {
		getAddressFilterIds: function() {
			return '';
		},
		resetInputField: function() {
			return '';
		}
	}
};

There, that’s better! Now, instead of adding three new lines of code, I actually ended up removing a line. For those of you playing along at home, I gathered up all of the original parts and pieces along with the updated version of this script and uploaded a new version of the Update Set for this hack.

Service Portal Form Fields, Part VIII

“I regret only one thing, which is that the days are so short and that they pass so quickly. One never notices what has been done; one can only see what remains to be done, and if one didn’t like the work it would be very discouraging.”
Maria Sklodowska

Well, I finally resolved the two major annoyances of my little form field project. One was having to include the form name as an attribute when I felt for sure that there must be a way to derive that without having to pass it in explicitly, and the other was related to the validation message behavior. When my error message routine was based on watching $valid, the message did not change even though the reason that it was not $valid changed. Empty required fields would show the message about the field being required, but when you started typing and the value was not yet a valid value, that original message would remain rather than switching to something more appropriate such as explaining to you that the value that you were entering was not a valid value. I tried switching to watching $error instead of $valid, but that didn’t work at all. So, I went out and scoured the Interwebs for an answer to my dilemma.

As it turns out, watching $error really was the right thing to do, but in order for that to work, I had to add true as a third argument to the scope.$watch function call (the first argument is the element to watch and the second is the function to run when something changes). I’m not sure what that third argument is or does, but I now know that if you add it, then it works, so I guess that’s all I really need to know.

As for the form name, after many, many, many different experiments, I finally stumbled across a sequence of random letters, numbers, and symbols that consistently returned the name of the form:

var form = element.parents("form").toArray()[0].name;

Of course, now that it is laid out right there in front of you in plain sight, you can tell right away how intuitively obvious that should have been from the start. Why I didn’t just know that right off of the bat will remain an eternal mystery, but the good news is that we have the secret now, and I can finally remove all of those unneeded snh-form=”form1″ attributes from all of my various test cases. I always felt as if that shouldn’t have been necessary, but I could never quite come up with an approach that would return the name of the form in every instance. Fortunately, I am rather relentless with these kinds of things and I just kept trying things until I finally stumbled upon something that actually worked.

Those were the two major items on my list of stuff that I thought needed to be addressed before we could really call this good enough. I also did a little bit of clean-up in a few other areas as well, just tidying a few little odds and ends up here and there where I thought things could use a little improvement. Here is the full text of the current version of the script that performs all of the magic:

function() {
	var SUPPORTED_TYPE = ['choicelist', 'date', 'datetime-local', 'email', 'inlineradio', 'month', 'number', 'password', 'radio', 'reference', 'tel', 'text', 'textarea', 'time', 'url', 'week'];
	var RESERVED_ATTR = ['ngHide', 'ngModel', 'ngShow', 'class', 'field', 'id', 'name', 'required'];
	var SPECIAL_TYPE = {
		email: {
			title: 'Send an email to {{MODEL}}',
			href: 'mailto:{{MODEL}}',
			icon: 'mail'
		},
		tel: {
			title: 'Call {{MODEL}}',
			href: 'tel:{{MODEL}}',
			icon: 'phone'
		},
		url: {
			title: 'Open {{MODEL}} in a new browser window',
			href: '{{MODEL}}" target="_blank',
			icon: 'pop-out'
		}
	};
	var STD_MESSAGE = {
		email: "Please enter a valid email address",
		max: "Please enter a smaller number",
		maxlength: "Please enter fewer characters",
		min: "Please enter a larger number",
		minlength: "Please enter more characters",
		number: "Please enter a valid number",
		pattern: "Please enter a valid value",
		required: "This information is required",
		url: "Please enter a valid URL",
		date: "Please enter a valid date",
		datetimelocal: "Please enter a valid local date/time",
		time: "Please enter a valid time",
		week: "Please enter a valid week",
		month: "Please enter a valid month"
	};
	return {
		restrict: 'E',
		replace: true,
		require: ['^form'],
		template: function(element, attrs) {
			var htmlText = '';
			var form = element.parents("form").toArray()[0].name;
			var name = attrs.snhName;
			var model = attrs.snhModel;
			var type = attrs.snhType || 'text';
			var required = attrs.snhRequired && attrs.snhRequired.toLowerCase() == 'true';
			var fullName = form + '.' + name;
			var refExtra = '';
			type = type.toLowerCase();
			if (SUPPORTED_TYPE.indexOf(type) == -1) {
				type = 'text';
			}
			if (type == 'reference') {
				fullName = form + "['" + name + " ']";
				refExtra = '.value';
			}
			htmlText += "    <div id=\"element." + name + "\" class=\"form-group\"";
			if (attrs.ngShow) {
				htmlText += " ng-show=\"" + attrs.ngShow + "\"";
			}
			if (attrs.ngHide) {
				htmlText += " ng-hide=\"" + attrs.ngHide + "\"";
			}
			htmlText += ">\n";
			htmlText += "      <div id=\"label." + name + "\" class=\"snh-label\" nowrap=\"true\">\n";
			htmlText += "        <label for=\"" + name + "\" class=\"col-xs-12 col-md-4 col-lg-6 control-label\">\n";
			htmlText += "          <span id=\"status." + name + "\"";
			if (required) {
				htmlText += " ng-class=\"" + model + refExtra + ">''?'snh-required-filled':'snh-required'\"";
			}
			htmlText += "></span>\n";
			htmlText += "          <span title=\"" + attrs.snhLabel + "\" data-original-title=\"" + attrs.snhLabel + "\">" + attrs.snhLabel + "</span>\n";
			htmlText += "        </label>\n";
			htmlText += "      </div>\n";
			if (attrs.snhHelp) {
				htmlText += "      <div id=\"help." + name + "\" class=\"snh-help\">" + attrs.snhHelp + "</div>\n";
			}
			if (type == 'radio' || type == 'inlineradio') {
				htmlText += buildRadioTypes(attrs, name, model, required, type);
			} else if (type == 'choicelist') {
				htmlText += buildChoiceList(attrs, name, model, required);
			} else if (SPECIAL_TYPE[type]) {
				htmlText += buildSpecialTypes(attrs, name, model, required, type, fullName);
			} else if (type == 'reference') {
				htmlText += "      <sn-record-picker field=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "></sn-record-picker>\n";
			} else if (type == 'textarea') {
				htmlText += "      <textarea class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "></textarea>\n";
			} else {
				htmlText += "      <input class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\" type=\"" + type + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "/>\n";
			}
			htmlText += "      <div id=\"message." + name + "\" ng-show=\"(" + fullName + ".$touched || " + fullName + ".$dirty || " + form + ".$submitted) && " + fullName + ".$invalid\" class=\"snh-error\">{{" + fullName + ".validationErrors}}</div>\n";
			htmlText += "    </div>\n";
			return htmlText;

			function buildRadioTypes(attrs, name, model, required, type) {
				var htmlText = "      <div style=\"clear: both;\"></div>\n";

				var option = null;
				try {
					option = JSON.parse(attrs.snhChoices);
				} catch(e) {
					alert('Unable to parse snh-choices value: ' + attrs.snhChoices);
				}
				if (option && option.length > 0) {
					for (var i=0; i<option.length; i++) {
						var thisOption = option[i];
						if (type == 'radio') {
							htmlText += "      <div>\n  ";
						}
						htmlText += "        <input ng-model=\"" + model + "\" id=\"" + name + thisOption.value + "\" name=\"" + name + "\" value=\"" + thisOption.value + "\" type=\"radio\"" + passThroughAttributes(attrs) + (required?' required':'') + "/> " + thisOption.label + "\n";
						if (type == 'radio') {
							htmlText += "      </div>\n";
						}
					}
				}

				return htmlText;
			}

			function buildChoiceList(attrs, name, model, required) {
				var htmlText = "      <select class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\"" + passThroughAttributes(attrs) + (required?' required':'') + ">\n";
				var option = null;
				try {
					option = JSON.parse(attrs.snhChoices);
				} catch(e) {
					alert('Unable to parse snh-choices value: ' + attrs.snhChoices);
				}
				htmlText += "        <option value=\"\"></option>\n";
				if (option && option.length > 0) {
					for (var i=0; i<option.length; i++) {
						var thisOption = option[i];
						htmlText += "        <option value=\"" + thisOption.value + "\">" + thisOption.label + "</option>\n";
					}
				}
				htmlText += "      </select>\n";

				return htmlText;
			}

			function buildSpecialTypes(attrs, name, model, required, type, fullName) {
				var title = SPECIAL_TYPE[type].title.replace('MODEL', model);
				var href = SPECIAL_TYPE[type].href.replace('MODEL', model);
				var icon = SPECIAL_TYPE[type].icon;
				var htmlText = "      <span class=\"input-group\" style=\"width: 100%;\">\n";
				htmlText += "       <input class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\" type=\"" + type + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "/>\n";
				htmlText += "       <span class=\"input-group-btn\" ng-show=\"" + fullName + ".$valid && " + model + " > ''\">\n";
				htmlText += "        <a href=\"" + href + "\" class=\"btn-ref btn btn-default\" role=\"button\" title=\"" + title + "\" tabindex=\"-1\" data-original-title=\"" + title + "\">\n";
				htmlText += "         <span class=\"icon icon-" + icon + "\" aria-hidden=\"true\"></span>\n";
				htmlText += "         <span class=\"sr-only\">" + title + "</span>\n";
				htmlText += "        </a>\n";
				htmlText += "       </span>\n";
				htmlText += "      </span>\n";
				return htmlText;
			}

			function passThroughAttributes(attrs) {
				var htmlText = '';
				for (var name in attrs) {
					if (name.indexOf('snh') != 0 && name.indexOf('$') != 0 && RESERVED_ATTR.indexOf(name) == -1) {
						htmlText += ' ' + camelToDashed(name) + '="' + attrs[name] + '"';
					}
				}
				return htmlText;
			}

			function camelToDashed(camel) {
				return camel.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase();
			}
		},
		link: function(scope, element, attrs, ctls) {
			var form = ctls[0].$name;
			var name = attrs.snhName;
			var fullName = form + '.' + name;
			if (!scope.$eval(fullName)) {
				fullName = form + '["' + name + ' "]';
			}
			var overrides = {};
			if (attrs.snhMessages) {
				overrides = scope.$eval(attrs.snhMessages);
			}
			scope.$watch(fullName + '.$error', function () {
				var elem = scope.$eval(fullName);
				elem.validationErrors = '';
				var separator = '';
				if (elem.$invalid) {
					for (var key in elem.$error) {
						elem.validationErrors += separator;
						if (overrides[key]) {
							elem.validationErrors += overrides[key];
						} else if (STD_MESSAGE[key]) {
							elem.validationErrors += STD_MESSAGE[key];
						} else {
							elem.validationErrors += 'Undefined field validation error: ' + key;
						}
						separator = '<br/>';
					}
				}
			}, true);
		}
	};
}

I already released an almost good enough version that I ended up calling 1.0, so I guess we’ll have to call this one 1.1. You can grab the full Update Set, which now includes the CSS as a separate file instead of being pasted into the test widget like it was in the original version. It would probably be beneficial to include some semblance of documentation at some point, but that will have to wait for a later release.

Service Portal Form Fields, Part VII

“Finishing races is important, but racing is more important.”
Dale Earnhardt

After tinkering around with various uses and locations for the Retina Icons that I came across the other day, I finally settled on decorating three of my form field types with action buttons:

Form Fields with related Action Buttons


For the email form field type, the button sends an email, for the tel form field type, the button calls the number, and for the url form field type, the button opens up a new browser and navigates the the URL. To make the magic happen, I added yet another static variable listing out the types for which I created support for actions.

var SPECIAL_TYPE = ['email', 'tel', 'url'];

Then I created a function to format the types listed in that array. The code is essentially the same for all three types, with the differences only being in the icon, the link, and the associated help text or title. I considered creating a JSON object instead of a simple array, and including the three unique values for each type, but to be completely honest, I really didn’t think of doing that until after I had already done it the other way and I was too lazy to go back and refactor everything. So for now, the array drives the decision to utilize the function, and the function contains a bunch of hard-coded if statements to sort out what is unique to each type.

One thing that I did not want to do was have the action buttons out there when there was no value in the field or when the value was not valid. To only show the buttons when there was something there that would actually work with the button code, I added an ng-show to the outer element preventing it from displaying unless the conditions were right.

Action Buttons removed when data is missing or invalid

Aside from the cool Retina Icons and hiding the buttons when not wanted, there isn’t too much else noteworthy about the code. It does work, which is always a good thing, but one day I can see myself going back in here and doing a little optimization here and there. But this is what the initial version looks like today:

function buildSpecialTypes(attrs, name, model, required, fullName) {
	var title = '';
	var href = '';
	var icon = '';
	if (type == 'email') {
		title = 'Send an email to {{' + model + '}}';
		href = 'mailto:{{' + model + '}}';
		icon = 'mail';
	}
	if (type == 'tel') {
		title = 'Call {{' + model + '}}';
		href = 'tel:{{' + model + '}}';
		icon = 'phone';
	}
	if (type == 'url') {
		title = 'Open {{' + model + '}} in a new browser window';
		href = '{{' + model + '}}" target="_blank';
		icon = 'pop-out';
	}
	var htmlText = "      <span class=\"input-group\" style=\"width: 100%;\">\n";
	htmlText += "       <input class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\" type=\"" + type + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "/>\n";
	htmlText += "       <span class=\"input-group-btn\" ng-show=\"" + fullName + ".$valid && " + model + " > ''\">\n";
	htmlText += "        <a href=\"" + href + "\" class=\"btn-ref btn btn-default\" role=\"button\" title=\"" + title + "\" tabindex=\"-1\" data-original-title=\"" + title + "\">\n";
	htmlText += "         <span class=\"icon icon-" + icon + "\" aria-hidden=\"true\"></span>\n";
	htmlText += "         <span class=\"sr-only\">" + title + "</span>\n";
	htmlText += "        </a>\n";
	htmlText += "       </span>\n";
	htmlText += "      </span>\n";
	return htmlText;
}

I still have my issues to deal with, but thanks to my pervasive skills at creative avoidance, we can put that off to a later installment, which will also be a good time to release an improved version of the Update Set for all of this.

Retina Icons

“Simplicity is the ultimate sophistication.”
Leonardo da Vinci

I was thinking about doing something with the form-field-addons that are a standard part of a ServiceNow UI form, and so I started looking at some of the ones that are currently in use on some of the existing forms. That led me down a path of looking into the source for the various icons used, which eventually led me to this:

https://community.servicenow.com/community?id=community_blog&sys_id=925eaaaddbd0dbc01dcaf3231f961940

According to this blog entry, you just add /styles/retina_icons/retina_icons.html to your existing instance URL and you can see them all. So I did:

Full list of Retina Icons

Pretty cool … that gets the little wheels turning just a bit …