Adding Detail to the Upgrade History Task

“Ask not that the journey be easy; ask instead that it be worth it.”
John F. Kennedy

With the addition of the Paris Upgrade Center, a new type of Task was introduced, the Upgrade History Task. Whenever you upgrade your instance, issues that require your attention will now produce an Upgrade History Task, which you can assign to the appropriate resources for resolution. This is a nice feature, and fairly well implemented, but the form layout is not quite organized in the way that I would like to see it, and there are some tidbits of data from various other, related tables that I would really like to see all in one place. Another thing that annoys me is that the Upgrade Details Related Link opens up a new window and breaks out of the iFrame, losing the header and sidebar menu. That page has a link back to the Upgrade History Task, and if you click back and forth a few times, suddenly you have all kinds of windows open, and none of them have navigation anymore. I don’t like that.

So, I thought about making a bunch of UI Formatters and rearranging the stock form layout to include all of the information that I like to see when I am working an upgrade issue, but the more upgrades that I work on, the less I like to tinker with stock components. Ultimately, I decided to just add a single UI Action that would pop up a modal dialog box that contained the information that I was looking for. Here are the things that I wanted to see:

  • A standard link to the Skip record that didn’t open up a new window,
  • The name of the affected table,
  • A link to the affected record,
  • When the affected record was last updated,
  • Who last updated the affected record,
  • Any indication that we have dealt with this issue before, and if so,
  • The details of what was done with this the last time that it came up in an upgrade.

None of that is present on the form right now, but I didn’t see any reason that we couldn’t pull it all together from the various sources, so I went to work. It seemed like there would be a bit of database querying to get all of this information, and I didn’t really want all of that on the page itself, so I started out by making myself a little Script Include to house all of major code. I called it UpgradeTaskUtils and created a single function called getAdditionalInfo to pull together all of the data.

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

    getAdditionalInfo: function(sysId) {
		var details = {};

		// all of the hard work goes here!

		return details;
	},

	type: 'UpgradeTaskUtils'
};

The first thing that we will need to do, of course, is to use the incoming sysId argument to go out and get the Upgrade History Record, and once we do that, we can then pull out the associated Skip record.

details.taskGR = new GlideRecord('upgrade_history_task');
if (details.taskGR.get(sysId)) {
	details.logGR = details.taskGR.upgrade_detail.getRefRecord();
	if (details.logGR && details.logGR.isValidRecord()) {
		// more code to follow ...
	}
}

Once we know that we have a valid Skip record, the next thing that we will want to do is go get the actual record that has the issue. That’s a little more complicated and uses a table called sys_metadata.

var metaGR = new GlideRecord('sys_metadata');
if (metaGR.get('sys_update_name', details.logGR.file_name.toString())) {
	details.recordGR = new GlideRecord(metaGR.sys_class_name);
	if (details.recordGR.get(metaGR.sys_id)) {
		details.lastRecordUpdate = details.recordGR.getDisplayValue('sys_updated_on');
		details.lastRecordUpdateBy = details.recordGR.getDisplayValue('sys_updated_by');
		// more code to follow ...
	}
}

Since the sys_updated_by fields is just a user_name string and not an actual reference to a User record, if we want to have the details on the User who last updated the record, we will need to go out and fetch that separately.

var userGR = new GlideRecord('sys_user');
if (userGR.get('user_name', details.lastRecordUpdateBy)) {
	details.userName = userGR.getDisplayValue('name');
	details.userSysId = userGR.getUniqueValue();
	details.userLink = '<a href="/sys_user.do?sys_id=' + details.userSysId + '">' + details.userName + '</a>';
}

That takes care of all of the interrelated records involved with this Task, but there is still more work to do if we want any historical data for this same artifact in previous upgrades. Basically, we want to find all of the records in the Upgrade History Task table that reference this same component, except for the one that we already have. We can just do a quick count to start off with, just to see if there is any point in looking any further.

details.previousIssues = 0;
details.prevIssueQuery = 'upgrade_detail.file_name=' + details.taskGR.upgrade_detail.file_name + '^number!=' + details.taskGR.number;
var taskGA = new GlideAggregate('upgrade_history_task');
taskGA.addAggregate('COUNT');
taskGA.addEncodedQuery(details.prevIssueQuery);
taskGA.query();
if (taskGA.next()) {
	details.previousIssues = taskGA.getAggregate('COUNT');
}

Now that we have a count of what’s out there, we can gather up all of the details if the count is greater than zero.

details.prevIssueLink = details.previousIssues;
if (details.previousIssues > 0) {
	details.prevIssueLink = '<a href="/upgrade_history_task_list.do?sysparm_query=' + details.prevIssueQuery + '">' + details.previousIssues + '</a>';
	var taskGR = new GlideRecord('upgrade_history_task');
	taskGR.addEncodedQuery(details.prevIssueQuery);
	taskGR.orderByDesc('sys_created_on');
	taskGR.query();
	if (taskGR.next()) {
		details.previousUpgrade = taskGR.getDisplayValue('upgrade_detail.upgrade_history.to_version');
		details.previousComments = taskGR.getDisplayValue('upgrade_detail.comments');
	}
}

That’s everything that I am looking for right at the moment. I may end up going back one day and tossing in a few more items, but for now, this should do the trick. All together, the new Script Include looks like this:

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

    getAdditionalInfo: function(sysId) {
		var details = {};

		details.taskGR = new GlideRecord('upgrade_history_task');
		if (details.taskGR.get(sysId)) {
			details.logGR = details.taskGR.upgrade_detail.getRefRecord();
			if (details.logGR && details.logGR.isValidRecord()) {
				var metaGR = new GlideRecord('sys_metadata');
				if (metaGR.get('sys_update_name', details.logGR.file_name.toString())) {
					details.recordGR = new GlideRecord(metaGR.sys_class_name);
					if (details.recordGR.get(metaGR.sys_id)) {
						details.lastRecordUpdate = details.recordGR.getDisplayValue('sys_updated_on');
						details.lastRecordUpdateBy = details.recordGR.getDisplayValue('sys_updated_by');
						var userGR = new GlideRecord('sys_user');
						if (userGR.get('user_name', details.lastRecordUpdateBy)) {
							details.userName = userGR.getDisplayValue('name');
							details.userSysId = userGR.getUniqueValue();
							details.userLink = '<a href="/sys_user.do?sys_id=' + details.userSysId + '">' + details.userName + '</a>';
						}
					}
				}
			}
			details.previousIssues = 0;
			details.prevIssueQuery = 'upgrade_detail.file_name=' + details.taskGR.upgrade_detail.file_name + '^number!=' + details.taskGR.number;
			var taskGA = new GlideAggregate('upgrade_history_task');
			taskGA.addAggregate('COUNT');
			taskGA.addEncodedQuery(details.prevIssueQuery);
			taskGA.query();
			if (taskGA.next()) {
				details.previousIssues = taskGA.getAggregate('COUNT');
			}
			details.prevIssueLink = details.previousIssues;
			if (details.previousIssues > 0) {
				details.prevIssueLink = '<a href="/upgrade_history_task_list.do?sysparm_query=' + details.prevIssueQuery + '">' + details.previousIssues + '</a>';
				var taskGR = new GlideRecord('upgrade_history_task');
				taskGR.addEncodedQuery(details.prevIssueQuery);
				taskGR.orderByDesc('sys_created_on');
				taskGR.query();
				if (taskGR.next()) {
					details.previousUpgrade = taskGR.getDisplayValue('upgrade_detail.upgrade_history.to_version');
					details.previousComments = taskGR.getDisplayValue('upgrade_detail.comments');
				}
			}
		}

		return details;
	},

	type: 'UpgradeTaskUtils'
};

Now we need put all of this data on page that we can use for our modal pop-up. I created a new UI Page called upgrade_history_task_info, and started it out by calling our new Script Include to obtain the data.

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
	<g:evaluate var="jvar_not_used">
var utu = new UpgradeTaskUtils();
var obj = utu.getAdditionalInfo(RP.getWindowProperties().get('task_sys_id'));
obj.lastRecordUpdate;		
	</g:evaluate>
	<div style="padding: 20px;">
		<!-- display grid goes here -->
	</div>
</j:jelly>

To format the data, I just used a basic HTML table, with one column for the labels and another for the data.

<table>
	<tr>
		<th style="padding: 5px;">Issue Details:</th>
		<td style="padding: 5px;"><a href="/${obj.logGR.getTableName()}.do?sys_id=${obj.logGR.getUniqueValue()}">${obj.logGR.getDisplayValue()}</a></td>
	</tr>
	<tr>
		<th style="padding: 5px;">Affected Table:</th>
		<td style="padding: 5px;">${obj.recordGR.getLabel()} (${obj.recordGR.getTableName()})</td>
	</tr>
	<tr>
		<th style="padding: 5px;">Affected Record:</th>
		<td style="padding: 5px;"><a href="/${obj.recordGR.getTableName()}.do?sys_id=${obj.recordGR.getUniqueValue()}">${(obj.recordGR.getDisplayValue() > ''?obj.recordGR.getDisplayValue():obj.recordGR.getUniqueValue())}</a></td>
	</tr>
	<tr>
		<th style="padding: 5px;">Record Last Updated:</th>
		<td style="padding: 5px;">${obj.recordGR.getDisplayValue('sys_updated_on')}</td>
	</tr>
	<tr>
		<th style="padding: 5px;">Record Last Updated By:</th>
		<td style="padding: 5px;"><g:no_escape>${(obj.userSysId > ''?obj.userLink:obj.recordGR.getDisplayValue('sys_updated_by'))}</g:no_escape></td>
	</tr>
	<tr>
		<th style="padding: 5px;">Previous Upgrade Issues:</th>
		<td style="padding: 5px;"><g:no_escape>${obj.prevIssueLink}</g:no_escape></td>
	</tr>
	<j:if test="${obj.previousIssues > 0}">
		<tr>
			<th style="padding: 5px;">Last Upgrade w/Issue:</th>
			<td style="padding: 5px;">${obj.previousUpgrade}</td>
		</tr>
		<tr>
			<th style="padding: 5px;">Last Upgrade Comments:</th>
			<td style="padding: 5px;">${obj.previousComments}</td>
		</tr>
	</j:if>
