@mentions in the Ticket Conversations Widget, Revisited

“Continuous improvement is better than delayed perfection.”
Mark Twain

When I hacked up the Ticket Conversations Widget to add support for @mentions, I knew that a number of people had already asked ServiceNow to provide the same capability out of the box. I also knew, though, that these things take time, and not wanting to wait, I just charged ahead as I am often prone to do. However, I was happy to hear recently that the wait may not be all that much longer:

Hi,

The state of idea Service Portal – @Mention Support has been marked as Available

Work for idea is complete and planned to be released in the next Family version

Log in to Idea Portal to view the idea.

Note: This is a system-generated email, do not reply to this email id.
If you would like to stop receiving these emails you can change your preferences here.

Sincerely,
Community Team  

I am assuming that the “next Family version” is a reference to Quebec, although I have nothing on which to base that interpretation. It’s either that or Rome, so one way or another, it appears to be on the way. If and when it does arrive, I will gladly toss my version into the trash and use the real deal instead. I don’t mind building out things that I want that the product currently doesn’t provide, but as soon the product does provide it, my theory is that you fully embrace the out-of-the-box version and throw that steaming pile of home-grown customized nonsense right out the window. I actually look forward to that day.

But then again, that day is not today! Not yet, anyway.

@mentions in the Ticket Conversations Widget

“Talk is cheap. Show me the code.”
Linus Torvalds

When I first started playing around with @mentions in the Service Portal, I mainly just wanted to see if I could get it to work. Once I was able to figure all of that out, I wanted to work that feature into my form field tag, so I spent a little time working all of that out. But I never returned to the real reason that I want to investigate this capability in the first place, which was to add the @mention feature to the Ticket Conversations widget in the Service Portal. So, let’s do that now.

Now that I know a little bit more about what it takes to do what I want to do, I am going to attempt to be a bit surgical in my interactions with the existing code and try to disturb the existing artifact as little as possible and still accomplish my goal. I still don’t want to alter the original, though, so the first thing that I did was to clone the existing Ticket Conversations widget and create one of my own called Mention Conversations. That gave me my initial canvas on which to work, and left the original widget intact and unmolested. So let’s work our way through the changes from top to bottom.

First, we’ll tackle the Body HTML Template, which contains all of the HTML. The only thing that we really want to change here is the input element for the journal entry (comments). Even though it is only a single line, they still used a TEXTAREA, and we’ll leave that alone, other than to add a few new attributes. Here are the original attributes out of the box:

<textarea
  ng-keypress="keyPress($event)"
  sn-resize-height="trim"
  rows="1"
  id="post-input"
  class="form-control no-resize overflow-hidden"
  ng-model='data.journalEntry'
  ng-model-options='{debounce: 250}'
  ng-attr-placeholder="{{getPlaceholder()}}"
  aria-label="{{getPlaceholder()}}"
  autocomplete="off"
  ng-change="userTyping(data.journalEntry)"/>

To that we will add all of the ment-io attributes needed to support the @mention feature:

<textarea
  ng-keypress="keyPress($event)"
  sn-resize-height="trim"
  rows="1"
  id="post-input"
  class="form-control no-resize overflow-hidden"
  ng-model='data.journalEntry'
  ng-model-options='{debounce: 250}'
  ng-attr-placeholder="{{getPlaceholder()}}"
  aria-label="{{getPlaceholder()}}"
  autocomplete="off"
  ng-change="userTyping(data.journalEntry)"
  mentio=""
  mentio-macros="macros"
  mentio-trigger-char="'@'"
  mentio-items="members"
  mentio-search="searchMembersAsync(term)"
  mentio-template-url="/at-mentions.tpl"
  mentio-select="selectAtMention(item)"
  mentio-typed-term="typedTerm"
  mentio-id="'post-input'"/>

Although that is the only change that we will need to make to the existing HTML code, we also need to provide the template referenced in the mentio-template-url attribute. To include that, we will drop this guy down at the bottom of the existing HTML, just inside the outer, enclosing DIV:

<script type="text/ng-template" id="/at-mentions.tpl">
    <div class="dropdown-menu sn-widget sn-mention">
        <ul class="sn-widget-list_v2">
            <li ng-if="items.length > 0 && !items[0].termLengthIsZero" mentio-menu-item="person" ng-repeat="person in items">
                <div class="sn-widget-list-content sn-widget-list-content_static">
                    <sn-avatar primary="person" class="avatar-small" show-presence="true"></sn-avatar></div>
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title" ng-bind-html="person.name"></span>
                    <span class="sn-widget-list-subtitle" ng-if="!person.record_is_visible">Cannot see record</span></div></li>
            <li ng-if="items.length === 1 && items[0].termLengthIsZero">
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title sn-widget-list-title_wrap">Enter the name of a person you want to mention</span></div></li>
            <li ng-if="items.length === 0 && items.loading && visible">
                <div class="sn-widget-list-content sn-widget-list-content_static">
                    <span class="sn-widget-list-icon icon-loading"></span></div>
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title">Loading...</span></div></li>
            <li ng-if="items.length === 0 && !items.loading">
                <div class="sn-widget-list-content">
                    <span class="sn-widget-list-title">No users found</span></div></li></ul>
    </div>
</script>

That takes care of the HTML, so let’s move on to the CSS. All we need to do there is to include one very important additional CCS file, so we add this line right at the top of that section:

@import url("/css_includes_ng.cssx");

That’s it for the CSS section. The next section is the Server Script, but we don’t need to alter that at all, which is cool, so we will just leave that alone and move on to the Client Controller. Down at the very bottom, we will add three new functions:

$scope.searchMembersAsync = function(term) {
	$scope.userSysId = window.NOW.user_id;
	$scope.members = [];
	$scope.members.loading = true;
	clearTimeout($scope.typingTimer);
	if (term.length === 0) {
		$scope.members = [{
			termLengthIsZero: true
		}];
		$scope.members.loading = false;
	} else {
		$scope.typingTimer = setTimeout(function() {
			snMention.retrieveMembers('sys_id', $scope.userSysId, term).then(function(members) {
				$scope.members = members;
				$scope.members.loading = false;
			}, function () {
				$scope.members = [{
					termLengthIsZero: true
				}];
				$scope.members.loading = false;
			});
		}, 500);
	}
};

$scope.selectAtMention = function(item) {
	if (!$scope.mentionMap) {
		$scope.mentionMap = {};
	}
	if (item.termLengthIsZero) {
		return (item.name || "") + "\n";
	}
	$scope.mentionMap[item.name] = item.sys_id;
	return "@[" + item.name + "]";
};

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

The first two are referenced in the ment-io attributes that we added to the TEXTAREA in the HTML template. The last one is used just before saving the comment, so we have to hunt down the line that sends that data back to server for posting to the record, and insert a call to that function. That logic is in the existing post function, and out of the box looks like this:

input = input.trim();
$scope.data.journalEntry = input;

We’ll tweak that just a bit to expand the mentions before sending the comments off to the server to be written to the database:

input = expandMentions(input.trim());
$scope.data.journalEntry = input;

One last thing that we will need to do with the client side code is to add the snMention object to the long list of arguments passed into the controller function. This is necessary because that object is referenced by our new searchMembersAsync function. That should wrap things up one the client side, though, which is the last thing that we need to change, so all that is left to do now is to drop this baby onto the page, fire it up, and give it a spin.

First, we need to find an Incident on the Service Portal to bring up on the ticket page, and then we start typing with an @ sign to activate the pop-up pick list from which you can select a person. So far, so good:

Selecting a person to @mention in the comment

Selecting the person puts square brackets around the person’s name while in the input element, which you can see before hitting Send.

@mentions in the input element before saving

Clicking on the Send button triggers the expandMentions function that we added to the controller, which then adds the sys_id of the User inside those square brackets, all of which gets sent over to the server side to be written to the database. A lot of things happen after that which are not a part of this widget, but when all is said and done, the comment comes back out as part of the time line, and both the sys_id and square brackets are long gone at this point.

New comment added to the timeline

In addition to the removal of the square brackets and sys_id, one other thing that happens when you add an @mention to a comment is that the person mentioned gets notified. If you look in the system email logs, you can find a record of this notification, which comes out like this with an out-of-the-box template:

Standard notice to those @mentioned

The cool thing about that was that we didn’t have to add any additional code or do anything special to make that happen — we just had to pass the @mention details in the proper format and things took care of themselves from there. Pretty slick.

Well, that’s about all there is to that. If you want all the parts and pieces needed to make this work, here is an Update Set. I tried my best to have a fairly light touch as far as the existing code goes, but if you have any ideas on how to make it even better, I would love to hear about them.

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.

@mentions in the Service Portal, Revisited

“If you don’t have time to do it right, when will you have the time to do it over?”
John Wooden

After adding a mention type to my Service Portal form field tag, I broke my original ment.io example. I actually used that example in building the code to support the mentions type, but I did have to make a few changes in order to get everything to work, and now that I have done that, my original example doesn’t work anymore. I thought about just abandoning it since I got the form field version working, but then I thought that there were some other possible use cases where I wouldn’t want to be using an snh-form-field, so I decided that I had better dig around and see what was what.

The form field tag version did make things quite a bit simpler. Compare the initial version:

<snh-form-field
  snh-model="c.data.textarea"
  snh-name="textarea"
  snh-type="textarea"
  snh-label="${Original ment.io Example}"
  snh-help="While entering your text, type @username anywhere in the message. As you type, an auto-suggest list appears with names and pictures of users that match your entries. For example, if you type @t, the auto-suggest list shows the pictures and names of all users with names that start with T. Click the user you want to add and that user's name is inserted into the @mention in the body of your text."
  mentio=""
  mentio-macros="macros"
  mentio-trigger-char="'@'"
  mentio-items="members"
  mentio-search="searchMembersAsync(term)"
  mentio-template-url="/at-mentions.tpl"
  mentio-select="selectAtMention('textarea', item)"
  mentio-typed-term="typedTerm"
  mentio-id="'textarea'"
  ng-trim="false"
  autocomplete="off"/>

… with the snh-form-field version, and you can see right away that there is a significant difference in size alone:

<snh-form-field
  snh-model="c.data.mention"
  snh-name="mention"
  snh-type="mention"
  snh-label="${snh-form-field ment.io Example}"/>

Just for comparison, I decided to put both on the page (and make them both work!), and then add another using the new feedback field type, and yet another using just a plain, old, ordinary textarea.

new @mentions example page with various versions

The errors on the original weren’t that significant. Mainly I just wanted to put out a new version of the Update Set so that you could see all of the various approaches working on the same page side by side (… well, on top of each other, if you really want to get specific). If you are really curious as to what exactly needed to be changed, you can always compare the old to the new.

Even More Service Portal Form Fields

“Have a bias toward action – let’s see something happen now. You can break that big plan into small steps and take the first step right away.”
Indira Gandhi

After working out all of the little issues in my @mentions example, it occurred to me that it might be even better to just make an @mentions textarea another supported field type like I just did for feedback. This way, you wouldn’t have to bother with all of those mentio- prefixed attributes at all — they would just become part of the internal process of rendering the form field. Some things would have to be standardized, which might limit your flexibility in certain areas, but that still wouldn’t prevent you from mentionizing your own textarea or textarea-type form field. This would just be yet another option.

The first thing to, then, would be to add mention to the list of supported field types:

	var SUPPORTED_TYPE = ['choicelist', 'date', 'datetime-local', 'email', 'feedback', 'inlineradio', 'mention', 'month', 'number', 'password', 'radio', 'rating', 'reference', 'select', 'tel', 'text', 'textarea', 'time', 'url', 'week'];

