SNH Data Table Widgets on Share, Updated

“Baby steps count, as long as you are going forward. You add them all up, and one day you look back and you’ll be surprised at where you might get to.”
Chris Gardner

Recently, I came across some issues with the Content Selector Configuration Editor related to Scoped Applications, so I made a number of small corrections and put out a new Update Set for the editor. What I did not do, however, was to create a new Update Set for the SNH Data Table Widgets, which are also bundled with the very same editor. Yesterday, I finally got around to correcting that oversight, and now you should be able to find version 2.5 out on Share.

Version 2.5 is essentially the exact same bundle as the previous version (2.4.1), with the only change being the inclusion of the corrected configuration editor. Still, it does address the issues related to scoped configuration scripts, so it’s probably worth pulling down and installing it, just to avoid running into those annoying problems one day in the future. There are no new features or components in this new version, but it does now include the latest of everything, so this is the one that you will want.

Companion Widgets for SNH Data Tables, Part III

“Whenever you have taken up work in hand, you must see it to the finish. That is the ultimate secret of success. Never, never, never give up!”
Dada Vaswani

Last time, we built a companion widget to handle icons and bulk actions. Today we will do something a little different and build a companion widget to handle aggregate columns. Usually, clicking on a aggregate column will bring you to a list of the records represented by the value. If the value was 10, then you would expect to see a list of 10 records, either on a new page or in a modal pop-up. Linking to a new page is already built into the aggregate column specification, so our companion widget example will demonstrate the modal alternative. For our example, we will use catalog requests, with the aggregate column representing the number of requested items in each request.

A list of requests that includes a count of requested items

Here is the configuration script used to produce this table:

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

	perspective: [{
		name: 'all',
		label: 'All',
		roles: ''
	}],

	state: [{
		name: 'all',
		label: 'All'
	}],

	table: {
		all: [{
			name: 'sc_request',
			displayName: 'Request',
			all: {
				filter: 'requested_forDYNAMIC90d1921e5f510100a9ad2572f2b477fe^active=true',
				fields: 'number,opened_at,short_description,stage,sys_updated_on',
				svcarray: [],
				aggarray: [{
					name: 'items',
					label: 'Items',
					heading: '',
					table: 'sc_req_item',
					field: 'request',
					filter: '',
					source: '',
					hint: 'Click here to view the items in this request',
					page_id: ''
				}],
				btnarray: [],
				refmap: {
					sys_user: 'user_profile'
				},
				actarray: []
			}
		}]
	},

	type: 'MyRequestsConfig'
});

Without a page_id property in the aggregate column specification, it will not automatically link to another page, but it will broadcast the click event, so we can use a version of the Simple List widget to display our records.

Modal pop-up of requested items

Clicking on an item should bring up the ticket page, where you can see more details about the item.

Requested Item details

The reason we need to use a version of the Simple List widget is that the Simple List widget is configured using widget options, and the spModal open() function does not provide the capability of setting the widget’s options. It does, however, provide the capability to configure widget input, so all we need to do is to add these few lines to the top of our version’s Server script to convert that input to options.

// begin mod
	if (input && input.options) {
		for (var name in input.options) {
			options[name] = input.options[name];
		}
	}
// end mod

With that in place, we can now build a typical companion widget that listens for the aggregate click event and pops open our modal dialog using the modified Simple List widget.

api.controller = function($scope, $rootScope, $window, spModal) {
	var c = this;
	var eventNames = {
		referenceClick: 'data_table.referenceClick',
		aggregateClick: 'data_table.aggregateClick',
		buttonClick: 'data_table.buttonClick',
		bulkAction: 'data_table.bulkAction'
	};

	$rootScope.$on(eventNames.aggregateClick, function(e, parms) {
		var modelOptions = {
			title: "${Requested Items}",
			widget: "widget-modal-list",
			widgetInput: {
				options: {
					table: 'sc_req_item',
					filter: 'request=' + parms.record.sys_id,
					display_field: 'number',
					order_by: 'number',
					image_field: 'cat_item.picture',
					secondary_fields: 'stage,cat_item.name',
					sp_page: 'ticket'
				}
			},
			buttons: [
				{label: '${Done}', cancel: true}
			],
			size: 'md'
		};
		spModal.open(modelOptions);
	});
};

