Flow Designer Scratchpad

“Necessity is the mother of invention.”
English-language proverb

I really should be working on testing out the latest enhancements to my Dynamic Service Portal Breadcrumbs right now, but I ran into this other issue recently, and I really want to see if I can make this work. I hate it when people start something and then move on to other things without ever finishing up what they started, so I definitely want to circle back and wrap that one up; however, today is not that day.

Today I want to talk about the Flow Designer. I have been striving to convert any of my old legacy Workflows over to the newer Flow Designer tool whenever the opportunity arises. The other day I was doing just that with a Workflow that made extensive use of the Workflow Scratchpad feature. When I went to look for the equivalent feature in the Flow Designer, I couldn’t find anything. I thought maybe that it wasn’t needed for some reason, so I tried several workarounds, but nothing worked. Nothing that I tried would preserve and/or modify data between or across Actions or Subflows. After quite a number of failed attempts to find something that would do the job, I eventually came to realize that if I wanted some kind of Scratchpad capability in the Flow Designer, I was going to have to build it myself.

My first thought was that all that I would need would be simple setProperty and getProperty functions, but then I realized that I would first need to establish the scratchpad, and once established, I would want to be able to get rid of it as well, so that turned into four relatively simple functions, which is still not too bad. When I say functions, what I really mean are Flow Designer Actions, but since I will be calling some function in a common Script Include built for this purpose, I still think of them as functions. Here is the function to create a scratchpad, which is just basically a record on a table that I created for this purpose:

createScratchpad: function() {
	var response = {};

	var spGR = new GlideRecord('u_snh_scratchpad');
	spGR.initialize();
	spGR.setValue('u_scratchpad', '{}');
	if (spGR.insert()) {
		response.success = true;
		response.scratchpad_id = spGR.getUniqueValue();
		response.message = 'Scratchpad record successfully created';
	} else {
		response.success = false;
		response.message = 'Unable to create scratchpad record';
	}

	return response;
},

The scratchpad itself is just a JSON string stored in the only column added to the table, u_scratchpad. We initialize that to an empty object and save the record and that’s about all there is to that. To get rid of it, we will need to have the sys_id of the record, but there is not much code behind that process, either:

deleteScratchpad: function(spId) {
	var response = {};

	var spGR = new GlideRecord('u_snh_scratchpad');
	if (spGR.get(spId)) {
		if (spGR.deleteRecord()) {
			response.success = true;
			response.message = 'Scratchpad record successfully deleted';
		} else {
			response.success = false;
			response.message = 'Unable to delete scratchpad record';
		}
	} else {
		response.success = false;
		response.message = 'Scratchpad record not found';
	}

	return response;
},

That takes care of building up and tearing down the scratchpad object. Now, to use it, we will need those setProperty and getProperty functions that we were talking about earlier. This one will let you set the value of a property on the scratchpad:

setScratchpadProperty: function(spId, propertyName, propertyValue) {
	var response = {};

	var spGR = new GlideRecord('u_snh_scratchpad');
	if (spGR.get(spId)) {
		var jsonString = spGR.getValue('u_scratchpad');
		var jsonObject = {};
		try {
			jsonObject = JSON.parse(jsonString);
		} catch(e) {
			response.warning = 'Unable to parse JSON string: ' + e;
		}
		jsonObject[propertyName] = propertyValue;
		jsonString = JSON.stringify(jsonObject, null, '\t');
		spGR.setValue('u_scratchpad', jsonString);
		if (spGR.update()) {
			response.success = true;
			response.message = 'Scratchpad property "' + propertyName + '" set to "' + propertyValue + '"';
		} else {
			response.success = false;
			response.message = 'Unable to update scratchpad record';
		}
	} else {
		response.success = false;
		response.message = 'Scratchpad record not found';
	}

	return response;
},

… and this one lets you retrieve the value of a property on the scratchpad:

getScratchpadProperty: function(spId, propertyName) {
	var response = {};

	var spGR = new GlideRecord('u_snh_scratchpad');
	if (spGR.get(spId)) {
		var jsonString = spGR.getValue('u_scratchpad');
		try {
			var jsonObject = JSON.parse(jsonString);
			var propertyValue = jsonObject[propertyName];
			if (propertyValue > '') {
				response.success = true;
				response.property_value = propertyValue;
				response.message = 'Returning value "' + propertyValue + '" for scratchpad property "' + propertyName + '"';
			} else {
				response.success = false;
				response.message = 'Scratchpad property "' + propertyName + '" has no value';
			}
		} catch(e) {
			response.success = false;
			response.message = 'Unable to parse JSON string: ' + e;
		}
	} else {
		response.success = false;
		response.message = 'Scratchpad record not found';
	}

	return response;
},

