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 III

“All things are created twice. There’s a mental or first creation, and a physical or second creation of all things. You have to make sure that the blueprint, the first creation, is really what you want, that you’ve thought everything through. Then you put it into bricks and mortar. Each day you go to the construction shed and pull out the blueprint to get marching orders for the day. You begin with the end in mind.”
Stephen Covey

I like the way that my 5 star rating scorecard widget came out, but I still want to be able to do more than just stars, and be more flexible than having just a 1 to 5 rating system. Ultimately, it would be nice to be able to customize things a bit more with different icons or images, be able to pick your own colors, and even have a different image for each rating value. As always, I would want to have a default value for just about everything, so your wouldn’t have to specify most of that if you didn’t need or want to, but it would be nice to have the option. I’d like to be able to support the following customization attributes:

  • snh-max – this would be the upper limit on rating value, and I would make it totally optional, with a default value of 5, which seems to be pretty universal for most use cases.
  • snh-icon – this would be the name of one of the stock Retina Icons, which again, would be optional and default to star if you didn’t override it.
  • snh-image – this would be a link to an uploaded image file, which would serve the same purpose as the snh-icon, but provide the ability have a custom image not found in the stock icon set. Obviously, you could only have one or the other, so there would have to be some hierarchy established in the event that someone specified both an icon and an image.
  • snh-image-array – this would be an array of links much like the snh-image attribute, but instead of a single image, it would provide the capability to have a different image for each rating value. Again, there would have to be some kind of hierarchy established to avoid a conflict between an array of images, a single image, or a single icon.
  • snh-name – this would be the text equivalent of the icon or image, and would default to Star, to match the default icon. This name would be used in some of the labels, and would allow you to replace labels such as 1 Star with 1 Bell or 1 Thumbs Up.
  • snh-plural – this would just be the plural version of snh-name, and really only necessary if adding an “s” to the end of the word didn’t come out right. In the examples above, 2 Stars or 2 Bells would be OK, but 2 Thumbs Ups wouldn’t be right, so you would want to add snh-plural=”Thumbs Up” to fix that.
  • snh-icon-color – this would give you the ability to choose the color of the selected icon, and again, I would make this optional and have a default color selected so you wouldn’t have to specify this unless you wanted something different.
  • snh-bar-colors – this would be an array of HTML colors codes that would give you the ability to choose the colors in the vote breakdown bar chart. Like most other items on my wish list, this would be optional and there would be default values that would be used in the event that you elected not to customize this particular parameter.

These are all of the things that I can think of at the moment, but I am sure that others will come up over time. Of course it’s one thing to have all of these ideas in your head and quite another to actually write the code to make it happen. At this point, these are just ideas. If I want to actually see it in action, I will have to actually do the work!

Generic Feedback Widget, Part X

When I added the User Rating Scorecard to my Generic Feedback Widget, I only wanted to show it when appropriate, so I added an ng-show attribute to the snh-rating element:

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

I did the same thing to the snh-form-field element for the rating entry for the very same reason:

<snh-form-field
       ng-show="data.includeRating"
       snh-model="data.rating"
       snh-name="rating"
       snh-type="rating"
       snh-label="Rate this {{data.tableLabel}}"/>

Providing a rating and viewing the cumulative results of ratings was something that I wanted to be optional, as including a rating in feedback is not always appropriate depending on the use case. To populate the data.includeRating variable with the desired alternative, I created a widget option, which I defined with the following JSON option schema:

[{
    "hint": "Set to TRUE if you want to include a rating with the text feedback",
    "name": "include_rating",
    "default_value": "false",
    "label": "Include Rating?",
    "type": "boolean"
}]

Once the option schema has been established, editing the widget instance in the Service Portal Page Designer will pop open a modal dialog where you can check a box to indicate that you would like ratings to be included for this instance of the widget.

Option dialog in Service Portal Page Designer

In the server side script, I look for this option and use it to determine the value of the data.includeRating variable.

data.includeRating = false;
if (options && options.include_rating) {
	data.includeRating = true;
}

This makes the widget a little more flexible, as you can check the box for one circumstance and leave it unchecked (the default) for others. Checking the box will get you the rating score card, user ratings under each user comment heading, and the ability to rate the item when you provide feedback. Without the box checked, all of those items are removed from view.

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

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