</table>

Now we have our data and we have it laid out nicely on a page, all that’s left to do is to pop it up on the screen. For that, we will need to build a UI Action. I called mine Additional Info, linked it to the Upgrade History Task table, and gave it the following onClick script:

function openAdditionalInfo() {
	var dialog = new GlideDialogWindow('upgrade_history_task_info');
	dialog.setSize(600, 600);
	dialog.setTitle('Additional Info');
	dialog.setPreference('task_sys_id', NOW.sysId);
	dialog.render();
}

That’s pretty much all there is to that. We still need to pull it up and click on it and see what happens, but assuming that all goes well, this exercise should have produced a nice little tool to make plodding through the skipped records in an upgrade just a little bit easier.

Additional Info modal pop-up on the Upgrade History Task form

Nice! Now all we need to do is gather up all of the parts and stuff them into an Update Set.

Content Selector Configuration Editor, Part IX

“Plans are only good intentions unless they immediately degenerate into hard work.”
Peter Drucker

It seemed as if we had this topic all wrapped up a while back, but then we had to play around with the Buttons and Icons. That should have been the end of that, but then we went and added Bulk Actions to the hacked up Data Table widget, which has now broken our new Content Selector Configuration Editor. So now we have to add support for the Bulk Action configuration to the editor to put things back together again. Fortunately, this is all very similar stuff, so it is mainly just a matter of making some copies of things that we have already built and then hacking them up just a bit to fit the current need.

To start with, we will need a Bulk Action Editor pop-up very similar to all of the other pop-up editors that we have been creating, and since Bulk Actions only have a name and a label for properties, the State Editor seems like the best choice for something to copy, since it too only has a name and a label for properties. Once we pull that widget up on the widget form, change the name, and then select Insert and Stay from the context menu, we can start working on our new copy. There is literally nothing to change with the HTML:

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="persp"
      snh-label="Name"
      snh-required="true"/>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

… and only a function name change on the client script:

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

	$scope.cancel = function() {
		$timeout(function() {
			angular.element('[ng-click*="buttonClicked"]').get(0).click(); 
		});
	};

	$scope.save = function() {
		if ($scope.form1.$valid) {
			$timeout(function() {
				angular.element('[ng-click*="buttonClicked"]').get(1).click(); 
			});
		} else {
			$scope.form1.$setSubmitted(true);
		}
	};

	$timeout(function() {
		angular.element('[class*="modal-footer"]').css({display:'none'});
	}, 100);
}

As you may recall, there is no server-side code for these guys, so that’s all there is to that. Now we have a Bulk Action Editor. There is a little more work involved in the main Content Selector Configurator widget. On the HTML, we need to define a table for the Bulk Actions:

<div id="label.actarray" class="snh-label" nowrap="true">
  <label for="actarray" class="col-xs-12 col-md-4 col-lg-6 control-label">
    <span id="status.actarray"></span>
    <span title="Bulk Actions" data-original-title="Bulk Actions">${Bulk Actions}</span>
  </label>
</div>
<table class="table table-hover table-condensed">
  <thead>
    <tr>
      <th style="text-align: center;">${Label}</th>
      <th style="text-align: center;">${Name}</th>
      <th style="text-align: center;">${Edit}</th>
      <th style="text-align: center;">${Delete}</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="act in tbl[state.name].actarray" ng-hide="btn.removed">
      <td data-th="${Label}">{{act.label}}</td>
      <td data-th="${Name}">{{act.name}}</td>
      <td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editAction(act)" alt="Click here to edit this Bulk Action" title="Click here to edit this Bulk Action" style="cursor: pointer;"/></td>
      <td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteAction(act, tbl[state.name].actarray)" alt="Click here to delete this Bulk Action" title="Click here to delete this Bulk Action" style="cursor: pointer;"/></td>
    </tr>
  </tbody>
</table>
<div style="width: 100%; text-align: right;">
  <action ng-click="editAction('new', tbl[state.name].actarray, tbl);" class="btn btn-primary ng-binding ng-scope" role="action" title="Click here to add a new Bulk Action">Add a new Bulk Action</action>
</div>

On the client side, we need to add a couple more functions to handle the editing and deleting of the actions, which we can basically copy from the same functions we already have for editing and deleting buttons and icons.

$scope.editAction = function(action, actArray) {
	var shared = {page_id: {value: '', displayValue: ''}};
	if (action != 'new') {
		shared.label = action.label;
		shared.name = action.name;
	}
	spModal.open({
		title: 'Bulk Action Editor',
		widget: 'bulk-action-editor',
		shared: shared
	}).then(function() {
		if (action == 'new') {
			action = {};
			actArray.push(action);
		}
		action.label = shared.label || '';
		action.name = shared.name || '';
	});
};

$scope.deleteAction = function(action, actArray) {
	var confirmMsg = '<b>Delete Bulk Action</b>';
	confirmMsg += '<br/>Are you sure you want to delete the ' + action.label + ' Bulk Action?';
	spModal.confirm(confirmMsg).then(function(confirmed) {
		if (confirmed) {
			var a = -1;
			for (var b=0; b<actArray.length; b++) {
				if (actArray[b].name == action.name) {
					a = b;
				}
			}
			actArray.splice(a, 1);
		}
	});
};

On the server side, we just need to add some code to format the Bulk Action section of the table configurations, which we can copy from the code that formats the Buttons/Icons section, and then delete all of the extra properties that are not needed for Bulk Actions.