That’s it for the core functions needed to make this work. Putting it all together, the entire Script Include looks like this:

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

	createScratchpad: function() {
		var response = {};

		var spGR = new GlideRecord('u_snh_scratchpad');
		spGR.initialize();
		spGR.setValue('u_scratchpad', '{}');
		if (spGR.insert()) {
			response.success = true;
			response.scratchpad_id = spGR.getUniqueValue();
			response.message = 'Scratchpad record successfully created';
		} else {
			response.success = false;
			response.message = 'Unable to create scratchpad record';
		}

		return response;
	},

	setScratchpadProperty: function(spId, propertyName, propertyValue) {
		var response = {};

		var spGR = new GlideRecord('u_snh_scratchpad');
		if (spGR.get(spId)) {
			var jsonString = spGR.getValue('u_scratchpad');
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch(e) {
				response.warning = 'Unable to parse JSON string: ' + e;
			}
			jsonObject[propertyName] = propertyValue;
			jsonString = JSON.stringify(jsonObject, null, '\t');
			spGR.setValue('u_scratchpad', jsonString);
			if (spGR.update()) {
				response.success = true;
				response.message = 'Scratchpad property "' + propertyName + '" set to "' + propertyValue + '"';
			} else {
				response.success = false;
				response.message = 'Unable to update scratchpad record';
			}
		} else {
			response.success = false;
			response.message = 'Scratchpad record not found';
		}

		return response;
	},

	getScratchpadProperty: function(spId, propertyName) {
		var response = {};

		var spGR = new GlideRecord('u_snh_scratchpad');
		if (spGR.get(spId)) {
			var jsonString = spGR.getValue('u_scratchpad');
			try {
				var jsonObject = JSON.parse(jsonString);
				var propertyValue = jsonObject[propertyName];
				if (propertyValue > '') {
					response.success = true;
					response.property_value = propertyValue;
					response.message = 'Returning value "' + propertyValue + '" for scratchpad property "' + propertyName + '"';
				} else {
					response.success = false;
					response.message = 'Scratchpad property "' + propertyName + '" has no value';
				}
			} catch(e) {
				response.success = false;
				response.message = 'Unable to parse JSON string: ' + e;
			}
		} else {
			response.success = false;
			response.message = 'Scratchpad record not found';
		}

		return response;
	},

	deleteScratchpad: function(spId) {
		var response = {};

		var spGR = new GlideRecord('u_snh_scratchpad');
		if (spGR.get(spId)) {
			if (spGR.deleteRecord()) {
				response.success = true;
				response.message = 'Scratchpad record successfully deleted';
			} else {
				response.success = false;
				response.message = 'Unable to delete scratchpad record';
			}
		} else {
			response.success = false;
			response.message = 'Scratchpad record not found';
		}

		return response;
	},

	type: 'SNHScratchpadUtils'
};

Now that we have all of the functions, we need to turn those into Flow Designer Actions. Before we do that, though, let’s create a Category for them so that we can group them and they will be easy to find. We do that by adding a row to the Action Category table, sys_hub_category. With that out of the way, we can create our first Action, which will invoke the createScratchpad function in our Script Include.

Create Scratchpad Action

The entire Action is just a Script step that leverages our Script Include and passes the results on to the Action Outputs. The small script to make that happen is just a few short lines of code:

var snhspu = new SNHScratchpadUtils();
var result = snhspu.createScratchpad();
for (var key in result) {
	outputs[key] = result[key];
}

Now we just need to repeat that process 3 more times to create Flow Designer Actions from our three other Script Include functions and we’re all set. To test things out, there is a little Test button right at the top of the Flow Designer page, and for the Create Scratchpad Action, there isn’t even any input to set up, so you can just click that button and go. Once you test out the Create Scratchpad Action, you can snag the Scratchpad ID out of the Action Outputs and then use that as an input to test all of the others.

Well, that wasn’t so bad: one Script Include, four functions, one Action Category, and four Actions. I threw this together rather quickly, but here is the Update Set. If you run into any issues with that, or if you can think of any way to make it better, please let me know in the comments. Of if you know of a built-in function that eliminates the need for this, that would be even better!

Note: With the introduction of Flow Variables, this component is no longer necessary.

Configuration Item Icon Assignment Widget, Part III

When to use iterative development? You should use iterative development only on projects that you want to succeed.”
Martin Fowler

I probably could have wrapped all of this up last time, but I had not really given a whole lot of thought to a couple of critical decisions related to the last two items that needed to be coded out, the Save button and the Cancel button. For the Cancel button, the main question is where are you going to land once you leave the page. For the Save button, which is the bigger question, I need to figure out where I am going to store the information now that I have collected it on the screen. Since that’s a more complicated deliberation, let’s focus on the Cancel button first.

One thing that I wanted to do with the Cancel button was to pop up a confirmation that you really did want to bail, but only if you had made any changes that you would be losing by leaving the page. I wasn’t keeping track of whether or not you had made any changes, though, so I needed to fix that first. I created a variable called dataAltered and initialized it to false, and then I set it to true if you added or removed a row or selected an icon. With that in place, I built the function to handle the Cancel button clicks.

