Periodic Review, Part XI

“Take a deep breath, pick yourself up, dust yourself off and start all over again.”
Frank Sinatra

Last time, we finished up the code for the function that runs the notice distribution process. Now we need to come up with the actual content of those notices, which should direct the recipient to some function where they can indicate the disposition of the artifacts to be reviewed. In its simplest form, this would be a binary choice between keeping or discarding each item on the review list. However, there may be other dispositions for certain configurations, and there may be a need for additional options such as This item is no longer my responsibility or Remove this account only if it has been inactive for 90 days. To support such custom responses to a review request, we would need to add yet another table to our application to store the configured response options and link them to the configuration record. This adds a little bit of complexity to the process, but I think it would be worth doing to make things as flexible and useful as possible, so let’s go ahead and build that table now.

New Review Statement table

We will call our new table Review Statement and give it four fields, the reference to the configuration, an order, and a description and short description. We will want to link this as a related table on the configuration form so that statements can be easily added when setting up the configuration.

With that out of the way, we can start to visualize the form or page that the notice recipient would use to respond to the review notification. We could list the items to be reviewed down the page, and the configured choice across the top of the page, with a checkbox for each configured statement on each line containing an item. If there is more than one item to be reviewed, then a master checkbox at the top would also be helpful, so the recipient could simply check one box for the entire list of items. On the Now Platform, there are a number of different ways to construct such a page, but I am still partial to the Service Portal, so let’s build a Portal Widget for a Portal Page.

That was the plan, anyway.

Unfortunately, I did a really stupid thing before I got a chance to get started on that. It all started when I received a notice that my instance had some technical issues and that I needed to get rid of it and start over. Fair enough. I have had that particular instance for longer than I can remember, and I am sure that it was well past time to retire it and start all over with a new one. The notice said to be sure and back everything up before I wiped it out, but I have pretty much published every single thing that I have ever worked on, so I didn’t see any point in going through that. So I didn’t. I killed the old one and started over with a new one. Easy peasy.

What I neglected to consider was that this current project that I am working on right at the moment has not gotten far enough along for me to produce any public Update Sets, so there was no back up of everything that I have done so far. Oops! So now I have to go back and recreate all of the work that has been done so far, just to catch up to this point. I don’t mind doing things; in fact, I actually enjoy most of the stuff that I do here, and I mainly do it just for the fun of it. But I absolutely hate doing things twice. Now I just have to find the motivation to go back and do all of this again, just to get back to where I already was!

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.

Collaboration Store, Part LXXXI

“The trouble with programmers is that you can never tell what a programmer is doing until it’s too late.”
Seymour Cray

Last time, we started building a widget for the application details pop-up and today we need to wrap that up. We left off with a rough layout of what the pop-up might contain, and now we need to gather up all of the data necessary to populate the screen. The first thing that we need to do is get the primary application record.

data.sysId = input.sys_id;
data.record = {};
var appGR = new GlideRecord('x_11556_col_store_member_application');
appGR.query();
if (appGR.get(data.sysId)) {
	var item = {};
	data.record.name = appGR.getDisplayValue('name');
	data.record.description = appGR.getDisplayValue('description');
	data.record.applicationId = appGR.getValue('application');
	data.record.logo = appGR.getValue('logo');
	data.record.version = appGR.getDisplayValue('current_version');
	data.record.provider = appGR.getDisplayValue('provider.name');
	data.record.providerId = appGR.getValue('provider');
	data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
	data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
	data.record.state = 0;
	if (data.record.applicationId) {
		data.record.state = 1;
		data.record.installedVersion = appGR.getDisplayValue('application.version');
		if (data.record.version == data.record.installedVersion) {
			data.record.state = 2;
		}
	}
	if (!data.record.local && data.record.state != 2) {
		data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
	}
	data.record.versionList = getVersionRecords(data.sysId);
}

Then we need the functions that fetch the attachment ID and all of the version records.