script += ",\n				actarray: [";
lastSeparator = '';
for (var a=0; a<tableTable[tableState.name].actarray.length; a++) {
	var thisAction = tableTable[tableState.name].actarray[a];
	script += lastSeparator;
	script += "{\n					name: '";
	script += thisAction.name;
	script += "',\n					label: '";
	script += thisAction.label;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

Now all we need to do is pull up our modified ButtonTestConfig script in the editor and see how we did.

Content Selector Configuration Editor with new Bulk Actions section

So far, so good. Now let’s pull up that new Bulk Action Editor and see how that guy works:

Bulk Action Editor

Not bad. A little bit of testing here and there, just to make sure that we didn’t break anything along the way, and we should be good to go. Now if we can just stop tinkering with things, this Update Set should be the final release and we can move on to other exciting adventures.

Bulk Actions in the Service Portal Data Table Widget

“Innovation comes from people who take joy in their work.”
W. Edwards Deming

Since the day that I first touched the Data Table widget, I have somehow kept finding reasons to go back and tinker with it some more for one reason or another. While I was playing around with the buttons and icons feature that I added, I noticed that one of the other features that was present in the primary UI, but missing in the Service Portal Data Table widget, was the ability to select more than one row and perform some action on all of the selected rows. That got the little wheels spinning around inside of my head and I started trying to figure out just what it would take to implement that feature in my hacked up version of the stock widget. It seemed to me that you would need a number of things:

  • A way to pass in one or more bulk actions as part of the configuration,
  • A master checkbox in the headings that you could use to toggle all of the individual checkboxes off and on,
  • A checkbox on every row,
  • A select statement in the footer with the choices being all of the specified bulk actions, and
  • Some client side scripts to handle the clicks on the master checkbox and the action selector.

We already pass in an Array of Buttons/Icons, so why not an Array of Bulk Actions? We should be able to copy most of that code wherever it lives, since this would pretty much be handled in the same way. Having at least one item in the Array could drive the visibility of the checkboxes and select statement, and as far as processing the Action is concerned, we could take the same approach that we took with the buttons and just broadcast the selected action and let some other widget deal with the actual response whenever a Bulk Action was clicked. It all seemed simple enough to give it a go, so I started hacking up the HTML, first for the master checkbox in the heading row:

<th ng-if="data.actarray.length > 0" class="text-nowrap center" tabindex="0">
  <input type="checkbox" ng-model="data.master_checkbox" ng-click="masterCheckBoxClick();"/>
</th>

Then I did the same for the individual checkboxes in the data rows:

<td ng-if="data.actarray.length > 0" class="text-nowrap center" tabindex="0">
  <input type="checkbox" ng-model="item.selected"/>
</td>

And then to finish out the HTML changes, I added an extra footer down at the bottom for the SELECT element:

<div class="panel-footer" ng-if="data.actarray.length > 0 && data.row_count">
  <div class="btn-toolbar m-r pull-left">
    <select class="form-control" ng-model="data.bulk_action" ng-click="bulkActionSelected();">
      <option value="">${Actions on selected rows ...}</option>
      <option ng-repeat="opt in data.actarray" value="{{opt.name}}">{{opt.label}}</option>
    </select>
  </div>
  <span class="clearfix"></span>
</div>

That took care of the HTML. On the server-side code, I added a couple of new items to the comments explaining all of the various options, and then added the bulk actions to the list of things that get copied in:

 * data.buttons = the JSON string containing the button specifications
 * data.btnarray = the array of button specifications
 * data.refpage = the JSON string containing the reference link specifications
 * data.refmap = the reference link specifications object
 * data.bulkactions = the JSON string containing the bulk action specifications
 * data.actarray = the bulk actions specifications object
 */
// copy to data[name] from input[name] || option[name]
optCopy(['table', 'buttons', 'btns', 'refpage', 'bulkactions', 'p', 'o', 'd', 'filter',
	'filterACLs', 'fields', 'field_list', 'keywords', 'view', 'relationship_id',
	'apply_to', 'apply_to_sys_id', 'window_size', 'show_breadcrumbs']);

I also copied the code that converts the buttons string into the btnarray array and hacked it up to convert the bulkactions string into an actarray array,

if (data.bulkactions) {
	try {
		var actioninfo = JSON.parse(data.bulkactions);
		if (Array.isArray(actioninfo)) {
			data.actarray = actioninfo;
		} else if (typeof actioninfo == 'object') {
			data.actarray = [];
			data.actarray[0] = actioninfo;
		} else {
			gs.error('Invalid bulk actions in SNH Data Table widget: ' + data.bulkactions);
			data.actarray = [];
		}
	} catch (e) {
		gs.error('Unparsable bulk actions in SNH Data Table widget: ' + data.bulkactions);
		data.actarray = [];
	}
} else {
	data.actarray = [];
}

Over on the client side, I added two functions to the $scope, one for the master check box:

$scope.masterCheckBoxClick = function() {
	for (var i in c.data.list) {
		c.data.list[i].selected = c.data.master_checkbox;
	}
};

… and one for the bulk action selection:

$scope.bulkActionSelected = function() {
	if (c.data.bulk_action) {
		var parms = {};
		parms.table = c.data.table;
		parms.selected = [];
		for (var x in c.data.list) {
			if (c.data.list[x].selected) {
				parms.selected.push(c.data.list[x]);
			}
		}
		if (parms.selected.length > 0) {
			for (var b in c.data.actarray) {
				if (c.data.actarray[b].name == c.data.bulk_action) {
					parms.action = c.data.actarray[b];
				}
			}
			$rootScope.$emit(eventNames.bulkAction, parms);
		} else {
			spModal.alert('You must select at least one row for this action');
		}
	}
	c.data.bulk_action = '';
};

That took care of the root Data Table widget, but I still needed to do a little work on the SNH Data Table from URL Definition widget to pull our new query parameter down from the URL. That turned out to be just a simple addition to this line to add the new parameter name to the list of parameters to be copied:

copyParameters(data, ['p', 'o', 'd', 'filter', 'buttons', 'refpage', 'bulkactions']);

I also needed to copy the button code in the Content Selector widget to create similar code for the new bulk actions:

if (tableInfo[state].actarray && Array.isArray(tableInfo[state].actarray) && tableInfo[state].actarray.length > 0) {
	s.bulkactions = JSON.stringify(tableInfo[state].actarray);
}

Now we can go into the ButtonTestConfig Script Include that we built the other day and add a couple of bulk actions so that we can test this out:

Adding a few bulk actions to the test configuration

Now, let’s pull up our button_test page and see what we’ve got.

First look at our bulk actions modifications

Not too bad … we have our master checkbox in the heading row, the individual check boxes in the data rows, and our bulk action selector in the new extra footer. Very nice. And we can even test for the requirement that you have to select at least one item by selecting an action without selecting any rows.

Selecting an action without selecting any rows

Well, that seems to work. The master checkbox also seemed to work as desired, and selecting a few rows and then selecting an action also seemed to work, but since there is currently no one listening on the other end, it’s kind of hard to tell if that actually did anything or not. Maybe we can modify our Button Click Handler Example widget to listen for bulk actions as well. Maybe add something like this:

$rootScope.$on('data_table.bulkAction', function(e, parms) {
	displayBulkActionDetails(parms);
});

function displayBulkActionDetails(parms) {
	var html = '<div>'; 
	html += ' <div class="center"><h3>You selected the ' + parms.action.name + ' bulk action</h3></div>\n';
	html += ' <table>\n';
	html += '  <tbody>\n';
	html += '   <tr>\n';
	html += '    <td class="text-primary">Table: &nbsp;</td>\n';
	html += '    <td>' + parms.table + '</td>\n';
	html += '   </tr>\n';
	html += '   <tr>\n';
	html += '    <td class="text-primary">Action: &nbsp;</td>\n';
	html += '    <td><pre>' + JSON.stringify(parms.action, null, 4) + '</pre></td>\n';
	html += '   </tr>\n';
	html += '   <tr>\n';
	html += '    <td class="text-primary">Records: &nbsp;</td>\n';
	html += '    <td><pre>' + JSON.stringify(parms.selected, null, 4) + '</pre></td>\n';
	html += '   </tr>\n';
	html += '  </tbody>\n';
	html += ' </table>\n';
	html += '</div>';
	spModal.alert(html);
}

Let’s give that a whirl …

Bulk action listener results

Beautiful. It all appears to work as intended. Clearly some additional testing is warranted, but it’s not bad for an initial effort. I think it’s good enough to release an Update Set with all of the code. Of course, now we have broken our new Content Selector Configuration Editor, since that was not built to handle bulk actions, but that’s a problem for another day.

Content Selector Configuration Editor, Part VIII

“Tinkering is something we need to know how to do in order to keep something like the space station running. I am a tinkerer by nature.”
Leroy Chiao

I know what you are thinking: Didn’t we wrap this series up last time with the release of the final Update Set? Well, technically you would be correct in that we finished building all of the parts and bundled them all up in an Update Set for those of you who like to play along at home, but … we really did not spend a whole lot of time on the purpose for all of this, so I thought it might be a good time to back up the truck just a tad and demo some of the features of the customized data table widget and the associated content selector widget. Mainly, I want to talk about the buttons and icons and how all of that works, but before we do that, let’s go all the way back and talk about the basic idea behind these customizations of some pretty cool stock products.

I really liked the stock Data Table widget that comes bundled with the Service Portal, but the one thing that annoyed me was that each row was one huge clickable link, which was a departure from the primary UI, where every column could potentially be a link if it contained a Reference field. So, I rewired it. Of course, once you start playing with something then you end up doing all kinds of other crazy things and before you know it, you have this whole subset of trinkets and doodads that only you know anything about. So, on occasion, I feel the need to stop talking about what I am building and how it is constructed, and spend a little time talking about how to use it and what it is good for. Hence, today’s discussion of buttons and icons on the customized Data Table widget.

Before we get started, though, I do need to confess that in all of the excitement around creating a way to edit the JSON configuration object for the Configurable Data Table Widget Content Selector, I completely forgot about one of the options for handling a button click: opening up a new portal page. When we first introduced the idea of having buttons and icons on the rows of the Data Table widget, we made allowances for adding a page_id property to the button definition, and if that property were valued, we would link to that page on a click; otherwise, we would broadcast the click details. We did not include the page_id property in either the Content Selector Configuration Editor widget or the Button/Icon Editor widget, so let’s correct that oversight right now. First, we will need to add that property to the HTML for the table of Buttons/Icons.

<table class="table table-hover table-condensed">
  <thead>
    <tr>
      <th style="text-align: center;">${Label}</th>
      <th style="text-align: center;">${Name}</th>
      <th style="text-align: center;">${Heading}</th>
      <th style="text-align: center;">${Icon}</th>
      <th style="text-align: center;">${Icon Name}</th>
      <th style="text-align: center;">${Color}</th>
      <th style="text-align: center;">${Hint}</th>
      <th style="text-align: center;">${Page}</th>
      <th style="text-align: center;">${Edit}</th>
      <th style="text-align: center;">${Delete}</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="btn in tbl[state.name].btnarray" ng-hide="btn.removed">
      <td data-th="${Label}">{{btn.label}}</td>
      <td data-th="${Name}">{{btn.name}}</td>
      <td data-th="${Heading}">{{btn.heading}}</td>
      <td data-th="${Icon}" style="text-align: center;">
        <a ng-if="btn.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{btn.color || 'default'}}" title="{{btn.hint}}" data-original-title="{{btn.hint}}">
          <span class="icon icon-{{btn.icon}}" aria-hidden="true"></span>
          <span class="sr-only">{{btn.hint}}</span>
         </a>
       </td>
       <td data-th="${Icon Name}">{{btn.icon}}</td>
       <td data-th="${Color}">{{btn.color}}</td>
       <td data-th="${Hint}">{{btn.hint}}</td>
       <td data-th="${Page}">{{btn.page_id}}</td>
       <td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editButton(btn)" alt="Click here to edit this Button/Icon" title="Click here to edit this Button/Icon" style="cursor: pointer;"/></td>
       <td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteButton(btn, tbl[state.name].btnarray)" alt="Click here to delete this Button/Icon" title="Click here to delete this Button/Icon" style="cursor: pointer;"/></td>
     </tr>
   </tbody>
 </table>

… and then we will need to pass it back and forth between the main widget and the pop-up Button/Icon Editor widget:

$scope.editButton = function(button, btnArray) {
	var shared = {page_id: {value: '', displayValue: ''}};
	if (button != 'new') {
		shared.label = button.label;
		shared.name = button.name;
		shared.heading = button.heading;
		shared.icon = button.icon;
		shared.color = button.color;
		shared.hint = button.hint;
		shared.page_id = {value: button.page_id, displayValue: button.page_id};
	}
	spModal.open({
		title: 'Button/Icon Editor',
		widget: 'button-icon-editor',
		shared: shared
	}).then(function() {
		if (button == 'new') {
			button = {};
			btnArray.push(button);
		}
		button.label = shared.label || '';
		button.name = shared.name || '';
		button.heading = shared.heading || '';
		button.icon = shared.icon || '';
		button.color = shared.color || '';
		button.hint = shared.hint || '';
		button.page_id = shared.page_id.value || '';
	});
};

… and then, of course, we need to update the Button/Icon Editor itself by dragging in the page selector form field from the Reference Page Editor widget and adding it to the fields on the Button/Icon Editor form.

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="the_name"
      snh-label="Name"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.heading"
      snh-name="heading"/>
    <snh-form-field
      snh-model="c.widget.options.shared.icon"
      snh-name="icon"
      snh-type="icon"/>
    <snh-form-field
      snh-model="c.widget.options.shared.color"
      snh-name="color"
      snh-type="select"
      snh-choices='[{"label":"default","value":"default"},{"label":"primary","value":"primary"},{"label":"secondary","value":"secondary"},{"label":"success","value":"success"},{"label":"danger","value":"danger"},{"label":"warning","value":"warning"},{"label":"info","value":"info"},{"label":"light","value":"light"},{"label":"dark","value":"dark"},{"label":"muted","value":"muted"},{"label":"white","value":"white"}]'/>
    <snh-form-field
      snh-model="c.widget.options.shared.hint"
      snh-name="hint"/>
    <snh-form-field
      snh-type="reference"
      snh-model="c.widget.options.shared.page_id"
      snh-name="page_id"
      placeholder="Choose a Portal Page"
      table="'sp_page'"
      display-field="'title'"
      display-fields="'id'"
      value-field="'id'"
      search-fields="'id,title'"/>
    <div id="element.example" class="form-group">
      <div id="label.example" class="snh-label" nowrap="true">
        <label for="example" class="col-xs-12 col-md-4 col-lg-6 control-label">
          <span id="status.example"></span>
          <span title="Example" data-original-title="Example">Example</span>
        </label>
      </div>
      <span class="input-group">
        <a ng-if="!c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">{{c.widget.options.shared.label}}</a>
        <a ng-if="c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">
          <span class="icon icon-{{c.widget.options.shared.icon}}" aria-hidden="true"></span>
          <span class="sr-only">{{c.widget.options.shared.hint}}</span>
        </a>
      </span>
    </div>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

That should take care of that little oversight. Now, let’s get back to showing off the various ways in which you can use buttons and icons on the customized Data Table widget.

To start out, let’s use the tool that we just made to create a brand new configuration for a sample page where we can demonstrate how the buttons and icons work. Let’s click on our new Menu Item to bring up the tool, click on the Create a new Content Selector Configuration button, enter the name of our new Script Include, and then click on the OK button.

Creating a new configuration object using the new tool

When our new empty configuration comes up in the tool, let’s define a single Perspective called Button Test, and a couple of different States, Active and Not Active. We want to keep things simple at this point, mainly so as not to distract from our primary purpose here, which is to show how the buttons and icons work. Once we have defined our Perspective and States, click on the Add new Table button and select the Incident table from the list.

Selecting the Incident table from the pop-up selection list

Select just a couple of fields, say Number and Short Description, and set the filter for the Active State to active=true. Then we can start adding Buttons and Icons using the Add new Button/Icon button.

Adding a new Button/Icon

Those of you paying close attention will notice that the image above was taken before we added the page_id property to the Button/Icon configuration. The newer version has a pick list of portal pages below the Hint and above the Example. You will want to define at least one Button/Icon with a page value. Keep adding different buttons and icons until you have a representative sample of different things and then we can see how it all renders out in actual use.

New configuration with several different buttons and icons

Once you have a few set up for testing, save the new configuration and take a look at the resulting script, which should look something like this:

var ButtonTestConfig = Class.create();
ButtonTestConfig.prototype = Object.extendsObject(ContentSelectorConfig, {
	initialize: function() {
	},

	perspective: [{
		name: 'button_test',
		label: 'Button Test',
		roles: ''
	}],

	state: [{
		name: 'active',
		label: 'Active'
	},{
		name: 'not_active',
		label: 'Not Active'
	}],

	table: {
		button_test: [{
			name: 'incident',
			displayName: 'Incident',
			active: {
				filter: 'active=true',
				fields: 'number,short_description',
				btnarray: [{
					name: 'button1',
					label: 'Button #1',
					heading: '',
					icon: '',
					color: 'info',
					hint: 'This is Button #1',
					page_id: ''
				},{
					name: 'button2',
					label: 'Button #2',
					heading: 'Button #2',
					icon: '',
					color: 'warning',
					hint: 'This is Button #2',
					page_id: ''
				},{
					name: 'button3',
					label: 'Button #3',
					heading: 'B3',
					icon: '',
					color: '',
					hint: 'This is Button #3',
					page_id: 'ticket'
				},{
					name: 'icon1',
					label: 'Icon #1',
					heading: '-',
					icon: 'cross',
					color: 'danger',
					hint: 'Danger Will Robinson!',
					page_id: ''
				},{
					name: 'icon2',
					label: 'Icon #2',
					heading: 'I2',
					icon: 'download',
					color: 'success',
					hint: 'Click here to do something ...',
					page_id: ''
				}],
				refmap: {}
			},
			not_active: {
				filter: 'active=false',
				fields: 'number,short_description',
				btnarray: [],
				refmap: {}
			}
		}]
	},

	type: 'ButtonTestConfig'
});

Now that we have that all ready to rock and roll, we need to configure a new test page so that we can put it to use. From the list of Portal Pages, click on the New button and create a page called button_test and save it so that we can pull it up in the Page Designer.

Setting up the new page in the Page Designer

From the container list, drag over a 3/9 container and drag the Content Selector widget into the narrow portion and the SNH Data Table from URL Configuration widget into the wider portion. You shouldn’t have to edit the Data Table widget, but you will need to click on the pencil icon on the Content Selector widget so that you enter the name of your new configuration script.

Entering the name of the configuration script in the widget options

Once that has been completed, you should be able to pull up your new page in the Service Portal and see how it renders out. You can click on any of your buttons or icons at this point, and if you click on one with a page_id value, that page should come up with the record from that row. If you click on any of the other buttons or icons, through, nothing will happen, because we have not set up anything to react to the button clicks at this point. However, if everything is working as it should be, we are now ready to do just that.

When a button or icon is clicked on the customized Data Table widget, and there is no page_id value defined, all that happens is that the details are broadcast out. Some other widget on the page needs to be listening for that broadcast in order for something to happen. The secret, then, is to have some client-side code somewhere that looks something like this:

$rootScope.$on('button.click', function(e, parms) {
	// do something 
});

For demonstration purposes we can build a simple test widget that will do just that, and in response to a click event, display all of the details of that event in an spModal alert. Let’s call this widget Button Click Handler Example, and give it the following client-side code:

function ButtonClickHandlerExample(spModal, $rootScope) {
	var c = this;

	$rootScope.$on('button.click', function(e, parms) {
		displayClickDetails(parms);		
	});

	function displayClickDetails(parms) {
		var html = '<div>'; 
		html += ' <div class="center"><h3>You clicked on the ' + parms.button.name + ' button</h3></div>\n';
		html += ' <table>\n';
		html += '  <tbody>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Table: &nbsp;</td>\n';
		html += '    <td>' + parms.table + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Sys ID: &nbsp;</td>\n';
		html += '    <td>' + parms.sys_id + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Button: &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.button, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Record: &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.record, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '  </tbody>\n';
		html += ' </table>\n';
		html += '</div>';
		spModal.alert(html);
	}
}

Now that we have our widget, we can pop back into the Page Designer and drag the widget anywhere on the screen. It has no displayable content, so it really doesn’t matter where you put it as long as it is present on the page somewhere. Once that’s done, we can pull up our page on the portal again and start clicking around again to see what we get.

Button click results

In addition to the details of the record on that row and the button that was clicked, you also get the name of the table and the sys_id of the record. Any of this data can be used to perform any number of potential actions, all of which are completely outside of the two reusable components, the customized Data Table and the configurable Content Selector. You shouldn’t have to modify either of those widgets to create custom functionality; just configure the Content Selector with an appropriate JSON config object, and then add any custom click handlers to your page for any button actions that you would like to configure. Here is an Update Set that includes the page_id corrections as well as the button test widget so that you can click around on your own and see how everything plays together. There is an even better version here.

Content Selector Configuration Editor, Part VII

“Always do more than is required of you.”
George S. Patton

Well, we have come a long way since we first set to out to build our little Content Selector Configuration Editor. We have built the primary widget and quite a few modal pop-up widgets and have pretty much built out everything that you would need to maintain the configuration. The only thing left at this point is to actually save the data now that it has been edited. Not too long ago, we built a tool that saved its data in a Script Include by rewriting the script and updating a Script Include record. We can use that same technique here by building the script from the user’s input, and then storing it in the script column of a new or updated Script Include record. The first order of business, then, would be to build the script, starting with the initial definition of the class and its prototype:

var script = "var ";
script += name;
script += " = Class.create();\n";
script += name;
script += ".prototype = Object.extendsObject(ContentSelectorConfig, {\n";
script += "	initialize: function() {\n";
script += "	},\n";
script += "\n";

Assuming the name of your script was MyTestConfig, that would generate the following starting lines for your Script Include:

var MyTestConfig = Class.create();
MyTestConfig.prototype = Object.extendsObject(ContentSelectorConfig, {
	initialize: function() {
	},

Next, we need to build the Array of all of the defined Perspectives. This we can do via a simple loop through the Perspective data:

script += "	perspective: [";
var separator = '';
for (var p=0; p<input.config.perspective.length; p++) {
	var thisPerspective = input.config.perspective[p];
	script += separator;
	script += "{\n		name: '";
	script += thisPerspective.name;
	script += "',\n		label: '";
	script += thisPerspective.label;
	script += "',\n		roles: '";
	script += thisPerspective.roles;
	script += "'\n	}";
	separator = ",";
}
script += "],\n\n";

Next, we will need to build the Array of all of the States, which will look very similar to what we just did for all of the Perspectives.

script += "	state: [";
separator = '';
for (var s=0; s<input.config.state.length; s++) {
	var thisState = input.config.state[s];
	script += separator;
	script += "{\n		name: '";
	script += thisState.name;
	script += "',\n		label: '";
	script += thisState.label;
	script += "'\n	}";
	separator = ",";
}
script += "],\n\n";

Now it is time for the Tables, which is where things get a little more complicated. Here we have to loop through every defined Perspective, and then loop through every Table defined for that Perspective, and then loop through every defined State, and then if there are Buttons/Icons and/or Reference Pages, we will need to loop through all of those as well.

script += "	table: {";
separator = '';
for (var tp=0; tp<input.config.perspective.length; tp++) {
	var tablePerspective = input.config.perspective[tp];
	script += separator;
	script += "\n		";
	script += tablePerspective.name;
	script += ": [";
	var tableSeparator = '';
	for (var tt=0; tt<input.config.table[tablePerspective.name].length; tt++) {
		var tableTable = input.config.table[tablePerspective.name][tt];
		script += tableSeparator;
		script += "{\n			name: '";
		script += tableTable.name;
		script += "',\n			displayName: '";
		script += tableTable.displayName;
		script += "'";
		for (var ts=0; ts<input.config.state.length; ts++) {
			var tableState = input.config.state[ts];
			script += ",\n			";
			script += tableState.name;
			script += ": {\n				filter: '";
			script += tableTable[tableState.name].filter;
			script += "',\n				fields: '";
			script += tableTable[tableState.name].fields;
			script += "',\n				btnarray: [";
			var lastSeparator = '';
			for (var b=0; b<tableTable[tableState.name].btnarray.length; b++) {
				var thisButton = tableTable[tableState.name].btnarray[b];
				script += lastSeparator;
				script += "{\n					name: '";
				script += thisButton.name;
				script += "',\n					label: '";
				script += thisButton.label;
				script += "',\n					heading: '";
				script += thisButton.heading;
				script += "',\n					icon: '";
				script += thisButton.icon;
				script += "',\n					color: '";
				script += thisButton.color;
				script += "',\n					hint: '";
				script += thisButton.hint;
				script += "'\n				}";
				lastSeparator = ",";
			}
			script += "],\n				refmap: {";
			lastSeparator = '';
			for (var key  in tableTable[tableState.name].refmap) {
				script += lastSeparator;
				script += "\n					";
				script += key;
				script += ": '";
				script += tableTable[tableState.name].refmap[key];
				script += "'";
				lastSeparator = ",";
			}
			script += "\n				}\n			}";
		}
		script += "\n		}";
		tableSeparator = ",";
	}
	script += "]";
	separator = ",";
}
script += "\n	},\n\n";

And finally, we have to close out the script with the type property and all of the closing characters.

		script += "	type: '";
		script += name;
		script += "'\n});";

That completes the creation of the script from the user’s input. Now we have to save it. If this is an existing record, then all we need to do is fetch it and update the script column, but if this is a new record, then we have a little bit more work to do.

var scriptGR = new GlideRecord('sys_script_include');
if (data.newRecord) {
	scriptGR.initialize();
	scriptGR.name = name;
	scriptGR.api_name = 'global.' + name;
	scriptGR.description = name;
	scriptGR.access = 'public';
	scriptGR.insert();
} else {
	scriptGR.get('name', name);
}
scriptGR.setValue('script', script);
scriptGR.update();
data.sys_id = scriptGR.getUniqueValue();

The reason that we grab the sys_id there at the end after the record has been saved is so that we can take the user to the saved Script Include once the record has been inserted/updated. We do that on the client-side in the code that is launched by the Save button.

$scope.save = function() {
	var missingData = false;
	if (c.data.config.perspective.length == 0) {
		missingData = true;
	} else if (c.data.config.state.length == 0) {
		missingData = true;
	} else {
		for (var p in c.data.config.perspective) {
			if (c.data.config.table[c.data.config.perspective[p].name].length == 0) {
				missingData = true;
			}
		}
	}
	if ($scope.form1.$valid && !missingData) {
		c.data.action = 'save';
		c.server.update().then(function(response) {
			window.location.href = '/sys_script_include.do?sys_id=' + response.sys_id;
		});
	} else {
		$scope.form1.$setSubmitted(true);
		spModal.alert('You must correct all form validation errors before saving');
	}		
};

This assumes that we are doing all of this in the main UI and not on some Portal Page, and to facilitate that, we add a menu item to the Tools menu so that we can launch this widget from within the primary UI.

Content Selector Configurator menu item definition

That’s about it for all of the parts and pieces necessary to make this initial version work. Certainly there are a number of things that we could do to make it a little better here and there, but overall I think it is a pretty good start. We should play around with it a bit and try building out a few different configurations, but for those of you who like to play along at home, here is an Update Set with what should be everything that you need to make this work.

Content Selector Configuration Editor, Part VI

“It is amazing what you can accomplish if you do not care who gets the credit.”
Harry Truman

At the end of our last installment in this series we had coded all of the client-side functions to add and remove Buttons/Icons and Reference Pages, but still needed to create the modal widgets needed to edit the values for those. The Button/Icon editor is the more complex of the two, plus it’s the first one that we encounter on the page, so let’s start with that one, and as usual, let’s start with the HTML.

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="the_name"
      snh-label="Name"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.heading"
      snh-name="heading"/>
    <snh-form-field
      snh-model="c.widget.options.shared.icon"
      snh-name="icon"
      snh-type="icon"/>
    <snh-form-field
      snh-model="c.widget.options.shared.color"
      snh-name="color"
      snh-type="select"
      snh-choices='[
        {"label":"default","value":"default"},
        {"label":"primary","value":"primary"},
        {"label":"secondary","value":"secondary"},
        {"label":"success","value":"success"},
        {"label":"danger","value":"danger"},
        {"label":"warning","value":"warning"},
        {"label":"info","value":"info"},
        {"label":"light","value":"light"},
        {"label":"dark","value":"dark"},
        {"label":"muted","value":"muted"},
        {"label":"white","value":"white"}]'/>
    <snh-form-field
      snh-model="c.widget.options.shared.hint"
      snh-name="hint"/>
    <div id="element.example" class="form-group">
      <div id="label.example" class="snh-label" nowrap="true">
        <label for="example" class="col-xs-12 col-md-4 col-lg-6 control-label">
          <span id="status.example"></span>
          <span title="Example" data-original-title="Example">Example</span>
        </label>
      </div>
      <span class="input-group">
        <a ng-if="!c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">{{c.widget.options.shared.label}}</a>
        <a ng-if="c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">
          <span class="icon icon-{{c.widget.options.shared.icon}}" aria-hidden="true"></span>
          <span class="sr-only">{{c.widget.options.shared.hint}}</span>
        </a>
      </span>
    </div>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

Pretty standard stuff. There are just a few more fields here than with some of the other things that we have been editing in a pop-up window, and one of them happens to be the new icon field type. Also, there is an “Example” field to show you what the Button/Icon will look like based on the values that you have entered. Here’s how it looks in action:

Button/Icon Editor

Other than the additional fields, it’s pretty much the same as the others, and the client-side code is virtually identical to what we have used in the past, with the one exception of having to assign spModal to the $scope so that it can be available to the Icon Picker.

function ButtonIconEditor($scope, $timeout, spModal) {
	var c = this;

	$scope.spModal = spModal;

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

	$scope.cancel = function() {
		$timeout(function() {
			angular.element('[ng-click*="buttonClicked"]').get(0).click(); 
		});
	};

	$scope.save = function() {
		if ($scope.form1.$valid) {
			$timeout(function() {
				angular.element('[ng-click*="buttonClicked"]').get(1).click(); 
			});
		} else {
			$scope.form1.$setSubmitted(true);
		}
	};

	$timeout(function() {
		angular.element('[class*="modal-footer"]').css({display:'none'});
	}, 100);
}

As with our other pop-up editor widgets, there is no server-side code, so that’s the entire widget. The Reference Page editor is even simpler.

<div>
  <form name="form1">
    <snh-form-field
      snh-type="reference"
      snh-model="c.widget.options.shared.table"
      snh-name="table"
      snh-change="buildFieldFilter();"
      snh-required="true"
      placeholder="Choose a Table"
      table="'sys_db_object'"
      display-field="'label'"
      display-fields="'name'"
      value-field="'name'"
      search-fields="'name,label'"/>
    <snh-form-field
      snh-type="reference"
      snh-model="c.widget.options.shared.page"
      snh-name="page"
      snh-required="true"
      placeholder="Choose a Portal Page"
      table="'sp_page'"
      display-field="'title'"
      display-fields="'id'"
      value-field="'id'"
      search-fields="'id,title'"/>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

Here, we just have two sn-record-pickers, one for the reference table and the other for the portal page that you want to bring up whenever someone clicks on a reference value from that table. And once again, the client-side code looks very familiar.

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

	$scope.cancel = function() {
		$timeout(function() {
			angular.element('[ng-click*="buttonClicked"]').get(0).click(); 
		});
	};

	$scope.save = function() {
		if ($scope.form1.$valid) {
			$timeout(function() {
				angular.element('[ng-click*="buttonClicked"]').get(1).click(); 
			});
		} else {
			$scope.form1.$setSubmitted(true);
		}
	};

	$timeout(function() {
		angular.element('[class*="modal-footer"]').css({display:'none'});
	}, 100);
}

That takes care of everything that we discussed last time, but last time we neglected to make allowances for a couple of very import features: the ability to add a new Table and the ability to remove a Table. Since each Perspective has its own list of Tables, we will need to add a New Table button at the bottom of the list for each Perspective. To remove a Table, we can just add a Delete icon next to the Table name for that purpose. That will change the basic HTML structure to now look like this:

<div>
  <h4 class="text-primary">${Tables}</h4>
</div>
<uib-tabset active="active">
  <uib-tab ng-repeat="persp in c.data.config.perspective track by persp.name" heading="{{persp.label}}">
    <div ng-repeat="tbl in c.data.config.table[persp.name] track by tbl.name" style="padding-left: 25px;">
      <h4 style="color: darkblue">
        {{tbl.displayName}}
        ({{tbl.name}})
        <img src="/images/delete_row.gif" ng-click="deleteTable(persp.name, tbl.name)" alt="Click here to permanently delete table {{tbl.name}} from this Perspective" title="Click here to permanently delete table {{tbl.name}} from this Perspective" style="cursor: pointer;"/>
      </h4>
      <uib-tabset active="active">
        <uib-tab ng-repeat="state in c.data.config.state track by state.name" heading="{{state.label}}">
          <div style="padding-left: 25px;">
 
<!-----  all of the specific properties are defined here  ----->
 
          </div>
        </uib-tab>
      </uib-tabset>
    </div>
    <div style="width: 100%;">
      <button ng-click="newTable(persp.name)" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new table to the {{persp.label}} perspective">Add a new Table</button>
    </div>
  </uib-tab>
</uib-tabset>

And of course, we will need functions to handle both the Table Add and Table Remove processes. For the Add, let’s use a pop-up Table selector that will allow you to select a Table from a list. For the Delete, we can adapt one of the other Delete functions that we have already written.

$scope.deleteTable = function(perspective, tableName) {
	var confirmMsg = '<b>Delete Table</b>';
	confirmMsg += '<br/>Deleting the ';
	confirmMsg += tableName;
	confirmMsg += ' Table will also delete all information for that Table in every State in this Perspective.';
	confirmMsg += '<br/>Are you sure you want to delete this Table?';
	spModal.confirm(confirmMsg).then(function(confirmed) {
		if (confirmed) {
			var a = -1;
			for (var b=0; b<c.data.config.table[perspective].length; b++) {
				if (c.data.config.table[perspective][b].name == tableName) {
					a = b;
				}
			}
			c.data.config.table[perspective].splice(a, 1);
		}
	});
};

The function to pop open the modal Table Selector widget is quite familiar as well.

$scope.newTable = function(perspective) {
	var shared = {};
	spModal.open({
		title: 'Table Selector',
		widget: 'table-selector',
		shared: shared
	}).then(function() {
		c.data.config.table[perspective].push(shared);
	});
};

… and the widget itself we can clone from the Reference Page widget, which already had a Table picker defined.

<div>
  <form name="form1">
    <snh-form-field
      snh-type="reference"
      snh-model="c.data.table"
      snh-name="table"
      snh-change="tableSelected();"
      snh-required="true"
      snh-help="Choose a Table to be added to this Perspective"
      placeholder="Choose a Table"
      table="'sys_db_object'"
      display-field="'label'"
      display-fields="'name'"
      value-field="'name'"
      search-fields="'name,label'"/>
  </form>
</div>

Since all we want them to do is to select a table, we can use an snh-change to call the function and send back the selection.

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

	$scope.tableSelected = function() {
		if (c.data.table.value > '') {
			c.server.update().then(function(response) {
				c.widget.options.shared.name = response.table.value;
				c.widget.options.shared.displayName = response.table.displayValue;
				$timeout(function() {
					angular.element('[ng-click*="buttonClicked"]').get(1).click(); 
				});
			});
		}
	};
}