That’s getting to be quite a list, but that’s not necessarily a bad thing. Next, we have to add the logic to render the input element:

} else if (type == 'mention') {
	htmlText += "      <textarea class=\"snh-form-control\" ng-model=\"" + model + "\" id=\"" + name + "\" name=\"" + name + "\" mentio=\"\" mentio-macros=\"macros\" mentio-trigger-char=\"'@'\" mentio-items=\"members\" mentio-search=\"searchMembersAsync(term)\" mentio-template-url=\"/at-mentions.tpl\" mentio-select=\"selectAtMention('" + name + "', item)\" mentio-typed-term=\"typedTerm\" mentio-id=\"'" + name + "'\" ng-trim=\"false\" autocomplete=\"off\"" + passThroughAttributes(attrs) + (required?' required':'') + "></textarea>\n";

So far, so good. Now, where to stuff the template? In my mind, the best place to locate the template would be in the Service Portal itself, as that would ensure that it would only appear on the page once and only once. You could just make it part of your portal page header and be done with it. However, if you didn’t know to do that, or if you moved your widget from a portal that had it to another portal that did not, things would not work and would not be that intuitive as to why that was. Placing it in the widget is another option, but again, you would have to know to do that. Rendering it as part of the form field means that you could have many copies of the template ending up on a single page, but then everything would be self-contained and you wouldn’t have to know to add anything else to include this feature other than the appropriate form field type. Right now, I am thinking that this latter approach is the best option, although I may end up going the other way one day after I’ve had more time to contemplate all of the ramifications of that strategy. For now, I’ve added this line just under the lines above:

	htmlText += getMentionTemplate();

… and this function to provide the template:

function getMentionTemplate() {
	var htmlText = "      <script type=\"text/ng-template\" id=\"/at-mentions.tpl\">\n";
	htmlText += "        <div class=\"dropdown-menu sn-widget sn-mention\">\n";
	htmlText += "          <ul class=\"sn-widget-list_v2\">\n";
	htmlText += "            <li ng-if=\"items.length > 0 && !items[0].termLengthIsZero\" mentio-menu-item=\"person\" ng-repeat=\"person in items\">\n";
	htmlText += "              <div class=\"sn-widget-list-content sn-widget-list-content_static\">\n";
	htmlText += "                <sn-avatar primary=\"person\" class=\"avatar-small\" show-presence=\"true\"></sn-avatar>\n";
	htmlText += "              </div>\n";
	htmlText += "              <div class=\"sn-widget-list-content\">\n";
	htmlText += "                <span class=\"sn-widget-list-title\" ng-bind-html=\"person.name\"></span>\n";
	htmlText += "                <span class=\"sn-widget-list-subtitle\" ng-if=\"!person.record_is_visible\">Cannot see record</span>\n";
	htmlText += "              </div>\n";
	htmlText += "            </li>\n";
	htmlText += "            <li ng-if=\"items.length === 1 && items[0].termLengthIsZero\">\n";
	htmlText += "              <div class=\"sn-widget-list-content\">\n";
	htmlText += "                <span class=\"sn-widget-list-title sn-widget-list-title_wrap\">Enter the name of a person you want to mention</span>\n";
	htmlText += "              </div>\n";
	htmlText += "            </li>\n";
	htmlText += "            <li ng-if=\"items.length === 0 && items.loading && visible\">\n";
	htmlText += "              <div class=\"sn-widget-list-content sn-widget-list-content_static\">\n";
	htmlText += "                <span class=\"sn-widget-list-icon icon-loading\"></span>\n";
	htmlText += "              </div>\n";
	htmlText += "              <div class=\"sn-widget-list-content\">\n";
	htmlText += "                <span class=\"sn-widget-list-title\">Loading...</span>\n";
	htmlText += "              </div>\n";
	htmlText += "            </li>\n";
	htmlText += "            <li ng-if=\"items.length === 0 && !items.loading\">\n";
	htmlText += "              <div class=\"sn-widget-list-content\">\n";
	htmlText += "                <span class=\"sn-widget-list-title\">No users found</span>\n";
	htmlText += "              </div>\n";
	htmlText += "            </li>\n";
	htmlText += "          </ul>\n";
	htmlText += "        </div>\n";
	htmlText += "      </script>\n";
	return htmlText;
}

That does create the potential for more than one template appearing in the source code, but other than the obvious bloat, that doesn’t really hurt anything.

Now we have to deal with the functions, one to fetch the data and one to handle the selection. We will add these to the link section of the provider, starting with the one that fetches the data:

scope.searchMembersAsync = function(term) {
	scope.userSysId = window.NOW.user_id;
	scope.members = [];
	scope.members.loading = true;
	clearTimeout(scope.typingTimer);
	if (term.length === 0) {
		scope.members = [{
			termLengthIsZero: true
		}];
		scope.members.loading = false;
	} else {
		scope.typingTimer = setTimeout(function() {
			scope.snMention.retrieveMembers('sys_id', scope.userSysId, term).then(function(members) {
				scope.members = members;
				scope.members.loading = false;
			}, function () {
				scope.members = [{
					termLengthIsZero: true
				}];
				scope.members.loading = false;
			});
		}, 500);
	}
};

This script required two things that we brought in via the client script function arguments: snMention and $timeout. In this context, $timeout can be replaced with the standard window functions setTimeout and clearTimeout, so that eliminates the need for that one. The other requirement, though, snMention, is still going to have to come in via a client script function argument, and then passed into the scope. I don’t like doing that, as that is yet another thing that the person using this is going to have to know, but I couldn’t see any other way around it. That makes the client script in our example widget now start out like this:

function($scope, snMention) {
	var c = this;
	$scope.snMention = snMention;

The other script we will need is the one that handles the selection:

scope.selectAtMention = function(field, item) {
	if (!scope.mentionMap) {
		scope.mentionMap = {};
	}
	if (!scope.mentionMap[field]) {
		scope.mentionMap[field] = {};
	}
	if (item.termLengthIsZero) {
		return (item.name || "") + "\n";
	}
	scope.mentionMap[field][item.name] = item.sys_id;
	return "@[" + item.name + "]";
};

The major revision here is that we have added a field argument to the function call to allow for the fact that there could be more than one field on the form that contains @mentions. This way, mentions from one field are collected in a different object than mentions from another field. We also added checks for both the base mention map and the field-specific map so that they can be initialized as needed.

To test things out, I added a mention type form field to my form field test widget and gave it a whirl.

Form field test page with @mention field added

Everything seems to work, which is good, so I’ve bundled it all up into yet another Update Set just in case someone wants to dig into all of the details. I’m going to have to quit using the word final in relationship to this little form field experiment, as I keep finding new form field types to toss onto the pile; who knows what will pop up tomorrow …

@mentions in the Service Portal

“Don’t worry if it doesn’t work right. If everything did, you’d be out of a job.”
Mosher’s Law of Software Engineering

There is a nifty feature on the UI side of the Now Platform that allows you to include “mentions” of other platform users in various messages and form fields such as the Comments and Work Notes on the Incident form. To mention another user, you simply type an @ character before typing out their name:

Adding an @mention to a form field

I stole that image from the documentation, which you can find here. It’s a nice feature, and it opens up a number of possibilities for other cool stuff, but it’s only available on the primary UI side. As of yet, this feature has not found its way to the Service Portal. I’m sure that if I were to wait long enough, some future version will resolve that minor shortcoming, but not being one who likes to wait for things, I though that maybe I would try to see if I could make it work myself. How hard could it be?

I started out by digging around in the source code of the Incident form, trying to figure out how everything worked. I located the textarea tag for the comments field and took a look at all of the attributes:

<textarea
  id="activity-stream-comments-textarea"
  aria-label="{{activity_field_1.label}}"
  class="sn-string-textarea form-control"
  placeholder="Additional comments"
  data-stream-text-input="comments"
  ng-required="activity_field_1.mandatory && !activity_field_1.filled"
  ng-model="activity_field_1.value"
  ng-attr-placeholder="{{activity_field_1.label}}"
  sn-sync-with="activity_field_1.name"
  sn-sync-with-value-in-fn="reduceMentions(text)"
  sn-sync-with-value-out-fn="expandMentions(text)"
  mentio=""
  mentio-id="'activity-stream-comments-textarea'"
  mentio-typed-term="typedTerm"
  mentio-require-leading-space="true"
  mentio-trigger-char="'@'"
  mentio-items="members"
  mentio-search="searchMembersAsync(term)"
  mentio-template-url="/at-mentions.tpl"
  mentio-select="selectAtMention(item)"
  mentio-suppress-trailing-space="true"
  sn-resize-height="">
</textarea>

Clearly, everything that started with mentio was related to this feature, so on a whim I decided to search the Interwebs for the term mentio and discovered that the good folks at ServiceNow were using this product to implement this feature. That was actually a nice find, as the site came complete with documentation, which really made figuring all of this out quite a bit easier than I had originally imagined.

I wasn’t sure how many of the parts and pieces needed to make this work were already present in the Service Portal, so my first attempt was just to create a widget with a single textarea form field and add all of these mentio– attributes:

<snh-form-field
  snh-model="c.data.textarea"
  snh-name="textarea"
  snh-type="textarea"
  snh-label="${ment.io Example}"
  snh-help="While entering your text, type @username anywhere in the message. As you type, an auto-suggest list appears with names and pictures of users that match your entries. For example, if you type @t, the auto-suggest list shows the pictures and names of all users with names that start with T. Click the user you want to add and that user's name is inserted into the @mention in the body of your text."
  mentio=""
  mentio-macros="macros"
  mentio-trigger-char="'@'"
  mentio-items="members"
  mentio-search="searchMembersAsync(term)"
  mentio-template-url="/at-mentions.tpl"
  mentio-select="selectAtMention(item)"
  mentio-typed-term="typedTerm"
  mentio-id="'textarea'"
  ng-trim="false"
  autocomplete="off"/>

That at least got me the basic structure with which to experiment, and turned out looking like this:

Basic textarea for @mentions enablement

Of course, typing an @ character did not immediately pop up the expected user selection screen, but I really didn’t expect that at this stage of the game. Three of those mentio attributes in particular seemed to suggest that there was more parts needed to make all of this work:

  mentio-search="searchMembersAsync(term)"
  mentio-template-url="/at-mentions.tpl"
  mentio-select="selectAtMention(item)"

The first and the last I recognized as missing Javascript functions, but I wasn’t sure what that one in the middle could be. A quick check of the ment.io documentation revealed this:

mentio-template-url
Optional. Specifies the template url to use to render the select menu. The template should iterate the items list to present a menu of choices. The items scope property from the mentio-menu is available to iterate within an ng-repeat. The typedTerm scope property from the mentio-menu can be accessed in order to highlight text in the menu. The default template presents a simple menu, and assumes that each object has a property called label.

Armed with that little tidbit of additional knowledge, I went back to the source code of the Incident form, and found this:

<script type="text/ng-template" id="/at-mentions.tpl">
	<div class="dropdown-menu sn-widget sn-mention">
		<ul class="sn-widget-list_v2">
			<li ng-if="items.length > 0 && !items[0].termLengthIsZero" mentio-menu-item="person" ng-repeat="person in items">
				<div class="sn-widget-list-content sn-widget-list-content_static">
					<sn-avatar primary="person" class="avatar-small" show-presence="true"></sn-avatar></div>
				<div class="sn-widget-list-content">
					<span class="sn-widget-list-title" ng-bind-html="person.name"></span>
					<span class="sn-widget-list-subtitle" ng-if="!person.record_is_visible">Cannot see record</span></div></li>
			<li ng-if="items.length === 1 && items[0].termLengthIsZero">
				<div class="sn-widget-list-content">
					<span class="sn-widget-list-title sn-widget-list-title_wrap">Enter the name of a person you want to mention</span></div></li>
			<li ng-if="items.length === 0 && items.loading && visible">
				<div class="sn-widget-list-content sn-widget-list-content_static">
					<span class="sn-widget-list-icon icon-loading"></span></div>
				<div class="sn-widget-list-content">
					<span class="sn-widget-list-title">Loading...</span></div></li>
			<li ng-if="items.length === 0 && !items.loading">
				<div class="sn-widget-list-content">
					<span class="sn-widget-list-title">No users found</span></div></li></ul>
	</div>
</script>

I’ve never seen HTML stored inside of a script tag before, but obviously it works, so I just copied that into the HTML for the widget. Then I hunted down the two missing functions that were referenced in the other attributes, pasted those into the widget’s client script and gave it another try. Still nothing. Now it was time to take a hard look at those functions and see if I couldn’t toss in some alerts here and there to see if I could figure out what was working and what was having issues. I figured that the best place to start was with the function that fetched the data to be display on the pick list. Here is the original script from the Incident page:

$scope.searchMembersAsync = function(term) {
$scope.members = [];
$scope.members.loading = true;
$timeout.cancel(typingTimer);
if (term.length === 0) {
$scope.members = [{
termLengthIsZero: true
}];
$scope.members.loading = false;
} else {
typingTimer = $timeout(function() {
snMention.retrieveMembers($scope.table, $scope.sysId, term).then(function(members) {
$scope.members = members;
$scope.members.loading = false;
}, function () {
$scope.members = [{
termLengthIsZero: true
}];
$scope.members.loading = false;
});
}, 500);
}
};

That’s not very nicely formatted, so it’s a little hard for me to read, but I noticed two things: 1) it was using a component called snMention to fetch the data, and 2) it was passing the function a table and a sys_id in addition to what the person had typed on the screen. The first thing that I did was add snMention as an argument to the client script function along with $timeout, which was also referenced in the script. I also wasn’t on a form associated with a table, so I replaced those two variables with null. That allowed the function to work, but it did not send back any data. Apparently, you have to send in the parameters for a record to which the current user has write authority. I didn’t have one of those, so I decided to try the user’s sys_user record, which actually did the trick. I still wasn’t getting anything on the screen, but at least I could see that I was actually getting results based on what was being entered in the field. That was progress. Here is how the client script turned out after all of that:

function($scope, $timeout, snMention, spModal) {
	var c = this;
	var typingTimer;

	$scope.snMention = snMention;
	$scope.macros = {};
	$scope.mentionMap = {};
	$scope.members = [];
	$scope.theTextArea = '';

	$scope.searchMembersAsync = function(term) {
		$scope.members = [];
		$scope.members.loading = true;
		$timeout.cancel(typingTimer);
		if (term.length === 0) {
			$scope.members = [{
				termLengthIsZero: true
			}];
			$scope.members.loading = false;
		} else {
			typingTimer = $timeout(function() {
				snMention.retrieveMembers('sys_id', c.data.userId, term).then(function(members) {
					$scope.members = members;
					$scope.members.loading = false;
				}, function () {
					$scope.members = [{
						termLengthIsZero: true
					}];
					$scope.members.loading = false;
				});
			}, 500);
		}
	};

	$scope.selectAtMention = function(item) {
		if (item.termLengthIsZero) {
			return (item.name || "") + "\n";
		}
		$scope.mentionMap[item.name] = item.sys_id;
		return "@[" + item.name + "]";
	};
}

After a lot of trial and error (mostly error!), I finally figured out that the reason that I wasn’t getting anything to show up on the screen was that I was missing some really important CSS. Rather than pick through the style sheets and pull out the various bits that I needed, I just added the following to the top of my HTML:

<link rel="stylesheet" type="text/css" href="/styles/heisenberg/heisenberg_all.cssx">
<link rel="stylesheet" type="text/css" href="/css_includes_ng.cssx">

Now we’re cooking with gas!

What do you know … it actually works!

That was a little lazy, as I am sure that I only needed a few odds and ends from those additional style sheets, but this is just an example, so I just pulled in the entire thing, unnecessary bloat and all. Once I got everything working as it should, I wanted to do something with the list of folks who were mentioned in the text, just to show how that part works as well, so I created another widget to pop up in a modal dialog that listed out the people. Now, when you hit the Submit button on this little example, you get this:

List of Users mentioned in the text

In addition to the separate widget to display the names, I had to add one more function to the client side script of the main widget:

$scope.showMentions = function() {
	var mentions = [];
	for (var name in $scope.mentionMap) {
		mentions.push({name: name, sys_id: $scope.mentionMap[name]});
	}
	spModal.open({
		title: 'Users Mentioned',
		widget: 'mentions',
		widgetInput: {mentions: JSON.stringify(mentions)},
		buttons: [
			{label: 'Close', primary: true}
		],
		size: 'lg'
	});
}

There is still quite a bit of clean-up that I would like to do before considering this really ready for prime time, but I achieved my main objective, which was just to see if I could get it to work. It does work, and if I don’t say so myself, it’s a pretty cool feature to add to the toolbox. If you would like to play around with code yourself, here is an Update Set with everything that you will need to pull this off.