Collaboration Store, Part LXX

“Software bugs are like cockroaches; there are probably dozens hiding in difficult to reach places for every one you find and fix.”
Donald G. Firesmith

Last time, we went through the list of issues that have been reported so far, the biggest one being the fact that the REST API call to the Host instance is sending over the application logo image attachment instead of the Update Set XML file attachment. Since then, we have received some additional information in the form of the data logged to the REST API log file. Here is the entry of interest:

{
	“size_bytes”: “547670”,
	“file_name”: “logo”,
	“sys_mod_count”: “0”,
	“average_image_color”: “”,
	“image_width”: “”,
	“sys_updated_on”: “2022-08-02 16:55:55”,
	“sys_tags”: “”,
	“table_name”: “x_11556_col_store_member_application_version”,
	“sys_id”: “c227acc297855110b40ebde3f153aff3”,
	“image_height”: “”,
	“sys_updated_by”: “csworker1.dev69362”,
	“download_link”: “https://dev69362.service-now.com/api/now/attachment/c227acc297855110b40ebde3f153aff3/file”,
	“content_type”: “image/jpeg”,
	“sys_created_on”: “2022-08-02 16:55:55”,
	“size_compressed”: “247152”,
	“compressed”: “true”,
	“state”: “pending”,
	“table_sys_id”: “b127a88297855110b40ebde3f153afa6”,
	“chunk_size_bytes”: “700000”,
	“hash”: “8b5a07a6c0edf042df4b3c24e729036562985b705427ba7e33768566de94e96f”,
	“sys_created_by”: “csworker1.dev69362”
}

If you look at the table_name property, you can see that it is attaching something to the version record, and if you look at the file_name and content_type properties, you can see that it isn’t the Update Set XML file that it is sending over. So let’s take a look at the shared code that sends over the Update Set XML file attachment and see if we can see where things may have gone wrong.

pushAttachment: function(attachmentGR, targetGR, remoteVerId) {
	var result = {};

	var gsa = new GlideSysAttachment();
	result.url = 'https://';
	result.url += targetGR.getDisplayValue('instance');
	result.url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
	result.url += remoteVerId;
	result.url += '&file_name=';
	result.url += attachmentGR.getDisplayValue('file_name');
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', attachmentGR.getDisplayValue('content_type'));
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(gsa.getContent(attachmentGR));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}
	this.logRESTCall(targetGR, result);

	return result;
}

By this point in the process, the GlideRecord for the attachment has already been obtained from the database, so the problem has to be upstream from here. This is a shared function called from many places, but our problem is related to the application publishing process, so let’s take a look at the ApplicationPublisher Script Include and see if we can find where this function is called.