On server side, we just make sure that we have both a table name and a display name.

(function() {
	data.table = {};

	if (input && input.table && input.table.value) {
		data.table = input.table;
		if (!data.table.displayValue) {
			data.table.displayValue = getItemName(data.table.value);
		}
	}

	function getItemName(key) {
		var ciGR = new GlideRecord(key);
		return ciGR.getLabel();
	}
})();

That pretty much wraps up all of the editing functions. We still need to throw in some code to save all of the changes, but that might get pretty involved, so let’s say we save that for our next installment.

Content Selector Configuration Editor, Part V

“It takes considerable knowledge just to realize the extent of your own ignorance.”
Thomas Sowell

At the end of our last installment in this series, we had the HTML for the Tables section, but none of the underlying code to make it all work. Now it’s time to create that code by building all of the client-side functions needed to support all of the ng-click attributes sprinkled throughout the HTML. The first one that you come to in this section is the editButton function that passes the button object as an argument. This should pop open a modal editor just like we did with the Perspectives and the States, so this function could be loosely modeled after those other two. Something like this should do the trick:

$scope.editButton = function(button, btnArray) {
	var shared = {};
	if (button != 'new') {
		shared.label = button.label;
		shared.name = button.name;
		shared.heading = button.heading;
		shared.icon = button.icon;
		shared.color = button.color;
		shared.hint = button.hint;
	}
	spModal.open({
		title: 'Button/Icon Editor',
		widget: 'button-icon-editor',
		shared: shared
	}).then(function() {
		if (button == 'new') {
			button = {};
			btnArray.push(button);
		}
		button.label = shared.label;
		button.name = shared.name;
		button.heading = shared.heading;
		button.icon = shared.icon;
		button.color = shared.color;
		button.hint = shared.hint;
	});
};