function getAttachmentId(applicationId, version) {
	var attachmentId = '';

	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application', applicationId);
	versionGR.addQuery('version', version);
	versionGR.query();
	if (versionGR.next()) {
		var attachmentGR = new GlideRecord('sys_attachment');
		attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
		attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
		attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
		attachmentGR.query();
		if (attachmentGR.next()) {
			attachmentId = attachmentGR.getUniqueValue();
		}
	}
		
	return attachmentId;
}

function getVersionRecords(applicationId) {
	var versionList = [];

	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application', applicationId);
	versionGR.orderByDesc('sys_created_on');
	versionGR.query();
	while (versionGR.next()) {
		var thisVersion = {};
		thisVersion.date = formatDate(versionGR.getDisplayValue('sys_created_on'));
		thisVersion.builtOn = versionGR.getDisplayValue('built_on');
		thisVersion.version = versionGR.getDisplayValue('version');
		versionList.push(thisVersion);
	}
		
	return versionList;
}

The version records are dated, and the date format that I have chosen is month day, year (‘MMM d, yyyy’); however, for today’s date and yesterday’s date, I replace the date with the words Today and Yesterday. To pull that off, I need to create some variables for those two dates right at the top.

var gd = new GlideDate();
var today = gd.getByFormat('MMM d, yyyy');
var gdt = new GlideDateTime();
gdt.addDaysLocalTime(-1);
gd.setValue(gdt.getDate());
var yesterday = gd.getByFormat('MMM d, yyyy');

Once those values have been establish, I can reference them in the date format function.

function formatDate(dateString) {
	var response = '';
	if (dateString) {
		var date = new GlideDate();
		date.setValue(dateString);
		response = date.getByFormat('MMM d, yyyy');
		if (response == today) {
			response = 'Today';
		} else if (response == yesterday) {
			response = 'Yesterday';
		}
	}
	return response;
}

That’s pretty much it for the server side code. Here is the whole thing all put together.

(function() {
	var gd = new GlideDate();
	var today = gd.getByFormat('MMM d, yyyy');
	var gdt = new GlideDateTime();
	gdt.addDaysLocalTime(-1);
	gd.setValue(gdt.getDate());
	var yesterday = gd.getByFormat('MMM d, yyyy');
	if (input) {
		data.sysId = input.sys_id;
		data.record = {};
		var appGR = new GlideRecord('x_11556_col_store_member_application');
		appGR.query();
		if (appGR.get(data.sysId)) {
			var item = {};
			data.record.name = appGR.getDisplayValue('name');
			data.record.description = appGR.getDisplayValue('description');
			data.record.applicationId = appGR.getValue('application');
			data.record.logo = appGR.getValue('logo');
			data.record.version = appGR.getDisplayValue('current_version');
			data.record.provider = appGR.getDisplayValue('provider.name');
			data.record.providerId = appGR.getValue('provider');
			data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
			data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
			data.record.state = 0;
			if (data.record.applicationId) {
				data.record.state = 1;
				data.record.installedVersion = appGR.getDisplayValue('application.version');
				if (data.record.version == data.record.installedVersion) {
					data.record.state = 2;
				}
			}
			if (!data.record.local && data.record.state != 2) {
				data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
			}
			data.record.versionList = getVersionRecords(data.sysId);
		}
	}

	function getAttachmentId(applicationId, version) {
		var attachmentId = '';

		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		versionGR.addQuery('member_application', applicationId);
		versionGR.addQuery('version', version);
		versionGR.query();
		if (versionGR.next()) {
			var attachmentGR = new GlideRecord('sys_attachment');
			attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
			attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
			attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
			attachmentGR.query();
			if (attachmentGR.next()) {
				attachmentId = attachmentGR.getUniqueValue();
			}
		}
		
		return attachmentId;
	}

	function getVersionRecords(applicationId) {
		var versionList = [];

		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		versionGR.addQuery('member_application', applicationId);
		versionGR.orderByDesc('sys_created_on');
		versionGR.query();
		while (versionGR.next()) {
			var thisVersion = {};
			thisVersion.date = formatDate(versionGR.getDisplayValue('sys_created_on'));
			thisVersion.builtOn = versionGR.getDisplayValue('built_on');
			thisVersion.version = versionGR.getDisplayValue('version');
			versionList.push(thisVersion);
		}
		
		return versionList;
	}

	function formatDate(dateString) {
		var response = '';
		if (dateString) {
			var date = new GlideDate();
			date.setValue(dateString);
			response = date.getByFormat('MMM d, yyyy');
			if (response == today) {
				response = 'Today';
			} else if (response == yesterday) {
				response = 'Yesterday';
			}
		}
		return response;
	}
})();