$scope.cancel = function() {
	if (dataAltered) {
		spModal.confirm('Discard your recent changes?').then(function(confirmed) {
			if (confirmed) {
				goBack();
			}
		});
	} else {
		goBack();
	}
};

Those of you paying close attention will notice that I still did not address the question of where to go after clicking the Cancel button; I just called a nonexistent function called goBack. That’s basically my way of putting things off until some future time when I am forced to deal with it, and usually I will create the function with a single line of code that pops an alert or write a log record to the let me know that I’ve made it that far. That let’s me test what I have done without having to deal with what I haven’t done. In this particular case, though, that was a short delay, because now it’s time to put real code in the goBack function, which means that it’s time to decide where people are going to land when they hit the Cancel button.

One of my first thoughts was that if this widget was sharing a screen with my Dynamic Service Portal Breadcrumbs widget, I could just go back one step in the chain to the previous screen. In order for that to work, I would need to know if I was sharing the page with that widget, which is not really possible at the moment. Ideally, that widget should announce its presence with some kind rootScope broadcast message that says something like, “Hey, I am here, and oh by the way, here is the URL for the previous page if you want to go back to it.” That actually sounds like a good idea, but it also sounds like a project for another day. I was planning on adding this widget to my existing Tools menu in the main UI, so I didn’t really see a lot of value in investing a lot of effort right now on getting it to work on the portal with breadcrumbs.

Ultimately, I decided to just go to the home page of the main UI, unless of course, we really were running in the portal, and in that case, I would just return to the home page of the portal. Since the main UI pages run inside of an iFrame, it seemed relatively simple enough to detect the difference by looking at the parent window. The final code turned out to be just this:

function goBack() {
	var destination = '?';
	if (window.parent.location.href != window.location.href) {
		destination = '/home.do';
	}
	window.location.href = destination;
}

That takes care of the easy part. Now it is finally time to make a decision on where to store this information. The simplest thing to do seemed to be to just add another column to the sys_db_object table for the name of the icon. That ‘s a fairly foundational table to the whole ServiceNow platform, though, and I really do not like to mess with those if I can avoid it. A preferable alternative would be to create a simple m2m table that had a column for the CI class and another column for the icon name. That’s much better, but there is still a certain amount of work involved in creating the table, saving the data to the table, and then reading the data back in from the table whenever you wanted to use it. What I finally decided to do was to just keep the icon map hard-coded in my Script Include, and then just update the script column of the Script Include whenever the Save button is clicked. Look, Ma — no tables!

My plan was to grab the script, substring off the parts of the script before and after the property list, rebuild the property list from the data on the screen, and then rebuild the script from the before and after pieces and the new property list. There is probably some regex wizardry that would do all of that with a single line of code, but most of that is all voodoo magic as far as I can tell, and I like to build things that can be comprehended by a little bit larger audience. In the end, my approach seemed to have an awful lot of code and variables involved, but it does work, so there you go.

function save() {
	var scriptGR = new GlideRecord('sys_script_include');
	if (scriptGR.get('7b6ebf052f8e18104425fcecf699b6f6')) {
		var original = scriptGR.getValue('script');
		var i = original.indexOf('map: {');
		if (i != -1) {
			var preamble = original.substring(0, i);
			var theRest = original.substring(i);
			i = theRest.indexOf('\n\t},');
			if (i != -1) {
				var postamble = theRest.substring(i);
				var separator = '';
				var newScript = preamble + 'map: {';
				for (i=0; i<data.itemArray.length; i++) {
					newScript += separator + '\n\t\t' + data.itemArray[i].id + ": '" + data.itemArray[i].icon + "'";
					separator = ',';
				}
				newScript += postamble;
				scriptGR.setValue('script', newScript);
				scriptGR.update();
				gs.addInfoMessage('The Configuration Item icon assignments have been saved');
			} else {
				gs.addErrorMessage('The Script Include to update has been corrupted.');
			}
		} else {
			gs.addErrorMessage('The Script Include to update has been corrupted.');
		}
	} else {
		gs.addErrorMessage('The Script Include to update was not found.');
	}
}

That’s a server side script, by the way, so we still need a client side script to send things over to the server. That one is pretty simple, though, and looks like this:

$scope.save = function() {
	c.data.action = 'save';
	c.server.update();
};

In fact, the whole client side script, including the code that we just added to detect changes and handle the two buttons now looks like this in its final form:

function CIIconAssignment($scope, spModal) {
	var c = this;
	var dataAltered = false;

	$scope.addSelected = function() {
		c.server.update();
		$('#snrp').select2("val","");
		dataAltered = true;
	};

	$scope.removeItem = function(inx) {
		spModal.confirm('Remove ' + c.data.itemArray[inx].id + ' from the list?').then(function(confirmed) {
			if (confirmed) {
				c.data.itemArray.splice(inx, 1);
				dataAltered = true;
			}
		});
	};

	$scope.selectIcon = function(inx) {
		spModal.open({
			title: 'Select Icon',
			widget: 'icon-picker',
			buttons: [
				{label: '${Cancel}', cancel: true}
			],
			size: 'sm',
		}).then(function(response) {
			c.data.itemArray[inx].icon = response.selected;
			dataAltered = true;
		});
	};

	$scope.save = function() {
		c.data.action = 'save';
		c.server.update();
	};

	$scope.cancel = function() {
		if (dataAltered) {
			spModal.confirm('Discard your recent changes?').then(function(confirmed) {
				if (confirmed) {
					goBack();
				}
			});
		} else {
			goBack();
		}
	};

	function goBack() {
		var destination = '?';
		if (window.parent.location.href != window.location.href) {
			destination = '/home.do';
		}
		window.location.href = destination;
	}
}

The last thing that I did was grab one of the existing sidebar menu options under my Tools section and use it as model for a new menu option to launch this page. After that, I used the new option to bring up the page and test everything out. Everything seems to work, and it came out looking pretty good, all things considered.

Configuration Item Icon Assignment widget and related menu option

One thing that I did realize during my testing is that there is nothing to stop you from deleting the root cmdb_ci entry, which serves as my default. I don’t really want you doing that, so I added an ng-hide to the delete icon that will remove that option when the item.id is cmdb_ci. You can see in the image above that there is no delete option for that row. That’s much better.

After playing around and testing, I ended up saving my Script Include several times over. Right now, it looks like this:

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

	getIcon: function(ciClass) {
		var icon = this.map[ciClass];

		if (!icon) {
			ciClass = this.getParentClass(ciClass);
			if (ciClass) {
				icon = this.getIcon(ciClass);
			}
		}

		return icon;
	},

	getParentClass: function(ciClass) {
		var parentClass = '';

		var tableGR = new GlideRecord('sys_db_object');
		if (tableGR.get('name', ciClass)) {
			parentClass = tableGR.super_class.name;
		}

		return parentClass;
	},

	map: {
		cmdb_ci: 'configuration',
		cmdb_ci_aix_server: 'connect-viewdocument',
		cmdb_ci_computer: 'hardware',
		cmdb_ci_config_file: 'document',
		cmdb_ci_database: 'database',
		cmdb_ci_hardware: 'keyboard',
		cmdb_ci_linux_server: 'sp-wishlist-sm',
		cmdb_ci_server: 'server',
		cmdb_ci_spkg: 'software',
		cmdb_ci_unix_server: 'text-underlined',
		cmdb_ci_win_server: 'vtb-freeform'
	},

	type: 'ConfigurationItemIconMap'
};

Of course, that will change the next time that I use the tool, but I like the fact that I did not end up creating any extra columns or tables to pull this off. For those of you who like to play along at home, here is an Update Set containing all of the parts and pieces.

Configuration Item Icon Assignment Widget

“You don’t have to see the whole staircase, just take the first step.”
Martin Luther King, Jr.

The main reason that I wanted to find a way to include an Icon in a pick list is because I wanted to assign different icons to various classes of Configuration Items to help visually distinguish the items based on their type. Now that I know that I can’t just add an icon to a single SELECT statement, I’m not exactly sure how I am going to that, but that’s not today’s issue. When all is said and done, I may not even be able to do what I am hoping to do, but I’m not working on that part right now. I try not to get too distracted by the things that I don’t understand or don’t know how I’m going to accomplish before it’s time. Since I can’t code everything all at once, I don’t need to solve all of the mysteries all at once, either. My theory is that I should be able to figure it out when the time comes, so there is no need to worry about it right now. To keep productive, and to maintain focus, I like to deal with things One Piece at a Time.

Today, I want to build a function that will return the appropriate icon name based on a passed configuration item class, which will give me something to call when it is time to go get the icon associated with a particular item. I want to return an icon name in all circumstances, so if the specific CI class is not mapped to an icon, then I want the function to check the parent class, and basically keep doing that until an icon name is found. If it makes it all the way to the top and there is still no icon, then there needs to be a default, because one way or another, I want to return an icon name no matter what. I will eventually stuff this function into a Script Include of some kind, but for now, I just want to code out the function. Here is what I came up with.

getIcon: function(ciClass) {
	var icon = this.map[ciClass];
	if (!icon) {
		ciClass = this.getParentClass(ciClass);
		if (ciClass) {
			icon = this.getIcon(ciClass);
		}
	}
	return icon;
},

getParentClass: function(ciClass) {
	var parentClass = '';
	var tableGR = new GlideRecord('sys_db_object');
	if (tableGR.get('name', ciClass)) {
		parentClass = tableGR.super_class.name;
	}
	return parentClass;
},

