Fun with Highcharts, Part III

“It’s the little details that are vital. Little things make big things happen.”
John Wooden

So far, we have built a generic chart widget and a generic chart utility that produces chart objects from templates and dynamic data. Now it’s time to build the widget that will allow the user to pick and choose what they want to see, and then present the appropriate chart based on the user’s selections. Let’s start with a little HTML for the various pick lists that we will present to the user:

<link rel="stylesheet" type="text/css" href="/747e2219db213300f9699006db9619b9.cssdbx"/>
<link rel="stylesheet" type="text/css" href="/styles/retina_icons/retina_icons.css"/>
<div class="panel panel-default">
  <div class="panel-body form-horizontal">
    <div class="col-sm-12">
      <form id="form1" name="form1" novalidate>
        <div class="col-sm-3">
          <snh-form-field
            snh-model="c.data.group"
            snh-name="group"
            snh-label="Group"
            snh-type="choicelist"
            sn-value-field="value"
            sn-text-field="label"
            sn-items="c.data.config.groupOptions"
            ng-click="updateChart();"/>
        </div>
        <div class="col-sm-3">
          <snh-form-field
            snh-model="c.data.type"
            snh-name="type"
            snh-label="Task Type"
            snh-type="choicelist"
            sn-value-field="value"
            sn-text-field="label"
            sn-items="c.data.config.typeOptions"
            ng-click="updateChart();"/>
        </div>
        <div class="col-sm-3">
          <snh-form-field
            snh-model="c.data.frequency"
            snh-name="frequency"
            snh-label="Frequency"
            snh-type="choicelist"
            sn-value-field="value"
            sn-text-field="label"
            sn-items="c.data.config.freqOptions"
            ng-click="updateChart();"/>
        </div>
        <div class="col-sm-3">
          <snh-form-field
            snh-model="c.data.ending"
            snh-name="ending"
            snh-label="Period Ending"
            snh-type="choicelist"
            sn-value-field="value"
            sn-text-field="label"
            sn-items="c.data.config.endingOptions[c.data.frequency]"
            ng-click="updateChart();"/>
        </div>
      </form>
    </div>
    <div class="col-sm-12">
      <sp-widget widget="data.workloadWidget"></sp-widget>
    </div>
  </div>
</div>

This particular layout leverages our snh-form-field tag, but you could do basically the same thing with a simple sn-choice-list. There is nothing too exotic here, except maybe the lists of choices for the last element (Period Ending), which are contained in an object keyed by the value of the previous selection (Frequency). When you change the Frequency, the list of period ending dates changes to a list that is appropriate for the selected Frequency. Other than that one little oddity, it’s pretty vanilla stuff.

There are several reasons that I chose choicelist, which implements the sn-choice-list tag, for the snh-type of each pick list rather than reference, which implements the sn-record-picker tag. It would have been relatively easy to set up a filter on the sys_user_group table to create a record picker of active user groups, but I wanted to limit the choices to just those groups who had tasks assigned in the task table. That requires a GlideAggregate rather than a GlideRecord query, and I’m not sure how you would set that up in an sn-record-picker. For a choice list, you run the query yourself and then just set the value of the specified variable to an array created from your query results. For this widget, I created a config object to hold all of the choice list arrays, and used the following code to populate the array of choices for the group pick list:

cfg.max = 0;
cfg.maxGroup = '';
cfg.groupOptions = [];
var group = new GlideAggregate('task');
group.addAggregate('COUNT');
group.groupBy('assignment_group');
group.ordderBy('assignment_group');
group.query();
while (group.next()) {
	if (group.getDisplayValue('assignment_group')) {
		cfg.groupOptions.push({label: group.getDisplayValue('assignment_group'), value: group.getValue('assignment_group'), size: group.getAggregate('COUNT')});
		if (group.getAggregate('COUNT') > cfg.max) {
			cfg.max = group.getAggregate('COUNT');
			cfg.maxGroup = group.getValue('assignment_group');
		}
	}
}

For the choice list of task types, I wanted to wait until a group was selected, and then limit the choices to only those types that had been assigned to the selected group. This was another GlideAggregate, and that turned out to be very similar code:

