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 …”