And that’s all there is to that. That gives us three different examples covering buttons, icons, bulk actions, and now aggregate columns, so that should cover just about everything. Once you do a few and get the hang of how all of the parts play together, it’s pretty simple to create a new one for a different purpose. For those of you who like to play along at home, here is an Update Set that includes all of the parts and pieces for these various examples. Of course, you will need to install the SNH Data Table Widgets for any of this to be of any value, but you already knew that!

Companion Widgets for SNH Data Tables, Part II

“If you set a good example you need not worry about setting rules.”
Lee Iacocca

Last time, we put together a simple companion widget for an SNH Data Table that contained a single button. Today we will try something a little more complicated, with three different buttons and three different bulk actions. For this example, we will create a list of records waiting for an approval decision from the perspective of the person who can make such a decision. All of our buttons and bulk actions will be related to passing judgement on the items listed.

My Approvals example page

Here is the configuration script that we will used to produce this table.

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

	perspective: [{
		name: 'all',
		label: 'All',
		roles: ''
	}],

	state: [{
		name: 'all',
		label: 'All'
	}],

	table: {
		all: [{
			name: 'sysapproval_approver',
			displayName: 'Approval',
			all: {
				filter: 'approverDYNAMIC90d1921e5f510100a9ad2572f2b477fe^state=requested',
				fields: 'sysapproval.sys_class_name,sysapproval,sysapproval.short_description,sysapproval.opened_by',
				svcarray: [],
				aggarray: [],
				btnarray: [{
					name: 'approve',
					label: 'Approve',
					heading: 'Approve',
					icon: 'workflow-approved',
					color: 'success',
					hint: 'Click here to approve',
					page_id: '',
					condition: ''
				},{
					name: 'approvecmt',
					label: 'Approve w/Comments',
					heading: 'w/Comments',
					icon: 'comment-hollow',
					color: 'success',
					hint: 'Click here to approve with comments',
					page_id: '',
					condition: ''
				},{
					name: 'reject',
					label: 'Reject',
					heading: 'Reject',
					icon: 'workflow-rejected',
					color: 'danger',
					hint: 'Click here to reject',
					page_id: '',
					condition: ''
				}],
				refmap: {
					sys_user: 'user_profile'
				},
				actarray: [{
					name: 'approve',
					label: 'Approve'
				},{
					name: 'approvecmt',
					label: 'Approve w/Comments'
				},{
					name: 'reject',
					label: 'Reject'
				}]
			}
		}]
	},

	type: 'MyApprovalsConfig'
});

The buttons handle actions for individual items and the bulk actions perform the same functions, but for more than one item at a time. Clicking on a button that involves comments should bring up an input dialog.

Entering comments for the selected action

Once the comments are entered, the companion widget should go ahead and update the database and inform the user of the action taken.

The selected item has been approved

Once the list is refreshed to reflect the changes, we can demonstrate a similar process for bulk actions.

Selecting all records for a bulk action

Once again, a dialog appears so that the rejection reason can be entered.

Entering comments for the selected bulk action

And once the comments have been entered, the action is taken for all selected records.

Bulk action taken on all selected records

And once the action has been taken, the screen is again refreshed to reveal that there are no further items requiring approval decisions.

Empty list after all items have been resolved

So that’s the concept. Now let’s take a look the companion widget that makes this all work. To begin, we throw in the same list of event names at the top like we do with all of the others.

var eventNames = {
	referenceClick: 'data_table.referenceClick',
	aggregateClick: 'data_table.aggregateClick',
	buttonClick: 'data_table.buttonClick',
	bulkAction: 'data_table.bulkAction'
};

We have to deal with both buttons and bulk actions for this one, but since they both do essentially the same thing, it would be nice to share as much of the code as possible. Since the only difference is that the buttons work on a single record and the bulk actions work on a list of records, we could convert the single record for the button into a list of one, and then hand off the work to a function that expected a list, which would then work for both. We still need two listeners, though, so let’s build those next.

