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.

Scripted Value Columns, Enhanced, Part III

“It’s not about ideas. It’s about making ideas happen.”
Scott Belsky

Last time, we played around with the Customer column in our new example data table, and today we will jump back over to the Labor Hours column and see if we can create that mouse-over breakdown of who put in the hours. The first thing that we will need to do is to gather up the data, so will need to add a groupBy to our GlideAggregate so that we can obtain the hours per technician.

var timeGA = new GlideAggregate('time_card');
timeGA.addQuery('task', item.sys_id);
timeGA.addAggregate('SUM', 'total');
timeGA.groupBy('user');
timeGA.orderBy('user');
timeGA.query();

Once we have the data, we will need to format it for display. There are a number of different ways that we can approach this, including straight CSS and many creative variations, but the easiest thing to do is to just use the title attribute of a span, which seems to accomplish the same thing.

Mouseover breakdown of labor hours

To create the text, we can establish some variables before we go into the while loop, and then inside the loop we can build up the text, one person’s hours at a time.

var hours = 0;
var tooltip = '';
var separator = '';
var timeGA = new GlideAggregate('time_card');
timeGA.addQuery('task', item.sys_id);
timeGA.addAggregate('SUM', 'total');
timeGA.groupBy('user');
timeGA.orderBy('user');
timeGA.query();
while (timeGA.next()) {
	var total = timeGA.getAggregate('SUM', 'total') * 1;
	hours += total;
	tooltip += separator + timeGA.getDisplayValue('user') + ' - ' + total.toFixed(2);
	separator = '\n';
}

Once we accumulate the total hours and build up the tooltip text, we can then format the HTML we want, but only when there are hours charged to the task.

if (hours > 0) {
	response = '<span style="text-align: right; width: 100%;" title="' + tooltip + '">' + hours.toFixed(2) + '</span>';
}

Putting it all together, our new function to provide right-justified total hours with a mouseover breakdown looks like this:

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

	var hours = 0;
	var tooltip = '';
	var separator = '';
	var timeGA = new GlideAggregate('time_card');
	timeGA.addQuery('task', item.sys_id);
	timeGA.addAggregate('SUM', 'total');
	timeGA.groupBy('user');
	timeGA.orderBy('user');
	timeGA.query();
	while (timeGA.next()) {
		var total = timeGA.getAggregate('SUM', 'total') * 1;
		hours += total;
		tooltip += separator + timeGA.getDisplayValue('user') + ' - ' + total.toFixed(2);
		separator = '\n';
	}
	if (hours > 0) {
		response = '<span style="text-align: right; width: 100%;" title="' + tooltip + '">' + hours.toFixed(2) + '</span>';
	}

	return response;
}

I think that is enough examples for folks to get the basic idea. Adding the ability to include HTML with a scripted value opens up a number of possibilities. We have just explored a couple of them here, but I am sure that specific requirements will drive many other variations from those willing to give it a try and see what they can do with it.

Here is an Update Set with the modifications, including this new example page. Feedback can be left here in the comments, or in the discussion area where it has been posted out on Share. If you have been able to utilize this feature for anything interesting, a screenshot would definitely be something in which folks would have an interest, so please let us all in on what you were able to accomplish.

Scripted Value Columns, Enhanced, Part II

“Alone we can do so little; together we can do so much.”
Helen Keller

Last time, we tinkered with the Scripted Value Columns section of the SNH Data Table Widgets, and created an example Task list using an aggregation of labor hours as a demonstration of how you can include HTML with your returned value. Today we will have a look at another scripted column in that example, the Customer column.

