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 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.

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 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.

Collaboration Store, Part LXXVI

“Never look down to test the ground before taking your next step; only he who keeps his eye fixed on the far horizon will find the right road.”
Dag Hammarskjold

Last time, we took a look at a number of different examples of pages that could serve as a model for locating an app in the Collaboration Store. We decided to use the Service Portal page sc_category, the Service Catalog category browse page, as a starting point for our efforts. Taking a look at the page, we can see two containers containing three rows of various widgets.

sc_category page contents

The only thing that we really need off of this page at this point is our own copy of the SC Category Page widget, so let’s go make a copy of that and call it Collaboration Store, since this is basically going to be the storefront of our Collaboration Store.

Cloning the SC Category Page widget

Once we make the copy, we can update the Name, ID, and Description fields and save our new widget.

New Collaboration Store widget

Now that we have our own copy to play with, let’s take a look at that HTML and see what we want to keep and what we want to toss.

<div id="sc_category_page"  class="m-t-sm " ng-class="{'hidden-xs' : hideItemWidget, 'm-l-sm': !isMobile}">
  <h4 ng-if="data.error">{{data.error}}</h4>
  <div ng-init="spSearch.targetCatalog()">
    <div class="row">
      <div class="col-xs-9">
        	<button ng-click="showCategories()" class="visible-xs m-b-sm pointer btn-link" tabindex="0" id="all-categories-link">
            <i class="fa fa-chevron-left m-r-xs"></i> ${All Categories}
        	</button>
          <h2 class="h4 m-t-none break-word" aria-label="{{data.categoryPageAriaLabel}}">{{data.category.title}}</h2>
        	<p class="hidden-xs break-word">
            {{data.category.description}}
          </p>
      </div>
      <div class="col-xs-3" ng-if="!isMobile">
        <div role="tablist" class="pull-right padder-t-sm text-lg toggle" ng-show="!data.error && data.items.length > 0">
          <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>
          <span class="m-l-sm m-r-sm " aria-hidden="true"> | </span>
          <i id="tab-grid" 
             role="tab"
             class="fa fa-list-ul tab-card-padding" 
             ng-click="changeView('grid')" 
             ng-keydown="switchTab($event)" 
             ng-class="{'active' : view == 'grid'}"
             title="${Table View}"
             data-toggle="{{!isTouchDevice() ? 'tooltip' : undefined}}"
 	           data-placement="top"
             data-container="body"
             aria-selected="{{view == 'grid'}}"
             aria-label="${Table View}" 
             ng-attr-aria-controls="{{view == 'grid' ? 'tabpanel-grid-' + (data.category_id ? data.category_id : '') : undefined}}" 
             tabindex="{{view == 'grid' ? '0' : '-1'}}"></i>
        </div>
       </div>
    </div>
    <div class="row">
    	<div class="text-a-c" ng-if="showTopLoader">
      		<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
			<span class="sr-only">${Loading...}</span>
    	</div>
      <div class="col-sm-6 col-md-4" ng-if="!showTopLoader && !data.items.length && !data.error">
        ${No items in category}
      </div>
      <div id="tabpanel-grid-{{::data.category_id}}" role="tabpanel" aria-labelledby="{{'tab-grid'}}" ng-if="view == 'grid' && data.items.length > 0">
        <table class="table table-striped item-table" aria-label="{{::data.category.title}}" aria-describedby="id-caption-category">
        	<caption id="id-caption-category"><span class="sr-only">{{::data.category.title}}</span></caption>
          <thead>
            <tr>
              <th id="id-header-item" scope="col" colspan="2">${Item}</th>
              <th id="id-header-description" scope="col" colspan="3">${Description}</th>
            	  <th id="id-header-price" scope="col" ng-if="data.showPrices">${Price}</th>
            </tr>
          </thead>
          <tbody>
            <tr ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()">
              <td id="id-item-{{item.sys_id}}" headers="id-header-item" scope="row" colspan="2">
                <a target="{{::item.target}}" ng-href="{{::getItemHREF(item)}}" sn-focus="{{::item.highlight}}" ng-click="onClick($event, item)"> 
                	<div>
                    <img ng-src="{{::item.picture}}?t=small" ng-if="item.picture" alt="" class="m-r-sm m-b-sm item-image pull-left"/>
                    <span class="catalog-text-wrap catalog-item-name">{{::item.name}}</span>
                    <span ng-if="item.content_type == 'external'"><span class="sr-only">${External Link}</span> âžš</span>
                  </div>
                </a>
            	  </td>
            	  <td headers="id-header-description id-item-{{item.sys_id}}" class="catalog-text-wrap" colspan="3">{{::item.short_description}}</td>
            	  <td headers="id-header-price id-item-{{item.sys_id}}" ng-if="data.showPrices">{{::item.price}}</td>
            </tr>
          </tbody>
        </table>
      </div>
      <div id="tabpanel-card-{{::data.category_id}}" ng-if="view == 'card' && data.items.length > 0" role="tabpanel" aria-labelledby="{{'tab-card'}}">
      	<ul class="item-list-style-type-none item-card-row" role="list" aria-label="{{data.category.title}} ${items}">
      		<li class="item-card-column" ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()" role="listitem">
            <div class="panel panel-{{::options.color}} item-card b sc-panel"  data-original-title="{{::item.name}}">
              <a target="{{::item.target}}" ng-href="{{::getItemHREF(item)}}" ng-click="onClick($event, item)" class="panel-body block height-100" sn-focus="{{::item.highlight}}">
                <div>
                  <h3 class="h4 m-t-none m-b-xs text-overflow-ellipsis catalog-item-name" title="{{::item.titleTag}}" style="padding-bottom:1px">{{::item.name}}<span ng-if="item.content_type == 'external'"><span class="sr-only">${External Link}</span> âžš</span></h3>
                  <img ng-src="{{::item.picture}}?t=small" ng-if="item.picture" 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">{{::item.short_description}}</div>
                </div>
              </a>
            </div>
            <div class="panel-footer b">
              <a aria-label="${View Details} {{::item.name}}" ng-if="item.sys_class_name != 'sc_cat_item_content' || item.content_type == 'kb' || item.content_type == 'literal'" ng-click="onClick($event, item)" ng-href="{{getItemHREF(item)}}" class="pull-left text-muted">${View Details}</a>
              <a aria-label="${View Details} {{::item.name}}" ng-if="item.sys_class_name == 'sc_cat_item_content' && item.content_type == 'external'" ng-click="onClick($event, item)" ng-href="{{getItemHREF(item)}}" target="_blank" class="pull-left text-muted">${View Details}</a>
              <span ng-if="data.showPrices && item.hasPrice" class="pull-right item-price font-bold">{{::item.price}}</span> &nbsp;
            </div>
      		</li>
      	</ul>  
      </div>
    </div>
    <div class="text-a-c" ng-if="!stopLoader && data.items.length > 0 && !data.error">
      <i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
			<span class="sr-only">${Loading...}</span>
    </div>
    <div ng-if="data.show_more && !show_popular_item">
      	<div class="text-a-c">
          {{data.more_msg}}
      	</div>
        <button class="m-t-xs btn btn-default btn-loadmore" ng-click="loadMore()">
          ${Show More Items}
        </button>
      </div>
  </div>