$rootScope.$on(eventNames.buttonClick, function(e, parms) {
	var action = parms.config.name;
	var sysId = [];
	sysId.push(parms.record.sys_id);
	processAction(action, sysId);
});

$rootScope.$on(eventNames.bulkAction, function(e, parms) {
	var action = parms.config.name;
	var sysId = [];
	for (var i=0; i<parms.record.length; i++) {
		sysId.push(parms.record[i].sys_id);
	}
	processAction(action, sysId);
});

Our processAction function then will not know or care whether the action came from a button or a bulk action. Everything from this point on will be the same either way. Let’s take a quick peek at that function now.

function processAction(action, sysId) {
	if (!c.data.inProgress) {
		c.data.inProgress = true;
		c.data.action = action;
		c.data.sys_id = sysId;
		c.data.comments = '';
		if (action == 'reject' || action == 'approvecmt') {
			getComments(action);
		} else if (action == 'approve') {
			processDecision();
		}
		c.data.inProgress = false;
	}
}

The check of c.data.inProgress is just a defensive mechanism to ensure that we are only processing one action at a time. We set it to true when we start and to false when we are done, and if it is already set to true when we start, then we do nothing, as there is already another action in progress. The rest is just a check to see if we need to collect comments, and if not, we proceed directly to processing the action. If we do need to collect comments, then we call this function.

function getComments(action) {
	var msg = 'Approval comments:';
	if (action == 'reject') {
		msg = 'Please enter the reason for rejection:';
	}
	spModal.prompt(msg, '').then(function(comments) {
		c.data.comments = comments;
		processDecision();
	});
}

And in either case, we end up here to process the decision.

function processDecision() {
	c.server.update().then(function(response) {
		window.location.reload(true);
	});
}

Basically, that just makes a call over to server side where the actual database updates take place. Here is the entire Client controller all put together.

api.controller = function($scope, $rootScope, $window, spModal) {
	var c = this;
	var eventNames = {
		referenceClick: 'data_table.referenceClick',
		aggregateClick: 'data_table.aggregateClick',
		buttonClick: 'data_table.buttonClick',
		bulkAction: 'data_table.bulkAction'
	};

	$rootScope.$on(eventNames.buttonClick, function(e, parms) {
		var action = parms.config.name;
		var sysId = [];
		sysId.push(parms.record.sys_id);
		processAction(action, sysId);
	});

	$rootScope.$on(eventNames.bulkAction, function(e, parms) {
		var action = parms.config.name;
		var sysId = [];
		for (var i=0; i<parms.record.length; i++) {
			sysId.push(parms.record[i].sys_id);
		}
		processAction(action, sysId);
	});

	function processAction(action, sysId) {
		if (!c.data.inProgress) {
			c.data.inProgress = true;
			c.data.action = action;
			c.data.sys_id = sysId;
			c.data.comments = '';
			if (action == 'reject' || action == 'approvecmt') {
				getComments(action);
			} else if (action == 'approve') {
				processDecision();
			}
			c.data.inProgress = false;
		}
	}

	function getComments(action) {
		var msg = 'Approval comments:';
		if (action == 'reject') {
			msg = 'Please enter the reason for rejection:';
		}
		spModal.prompt(msg, '').then(function(comments) {
			c.data.comments = comments;
			processDecision();
		});
	}

	function processDecision() {
		c.server.update().then(function(response) {
			window.location.reload(true);
		});
	}
};

The actual work of updating the approval records takes place over on the server side. Here is the complete Server script.

(function() {
	if (input && input.sys_id && input.sys_id.length > 0) {
		var total = 0;
		var approvalGR = new GlideRecord('sysapproval_approver');
		for (var i=0; i<input.sys_id.length; i++) {
			approvalGR.get(input.sys_id[i]);
			if (approvalGR.state == 'requested') {
				approvalGR.state = 'approved';
				if (input.action == 'reject') {
					approvalGR.state = 'rejected';
				}
				var comments = 'Approval response from ' + gs.getUserDisplayName() + ':';
				comments += '\n\nDecision: ' + approvalGR.getDisplayValue('state');
				if (input.comments) {
					comments += '\nReason: ' + input.comments;
				}
				approvalGR.comments = comments;
				if (approvalGR.update()) {
					total++;
				}
			}
		}
		if (total > 0) {
			var message = total + ' items ';
			if (total == 1) {
				message = 'One item ';
			}
			if (input.action == 'reject') {
				message += 'rejected.';
			} else {
				message += 'approved.';
			}
			gs.addInfoMessage(message);
		}
	}
})();