There are quite a number of extensions to the core Task table in ServiceNow, all of which share the same core list of fields. One of those fields is called opened_by, and identifies the person who was signed on to the system when the task was initially opened. That person is not necessarily the customer, though. In the case of an Incident, for example, that is more likely to be a call center agent who took the call, not the person who is reporting the issue. There is no core table field to represent the customer. Each extension of the Task table has its own way of identifying the person awaiting the completion of the task. Using the scripted value column feature, though, you can create a “customer” column by looking at the type of task, and then grabbing the value of the field that is appropriate for that particular type.

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

	var table = item.sys_class_name.value;
	var field = 'opened_by';
	if (table == 'incident') {
		field = 'caller_id';
	} else if (table == 'sc_request') {
		field = 'requested_for';
	} else if (table == 'sc_req_item') {
		field = 'request.requested_for';
	} else if (table == 'sc_task') {
		field = 'request_item.request.requested_for';
	} else if (table == 'change_request') {
		field = 'requested_by';
	}
	var taskGR = new GlideRecord(table);
	if (taskGR.get(item.sys_id)) {
		if (taskGR.getValue(field) > '') {
			response = taskGR.getDisplayValue(field);
		}
	}

	return response;
}

This particular example handles incidents, catalog activity, and change requests, but could easily be modified to handle any number of other types of extensions to the core task table. This just returns the value, though. If you wanted to make the field a link to the user profile page, for example, you could do something like this.

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

	var table = item.sys_class_name.value;
	var field = 'opened_by';
	if (table == 'incident') {
		field = 'caller_id';
	} else if (table == 'sc_request') {
		field = 'requested_for';
	} else if (table == 'sc_req_item') {
		field = 'request.requested_for';
	} else if (table == 'sc_task') {
		field = 'request_item.request.requested_for';
	} else if (table == 'change_request') {
		field = 'requested_by';
	}
	var taskGR = new GlideRecord(table);
	if (taskGR.get(item.sys_id)) {
		if (taskGR.getValue(field) > '') {
			var sysId = taskGR.getValue(field);
			var name = taskGR.getDisplayValue(field);
			response += '<a href="?id=user_profile&table=sys_user&sys_id=';
			response += sysId;
			response += '" aria-label="${Click for more on }';
			response += name;
			response += '">';
			response += name;
			response += '</a>\n';
		}
	}

	return response;
}

That would provide an output such as this.

Customer column as link to user profile page

The one thing that the Customer column does not have that the Assigned to column, which is a standard SNH Data Table user reference, does have is the user avatar image. That’s easily done with the sn-avatar tag, but that tag is not simple HTML, and not processed using the ng-bind-html attribute. Theoretically, it can be processed using the sc-bind-html-compile attribute, but I have never had any luck in utilizing this component. I tried it, and couldn’t get it to work, so I gave up and went back to using the ng-bind-html attribute. So, there is no user avatar in this version. Maybe one day I will figure that out, but that day is not today.

Speaking of figuring things out one day, I still have not been able to get rid of that extra line that appears when there is an image available for the avatar. If you look at the example above, the lines with an avatar image are wider than the lines where that field is blank, or the initials are used in place of an image. This because there is an extra carriage return in there that expands the height of the line. Only when there is an actual image does this occur, but it shouldn’t occur at all. If there is some grand master CSS wizard out there who understands why this is happening and can explain it to me, or better yet, tell me what I can do to prevent it, I would be forever in your debt. I have not been able to figure that one out.

One other thing that you may have noticed in that image is that I got rid of the 0.00 value for tasks that had no hours charged to them. I like that better, and that was a pretty simple thing to do. I still want to have some kind of mouse-over display of who logged the hours, so maybe we will take a look at that next time out.

Scripted Value Columns, Enhanced

“Never let an inventor run a company. You can never get him to stop tinkering and bring something to market.”
Ernst Friedrich Schumacher

Since I posted a new version of the SNH Data Table Widgets out on Share with the new Scripted Value Columns feature, I have been playing around with it for various purposes, and have decided to make a little tweak to the core Data Table widget to accommodate some additional capabilities. My interest was in having the value provider script have the ability to return HTML as part of the value, which I was able to do, but when I did that, the table would simply display the HTML as text rather than process it to format the value. So I dug into the HTML in the core SNH Data Table widget and changed this:

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

… to this:

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

That sort of worked, but it still stripped out some portions of the HTML for safety reasons. To get around that problem, I had to change things to this:

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

… and then add this new function to the Client script of the widget:

$scope.trustAsHtml = function(string) {
	return $sce.trustAsHtml(string);
};