OK, it turns out that it is actually two functions, but you get the idea. This code assumes that there is a map included that associates icon names to CI classes, and that the map is keyed by CI class and returns the icon name. We don’t have to have the fully populated map right now, but to test the code, we will at least need to stub it out with a minimum of one item. That should be fairly simple to do.

map: {
	cmdb_ci: 'configuration'
},

Since the cmdb_ci class is basically the root class of all Configuration Items, defining a value for that class essentially establishes the default. As you crawl up the parentage of any specific CI class, you will eventually find your way to the cmdb_ci class, so that should satisfy my requirement that there should always be a default response from the function call.

The stubbed-out map is a good start, but I want to build up my map using some kind of tool that will allow me to select a CI class and then use my new icon picker to select an appropriate icon for the class. Something that would look like this:

CI/Icon map maintenance tool

Once you added your CI class to the list, then you could click on the little magnifying glass icon to launch our icon picker to select your icon.

Using the Icon Picker to select an icon for a CI class

What you are seeing is just a screen mock up at this point, but putting the screen together is always a good place to start. Here is the HTML that I used to produce the screen image.

<div class="panel">
<div style="width: 100%; padding: 5px 50px;">
  <h2 style="width: 100%; text-align: center;">${Configuration Item Icon Assignment}</h2>
  <table class="table table-hover table-condensed">
    <thead>
      <tr>
        <th style="text-align: center;">Item</th>
        <th style="text-align: center;">Label</th>
        <th style="text-align: center;">Icon</th>
        <th style="text-align: center;">Icon Name</th>
        <th style="text-align: center;">Delete</th>
      </tr>
    </thead>
    <tbody>
      <tr ng-repeat="item in c.data.itemArray track by item.id | orderBy: 'id'" ng-hide="item.removed">
        <td data-th="Item"><input class="form-control" ng-model="item.id" readonly="readonly"/></td>
        <td data-th="Label"><input class="form-control" ng-model="item.name" readonly="readonly"/></td>
        <td data-th="Icon" style="text-align: center;"><span style="font-size: 25px;" class="icon icon-{{item.icon}}"></span></td>
        <td data-th="Icon Name">
          <span class="input-group" style="width: 100%;">
            <input class="form-control" ng-model="item.icon" readonly="readonly"/>
            <span class="input-group-btn" ng-click="selectIcon($index)" aria-hidden="false">
              <button class="btn-ref btn btn-default">
                <span class="icon icon-search" aria-hidden="true"></span>
                <span class="sr-only ng-binding">${Select an icon}</span>
              </button>
            </span>
          </span>
        </td>
        <td data-th="Delete" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="removeItem($index)" alt="Click here to remove this item from the list" title="Click here to remove this item from the list" style="cursor: pointer;"/></td>
      </tr>
    </tbody>
  </table>
  <p>To add another Configuration Item to the list, select an item from below:</p>
  <sn-record-picker
    id="snrp"
    field="data.classToAdd"
    ng-change="addSelected()"
    table="'sys_db_object'"
    default-query="'super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^ORsuper_class.super_class.super_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54'"
    display-field="'label'"
    display-fields="'name'"
    search-fields="'label'"
    value-field="'name'">
  </sn-record-picker>
  <br/>
  <p>To remove an item from the list, click on the Delete icon.</p>
</div>

<div style="width: 100%; padding: 5px 50px; text-align: center;">
  <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
   
  <button ng-click="cancel()" class="btn ng-binding ng-scope" role="button" title="Click here to cancel your changes">Cancel</button>
</div>
</div>

For the sn-record-picker, I used my old friend, the sn-record-picker Helper, but before I got that far, I had to first work out the query. I wanted any CI table (the table name is also the class name), so I was looking for any table that was based on the cmdb_ci table. There may be an easier way to do this, but this works, so I just went with it.

super_class=72e7251abc002300aadb875973a34b54^OR
super_class.super_class=72e7251abc002300aadb875973a34b54^OR
super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^OR
super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^OR
super_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^OR
super_class.super_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54^OR
super_class.super_class.super_class.super_class.super_class.super_class.super_class=72e7251abc002300aadb875973a34b54

That’s an ugly, brute force way of doing that, but it gets the job done, which is really all that we are after at this point. The real fun will be putting all of the code behind the screen to both build up the map and then store it somewhere. That’s pretty complicated stuff, and I’m not really sure exactly how I am going to do all of that, so we’ll save that as an exercise for another day.

Hacking the REST Message API to Fetch a Remote File

“The way to get started is to quit talking and begin doing.”
Walt Disney

The other day I needed to fetch a file that was posted on another web site via its URL and process it. I have processed ServiceNow file attachments before, but I have never attempted to go out to the Internet and pull in a file using an HTTP GET of the URL. There are several ways to do that in Javascript such as XMLHttpRequest or fetch, but none of those seemed to work in server-side code in ServiceNow. But you can open up a URL using the ServiceNow RESTMessageV2 API, so I thought that maybe I would give that a shot. How hard could it be?