We had to add a second argument to the function in this case, but only when creating a new Button/Icon. For an existing object, it is already in place in the object tree, but in the case of a new Button/Icon that we create from a new blank object, we need to know where to put it so that it lives with all of its siblings. Other than that one little wrinkle, the rest is pretty much the same as we have built before. The deleteButton function is also a little different in that we do not have a specified index, so we have to spin through the list to determine the index. Once we have that, though, all we need to do is remove the Button/Icon from the list, as there are no other dependent objects that have to be removed, unlike the Perspectives and the States.

$scope.deleteButton = function(button, btnArray) {
	var confirmMsg = '<b>Delete Button/Icon</b>';
	confirmMsg += '<br/>Are you sure you want to delete the ' + button.label + ' Button/Icon?';
	spModal.confirm(confirmMsg).then(function(confirmed) {
		if (confirmed) {
			var a = -1;
			for (var b=0; b<btnArray.length; b++) {
				if (btnArray[b].name == button.name) {
					a = b;
				}
			}
			btnArray.splice(a, 1);
		}
	});
};

That takes care of the Buttons/Icons … now we have to do essentially the same thing for the Reference Pages. Once again, we have a few little wrinkles that make things not quite exactly the same. For one thing, the Button/Icon data is stored in an Array, but the Reference Page data is stored in a Map. Also, both of the properties for a Reference Page (Table and Portal Page) have values that come from a ServiceNow database table, so the pop-up editor can use an sn-record-picker for both fields. That means their values will be stored in objects, not strings, so again, not an exact copy of the other functions. Still, it looks pretty close to all of its cousins:

$scope.editRefMap = function(table, refMap) {
	var shared = {table: {}, page: {}};
	if (table != 'new') {
		shared.table.value = table;
		shared.table.displayValue = table;
		shared.page.value = refMap[table];
		shared.page.displayValue = refMap[table];
	}
	spModal.open({
		title: 'Reference Page Editor',
		widget: 'reference-page-editor',
		shared: shared
	}).then(function() {
		if (table != 'new') {
			if (shared.table.value != table) {
				delete refMap[table];
			}
		}
		refMap[shared.table.value] = shared.page.value;
	});
};

… and because of those differences, the deleteRefMap function turns out to be the most simplest of all:

$scope.deleteRefMap = function(table, refMap) {
		var confirmMsg = '<b>Delete Reference Page</b>';
		confirmMsg += '<br/>Are you sure you want to delete the ' + table + ' table reference page mapping?';
		spModal.confirm(confirmMsg).then(function(confirmed) {
			if (confirmed) {
				delete refMap[table];
			}
		});
	};

So now we are done with all of the client-side functions, but our work is still not finished. Both of the edit functions pop open modal editor widgets, so we are going to need to build those. We already have a couple of different models created for the Perspective and State editors, so it won’t be as if we have to start out with a blank canvas. Still, there is a certain amount of work involved, so that sounds like a good place to start out in our next installment.

Content Selector Configuration Editor, Part IV

“Everyone’s time is limited. What matters most is to focus on what matters most.”
Roy Bennett

Now that we have built out all of the easy parts of our new Content Selector Configuration Editor, it’s time to dive into the more complex of the three major sections, the Tables section. In the Tables section, there are configuration properties for every State of every Table in every Perspective, which is a lot of data to put on the page all at once. To help organize this data in a more manageable format, my intent is to leverage the Tabs directives from UI Bootstrap, which are already available on the ServiceNow platform. Immediately under the Tables section header, I would like to see a tab for each Perspective, so that you could focus on one Perspective at a time by selecting that specific tab. Within the tab for each Perspective, I want to have a list of Tables, and then for each Table, another set of nested tabs, one for each State. The contents of the State tab, then, would be all of the properties for that State of that Table in that Perspective, which would include such things as the list of Fields, the query Filter, and any configured Buttons, Icons, or Reference page mappings. At the highest level, it should looks something like this:

Typical Table section layout

It’s still relatively complicated, but the tabs help cut down on the clutter, as you are only looking at one specific Perspective/State at any given time. And it is relatively easy to implement, thanks to the UI Bootstrap components. Here is the basic HTML configuration of the Tables section:

<div>
  <h4 class="text-primary">${Tables}</h4>
</div>
<uib-tabset active="active">
  <uib-tab ng-repeat="persp in c.data.config.perspective" heading="{{persp.label}}">
    <div ng-repeat="tbl in c.data.config.table[persp.name]" style="padding-left: 25px;">
      <h4 style="color: darkblue">{{tbl.displayName}} ({{tbl.name}})</h4>
      <uib-tabset active="active">
        <uib-tab ng-repeat="state in c.data.config.state" heading="{{state.label}}">
          <div style="padding-left: 25px;">