Generic Feedback Widget, Part VI

“Failure after long perseverance is much grander than never to have a striving good enough to be called a failure.”
George Eliot

Well, it turns out that creating a group was not nearly as challenging as reading a group that already exists. I had already started pulling the code out of the widget proper and stuffing it into my accompanying Script Include, so I went ahead and kept that in there, but it’s pretty vanilla stuff. I also tossed in the code to verify group membership, which is also pretty vanilla stuff, so the resulting collection now includes functions to read the group profile, to create a new group, and to join the group if needed.

var SnhFeedbackUtils = Class.create();
SnhFeedbackUtils.prototype = {
	initialize: function() {
	},

	getGroupID: function(table, sys_id) {
		var groupId = null;

		var grp = new GlideRecord('live_group_profile');
		grp.addQuery('table', table);
		grp.addQuery('document', sys_id);
		grp.query();
		if (grp.next()) {
			groupId = grp.getValue('sys_id');
		}
		if (!groupId) {
			gs.getUser().setPreference('snh.live.group.read.authorization', 'true');
			grp = new GlideRecord('live_group_profile');
			grp.setWorkflow(false);
			grp.addQuery('table', table);
			grp.addQuery('document', sys_id);
			grp.query();
			if (grp.next()) {
				groupId = grp.getValue('sys_id');
			}
			gs.getUser().setPreference('snh.live.group.read.authorization', null);
		}

		return groupId;
	},

	createGroup: function(table, sys_id, name, description) {
		var conv = new GlideRecord('live_group_profile');
		conv.initialize();
		conv.setWorkflow(false);
		conv.document_group = true;
		conv.table = table;
		conv.document = sys_id;
		conv.name = name;
		conv.short_description = description;
		conv.insert();
		return conv.getValue('sys_id');
	},

	ensureGroupMembership: function(groupId, liveProfileId) {
		var mbr = new GlideRecord('live_group_member');
		mbr.addQuery('group', groupId);
		mbr.addQuery('member', liveProfileId);
		mbr.query();
		if (!mbr.next()) {
			mbr.initialize();
			mbr.group = groupId;
			mbr.member = liveProfileId;
			mbr.insert();
		}
	},

	type: 'SnhFeedbackUtils'
};

Originally, I had the code to add the person to the group upon reading the feedback, but it turns out that you don’t really have to be a member of the group to read the feedback, so I decided to pull that out and only add the person to the group if they left feedback of their own. This keeps people out of the group who were just looking, and limits the membership of the group to just those folks who have participated in the discussion. The final version of the server side script now looks like this:

(function() {
	var feedbackUtils = new SnhFeedbackUtils();
	data.feedback = [];
	data.mention = '';
	if (input && input.comment) {
		data.table = input.table;
		data.sys_id = input.sys_id;
		data.convId  = input.convId;
		data.tableLabel = input.tableLabel;
		data.recordLabel = input.recordLabel;
		data.recordDesc = input.recordDesc;
		data.mentionMap  = input.mentionMap;
		postComment(input.comment);
	} else {
		if (input) {
			data.table = input.table;
			data.sys_id = input.sys_id;
		} else {
			data.table = $sp.getParameter('table');
			data.sys_id = $sp.getParameter('sys_id');
		}
		if (data.table && data.sys_id) {
			var gr = new GlideRecord(data.table);
			if (gr.isValid()) {
				if (gr.get(data.sys_id)) {
					data.tableLabel = gr.getLabel();
					data.recordLabel = gr.getDisplayValue();
					data.recordDesc = gr.getDisplayValue('short_description');
					data.convId = feedbackUtils.getGroupID(data.table, data.sys_id);
					if (data.convId) {
						var fb = new GlideRecord('live_message');
						fb.addQuery('group', data.convId);
						fb.orderByDesc('sys_created_on');
						fb.query();
						while(fb.next()) {
							var feedback = {};
							feedback.userSysId = getUserSysId(fb.getValue('profile'));
							feedback.userName = fb.getDisplayValue('profile');
							feedback.dateTime = getTimeAgo(new GlideDateTime(fb.getValue('sys_created_on')));
							feedback.comment = formatMentions(fb.getDisplayValue('message'));
							data.feedback.push(feedback);
						}
					}
				} else {
					data.invalidRecord = true;
					data.tableLabel = gr.getLabel();
					data.recordLabel = '';
				}
			} else {
				data.invalidTable = true;
				data.tableLabel = data.table;
				data.recordLabel = '';
			}
		} else {
			data.invalidTable = true;
			data.tableLabel = '';
			data.recordLabel = '';
		}
	}

	function postComment(comment) {
		if (!data.convId) {
			data.convId = feedbackUtils.createGroup(data.table, data.sys_id, data.recordLabel. data.recordDesc);
		}
		comment = comment.trim();
		comment = expandMentions(comment, data.mentionMap['comment']);
		var liveProfileId = getProfileSysId(gs.getUserID());
		var fb = new GlideRecord('live_message');
		fb.initialize();
		fb.group = data.convId;
		fb.profile = liveProfileId;
		fb.message = comment;
		fb.insert();
		feedbackUtils.ensureGroupMembership(data.convId, liveProfileId);
	}

	function expandMentions(entryText, mentionIDMap) {
		return entryText.replace(/@\[(.+?)\]/gi, function (mention) {
			var response = mention;
			var mentionedName = mention.substring(2, mention.length - 1);
			if (mentionIDMap[mentionedName]) {
				var liveProfileId = getProfileSysId(mentionIDMap[mentionedName]);
				if (liveProfileId) {
					response = "@[" + liveProfileId + ":" + mentionedName + "]";
				}
			}
			return response;
		});
	}

	function formatMentions(text) {
		if (!text) {
			text = '';
		}
		var regexMentionParts = /[\w\d\s/']+/gi;
		text = text.replace(/@\[[\w\d\s]+:[\w\d\s/']+\]/gi, function (mention) {
			var response = mention;
			var mentionParts = mention.match(regexMentionParts);
			if (mentionParts.length === 2) {
				var liveProfileId = mentionParts[0];
				var name = mentionParts[1];
				response = '<a href="?id=user_profile&table=sys_user&sys_id=';
				response += getUserSysId(liveProfileId);
				response += '">@';
				response += name;
				response += '</a>';
			}
			return response;
		});
		return text.replace('\n', '<br/>');
	}

	function getUserSysId(liveProfileId) {
		if (!data.userSysIdMap) {
			data.userSysIdMap = {};
		}
		if (!data.userSysIdMap[liveProfileId]) {
			fetchUserSysId(liveProfileId);
		}
		return data.userSysIdMap[liveProfileId];
	}

	function fetchUserSysId(liveProfileId) {
		if (!data.profileSysIdMap) {
			data.profileSysIdMap = {};
		}
		var lp = new GlideRecord('live_profile');
		if (lp.get(liveProfileId)) {
			var userSysId = lp.getValue('document');
			data.userSysIdMap[liveProfileId] = userSysId;
			data.profileSysIdMap[userSysId] = liveProfileId;
		}
	}

	function getProfileSysId(userSysId) {
		if (!data.profileSysIdMap) {
			data.profileSysIdMap = {};
		}
		if (!data.profileSysIdMap[userSysId]) {
			fetchProfileSysId(userSysId);
		}
		return data.profileSysIdMap[userSysId];
	}

	function fetchProfileSysId(userSysId) {
		if (!data.userSysIdMap) {
			data.userSysIdMap = {};
		}
		var lp = new GlideRecord('live_profile');
		lp.addQuery('document', userSysId);
		lp.query();
		if (lp.next()) {
			var liveProfileId = lp.getValue('sys_id');
			data.userSysIdMap[liveProfileId] = userSysId;
			data.profileSysIdMap[userSysId] = liveProfileId;
		}
	}
	
	function getTimeAgo(glidedatetime) {
		var response = '';
		if (glidedatetime) {
			var timeago = new GlideTimeAgo();
			response = timeago.format(glidedatetime);
		}
		return response;
	}
})();

With this latest version, anyone can now view the feedback, and anyone can post feedback. If you are the first person to post feedback on a particular item, then a new group gets created, and anyone who posts gets added to the group. Using the Live Feed infrastructure rather than creating my own tables may end up having some unforeseen adverse consequences, but for now, everything seems to have worked out as I had intended, so I’m calling it good enough. If you want to check it out yourself, here is the latest Update Set.

Generic Feedback Widget, Part III

“If you think you can do a thing or think you can’t do a thing, you’re right.”
Henry Ford

Last time, I cleaned up all of the loose odds and ends of the display portion of the Generic Feedback Widget, which should have wrapped up that portion of the project. I say “should have” because now that I have it all working I see that there are a couple more things that I would like to do. For one, I think that there should be a limit on the number of entries initially displayed with an option to show them all. Once you accumulate a certain amount of feedback, no one is really going to want to go through all of that, so I think the initial limit should be something like 5 or 10 entries, with the rest only appearing if requested.

Another thing that I noticed in looking at some existing sample data is that the live_message text can contain hashtags, and there should be some code included that formats those hashtags. Of course, that would mean that there would have to be some place to go if you clicked on a hashtag, which sounds like an entirely different project. I’m not really up for that just yet, so I decided to push all of those thoughts aside and get back to the main purpose of this little widget, which is to collect new feedback.

Since the live_message table that I am using to store this feedback supports @mentions, I decided to use the snh-form-field tag that supports @mentions as the input element. This makes the HTML for the form field pretty simple.

<form ng-show="data.showComment" id="form1" name="form1" novalidate>
  <snh-form-field snh-model="c.data.comment" snh-name="comment" snh-type="mention" snh-label="Leave a comment:"/>
  <button class="btn btn-primary" ng-click="postComment();">Post Comment</button>
</form>

In order to use the snh-form-field tags, you also need to add the snhFormField Angular Provider down at the bottom of the widget, but that’s just a matter of selecting that tab and clicking on the Edit button.

One other thing that you need to do with the @mentions is to expand those to include the sys_id before writing them to the database. Here again we will have the sys_user sys_id in the mentionMap, but what we really need for the live_message text is the live_profile sys_id. For that, we will go back to our earlier functions to translate one to the other.

function expandMentions(entryText, mentionIDMap) {
	return entryText.replace(/@\[(.+?)\]/gi, function (mention) {
		var response = mention;
		var mentionedName = mention.substring(2, mention.length - 1);
		if (mentionIDMap[mentionedName]) {
			var liveProfileId = getProfileSysId(mentionIDMap[mentionedName]);
			if (liveProfileId) {
				response = "@[" + liveProfileId + ":" + mentionedName + "]";
			}
		}
		return response;
	});
}

To save the feedback, we need the conversation, the author, and the expanded message. The conversation is the live_group_profile record that points to the table and sys_id to which this feedback is attached. If there were any existing comments, then this record already exists. If not, then we will have to create it using the table and sys_id. The author is the currently authenticated user, but again, we have that person’s sys_user sys_id, when what we really need is the live_profile sys_id. For that we will have to go back to our sys_id translators once again. All together, the code ended up looking like this:

function postComment(comment) {
	if (!data.convId) {
		var conv = new GlideRecord('live_group_profile');
		conv.initialize();
		conv.table = data.table;
		conv.document = data.sys_id;
		conv.insert();
		data.convId = conv.getValue('sys_id');
	}
	comment = comment.trim();
	comment = expandMentions(comment, data.mentionMap['comment']);
	var liveProfileId = getProfileSysId(gs.getUserID());
	var fb = new GlideRecord('live_message');
	fb.initialize();
	fb.group = data.convId;
	fb.profile = liveProfileId;
	fb.message = comment;
	fb.insert();
}

You can also mark the message public or private, and I have thought about providing an option at the widget level to let you configure instances one way or another, but for this initial version, we will just take the default. At this point, all that is left is to take it out for a spin …

Posting a new feedback comment with an @mention

Clicking on the Post Comment button saves the feedback and then reloads the page.

New feedback comment posted.

I like it! Now, this little sample page only contains the Dynamic Breadcrumbs Widget and the new Generic Feedback Widget, but in the real world this new feedback widget would just be a secondary widget on a page with the main attraction displayed in a primary widget. This widget could be placed underneath or in a sidebar, but it really isn’t intended to be the main focus of a page. But then again, how you want to use it is clearly up to you. There are still a few things that I would like to do before I consider this one all finished up, but there is enough here now to release an Update Set for any of you who are interested in playing along at home. The next one will be better, but this version does work.

Generic Feedback Widget, Part II

“Slow and steady wins the race.”
Aesop

Yesterday, we made a bit of progress on my Generic Feedback Widget concept, but we left with quite a few loose ends that needed tidying up. I wasn’t too happy with the way that the date fields came out, so I went sniffing around for an out-of-the-box date formatter and came across the GlideTimeAgo object. This cool little doodad will not only format the date and time, but for relatively recent values, will turn it into a text describing just how long ago it was. To put it to use, I adapted a function that I discovered on another page and threw it down into the bottom of the widget’s server side code.

function getTimeAgo(glidedatetime) {
	var response = '';
	if (glidedatetime) {
		var timeago = new GlideTimeAgo();
		response = timeago.format(glidedatetime);
	}
	return response;
}

Then I updated the code that pulled the value out of the record.

feedback.dateTime = getTimeAgo(new GlideDateTime(fb.getValue('sys_created_on')));

Now recent comments will have timestamps such as 7 minutes ago or Just Now or 4 days ago. I like that much better.

Another issue was the need to translate live_profile sys_ids into sys_user sys_ids. I actually found that I needed to translate from one to the other in both directions, so I set up a couple of maps to retain anything that I found (so I wouldn’t have to look it up again if I needed to translate it again or go back the other direction). I ended up creating four different functions related to this process, one to pull from the map and another to populate the map for each of the two different ways this could be approached (live_profile to sys_user and sys_user to live_profile).

function getUserSysId(liveProfileId) {
	if (!data.userSysIdMap) {
		data.userSysIdMap = {};
	}
	if (!data.userSysIdMap[liveProfileId]) {
		fetchUserSysId(liveProfileId);
	}
	return data.userSysIdMap[liveProfileId];
}

function fetchUserSysId(liveProfileId) {
	if (!data.profileSysIdMap) {
		data.profileSysIdMap = {};
	}
	var lp = new GlideRecord('live_profile');
	if (lp.get(liveProfileId)) {
		var userSysId = lp.getValue('document');
		data.userSysIdMap[liveProfileId] = userSysId;
		data.profileSysIdMap[userSysId] = liveProfileId;
	}
}

function getProfileSysId(userSysId) {
	if (!data.profileSysIdMap) {
		data.profileSysIdMap = {};
	}
	if (!data.profileSysIdMap[userSysId]) {
		fetchProfileSysId(userSysId);
	}
	return data.profileSysIdMap[userSysId];
}

function fetchProfileSysId(userSysId) {
	if (!data.userSysIdMap) {
		data.userSysIdMap = {};
	}
	var lp = new GlideRecord('live_profile');
	lp.addQuery('document', userSysId);
	lp.query();
	if (lp.next()) {
		var liveProfileId = lp.getValue('sys_id');
		data.userSysIdMap[liveProfileId] = userSysId;
		data.profileSysIdMap[userSysId] = liveProfileId;
	}
}

To fix the sys_id references in the comment author link, I leveraged one of the above functions to swap out the live_profile id with the one that I needed from the sys_user table.

feedback.userSysId = getUserSysId(fb.getValue('profile'));

I also utilized those functions formatting the @mentions that can be present in live_message text. I wanted those to be links to the User Profile page as well, so I hunted down some existing code that formats @mentions on the Live Feed page and adapted it for my own purpose.

function formatMentions(text) {
	if (!text) {
		text = '';
	}
	var regexMentionParts = /[\w\d\s/\']+/gi;
	text = text.replace(/@\[[\w\d\s]+:[\w\d\s/\']+\]/gi, function (mention) {
		var response = mention;
		var mentionParts = mention.match(regexMentionParts);
		if (mentionParts.length === 2) {
			var liveProfileId = mentionParts[0];
			var name = mentionParts[1];
			response = '<a href="?id=user_profile&table=sys_user&sys_id=';
			response += getUserSysId(liveProfileId);
			response += '">@';
			response += name;
			response += '</a>';
		}
		return response;
	});
	return text.replace('\n', '<br/>');
}

Most of that is regex gobbledygook that I couldn’t possibly understand, but it does work, so now any @mentions in the text of message are converted into links to the User Profile page of the person mentioned. As with the other corrections, I just needed to modify the code that populates the feedback object to make use of this new function.

feedback.comment = formatMentions(fb.getDisplayValue('message'));

Well, we didn’t get to the part where we take in new feedback, so we will have to take that up next time out. Still, we did make quite a few improvements in the way that the existing feedback is formatted, so we are making progress.