</div>
<now-message key="Catalogs" value="${Catalogs}"/>

Starting at the top, the All Categories button has no use in our scenario, so we can cut that part out. The category title and category description data can be replaced with the name and description of the store (the Host instance). The next block contains the pair of icons used to select between a display of tiles or a simple list. I like those options, so we will keep that section intact. Following that we have a loading block and a nothing to see here block, both of which would seem to have a valid use in our adaptation, so we will leave those there for now as well.

The next section is the table view, with columns for Item, Description, and Price. We will do something similar, but our columns will be Application, Description, Version, and Provider. The next section is the tile view, and we will work our same data points into the tile layout as well. The final block is all of the elements of the optional Show More Items section, and we can just leave that in place for now. That leaves our HTML looking something like this:

<div id="sc_category_page"  class="m-t-sm " ng-class="{'hidden-xs' : hideItemWidget, 'm-l-sm': !isMobile}">
  <h4 ng-if="data.error">{{data.error}}</h4>
  <div>
    <div class="row">
      <div class="col-xs-9">
        <h2 class="h4 m-t-none break-word" aria-label="{{data.store.name}}">{{data.store.name}}</h2>
       	<p class="hidden-xs break-word">
          {{data.store.description}}
        </p>
      </div>
      <div class="col-xs-3" ng-if="!isMobile">
        <div role="tablist" class="pull-right padder-t-sm text-lg toggle" ng-show="!data.error && data.items.length > 0">
          <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>
          <span class="m-l-sm m-r-sm " aria-hidden="true"> | </span>
          <i id="tab-grid" 
             role="tab"
             class="fa fa-list-ul tab-card-padding" 
             ng-click="changeView('grid')" 
             ng-keydown="switchTab($event)" 
             ng-class="{'active' : view == 'grid'}"
             title="${Table View}"
             data-toggle="{{!isTouchDevice() ? 'tooltip' : undefined}}"
 	           data-placement="top"
             data-container="body"
             aria-selected="{{view == 'grid'}}"
             aria-label="${Table View}" 
             ng-attr-aria-controls="{{view == 'grid' ? 'tabpanel-grid-' + (data.category_id ? data.category_id : '') : undefined}}" 
             tabindex="{{view == 'grid' ? '0' : '-1'}}"></i>
        </div>
       </div>
    </div>
    <div class="row">
      <div class="text-a-c" ng-if="showTopLoader">
        <i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
        <span class="sr-only">${Loading...}</span>
      </div>
      <div class="col-sm-6 col-md-4" ng-if="!showTopLoader && !data.items.length && !data.error">
        ${There are no applications in this store}
      </div>
      <div id="tabpanel-grid-{{::data.store.name}}" role="tabpanel" aria-labelledby="{{'tab-grid'}}" ng-if="view == 'grid' && data.items.length > 0">
        <table class="table table-striped item-table" aria-label="{{::data.store.name}}" aria-describedby="id-caption-category">
        	<caption id="id-caption-category"><span class="sr-only">{{::data.store.name}}</span></caption>
          <thead>
            <tr>
              <th id="id-header-name" scope="col" colspan="2">${Name}</th>
              <th id="id-header-description" scope="col" colspan="3">${Description}</th>
              <th id="id-header-version" scope="col" colspan="3">${Version}</th>
              <th id="id-header-provider" scope="col" colspan="3">${Provider}</th>
            </tr>
          </thead>
          <tbody>
            <tr ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()">
              <td id="id-item-{{item.sys_id}}" headers="id-header-item" scope="row" colspan="2">
                <a href="javascript:void(0)"> 
                  <div>
                    <img ng-src="{{::item.logo}}.iix?t=small" ng-if="item.picture" alt="" class="m-r-sm m-b-sm item-image pull-left"/>
                    <span class="catalog-text-wrap catalog-item-name">{{::item.name}}</span>
                  </div>
                </a>
              </td>
              <td headers="id-header-description id-item-{{item.sys_id}}" class="catalog-text-wrap" colspan="3">{{::item.description}}</td>
              <td headers="id-header-version id-item-{{item.sys_id}}">{{::item.version}}</td>
              <td headers="id-header-provider id-item-{{item.sys_id}}">{{::item.provider}}</td>
            </tr>
          </tbody>
        </table>
      </div>
      <div id="tabpanel-card-{{::data.category_id}}" ng-if="view == 'card' && data.items.length > 0" role="tabpanel" aria-labelledby="{{'tab-card'}}">
      	<ul class="item-list-style-type-none item-card-row" role="list" aria-label="{{data.category.title}} ${items}">
      	  <li class="item-card-column" ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()" role="listitem">
            <div class="panel panel-{{::options.color}} item-card b sc-panel">
              <a href="javascript:void(0);" class="panel-body block height-100" sn-focus="{{::item.highlight}}" aria-labelledby="sc_cat_item_{{::item.sys_id}}" aria-describedby="sc_cat_item_short_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="sc_cat_item_{{::item.sys_id}}">{{::item.name}}<span ng-if="item.content_type == 'external'"><span class="sr-only">${External Link}</span> âžš</span></h3>
                  <img ng-src="{{::item.picture}}.iix?t=small" ng-if="item.picture" 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="sc_cat_item_short_desc_{{::item.sys_id}}">{{::item.short_description}}</div>
                </div>
              </a>
            </div>
            <div class="panel-footer b">
              <span ng-if="item.version" class="font-bold">v{{::item.version}}</span>
              <span style="display: inline-flex;" class="pull-right">
                <span ng-if="item.provider">{{::item.provider}}</span>
                &nbsp;
                <img ng-src="{{::item.providerLogo}}.iix?t=small" ng-if="item.providerLogo" alt="{{::item.provider}}" class="avatar-small" style="display: inline-flex; width: 16px; height: 16px;"/>
                &nbsp;
              </span>
            </div>
      	  </li>
      	</ul>  
      </div>
    </div>
    <div class="text-a-c" ng-if="!stopLoader && data.items.length > 0 && !data.error">
      <i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
      <span class="sr-only">${Loading...}</span>
    </div>
    <div ng-if="data.show_more && !show_popular_item">
      <div class="text-a-c">
        {{data.more_msg}}
      </div>
      <button class="m-t-xs btn btn-default btn-loadmore" ng-click="loadMore()">
        ${Show More Items}
      </button>
    </div>
  </div>
</div>