<!-----  all of the specific properties are defined here  ----->

          </div>
        </uib-tab>
      </uib-tabset>
    </div>
  </uib-tab>
</uib-tabset>

Basically it is a DIV within a Tab within a Tabset, which is all tucked into a repeating DIV within a Tab within a Tabset. The innermost DIV will then contain all of the properties for the specific State of the specific Table of the specific Perspective selected. Those properties include the following:

Fields

This is a comma-separated list of the fields to be displayed on a row. To collect this data, I have just provided a single text input element. I would really like to pick these fields from a list, but an sn-record-picker would only select primary fields, and would not give you the option of selecting dot-walked fields from other tables. I could build my own Field Selector widget that supported dot-walking, or maybe there is already one out there that I could borrow, but that seemed like a separate project, so I resisted the temptation to go down that road and just used a single text input element for now. This will work.

Filter

This is just your typical GlideRecord encoded query, and again, I thought about having some kind of query builder UI here that was based on the associated table, but there are plenty of places to go on the platform to build a query, so in the end I decided to take the lazy way out and just define a simple text input element here for now.

Buttons/Icons

In my enhanced version of the Data Table widget, you can define Buttons and Icons to appear on the row with the table data, and the JSON configuration object has a place for an Array of these definitions. For input, we can display these in a simple table, much like we did for the Perspectives and the States, with a pop-up editor to make any changes.

Reference Pages

In the stock Data Table widget, the entire line is a link to the details of the record on that row. In my enhanced version, the first column takes you to that record, but any other columns where the data type is Reference will link you to that Reference record. The standard page for that is the generic form page, but you can override that by specifying your own page for any given table. As I did with the Buttons and Icons, I built a simple table for those, with an associated pop-up editor.

Here, then, is the HTML for a single State tab within a specific table.

<snh-form-field
  snh-label="Fields"
  snh-model="tbl[state.name].fields"
  snh-name="fields"
  snh-required="true"/>
<snh-form-field
  snh-label="Filter"
  snh-model="tbl[state.name].filter"
  snh-name="filter"
  snh-required="true"/>
<div id="label.btnarray" class="snh-label" nowrap="true">
  <label for="btnarray" class="col-xs-12 col-md-4 col-lg-6 control-label">
    <span id="status.btnarray"></span>
    <span title="Buttons/Icons" data-original-title="Buttons/Icons">${Buttons/Icons}</span>
  </label>
</div>
<table class="table table-hover table-condensed">
  <thead>
    <tr>
      <th style="text-align: center;">${Label}</th>
      <th style="text-align: center;">${Name}</th>
      <th style="text-align: center;">${Heading}</th>
      <th style="text-align: center;">${Icon}</th>
      <th style="text-align: center;">${Icon Name}</th>
      <th style="text-align: center;">${Color}</th>
      <th style="text-align: center;">${Hint}</th>
      <th style="text-align: center;">${Edit}</th>
      <th style="text-align: center;">${Delete}</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="btn in tbl[state.name].btnarray" ng-hide="btn.removed">
      <td data-th="${Label}">{{btn.label}}</td>
      <td data-th="${Name}">{{btn.name}}</td>
      <td data-th="${Heading}">{{btn.heading}}</td>
      <td data-th="${Icon}" style="text-align: center;">
        <a ng-if="btn.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{btn.color || 'default'}}" title="{{btn.hint}}" data-original-title="{{btn.hint}}">
          <span class="icon icon-{{btn.icon}}" aria-hidden="true"></span>
          <span class="sr-only">{{btn.hint}}</span>
        </a>
      </td>
      <td data-th="${Icon Name}">{{btn.icon}}</td>
      <td data-th="${Color}">{{btn.color}}</td>
      <td data-th="${Hint}">{{btn.hint}}</td>
      <td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editButton(btn)" alt="Click here to edit this Button/Icon" title="Click here to edit this Button/Icon" style="cursor: pointer;"/></td>
      <td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteButton(btn, tbl[state.name].btnarray)" alt="Click here to delete this Button/Icon" title="Click here to delete this Button/Icon" style="cursor: pointer;"/></td>
    </tr>
  </tbody>
</table>
<div style="width: 100%; text-align: right;">
  <button ng-click="editButton('new', tbl[state.name].btnarray, tbl);" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new Button/Icon">Add a new Button/Icon</button>
</div>
<div id="label.refmap" class="snh-label" nowrap="true">
  <label for="refmap" class="col-xs-12 col-md-4 col-lg-6 control-label">
    <span id="status.refmap"></span>
    <span title="Reference Pages" data-original-title="Reference Pages">${Reference Pages}</span>
  </label>
</div>
<table class="table table-hover table-condensed">
  <thead>
    <tr>
      <th style="text-align: center;">${Reference Table}</th>
      <th style="text-align: center;">${Target Page}</th>
      <th style="text-align: center;">${Edit}</th>
      <th style="text-align: center;">${Delete}</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="(key, value) in tbl[state.name].refmap">
      <td data-th="Reference Table">{{key}}</td>
      <td data-th="Target Page">{{value}}</td>
      <td data-th="Edit" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editRefMap(key, tbl[state.name].refmap)" alt="Click here to edit this Reference Page" title="Click here to edit this Reference Page" style="cursor: pointer;"/></td>
      <td data-th="Delete" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteRefMap(key, tbl[state.name].refmap)" alt="Click here to delete this Reference Page" title="Click here to delete this Reference Page" style="cursor: pointer;"/></td>
    </tr>
  </tbody>
</table>
<div style="width: 100%; text-align: right;">
  <button ng-click="editRefMap('new', tbl[state.name].refmap);" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new Reference Page">Add a new Reference Page</button>
</div>

Of course, that’s just the HTML. There are several ng-click functions referenced here that will need to be coded out, along with a number of pop-up editor widgets. And of course, we will need some code to save the whole mess once you get everything configured the way that you would like. Let’s not get too far ahead of ourselves just yet, though. Here we just put one foot in front of the other, taking things on one at a time and focusing on the task at hand. The next task will be some client-side functions to handle all of these newly specified ng-clicks. That sounds like a good place to start, next time out.

Yet Another Service Portal Form Field Type

“In every kind of endeavor, there are ample opportunities for extra effort. Grab those opportunities, embrace that extra effort and transform ordinary mediocrity into bright and shining excellence.”
Ralph Marston

Just when I start thinking that I have finally come up with all of the different Form Field Types than I could possibly use, something comes along to make me realize that I could maybe use just one or two more. While building the Button/Icon Editor widget for my Content Selector Configuration Editor, I came across a need for a field that would allow me to select an Icon. I already had a pop-up Icon selection widget, but there wasn’t a form field type that would allow me to configure that as a selection source. I thought about just coding the HTML myself and not bothering with trying to use the snh-form-field element, but then it occurred to me that if I was going to go to all that trouble, I might as well make the changes to the form field provider and release a new version. The work effort seemed to be roughly equivalent, so here we go.

To begin, we need to add the new type to the list of supported field types up at the top. We will call our new field type icon, and just add it to the existing list in its place in alphabetical order.

var SUPPORTED_TYPE = ['checkbox', 'choicelist', 'date', 'datetime-local', 'email', 'feedback', 'icon', 'inlinemultibox', 'inlineradio', 'mention', 'month', 'multibox', 'number', 'password', 'radio', 'rating', 'reference', 'select', 'tel', 'text', 'textarea', 'time', 'url', 'week'];

My plan is to make the input field read-only and only accept input from the pop-up icon picker. To launch the icon picker, I plan to put a search icon at the end of the input element, much like the icons for the url, email, and tel field types. In fact, the code will be so similar, I think I will add the type to the list of special types, which will drive the processing into the section that builds the layout for those types. Currently, that code looks like this:

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

In addition to adding the icon type to the list, I am also going to add an additional property to each type configuration to enable conditional application of the read-only option. For the new icon type, that value will be set to ‘ readonly=”readonly”‘, and for all other types, it will just be an empty string. This way, I can insert the type-specific value into the input element definition without having to have any conditional logic in that part of the process. The updated version of the SPECIAL_TYPE variable value now looks like this:

var SPECIAL_TYPE = {
	email: {
		title: 'Send an email to {{MODEL}}',
		href: 'mailto:{{MODEL}}',
		icon: 'mail',
		extra: ''
	},
	icon: {
		title: 'Select an Icon',
		href: 'javascript:void(0)" ng-click="selectIcon(\'MODEL\');',
		icon: 'search',
		extra: ' readonly="readonly"'
	},
	tel: {
		title: 'Call {{MODEL}}',
		href: 'tel:{{MODEL}}',
		icon: 'phone',
		extra: ''
	},
	url: {
		title: 'Open {{MODEL}} in a new browser window',
		href: '{{MODEL}}" target="_blank',
		icon: 'pop-out',
		extra: ''
	}
};

Those of you paying close attention might also have noticed the other little hack used on the new type: the href value terminates the href attribute and begins a new ng-click attribute. We don’t want to link to another page in this instance, so the value will set the href attribute to “javascript:void(0)” and then add an ng-click attribute to call a new function that will launch the icon picker. We’ll have to add that new function to the scope down in the link section of the provider a little later on.

With that now in place, we can scroll down to the buildSpecialTypes function and start hacking that up to accommodate our new type. Right at the top we grab all of the values relevant to the type for which we are currently building, That code looks like this:

var title = SPECIAL_TYPE[type].title.replace('MODEL', model);
var href = SPECIAL_TYPE[type].href.replace('MODEL', model);
var icon = SPECIAL_TYPE[type].icon;

Since we added an additional property to the type configurations, we will want to modify that code to include that new property as well:

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 extra = SPECIAL_TYPE[type].extra;