I decided to encapsulate everything into a Script Include, mainly so that if I ever needed to do this again, I could call the same function in some other context. My thought was to pass in the URL of the file that I wanted to fetch along with the table name and sys_id of the record to which I wanted the file to be attached, and then have the function fetch the file, attach it, and send back the attachment. Something like this:

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

	fetchFileFromUrlAndAttach: function(table, sys_id, url) {
		...
	},

	type: 'FileFetchUtils'
};

That was idea, anyway. Let’s say that I wanted to attach this file, available on the ServiceNow web site, to some Incident:

https://www.servicenow.com/content/dam/servicenow-assets/public/en-us/doc-type/success/playbook/implementation.pdf

The code to do that would look something like this:

var fileFetcher = new FileFetchUtils();
fileFetcher.fetchFileFromUrlAndAttach('incident', incSysId, 'https://www.servicenow.com/content/dam/servicenow-assets/public/en-us/doc-type/success/playbook/implementation.pdf');

All we need to do now is come up with the code needed to do all of the work of fetching the file, attaching it to the specified record, and returning the attachment. To begin, we will need to extract the name of the file from the URL. Assuming that the file name is the last component of the path on the URL, we can do that by splitting the path into its component parts and grabbing the last part.

var parts = url.split('/');
var fileName = parts[parts.length-1];

Next, we will need to create and configure the request object.

var request  = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setEndpoint(url);

The next thing to do would be to execute the request, but before we do that, we can take advantage of a nice built-in feature that will really simplify this whole operation. There is an available function of the RESTMessageV2 API that allows you to declare your intent to turn the retrieved file into an attachment, which will then handle all of the details of doing that on your behalf when the request is executed. You just need to invoke the function before you execute the request.

request.saveResponseBodyAsAttachment(table, sys_id, fileName);        
response = request.execute();

Although that really makes things super simple, it’s still a good idea to check the HTTP Response Code, just to make sure all went well. If not, it’s a good practice to relay that to the user.

if (response.getStatusCode() == '200') {
	...
} else {
	returnValue = 'Error: Invalid HTTP Response: ' + response.getStatusCode();
}

Now, assuming that things actually did go well and our new attachments was created, we still want to send that back to the calling script as a response to this function. The RESTMessageV2 API saveResponseBodyAsAttachment function does not return the attachment that is created, so we will have to use the table and sys_id to hunt it down. And if we cannot find it for any reason, we will want to report that as well.

var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', table);
attachmentGR.addQuery('table_sys_id', sys_id);
attachmentGR.orderByDesc('sys_created_on');
attachmentGR.query();
if (attachmentGR.next()) {
	returnValue = attachmentGR.getUniqueValue();
} else {
	returnValue = 'Error: Unable to fetch attachment';
}

That should now be everything that we need to fetch the file, attach it, and send back the attachment. Putting it all together, the entire Script Include looks like this:

var FileFetchUtils = Class.create();
FileFetchUtils.prototype = {
	initialize: function() {
		this.REST_MESSAGE = '19bb0cde2fedd4101a75ad2ef699b6da';
	},

	fetchFileFromUrlAndAttach: function(table, sys_id, url) {
		var returnVlaue = '';
		var parts = url.split('/');
		var fileName = parts[parts.length-1];
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setEndpoint(url);
		request.saveResponseBodyAsAttachment(table, sys_id, fileName);        
		response = request.execute();
		if (response.getStatusCode() == '200') {
			var attachmentGR = new GlideRecord('sys_attachment');
			attachmentGR.addQuery('table_name', table);
			attachmentGR.addQuery('table_sys_id', sys_id);
			attachmentGR.orderByDesc('sys_created_on');
			attachmentGR.query();
			if (attachmentGR.next()) {
				returnValue = attachmentGR.getUniqueValue();
			} else {
				returnValue = 'Error: Unable to fetch attachment';
			}
		} else {
			returnValue = 'Error: Invalid HTTP Response: ' + response.getStatusCode();
		}
		return returnValue;
	},

	type: 'FileFetchUtils'
};

Now all we need to do is find an Incident to use for testing and use the background scripts feature to give it a try. First, we’ll need to pull up an Incident and then use the Copy sys_id option of the hamburger drop-down menu to snag the sys_id of the Incident.

Grabbing the sys_id of an Incident

Now we can pop over to the background script processor and enter our code, using the sys_id that we pulled from the selected Incident.

Testing using a background script

After running the script, we can return to the Incident to verify that the file from the specified URL is now attached to the Incident.

The selected test Incident with the remote file attached

Just to make sure that all went well, you can click on the download link to pull down the attachment and look it over, verifying that it came across complete and intact. That pretty much demonstrates that it all works as we had intended. If you would like a copy to play around with on your own, you can pick it up here.

Formatted Script Search Results, Enhanced

“Delivering good software today is often better than perfect software tomorrow, so finish things and ship.”
— David Thomas, The Pragmatic Programmer: From Journeyman to Master

