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 LXXXIV

“The power of one, if fearless and focused, is formidable, but the power of many working together is better.”
Gloria Macapagal Arroyo

Last time, we released yet another beta version of the app for testing, so now might be a good time to talk a little bit about what exactly needs to be tested, and maybe a little bit about where things stand and where we go from here. We have had a lot of good, quality feedback in the past, and I am hoping for even more from this version. Every bit helps drive out annoying errors and improves the quality of the product, so keep it coming. It is very much appreciated.

Installation

The first thing that needs to be tested, of course, is the installation itself. Just to review, you need to install three Update Sets in the appropriate order, SNH Form Fields, the primary Scoped Application, and the accompanying global components that could not be bundled with the scoped app. You can find the latest version of SNH Form Fields here, or you can simply grab the latest SNH Data Table Widgets from Share, which includes the latest version of the form field tag. Once that has been installed, you can then install collaboration_store_v0.7.5.xml, after which you can then install the globals, collaboration_store_globals_v0.7.xml.

There are two types of installations, a brand new installation and an upgrade of an existing installation. Both types of installs have had various issues reported with both Preview and Commit errors. On a brand new installation, just accept all updates and you should be good to go. I don’t actually know why some of those errors come up on a brand new install, but if anyone knows of any way to keep that from happening, I would love to hear about it. It doesn’t seem to hurt anything, but it would be so much better if those wouldn’t come up at all. On an upgrade to an existing installation, you will want to reject any updates related to system properties. The value of the application’s properties are established during the set-up process, and if you have already gone through the set-up process, you don’t want those values overlaid by the installation of a new version. Everything else can be accepted as is. Once again, if anyone has any ideas on how to prevent that kind of thing from happening, please let us all know in the comments below.

The Set-up Process

The Collaboration Store Set-up process

Once you have the software installed for the first time, you will need to go through the set-up process. This is another thing that needs to be tested thoroughly, both for a Host instance and a Client instance. It needs to tested with logo images and without, and for Client instances, you will need to check all of the other member instances in the community to ensure that the newly set-up instance now appears in each instance. During the set-up process, a verification email will be sent to the email address entered on the form, and if your instance does not actually send out mail, you will need to look in the system email logs for the code that you will need to complete the process.

The Publishing Process

Application Publishing Process

Once the software has been installed and the set-up process completed, you can now publish an application to the store. Both Client instances and Host instances can publish apps to the store. Publishing is accomplished on the system application form via a UI Action link at the bottom of the form labeled Publish to Application Store. Click on the link and follow the prompts to publish your application to the store. If you run into any issues, please report them in the comments below.

The Installation Process

Application Installation Process

Once published to the store, shared applications can be installed by any other Host or Client instance from either the version record of the version desired, or the Collaboration Store itself. Simply click on the Install button and the installation should proceed. Once again, if you run into any issues, please use the comments below to provide us with some detailed information on where things went wrong.

The Collaboration Store

The Collaboration Store

The Collaboration Store page is where you should see all of the applications shared with the community and there are a lot of things that need to be tested here. This is the newest addition to the application, so we will want to test this thing out thoroughly. One thing that hasn’t been tested at all is the paging, as I have never shared enough apps to my own test environment to exceed the page limit. The more the merrier as far as testing is concerned, so if you can add as many Client instances as possible, that would be helpful, and if each Client could share as many applications as possible, that would be helpful as well. Several pages worth, in varying states would help in the testing of the search widget as well as the primary store widget. And again, if you run into any problems, please report them in the comments.

The Periodic Sync Process

The periodic sync process is designed to recover from any previous errors and ensure that all Clients in the community have all of the same information that is stored in the Host. Testing the sync process is simply a matter of removing some artifacts from some Client instance and then running the sync process to see if those artifacts were restored. The sync process runs every day on the Host instance over the lunch hour, but you can also pull up the Flow and run it by hand.

Thanks in advance to those of you who have already contributed to the testing and especially to those of you who have decided to jump in at this late stage and give things a try. Your feedback continues to be quite helpful, and even if you don’t run into any issues, please leave us a comment and let us know that as well. Hopefully, we are nearing the end of this long, drawn out project, and your assistance will definitely help to wrap things up. Next time, we will talk a little bit more about where things go from here.

Collaboration Store, Part LXXXIII

“Many hands make light work.”
John Heywood

Last time, we built a little companion widget to share the page with our storefront widget to provide the ability to filter the list of applications displayed. Clicking on the search button on that widget reloads the page with the search criteria present in the query string of the URL. Now we need to modify the primary widget to pull that search criteria in from the URL and then use it when querying the database for applications to display. To begin, we can use basically the same code that we used in the search widget to bring in the values.