Now all we need to do is to use that new value in the input element definition that we are building. That’s just a a few lines down and looks like this:

htmlText += "       <input class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\" type=\"" + type + "\"" + passThroughAttributes(attrs) + (required?' ng-required="' + required + '"':'') + "/>\n";

Let’s just add the potential readonly attribute at the very end:

htmlText += "       <input class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\" type=\"" + type + "\"" + passThroughAttributes(attrs) + (required?' ng-required="' + required + '"':'') + extra + "/>\n";

Well, that was easy! We still don’t have any code to pop up the icon selector just yet, but we’ve done enough to be able to see how it looks, and to see if we have broken anything so far. Let’s add a new field of type icon to our existing form field test page and see how it comes out. In its simplest form, it would look something like this:

<snh-form-field
  snh-model="c.data.icon"
  snh-name="icon"
  snh-type="icon"/>

Now let’s take a quick peek.

First visual test of the new icon field type

Well, it is definitely read-only, but we did not get our icon displayed at the end of the line as I was hoping. It seems that I neglected to take into account the logic that suppresses the icon if there is no valid data in the field. We don’t really need that in this circumstance, and in fact, we absolutely don’t want it, so we need to make another little modification. This was all one line before, but now we only want to insert the part that hides the icon if the field type is not icon.

htmlText += "       <span class=\"input-group-btn\"";
if (type != 'icon') {
	htmlText += " ng-show=\"" + fullName + ".$valid && " + model + " > ''\"";
}
htmlText += ">\n";

Now, let’s take a look.

Second visual test of the new icon field type

Now that’s better. Now we need to add the function that needs to be called whenever someone clicks on the icon. That goes all the way down at the bottom of the provider script.

scope.selectIcon = function(model) {
	scope.spModal.open({
		title: 'Select Icon',
		widget: 'icon-picker',
		buttons: [
			{label: '${Cancel}', cancel: true}
		],
		size: 'sm',
	}).then(function(response) {
		scope.$eval(model + " = '" + response.selected + "';");
	});
};

Since we don’t have any way to pull in spModal at this point, we are going to have to rely on the client script in the widget to include spModal in their function arguments and attach spModal to the $scope. We already did something similar with snMention, so this will be essentially a copy of that same approach. Here it is on the form field test page, where we have both:

function FormFieldTest($scope, snMention, spModal) {
	var c = this;
	$scope.snMention = snMention;
	$scope.spModal = spModal;
	c.data.picker = {};
}

Now we can take one more look at our test page to see how things will work:

First functional test of the new icon field type

Sweet! Clicking on the search icon pops up the icon picker and clicking on one of the icons on the list populates the form field and closes the picker. And now we have yet one more type of form field. Just like that. I’ll do a little more testing, just to make sure that we did not miss anything, and then I will round up all of the parts and pieces and put them into a new Update Set.

Content Selector Configuration Editor, Part III

“For every complex problem there is an answer that is clear, simple, and wrong.”
H. L. Mencken

Now that we have completed all of the coding on the main widget, it is time to build the new Perspective Editor widget that we will launch in the modal dialog. This will be a simple form with three input fields for the three properties of a Perspective: Label, Name, and Roles. The first two will be simple text fields, but we can select the Roles from a list, so for that we can leverage our old friend, the sn-record-picker. Once again, we can start out with the HTML, just to see how things look.

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="persp"
      snh-label="Name"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.roles"
      snh-name="roles"
      snh-type="reference"
      table="'sys_user_role'"
      field="c.widget.options.shared.roles"
      default-query="'active=true'"
      display-field="'name'"
      search-fields="'name'"
      value-field="'name'"
      multiple="true"/>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

Basically, this is just three standard snh-form-field elements, the first two being of type text and the last one being of type reference, which is just a wrapper around the sn-record-picker. There are a couple of things to note here: 1) when I tried to use the word name for the name of the name field, it crashed the widget, so I called it persp instead, and 2) I ended up adding a couple of buttons to the layout, even though the spModal already provides buttons for you (more on that later). I’m not sure why using the word name crashed the widget, but I have a sinking feeling that there is some kind of bug in the snh-form-field code. I didn’t really feel like digging into that right at the moment, though, and changing the name fixed the problem, so I’m good for now. Here’s how it looks when it gets launched:

Perspective Editor layout

Now, about those buttons: if you don’t override the defaults, an spModal pop-up will have two standard buttons, Cancel and OK. I wanted to use those buttons, but I also wanted to validate the form, and I’m not smart enough to know how to insert form validation underneath the OK button so that it won’t just go right back to the main page and close the pop-up. I tried a few things, but nothing worked, so I ended up adding my own buttons, hiding the originals, but still clicking on them programmatically to obtain their original function. It’s pretty much a convoluted hack, but it gets the job done, so I’ll take it. Here is the client-side code that makes all of that work:

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

	$scope.cancel = function() {
		$timeout(function() {
			angular.element('[ng-click*="buttonClicked"]').get(0).click(); 
		});
	};

	$scope.save = function() {
		if ($scope.form1.$valid) {
			$timeout(function() {
				angular.element('[ng-click*="buttonClicked"]').get(1).click(); 
			});
		} else {
			$scope.form1.$setSubmitted(true);
		}
	};

	$timeout(function() {
		angular.element('[class*="modal-footer"]').css({display:'none'});
	}, 100);
}

That’s it. There is no server-side code on this one and no link code, so that’s the entire widget. That completes everything for the Perspectives section, and now that it all checks out, we can pretty much just copy it all to create the States section. Let’s start with the HTML:

<div>
  <h4 class="text-primary">${States}</h4>
</div>
<div>
  <table class="table table-hover table-condensed">
    <thead>
      <tr>
        <th style="text-align: center;">Label</th>
        <th style="text-align: center;">Name</th>
        <th style="text-align: center;">Edit</th>
        <th style="text-align: center;">Delete</th>
      </tr>
    </thead>
    <tbody>
      <tr ng-repeat="item in c.data.config.state" ng-hide="item.removed">
        <td data-th="Name">{{item.label}}</td>
        <td data-th="Label">{{item.name}}</td>
        <td data-th="Edit" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editState($index)" alt="Click here to edit the details of this State" title="Click here to edit the details of this State" style="cursor: pointer;"/></td>
        <td data-th="Delete" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteState($index)" alt="Click here to permanently delete this State" title="Click here to permanently delete this State" style="cursor: pointer;"/></td>
      </tr>
    </tbody>
  </table>
</div>
<div style="width: 100%; text-align: right;">
  <button ng-click="editState('new')" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new State">Add a new State</button>
</div>

This is just a line by line copy of the Perspectives section with the Roles column removed and the names changed. Let’s see how it looks.

States section added to widget

It looks good, and it has been pretty easy so far. The client-side functions should also be quite similar, although removing and altering States in the Table section of the JSON object will be a little more involved, as a State object appears in every Table in every Perspective. We’ll have to throw in a couple of nested loops to deal with every one of those when things change. Other than that, though, everything else should be a wholesale copy of the same code used in the Perspective section. Here are the new client-side functions:

$scope.editState = function(i) {
	var shared = {};
	if (i != 'new') {
		shared.label = c.data.config.state[i].label;
		shared.name = c.data.config.state[i].name;
	}
	spModal.open({
		title: 'State Editor',
		widget: 'e4bdae0d2f3b60104425fcecf699b649',
		shared: shared
	}).then(function() {
		if (i == 'new') {
			for (var x1 in c.data.config.perspective) {
				var p1 = c.data.config.perspective[x1].name;
				for (var y1 in c.data.config.table[p1]) {
					var table1 = c.data.config.table[p1][y1];
					table1[shared.name] = {btnarray: [], refmap: {}};
				}
			}
			i = c.data.config.state.length;
			c.data.config.state.push({});
		} else {
			if (shared.name != c.data.config.state[i].name) {
				for (var x2 in c.data.config.perspective) {
					var p2 = c.data.config.perspective[x2].name;
					for (var y2 in c.data.config.table[p2]) {
						var table2 = c.data.config.table[p2][y2];
						table2[shared.name] = table2[c.data.config.state[i].name];
						table2[c.data.config.state[i].name] = null;
					}
				}
			}
		}
		c.data.config.state[i].name = shared.name;
		c.data.config.state[i].label = shared.label;
	});
};

$scope.deleteState = function(i) {
	var confirmMsg = '<b>Delete State</b>';
	confirmMsg += '<br/>Deleting the ';
	confirmMsg += c.data.config.perspective[i].label;
	confirmMsg += ' State will also delete all information for that State in every Table in every Perspective.';
	confirmMsg += '<br/>Are you sure you want to delete this State?';
	spModal.confirm(confirmMsg).then(function(confirmed) {
		if (confirmed) {
			for (var x3 in c.data.config.perspective) {
				var p3 = c.data.config.perspective[x3].name;
				for (var y3 in c.data.config.table[p3]) {
					var table3 = c.data.config.table[p3][y3];
					table3[c.data.config.state[i].name] = null;
				}
			}
			c.data.config.state.splice(i, 1);
		}
	});
};

Other than the loops through the Perspectives and Tables and the missing Roles, it’s pretty much the exact same code that we had for the previous section. Now all we need to do is clone the Perspective Editor to create the State Editor, and this section should be completed as well. Dropping the Roles and changing the names leaves the HTML for the State Editor looking like this:

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="persp"
      snh-label="Name"
      snh-required="true"/>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

There is virtually no change at all to the client-side code, and there isn’t any server-side code, so we’re done. That was easy! Here is what it looks like in action:

The new State Editor modal dialog

So now we have completed two of our three sections, which makes it sound like we are two-thirds of the way there, but the next section is quite a bit more complicated than the first two. Tackling that guy sounds like a good place to start next time out.