It always seems to happen. The more I play around with some little bauble that I’ve thrown together, the more ideas that pop into my head to make it just a little bit better. I’ve actually found my script search tool to be quite useful now that I have finally gotten it to behave the way in which I would expect it to behave. But the more that I use it, the more that I find that I want add to it.

One thing that I realized after I released the code was that you can also find script in Condition String fields. This blows my whole theory that all column types that contain script have the word script in their name. Still, I can tweak my encoded query just a bit to ensure that these columns are searched as well. I just need to switch from this:

internal_typeCONTAINSscript^active=true^name!=sn_templated_snip_note_template

… to this:

active=true^name!=sn_templated_snip_note_template^internal_typeCONTAINSscript^ORinternal_type=condition_string

Another thing that crossed my mind while I was searching for something the other day was that, in addition to scripts, I would really like to have this same capability for searching HTML. At the time, it seemed like it wouldn’t be too difficult to just clone all of the parts and convert them to a second set that was dedicated to HTML instead of Javascript. When I took a look at doing that, though, I realized that with just little bit of extra code, I could make the parts that I had work for both, and not have to create an entirely new second set.

The first thing that I tackled was my Script Include, ScriptUtils, To start out, I renamed it to SearchUtils, since it was now going to handle both searching all of the script columns and searching all of the HTML columns. Then I added a second argument to the function call called type, so that I could differentiate between script search requests and HTML search requests. The only real difference in the code between searching for script and searching for HTML is which columns on which tables will be queried, which means that the only real difference between the two operations is the query used to find the columns and tables. In the current version, that query was hard-coded, so I switched that to a variable and then set the value of the variable to the new version of my encoded query for scripts, and then overrode that value with a new query for HTML if the incoming type argument was set to ‘html’.

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

	findInScript: function(string, type) {
		var table = {};
		var found = [];
		var encodedQuery = 'active=true^name!=sn_templated_snip_note_template^internal_typeCONTAINSscript^ORinternal_type=condition_string';
		if (type == 'html') {
			encodedQuery = 'active=true^name!=sn_templated_snip_note_template^internal_typeCONTAINShtml';
		}
		var columnGR = new GlideRecord('sys_dictionary');
		columnGR.addEncodedQuery(encodedQuery);
...

Beyond that point, everything else remains the same as it was in the earlier version. That takes care of the Script Include. Now we have to tackle the associated Widget. Here we can use the same basic philosophy, defaulting everything to a script search, and then overriding that default if an HTML search is requested. For a Widget, that request can come in the form of an additional URL parameter that we can also call type. We can establish the default values based on a script search, and then if the URL parameter type is equal to ‘html’, we can then change to the values for an HTML search.

(function() {
	data.type = 'script';
	data.title = 'Script Search';
	if ($sp.getParameter('type') && $sp.getParameter('type') == 'html') {
		data.type = 'html';
		data.title = 'HTML Search';
	}
	data.loading = false;
	data.searchFor = '';
	data.result = false;
	if ($sp.getParameter('search')) {
		data.loading = true;
		data.searchFor = $sp.getParameter('search');
		data.result = new SearchUtils().findInScript(data.searchFor, data.type);
		data.loading = false;
	}
})();

That was all there was to adding an HTML version of the script searching capability. To get to it, I pulled my Search Script menu item up in edit mode by clicking on the little pencil to the right of the title, made a few minor modifications and then selected Insert from the context menu to create a new menu item.

Cloning the Search Script menu item to create the Search HTML menu item

With the new menu item added to the sidebar, the only thing left to do was to click on the item and give it a go.

New HTML Search function in action

Well, that didn’t turn out too bad. I should have changed the name of the button to something that didn’t have the word script in it, but other than that one little nit, it all seems to work. I’ll fix up that button one day, but for now, here is an Update Set that includes all of the parts for the current version.

I know it’s in here somewhere …

“Three can keep a secret, if two of them are dead.”
Benjamin Franklin

Every once in a while I am chasing some issue or tying to remember how I did something and I know the answer is in the system somewhere, but I’m just not sure where. Since I mainly deal with Javascript code, what I would really like is a way to search all of the places in which script might be stored looking for some term or phrase. And I mean all of the places, including places that might come up later in future versions. What I really want to do is dynamically look at all of the tables and find all of the columns that might store script and then search all of those columns in all of those tables. Fortunately, because of the way in which the Now Platform is constructed, you can easily do exactly that.

The sys_dictionary table holds all of the information on all of the columns in all of the tables, including the column type. There are several ServiceNow column types that might contain script, but fortunately for us, they all contain the word script in their names. That makes it relatively easy to search the dictionary for all of the script columns in all of the tables:

var table = {};
var columnGR = new GlideRecord('sys_dictionary');
columnGR.addEncodedQuery('internal_typeCONTAINSscript^active=true');
columnGR.query();
while (columnGR.next()) {
	var tableName = columnGR.getDisplayValue('name');
	var fieldName = columnGR.getDisplayValue('element');
	if (tableName && fieldName && !tableName.startsWith('var__m_')) {
		if (!table[tableName]) {
			table[tableName] = [];
		}
		table[tableName].push(fieldName);
	}
}

This script builds a map keyed by table name that contains an array of script columns for each table. Before I add a column to the map, I make sure that there is both a field name and a table name, and I also filter out all of the variable mapping tables, as those don’t contain any scripts and some of them actually cause problems when I attempt to use them. Once we have established our target map, it is simply a matter of stepping through it and querying each table for the presence of your search argument in any of those columns:

var found = [];
for (tableName in table) {
	var query = '';
	var separator = '';
	for (var i=0; i<table[tableName].length; i++) {
		query += separator;
		query += table[tableName][i];
		query += 'CONTAINS';
		query += string;
		separator = 'OR';
	}
	var scriptGR = new GlideRecord(tableName);
	scriptGR.addEncodedQuery(query);
	scriptGR.query();
	while (scriptGR.next()) {
		found.push({table: tableName,  tableName: scriptGR.getLabel(), sys_id: scriptGR.getUniqueValue(), name: scriptGR.getDisplayValue('name') || scriptGR.getDisplayValue() || scriptGR.getUniqueValue()});
	}
}
return found;

This portion of the script builds an array of objects containing the table, table name, name, and sys_id of any records found during the search. I bundled the whole thing into a Script Include that I called ScriptUtils, and you can see the entire thing here:

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

	findInScript: function(string) {
		var table = {};
		var found = [];
		var columnGR = new GlideRecord('sys_dictionary');
		columnGR.addEncodedQuery('internal_typeCONTAINSscript^active=true');
		columnGR.query();
		while (columnGR.next()) {
			var tableName = columnGR.getDisplayValue('name');
			var fieldName = columnGR.getDisplayValue('element');
			if (tableName && fieldName && !tableName.startsWith('var__m_')) {
				if (!table[tableName]) {
					table[tableName] = [];
				}
				table[tableName].push(fieldName);
			}
		}
		for (tableName in table) {
			var query = '';
			var separator = '';
			for (var i=0; i<table[tableName].length; i++) {
				query += separator;
				query += table[tableName][i];
				query += 'CONTAINS';
				query += string;
				separator = 'OR';
			}
			var scriptGR = new GlideRecord(tableName);
			scriptGR.addEncodedQuery(query);
			scriptGR.query();
			while (scriptGR.next()) {
				found.push({table: tableName,  tableName: scriptGR.getLabel(), sys_id: scriptGR.getUniqueValue(), name: scriptGR.getDisplayValue('name') || scriptGR.getDisplayValue() || scriptGR.getUniqueValue()});
			}
		}
		return found;
	},

    type: 'ScriptUtils'
};