That’s all basic GlideRecord stuff, so there isn’t too much commentary to add beyond the actual code itself. And that’s all there is to that. I still want to bundle all of these example up into a little Update Set, but I think I will do one more example before we do that. We will take a look at that one next time out.

Companion Widgets for SNH Data Tables

“Few things are harder to put up with than the annoyance of a good example.”
Mark Twain

A while back we pushed our SNH Data Table Widgets out to Share, and then later made a few revisions and enhancements to come up with the version that is posted out there now. After spending a little time refactoring and standardizing some of the processes, we now have a fairly consistent way of handling reference links, bulk actions, buttons, icons, and the new scripted value columns. In most cases, there is a $rootScope broadcast message that just needs to be picked up by a companion widget, and then the companion widget handles all of the custom stuff that won’t be found in the common components. Most of the work that we did during that development process was centered on the shared components; the few companion widgets that we did throw in as samples were not the focus of the discussion. Now that the development of the table widgets is behind us, it is a good time to spend a little more time on the companion widgets and how they augment the primary artifacts.

Let’s say that we have a policy that Level 2 and 3 technicians working Incidents should respond to all customer comments, and even without customer inquiries, no open ticket should ever go more than X number of days without some comment from the assigned technician indicating the current status. To assist the technician in managing that expectation, we could configure a list of assigned incidents that included some visual indication of the comment status of each.

List of assigned Incidents with current comment status

For those tickets where a comment is needed, we would want to provide a means for the technician to provide that comment right from the list. One way to do that would be to pop up the Ticket Conversations widget in a modal dialog. This would allow the tech to both view the current conversion and add their own comments.

Ticket Conversations widget pop-up

Once a comment has been provided, the comment appears in the conversation stream, after which additional comments can be provided or the pop-up window closed.

New comment posted in the Ticket Conversations widget

Once the modal pop-up window is closed, the list should be updated to reflect the new comment status of the incident.

Updated Incident list after providing required comment

Here is the configuration script to produce the above table using the SNH Data Table from JSON Configuration widget.

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

	perspective: [{
		name: 'all',
		label: 'All',
		roles: ''
	}],

	state: [{
		name: 'all',
		label: 'All'
	}],

	table: {
		all: [{
			name: 'incident',
			displayName: 'Incident',
			all: {
				filter: 'active=true^assigned_toDYNAMIC90d1921e5f510100a9ad2572f2b477fe',
				fields: 'number,caller_id,state,opened_at,short_description',
				svcarray: [{
					name: 'comment',
					label: 'Comment',
					heading: '',
					script: 'global.LastCommentValueProvider'
				}],
				aggarray: [],
				btnarray: [],
				refmap: {
					sys_user: 'user_profile'
				},
				actarray: []
			}
		}]
	},

	type: 'MyWorkConfig'
});

The Data Table widget can be configured to produce the list, but to launch the modal pop-up, you will need to add a companion widget to the page. A companion widget shares the page with a Data Table widget, but has no visual component. It’s job is simply to listen for the broadcast messages and take whatever action is desired when a message of interest is received. At the top of the Client script of all companion widgets, I like to throw in this little chunk of code to help identify the values used to distinguish each potential message.

var eventNames = {
	referenceClick: 'data_table.referenceClick',
	aggregateClick: 'data_table.aggregateClick',
	buttonClick: 'data_table.buttonClick',
	bulkAction: 'data_table.bulkAction'
};

In this particular case, we are looking for a button click, and since there are no other buttons configured in this instance, we don’t even need to check to see which button it was.