To format all of this data, we use the following HTML.

<div class="panel{{c.data.record.local?' local-app':''}}">
  <img ng-src="{{::c.data.record.logo}}.iix?t=small" ng-if="c.data.record.logo" alt="" class="m-r-sm m-b-sm pull-left" aria-hidden="true"/>
  <h3>{{c.data.record.name}}</h3>
  <div>
    <p>{{::c.data.record.description}}</p>
    <strong>${Version History}</strong>
    <table>
      <thead>
        <tr>
          <th>${Version}</th>
          <th>${Published}</th>
          <th>${Built on}</th>
          <th>${Install}</th>
        </tr>
      </thead>
      <tbody>
        <tr ng-repeat="version in c.data.record.versionList">
          <td>{{::version.version}}</td>
          <td>{{::version.date}}</td>
          <td>{{::version.builtOn}}</td>
          <td ng-if="version.version == c.data.record.installedVersion">${Installed}</td>
          <td ng-if="version.version == c.data.record.version && c.data.record.state != 2">
            <button ng-click="alert('OK');">${Install version} {{::version.version}}</button>
          </td>
        </tr>
      </tbody>
    </table>
    <p>
      <a href="/x_11556_col_store_member_application.do?sys_id={{::c.data.sysId}}">${View Collaboration Store application record}</a><br/>
      <a ng-if="c.data.record.state > 0" href="/sys_app.do?sys_id={{::c.data.record.applicationId}}">${View installed application record}</a>
    </p>
    <p ng-if="!c.data.record.local">
      <span style="display: inline-flex;" class="pull-right">
        <span ng-if="c.data.record.provider">${This application provided by} <a href="/x_11556_col_store_member_organization.do?sys_id={{::c.data.record.providerId}}">{{::c.data.record.provider}}</a></span>
        &nbsp;
        <img ng-src="{{::c.data.record.providerLogo}}.iix?t=small" ng-if="c.data.record.providerLogo" alt="{{::c.data.record.provider}}" class="avatar-small" style="display: inline-flex; width: 16px; height: 16px;"/>
        &nbsp;
      </span>
    </p>
    <p>&nbsp;</p>
  </div>
</div>

We don’t need any client side code, but to pretty things up just a bit, we do need a wee bit of CSS.

  padding: 5px;
}

th {
  color: #ccc;
  font-style: italic;
  text-align: center;
  border-bottom: 1px solid #ccc;
}

.local-app {
  background-color: #f5f5f5;
  padding: 5px;
}

Someone who actually knows what they are doing could probably do a much better job with the prettying up part, but this will do for now.

So now all that is left is to bundle the whole thing up into yet another pre-release Update Set for testing purposes, so here you go:

This is another drop-in replacement for any previous 0.7.x version. If you have been already been testing with any other version, just install this one over the one that you have been using. If you installing for the first time, you will need the other prerequisites, which you can read about here and here and here. As always, feedback of any kind in the comments section is welcome, encouraged, and very much appreciated. Also, any ideas on the shopping experience in general, or on the search widget that we have yet to add to the other side of the page, would be great as well. Next time, we may start taking a look at that unless we have some test results to review. Thanks to everyone who has taken the time to take this out for a spin, and if you haven’t done it yet, please give it a try and let us know what you find.

Collaboration Store, Part LXXX

“Walk that walk and go forward all the time. Don’t just talk that talk, walk it and go forward. Also, the walk didn’t have to be long strides; baby steps counted too. Go forward.”
Chris Gardner