To test is out, we can can select Scripts – Background from the sidebar menu and enter something like this:

gs.info(JSON.stringify(new ScriptUtils().findInScript('xxx'), null, 4));

After running the above script, I received the following output:

[
    {
        "table": "discovery_proc_handler",
        "tableName": "Process Handler",
        "sys_id": "1f2473269733200010cb1bd74b297576",
        "name": "Java parameters"
    },
    {
        "table": "sys_script",
        "tableName": "Business Rule",
        "sys_id": "4532f571bf320100710071a7bf073929",
        "name": "Obfuscate password"
    },
    {
        "table": "sys_script_execution_history",
        "tableName": "Script Execution History",
        "sys_id": "807208e92ffb48d0ddadfe7cf699b696",
        "name": "Created 2020-03-26 05:55:27"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "326b53699f3010008f88ed93ee4bcc2b",
        "name": "ScrumSecurityManagerDefault"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "3f7d6f17537103003248cfa018dc347c",
        "name": "PwdResetPageInfo"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "57042a36932012001aa1f4b8b67ffb95",
        "name": "TourBuilderUtility"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "88c548dc37010100dcd48c00dfbe5d2e",
        "name": "SnmpIdentityInfoParser"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "8dbee16b530203003248cfa018dc349e",
        "name": "PwdResetPageInfo_V2"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "91a92c70733023008b516cb63cf6a79e",
        "name": "CommunityCacheUtilSNCJSC"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "a7ac57b7c710320003fa9c569b976312",
        "name": "MIDUserConnectivity"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "c54c989f37612100dcd48c00dfbe5df4",
        "name": "CiSchema"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "ee735ba66713220089ec9a6617415a75",
        "name": "CommunityForumImpl"
    },
    {
        "table": "sys_script_include",
        "tableName": "Script Include",
        "sys_id": "eee51271eb223100c46ac2eef106fed4",
        "name": "AddScriptedRESTVersionAjax"
    }
]

Now, there are obviously better ways to format and display that information, but that would be an entire project on its own, so we’ll just save that exercise for another time.

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!

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