processPhase7: function(answer) {
	var gsa = new GlideSysAttachment();
	var attachmentGR = new GlideRecord('sys_attachment');
	if (attachmentGR.get(answer.attachmentId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		var result = csu.pushAttachment(attachmentGR, targetGR, answer.hostVerId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostVerId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid attachment record sys_id: ' + answer.attachmentId);
	}

	return answer;
}

Here we are fetching the attachment record based on the sys_id in the answer object property called attachmentId. There isn’t much opportunity for things to go tango uniform with this particular code, so I think we have to assume that somewhere upstream of this logic the value of answer.attachmentId got set to the sys_id of the logo attachment instead of the sys_id of the Update Set XML file attachment. So it looks like we need to do a quick search for answer.attachmentId and see where this property may have gotten corrupted.

Since the version record does not yet exist when the Update Set XML file is generated, it is initially attached to the stock application record. Then, once the version record has been created, the attachment is copied from the application record to the version record, and then the original attachment file is removed from the stock application record. All of that seems to work, since the Update Set XML file is, in fact, attached to the version record on the original source instance; however, somewhere along the line, the sys_id of that attachment record in the answer object ends up being the sys_id of the logo image attachment record. Let’s take a look at that code.

processPhase4: function(answer) {
	var gsa = new GlideSysAttachment();
	var values = gsa.copy('sys_app', answer.appSysId, 'x_11556_col_store_member_application_version', answer.versionId);
	gsa.deleteAttachment(answer.attachmentId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			answer.attachmentId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' + JSON.stringify(values));
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' +  JSON.stringify(values));
	}

	return answer;
}

This has to be the source of the problem. The copy method the GlideSysAttachment object doesn’t allow you to select what to copy; it arbitrarily copies all attachments from one record to another and returns an array of sys_id pairs (before and after for each attachment). The code above assumed that the last pair contained the sys_id that we were looking for, but apparently, that is not always the case. It looks like we need to examine every sys_id pair in the array, select the one that contains the XML file, grab that sys_id, and then delete all of the other attachments from the version record. That would mean replacing this:

var ids = values[values.length - 1].split(',');
if (ids[1]) {
	answer.attachmentId = ids[1];
}

… with this:

var origId = answer.attachmentId;
for (var i=0; i<values.length; i++) {
	var ids = values[i].split(',');
	if (ids[0] == origId) {
		answer.attachmentId = ids[1];
		gsa.deleteAttachment(origId);
	} else {
		gsa.deleteAttachment(ids[1]);
	}
}

Basically, this code loops through all of the sys_id pairs, looks for the one where the first sys_id matches the original, grabs the second sys_id of that pair for the new answer.attachmentId value, and then deletes the original attachment record. When the first sys_id does not match, then it deletes the copied attachment from the version record, as we did not want to copy that one anyway. We will have to do a little testing to prove this out, but hopefully this will resolve this issue.

Next time, we should have a new Update Set available with this, and a few other, minor corrections in it, and then we can do a little retesting and see if that resolves a few of these issues. As always, if anyone finds anything else that we need to address, please leave the details in the comments section below. All feedback is heartily welcomed!

Scripted Value Columns, Part IV

“Where you’re headed is more important than how fast you’re going.”
Stephen Covey

Last time, we modified the core SNH Data Table widget to process the new configuration properties for scripted value columns. Before we can try it out, though, we need to update one or more of the three wrapper widgets, since no one uses the core widget directly. Probably the simplest to take on would be the SNH Data Table from JSON Configuration widget, the one that was added to process a configuration script directly. As we did with the editor and the core data table widget, we can scan the code for the aggarray and then copy any code needed as a starting point for our modifications. The only reference that appears in this widget is in the Server script, and only on a single line:

data.aggarray = data.tableData.aggarray;

We can replicate that line, and then modify the copy for our new array of scripted value columns.

data.svcarray = data.tableData.svcarray;
data.aggarray = data.tableData.aggarray;

And that’s the only thing that needs to be done to update this widget to support the new feature. Now we can build a page to try things out and see if it all works. Or better yet, maybe there is already a page out there that we can use for this quick test. Down at the bottom of the widget form there is a list of pages that already use this widget. Maybe we can tinker with one of those, just to give this a quick look.

Related list of portal pages that include the SNH Data Table from JSON Configuration widget

The table_from_json page looks like a prime candidate. All we need to do is to pull it up in the Portal Page Designer, point the configuration script option to the script that we modified earlier, and then give the page a try.

First test of the new scripted value column using random numbers as values

So, there is the “test” column that we added, filled with random numbers from our new ScriptedValueExample Script Include. This test demonstrates that our modified wrapper widget successfully passed the data from our recently edited configuration script to the core data table widget, and the core data table widget successfully handled the new configuration option and obtained the value for the new column from the specified Script Include. Sweet! Now we need to come up with some real world examples of how this feature might be employed for specific purposes, and also update the remaining wrapper widgets to accommodate this new feature. That all sounds like a good topic for our next installment.

Collaboration Store, Part LXIX

“We all need people who will give us feedback. That’s how we improve.”
Bill Gates

Last time, we released a new batch of Update Sets for the latest iteration of this effort and put out a plea for folks to take it all out for a spin. We got quite a lot of good, detailed feedback this time (Thanks, Joe!), so let’s make a quick list of everything that has been reported so far.

  • Preview errors during install
  • Application publishing failed during logo image copy
  • Application publishing failed after logo image removal
  • Application publishing failed due to Host instance being off line
  • Application publishing succeeded with new logo image, but on Host instance, logo image was attached to the version record instead of the Update Set XML file

None of these are good, but let’s take a look at them one at a time.

Preview errors during install

This one, I am able to duplicate. I also received 20 Preview errors when installing the Update Set on a new instance. Every one of the errors is basically the same.

Preview errors from initial install

Every one of the 20 errors contains the same message text.

Could not find a record in sys_hub_flow_base for column model referenced in this update

Searching for that message, I came across this:

https://community.servicenow.com/community?id=community_question&sys_id=82095744db9c70d0fb1e0b55ca9619b2

The accepted answer seems to be that this error message comes out because the Flow that you are trying to install is not present on the target instance. Well, that’s understandable, since you haven’t committed the Update Set just yet, but it doesn’t seem to me that that should be considered an error. Everyone’s answer is just to accept the remote update, but if you are shooting for a clean install, it doesn’t really look good to have these errors pop up for no reason. I looked for a way to suppress them or eliminate them, but so far I have not found anything of value. So it looks like you just accept them and continue, which is what I suggested when I first put this out there to install, but I don’t really like it. Maybe one day I will find a way to keep these messages from coming out, but for now, this is just the way that it is.

Application publishing failed during logo image copy

This one I have not been able to duplicate, which is unfortunate, because I would like to resolve it, and resolve it in a way that I can prove by running tests before and after the fix. In all of my testing, I have never had an image copy fail, so I am not sure how to proceed. However, it does occur to me that a failed logo image copy should not kill the entire process. Yes, it would be good to have the image along with the rest of the artifacts, but if that is the only issue, it seems to me that the rest of the publishing process should proceed. Here is the copy image function as it stands in version 0.7:

copyLogoImage: function(answer) {
	var logoId = '';

	var gsa = new GlideSysAttachment();
	var values = gsa.copy('ZZ_YYsys_app', answer.appSysId, 'ZZ_YYx_11556_col_store_member_application', answer.mbrAppId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			logoId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' +  JSON.stringify(values));
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' +  JSON.stringify(values));
	}

	return logoId;
}

The processError function that is called when things go South logs the details of the error, displays a message, and then adds an error property to the answer object. I think if I remove the error property from the answer object, then the publication process will not stop at this point and everything will continue as if there was no image associated with the application. This seems like the preferable approach, at least to me. Maybe something like this:

copyLogoImage: function(answer) {
	var logoId = '';

	var gsa = new GlideSysAttachment();
	var values = gsa.copy('ZZ_YYsys_app', answer.appSysId, 'ZZ_YYx_11556_col_store_member_application', answer.mbrAppId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			logoId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' +  JSON.stringify(values));
			delete answer.error;
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' +  JSON.stringify(values));
		delete answer.error;
	}

	return logoId;
}

That still doesn’t explain why this particular image could not be copied, but at least it would allow the publishing of the application to continue.

Application publishing failed after logo image removal

This is another one that I cannot seem to duplicate. The code related to an application image is fairly straightforward: if the app has an image and the store record does not, then it copies it over; otherwise, it does not do anything at all. If the app had no image, then if the publishing failed, it must have failed somewhere else, as the image copy function should not have even been invoked. Here is the relevant section of code:

if (sysAppGR.getValue('logo') && !mbrAppGR.getValue('logo')) {
	mbrAppGR.setValue('logo', this.copyLogoImage(answer));
}

If the app had no logo image, then nothing should have happened. I will have to look into this one a little deeper any maybe ask for a little more information before I understand what happened on this one.

Application publishing failed due to Host instance being off line

This is not actually a problem with the app, as there is no way to publish an application to a Host that is not up and running. but it does bring up an interesting question: should we check to see if the Host is available before we launch the process? That would at least prevent someone from going through half of the process only to have it die when it tries to move the artifacts over to the Host. We already have a getStoreInfo function that would tell us if the Host was available or not, so it wouldn’t take much to add a quick check before we launched the publishing process, and then inform the operator if things were not going to work out.

Application publishing succeeded with new logo image, but on Host instance, logo image was attached to the version record instead of the Update Set XML file

I have not found the source of this one just yet, but it appears to me that one or more sys_id values got passed to the wrong function or written to the wrong variable. Since everything turned out OK on the original Client, but ended up in the wrong place on the Host, the problem has to be in the REST API calls made from the Client to the Host. There are three calls that move attachments, one for the instance logo image, one for the application logo image, and one for the Update Set XML file attached to the version record. Either the logo image API call attached the logo to the wrong base record or the Update Set XML file call sent over the wrong attachment. A review of the relevant REST API call log records might reveal which one caused the problem, but I will dig through the code for both and see if I can understand how this might have happened. Obviously, you cannot install the app if you don’t have the Update Set XML file attached to the version record. This one definitely has to be fixed.

This was all great feedback, and very detailed, including copies of log file entries. That is very helpful in diagnosing these issue. If anyone else is having similar issues, please report them as well, and include as much information as you feel would be appropriate. And if someone has pulled this down and was able to run things without running into these issues, I would love to hear about that as well. As always, all feedback is welcome, positive, negative, or otherwise.

And Joe, if you are still willing to do a little more testing, try to publish a different app from your other Client, and see if you run into any similar issues with that. If you can find a fourth instance to join your trio, you might have the owner of that instance give this a shot as well. And thanks again for your assistance. It is very much appreciated. Thanks to all of you for helping to make this work the way that it should. I look forward to hearing more from anyone willing to give this all a try. Next time, we will take a look at any additional feedback, as well as any modifications that have been implemented as a result of the feedback that we have received thus far.

Scripted Value Columns, Part III

“Every day you may make progress. Every step may be fruitful. Yet there will stretch out before you an ever-lengthening, ever-ascending, ever-improving path.”
Winston Churchill

Last time, we took care of the configuration script editor and now we need to turn our attention to the main SNH Data Table Widgets starting with the core widget, SNH Data Table. As we did with the editor, we can search the various sections of the widget for aggarray, copy the relevant code, and modify it to handle the new svcarrary. As usual, we can start with the HTML, where we find a couple of sections, one for the headings:

<th ng-repeat="scripted in data.svcarray" class="text-nowrap center" tabindex="0">
  {{scripted.heading || scripted.label}}
</th>

… and one for the data columns:

<td ng-repeat="obj in item.svcValue" role="cell" class="sp-list-cell" ng-class="{selected: item.selected}" tabindex="0">
  {{obj.value}}
</td>

That takes care of the HTML. Now we need to take a look at the Server script. The first thing that we come across is this added block of comments:

// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements

So we will modify that to include two new properties for our new feature.

// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.scripteds = the JSON string containing the scripted value column specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.svcarray = the array of scripted value column specifications
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements

The next reference to aggarray is this added variable copy statement:

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	...
// End: SNH Data Table enhancements

So we will add our new variables to this list.

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'scripteds', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'svcarray', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	...
// End: SNH Data Table enhancements

Shortly after that, we come to this code that validates and initializes the aggarray value.

if (data.aggregates) {
	try {
		var aggregateinfo = JSON.parse(data.aggregates);
		if (Array.isArray(aggregateinfo)) {
			data.aggarray = aggregateinfo;
		} else if (typeof aggregateinfo == 'object') {
			data.aggarray = [];
			data.aggarray[0] = aggregateinfo;
		} else {
			gs.error('Invalid aggregates option in SNH Data Table widget: ' + data.aggregates);
			data.aggarray = [];
		}
	} catch (e) {
		gs.error('Unparsable aggregates option in SNH Data Table widget: ' + data.aggregates);
		data.aggarray = [];
	}
} else {
	if (!data.aggarray) {
		data.aggarray = [];
	}
}

So we can copy that, and add a section just like it for the new svcarray.

if (data.scripteds) {
	try {
		var scriptedinfo = JSON.parse(data.scripteds);
		if (Array.isArray(scriptedinfo)) {
			data.svcarray = scriptedinfo;
		} else if (typeof scriptedinfo == 'object') {
			data.svcarray = [];
			data.svcarray[0] = scriptedinfo;
		} else {
			gs.error('Invalid scripteds option in SNH Data Table widget: ' + data.scripteds);
			data.svcarray = [];
		}
	} catch (e) {
		gs.error('Unparsable scripteds option in SNH Data Table widget: ' + data.scripteds);
		data.svcarray = [];
	}
} else {
	if (!data.svcarray) {
		data.svcarray = [];
	}
}

The next reference that we find is the code that actually adds the values to the records. For the aggregate columns, that code looks like this:

record.aggValue = [];
if (data.aggarray.length > 0) {
	for (var j=0; j<data.aggarray.length; j++) {
		var config = data.aggarray[j];
		var sysId = record.sys_id;
		if (config.source) {
			sysId = gr.getValue(config.source);
		}
		record.aggValue.push(getAggregateValue(sysId, config));
	}
}

We can make a copy of this section as well, but since the scripted values do not require a source property, our new section will be even simpler.

record.svcValue = [];
if (data.svcarray.length > 0) {
	for (var j=0; j<data.svcarray.length; j++) {
		record.svcValue.push(getScriptedValue(record, data.svcarray[j]));
	}
}

Of course, now we have referenced a function that doesn’t yet exist, but that is in fact the next and last reference that we come across. The function for the aggregates looks like this:

// Start: SNH Data Table enhancements
	function getAggregateValue(sys_id, config) {
		var value = 0;
		var ga = new GlideAggregate(config.table);
		ga.addAggregate('COUNT');
		var query = config.field + '=' + sys_id;
		if (config.filter) {
			query += '^' + config.filter;
		}
		ga.addEncodedQuery(query);
		ga.query();
		if (ga.next()) {
			value = parseInt(ga.getAggregate('COUNT'));
		}
		var response = {value: value};
		if (config.hint || config.page_id) {
			response.name = config.name;
		}
		return response;
	}
// End: SNH Data Table enhancements

Here is where we have to do something completely different from the original that we are copying. For the aggregate columns, we are actually doing the query to count the related records. For our new purpose, we are just going to grab an instance of the specified script and call the function on the script to get the value. Since we will be calling the same script for every row, it would be better to fetch the instance of the script once and hang on to it so that the same instance could be used again and again. To support that, we can establish a map of instances and an instance of the Instantiator up near the top.

var instantiator = new Instantiator(this);
var scriptMap = {};

With that in place, we can add the following new function to support the new scripted value columns.

function getScriptedValue(record, config) {
	var response = {value: ''};
	var scriptName = config.script;
	if (scriptName) {
		if (scriptName.startsWith('global.')) {
			scriptName = scriptName.split('.')[1];
		}
		if (!scriptMap[scriptName]) {
			scriptMap[scriptName] = instantiator.getInstance(scriptName);
		}
		if (scriptMap[scriptName]) {
			response.value = scriptMap[scriptName].getScriptedValue(record, config);
		}
	}
	return response;
}

That’s it for the Server script. The whole thing now looks like this:

(function() {
	if (!input) // asynch load list
		return;

	data.msg = {};
	data.msg.sortingByAsc = gs.getMessage("Sorting by ascending");
	data.msg.sortingByDesc = gs.getMessage("Sorting by descending");

	/*
	 * data.table = the table
	 * data.p = the current page starting at 1
	 * data.o = the order by column
	 * data.d = the order by direction
	 * data.keywords = the keyword search term
	 * data.list = the table data as an array
	 * data.invalid_table = true if table is invalid or if data was not succesfully fetched
	 * data.table_label = the table's display name. e.g. Incident
	 * data.table_plural = the table's plural display name. e.g. Incidents
	 * data.fields = a comma delimited list of field names to show in the data table
	 * data.column_labels = a map of field name -> display name
	 * data.window_size = the number of rows to show
	 * data.filter = the encoded query
// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.scripteds = the JSON string containing the scripted value column specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.svcarray = the array of scripted value column specifications
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements
	 */
	// copy to data[name] from input[name] || option[name]
	optCopy(['table', 'p', 'o', 'd', 'filter', 'filterACLs', 'fields', 'keywords', 'view']);
	optCopy(['relationship_id', 'apply_to', 'apply_to_sys_id', 'window_size']);

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'scripteds', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'svcarray', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	// for some reason, 'buttons' and 'table' sometimes get lost in translation ...
	if (data.btns) {
		data.buttons = data.btns;
	}
	if (data.table_name) {
		data.table = data.table_name;
	}
// End: SNH Data Table enhancements

	if (!data.table) {
		data.invalid_table = true;
		data.table_label = "";
		return;
	}

// Start: SNH Data Table enhancements
	var instantiator = new Instantiator(this);
	var scriptMap = {};
	if (data.scripteds) {
		try {
			var scriptedinfo = JSON.parse(data.scripteds);
			if (Array.isArray(scriptedinfo)) {
				data.svcarray = scriptedinfo;
			} else if (typeof scriptedinfo == 'object') {
				data.svcarray = [];
				data.svcarray[0] = scriptedinfo;
			} else {
				gs.error('Invalid scripteds option in SNH Data Table widget: ' + data.scripteds);
				data.svcarray = [];
			}
		} catch (e) {
			gs.error('Unparsable scripteds option in SNH Data Table widget: ' + data.scripteds);
			data.svcarray = [];
		}
	} else {
		if (!data.svcarray) {
			data.svcarray = [];
		}
	}

	if (data.aggregates) {
		try {
			var aggregateinfo = JSON.parse(data.aggregates);
			if (Array.isArray(aggregateinfo)) {
				data.aggarray = aggregateinfo;
			} else if (typeof aggregateinfo == 'object') {
				data.aggarray = [];
				data.aggarray[0] = aggregateinfo;
			} else {
				gs.error('Invalid aggregates option in SNH Data Table widget: ' + data.aggregates);
				data.aggarray = [];
			}
		} catch (e) {
			gs.error('Unparsable aggregates option in SNH Data Table widget: ' + data.aggregates);
			data.aggarray = [];
		}
	} else {
		if (!data.aggarray) {
			data.aggarray = [];
		}
	}

	if (data.buttons) {
		try {
			var buttoninfo = JSON.parse(data.buttons);
			if (Array.isArray(buttoninfo)) {
				data.btnarray = buttoninfo;
			} else if (typeof buttoninfo == 'object') {
				data.btnarray = [];
				data.btnarray[0] = buttoninfo;
			} else {
				gs.error('Invalid buttons option in SNH Data Table widget: ' + data.buttons);
				data.btnarray = [];
			}
		} catch (e) {
			gs.error('Unparsable buttons option in SNH Data Table widget: ' + data.buttons);
			data.btnarray = [];
		}
	} else {
		if (!data.btnarray) {
			data.btnarray = [];
		}
	}

	if (data.refpage) {
		try {
			var refinfo = JSON.parse(data.refpage);
			if (typeof refinfo == 'object') {
				data.refmap = refinfo;
			} else {
				gs.error('Invalid reference page option in SNH Data Table widget: ' + data.refpage);
				data.refmap = {};
			}
		} catch (e) {
			gs.error('Unparsable reference page option in SNH Data Table widget: ' + data.refpage);
			data.refmap = {};
		}
	} else {
		if (!data.refmap) {
			data.refmap = {};
		}
	}

	if (data.bulkactions) {
		try {
			var actioninfo = JSON.parse(data.bulkactions);
			if (Array.isArray(actioninfo)) {
				data.actarray = actioninfo;
			} else if (typeof actioninfo == 'object') {
				data.actarray = [];
				data.actarray[0] = actioninfo;
			} else {
				gs.error('Invalid bulk actions in SNH Data Table widget: ' + data.bulkactions);
				data.actarray = [];
			}
		} catch (e) {
			gs.error('Unparsable bulk actions in SNH Data Table widget: ' + data.bulkactions);
			data.actarray = [];
		}
	} else {
		if (!data.actarray) {
			data.actarray = [];
		}
	}

	if (!data.fields) {
		if (data.field_list) {
			data.fields = data.field_list;
		} else if (data.view) {
			data.fields = $sp.getListColumns(data.table, data.view);
		} else {
			data.fields = $sp.getListColumns(data.table);
		}
	}
// End: SNH Data Table enhancements

	data.view = data.view || 'mobile';
	data.table = data.table || $sp.getValue('table');
	data.filter = data.filter || $sp.getValue('filter');
	data.keywords = data.keywords || $sp.getValue('keywords');
	data.p = data.p || $sp.getValue('p') || 1;
	data.p = parseInt(data.p);
	data.o = data.o || $sp.getValue('o') || $sp.getValue('order_by');
	data.d = data.d || $sp.getValue('d') || $sp.getValue('order_direction') || 'asc';
	data.useTinyUrl = gs.getProperty('glide.use_tiny_urls') === 'true';
	data.tinyUrlMinLength = gs.getProperty('glide.tiny_url_min_length');

// Start: SNH Data Table enhancements
	if (data.filter && data.filter.indexOf('{{sys_id}}')) {
		data.filter = data.filter.replace('{{sys_id}}', $sp.getParameter('sys_id'));
	}
// End: SNH Data Table enhancements


	var grForMetaData = new GlideRecord(data.table);

	if (input.setOrderUserPreferences) {
		// update User Preferences on a manual sort for UI consistency
		gs.getUser().savePreference(data.table + ".db.order", data.o);
		gs.getUser().savePreference(data.table + ".db.order.direction", data.d == "asc" ? "" : "DESC");
		data.setOrderUserPreferences = false;
	}
	// if no sort specified, find a default column for UI consistency
	if (!data.o)
		getOrderColumn();

	data.page_index = data.p - 1;
	data.show_new = data.show_new || options.show_new;
	var windowSize = data.window_size || $sp.getValue('maximum_entries') || 20;
	windowSize = parseInt(windowSize);
	if (isNaN(windowSize) || windowSize < 1)
		windowSize = 20;
	data.window_size = windowSize;

	var gr;
	// FilteredGlideRecord is not supported in scoped apps, so GlideRecordSecure will always be used in an application scope
	if (typeof FilteredGlideRecord != "undefined" && (gs.getProperty("glide.security.ui.filter") == "true" || grForMetaData.getAttribute("glide.security.ui.filter") != null)) {
		gr = new FilteredGlideRecord(data.table);
		gr.applyRowSecurity();
	} else
		gr = new GlideRecordSecure(data.table);
	if (!gr.isValid()) {
		data.invalid_table = true;
		data.table_label = data.table;
		return;
	}

	data.canCreate = gr.canCreate();
	data.newButtonUnsupported = data.table == "sys_attachment";
	data.table_label = gr.getLabel();
	data.table_plural = gr.getPlural();
	data.title = input.useInstanceTitle && input.headerTitle ? gs.getMessage(input.headerTitle) : data.table_plural;
	data.hasTextIndex = $sp.hasTextIndex(data.table);
	if (data.filter) {
		if (data.filterACLs)
			gr = $sp.addQueryString(gr, data.filter);
		else
			gr.addEncodedQuery(data.filter);
	}
	if (data.keywords) {
		gr.addQuery('123TEXTQUERY321', data.keywords);
		data.keywords = null;
	}

	data.filter = gr.getEncodedQuery();

	if (data.relationship_id) {
		var rel = GlideRelationship.get(data.relationship_id);
		var target = new GlideRecord(data.table);
		var applyTo = new GlideRecord(data.apply_to);
		applyTo.get("sys_id", data.apply_to_sys_id);
		rel.queryWith(applyTo, target); // put the relationship query into target
		data.exportQuery = target.getEncodedQuery();
		gr.addEncodedQuery(data.exportQuery); // get the query the relationship made for us
	}
	if (data.exportQuery)
		data.exportQuery += '^' + data.filter;
	else
		data.exportQuery = data.filter;
	data.exportQueryEncoded = encodeURIComponent(data.exportQuery);
	if (data.o){
		if (data.d == "asc")
			gr.orderBy(data.o);
		else
			gr.orderByDesc(data.o);
		if (gs.getProperty("glide.secondary.query.sysid") == "true")
			gr.orderBy("sys_id");
	}

	data.window_start = data.page_index * data.window_size;
	data.window_end = (data.page_index + 1) * data.window_size;
	gr.chooseWindow(data.window_start, data.window_end);
	gr.setCategory("service_portal_list");
	gr._query();

	data.row_count = gr.getRowCount();
	data.num_pages = Math.ceil(data.row_count / data.window_size);
	data.column_labels = {};
	data.column_types = {};
	data.fields_array = data.fields.split(',');

	// use GlideRecord to get field labels vs. GlideRecordSecure
	for (var i in data.fields_array) {
		var field = data.fields_array[i];
		var ge = grForMetaData.getElement(field);
		if (ge == null)
			continue;

		data.column_labels[field] = ge.getLabel();
		data.column_types[field] = ge.getED().getInternalType();
	}

	data.list = [];
	while (gr._next()) {
		var record = {};
		$sp.getRecordElements(record, gr, data.fields);
		if (typeof FilteredGlideRecord != "undefined" && gr instanceof FilteredGlideRecord) {
			// FilteredGlideRecord doesn't do field-level
			// security, so take care of that here
			for (var f in data.fields_array) {
				var fld = data.fields_array[f];
				if (!gr.isValidField(fld))
					continue;

				if (!gr[fld].canRead()) {
					record[fld].value = null;
					record[fld].display_value = null;
				}
			}
		}
		record.sys_id = gr.getValue('sys_id');

// Start: SNH Data Table enhancements
		for (var f in data.fields_array) {
			var fld = data.fields_array[f];
			if (record[fld].type == 'reference') {
				var refGr = gr;
				var refFld = fld;
				if (fld.indexOf('.') != -1) {
					var parts = fld.split('.');
					for (var x=0;x<parts.length-1;x++) {
						refGr = refGr[parts[x]].getRefRecord();
					}
					refFld = parts[parts.length-1];
				}
				if (refGr.isValidField(refFld)) {
					record[fld].table = refGr.getElement(refFld).getED().getReference();
					record[fld].record = {type: 'reference', sys_id: {value: record[fld].value, display_value: record[fld].value}, name: {value: record[fld].display_value, display_value: record[fld].display_value}};
				}
			}
		}
		record.svcValue = [];
		if (data.svcarray.length > 0) {
			for (var j=0; j<data.svcarray.length; j++) {
				record.svcValue.push(getScriptedValue(record, data.svcarray[j]));
			}
		}
		record.aggValue = [];
		if (data.aggarray.length > 0) {
			for (var k=0; k<data.aggarray.length; k++) {
				var config = data.aggarray[k];
				var sysId = record.sys_id;
				if (config.source) {
					sysId = gr.getValue(config.source);
				}
				record.aggValue.push(getAggregateValue(sysId, config));
			}
		}
// End: SNH Data Table enhancements

		record.targetTable = gr.getRecordClassName();
		data.list.push(record);
	}

	data.enable_filter = (input.enable_filter == true || input.enable_filter == "true" ||
		options.enable_filter == true || options.enable_filter == "true");
	var breadcrumbWidgetParams = {
		table: data.table,
		query: data.filter,
		enable_filter: data.enable_filter
	};
	data.filterBreadcrumbs = $sp.getWidget('widget-filter-breadcrumbs', breadcrumbWidgetParams);

	// copy to data from input or options
	function optCopy(names) {
		names.forEach(function(name) {
			data[name] = input[name] || options[name];
		})
	}

	// getOrderColumn logic mirrors that of Desktop UI when no sort column is specified
	function getOrderColumn() {
		// First check for user preference
		var pref = gs.getUser().getPreference(data.table + ".db.order");
		if (!GlideStringUtil.nil(pref)) {
			data.o = pref;
			if (gs.getUser().getPreference(data.table + ".db.order.direction") == "DESC")
				data.d = 'desc';
			return;
		}

		// If no user pref, check for table default using same logic as Desktop UI:
		// 1) if task, use number
		// 2) if any field has isOrder attribute, use that
		// 3) use order, number, name column if exists (in that priority)
		if (grForMetaData.isValidField("sys_id") && grForMetaData.getElement("sys_id").getED().getFirstTableName() == "task") {
			data.o = "number";
			return;
		}

		// Next check for isOrder attribute on any column
		var elements = grForMetaData.getElements();
		// Global and scoped GlideRecord.getElements return two different things,
		// so convert to Array if needed before looping through
		if (typeof elements.size != "undefined") {
			var elementArr = [];
			for (var i = 0; i < elements.size(); i++)
				elementArr.push(elements.get(i));
			elements = elementArr;
		}
		// Now we can loop through
		for (var j = 0; elements.length > j; j++) {
			var element = elements[j];
			if (element.getAttribute("isOrder") == "true") {
				data.o = element.getName();
				return;
			}
		}
		// As last resort, sort on Order, Number, or Name column
		if (grForMetaData.isValidField("order"))
			data.o = "order";
		else if (grForMetaData.isValidField("number"))
			data.o = "number";
		else if (grForMetaData.isValidField("name"))
			data.o = "name";
	}

// Start: SNH Data Table enhancements
	function getScriptedValue(record, config) {
		var response = {value: ''};
		var scriptName = config.script;
		if (scriptName) {
			if (scriptName.startsWith('global.')) {
				scriptName = scriptName.split('.')[1];
			}
			if (!scriptMap[scriptName]) {
				scriptMap[scriptName] = instantiator.getInstance(scriptName);
			}
			if (scriptMap[scriptName]) {
				response.value = scriptMap[scriptName].getScriptedValue(record, config);
			}
		}
		return response;
	}

	function getAggregateValue(sys_id, config) {
		var value = 0;
		var ga = new GlideAggregate(config.table);
		ga.addAggregate('COUNT');
		var query = config.field + '=' + sys_id;
		if (config.filter) {
			query += '^' + config.filter;
		}
		ga.addEncodedQuery(query);
		ga.query();
		if (ga.next()) {
			value = parseInt(ga.getAggregate('COUNT'));
		}
		var response = {value: value};
		if (config.hint || config.page_id) {
			response.name = config.name;
		}
		return response;
	}
// End: SNH Data Table enhancements

})();

There are no changes needed to the Client script, or any other area, so we are done with the modifications to this widget. Now would be a good time to try it out, but we will need at least one of the three wrapper widgets to be updated before we can give things a try. That sounds like a good project for our next installment.

Scripted Value Columns, Part II

“Progress always involves risks. You can’t steal second base and keep your foot on first.”
Frederick B. Wilcox

Last time, we introduced the concept of Scripted Value Columns, created a sample script for testing, and built the pop-up editor widget. Now we need to modify the Content Selector Configurator widget to accommodate the new configuration option. As usual, we can start with the HTML, and as we have done before, we can copy one of the existing sections and then alter it to meet our new list of properties for this type of column.

<div id="label.svcarray" class="snh-label" nowrap="true">
  <label for="svcarray" class="col-xs-12 col-md-4 col-lg-6 control-label">
    <span id="status.svcarray"></span>
    <span class="text-primary" title="Scripted Value Columns" data-original-title="Scripted Value Columns">${Scripted Value Columns}</span>
  </label>
</div>
<table class="table table-hover table-condensed">
  <thead>
    <tr>
      <th style="text-align: center;">${Label}</th>
      <th style="text-align: center;">${Name}</th>
      <th style="text-align: center;">${Heading}</th>
      <th style="text-align: center;">${Script Include}</th>
      <th style="text-align: center;">${Edit}</th>
      <th style="text-align: center;">${Delete}</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="svc in tbl[state.name].svcarray" ng-hide="svc.removed">
      <td data-th="${Label}">{{svc.label}}</td>
      <td data-th="${Name}">{{svc.name}}</td>
      <td data-th="${Heading}">{{svc.heading}}</td>
      <td data-th="${Table}">{{svc.script}}</td>
      <td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editScriptedValueColumn(svc)" alt="Click here to edit this Scripted Value Column" title="Click here to edit this Scripted Value Column" style="cursor: pointer;"/></td>
      <td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteScriptedValueColumn(svc, tbl[state.name].svcarray)" alt="Click here to delete this Scripted Value Column" title="Click here to delete this Scripted Value Column" style="cursor: pointer;"/></td>
    </tr>
  </tbody>
</table>
<div style="width: 100%; text-align: right;">
  <button ng-click="editScriptedValueColumn('new', tbl[state.name].svcarray, tbl);" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new Scripted Value Column">Add a new Scripted Value Column</button>
</div>

Basically, this is just another table of configuration properties with labels and action buttons. To support the buttons we can copy similar functions for the other types of configurable columns, but before we do that, let’s jump into the Server script and see what we need to do there. We have called our new array of column configurations svcarray, and we can search the scripts for one of the existing arrays such as aggarray and basically cut and paste the existing code to support the new addition.

The only mention of aggarray on the server side is in the code that rebuilds the script being edited from data collected by the editor. We can make a quick copy of that section and then modify it for our purpose.

script += "',\n				svcarray: [";
var lastSeparator = '';
for (var v=0; v<tableTable[tableState.name].svcarray.length; v++) {
	var thisScriptedValue = tableTable[tableState.name].svcarray[v];
	script += lastSeparator;
	script += "{\nname: '";
	script += thisScriptedValue.name;
	script += "',\nlabel: '";
	script += thisScriptedValue.label;
	script += "',\nheading: '";
	script += thisScriptedValue.heading;
	script += "',\nscript: '";
	script += thisScriptedValue.script;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

While we are in the Server script, there is one more thing that we should do. Since this is a new configuration item, if you attempt to edit an existing item that was built using an earlier version, this array will not be present in the existing script that you will be editing. To make sure that we do not run into any null pointer issues with code expecting this array to exist, we should do a quick check when we load the script for editing and make sure that all of the configuration objects are present. There is already code in there to help initialize the data once the script has been loaded. Right now it looks like this:

var instantiator = new Instantiator(this);
var configScript = instantiator.getInstance(data.scriptInclude);
if (configScript != null) {
	data.config = configScript.getConfig($sp);
	for (var persp in data.config.table) {
		for (var tbl in data.config.table[persp]) {
			if (!data.config.table[persp][tbl].displayName) {
				data.config.table[persp][tbl].displayName = getItemName(data.config.table[persp][tbl].name);
			}
		}
	}
}

We are looping through every table in every perspective already, so all that we need to do is loop through every state in every table and check all of the objects that will need to be there for things to work. That will make the above section of code now look like this:

var instantiator = new Instantiator(this);
var configScript = instantiator.getInstance(data.scriptInclude);
if (configScript != null) {
	data.config = configScript.getConfig($sp);
	for (var persp in data.config.table) {
		for (var tbl in data.config.table[persp]) {
			if (!data.config.table[persp][tbl].displayName) {
				data.config.table[persp][tbl].displayName = getItemName(data.config.table[persp][tbl].name);
			}
			for (var st in data.config.state) {
				var thisState = data.config.table[persp][tbl][data.config.state[st].name];
				if (!thisState) {
					thisState = {};
					data.config.table[persp][tbl][data.config.state[st].name] = thisState;
				}
				thisState.svcarray = thisState.svcarray || [];
				thisState.aggarray = thisState.aggarray || [];
				thisState.btnarray = thisState.btnarray || [];
				thisState.refmap = thisState.refmap || {};
				thisState.actarray = thisState.actarray || [];
			}
		}
	}
}

That takes care of the Server script. Now let’s do the same sort of searching on the Client script. The first thing that we find is actually a problem that was created when we added the aggregate columns. Whenever the user selects the Add a New Table button, we create an object to store the configuration for the new table. That code currently looks like this:

$scope.newTable = function(perspective) {
	var shared = {};
	spModal.open({
		title: 'Table Selector',
		widget: 'table-selector',
		shared: shared
	}).then(function() {
		for (var st=0; st<c.data.config.state.length; st++) {
			shared[c.data.config.state[st].name] = {btnarray: [], refmap: {}, actarray: []};
		}
		c.data.config.table[perspective].push(shared);
	});
};

For each state of the new table, we are creating empty objects for the buttons, the reference map, and the bulk actions. When we added the aggregate columns, we neglected to add an empty object for that new array, which may actually cause a problem with certain actions. So we need to add the new svcarray as well as the aggarray to make things right. That will make this code now look like this:

$scope.newTable = function(perspective) {
	var shared = {};
	spModal.open({
		title: 'Table Selector',
		widget: 'table-selector',
		shared: shared
	}).then(function() {
		for (var st=0; st<c.data.config.state.length; st++) {
			shared[c.data.config.state[st].name] = {svcarray: [], aggarray: [], btnarray: [], refmap: {}, actarray: []};
		}
		c.data.config.table[perspective].push(shared);
	});
};

The next thing that you fill find on the client side are the functions that handle the clicks for the Edit and Delete buttons. Copying those and modifying them for Scripted Value Columns gives us this new block of code:

$scope.editScriptedValueColumn = function(scripted_value, svcArray) {
	var shared = {script: {value: '', displayValue: ''}};
	if (scripted_value != 'new') {
		shared.label = scripted_value.label;
		shared.name = scripted_value.name;
		shared.heading = scripted_value.heading;
		shared.script = {value: scripted_value.script, displayValue: scripted_value.script};
	}
	spModal.open({
		title: 'Scripted Value Column Editor',
		widget: 'scripted-value-column-editor',
		shared: shared
	}).then(function() {
		if (scripted_value == 'new') {
			scripted_value = {};
			svcArray.push(scripted_value);
		}
		scripted_value.label = shared.label || '';
		scripted_value.name = shared.name || '';
		scripted_value.heading = shared.heading || '';
		scripted_value.script = shared.script.value || '';
	});
};

$scope.deleteScriptedValueColumn = function(scripted_value, svcArray) {
	var confirmMsg = '<b>Delete Scripted Value Column</b>';
	confirmMsg += '<br/>Are you sure you want to delete the ' + scripted_value.label + ' Scripted Value Column?';
	spModal.confirm(confirmMsg).then(function(confirmed) {
		if (confirmed) {
			var a = -1;
			for (var b=0; b<svcArray.length; b++) {
				if (svcArray[b].name == scripted_value.name) {
					a = b;
				}
			}
			svcArray.splice(a, 1);
		}
	});
};

It looks like there is nothing else after that, so all we need to do at this point is to save the changes and give it a try. Let’s pull up one of our test configuration scripts in the editor and see what the new Scripted Value Columns section looks like.

New Scripted Value Columns section of the editor

Well, it appears on the screen, so that’s a good start. Now let’s hit that Add button and see if our pop-up editor works.

Pop-up Scripted Value Column editor

That looks good as well. Now let’s hit that Save button, which should return us to the main editor and our new scripted value column should show up on the list.

Scripted Value Columns section of the editor after adding a new entry

And there it is. Good deal. After saving the script and examining the resulting Script Include, everything looks good. So now we can create configurations for our new scripted value columns. Of course, the actual data table widgets have no idea what to do with this information, but we can start working on that in our next installment.

Scripted Value Columns in the SNH Data Table Widgets

“Daring ideas are like chessmen moved forward; they may be beaten, but they may start a winning game.”
Johann Wolfgang von Goethe

Every once in a while, I go out to the Developer’s Forum, just to see what other folks are talking about or struggling with. I rarely comment; I leave that to people smarter or faster than myself. But occasionally, the things that people ask about will trigger a thought or idea that sounds interesting enough to pursue. The other day there were a couple of posts related to the Data Table widget, something that I spent a little time playing around with over the years, and it got me to wondering if I could do a little something with my SNH Data Table Widgets to address some of those requirements. One individual was looking to add catalog item variables to a table and another was looking to add some data from the last comment or work note on an Incident. In both cases, the response was essentially that it cannot be done with the out-of-the-box data table widgets. I never did like hearing that as an answer.

It occurred to me that these and other similar columns just needed a little code to fish out the values that they wanted to have displayed on the table with the rest of the standard table fields. We are already doing something like that with the aggregate columns, so it didn’t seem that much of a stretch to clone some of that code and adapt it to handle these types of requirements. If we took the stock properties for aggregate columns and buttons & icons (label, name, and heading) and added one more for the name of a Script Include, as the data was loaded from the base table, we could pass the row data and the column configuration to a standard function in that Script Include to obtain the value for that column. You know what I always ask myself: How hard could it be?

So here is the plan: create a new configuration option called Scripted Value Columns, update the configuration file editor to maintain the properties for those columns, and then update the Data Table widgets to process them based on the configuration. When the data for the table is loading, we’ll get an instance of the Script Include specified in the configuration and then call the function on that instance to get the value for the column. Sounded simple enough to me, but let’s see if we can actually make it work.

To begin, let’s create an example Script Include that we can use for testing purposes. At this point, it doesn’t matter where or how we get the values. We can simply return random values for now; we just want something that returns something so that we can demonstrate the concept. Here is the ScriptedValueExample that I came up with for this purpose.

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

	getScriptedValue: function(item, config) {
		return Math.floor(Math.random() * 100) + '';
	},

	type: 'ScriptedValueExample'
};

The function that we will be calling from the core Data Table widget will be getScriptedValue. In the editor, our pick list of Script Includes can be limited to just those that contain the following text:

getScriptedValue: function(item, config)

This will mean that the function in your custom Script Include will need to have the exact same spacing and argument naming conventions in order for it to show up on the list, but if you clone the script from the example, that shouldn’t be a problem.

Now that we have our example script, we can jump over to the list of portal widgets and pull up one of the existing pop-up configuration editors and clone it to create our new Scripted Value Column Editor. For the HTML portion, we can keep the label, name, and heading fields, delete the rest, and then add our new script property.

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="the_name"
      snh-label="Name"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.heading"
      snh-name="heading"/>
    <snh-form-field
      snh-model="c.widget.options.shared.script"
      snh-name="script"
      snh-type="reference"
      snh-required="true"
      placeholder="Choose a Script Include"
      table="'sys_script_include'"
      display-field="'name'"
      value-field="'api_name'"
      search-fields="'name'"
      default-query="'scriptLIKEgetScriptedValue: function(item, config)'"/>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

The default-query attribute of the sn-record-picker will limit our list of Script Includes to just those that will work as scripted value providers. Other than that, this pop-up editor will be very similar to all of the others that we are using for the other types of configurable columns.

There is no Server script needed for this one, and the Client script is the same as the one from which we made our copy, so there are no changes needed there.

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

	$scope.spModal = spModal;

	$scope.cancel = function() {
		$timeout(function() {
			angular.element('[ng-click*="buttonClicked"]').get(0).click(); 
		});
	};

	$scope.save = function() {
		if ($scope.form1.$valid) {
			$timeout(function() {
				angular.element('[ng-click*="buttonClicked"]').get(1).click(); 
			});
		} else {
			$scope.form1.$setSubmitted(true);
		}
	};

	$timeout(function() {
		angular.element('[class*="modal-footer"]').css({display:'none'});
	}, 100);
}

So that takes care of that. Now we need to modify the main configuration editor to utilize this new pop-up widget. That sounds like a good project for our next installment.

Collaboration Store, Part LXVIII

“Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.”
Brian Kernighan

Last time, we finished up with my rudimentary testing of the latest version of this project. I can still do a lot more testing on my own, but what I really need is for some person or group who is not me to try to give it all a go. In order for that to be an option, I need to create new Update Sets for the current version and post them out here so that other kind souls can download them and attempt to see if they can make it work and/or find out where all of the problems lie. I did not get much feedback the last time that I tried this, but today is a new day, so maybe there is somebody out there now who wouldn’t mind helping a guy out.

This is by no means a final version of this effort. There are still a number of things that I would like to do that have not been attempted as yet, and there are probably more that I have not yet even considered. But all of the major functions are there now, and I just did quite a bit of major refactoring, so now is a good time to roll out a new version and let folks take it out for a spin. Outside feedback is always helpful, and is always appreciated.

Before you install the Scoped Application Update Set, you need to install the latest version of the snh-form-field tag, which you can find here. Or better yet, you can do what I did and go out and grab the latest SNH Data Table Widgets, which includes everything that you need to support snh-form-fields. Either way, you will need to take care of that before you install these app artifacts in the following order:

When I installed the app on my new San Diego PDI, I got a handful of Preview errors about some missing Flow Designer components, but I just accepted all updates and went ahead and did the Commit, and everything seemed to be fine. It may just be that the app was built on Rome and the installation was done on San Diego, and there are some differences there, but I would be interested in hearing if anyone else had any similar issues with the install.

Once you have everything installed, the next step is to go through the set-up process. The first thing that you will want to do is to create a Host instance. Once the Host has been established, the software can be installed on other instances and those instances can be set up as Client instances by identifying the new Host instance during the set-up process. Instructions for the Set-up process, the Application Publishing process, and the Application Installation process can be found here.

The best test will involve three or more instances, and the more the merrier. You can test the set-up process with a single instance, but until you have at least two instances involved, you can’t really test much of the purpose of the app, which is to share applications between instances. Three or more is obviously better, as that is the only way to test an application being shared by one Client and making its way to another Client via the Host. But any level of testing is useful, so please feel free to pull it all down, install it, and try what you can under any circumstances. All feedback from any experience is always welcome in the Comments. Thanks in advance for your assistance. Hopefully, we will get a little feedback this time and we can take a look at it next time out.

Collaboration Store, Part LXVII

“The only real mistake is the one from which we learn nothing.”
Henry Ford

Last time, we were trying to test everything out and then we ran into what appeared to be a problem with the application form. I say appeared to be a problem, because as it turned out, it wasn’t a problem at all. I wanted to install the application on the Host that was just published by the Client, but I did not see the Install button on the screen. But after further review, I realized that the Install button doesn’t belong on the application screen. We don’t install applications; we install specific versions of applications. The Install button does not belong on the application form; it belongs on the version form, and there it is, right where it belongs.

Application Version form with the Install button

So everything is as it should be after all, which is good, because now we can hit that Install button and see what happens.

Completion of the application installation process

There are actually quite a few different screens that you go through during the application installation process, but this is the last thing that you see before you are returned to the application version form.

Application version form after successful installation

Two things you should notice back on the application version form is that the Installed checkbox is now checked and the Install button is no longer present. Going back to the main application form, we should be able to see some changes there as well.

Application form after installation

The changes here are the Application and Version fields being populated, which come from the newly installed application. We can pop up the installed application from here using the little info icon to the right of the Application field and selecting Open Record.

Simple Webhook application installed on the Host instance after being shared by the Client

We can also get to this record from the My Company Applications menu item, which brings up this screen.

My Company Applications

Here we can see both the Collaboration Store app and the shared Simple Webhook app, both including their logo images.

So it looks as if the Set-up process the Application Publishing process and the Application Installation process all seem to working. Of course, a lot more testing needs to be done, primarily by folks who are not authors of the application, but in order for anyone to do that I will need to put together another Update Set and post it out here with some helpful instructions so that any willing testers can actually make a go of it. That sounds like a good subject for our next installment.

Collaboration Store, Part LXVI

“Discovering the unexpected is more important than confirming the known.”
George E. P. Box

Last time, we wrapped up all of the modifications necessary to add the new logging feature to all of the remaining REST API calls in the application. Now we just need to run everything through its paces to make sure that it all still works before we release another Update Set to those folks willing to test this thing out. For the purposes of this initial testing, I went ahead and requested a brand new PDI from the ServiceNow Developer Site. Then I installed the latest version of the SNH Data Table Widgets, mainly because it includes the snh-form-field package, which is a requirement of this app as well. Then I installed the Collaboration Store app, and then the Collaboration Store Globals. Once everything was installed, I ran the set-up process to create a new Host instance.

Collaboration Store Set-up process

After entering all of the details on the initial screen, the next step was to enter the email verification code sent to the email address entered on the form.

Email Verification step

Once the email address was verified, the set-up process completed and sent out the final notification to the operator.

Set-up Completion

With that out of the way, I could now return to the primary development instance and clean out all of the tables to get a fresh start, then register the instance as a Client of the new Host, which basically just repeats the steps above. Once that was done, I could attempt to publish an application, which should push that application, including its logo image, over to the new Host instance. As before, I selected the Simple Webhook application for this test.

Simple Webhook application

I scrolled to the bottom of the page and selected the Publish to Collaboration Store Related Link. That launched the application publishing process, the progress of which could be monitored on the resulting pop-up dialog box.

Application publishing process

So far, so good. Now we need to bounce back over to the new Host instance and make sure that everything arrived intact.

Simple Webhook application on the Host instance

And there it is, complete with its logo image. Excellent. The next thing to do will be to attempt to install the shared application on the Host instance. That’s a fairly straightforward process as well, but if you look closely at the image above, you will see that there is no Install button. That’s a problem. Time to stop testing a do a little debugging. Well, that’s why we test these things. I’ll see if I can figure out what’s up with that and report on the solution next time out.

Collaboration Store, Part LXV

“There is no such thing as completion. These are only stages in an endless progression. There are no final outcomes or decisions, since nothing ever stays the same.”
Frederick Lenz

Last time, we finished adding the logging process to the remaining REST API calls in the CollaborationStoreUtils Script Include. Now we need to do the same thing for all of the remaining REST API calls in the InstanceSyncUtils Script Include. Here is the first one as it stands right now.

syncInstances: function(targetGR, instanceList) {
	var request  = new sn_ws.RESTMessageV2();
	request.setHttpMethod('get');
	request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader("Accept", "application/json");
	request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id');
	var response = request.execute();
	if (response.haveError()) {
		gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
	} else if (response.getStatusCode() == '200') {
		var jsonString = response.getBody();
		var jsonObject = {};
		try {
			jsonObject = JSON.parse(jsonString);
		} catch (e) {
			gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + jsonString);
		}
		if (Array.isArray(jsonObject.result)) {
			for (var i=0; i<instanceList.length; i++) {
				var thisInstance = instanceList[i];
				var remoteSysId = '';
				for (var j=0; j<jsonObject.result.length && remoteSysId == ''; j++) {
					if (jsonObject.result[j].instance == thisInstance) {
						remoteSysId = jsonObject.result[j].sys_id;
					}
				}
				if (remoteSysId == '') {
					remoteSysId = this.sendInstance(targetGR, thisInstance);
				}
				this.syncApplications(targetGR, thisInstance, remoteSysId);
			}
		} else {
			gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + response.getBody());
		}
	} else {
		gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + response.getStatusCode());
	}
}

