Generic Feedback Widget, Part IX

“There are three kinds of men. The one that learns by reading. The few who learn by observation. The rest of them have to pee on the electric fence for themselves.”
Will Rogers

Now that we have the Script Include put to bed, the next thing that I need to do to wrap up this little project is to update the widget. I need to add the code that displays a commenter’s rating, and I have also decided to replace the little comment icon next to each comment with the commenter’s avatar. Since ServiceNow already has a nice HTML tag set up for that purpose, all of that turned out to be a fairly simple reorganization of the existing HTML:

<div ng-repeat="item in data.feedback">
  <div class="snc-kb-comment-by" style="margin-top: 10px;">
    <sn-avatar primary="item.userSysId" class="avatar" show-presence="true" enable-context-menu="false" style="margin: 5px; float: left;"></sn-avatar>
    <span class="snc-kb-comment-by-title">
      Posted by <a class="snc-kb-comment-by-user" href="?id=user_profile&table=sys_user&sys_id={{item.userSysId}}">{{item.userName}}</a>
      <span class="snc-kb-comment-by-title">{{item.dateTime}}</span>
      <br/>{{item.rating}}
    </span>
  </div>
  <div style="clear: both;"></div>
  <div>
    <span class="snc-kb-comment-by-text" ng-bind-html="item.comment"></span>
  </div>
</div>

To fetch and format the rating, I added a new function to the server side Javascript:

function formatRating(profile) {
	var rating = '';
	var score = feedbackUtils.currentUserRating(data.table, data.sys_id, profile);
	for (var i=0; i<score; i++) {
		rating += '★';
	}
	return rating;
}

Putting it all together, the final result turns out a page that looks like this:

Formatted feedback with individual ratings and user avatars

… and if you want to see the breakdown of individual vote tallies, you can click on the Show Breakdown option and see this:

User feedback with rating breakdown displayed

I’m still not sure if it would be better to put the new comment input box ahead of the comment history rather than at the end, but I think I will leave things as they are for now. I’m sure that I will eventually come up with some other additions or corrections at some point, so I will put off any further thoughts of making any additional changes until that time. I can’t say that I have thoroughly tested every possible aspect of this process, but what I have spent a little time with all seems to working as it should. I think I will call this one done for today, and go ahead and publish a final(?) Update Set.

Generic Feedback Widget, Part VIII

“People rarely succeed unless they have fun in what they are doing.”
Dale Carnegie

Now that I have a way to display the cumulative rating to date, I need to work that into the widget and also provide a way to display any rating that accompanies a comment. The cumulative rating is pretty simple now that we have our snh-rating tag:

<snh-rating ng-show="c.data.includeRating" snh-values="c.data.ratingValues"></snh-rating>

Of course, we have to gather up the rating values to pass to the rating tag, but we already worked that out last time, so that’s pretty simple as well:

if (data.includeRating) {
	data.ratingInfo = feedbackUtils.currentRating(data.table, data.sys_id);
	data.ratingValues = data.ratingInfo.join(',');
}

That should take care of the overall rating. Now we just have to add the individual ratings to each comment. One thing that my earlier version did not support was the one person/one vote rule, which prevents a single individual from stuffing the ballot box and skewing the results. To enforce that approach, and also to support fetching a user’s existing vote, I added a function to my SnhFeedbackUtils Script Include to go out and get any existing vote for a table, sys_id, and profile combination:

fetchUserRating: function(table, sys_id, profile) {
	var response = null;
	var pollGR = new GlideRecord('live_poll');
	var castGR = new GlideRecord('live_poll_cast');
	if (pollGR.get('question', table + ':' + sys_id + ' Rating')) {
		castGR.addQuery('poll', pollGR.getUniqueValue());
		castGR.addQuery('profile', profile);
		castGR.query();
		if (castGR.next()) {
			response = castGR;
		}
	}
	return response;
},

The first place that I used this new function was in another new function that I created to fetch the current user’s existing rating:

currentUserRating: function(table, sys_id, profile) {
	var rating = 0;
	var castGR = this.fetchUserRating(table, sys_id, profile);
	if (castGR) {
		rating = parseInt(castGR.getValue('option.order'));
	}
	return rating;
},

The other place that I used it was when I modified the postRating function to update any existing vote rather than adding a second vote on the same item for the same person:

postRating: function(table, sys_id, rating) {
	if (rating > 0 && rating < 6) {
		var pollGR = new GlideRecord('live_poll');
		var optGR = new GlideRecord('live_poll_option');
		var castGR = new GlideRecord('live_poll_cast');
		if (!pollGR.get('question', table + ':' + sys_id + ' Rating')) {
			pollGR.initialize();
			pollGR.question = table + ':' + sys_id + ' Rating';
			pollGR.insert();
			for (var opt=1; opt<6; opt++) {
				optGR.initialize();
				optGR.poll = pollGR.getUniqueValue();
				optGR.order = opt;
				optGR.name = opt + '';
				optGR.insert();
			}
		}
		optGR.initialize();
		optGR.addQuery('poll', pollGR.getUniqueValue());
		optGR.addQuery('order', rating);
		optGR.query();
		if (optGR.next()) {
			var profile = new GlideappLiveProfile().getID();
			var existing = this.fetchUserRating(table, sys_id, profile);
			if (existing) {
				castGR = existing;
				if (castGR.getValue('option') != optGR.getUniqueValue()) {
					castGR.option = optGR.getUniqueValue();
					castGR.update();
				}
			} else {
				castGR.initialize();
				castGR.poll = pollGR.getUniqueValue();
				castGR.profile = profile;
				castGR.option = optGR.getUniqueValue();
				castGR.insert();
			}
		}
	}
},

That takes care of the modifications for the SnhFeedbackUtils Script Include. Now I just need to modify the server side code on the widget to invoke the function on the Script Include to get the score, format the score into some kind of graphic display, and then finally, modify the HTML to include the rating graphic. That sounds like quite a bit of work, so I think we will leave all of that for next time!

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.

User Rating Scorecard

“We keep moving forward, opening new doors, and doing new things, because we’re curious and curiosity keeps leading us down new paths.”
Walt Disney

So, I have been scouring the Interwebs for the different ways people have built and displayed the results of various user rating systems, looking for the best and easiest way to display the average rating based on all of the feedback to date. I wanted it to be graphic and intuitive, but also support fractional values. Even though the choices are whole numbers, once you start aggregating the responses from multiple individuals, the end result is going to end up falling somewhere in between, and on my Generic Feedback Widget, I wanted to be able to show that level of accuracy in the associated graphic.

The out-of-the-box display of a Live Feed Poll show how many votes were received from each of the possible options. That didn’t seem all that interesting to me, as I was looking for a single number that represented the average score, not how many votes each possibility received. Then I came across this.

User Rating Scorecard from W3C

That solution did not support the fractional graphic, but it did include the breakdown similar to the stock Live Feed Poll results, which got me rethinking my earlier perspective on not having any use for that information. After seeing this approach, I decided that it actually would be beneficial to make this available, but only on request. My thought was to display the graphic, the average rating, and the number of votes, and then have a clickable link for the breakdown that you could use if you were interested in more details.

All of that seemed rather complex, so I decided that I would not try to wedge all of that functionality into the Generic Feedback Widget, but would instead build a separate component to display the rating and then just use that component in the widget. This looked like a job for another Angular Provider like the ones that I used to create my Service Portal Form Fields and Service Portal Widget Help. I was thinking that it could something relatively simple to use, with typical usage looking something like this:

<snh-rating values="20,6,15,63,150"></snh-rating>

My theory on the values attribute was that as long as I had the counts of votes for each of the options, I could generate the rest of the data needed such as the total number of votes and the total score and the average rating. The trouble, of course, was that the function that I built to get the info on the votes cast so far did not provide this information. So, back the drawing board on that little doo-dad …

I actually wanted to use a GlideAggregate for this originally, but couldn’t do it when dot-walking the option.order wasn’t supported. But now that I have to group by option to get a count for each, that is no longer an issue and I can actually rearrange things a bit and not have to loop through every record to sum up the totals. Here’s the updated version that returns an array of votes for each possible rating:

currentRating: function(table, sys_id) {
	var rating = [0, 0, 0, 0, 0];
	var pollGR = new GlideRecord('live_poll');
	if (pollGR.get('question', table + ':' + sys_id + ' Rating')) {
		var castGA = new GlideAggregate('live_poll_cast');
		castGA.addAggregate('COUNT');
		castGA.addQuery('poll', pollGR.getUniqueValue());
		castGA.groupBy('option');
		castGA.orderBy('option.order');
		castGA.query();
		while (castGA.next()) {
			var i = castGA.getValue('option.order') - 1;
			rating[i] = parseInt(castGA.getAggregate('COUNT'));
		}
	}
	return rating;
},

That should get me the data that I will need to pass to the new snh-rating tag. Now all I have to do is build the Angular Provider behind that tag to turn those values into a nice looking presentation. That sounds like a good topic for a next installment!

Generic Feedback Widget, Part VII