Last time, we added some code to our storefront to launch the application installation process. Today, we want to build a detail screen to show all of the detailed information for a specific app. Although we could create a new UI Page or Portal Page for that, and have the tile click branch to that page, it seems as if it would be better if we just had a simple modal pop-up screen so that we could remain on the main shopping experience page after closing the modal dialog. To begin, let’s just create a simple Service Portal widget that we can call up using spModal. We can call our widget Application Details and give it an ID of cs-application-details.

We have already gathered up the application data in the main widget, so we could simply pass everything that we have over to the pop-up widget; however, that would make the pop-up widget highly dependent on the main widget, which is something that I try to avoid. I think the better approach will be to pass in the sys_id of the app and let the pop-up widget fetch its own data from the database. For that, we can cut and paste most of the code that we already have in the main widget.

(function() {
	if (input) {
		data.sysId = input.sys_id;
		data.record = {};
		var appGR = new GlideRecord('x_11556_col_store_member_application');
		appGR.query();
		if (appGR.get(data.sysId)) {
			var item = {};
			data.record.name = appGR.getDisplayValue('name');
			data.record.description = appGR.getDisplayValue('description');
			data.record.logo = appGR.getValue('logo');
			data.record.version = appGR.getDisplayValue('current_version');
			data.record.provider = appGR.getDisplayValue('provider.name');
			data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
			data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
			data.record.state = 0;
			if (appGR.getValue('application')) {
				data.record.state = 1;
				data.record.installedVersion = appGR.getDisplayValue('application.version');
				if (data.record.version == data.record.installedVersion) {
					data.record.state = 2;
				}
			}
			if (!data.record.local && data.record.state != 2) {
				data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
			}
		}
	}

	function getAttachmentId(applicationId, version) {
		var attachmentId = '';

		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		versionGR.addQuery('member_application', applicationId);
		versionGR.addQuery('version', version);
		versionGR.query();
		if (versionGR.next()) {
			var attachmentGR = new GlideRecord('sys_attachment');
			attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
			attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
			attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
			attachmentGR.query();
			if (attachmentGR.next()) {
				attachmentId = attachmentGR.getUniqueValue();
			}
		}
		
		return attachmentId;
	}
})();

Before we get too excited about formatting all of this data for display, let’s just throw a single value onto the display and see if we can get the mechanics of bringing up the widget all working. Here is some simple HTML to get things started.

<div>
  <h3>{{c.data.record.name}}</h3>
</div>

That should be enough to get things going, so let’s pop back over to the main widget and see if we can set up a function in the Client script to call up this widget.

$scope.openApplicationModal = function(evt, item) {
	var modelOptions = {
		title: "${Application Details}",
		widget: "cs-application-details",
		widgetInput: {
			sys_id: item.sys_id
		},
		buttons: [],
		footerStyle: {
			display: 'none'
		},
		size: 'lg'
	};
	$scope.applicationModal = spModal.open(modelOptions);
};

Now that we have a function to call, we need to go into the HTML and set up the call to the function when the operator clicks on the tile.

<a href="javascript:void(0);" ng-click="openApplicationModal($event, item)" class="panel-body block height-100" sn-focus="{{::item.highlight}}" aria-labelledby="cs_app_{{::item.sys_id}}" aria-describedby="cs_app_desc_{{::item.sys_id}}">
  <div>
    <h3 class="h4 m-t-none m-b-xs text-overflow-ellipsis" title="{{::item.name}}" style="padding-bottom:1px" id="cs_app_{{::item.sys_id}}">{{::item.name}}</h3>
    <img ng-src="{{::item.logo}}.iix?t=small" ng-if="item.logo" alt="" class="m-r-sm m-b-sm item-image pull-left" aria-hidden="true"/>
    <div class="text-muted item-short-desc catalog-text-wrap" id="cs_app_desc_{{::item.sys_id}}">{{::item.description}}</div>
  </div>
</a>

That should be enough to be able to open up the store and give things the old college try.

Simple application details pop-up

Not bad. OK, now that we have the basic mechanics working, we need to design the layout of the pop-up and also add whatever functionality we might want such as links to any forms or actions. At this point in the process, we have not added that much in the way of extra data. There are no categories or keywords or comments or user ratings or statistics or much of anything else in the way of interesting information outside of the name and description of the application. Some or all of that may come in some future version, but for now, about the best we can do to add detail would be to add the version history and to throw in a few useful links.