That was better, but still not exactly what I wanted, because when I tried to right justify some numeric values, they still came out on the left. The data was on the right side of the span, but the span was only as wide as the data, so that really did not do what I wanted. I wanted the data to be on the right side of the table cell, not the span inside of the table cell. However, I was able to solve that problem with a little extra style magic, so now I had this.

<td ng-repeat="obj in item.svcValue" role="cell" class="sp-list-cell" ng-class="{selected: item.selected}" tabindex="0">
  <span ng-bind-html="trustAsHtml(obj.value)" style="display: flex;"></span>
</td>

Even better, but still not exactly what I wanted. With the display set to flex, the carriage returns above and below the span end up rendered in the cell along with the (finally!) right-justified value. To solve that problem, I ended up putting the td, the span, and the closing td tags all on one line with no white space of any kind in between. Now I finally had what I was after. I just needed to do a little trial and error to see what I could do with it.

I was working on a list of assigned Tasks for any given Assignment Group, and one of the columns that I wanted to include on the list was the number of hours spent on the tasks so far. To get the value, I used a GlideAggregate on the time_card table.

var hours = 0;
var timeGA = new GlideAggregate('time_card');
timeGA.addQuery('task', item.sys_id);
timeGA.addAggregate('SUM', 'total');
timeGA.query();
if (timeGA.next()) {
	hours = timeGA.getAggregate('SUM', 'total');
}

That gave me the number of hours charged to the task from anyone from any group, whether or not the Time Card had been approved. I could have run a different query for a different number, but this was the data that I wanted to display on the table. Returning the hours value alone aligned the column on the left, though, and I wanted the values to be right justified. With the modifications made to core data table widget above, I was able to wrap the value with a right justified span and obtain the result that I wanted to see.

return '<span style="text-align: right; width: 100%;">' + (hours * 1).toFixed(2) + '</span>';

Things just look better with numbers lined up on the right.

Hours column lined up on the right

I like it, but there are still some things that we could do to make it better. It would be nice to know who put in these hours. Maybe a tooltip with a breakdown or a modal pop-up with the details would be nice. Also, it might be nice to have nothing at all in the column if the value is zero. That would make the rows with values stand out a little more. So many possibilities …

Oh, and we haven’t even begun to take a look at that Customer column just yet. Let’s play around with that and more next time out.

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.

User Rating Scorecard, Part IV

“You can’t have everything. Where would you put it?”
Steven Wright

Well, I had a few grand ideas for my User Rating Scorecard, but not all ideas are created equal. Some are quite a bit easier to turn into reality than others. Not that any of them were bad ideas — some just turned out to be a little more trouble than they were worth when I sat down and tried to turn the idea into code. I had visions of using Retina Icons and custom images for the rating symbol, but the code that I stole for handling the fractional rating values relied on the content property of the ::before pseudo-element. The value of the content property can only be text; icons, images, or any other HTML is not valid in that context and won’t get resolved. Basically, what I had in mind just wasn’t going to work.

That left me two choices: 1) redesign the HTML and CSS for the graphic to use something other than the content property, or 2) live with the restrictions and try to do what I wanted using unicode characters. I played around with choice #1 for quite a while, but I could never really come up with anything that gave me all of the flexibility that I wanted and still functioned correctly. So, I finally decided to see what was behind door #2. There are a lot of unicode characters. In fact, there are considerably more of those than there are Retina Icons, but not being able to use an image of your own choosing was still quite a bit more limiting than what I was imagining. Sill, it was better than just hard-coded starts, so I started hacking up the code to see if I could make it all work.

In my original version, the 5 stars were just an integral part of the rating CSS file:

.snh-rating::before {
    content: '★★★★★';
    background: linear-gradient(90deg, var(--star-background) var(--percent), var(--star-color) var(--percent));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
}

To make that more flexible, I just need to replace the hard-coded stars with a CSS variable:

.snh-rating::before {
    content: var(--char-content);
    background: linear-gradient(90deg, var(--star-background) var(--percent), var(--star-color) var(--percent));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
}

This would allow me to accept a new attribute as the unicode character to use and then set the value of that CSS variable to a string of those characters. My original Angular Provider had the template defined as a string, but to accommodate all of my various options, I had to convert that to a function. The first order of business in the function was to initialize all of the default values.

