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 V

“If at first you don’t succeed, you are running about average.”
M.H. Alderson

I looked at several different ways to solve my problem with the Generic Feedback Widget, but I couldn’t come up with anything that didn’t involve inactivating or altering the ACL that was at the heart of the issue.Finally, I settled on a plan that would at least involve minimally invasive alterations to the ACL. The plan was pretty simple: create an obscure User Preference and set it to true just before accessing the live_group_profile record, and then delete the preference as soon as the record was obtained. The alteration to the ACL, then, would be to check for that preference before applying the ACL. The updated version of the ACL script now looked like this:

if (gs.getPreference('snh.live.group.read.authorization') == 'true') {
	answer = true;
} else {
	var gr = new GlideRecord('live_group_member');
	gr.addQuery('member', GlideappLiveProfile().getID());
	gr.addQuery('group', current.sys_id);
	gr.addQuery('state', 'admin').addOrCondition('state', 'active');
	gr.query();
	answer = gr.next();
}

The first thing that we do is check for the preference, and if it’s there, then we bypass the original code; otherwise, things proceed as they always have. I don’t really like tinkering with stock components if I can avoid it, mainly because of the subsequent issues with patches and upgrades potentially skipping an upgrade of anything that you have touched. Still, this one seemed to be unavoidable if I wanted to salvage the original intent and still do what I wanted to do.

The next thing that I needed to do was to set the preference just before attempting the read operation, and then removing it as soon as I was done. That code turned out to look like this:

gs.getUser().setPreference('snh.live.group.read.authorization', 'true');
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');
}
gs.getUser().setPreference('snh.live.group.read.authorization', null);

I ended up pulling that out of the widget and putting it into its own Script Include, mainly to tuck the specialized code away and out of sight. Anyway, it all sounded like a great plan and all I needed to do now was to test it out, so I did. And it failed. So much for my great plan.

It took a little digging, but I finally figured out that the ACL was not the only thing keeping people from outside the group from reading the group profile record. There are also a number of Business Rules that do pretty much the same thing. I spent a little time combing through all of those looking for ways to hack around them, and then finally decided that, for my purposes anyway, I really didn’t to be running any Business Rules at all. So I added one more line to my read script to turn off all of the Business Rules.

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);

That did it. Now, people who are not in the group can still read the group profile record, which is good, because you need the sys_id of that record to read all of the messages in the group, which is what we are using as feedback. The only thing that I have accommodated at this point is situations where a group profile record does not exist at all, and I have to create one.

But that’s an entirely different adventure

Generic Feedback Widget, Part IV

“For all sad words of tongue and pen, the saddest are these, ‘It might have been.'”
John Greenleaf Whittier

Well, it seemed like a good idea at the time. In fact, I was pretty proud of my Generic Feedback Widget once I had it pretty much all put together. I even felt so good about it that I went ahead and put out an Update Set. Then I started playing around with it while using other User accounts that did not have the admin role, and I realized that something was seriously wrong. In fact, nothing really worked at all. If you are not an admin or an existing member of a conversation, not only can you not enter any new feedback; you can’t even see the existing feedback that is already there. That’s not right!

It took be a little digging around to finally lay my hands on the source of the problem, but I found it. There is read ACL on the live_group_profile table that includes the following script:

var gr = new GlideRecord('live_group_member');
gr.addQuery('member', GlideappLiveProfile().getID());
gr.addQuery('group', current.sys_id);
gr.addQuery('state', 'admin').addOrCondition('state', 'active');
gr.query();
answer = gr.next();

The impact of that ACL is that you cannot read a record from the live_group_profile table unless you are an existing member of that group. Without access to the group profile, you cannot obtain the sys_id of the group to use in the query of the live_feed_message table to see all of the messages. And you cannot put yourself in the group if you can’t get the ID of the group to include on the live_group_member record you would need to create in order to make yourself a member. The bottom line to all of that is that, if you are not an admin (which overrides this ACL), you cannot see any messages related to the subject of the page and you cannot create any. That pretty much kills the entire basis of what I was trying to do.