Now that we have the HTML all roughed out, it would nice to bring it up and see how it looks, but we are going to need some data first. For that, we are going to have take a look at the widget’s server-side script. Let’s take a quick peek and see what it is that we have to work with.

(function() {
	if (input && input.category_id)
		data.category_id = input.category_id;
	else
		data.category_id = $sp.getParameter("sys_id");

	data.catalog_id = $sp.getParameter("catalog_id") ? $sp.getParameter("catalog_id") + "" : "-1";
	var catalogsInPortal = ($sp.getCatalogs().value + "").split(",");
	var isCatalogAccessibleViaPortal = data.catalog_id == -1 ? true : false;
	catalogsInPortal.forEach(function(catalogSysId) {
		if (data.catalog_id == catalogSysId) {
			isCatalogAccessibleViaPortal = true;
		}
	});
	data.categorySelected = gs.getMessage('category selected');
	if(!isCatalogAccessibleViaPortal) {
		data.error = gs.getMessage("You do not have permission to see this catalog");
		return;
	}
	var catalogDisplayValue;
	if (data.catalog_id && data.catalog_id !== "-1") {
		var catalogObj = new sn_sc.Catalog(data.catalog_id);
		if (catalogObj) {
			if (!catalogObj.canView()) {
				data.error = gs.getMessage("You do not have permission to see this catalog");
				return;
			}
			catalogDisplayValue = catalogObj.getTitle();
		}
	}
	if (options && options.sys_id)
		data.category_id = options.sys_id;
	data.showPrices = $sp.showCatalogPrices();
	data.sc_catalog_page = $sp.getDisplayValue("sc_catalog_page") || "sc_home";
	data.sc_category_page = $sp.getDisplayValue("sc_category_page") || "sc_category";
	catalogDisplayValue = catalogDisplayValue ? catalogDisplayValue : $sp.getCatalogs().displayValue + "";
	var catalogIDs = (data.catalog_id && data.catalog_id !== "-1") ? data.catalog_id : $sp.getCatalogs().value + "";
	var catalogArr = catalogDisplayValue.split(",");
	var catalogIDArr = catalogIDs.split(",");
	data.sc_catalog = catalogArr.length > 1 ? "" : catalogArr[0];

	data.show_more = false;
	if (GlideStringUtil.nil(data.category_id)) {
		 data.items = getPopularItems();
		 data.show_popular_item = true;
		 data.all_catalog_msg = (($sp.getCatalogs().value + "").split(",")).length > 1 ? gs.getMessage("All Catalogs") : "";
		 data.all_cat_msg = gs.getMessage("All Categories");
		 data.category = {title: gs.getMessage("Popular Items"),
									 description: ''};
		return;
	}

	data.show_popular_item = false;
	// Does user have permission to see this category?
	var categoryId = '' + data.category_id;
	var categoryJS = new sn_sc.CatCategory(categoryId);
	if (!categoryJS.canView()) {
		data.error = gs.getMessage("You do not have permission to see this category");
		return;
	}
	data.category = {title: categoryJS.getTitle(),
									 description: categoryJS.getDescription()};

	var catalog = $sp.getCatalogs().value;

	data.items = [];
	var itemsInPage = options.limit_item || 9;

	data.limit = itemsInPage;
	if (input && input.new_limit)
		data.limit = input.new_limit;
	if (input && input.items) {
		data.items = input.items.slice();//Copy the input array
	}

	if (input && input.startWindow) {
		data.endWindow = input.endWindow;
	}
	else {
		data.startWindow = 0;
		data.endWindow = 0;
	}

	while (data.items.length < data.limit + 1) {
		data.startWindow = data.endWindow;
		data.endWindow = data.endWindow + itemsInPage;
		var itemGR = queryItems(catalog, categoryId, data.startWindow, data.endWindow);
		if (!itemGR.hasNext())
			break;
		fetchItemDetails(itemGR, data.items);
	}

	if (data.items.length > data.limit)
			data.show_more = true;

	data.more_msg = gs.getMessage(" Showing {0} items", data.limit);

	data.categories = [];
	while(categoryJS && categoryJS.getParent()) {
		var parentId =  categoryJS.getParent();
		categoryJS = new sn_sc.CatCategory(parentId);
		var category = {
			label: categoryJS.getTitle(),
			url: '?id='+data.sc_category_page+'&sys_id=' + parentId
		};
		data.categories.unshift(category);
	}

	data.all_catalog_msg = (($sp.getCatalogs().value + "").split(",")).length > 1 ? gs.getMessage("All Catalogs") : "";

	function fetchItemDetails(itemRecord, items) {
		while (itemRecord.next()) {
				var catalogItemJS = new sn_sc.CatItem(itemRecord.getUniqueValue());
				if (!catalogItemJS.canView())
					continue;

				var catItemDetails = catalogItemJS.getItemSummary();
				var item = {};
				item.name = catItemDetails.name;
				item.short_description = catItemDetails.short_description;
				item.picture = catItemDetails.picture;
				item.price = catItemDetails.price;
				item.sys_id = catItemDetails.sys_id;
				item.hasPrice = catItemDetails.show_price;
				item.page = 'sc_cat_item';
				item.type = catItemDetails.type;
				item.order = catItemDetails.order;
				item.sys_class_name = catItemDetails.sys_class_name;
				item.titleTag = catItemDetails.name;
				if (item.type == 'order_guide') {
					item.page = 'sc_cat_item_guide';
				} else if (item.type == 'content_item') {
					item.content_type = catItemDetails.content_type;
					item.url = catItemDetails.url;
					if (item.content_type == 'kb') {
						item.kb_article = catItemDetails.kb_article;
						item.page = 'kb_article';
					} else if (item.content_type == 'external') {
						item.target = '_blank';
						item.titleTag = catItemDetails.name + " âžš";
					}
				}
				items.push(item);
			}
	}

	function queryItems(catalog, categoryId, startWindow, endWindow) {
			var scRecord = new sn_sc.CatalogSearch().search(catalog, categoryId, '', false, options.show_items_from_child != 'true');
			scRecord.addQuery('sys_class_name', 'NOT IN', 'sc_cat_item_wizard');
			scRecord.addEncodedQuery('hide_sp=false^ORhide_spISEMPTY^visible_standalone=true');
			scRecord.chooseWindow(startWindow, endWindow);
			scRecord.orderBy('order');
			scRecord.orderBy('name');
			scRecord.query();
			return scRecord;
	}

	function getPopularItems() {
        return new SCPopularItems().useOptimisedQuery(gs.getProperty('glide.sc.portal.popular_items.optimize', true) + '' == 'true')
            .baseQuery(options.popular_items_created + '')
            .allowedItems(getAllowedCatalogItems())
            .visibleStandalone(true)
            .visibleServicePortal(true)
            .itemsLimit(6)
            .restrictedItemTypes('sc_cat_item_guide,sc_cat_item_wizard,sc_cat_item_content,sc_cat_item_producer'.split(','))
            .itemValidator(function(item, itemDetails) {
                if (!item.canView() || !item.isVisibleServicePortal())
                    return false;

                return true;
            })
            .responseObjectFormatter(function(item, itemType, itemCount) {
                return {
                    order: 0 - itemCount,
                    name: item.name,
                    short_description: item.short_description,
                    picture: item.picture,
                    price: item.price,
                    sys_id: item.sys_id,
                    hasPrice: item.price != 0,
                    page: itemType == 'sc_cat_item_guide' ? 'sc_cat_item_guide' : 'sc_cat_item'
                };
            })
            .generate();
    }

	function getAllowedCatalogItems () {
		var allowedItems = [];
		catalogIDArr.forEach(function(catalogID) {
			var catalogObj = new sn_sc.Catalog(catalogID);
			var catItemIds = catalogObj.getCatalogItemIds();
			for(var i=0; i<catItemIds.length; i++) {
				if (!allowedItems.includes(catItemIds[i]))
					 allowedItems.push(catItemIds[i]);
			}
		});
		return allowedItems;
	}

})();