var max = 5;
var symbol = 'star';
var plural = 'stars';
var charCode = '★';
var charColor = '#FFCC00';
var subject = 'item';
var color = ['#F44336','#FF9800','#00BCD4','#2196F3','#4CAF50'];

Without overriding any of these defaults, the new version would produce the same results as the old version. But the whole point of this version was to provide the capability to override these values, so that was the code that had to be added next:

if (attrs.snhMax && parseInt(attrs.snhMax) > 0) {
	max = parseInt(attrs.snhMax);
}
if (attrs.snhSymbol) {
	symbol = attrs.snhSymbol;
	if (attrs.snhPlural) {
		plural = attrs.snhPlural;
	} else {
		plural = symbol + 's';
	}
}
if (attrs.snhChar) {
	charCode = attrs.snhChar;
}
var content = '';
if (attrs.snhChars) {
	content = attrs.snhChars;
} else {
	for (var i=0; i<max; i++) {
		content += charCode;
	}
}
if (attrs.snhCharColor) {
	charColor = attrs.snhCharColor;
}
if (attrs.snhSubject) {
	subject = attrs.snhSubject;
}
if (attrs.snhBarColors) {
	color = JSON.parse(attrs.snhBarColors);
}

The above code is pretty straightforward; if there are attributes present that override the defaults, then the default value is overwritten by the value of the attribute. Once that work has been done, all that is left is to build the template based on the variable values.

var htmlText = '';
htmlText += '<div>\n';
htmlText += '  <div ng-hide="votes > 0">\n';
htmlText += '    This item has not yet been rated.\n';
htmlText += '  </div>\n';
htmlText += '  <div ng-show="votes > 0">\n';
htmlText += '    <div class="snh-rating" style="--rating: {{average}}; --char-content: \'' + content + '\'; --char-color: ' + charColor + ';">';
htmlText += '</div>\n';
htmlText += '    <div style="clear: both;"></div>\n';
htmlText += '    {{average}} average based on {{votes}} reviews.\n';
htmlText += '    <a href="javascript:void(0);" ng-click="c.data.show_breakdown = 1;" ng-hide="c.data.show_breakdown == 1">Show breakdown</a>\n';
htmlText += '    <div ng-show="c.data.show_breakdown == 1" style="background-color: #ffffff; max-width: 500px; padding: 15px;">\n';
for (var x=max; x>0; x--) {
	var i = x - 1;
	htmlText += '      <div class="snh-rating-row">\n';
	htmlText += '        <div class="snh-rating-side">\n';
	htmlText += '          <div>' + x + ' ' + (x>1?plural:symbol) + '</div>\n';
	htmlText += '        </div>\n';
	htmlText += '        <div class="snh-rating-middle">\n';
	htmlText += '          <div class="snh-rating-bar-container">\n';
	htmlText += '            <div style="--bar-length: {{bar[' + i + ']}};--bar-color: ' + color[i] + ';" class="snh-rating-bar"></div>\n';
	htmlText += '          </div>\n';
	htmlText += '        </div>\n';
	htmlText += '        <div class="snh-rating-side snh-rating-right">\n';
	htmlText += '          <div>{{values[' + i + ']}}</div>\n';
	htmlText += '        </div>\n';
	htmlText += '      </div>\n';
}
htmlText += '      <div style="text-align: center;">\n';
htmlText += '        <a href="javascript:void(0);" ng-click="c.data.show_breakdown = 0;">Hide breakdown</a>\n';
htmlText += '      </div>\n';
htmlText += '    </div>\n';
htmlText += '  </div>\n';
htmlText += '</div>\n';

Now, all we have to do is try it out. Let’s configure a few of thee options to override the defaults and then see how it all comes out. Here is one sample configuration that uses 7 hearts instead of the default 5 stars:

  <snh-rating
     snh-max="7"
     snh-char="♥"
     snh-symbol="heart"
     snh-char-color="#FF0000"
     snh-values="'2,6,11,13,4,77,36'"
     snh-bar-colors='["#FFD3D3","#F4C2C2","#FF6961","#FF5C5C","#FF1C00","#FF0800","#FF0000"]'>
  </snh-rating>