var max = 0;
var defaultType = '';
data.config.typeOptions = [];
var type = new GlideAggregate('task');
type.addQuery('assignment_group', data.group);
type.addAggregate('COUNT');
type.groupBy('sys_class_name');
type.ordderBy('sys_class_name');
type.query();
while (type.next()) {
	if (type.getDisplayValue('sys_class_name')) {
		data.config.typeOptions.push({label: type.getDisplayValue('sys_class_name'), value: type.getValue('sys_class_name'), size: type.getAggregate('COUNT')});
		if (type.getAggregate('COUNT') > max) {
			max = type.getAggregate('COUNT') * 1;
			defaultType = type.getValue('sys_class_name');
		}
	}
}

The frequency choices, on the other hand, were just a hard-coded list that I came up with on my own. I wanted to be able to display the chart on a daily, weekly, monthly, quarterly, or yearly basis, so that’s the list of choices that I put together:

cfg.freqOptions = [
	{value: 'd', label: 'Daily', size: 7},
	{value: 'w', label: 'Weekly', size: 12},
	{value: 'm', label: 'Monthly', size: 12},
	{value: 'q', label: 'Quarterly', size: 8},
	{value: 'y', label: 'Yearly', size: 5}
];

The choices for period ending dates took a bit more code. For one thing, I needed a different list of choices for each frequency. For another, the methodology for determining the next date in the series was slightly different for each frequency. That meant that much of the code was not reusable, as it was unique to each use case. There is probably a way to clean this up a bit, but this is what I have working for now:

cfg.endingOptions = {d: [], w: [], m: [], q: [], y: []};
var todaysDateInfo = getDateValues(new Date());
var today = new Date(todaysDateInfo.label);
var nextSaturday = new Date(today.getTime());
nextSaturday.setDate(nextSaturday.getDate() + (6 - nextSaturday.getDay()));
dt = new Date(nextSaturday.getTime());
for (var i=0; i<52; i++) {
	cfg.endingOptions['d'].push(getDateValues(dt));
	cfg.endingOptions['w'].push(getDateValues(dt));
	dt.setDate(dt.getDate() - 7);
}
dt = new Date(today.getTime());
dt.setMonth(11);
dt = getLastDayOfMonth(dt);
cfg.endingOptions['y'].push(getDateValues(dt));
dt = new Date(today.getTime());
dt.setDate(1);
dt.setMonth([2,2,2,5,5,5,8,8,8,11,11,11][dt.getMonth()]);
dt = getLastDayOfMonth(dt);
cfg.endingOptions['q'].push(getDateValues(dt));
dt = new Date(today.getTime());
for (var i=0; i<36; i++) {
	dt = getLastDayOfMonth(dt);
	var mm = dt.getMonth();
	cfg.endingOptions['m'].push(getDateValues(dt));
	if (mm == 2 || mm == 5 || mm == 8 || mm == 11) {
		cfg.endingOptions['q'].push(getDateValues(dt));
	}
	if (mm == 11 && i != 0) {
		cfg.endingOptions['y'].push(getDateValues(dt));
	}
	dt.setDate(1);
	dt.setMonth(dt.getMonth() - 1);
}

That takes care of the four choice lists and the code to come up with the values for the four choice lists. We’ll want something selected when the page first loads, though, so we’ll need some additional code to come up with the initial values for each of the four selections. For the group, my thought was to start out with the group that had the most tasks, and if the user was a member of any groups, group of which the user was a member with the most tasks would be event better. Here’s what I came up with the handle that:

function getDefaultGroup() {
	var defaultGroup = '';

	var max = 0;
	var group = new GlideAggregate('task');
	if (data.usersGroups.size() > 0) {
		var usersGroups = '';
		var separator = '';
		for (var i=0; i<data.usersGroups.size(); i++) {
			usersGroups = separator + "'" + data.usersGroups.get(i) + "'";
			separator = ',';
		}
		group.addQuery('assignment_group', 'IN', usersGroups);
	}
	group.addAggregate('COUNT');
	group.groupBy('assignment_group');
	group.ordderBy('assignment_group');
	group.query();
	while (group.next()) {
		if (group.getDisplayValue('assignment_group')) {
			if (group.getAggregate('COUNT') > max) {
				max = group.getAggregate('COUNT') * 1;
				defaultGroup = group.getValue('sys_class_name');
			}
		}
	}
	if (!defaultGroup) {
		defaultGroup = data.config.maxGroup;
	}

	return defaultGroup;
}