The question now, is what, if anything, can be done about it. Obviously, I could simply deactivate that ACL and the problem would be solved, but that would also open up all kinds of other problems that that ACL was designed to avoid, so that’s not really a viable option. I could give up on my desire to leverage these existing tables and functions and just set up all new tables for this process with their own ACLs, but that seems like quite a bit more work than I normally care to undertake. Still, it seems as though there has got to be a way to leverage what I have already built without breaking things that are already in the product and not signing up for a major project. I need to figure out a way for non group members to read the group record without effectively killing that ACL for other purposes, or I am going to have to start all over with custom tables of my own design.

This should be interesting …

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.

Generic Feedback Widget

“Feedback is the breakfast of champions.”
Ken Blanchard

The other day when I was testing out my Static Monthly Calendar I needed a page to demonstrate the modal pop-up, so I grabbed a page that I had created to show off my feedback form field. That got me to thinking that I would be nice to have a universal widget to not only collect new feedback on an item, but to also display all of the feedback that had been collected so far. This is something that ServiceNow already does quite well for Knowledge documents, but that isn’t really universal, and can’t be applied to any other component. Still, it’s a nice model, and one that I wanted to emulate in a way that could be applied to other things.

Typical feedback section of a Knowledge document

Previous comments are listed in reverse chronological order followed by an input form where you can enter new comments. This would be the layout of my proposed widget, with the intent that you could use it for any type of entity, and not just Knowledge. I looked at the underlying table structure for this feature, and the data is eventually stored in two places, a Knowledge table called kb_feedback, and the Live Feed table, live_message. After looking into both, I decided that I could accomplish what I had in mind with the live_message table alone, so I cracked open a shiny new widget that I called generic_feedback and got to work.

I like to solve one problem at a time and then move on to the next one, so I started out with the basic structure of the HTML for the widget, basically copying the original from the Knowledge page:

<div ng-repeat="item in data.feedback">
  <div class="snc-kb-comment-by" style="margin-top: 10px;">
    <img src="/images/icons/feedback.gifx" alt="Feedback" aria-label="Feedback">
    <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>
    </span>
  </div>
  <div>
    <span class="snc-kb-comment-by-text" ng-bind-html="item.comment"></span>
  </div>
</div>

My only significant deviation from the original was to make the name of the person leaving the comment a link to that person’s User profile page. Other than that, it pretty much followed the original layout.

Based on my data references, I was going to need an array of objects with userSysId, userName, dateTime, and comment properties. These values would all come from the live_message table, but first I needed to figure out how to select the specific messages for the particular item that was the subject of the page. One of the properties of the live_message table is labeled Conversation, and is a reference to another table, live_group_profile. The live_group_profile table contains two properties, table and document, that we can leverage to point to a specific record on a specific table, linking our feedback to pretty much anything in the ServiceNow database. Using this approach, to find all of the feedback for a particular entity, you need the name of the table and the sys_id of the record, and with that information, you can find the live_group_profile record that identifies the “conversation” about that entity. On the server side, that code looks something like this:

var conv = new GlideRecord('live_group_profile');
conv.addQuery('table', data.table);
conv.addQuery('document', data.sys_id);
conv.query();
if (conv.next()) {
	data.convId = conv.getValue('sys_id');
	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 = fb.getValue('profile');
		feedback.userName = fb.getDisplayValue('profile');
		feedback.dateTime = fb.getValue('sys_created_on');
		feedback.comment = fb.getDisplayValue('message');
		data.feedback.push(feedback);
	}
}

Before we can gather up all of the feedback, though, we have to establish the name of the table and the sys_id of the record. For the purpose of this particular widget, we will require that this information be passed in the form of URL parameters. These are the very same URL parameters that drive the Dynamic Service Portal Breadcrumbs (which I also included on the page), and are pretty much a standard in the Service Portal, so that shouldn’t be too onerous of a requirement. The code to pull those values in from the URL and validate them looks like this:

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();
			...
		}
	}
}

This doesn’t give us a way to enter new feedback, but we have enough now to give things a try, so let’s create a page, drag our new widget onto the page, and see how things are looking so far.

First test of feedback layout using existing Change Record comments

Well, overall it didn’t turn out too bad, but I can see right away a number of things that need a little work. For one, I don’t really like the date format. That definitely needs some improvement. Also, my links don’t work for the User Profile page. That’s because the profile column in the live_message table contains the sys_id of the live_profile record, not the sys_user record. That’s not going to work. It’s always something!

Still, I like the way things are shaping up. Next time out, let’s see if we can’t clean up all of those pesky little issues and then work on capturing new comments and posting them back to the database.