… and here’s how it looks once everything is rendered:

Rating widget output with default values overridden

So, it’s not every single thing that I had imagined, but it is much more flexible than the original. Like most things, it could still be even better, but for now, I’m ready to call it good enough. If you want to play around with it on your own, here is an Update Set.

User Rating Scorecard, Part II

“Critics are our friends, they show us our faults.”
Benjamin Franklin

Now that I had a concept for displaying the results of collecting feedback, I just needed to build the Angular Provider to produce the desired output. I had already built a couple of other Angular Providers for my Form Field and User Help efforts, so I was a little familiar with the concept. Still, I learned quite a lot during this particular adventure.

To start with, I had never used variables in CSS before. In fact, I never really knew that you could even do something like that. I stumbled across the concept looking for a way to display partial stars in the rating graphic, and ended up using it in displaying the colored bars in the rating breakdown chart as well. For the rating graphic, here is the final version of the CSS that I ended up with:

:root {
	--star-size: x-large;
	--star-color: #ccc;
	--star-background: #fc0;
}

.snh-rating {
	--percent: calc(var(--rating) / 5 * 100%);
	display: inline-block;
	font-size: var(--star-size);
	font-family: Times;
	line-height: 1;
}
  
.snh-rating::before {
	content: '★★★★★';
	background: linear-gradient(90deg, var(--star-background) var(--percent), var(--star-color) var(--percent));
	-webkit-background-clip: text;
	-webkit-text-fill-color: transparent;
}

The portion of the HTML that produces the star rating came out to be this:

<div class="snh-rating" style="--rating: {{average}};"></div>

… and the average value was calculated by adding up all of the values and dividing by the number of votes:

$scope.valueString = $scope.$eval($attributes.snhValues);
$scope.values = $scope.valueString.split(',');
$scope.votes = 0;
$scope.total = 0;
for (var i=0; i<$scope.values.length; i++) {
	var votes = parseInt($scope.values[i]);
	$scope.votes += votes;
	$scope.total += votes * (i + 1);
}
$scope.average = ($scope.total/$scope.votes).toFixed(2);

The content is simply 5 star characters and then the linear-gradient background controls how much of the five stars are highlighted. The computed average score passed as a variable allows the script to dictate to the stylesheet the desired position of the end of the highlighted area. Pretty slick stuff, and this part I actually understand!

Once I figured all of that out, I was able to adapt the same concept to the graph that illustrated the breakdown of votes cast. In the case of the graph, I needed to find the largest vote count to set the scale of the graph, to which I added 10% padding so that even the largest bar wouldn’t go all the way across. To figure all of that out, I just needed to expand a little bit on the code above:

link: function ($scope, $element, $attributes) {
	$scope.valueString = $scope.$eval($attributes.snhValues);
	$scope.values = $scope.valueString.split(',');
	$scope.votes = 0;
	$scope.total = 0;
	var max = 0;
	for (var i=0; i<$scope.values.length; i++) {
		var votes = parseInt($scope.values[i]);
		$scope.votes += votes;
		$scope.total += votes * (i + 1);
		if (votes > max) {
			max = votes;
		}
	}
	$scope.bar = [];
	for (var i=0; i<$scope.values.length; i++) {
		$scope.bar[i] = (($scope.values[i] * 100) / (max * 1.1)) + '%';
	}
	$scope.average = ($scope.total/$scope.votes).toFixed(2);
},

The CSS to set the bar length then just needed to reference a variable:

.snh-rating-bar {
	width: var(--bar-length);
	height: 18px;
}

… and then the HTML for the bar just needed to pass in relevant value:

<div style="--bar-length: {{bar[0]}};" class="snh-rating-bar snh-rating-bar-1"></div>

All together, the entire Angular Provider came out like this:

function() {
	return {
		restrict: 'E',
		replace: true,
		link: function ($scope, $element, $attributes) {
			$scope.valueString = $scope.$eval($attributes.snhValues);
			$scope.values = $scope.valueString.split(',');
			$scope.votes = 0;
			$scope.total = 0;
			var max = 0;
			for (var i=0; i<$scope.values.length; i++) {
				var votes = parseInt($scope.values[i]);
				$scope.votes += votes;
				$scope.total += votes * (i + 1);
				if (votes > max) {
					max = votes;
				}
			}
			$scope.bar = [];
			for (var i=0; i<$scope.values.length; i++) {
				$scope.bar[i] = (($scope.values[i] * 100) / (max * 1.1)) + '%';
			}
			$scope.average = ($scope.total/$scope.votes).toFixed(2);
		},
		template: '<div>\n' +
			'  <div ng-hide="votes > 0">\n' +
			'    This item has not yet been rated.\n' +
			'  </div>\n' +
			'  <div ng-show="votes > 0">\n' +
			'    <div class="snh-rating" style="--rating: {{average}};"></div>\n' +
			'    <div style="clear: both;"></div>\n' +
			'    {{average}} average based on {{votes}} reviews.\n' +
			'    <a href="javascript:void(0);" ng-click="c.data.show_breakdown = 1;" ng-hide="c.data.show_breakdown == 1">Show breakdown</a>\n' +
			'    <div ng-show="c.data.show_breakdown == 1" style="background-color: #ffffff; max-width: 500px; padding: 15px;">\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>5 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[4]}};" class="snh-rating-bar snh-rating-bar-5"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[4]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>4 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[3]}};" class="snh-rating-bar snh-rating-bar-4"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[3]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>3 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[2]}};" class="snh-rating-bar snh-rating-bar-3"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[2]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>2 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[1]}};" class="snh-rating-bar snh-rating-bar-2"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[1]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>1 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[0]}};" class="snh-rating-bar snh-rating-bar-1"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[0]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div style="text-align: center;">\n' +
			'        <a href="javascript:void(0);" ng-click="c.data.show_breakdown = 0;">Hide breakdown</a>\n' +
			'      </div>\n' +
			'    </div>\n' + 
			'  </div>\n' + 
			'</div>\n'
	};
}

… and here is the accompanying CSS style sheet:

:root {
	--star-size: x-large;
	--star-color: #ccc;
	--star-background: #fc0;
}

.snh-rating {
	--percent: calc(var(--rating) / 5 * 100%);
	display: inline-block;
	font-size: var(--star-size);
	font-family: Times;
	line-height: 1;
}
  
.snh-rating::before {
	content: '★★★★★';
	background: linear-gradient(90deg, var(--star-background) var(--percent), var(--star-color) var(--percent));
	-webkit-background-clip: text;
	-webkit-text-fill-color: transparent;
}

* {
	box-sizing: border-box;
}

.snh-rating-side {
	float: left;
	width: 15%;
	margin-top: 10px;
}

.snh-rating-middle {
	margin-top: 10px;
	float: left;
	width: 70%;
}

.snh-rating-right {
	text-align: right;
}


.snh-rating-row:after {
	content: "";
	display: table;
	clear: both;
}

.snh-rating-bar-container {
	width: 100%;
	background-color: #f1f1f1;
	text-align: center;
	color: white;
}

.snh-rating-bar {
	width: var(--bar-length);
	height: 18px;
}

.snh-rating-bar-5 {
	background-color: #4CAF50;
}

.snh-rating-bar-4 {
	background-color: #2196F3;
}

.snh-rating-bar-3 {
	background-color: #00bcd4;
}

.snh-rating-bar-2 {
	background-color: #ff9800;
}

.snh-rating-bar-1 {
	background-color: #f44336;
}

@media (max-width: 400px) {
	.snh-rating-side, .snh-rating-middle {
		width: 100%;
	}
	.snh-rating-right {
		display: none;
	}
}

Now, I ended up hard-coding the number of stars, or possible rating points, throughout this entire exercise, which I am not necessarily all that proud of, but I did get it all to work. In my defense, the “5 Star” rating system seems to be almost universal, even if you aren’t dealing with “Stars” and are counting pizzas or happy faces. Hardly anyone uses 4 or 6 or 10 Whatevers to rate anything these days. Still, I would much prefer to be able to set both the number of items and the image for the item, just have a more flexible component. But then, this is just Version 1.0 … maybe one day some future version will actually have that capability. In the meantime, here is an Update Set for those of you who would like to tinker on your own.