Since the type choices are dependent on the group selected, that code is already built into the type selection list creation process (above). For the initial frequency, I just arbitrarily decided to start out with daily, and for the initial period ending date, I decided that the current period would be the best place to start as well. That code turned out to be pretty basic.

data.frequency = 'd';
data.ending = data.config.endingOptions[data.frequency][0].value;

With the initial choices made, we now need to work out the process of gathering up the data for the chart based on the choice list selections. That’s a bit of a task as well, so let’s make that our focus the next time out.

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.

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 …

Service Portal Form Fields, Part VI

“If at first you don’t succeed, call it version 1.0”
Charles Lauller

Well, that was an adventure! Even though I was just about 100% sure that it wouldn’t work, I went ahead and defined yet another type for my snh-form-field tag to experiment with embedding an sn-record-picker. It seemed highly unlikely to me that AngularJS was smart enough to run through the evaluation process on a tag that was rendered in a template that itself was resolved in the evaluation process. But, I like to try things even when I’m pretty sure that they won’t work, so I added reference to my list of supported types and added this to my little test page widget:

        <snh-form-field
          snh-form="form1"
          snh-model="c.data.reference"
          snh-name="reference"
          snh-label="Reference Field"
          snh-type="reference"
          table="'cmn_department'"
          display-field="'name'"
          display-fields="'id'"
          value-field="'sys_id'"
          search-fields="'id,name'"
          default-query="'u_active=true"
          page-size="100"
          snh-required="true"/>

To my utter amazement, it actually worked! I did not expect that. I was pleasantly surprised to see that it worked perfectly fine, and I was very happy that I had decided to give it shot before trying to figure out some other way to incorporate this capability in my own directive. There were, of course, a few little adjustments needed here and there, but hey — it was pretty cool to see that it actually worked without me having to do much of anything. I was both astonished and elated. Elation, though, is a temporary state, and things really started to go down from there.

To start with, it just looked a little funky. It was like a field within a field, but not completely, with one element bleeding over the bottom edge of the other. Something just wasn’t right somewhere.

First attempt at setting up a reference field

That one wasn’t that hard to figure out though. I had added the snh-form-control class to the sn-record-picker tag, but the sn-record-picker generates its own class attribute on a different level, so the two were redundant as well as conflicting. I removed my class attribute and that solved that problem. But that was really an insignificant problem compared to what I noticed next: the field wasn’t validating.

The required field indicator was grey instead of red, no matter whether or not you had selected anything from the drop-down. And no error messages appeared no matter what you did. It was as if validation wasn’t turned on for that field at all. Then I remembered that the model for an sn-record-picker is not a string containing the value selected, but an object containing a property that contains the value selected, appropriately named value. Where the value of every other type of field was <form-name>.<field-name>, the value of an sn-record-picker is <form-name>.<field-name>.value. That meant putting in some conditional code just for the reference type fields, which I had not really had to do for any other types up to this point.

Unfortunately, things still were not working after all of that. Now I was starting to get a little frustrated because the picker itself just worked like a charm right out of the gate, which I absolutely did not expect, but the field validation, which I thought would be the one part to work just fine, still wasn’t working at all. I kept digging, though, and eventually I figure out that when the sn-record-picker renders the name attribute, it throws in an extra little trailing space at the end of field name. Instead of name=”reference” I ended up with name=”reference “. Well, that little extra, unwelcome, trailing space means that <form-name>.<field-name> doesn’t reference that field after all. You need to use <form-name>[‘<field-name-plus-one-space>’] because you can’t represent that trailing space without using the square bracket notation instead of the dot notation. So, I ended up adding yet more type-specific code. I don’t know if that extra trailing space is just an unfortunate mistake or it was put in by design, but if it was a mistake, I hope they don’t fix it one day, because now I am relying on it being there for things to work.