There is a lot here to digest, and an awful lot that is not relevant to our purpose, particularly all of those things that are related to catalogs and categories. We may want to just toss this out and replace it with some simple logic to pull in the Host information for the header and then all of the apps for the main section. Either way, this seems like a little more work than just rearranging the HTML, so let’s save all of that for our next installment.

Scripted Value Columns, Part VII

“Unplanned occurrences are reminders to check your tendency to think that you’re the one in control.”
James Martin

Last time, we created another example of how one might utilize the new scripted value column feature, this time with catalog item variables instead of Incident journal entries. There are a number of other things that we could try, but two examples should be enough to get the point across, and I’ll leave it to others to come up with additional examples of their own.

We still have two more wrapper widgets to update, though, and we still have that annoying misalignment between the original columns and the new. Here is the way things come out right now:

Misalignment of original columns and new columns

… and here is the way that it should look:

Correct alignment of original columns and new columns

I was able to capture that second image because I found and fixed the problem. I had to replace this HTML:

<sn-avatar ng-if="item[field].value && item[field].type == 'reference' && item[field].table == 'sys_user'" primary="item[field].value" class="avatar-small" show-presence="true" enable-context-menu="false"></sn-avatar>
<a ng-if="$first" href="javascript:void(0)" ng-click="go(item.targetTable, item)" aria-label="${Open record}: {{::item[field].display_value}}">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</a>
<a ng-if="!$first && item[field].type == 'reference' && item[field].value" href="javascript:void(0)" ng-click="referenceClick(field, item)" aria-label="${Click for more on }{{::item[field].display_value}}">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</a>
<span ng-if="!$first && item[field].type != 'reference'">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</span>   

… with this:

<span style="display: inline-flex;">
  <span style="display: inline-flex;" ng-if="item[field].value && item[field].type == 'reference' && item[field].table == 'sys_user'">
    <sn-avatar primary="item[field].value" class="avatar-small" show-presence="true" enable-context-menu="false"></sn-avatar>
    &nbsp;
  </span>
  <a ng-if="$first" href="javascript:void(0)" ng-click="go(item.targetTable, item)" aria-label="${Open record}: {{::item[field].display_value}}">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</a>
  <a ng-if="!$first && item[field].type == 'reference' && item[field].value" href="javascript:void(0)" ng-click="referenceClick(field, item)" aria-label="${Click for more on }{{::item[field].display_value}}">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</a>
  <span ng-if="!$first && item[field].type != 'reference'">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</span>
</span>

Now, without getting into too much detail that no one really cares about, the source of the problem was the sn-avatar tag, which I added a while back so that user columns would have the avatar in front of the name. For some reason, the tag renders out a carriage return and a handful of spaces just before the avatar image. With the ng-if attribute set to false, this collection of white space is still rendered on the page, even when the avatar itself is not. I solved that problem by wrapping the avatar tag with a span and putting the ng-if attribute on the outer span rather than on the sn-avatar tag. That took care of things for columns where there was no avatar, but the user columns, which show the avatar, were still out of alignment with the rest of the columns. Adding style=”display: inline-flex; took care of that problem with the avatar, but then the user name ended up underneath the avatar instead of next to it. To solve that problem, I wrapped the whole thing in another span with the same style attribute. Now everything lines up the way that it should.

Now that that is out of the way, we still have two more wrapper widgets to update. Let’s jump into the SNH Data Table from Instance Definition and do the same kind of searching we did before, looking for some code that might need to be copied and modified. On this particular widget, such a search turns up nothing at all in either the Server script or the Client script, so the only thing that we really need to do is to add another entry to the Option schema for our new scripted value column specification.

{"hint":"A JSON object containing the specifications for scripted value columns",
"name":"scripteds",
"default_value":"",
"section":"Behavior",
"label":"Scripted Value Column Specifications (JSON)",
"type":"String"}

To test this, we can modify our scripted_value_test_2 page to use this widget instead of the SNH Data Table from JSON Configuration widget, and then transfer our configuration options from the Script Include to the widget options.

Widget configuration options for the modified wrapper widget

Now all we need to do is to save it and then run out to the Service Portal and take a quick peek.

Testing the modified SNH Data Table from JSON Configuration widget

So that all looks good. And much, much better now that the column data all lines up as it should! It’s nice to finally have that fixed. That takes care of wrapper widget #2. Now let’s take a look at that last one, the SNH Data Table from URL Definition widget. The only line that appears to require modification is this one:

copyParameters(data, ['aggregates', 'buttons', 'refpage', 'bulkactions']);

… which we can convert to this to pick up our new configuration option:

copyParameters(data, ['scripteds', 'aggregates', 'buttons', 'refpage', 'bulkactions']);

Now we need to test it, so we will need to find or create a page that use this widget. Let’s take a look at the ones that are already out there by checking out the Related List down at the bottom of the form.

Pages that use the SNH Data Table from URL Definition widget

The page my_things looks like a good candidate, so we can take a look at the configuration script that it uses and then edit it to add one or more scripted value columns. One of the tables utilized on that page is the Incident table, so let’s go ahead and use our existing value provider script to add a journal entry column to one of those.

name: 'incident',
displayName: 'Incident',
open: {
	filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe^active=true',
	fields: 'number,opened_by,opened_at,short_description',
	svcarray: [{
		name: 'last_comment',
		label: 'Last Comment',
		heading: 'Last Comment',
		script: 'global.ScriptedJournalValueProvider'
	}],
	aggarray: [],
	btnarray: [],
	refmap: {
		sys_user: 'user_profile'
	},
	actarray: []
}

Now let’s take a look.

First test of the modified SNH Data Table from URL Definition widget