Up to this point, we have always called the logging routine just before we returned the result object. In the above function, however, we call other functions that also make their own REST API calls, so it would be preferable to log this call before calling any other function that might make a call of its own. Because of this, not only will we need to restructure the code to build the result object that the logging function is expecting, we will also need to make the call to the logging function prior to making the call to the other functions in the instance sync process. To begin, we will build the result object in the normal manner by populating the url and method properties, and then using those values to populate the sn_ws.RESTMessageV2 object.

var result = {};
result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id';
result.method = 'GET';
var request = new sn_ws.RESTMessageV2();
request.setEndpoint(result.url);
request.setHttpMethod(result.method);
request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader('Accept', 'application/json');

Once the sn_ws.RESTMessageV2 is fully populated, we can then obtain the response object by executing the call and then continue populating the result object with the values returned in the response.

var response = request.execute();
result.status = response.getStatusCode();
result.body = response.getBody();
if (result.body) {
	try {
		result.obj = JSON.parse(result.body);
	} catch (e) {
		result.parse_error = e.toString();
		gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + result.body);
	}
}
result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
	gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.status != '200') {
	gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + result.status);
}

Now that the result object is fully populated, we can go ahead and make the call to the logging function before calling the other functions involved in the instance sync process.

this.logRESTCall(targetGR, result);
if (!result.error && result.status == '200' && result.obj) {
	if (Array.isArray(result.obj.result)) {
		for (var i=0; i<instanceList.length; i++) {
			var thisInstance = instanceList[i];
			var remoteSysId = '';
			for (var j=0; j<result.obj.result.length && remoteSysId == ''; j++) {
				if (result.obj.result[j].instance == thisInstance) {
					remoteSysId = result.obj.result[j].sys_id;
				}
			}
			if (remoteSysId == '') {
				remoteSysId = this.sendInstance(targetGR, thisInstance);
			}
			this.syncApplications(targetGR, thisInstance, remoteSysId);
		}
	} else {
		gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + result.body);
	}
}