After all of those changes, though, it still wasn’t validating. Things were starting to progress from frustration to irritation. Every time that I thought that I had found and fixed the problem, I would try it out expecting things to be fixed, and it still wouldn’t be working. After a lot of head scratching and debugging alerts and trial and error, I finally was able to figure out that the validation messages only appear once the field had been $touched, and the sn-record-picker fields are never $touched. They can get $dirty, but for some reason, they are never $touched. So I modified my condition on showing the validation errors be $touched or $dirty, and finally, everything worked as it should. Finally.

So here is the way things look so far:

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 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 = attrs.snhForm;
			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 (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) && " + 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 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 + '.$valid', function (isValid) {
				var elem = scope.$eval(fullName);
				elem.validationErrors = '';
				var separator = '';
				if (!isValid) {
					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/>';
					}
				}
			});
		}
	};
}

I’m still not happy about having to specify the form name, still need to do something about the wrong validation message showing up in certain circumstances, and I still haven’t tested all of the support types just yet, but I think it just might be complete enough to go ahead and throw an initial version into an Update Set for anyone who might want to play along at home. There is still a lot that I would like to do before I’m ready to call it really good, but it does work, so there is that. As the man who jumped off of the high-rise building was heard saying as he passed each floor, “So far, so good …”

Service Portal Form Fields, Part V

“There is nothing impossible to him who will try.”
Alexander the Great

After doing a lot of testing and tweaking, I decided that it was time to add a few more options to my list of supported types. Everything up to this point was still a basic text or textarea variation that didn’t require any special coding to produce the INPUT element. Now it is time to try something a little more sophisticated that will require a little bit of extra work. Both radio buttons and select statements work off of a list of choices from which you select your value, so I came up with yet another attribute to support types of this nature. I called it snh-choices, and the expectation is that you will provide a JSON array of label/value pairs in the order in which you want them to be presented. Then I added three new values to my list of supported types, choicelist, radio, and inlineradio. The only difference between radio and inlineradio is that the radio type lists the choices from top to bottom and the inlineradio type lists them from left to right. Other than that, they are essentially the same thing.