“The secret of getting ahead is getting started. The secret of getting started is breaking your complex overwhelming tasks into small manageable tasks, and starting on the first one.”
Mark Twain

One of the things that you often see in a feedback block is some kind of numerical or star rating in addition to the textual comments. I actually added that feature in the snh-form-field feedback type, but left it out when I set up the Generic Feedback Widget. The main reason that I left it out was to adhere to my general philosophy of keeping things simple in the beginning, but another driving factor was that the live_message table that I was using for the feedback did not have a column in which we could store the rating. Still, I always had in mind that I would circle back and address that later on at some point, and now, here we are at that very some point.

While nosing around for a place to put the rating without altering any of the existing tables, I came across the Live Poll feature. This feature utilizes three tables, one for the poll definition, one for the option definitions, and another for the actual votes cast. That was a little overkill for what I was looking for, but it would work. Live Polls are linked to a specific message in the Live Feed ecosystem, which is not quite what I needed, but it was close. In my case, I would need to link a “poll” to a conversation, which I have already linked to a specific sys_id on a specific table. The poll would then serve as the rating definition, the options would then be the rating choices, and the votes cast would be the actual ratings posted with the feedback.

My plan was to alter my existing SnhFeedbackUtils Script Include to add a couple more functions, one to get the current rating values and another to post a new rating. Each would take the table name and sys_id as arguments, and the current rating functions would return the average rating and the number of votes cast in an object. There was no reference field that would link the “poll” to a conversation, so I decided to use the question column to store the table name and sys_id, since that would never actually be seen in my particular use case. The function to fetch the current values turned out like this:

currentRating: function(table, sys_id) {
	var rating = {users: 0, total: 0, average: 0};
	var pollGR = new GlideRecord('live_poll');
	if (pollGR.get('question', table + ':' + sys_id + ' Rating')) {
		var castGR = new GlideRecord('live_poll_cast');
		castGR.addQuery('poll', pollGR.getUniqueValue());
		castGR.query();
		while (castGR.next()) {
			rating.users += 1;
			rating.total += castGR.option.order;
		}
		rating.average = rating.total / rating.users;
	}
	return rating;
},

Basically, it uses the table and sys_id to find the live_poll record, and if it finds one, it uses the sys_id of that record to find all of the live_poll_cast records linked to that live_poll. I tried to do that with a GlideAggregate, but apparently you can’t do a SUM on a dot-walked property and I needed to sum up the values in the order column from the referenced live_poll_option record. So, I ended up looping through all of the records and adding them up the hard way.

Getting the current rating info was the easy part (the part that I always like tackle first!). Posting the rating was a little more involved, mainly because the first poster for any give table and sys_id has to create both the poll record and all of the option records. To keep things simple for this current iteration, I decided that all ratings would be on a 1 to 5 scale, and built everything accordingly. Eventually, I may want to make that a configurable parameter, but that’s something worthy of future version — right now, I just wanted to get to the point where I could see it all work. Here’s the current version of this function:

postRating: function(table, sys_id, rating) {
	if (rating > 0 && rating < 6) {
		var pollGR = new GlideRecord('live_poll');
		var optGR = new GlideRecord('live_poll_option');
		var castGR = new GlideRecord('live_poll_cast');
		if (!pollGR.get('question', table + ':' + sys_id + ' Rating')) {
			pollGR.initialize();
			pollGR.question = table + ':' + sys_id + ' Rating';
			pollGR.insert();
			for (var opt=1; opt<6; opt++) {
				optGR.initialize();
				optGR.poll = pollGR.getUniqueValue();
				optGR.order = opt;
				optGR.name = opt + '';
				optGR.insert();
			}
		}
		optGR.initialize();
		optGR.addQuery('poll', pollGR.getUniqueValue());
		optGR.addQuery('order', rating);
		optGR.query();
		if (optGR.next()) {
			castGR.initialize();
			castGR.poll = pollGR.getUniqueValue();
			castGR.profile = new GlideappLiveProfile().getID();
			castGR.option = optGR.getUniqueValue();
			castGR.insert();
		}
	}
},

If you don’t pass it a rating value from 1 to 5, it doesn’t do anything at all, but if you do, then it first checks to see if the poll for this table and sys_id exists, and if not, it creates it, along with the 5 option records representing the 5 possible ratings. At that point, it looks for the one option record that matches the rating passed, and then finally, it builds a live_poll_cast record to post the rating.

That pretty much takes care of all of the background work. Now I just need to modify my widget to include a rating option with the feedback and configure some kind of display at the top that shows the average rating and the number of users who have participated in the process. Looks like I will be tackling all of that next time out.