Putting it all together, the entire new function now looks like this.

syncInstances: function(targetGR, instanceList) {
	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id';
	result.method = 'GET';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Accept', 'application/json');
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
			gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + result.body);
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
		gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
	} else if (result.status != '200') {
		gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + result.status);
	}
	this.logRESTCall(targetGR, result);
	if (!result.error && result.status == '200' && result.obj) {
		if (Array.isArray(result.obj.result)) {
			for (var i=0; i<instanceList.length; i++) {
				var thisInstance = instanceList[i];
				var remoteSysId = '';
				for (var j=0; j<result.obj.result.length && remoteSysId == ''; j++) {
					if (result.obj.result[j].instance == thisInstance) {
						remoteSysId = result.obj.result[j].sys_id;
					}
				}
				if (remoteSysId == '') {
					remoteSysId = this.sendInstance(targetGR, thisInstance);
				}
				this.syncApplications(targetGR, thisInstance, remoteSysId);
			}
		} else {
			gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + result.body);
		}
	}
}

That takes care of the syncInstances function. Now we need to do the same with the syncApplications function, which currently look like this.

syncApplications: function(targetGR, thisInstance, remoteSysId) {
	var applicationList = [];
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	applicationGR.addQuery('provider.instance', thisInstance);
	applicationGR.query();
	while (applicationGR.next()) {
		applicationList.push(applicationGR.getDisplayValue('name'));
	}
	if (applicationList.length > 0) {
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId);
		var response = request.execute();
		if (response.haveError()) {
			gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list: ' + jsonString);
			}
			if (Array.isArray(jsonObject.result)) {
				for (var i=0; i<applicationList.length; i++) {
					var thisApplication = applicationList[i];
					var remoteAppId = '';
					for (var j=0; j<jsonObject.result.length && remoteAppId == ''; j++) {
						if (jsonObject.result[j].name == thisApplication) {
							remoteAppId = jsonObject.result[j].sys_id;
						}
					}
					if (remoteAppId == '') {
						remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
					}
					this.syncVersions(targetGR, thisApplication, thisInstance, remoteAppId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list: ' + response.getBody());
			}
		} else {
			gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list: ' + response.getStatusCode());
		}
	} else {
		gs.info('InstanceSyncUtils.syncApplications - No applications to sync for instance ' + thisInstance);
	}
}