To support the new types, I created a couple of functions to render the appropriate INPUT element and the associated choices:

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) {
		console.log('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) {
		console.log('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;
}

To test out these new types, I modified my little test page yet again to add some additional form fields to try out each of the three new types. The HTML for that page now looks like this:

<snh-panel title="'${Form Field Test}'">
  <form id="form1" name="form1" novalidate>
    <div class="row">
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.firstName"
          snh-name="firstName"
          snh-label="First Name"
          snh-type="text"
          snh-required="true"
          snh-messages='{"required":"You must enter a First Name"}'/>
        <snh-form-field snh-form="form1"
          snh-model="c.data.middleName"
          snh-name="middleName"
          snh-label="Middle Name"
          snh-type="text"
          snh-required="false" />
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.lastName"
          snh-name="lastName"
          snh-label="Last Name"
          snh-type="text"
          snh-required="true"
          minlength="5"/>
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.email"
          snh-name="email"
          snh-label="Email Address"
          snh-type="email"
          snh-required="true"
          snh-messages='{"required":"You must enter an Email address", "email":"You must enter a valid Email address"}'/>
      </div>
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.textarea"
          snh-name="textarea"
          snh-label="Text Area"
          snh-type="textarea"
          maxlength="20"
          snh-help="This is where the field-level help is displayed."/>
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.choicelist"
          snh-name="choicelist"
          snh-label="Choice List"
          snh-type="choicelist"
          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-form="form1"
          snh-model="c.data.radio"
          snh-name="radio"
          snh-label="Radio"
          snh-type="radio"
          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-form="form1"
          snh-model="c.data.inlineradio"
          snh-name="inlineradio"
          snh-label="In-line Radio"
          snh-type="inlineradio"
          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"}]'/>
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12">
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.comments"
          snh-name="comments"
          snh-label="Comments"
          snh-type="textarea"/>
      </div>
    </div>
  </form>
</snh-panel>

And here is the screen shot of the latest version of the test page with the examples of the three new field types:

Form field tester with choice list and radio fields

Of course, that still leaves a lot to be tested, and even more that could be added, but I am starting to think that we are finally at that point where there is more work that has been completed than there is left to be done. Still, there are quite a few items that still remain on the needs to be done list:

  • Fix the way validation messages are displayed when the message changes, but the valid state does not,
  • Figure out how to obtain the name of the form in the template function so that it doesn’t have to be passed in as an attribute,
  • Test all of the field types that are specified in the list of valid field types,
  • Add even more choices to the list of valid field types, and
  • Revisit the form-field-extras DIV that we left out, and see what we might be able to with that feature if we brought that back into play.

As far as additional types go, I was thinking about seeing what I could do with the sn-record-picker, but that sounds like it could be its own challenge. That’s an AngularJS provider as well, and I’m not exactly sure how that would work with a provider inside of a provider. But I guess that’s how you find those things out. But I think I better stick with some of the other loose ends that I’ve skipped over before I start out on yet another experiment into the unknown. Although, it would be interesting to try …

Service Portal Form Fields, Part IV

“All progress is precarious, and the solution of one problem brings us face to face with another problem.”
Martin Luther King, Jr.

Some things I really like, and others not so much. I like the standard look and feel that you get when you use the same template for everything, and I really like the way that the required marker works without even leaving the field, but I’m not quite sold on the way that the field validation works just yet. I think that could use a little work somewhere, although I am not exactly sure where.

This is the line that triggers the validation:

scope.$watch(fullName + '.$valid', function (isValid) {

Basically, we are watching the $valid property of the field, and then updating the messages based on the $errors that are present for that particular field. Unfortunately, if you have a field with multiple validations, such as a required field with a minimum length, the field is not $valid when it is empty, and it is also not $valid when it is too short. When you start typing in the field, the required field error message does not get replaced with the field too short error message because the state never changed from not being $valid. This leaves a misleading error message underneath that field, which I don’t really care for. I may have to adjust my approach on that to work around that problem. But that’s not really today’s issue, so I’m going to have to set that aside for now.

What I really want to do is test what I have built so far. To expand my testing, I added a few more supported types, but I haven’t really tried them all just yet. Right now, my static list looks like this:

var SUPPORTED_TYPE = ['date', 'datetime-local', 'email', 'month', 'number', 'password', 'tel', 'text', 'textarea', 'time', 'url', 'week'];

Although I haven’t tried testing them all, I did expand my test page just a little to try a few more things out. I added some additional attributes to make sure that those got passed in OK, I added some custom error messages to verify that that process was working as it should, and then added an email field to see how that new type rendered out as well. Here is the latest version of that test page:

<snh-panel title="'${Form Field Test}'">
  <form id="form1" name="form1" novalidate>
    <div class="row">
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.firstName"
          snh-name="firstName"
          snh-label="First Name"
          snh-type="text"
          snh-required="true"
          snh-messages='{"required":"You must enter a First Name"}'/>
        <snh-form-field snh-form="form1"
          snh-model="c.data.middleName"
          snh-name="middleName"
          snh-label="Middle Name"
          snh-type="text"
          snh-required="false" />
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.lastName"
          snh-name="lastName"
          snh-label="Last Name"
          snh-type="text"
          snh-required="true"
          minlength="5"/>
      </div>
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.textarea"
          snh-name="textarea"
          snh-label="Text Area"
          snh-type="textarea"
          maxlength="20"
          snh-help="This is where the field-level help is displayed."/>
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.email"
          snh-name="email"
          snh-label="Email Address"
          snh-type="email"
          snh-required="true"
          snh-messages='{"required":"You must enter an Email address", "email":"You must enter a valid Email address"}'/>
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12">
        <snh-form-field
          snh-form="form1"
          snh-model="c.data.comments"
          snh-name="comments"
          snh-label="Comments"
          snh-type="textarea"/>
      </div>
    </div>
  </form>
</snh-panel>

… and here is how it renders out, after I triggered a few of the validation errors:

Form Field Validation Test

For the most part, I am still very pleased with the results so far, but I am definitely going to have to make some adjustments in the validation message area to clean up the way that works. There is still much to be done, but I am getting closer to having a somewhat finished product.

Service Portal Form Fields, Part III

“For the great doesn’t happen through impulse alone, and is a succession of little things that are brought together.”
Vincent van Gogh

It’s only just the very beginnings, but I am really starting to like how things are turning out, at least so far. There is a lot left to do, and so much so that it is difficult to decide on what should be tackled next, but at least things are starting to take shape, and you can begin to see where things might lead. If you were paying close attention, you will notice that my initial HTML structure left out a couple of key elements from the example that we lifted from the Incident form. One is the hidden INPUT element that contains the original value and the other is the DIV element that contains all of the “extras” — that collection of little icons to the right of the INPUT element that do things like pop up the details for a reference field or provide some other functionality related to the value of the field. The first I skipped because I wasn’t exactly sure what value that provided in the AngularJS world, and the second I skipped because I’m not exactly sure how I would support that feature at this point. I may go back to one or both of those at some point, but for now, I’ve left those out of this little experiment.

So, the question remains: where do we go from here? Although I’m seriously tempted to start adding support for additional input types beyond text and textarea, I think I will defer that for now and start digging into field validation. I’ve been doing a little reading on HTML5 form validation and AngularJS form validation, and it appears that you have to make a conscious decision to go one way or another. Not being that familiar with either one, it will be a learning experience for me either way, so I thought briefly about just flipping a coin just to get the deciding part over with. After further review, though, I decided to go the AngularJS route, mainly because I need to learn a lot more about AngularJS anyway.

As usual, I hunt down the easy parts first and get those out of the way. There are a handful of static values that I want to use for reference purposes, so I came up with those first:

var SUPPORTED_TYPE = ['text', 'textarea'];
var RESERVED_ATTR = ['ng-model', 'class', 'id', 'name', 'required'];
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"
};

The first is an array of supported types, which right now just stands at text and textarea. I plan to expand on that in the future, and this array will help make those additions a little easier. The second is an array of reserved attributes, and the purpose of that one is to allow me to copy all of the attributes from the original snh-form-field element over to the generated input element, except those that are on that list (well, those that are on that list, plus any that start with snh- or $). That way, developers can specify any additional attributes and they will be carried forward as long as they are not already used in the processing of the directive. The last is a list of standard validation messages, which I also might expand on at some point in the future. I found that initial list of potential keys out on the Interwebs somewhere, and it looked like a good place to start, so I went ahead and grabbed it and tried to come with an appropriate message for each one.

.I already included the code that uses that first one in my last installment, but let’s reproduce it here, just to show how that is supposed to work:

var type = attrs.snhType || 'text';
type = type.toLowerCase();
if (SUPPORTED_TYPE.indexOf(type) == -1) {
	type = 'text';
}

Initially, we get the type from the snh-type attribute, or if you don’t provide one, we default it to text. Then, we convert it to lower case and check it against our array of supported types. If we don’t find it on the list, then again, we default it over to text. This ensures that we are always working with a valid, supported type.

The second array contains any attributes that might be used in our template, and we maintain that list so that we don’t pass along any conflicting values for any of those. When we copy over the remaining attributes, which we do in a function so that it can be called from multiple places, we use the list in much the same was as we did this first one, but this time we are not looking for the ones to keep, but rather the ones to avoid.

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

Basically, if it doesn’t start with snh and it doesn’t start with $ and it isn’t found on the list, then we go ahead and pass it through to the generated INPUT field, whatever that happens to be.

The last item is not an array, but a simple object, or data map, of the standard messages to use for the known list of possible validation error key values. I created an attribute called snh-messages so that developers could override these messages, but the basic idea here that there is a default message for everything, and if that’s good enough for your purposes, then there is nothing more for you to have to do.

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/>';
}

The above code loops through all of the validation errors, and if an override value was provided, that is the message that will be used. If not, and there is a standard message available, then that is the message that will be used. If there is no override and no standard message, then we report the fact that we do not have a message to go along with that particular error, and include the error key.

So, the full thing looks like this at this point:

function() {
	var SUPPORTED_TYPE = ['text','textarea'];
	var RESERVED_ATTR = ['ng-model', 'class', 'id', 'name', 'required'];
	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 = attrs.snhForm;
			var name = attrs.snhName;
			var fullName = form + '.' + name;
			var type = attrs.snhType || 'text';
			type = type.toLowerCase();
			if (SUPPORTED_TYPE.indexOf(type) == -1) {
				type = 'text';
			}
			var required = attrs.snhRequired && attrs.snhRequired.toLowerCase() == 'true';
			var model = attrs.snhModel;
			var value = '';
			htmlText += "    <div id=\"element." + name + "\" class=\"form-group\">\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 + ">''?'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 == 'textarea') {
				htmlText += "      <textarea class=\"form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\"" + passThroughAttributes(attrs) + (required?' required':'') + "></textarea>\n";
			} else {
				htmlText += "      <input class=\"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 + ".$invalid\" class=\"snh-error\">{{" + fullName + ".validationErrors}}</div>\n";
			htmlText += "     </div>\n";
			return htmlText;

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

				return tags;
			}
		},
		link: function(scope, element, attrs, ctls) {
			var form = ctls[0].$name;
			var name = attrs.snhName;
			var fullName = form + '.' + name;
			var overrides = {};
			if (attrs.snhMessages) {
				overrides = JSON.parse(attrs.snhMessages);
			}
			scope.$watch(fullName + '.$valid', function (isValid) {
				var elem = scope.$eval(fullName);
				elem.validationErrors = '';
				var separator = '';
				if (!isValid) {
					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/>';
					}
				}
			});
		}
	};
}