Well, that didn’t work! It’s always something. Even if there were no comments on any of these Incidents, we should still have a column heading for our new scripted value column. I don’t think this problem is in the widget that we just modified, however. This widget shares the page with the Configurable Data Table Widget Content Selector widget, and that is a widget that we have not even touched. That is going to have to be modified to accommodate our new feature as well, as it builds the URL that the SNH Data Table from URL Definition widget turns to for its configuration information. This was not on our list of things to do for this feature, but it definitely needs to be done.

I was hoping to wrap things up with this installment, but now we have a new widget to modify and more testing to do, so I think we will just save all of that, plus the Update Set creation, for our next time out.

Scripted Value Columns, Part III

“Every day you may make progress. Every step may be fruitful. Yet there will stretch out before you an ever-lengthening, ever-ascending, ever-improving path.”
Winston Churchill

Last time, we took care of the configuration script editor and now we need to turn our attention to the main SNH Data Table Widgets starting with the core widget, SNH Data Table. As we did with the editor, we can search the various sections of the widget for aggarray, copy the relevant code, and modify it to handle the new svcarrary. As usual, we can start with the HTML, where we find a couple of sections, one for the headings:

<th ng-repeat="scripted in data.svcarray" class="text-nowrap center" tabindex="0">
  {{scripted.heading || scripted.label}}
</th>

… and one for the data columns:

<td ng-repeat="obj in item.svcValue" role="cell" class="sp-list-cell" ng-class="{selected: item.selected}" tabindex="0">
  {{obj.value}}
</td>

That takes care of the HTML. Now we need to take a look at the Server script. The first thing that we come across is this added block of comments:

// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements

So we will modify that to include two new properties for our new feature.

// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.scripteds = the JSON string containing the scripted value column specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.svcarray = the array of scripted value column specifications
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements

The next reference to aggarray is this added variable copy statement:

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	...
// End: SNH Data Table enhancements

So we will add our new variables to this list.

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'scripteds', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'svcarray', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	...
// End: SNH Data Table enhancements

Shortly after that, we come to this code that validates and initializes the aggarray value.

if (data.aggregates) {
	try {
		var aggregateinfo = JSON.parse(data.aggregates);
		if (Array.isArray(aggregateinfo)) {
			data.aggarray = aggregateinfo;
		} else if (typeof aggregateinfo == 'object') {
			data.aggarray = [];
			data.aggarray[0] = aggregateinfo;
		} else {
			gs.error('Invalid aggregates option in SNH Data Table widget: ' + data.aggregates);
			data.aggarray = [];
		}
	} catch (e) {
		gs.error('Unparsable aggregates option in SNH Data Table widget: ' + data.aggregates);
		data.aggarray = [];
	}
} else {
	if (!data.aggarray) {
		data.aggarray = [];
	}
}

So we can copy that, and add a section just like it for the new svcarray.

if (data.scripteds) {
	try {
		var scriptedinfo = JSON.parse(data.scripteds);
		if (Array.isArray(scriptedinfo)) {
			data.svcarray = scriptedinfo;
		} else if (typeof scriptedinfo == 'object') {
			data.svcarray = [];
			data.svcarray[0] = scriptedinfo;
		} else {
			gs.error('Invalid scripteds option in SNH Data Table widget: ' + data.scripteds);
			data.svcarray = [];
		}
	} catch (e) {
		gs.error('Unparsable scripteds option in SNH Data Table widget: ' + data.scripteds);
		data.svcarray = [];
	}
} else {
	if (!data.svcarray) {
		data.svcarray = [];
	}
}

The next reference that we find is the code that actually adds the values to the records. For the aggregate columns, that code looks like this:

record.aggValue = [];
if (data.aggarray.length > 0) {
	for (var j=0; j<data.aggarray.length; j++) {
		var config = data.aggarray[j];
		var sysId = record.sys_id;
		if (config.source) {
			sysId = gr.getValue(config.source);
		}
		record.aggValue.push(getAggregateValue(sysId, config));
	}
}

We can make a copy of this section as well, but since the scripted values do not require a source property, our new section will be even simpler.

record.svcValue = [];
if (data.svcarray.length > 0) {
	for (var j=0; j<data.svcarray.length; j++) {
		record.svcValue.push(getScriptedValue(record, data.svcarray[j]));
	}
}

Of course, now we have referenced a function that doesn’t yet exist, but that is in fact the next and last reference that we come across. The function for the aggregates looks like this:

// Start: SNH Data Table enhancements
	function getAggregateValue(sys_id, config) {
		var value = 0;
		var ga = new GlideAggregate(config.table);
		ga.addAggregate('COUNT');
		var query = config.field + '=' + sys_id;
		if (config.filter) {
			query += '^' + config.filter;
		}
		ga.addEncodedQuery(query);
		ga.query();
		if (ga.next()) {
			value = parseInt(ga.getAggregate('COUNT'));
		}
		var response = {value: value};
		if (config.hint || config.page_id) {
			response.name = config.name;
		}
		return response;
	}
// End: SNH Data Table enhancements

Here is where we have to do something completely different from the original that we are copying. For the aggregate columns, we are actually doing the query to count the related records. For our new purpose, we are just going to grab an instance of the specified script and call the function on the script to get the value. Since we will be calling the same script for every row, it would be better to fetch the instance of the script once and hang on to it so that the same instance could be used again and again. To support that, we can establish a map of instances and an instance of the Instantiator up near the top.

var instantiator = new Instantiator(this);
var scriptMap = {};

With that in place, we can add the following new function to support the new scripted value columns.

function getScriptedValue(record, config) {
	var response = {value: ''};
	var scriptName = config.script;
	if (scriptName) {
		if (scriptName.startsWith('global.')) {
			scriptName = scriptName.split('.')[1];
		}
		if (!scriptMap[scriptName]) {
			scriptMap[scriptName] = instantiator.getInstance(scriptName);
		}
		if (scriptMap[scriptName]) {
			response.value = scriptMap[scriptName].getScriptedValue(record, config);
		}
	}
	return response;
}

That’s it for the Server script. The whole thing now looks like this:

(function() {
	if (!input) // asynch load list
		return;

	data.msg = {};
	data.msg.sortingByAsc = gs.getMessage("Sorting by ascending");
	data.msg.sortingByDesc = gs.getMessage("Sorting by descending");

	/*
	 * data.table = the table
	 * data.p = the current page starting at 1
	 * data.o = the order by column
	 * data.d = the order by direction
	 * data.keywords = the keyword search term
	 * data.list = the table data as an array
	 * data.invalid_table = true if table is invalid or if data was not succesfully fetched
	 * data.table_label = the table's display name. e.g. Incident
	 * data.table_plural = the table's plural display name. e.g. Incidents
	 * data.fields = a comma delimited list of field names to show in the data table
	 * data.column_labels = a map of field name -> display name
	 * data.window_size = the number of rows to show
	 * data.filter = the encoded query
// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.scripteds = the JSON string containing the scripted value column specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.svcarray = the array of scripted value column specifications
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements
	 */
	// copy to data[name] from input[name] || option[name]
	optCopy(['table', 'p', 'o', 'd', 'filter', 'filterACLs', 'fields', 'keywords', 'view']);
	optCopy(['relationship_id', 'apply_to', 'apply_to_sys_id', 'window_size']);

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'scripteds', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'svcarray', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	// for some reason, 'buttons' and 'table' sometimes get lost in translation ...
	if (data.btns) {
		data.buttons = data.btns;
	}
	if (data.table_name) {
		data.table = data.table_name;
	}
// End: SNH Data Table enhancements

	if (!data.table) {
		data.invalid_table = true;
		data.table_label = "";
		return;
	}

// Start: SNH Data Table enhancements
	var instantiator = new Instantiator(this);
	var scriptMap = {};
	if (data.scripteds) {
		try {
			var scriptedinfo = JSON.parse(data.scripteds);
			if (Array.isArray(scriptedinfo)) {
				data.svcarray = scriptedinfo;
			} else if (typeof scriptedinfo == 'object') {
				data.svcarray = [];
				data.svcarray[0] = scriptedinfo;
			} else {
				gs.error('Invalid scripteds option in SNH Data Table widget: ' + data.scripteds);
				data.svcarray = [];
			}
		} catch (e) {
			gs.error('Unparsable scripteds option in SNH Data Table widget: ' + data.scripteds);
			data.svcarray = [];
		}
	} else {
		if (!data.svcarray) {
			data.svcarray = [];
		}
	}

	if (data.aggregates) {
		try {
			var aggregateinfo = JSON.parse(data.aggregates);
			if (Array.isArray(aggregateinfo)) {
				data.aggarray = aggregateinfo;
			} else if (typeof aggregateinfo == 'object') {
				data.aggarray = [];
				data.aggarray[0] = aggregateinfo;
			} else {
				gs.error('Invalid aggregates option in SNH Data Table widget: ' + data.aggregates);
				data.aggarray = [];
			}
		} catch (e) {
			gs.error('Unparsable aggregates option in SNH Data Table widget: ' + data.aggregates);
			data.aggarray = [];
		}
	} else {
		if (!data.aggarray) {
			data.aggarray = [];
		}
	}

	if (data.buttons) {
		try {
			var buttoninfo = JSON.parse(data.buttons);
			if (Array.isArray(buttoninfo)) {
				data.btnarray = buttoninfo;
			} else if (typeof buttoninfo == 'object') {
				data.btnarray = [];
				data.btnarray[0] = buttoninfo;
			} else {
				gs.error('Invalid buttons option in SNH Data Table widget: ' + data.buttons);
				data.btnarray = [];
			}
		} catch (e) {
			gs.error('Unparsable buttons option in SNH Data Table widget: ' + data.buttons);
			data.btnarray = [];
		}
	} else {
		if (!data.btnarray) {
			data.btnarray = [];
		}
	}

	if (data.refpage) {
		try {
			var refinfo = JSON.parse(data.refpage);
			if (typeof refinfo == 'object') {
				data.refmap = refinfo;
			} else {
				gs.error('Invalid reference page option in SNH Data Table widget: ' + data.refpage);
				data.refmap = {};
			}
		} catch (e) {
			gs.error('Unparsable reference page option in SNH Data Table widget: ' + data.refpage);
			data.refmap = {};
		}
	} else {
		if (!data.refmap) {
			data.refmap = {};
		}
	}

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

	if (!data.fields) {
		if (data.field_list) {
			data.fields = data.field_list;
		} else if (data.view) {
			data.fields = $sp.getListColumns(data.table, data.view);
		} else {
			data.fields = $sp.getListColumns(data.table);
		}
	}
// End: SNH Data Table enhancements

	data.view = data.view || 'mobile';
	data.table = data.table || $sp.getValue('table');
	data.filter = data.filter || $sp.getValue('filter');
	data.keywords = data.keywords || $sp.getValue('keywords');
	data.p = data.p || $sp.getValue('p') || 1;
	data.p = parseInt(data.p);
	data.o = data.o || $sp.getValue('o') || $sp.getValue('order_by');
	data.d = data.d || $sp.getValue('d') || $sp.getValue('order_direction') || 'asc';
	data.useTinyUrl = gs.getProperty('glide.use_tiny_urls') === 'true';
	data.tinyUrlMinLength = gs.getProperty('glide.tiny_url_min_length');

// Start: SNH Data Table enhancements
	if (data.filter && data.filter.indexOf('{{sys_id}}')) {
		data.filter = data.filter.replace('{{sys_id}}', $sp.getParameter('sys_id'));
	}
// End: SNH Data Table enhancements


	var grForMetaData = new GlideRecord(data.table);

	if (input.setOrderUserPreferences) {
		// update User Preferences on a manual sort for UI consistency
		gs.getUser().savePreference(data.table + ".db.order", data.o);
		gs.getUser().savePreference(data.table + ".db.order.direction", data.d == "asc" ? "" : "DESC");
		data.setOrderUserPreferences = false;
	}
	// if no sort specified, find a default column for UI consistency
	if (!data.o)
		getOrderColumn();

	data.page_index = data.p - 1;
	data.show_new = data.show_new || options.show_new;
	var windowSize = data.window_size || $sp.getValue('maximum_entries') || 20;
	windowSize = parseInt(windowSize);
	if (isNaN(windowSize) || windowSize < 1)
		windowSize = 20;
	data.window_size = windowSize;

	var gr;
	// FilteredGlideRecord is not supported in scoped apps, so GlideRecordSecure will always be used in an application scope
	if (typeof FilteredGlideRecord != "undefined" && (gs.getProperty("glide.security.ui.filter") == "true" || grForMetaData.getAttribute("glide.security.ui.filter") != null)) {
		gr = new FilteredGlideRecord(data.table);
		gr.applyRowSecurity();
	} else
		gr = new GlideRecordSecure(data.table);
	if (!gr.isValid()) {
		data.invalid_table = true;
		data.table_label = data.table;
		return;
	}

	data.canCreate = gr.canCreate();
	data.newButtonUnsupported = data.table == "sys_attachment";
	data.table_label = gr.getLabel();
	data.table_plural = gr.getPlural();
	data.title = input.useInstanceTitle && input.headerTitle ? gs.getMessage(input.headerTitle) : data.table_plural;
	data.hasTextIndex = $sp.hasTextIndex(data.table);
	if (data.filter) {
		if (data.filterACLs)
			gr = $sp.addQueryString(gr, data.filter);
		else
			gr.addEncodedQuery(data.filter);
	}
	if (data.keywords) {
		gr.addQuery('123TEXTQUERY321', data.keywords);
		data.keywords = null;
	}

	data.filter = gr.getEncodedQuery();

	if (data.relationship_id) {
		var rel = GlideRelationship.get(data.relationship_id);
		var target = new GlideRecord(data.table);
		var applyTo = new GlideRecord(data.apply_to);
		applyTo.get("sys_id", data.apply_to_sys_id);
		rel.queryWith(applyTo, target); // put the relationship query into target
		data.exportQuery = target.getEncodedQuery();
		gr.addEncodedQuery(data.exportQuery); // get the query the relationship made for us
	}
	if (data.exportQuery)
		data.exportQuery += '^' + data.filter;
	else
		data.exportQuery = data.filter;
	data.exportQueryEncoded = encodeURIComponent(data.exportQuery);
	if (data.o){
		if (data.d == "asc")
			gr.orderBy(data.o);
		else
			gr.orderByDesc(data.o);
		if (gs.getProperty("glide.secondary.query.sysid") == "true")
			gr.orderBy("sys_id");
	}

	data.window_start = data.page_index * data.window_size;
	data.window_end = (data.page_index + 1) * data.window_size;
	gr.chooseWindow(data.window_start, data.window_end);
	gr.setCategory("service_portal_list");
	gr._query();

	data.row_count = gr.getRowCount();
	data.num_pages = Math.ceil(data.row_count / data.window_size);
	data.column_labels = {};
	data.column_types = {};
	data.fields_array = data.fields.split(',');

	// use GlideRecord to get field labels vs. GlideRecordSecure
	for (var i in data.fields_array) {
		var field = data.fields_array[i];
		var ge = grForMetaData.getElement(field);
		if (ge == null)
			continue;

		data.column_labels[field] = ge.getLabel();
		data.column_types[field] = ge.getED().getInternalType();
	}

	data.list = [];
	while (gr._next()) {
		var record = {};
		$sp.getRecordElements(record, gr, data.fields);
		if (typeof FilteredGlideRecord != "undefined" && gr instanceof FilteredGlideRecord) {
			// FilteredGlideRecord doesn't do field-level
			// security, so take care of that here
			for (var f in data.fields_array) {
				var fld = data.fields_array[f];
				if (!gr.isValidField(fld))
					continue;

				if (!gr[fld].canRead()) {
					record[fld].value = null;
					record[fld].display_value = null;
				}
			}
		}
		record.sys_id = gr.getValue('sys_id');

// Start: SNH Data Table enhancements
		for (var f in data.fields_array) {
			var fld = data.fields_array[f];
			if (record[fld].type == 'reference') {
				var refGr = gr;
				var refFld = fld;
				if (fld.indexOf('.') != -1) {
					var parts = fld.split('.');
					for (var x=0;x<parts.length-1;x++) {
						refGr = refGr[parts[x]].getRefRecord();
					}
					refFld = parts[parts.length-1];
				}
				if (refGr.isValidField(refFld)) {
					record[fld].table = refGr.getElement(refFld).getED().getReference();
					record[fld].record = {type: 'reference', sys_id: {value: record[fld].value, display_value: record[fld].value}, name: {value: record[fld].display_value, display_value: record[fld].display_value}};
				}
			}
		}
		record.svcValue = [];
		if (data.svcarray.length > 0) {
			for (var j=0; j<data.svcarray.length; j++) {
				record.svcValue.push(getScriptedValue(record, data.svcarray[j]));
			}
		}
		record.aggValue = [];
		if (data.aggarray.length > 0) {
			for (var k=0; k<data.aggarray.length; k++) {
				var config = data.aggarray[k];
				var sysId = record.sys_id;
				if (config.source) {
					sysId = gr.getValue(config.source);
				}
				record.aggValue.push(getAggregateValue(sysId, config));
			}
		}
// End: SNH Data Table enhancements

		record.targetTable = gr.getRecordClassName();
		data.list.push(record);
	}

	data.enable_filter = (input.enable_filter == true || input.enable_filter == "true" ||
		options.enable_filter == true || options.enable_filter == "true");
	var breadcrumbWidgetParams = {
		table: data.table,
		query: data.filter,
		enable_filter: data.enable_filter
	};
	data.filterBreadcrumbs = $sp.getWidget('widget-filter-breadcrumbs', breadcrumbWidgetParams);

	// copy to data from input or options
	function optCopy(names) {
		names.forEach(function(name) {
			data[name] = input[name] || options[name];
		})
	}

	// getOrderColumn logic mirrors that of Desktop UI when no sort column is specified
	function getOrderColumn() {
		// First check for user preference
		var pref = gs.getUser().getPreference(data.table + ".db.order");
		if (!GlideStringUtil.nil(pref)) {
			data.o = pref;
			if (gs.getUser().getPreference(data.table + ".db.order.direction") == "DESC")
				data.d = 'desc';
			return;
		}

		// If no user pref, check for table default using same logic as Desktop UI:
		// 1) if task, use number
		// 2) if any field has isOrder attribute, use that
		// 3) use order, number, name column if exists (in that priority)
		if (grForMetaData.isValidField("sys_id") && grForMetaData.getElement("sys_id").getED().getFirstTableName() == "task") {
			data.o = "number";
			return;
		}

		// Next check for isOrder attribute on any column
		var elements = grForMetaData.getElements();
		// Global and scoped GlideRecord.getElements return two different things,
		// so convert to Array if needed before looping through
		if (typeof elements.size != "undefined") {
			var elementArr = [];
			for (var i = 0; i < elements.size(); i++)
				elementArr.push(elements.get(i));
			elements = elementArr;
		}
		// Now we can loop through
		for (var j = 0; elements.length > j; j++) {
			var element = elements[j];
			if (element.getAttribute("isOrder") == "true") {
				data.o = element.getName();
				return;
			}
		}
		// As last resort, sort on Order, Number, or Name column
		if (grForMetaData.isValidField("order"))
			data.o = "order";
		else if (grForMetaData.isValidField("number"))
			data.o = "number";
		else if (grForMetaData.isValidField("name"))
			data.o = "name";
	}

// Start: SNH Data Table enhancements
	function getScriptedValue(record, config) {
		var response = {value: ''};
		var scriptName = config.script;
		if (scriptName) {
			if (scriptName.startsWith('global.')) {
				scriptName = scriptName.split('.')[1];
			}
			if (!scriptMap[scriptName]) {
				scriptMap[scriptName] = instantiator.getInstance(scriptName);
			}
			if (scriptMap[scriptName]) {
				response.value = scriptMap[scriptName].getScriptedValue(record, config);
			}
		}
		return response;
	}

	function getAggregateValue(sys_id, config) {
		var value = 0;
		var ga = new GlideAggregate(config.table);
		ga.addAggregate('COUNT');
		var query = config.field + '=' + sys_id;
		if (config.filter) {
			query += '^' + config.filter;
		}
		ga.addEncodedQuery(query);
		ga.query();
		if (ga.next()) {
			value = parseInt(ga.getAggregate('COUNT'));
		}
		var response = {value: value};
		if (config.hint || config.page_id) {
			response.name = config.name;
		}
		return response;
	}
// End: SNH Data Table enhancements

})();