$rootScope.$on(eventNames.buttonClick, function(e, parms) {
	var modelOptions = {
		title: "${Incident Conversation}",
		widget: "widget-ticket-conversation",
		widgetInput: {
			sys_id: parms.sys_id,
			table: 'incident'
		},
		buttons: [
			{label: '${Done}', cancel: true}
		],
		size: 'lg'
	};
	spModal.open(modelOptions).then(function() {
		$window.location.reload();
	}, function() {
		$window.location.reload();
	});
});

Basically, this is just your typical spModal widget open, passing in some widget input. In the case of the Ticket Conversations widget, you need both a table name and the sys_id of a record on that table. In our case, we know that the table is the Incident table, and we can obtain the sys_id of the incident in the row from the parameters passed in with the broadcast message. When the modal window is closed, there are two functions passed in as arguments to the then function, the first for a successful completion and the second for a cancellation. In our case, we want to reload the page for either result, so the logic for each is the same.

For most circumstances, this would be the extent of the widget. For the most part, companion widgets are pretty simple, narrowly focused components that are built for a single purpose: to accomplish something that is not built in to the standard artifacts. Everything else is left for the primary widgets. We couldn’t get away without throwing in a little bit of hackery, though, since that’s what we do around here, so in this particular example, we will need to add just a bit more to the widget before we can call it complete.

In our example, we are using the class of the button to visually identify the current state of the comments on each incident. This cannot be done with the standard button configuration, as the class name is one of the configuration properties, and it is applied to all buttons in the column. To produce specific class values for each row, we have to resort to using a scripted value column instead of configuring a button. Each scripted value column requires a script to produce the value, and for this particular example, our script looks like this:

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

	getScriptedValue: function(item, config) {
		var className = '';
		var helpText = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			if (journalGR.getValue('sys_created_by') == gs.getUserName()) {
				if (journalGR.getValue('sys_created_on') > gs.daysAgo(7)) {
					className = 'success';
					helpText = 'Click here to add additional comments';
				} else {
					className = 'danger';
					helpText = 'Click here to update the status';
				}
			} else {
				className = 'danger';
					helpText = 'Click here to respond to the latest comment';
			}
		} else {
			className = 'default';
					helpText = 'Click here to provide a status comment';
		}

		return this.getHTML(item, className, helpText);
	},

	getHTML: function(item, className, helpText) {
		var response = '<a href="javascript:void(0)" role="button" class="btn-ref btn btn-';
		response += className;
		response += '" onClick="tempFunction(this, \'';
		response += item.sys_id;
		response += '\')" title="';
		response += helpText;
		response += '" data-original-title="';
		response += helpText;
		response += '">Comment</a>';
		return response;
	},

	getLastJournalEntry: function(sys_id) {
		var journalGR = new GlideRecord('sys_journal_field');
		journalGR.orderByDesc('sys_created_on');
		journalGR.addQuery('element_id', sys_id);
		journalGR.setLimit(1);
		journalGR.query();
		journalGR.next();
		return journalGR;
	},

	type: 'LastCommentValueProvider'
};

Basically, we go grab the last comment, and look at the author and the date to determine both the class name and the associated help text for the button. Once that has been determined, then we build the HTML to produce the button in a similar fashion to the buttons created using the button/icon configuration. Those of you paying close attention will notice that the one significant difference between this HTML and the HTML produced from a button/icon configuration is the use of onClick in the place of ng-click. This has to be done because the HTML added to the page for a scripted value column is not compiled, so an ng-click will not work. The problem with an onClick, though, is that it is outside the scope of the widget, so we have to add this little tidbit of script to the HTML of our companion widget to address that.

<div>
<script>
function tempFunction(elem, sys_id) {
 var scope = angular.element(elem).scope();
  scope.$apply(function() {
    scope.buttonClick('comment', {sys_id: sys_id});
 });
}
</script>
</div>

This brings things full circle and gets us back inside the scope of the primary widget to activate the normal button click process, which will send out the $rootScope broadcast message, which will in turn get picked up by our companion widget. Normally, the HTML for a companion widget would be completely empty, but in this particular case, we were able to leverage that section to insert our little client script. I plan to bundle all of these artifacts up into an Update Set so that folks can play around with them, but before I do that, I wanted to throw out a couple more examples. We will take a look at another one of those next time out.