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.