There are no changes needed to the Client script, or any other area, so we are done with the modifications to this widget. Now would be a good time to try it out, but we will need at least one of the three wrapper widgets to be updated before we can give things a try. That sounds like a good project for our next installment.

Scripted Value Columns, Part II

“Progress always involves risks. You can’t steal second base and keep your foot on first.”
Frederick B. Wilcox

Last time, we introduced the concept of Scripted Value Columns, created a sample script for testing, and built the pop-up editor widget. Now we need to modify the Content Selector Configurator widget to accommodate the new configuration option. As usual, we can start with the HTML, and as we have done before, we can copy one of the existing sections and then alter it to meet our new list of properties for this type of column.

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

Basically, this is just another table of configuration properties with labels and action buttons. To support the buttons we can copy similar functions for the other types of configurable columns, but before we do that, let’s jump into the Server script and see what we need to do there. We have called our new array of column configurations svcarray, and we can search the scripts for one of the existing arrays such as aggarray and basically cut and paste the existing code to support the new addition.

The only mention of aggarray on the server side is in the code that rebuilds the script being edited from data collected by the editor. We can make a quick copy of that section and then modify it for our purpose.

script += "',\n				svcarray: [";
var lastSeparator = '';
for (var v=0; v<tableTable[tableState.name].svcarray.length; v++) {
	var thisScriptedValue = tableTable[tableState.name].svcarray[v];
	script += lastSeparator;
	script += "{\nname: '";
	script += thisScriptedValue.name;
	script += "',\nlabel: '";
	script += thisScriptedValue.label;
	script += "',\nheading: '";
	script += thisScriptedValue.heading;
	script += "',\nscript: '";
	script += thisScriptedValue.script;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

While we are in the Server script, there is one more thing that we should do. Since this is a new configuration item, if you attempt to edit an existing item that was built using an earlier version, this array will not be present in the existing script that you will be editing. To make sure that we do not run into any null pointer issues with code expecting this array to exist, we should do a quick check when we load the script for editing and make sure that all of the configuration objects are present. There is already code in there to help initialize the data once the script has been loaded. Right now it looks like this:

var instantiator = new Instantiator(this);
var configScript = instantiator.getInstance(data.scriptInclude);
if (configScript != null) {
	data.config = configScript.getConfig($sp);
	for (var persp in data.config.table) {
		for (var tbl in data.config.table[persp]) {
			if (!data.config.table[persp][tbl].displayName) {
				data.config.table[persp][tbl].displayName = getItemName(data.config.table[persp][tbl].name);
			}
		}
	}
}

We are looping through every table in every perspective already, so all that we need to do is loop through every state in every table and check all of the objects that will need to be there for things to work. That will make the above section of code now look like this:

var instantiator = new Instantiator(this);
var configScript = instantiator.getInstance(data.scriptInclude);
if (configScript != null) {
	data.config = configScript.getConfig($sp);
	for (var persp in data.config.table) {
		for (var tbl in data.config.table[persp]) {
			if (!data.config.table[persp][tbl].displayName) {
				data.config.table[persp][tbl].displayName = getItemName(data.config.table[persp][tbl].name);
			}
			for (var st in data.config.state) {
				var thisState = data.config.table[persp][tbl][data.config.state[st].name];
				if (!thisState) {
					thisState = {};
					data.config.table[persp][tbl][data.config.state[st].name] = thisState;
				}
				thisState.svcarray = thisState.svcarray || [];
				thisState.aggarray = thisState.aggarray || [];
				thisState.btnarray = thisState.btnarray || [];
				thisState.refmap = thisState.refmap || {};
				thisState.actarray = thisState.actarray || [];
			}
		}
	}
}

That takes care of the Server script. Now let’s do the same sort of searching on the Client script. The first thing that we find is actually a problem that was created when we added the aggregate columns. Whenever the user selects the Add a New Table button, we create an object to store the configuration for the new table. That code currently looks like this:

$scope.newTable = function(perspective) {
	var shared = {};
	spModal.open({
		title: 'Table Selector',
		widget: 'table-selector',
		shared: shared
	}).then(function() {
		for (var st=0; st<c.data.config.state.length; st++) {
			shared[c.data.config.state[st].name] = {btnarray: [], refmap: {}, actarray: []};
		}
		c.data.config.table[perspective].push(shared);
	});
};

For each state of the new table, we are creating empty objects for the buttons, the reference map, and the bulk actions. When we added the aggregate columns, we neglected to add an empty object for that new array, which may actually cause a problem with certain actions. So we need to add the new svcarray as well as the aggarray to make things right. That will make this code now look like this:

$scope.newTable = function(perspective) {
	var shared = {};
	spModal.open({
		title: 'Table Selector',
		widget: 'table-selector',
		shared: shared
	}).then(function() {
		for (var st=0; st<c.data.config.state.length; st++) {
			shared[c.data.config.state[st].name] = {svcarray: [], aggarray: [], btnarray: [], refmap: {}, actarray: []};
		}
		c.data.config.table[perspective].push(shared);
	});
};

The next thing that you fill find on the client side are the functions that handle the clicks for the Edit and Delete buttons. Copying those and modifying them for Scripted Value Columns gives us this new block of code:

$scope.editScriptedValueColumn = function(scripted_value, svcArray) {
	var shared = {script: {value: '', displayValue: ''}};
	if (scripted_value != 'new') {
		shared.label = scripted_value.label;
		shared.name = scripted_value.name;
		shared.heading = scripted_value.heading;
		shared.script = {value: scripted_value.script, displayValue: scripted_value.script};
	}
	spModal.open({
		title: 'Scripted Value Column Editor',
		widget: 'scripted-value-column-editor',
		shared: shared
	}).then(function() {
		if (scripted_value == 'new') {
			scripted_value = {};
			svcArray.push(scripted_value);
		}
		scripted_value.label = shared.label || '';
		scripted_value.name = shared.name || '';
		scripted_value.heading = shared.heading || '';
		scripted_value.script = shared.script.value || '';
	});
};

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

It looks like there is nothing else after that, so all we need to do at this point is to save the changes and give it a try. Let’s pull up one of our test configuration scripts in the editor and see what the new Scripted Value Columns section looks like.

New Scripted Value Columns section of the editor

Well, it appears on the screen, so that’s a good start. Now let’s hit that Add button and see if our pop-up editor works.

Pop-up Scripted Value Column editor

That looks good as well. Now let’s hit that Save button, which should return us to the main editor and our new scripted value column should show up on the list.

Scripted Value Columns section of the editor after adding a new entry

And there it is. Good deal. After saving the script and examining the resulting Script Include, everything looks good. So now we can create configurations for our new scripted value columns. Of course, the actual data table widgets have no idea what to do with this information, but we can start working on that in our next installment.