Store application detail pop-up

The above example is for an app pulled down from the store. For local apps that have been pushed up to the store, the look would be similar, but with a few differences.

Local application detail pop-up

For the local application, we should probably continue with the modified background, just to be consistent, but you get the idea. To pull this off, we will have to fetch more data from the database, and work out all of the associated HTML. Once that is done, that should be good enough to push out a new version so that folks can try it all out at home. That’s still a bit of work, so let’s deal with all of that next time out.

Collaboration Store, Part LXXIX

“You just have to keep driving down the road. It’s going to bend and curve and you’ll speed up and slow down, but the road keeps going.”
Ellen DeGeneres

Last time, we got the ability to toggle between the card/tile view and table view working as it should, and we made an initial stab at making the local apps look a little different from the apps that could be or have been pulled down from the Host. Now we need to figure out what we want to happen when the operator clicks on any of the links that are present on the tiles or table rows. Currently, the main portion of the tile is clickable, and in the footer, the version number could be clickable as well and could potentially launch the install process. In fact, let’s take a look at that first.

Right now, if you want to install a version of a store application, you go to the version record and click on the Install button. Let’s take a quick peek at that UI Action and see how that works.

var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', current.sys_id);
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
	action.setRedirectURL('/upload.do?attachment_id=' + attachmentGR.getUniqueValue());
} else {
	gs.addErrorMessage('No Update Set XML file found attached to this version');
}

Basically, it just links to the stock upload.do page (which we hacked up a bit sometime back) with the attachment sys_id as a parameter. Assuming that we had the attachment sys_id included along with the rest of the row data, we could simply build an anchor tag to launch the install such as the following.

<a href="/upload.do?attachment_id={{::item.attachmentId}}">

To get the attachment sys_id, we could steal some of the UI Action code above to build a function for that purpose.

function getAttachmentId(applicationId, version) {
	var attachmentId = '';

	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application', applicationId);
	versionGR.addQuery('version', version);
	versionGR.query();
	if (versionGR.next()) {
		var attachmentGR = new GlideRecord('sys_attachment');
		attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
		attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
		attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
		attachmentGR.query();
		if (attachmentGR.next()) {
			attachmentId = attachmentGR.getUniqueValue();
		}
	}
		
	return attachmentId;
}

For local apps, or apps that are already installed and up to date, there is no action to take. But for apps that have not been installed, or those that have, but are not on the latest version, a link to the install process would be appropriate. Two mutually exclusive tags would cover both cases.

<span ng-if="item.local || item.state == 2" class="current-version" title="${Version} {{::item.version}}">v{{::item.version}}</span>
<a ng-if="!item.local && item.state != 2" class="{{['not-installed','earlier-version'][item.state]}}" href="/upload.do?attachment_id={{::item.attachmentId}}" title="${Click here to} {{item.state == 0?'install':'upgrade to'}} ${version} {{::item.version}}">v{{::item.version}}</a>

To test this out, we will need to publish an app on our Host instance and bring up the store on our Client instance to see how this looks.

Install/Upgrade link on nonlocal applications

That takes care of the install link. For the main action when clicking on the tile itself (or on the app name in the table view), we should have some way of displaying all of the details of the app. We could link to the existing form for the application table, but we might want something a little more formatted, and maybe even just a pop-up so that you don’t actually leave the shopping experience. Let’s see if we can throw something like that together next time out.

Collaboration Store, Part LXXVIII

“Don’t dwell on what went wrong. Instead, focus on what to do next. Spend your energies on moving forward toward finding the answer.”
Denis Waitley

Last time, we were able to get to the point where we could bring up our new storefront and take a quick peek at how things were looking. It was a good start, but there are still a number of things that we need to do to, and some important decisions to be made before we can wrap this up. Visually, I think we want to distinguish between the apps that were developed on the local instance, and the apps that we pulled in from the other members of the community. Also, of the apps that have been pulled in from other member instances, we want to somehow distinguish between those that have been installed locally and those that have not, and of those that have been installed, which are running the most current version and which are eligible for an upgrade.