var search = $sp.getParameter('search');
var local = $sp.getParameter('local') == 'true';
var current = $sp.getParameter('current') == 'true';
var upgrade = $sp.getParameter('upgrade') == 'true';
var notins = $sp.getParameter('notins') == 'true';

Now that we have the information, we need to use it to filter the query results. For the search string, we want to look for that string in either the name or the description of the app. That one is relatively straightforward and can be handled with a single encoded query.

var appGR = new GlideRecord('x_11556_col_store_member_application');
if (search) {
	appGR.addEncodedQuery('nameLIKE' + search + '^ORdescriptionLIKE' + search);
}

The remaining parameters are all boolean values that relate to one another in some way, so we have to handle them as a group. First of all, if none of the boxes are checked or all of the boxes are checked, then there is no need for any filtering, so we can eliminate those cases right at the top.

if (local && current && upgrade && notins) {
		// everything checked -- no filter needed
} else if (!local && !current && !upgrade && !notins) {
	// nothing checked -- no filter needed
} else {
	...
}

After that, things get a little more complicated. Local apps are those where the provider instance is the local instance, and we don’t apply any other filter to that pool. You either want the local apps included or you do not. They are included by default, but if you start checking boxes then they are only included if you check the Local checkbox.

var query = '';
var separator = '';
if (local) {
	query += 'provider.instance=' + gs.getProperty('instance_name');
	separator = '^OR';
} else {
	query += separator + 'provider.instance!=' + gs.getProperty('instance_name');
	separator = '^';
}

The remaining three values relate to the apps pulled down from the store, which are classified based on whether they have been installed on the local instance (current and upgrade) or not (notins). Installed apps have a value in the application field and those that have not been installed do not. Additionally, installed apps are considered current if the installed version is the same as the current version; otherwise they are classified as having an upgrade available. We only need to check the version if you want one, but not the other. If you want both current and upgrade or neither current nor upgrade, then there is no point in making that distinction. So we first check both cases where the values are different.

if (current && !upgrade) {
	if (notins) {
		query += separator + 'applicationISEMPTY^ORversionSAMEASapplication.version';
	} else {
		query += separator + 'versionSAMEASapplication.version';
	}
} else if (!current && upgrade) {
	if (notins) {
		query += separator + 'applicationISEMPTY^ORversionNSAMEASapplication.version';
	} else {
		query += separator + 'versionNSAMEASapplication.version';
	}
...

And finally, we check the last two cases where current and upgrade are the same value.

} else if (current && upgrade && !notins) {
	query += separator + 'applicationISNOTEMPTY';
} else if (!current && !upgrade && notins) {
	query += separator + 'applicationISEMPTY';
}

That should do it. Putting that all together with the original fetchItemDetails function gives us this new version of that function.

function fetchItemDetails(items) {
	var search = $sp.getParameter('search');
	var local = $sp.getParameter('local') == 'true';
	var current = $sp.getParameter('current') == 'true';
	var upgrade = $sp.getParameter('upgrade') == 'true';
	var notins = $sp.getParameter('notins') == 'true';
	var appGR = new GlideRecord('x_11556_col_store_member_application');
	if (search) {
		appGR.addEncodedQuery('nameLIKE' + search + '^ORdescriptionLIKE' + search);
	}
	if (local && current && upgrade && notins) {
		// everything checked -- no filter needed
	} else if (!local && !current && !upgrade && !notins) {
		// nothing checked -- no filter needed
	} else {
		var query = '';
		var separator = '';
		if (local) {
			query += 'provider.instance=' + gs.getProperty('instance_name');
			separator = '^OR';
		} else {
			query += separator + 'provider.instance!=' + gs.getProperty('instance_name');
			separator = '^';
		}
		if (current && !upgrade) {
			if (notins) {
				query += separator + 'applicationISEMPTY^ORversionSAMEASapplication.version';
			} else {
				query += separator + 'versionSAMEASapplication.version';
			}
		} else if (!current && upgrade) {
			if (notins) {
				query += separator + 'applicationISEMPTY^ORversionNSAMEASapplication.version';
			} else {
				query += separator + 'versionNSAMEASapplication.version';
			}
		} else if (current && upgrade && !notins) {
			query += separator + 'applicationISNOTEMPTY';
		} else if (!current && !upgrade && notins) {
			query += separator + 'applicationISEMPTY';
		}
		appGR.addEncodedQuery(query);
	}
	appGR.orderBy('name');
	appGR.query();
	while (appGR.next()) {
		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;
			}
		}
		if (!item.local && item.state != 2) {
			item.attachmentId = getAttachmentId(item.sys_id, item.version);
		}
		items.push(item);
	}
}