One thing that I had to do temporarily was to pass the name of the form to the template as an attribute. I don’t think that I should have to do that, and I really don’t like it, but I spent way too much time trying to figure out how to pick that up from the element itself with no success, so I put that aside for now and just passed it in as another attribute. I don’t like that, though, and I really don’t think that that should be necessary, but for now, I have put that aside for another day and intend to go back to it at some point and make that right.

There is still a lot more to do, but it is starting to come together. Next time, I think I will do a little testing to make sure all of this works, and then maybe explore supporting a few more different type values.

Service Portal Form Fields

“Everything begins with an idea.”
Earl Nightingale

Even though I tend to prefer the Service Portal environment over the original ServiceNow UI, the one thing that you just can’t beat on the original side of the house is the built-in support for form fields. As soon as you create a Table in ServiceNow, a corresponding form is also created, built using a generic infrastructure that is driven off of the embedded data dictionary. Every field on the form is formatted in a consistent way using consistent components supporting consistent features. So far, I have not seen anything comparable to that in the Service Portal environment.

On a standard ServiceNow UI form, every field on the page will have the same set of standard elements based on the same basic template, customized to the specifications of the specific field as contained in the data dictionary. For example, here is the HTML from the Incident ID field on the Incident form:

   <div id="element.incident.number" class="form-group " style="">
      <div class="" data-type="label" choice="0" type="string" id="label.incident.number" nowrap="true"><label onclick="return labelClicked(this);" for="incident.number" dir="ltr" class=" col-xs-12 col-md-3 col-lg-4 control-label"><span id="status.incident.number" data-dynamic-title="" mandatory="false" oclass="" class=" label_description" aria-label=""></span><span title="" class="label-text" data-html="false" data-original-title="">Number</span></label></div>
      <div class="col-xs-10 col-sm-9 col-md-6 col-lg-5 form-field input_controls">
         <div ng-non-bindable="" class="hidden"><input id="sys_original.incident.number" name="sys_original.incident.number" value="INC0010037" type="hidden"></div>
         <input id="incident.number" name="incident.number" aria-required="false" onchange="onChange('incident.number');" maxlength="40" value="INC0010037" style="; " autocomplete="off" ng-non-bindable="" class="form-control" spellcheck="false" type="text">
      </div>
      <div class="col-xs-2 col-sm-3 col-lg-2 form-field-addons"></div>
   </div>

Looking through the code, you can spot a number of unique standard elements, each with its own specific purpose, and many with a standard ID:

  • element.incident.number
  • label.incident.number
  • status.incident.number
  • sys_original.incident.number
  • incident.number

So, in theory, it seems as if you could use the above block of HTML as a guide and set up some kind of AngularJS component to replicate that entire concept over on the Service Portal side of the house. Unfortunately, I’m not all that familiar with AngularJS, but it sounds like an interesting challenge. Who knows … maybe someone has already put this together. Surely someone smarter than I am has already thought of doing this, and maybe has already figured all of it out. Even if that’s true, it would still be fun to try on my own. You know what I always ask myself: how hard could it be?