Before we get into all of that, though, let’s jump into something easy. There are two views on the original widget, the tile/card view and the table view. The default is the tile/card view, which we were able to bring up, but to switch views, we will need some client-side code. Taking a quick peek at the HTML for the view selectors, we can see which function is being called.

<i id="tab-card"
  role="tab"
  class="fa fa-th tab-card-padding"
  ng-click="changeView('card')" 
  ng-keydown="switchTab($event)"
  aria-label="${Card View}" 
  ng-class="{'active' : view == 'card'}"
  title="${Card View}"
  data-toggle="{{!isTouchDevice() ? 'tooltip' : undefined}}"
  data-placement="top"
  data-container="body"
  aria-selected="{{view == 'card'}}" 
  aria-label="${Card View}"
  ng-attr-aria-controls="{{view == 'card' ? 'tabpanel-card-' + (data.category_id ? data.category_id : '') : undefined}}"
  tabindex="{{view == 'card' ? '0' : '-1'}}">
</i>

There are actually two functions referenced here, one for the ng-click and a different one for the ng-keydown. We should be able to locate both of those in the original widget, and we should be able to use them just the way that that appear in the original.

$scope.changeView = function (view) {
	$scope.view = view;
};

$scope.switchTab = function($event) {
	if ($event.which == 37 || $event.which == 39) {
		$event.stopPropagation();
		var layout = $scope.view === 'card' ? 'grid' : 'card';
		$scope.changeView(layout);
		$('#tab-' + layout).focus();
	}
};

With those in place, we should be able to pull up our new page and use the selectors to toggle over to the other view.

Collaboration Store table view

So that works, which is good. One other thing that you might have noticed is that we added the Host instance logo to the header of the store. That just took a little bit of extra HTML that we copied from the application list.

<div class="col-xs-9">
  <img ng-src="{{::c.data.store.logo}}.iix?t=small" ng-if="c.data.store.logo" alt="" class="m-r-sm m-b-sm item-image pull-left" aria-hidden="true"/>
  <h2 class="h4 m-t-none break-word">{{c.data.store.name}} Collaboration Store</h2>
  <p class="hidden-xs break-word">
    {{c.data.store.description}}
  </p>
</div>

Now, back to our earlier dilemma of differentiating between the various states of the applications from the store. The first thing that we will need to do is to pull the data that we need from the database and also get rid of all of that left-over catalog related stuff that we neglected to strip out earlier. Building up the item object now looks like this.

var item = {};
item.name = appGR.getDisplayValue('name');
item.description = appGR.getDisplayValue('description');
item.logo = appGR.getValue('logo');
item.version = appGR.getDisplayValue('current_version');
item.provider = appGR.getDisplayValue('provider.name');
item.providerLogo = appGR.provider.getRefRecord().getValue('logo');
item.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
item.sys_id = appGR.getUniqueValue();
item.state = 0;
if (appGR.getValue('application')) {
	item.state = 1;
	item.installedVersion = appGR.getDisplayValue('application.version');
	if (item.version == item.installedVersion) {
		item.state = 2;
	}
}

Now that we have all of the data that we need, the next question is how do we want things to behave. For the local applications, maybe just a slightly different background would make those visually distinct. Let’s add the following class to the widget’s CSS:

.local-app {
	background-color: #b5ebd4;
}

Then, in the HTML for the card/tile layout, we can tweak the first DIV in the list item to look like this:

<div class="panel item-card b sc-panel{{item.local?' local-app':''}}">

This will add the local-app class to the DIV if the item is a local application. We can pull up the store and take a quick peek to see how that looks.

Collaboration Store with local apps visually distinguished

Not bad. We may still want to tinker with the CSS a bit to get things to our liking, but at least we have a method now to make the local apps look different than the other apps in the store.

Beyond looking different, though, we are also going to want store apps to behave differently than local apps, since local apps are published to the store, and store apps that are not local are meant to be pulled down from the store and installed on the local instance. Those that are already installed will have different options than those that are installed and up to date, and those that are not up to date will have different options compared with those that are. Let’s dive into all of that next time out.