Using the same restructuring approach, we can convert the function to this.

syncApplications: function(targetGR, thisInstance, remoteSysId) {
	var applicationList = [];
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	applicationGR.addQuery('provider.instance', thisInstance);
	applicationGR.query();
	while (applicationGR.next()) {
		applicationList.push(applicationGR.getDisplayValue('name'));
	}
	if (applicationList.length > 0) {
		var result = {};
		result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId;
		result.method = 'GET';
		var request = new sn_ws.RESTMessageV2();
		request.setEndpoint(result.url);
		request.setHttpMethod(result.method);
		request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader('Accept', 'application/json');
		var response = request.execute();
		result.status = response.getStatusCode();
		result.body = response.getBody();
		if (result.body) {
			try {
				result.obj = JSON.parse(result.body);
			} catch (e) {
				result.parse_error = e.toString();
				gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
			gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
		} else if (result.status != '200') {
			gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
		}
		this.logRESTCall(targetGR, result);
		if (!result.error && result.status == '200' && result.obj) {
			if (Array.isArray(result.obj.result)) {
				for (var i=0; i<applicationList.length; i++) {
					var thisApplication = applicationList[i];
					var remoteAppId = '';
					for (var j=0; j<result.obj.result.length && remoteAppId == ''; j++) {
						if (result.obj.result[j].name == thisApplication) {
							remoteAppId = result.obj.result[j].sys_id;
						}
					}
					if (remoteAppId == '') {
						remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
					}
					this.syncVersions(targetGR, thisApplication, thisInstance, remoteAppId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
	} else {
		gs.info('InstanceSyncUtils.syncApplications - No applications to sync for instance ' + thisInstance);
	}
}

We can repeat this same refactoring exercise for the two other similar functions, syncVersions and syncAttachments, which now look like this.

syncVersions: function(targetGR, thisApplication, thisInstance, remoteAppId) {
	var versionList = [];
	var versionIdList = [];
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application.name', thisApplication);
	versionGR.addQuery('member_application.provider.instance', thisInstance);
	versionGR.query();
	while (versionGR.next()) {
		versionList.push(versionGR.getDisplayValue('version'));
		versionIdList.push(versionGR.getUniqueValue());
	}
	if (versionList.length > 0) {
		result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version?sysparm_fields=version%2Csys_id&sysparm_query=member_application%3D' + remoteAppId;
		result.method = 'GET';
		var request = new sn_ws.RESTMessageV2();
		request.setEndpoint(result.url);
		request.setHttpMethod(result.method);
		request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader('Accept', 'application/json');
		var response = request.execute();
		result.status = response.getStatusCode();
		result.body = response.getBody();
		if (result.body) {
			try {
				result.obj = JSON.parse(result.body);
			} catch (e) {
				result.parse_error = e.toString();
				gs.error('InstanceSyncUtils.syncVersions - Unparsable JSON string returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
			gs.error('InstanceSyncUtils.syncVersions - Error returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
		} else if (result.status != '200') {
			gs.error('InstanceSyncUtils.syncVersions - Invalid HTTP response code returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
		}
		this.logRESTCall(targetGR, result);
		if (!result.error && result.status == '200' && result.obj) {
			if (Array.isArray(result.obj.result)) {
				for (var i=0; i<versionList.length; i++) {
					var thisVersion = versionList[i];
					var thisVersionId = versionIdList[i];
					var remoteVerId = '';
					for (var j=0; j<result.obj.result.length && remoteVerId == ''; j++) {
						if (result.obj.result[j].version == thisVersion) {
							remoteVerId = result.obj.result[j].sys_id;
						}
					}
					if (remoteVerId == '') {
						remoteVerId = this.sendVersion(targetGR, thisVersion, thisApplication, thisInstance, remoteAppId);
					}
					this.syncAttachments(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncVersions - Invalid response body returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
	} else {
		gs.info('InstanceSyncUtils.syncVersions - No versions to sync for application ' + thisApplication + ' on instance ' + thisInstance);
	}
}
syncAttachments: function(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId) {
	var attachmentList = [];
	var attachmentGR = new GlideRecord('sys_attachment');
	attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
	attachmentGR.addQuery('table_sys_id', thisVersionId);
	attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
	attachmentGR.query();
	while (attachmentGR.next()) {
		attachmentList.push(attachmentGR.getUniqueValue());
	}
	if (attachmentList.length > 0) {
		var result = {};
		result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/sys_attachment?sysparm_fields=sys_id&sysparm_query=table_name%3Dx_11556_col_store_member_application_version%5Etable_sys_id%3D' + remoteVerId + '%5Econtent_typeCONTAINSxml';
		result.method = 'GET';
		var request = new sn_ws.RESTMessageV2();
		request.setEndpoint(result.url);
		request.setHttpMethod(result.method);
		request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader('Accept', 'application/json');
		var response = request.execute();
		result.status = response.getStatusCode();
		result.body = response.getBody();
		if (result.body) {
			try {
				result.obj = JSON.parse(result.body);
			} catch (e) {
				result.parse_error = e.toString();
				gs.error('InstanceSyncUtils.syncAttachments - Unparsable JSON string returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
			gs.error('InstanceSyncUtils.syncAttachments - Error returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
		} else if (result.status != '200') {
			gs.error('InstanceSyncUtils.syncAttachments - Invalid HTTP response code returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
		}
		this.logRESTCall(targetGR, result);
		if (!result.error && result.status == '200' && result.obj) {
			if (Array.isArray(result.obj.result)) {
				if (result.obj.result.length == 0) {
					this.sendAttachment(targetGR, attachmentList[0], remoteVerId, thisVersion, thisApplication);
				}
			} else {
				gs.error('InstanceSyncUtils.syncAttachments - Invalid response body returned from attempt to fetch attachment list: ' + result.body);
			}
		}
	} else {
		gs.info('InstanceSyncUtils.syncAttachments - No attachments to sync for version ' + thisVersionId + ' of application ' + thisApplication + ' on instance ' + thisInstance);
	}
}

That should take care of all of the REST API calls in all of the Script Includes in the application. Now every call will be recorded in the new table and linked to the instance to which the call was made. With the completion of the work on the images and the logging, it is about time to create yet another Update Set and turn it over to the testers for some serious regression testing. Before we do that, though, it would probably be a good idea to try all of this out ourselves and make sure that it all works. Let’s jump right into that next time out.