Now we can fire up the storefront page and start clicking around and see what we have. Or better yet, we can push out yet another Update Set and let all of you following along at home click around and see if everything works as it should. I always like it when folks with a little different perspective take the time to pull stuff down and give it a whirl, so here you go:

If this is your first time, you will want to take a peek here and here and here. For the rest of you, this is just another 0.7.x drop-in replacement, and you should know what to do by now. Please let us all know what you find in the comments below. Feedback is always welcome and always very much appreciated. Hopefully, we will get some interesting results and we can take a look at those next time out.

Collaboration Store, Part LXXXII

“It’s really complex to make something simple.”
Jack Dorsey

Last time, we wrapped up an initial version of the storefront and released a new Update Set in the hopes that a few folks out there would pull it down and take it for a spin. While we wait patiently to hear back from anyone who might have been willing to download the new version and run it through its paces, let’s see if we can’t add a little more functionality to our shopping experience. The page that we laid out had a place for two widgets, but we only completed the primary display widget so far. The other, smaller portion of the screen was reserved for some kind of search/filter widget to help narrow down the results for installations where a large number of applications have been shared with the community. Adding that second widget to the page would give us something like this:

Storefront with new search/filter widget added

Rather than have the two widgets speak directly to one another, my thought was that we could use the same technique that was used with the Configurable Data Table Widget Content Selector bundled with the SNH Data Table Widgets. Instead of having one widget broadcast messages and the other widget listen for those messages, the Content Selector communicates with the Data Table widget via the URL. Whenever a selection is made, a URL is constructed from the selections, and then the Content Selector branches to that URL where both the Content Selector and the Data Table widget pull their control information from the shared URL query parameters. We can do exactly the same thing here for the same reasons.

For this initial attempt, I laid out a generic search box and a number of checkboxes for various states of applications on the local instance. To support that, we can use the following URL parameters: search, local, current, upgrade, and notins. In the widget, we can also use the same names for the variables used to store the information, and we can populate the variables from the URL. In fact, that turns out to be the entirety of the server-side code.

(function() {
	data.search = $sp.getParameter('search') ? decodeURIComponent($sp.getParameter('search')) : '';
	data.local = $sp.getParameter('local') == 'true';
	data.current = $sp.getParameter('current') == 'true';
	data.upgrade = $sp.getParameter('upgrade') == 'true';
	data.notins = $sp.getParameter('notins') == 'true';
})();

And here is the HTML that I put together to display the information.

<div class="panel">
  <div class="row">
    <div class="col">
      <p>&nbsp;</p>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.search" name="search" id="search" type="text" class="form-control" placeholder="&#x1F50D;"/>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.local" name="local" id="local" type="checkbox"/>
      <label for="local">${Local}</label>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.current" name="current" id="current" type="checkbox"/>
      <label for="current">${Current}</label>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.upgrade" name="upgrade" id="upgrade" type="checkbox"/>
      <label for="upgrade">${Upgrade Available}</label>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.notins" name="new" id="new" type="checkbox"/>
      <label for="notins">${Not Installed}</label>
    </div>
  </div>
  <div class="row">
    <div class="col text-center">
      <button ng-click="search()" class="btn btn-primary" title="${Click to search the Collaboration Store}">${Search}</button>
    </div>
  </div>
</div>

To format things a little nicer, and to kick that little magnifying glass search icon over to the right, we also need to throw in some CSS.

.col {
  padding: .5vw;
}

label {
  margin-left: .5vw;
}

::placeholder {
  text-align: right;
}

There is one client-side function referenced in the HTML for the button click, and that’s pretty much all there is to the widget’s Client script.

api.controller = function($scope, $location) {
	var c = this;

	$scope.search = function() {
		var search = '?id=' + $location.search()['id'];
		if (c.data.search) {
			search += '&search=' + encodeURIComponent(c.data.search);
		}
		if (c.data.local) {
			search += '&local=true';
		}
		if (c.data.current) {
			search += '&current=true';
		}
		if (c.data.upgrade) {
			search += '&upgrade=true';
		}
		if (c.data.notins) {
			search += '&notins=true';
		}
		window.location.search = search;
	};
};

The search() function builds up a URL query string based on the operator’s input and then updates the current location with the new query string, essentially branching to the new location. When the new page loads, both the search widget and the storefront widget can then pull their information from the current URL. We can test all of this out now by saving the new widget, pulling up our collaboration_store page in the Service Portal Designer, and then dragging our new widget onto the page in the space already reserved for that purpose.

Dragging the new widget onto the existing page

With that completed, we can now try out the page and see that entering some data and clicking on the Search button actually does reload the page with a new URL. However, at this point the content of the primary page never changes because we have yet to add code to the main widget to pull in the URL parameters and then use that data to adjust the database query. That sounds like a good subject for our next installment.