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!

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.

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.

SNH Data Table Widgets on Share, Corrected

“An essential part of being a humble programmer is realizing that whenever there’s a problem with the code you’ve written, it’s always your fault.”
Jeff Atwood

Well, it turns out that I missed a critical widget when I built my first Update Set to be posted out on Share the other day. The Table Selector widget is used in the Content Selector Configuration Editor to select a new table whenever you wanted to add a table to the configuration. Without that widget included in the Update Set, a blank modal pop-up comes up when you click on the Add a New Table button, which is obviously not helpful. I have added the widget to a corrected Update Set that I need to get out on the Share site, but I thought that I would post it here first to see if there was anything else that I needed to fix before I do that. Thanks for the feedback, by the way … it is always much appreciated.

By the way, I built the Update Set using the Add to Update Set Utility, which is an awesome tool that I cannot recommend highly enough. The only problem with that, of course, is that you have to remember to include all of your artifacts when you create an Update Set and start adding in all of the various parts and pieces for your project. If you are not careful, you can leave out an important piece, which I did, and that’s on me. Hopefully, this is the only one, but there is no guarantee …

Conditional Buttons and Icons on the SNH Data Table Widget

“Become a beacon of enhancement, and then, when the night is gray, all of the boats will move towards you, bringing their bountiful riches.”
James Altucher

After I posted the SNH Data Table widget collection out on Share, there were a couple of things that I felt were missing in the version that I put out there. One of those was the ability to click on an aggregate column and pull up a list of the records represented by that value. I took care of that one here, but after having second thoughts, the version that I ended up with is not quite like the one described in that installment of the series. Still, after a litter refactoring, I got it working in a way that finally seemed to be acceptable. But there is still one more thing that I would like to add before I release another version of the collection: the ability to control the presence of a button or icon on the row based on some condition.

Right now, if you configure a button or icon, that button or icon appears on every row of the table. That’s a nice feature that the stock Data Table widget does not include, but it would be even nicer if you could control whether or not the button appeared based on some condition. My thought was that I could add yet one more property to the button/icon configuration object called condition that could be used to control the presence of the action item on the row. I wasn’t exactly sure how to make that happen, but as usual, I thought that I would tackle the easiest portion first, which would be to modify the Content Selector Configurator widget to include that additional property. We just went through that exercise when adding the action property to the aggregate column configuration object (which was later replaced with the hint and page_id properties during refactoring), so the exercise is virtually the same.

As we did with the new aggregate column configuration property, we can start with the main widget and add the following to the list of column headings for the buttons and icons section of the table specification:

<th style="text-align: center;">${Condition}</th>

And then in the repeating rows of that table, we can insert this line:

<td data-th="${Condition}">{{btn.condition}}<

On the client side, we can add the following line to the new record section of the editButton() function:

shared.condition = button.condition;

… and this line to the section that saves the edits in that same function:

button.condition = shared.condition || '';

Finally, on the server side, in the Save() function that rebuilds the script, let’s add these lines in the button/icon specification section:

script += "',\n                 condition: '";
script += thisButton.condition;

That will handle things in most cases, but since this particular property is a Javascript expression, we need to account for the fact that the value might contain single quotes, and surrounding a value containing single quotes with single quotes will result in a syntax error in our Script Include. We should at least check for that, and if single quotes are present in the value, we should escape them. Instead of the script fragment above, which has been working for us in all other cases, let’s expand that a little bit to accommodate our concerns to something like this:

script += "',\n					condition: '";
if (thisButton.condition) {
	var condition = thisButton.condition;
	if (condition.indexOf("'") != -1) {
		condition = condition.replace(/'/g, "\\'");
	}
	script += condition;
}

That takes care of the main widget, but we also need to add a new input field to the pop-up editor before this will actually work, so we need to add this line to the HTML of the Button/Icon Editor widget:

<snh-form-field snh-model="c.widget.options.shared.condition" snh-name="condition"/>

That updates the editor to now include a new button/icon specification property called condition. Of course, that doesn’t actually add any functionality to the Data Table widgets just yet, but at least now we can add a value to that property through the editor, which is a start. Now let’s take a look at the actual SNH Data Table widget and see what we need to do in order to leverage that new property.

Once again, the easiest place to start is with the HTML. Here is the current section of the HTML that deals with buttons and icons:

<td ng-repeat="button in data.btnarray" role="cell" class="text-nowrap center" ng-class="{selected: item.selected}" tabindex="0">
  <a ng-if="!button.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonClick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">{{button.label}}</a>
  <a ng-if="button.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonClick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">
    <span class="icon icon-{{button.icon}}" aria-hidden="true"></span>
    <span class="sr-only">{{button.hint}}</span>
  </a>
</td>

In the current version, there are two mutually exclusive anchor tags controlled by ng-if attributes that look to see whether or not an icon image was specified. We should be able to logically and our new condition to the existing conditions without disturbing the rest of the existing structure. The easiest way to do that at this point would simply be to call a function that dealt with the condition and have it return true or false based on the contents of our new condition property. We will have to build that function, but for now, we can just assume that it exists and modify the above to now look like this:

<td ng-repeat="button in data.btnarray" role="cell" class="text-nowrap center" ng-class="{selected: item.selected}" tabindex="0">
  <a ng-if="!button.icon && buttonCondition(button.condition, item)" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonClick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">{{button.label}}</a>
  <a ng-if="button.icon && buttonCondition(button.condition, item)" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonClick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">
    <span class="icon icon-{{button.icon}}" aria-hidden="true"></span>
    <span class="sr-only">{{button.hint}}</span>
  </a>
</td>

That should take care of the HTML. Now we need to come up with a function that will do what we want to do, which is to return true if we want the button/icon to appear and false if we do not. We can start out with something like this:

$scope.buttonCondition = function(expression, item) {
	var response = true;
	if (expression) {
		// check to see if the condition is true or false
	}
	return response;
};

This defaults the response to true, and if there is no condition specified, then the response will be true. If there is a condition, then we need to see if that condition is true for this item. Basically, we want to run the code that is stored in the property value. For that, we can use $scope.$eval. This AngularJS function will run the expression and return the result, which will then become our response. That will make our function look like this:

$scope.buttonCondition = function(expression, item) {
	var response = true;
	if (expression) {
		response = $scope.$eval(expression, this);
	}
	return response;
};

And that should be that! Now all we need to do is test all of this out, plus do a whole lot of regression testing for all of the refactoring, and then we can finally put out a new Update Set. That is definitely quite a bit of work in an of itself, so let’s save all of that for a future installment.

Refactoring the SNH Data Table Widget, Part II

“An intuitive definition is that a safe refactoring is one that doesn’t break a program. Because a refactoring is intended to restructure a program without changing its behavior, a program should perform the same way after a refactoring as it does before.”
Martin Fowler

Last time, we began the work of cleaning up the SNH Data Table widgets by consolidating all of the added action functions and bringing in the latest version of the original widget. To complete the work, we need to do the same for the remaining wrapper widgets in the collection, two of which were cloned from existing stock components, with the third being a new addition having no original source (although it was actually cloned from the modified version of one of the other two). As usual, we will start with the easy one first, the SNH Data Table from Instance Definition, cloned from the stock Data Table from Instance Definition widget.

The biggest change here was the addition of four new options to allow the entry of JSON strings for configuring each of the four new features:

[{"hint":"If enabled, show the list filter in the breadcrumbs of the data table",
"name":"enable_filter",
"default_value":"false",
"section":"Behavior",
"label":"Enable Filter",
"type":"boolean"},
{"hint":"A JSON object containing the specifications for aggregate data columns",
"name":"aggregates",
"default_value":"",
"section":"Behavior",
"label":"Aggregate Column Specifications (JSON)",
"type":"String"},
{"hint":"A JSON object containing the specifications for row-level buttons and action icons",
"name":"buttons",
"default_value":"",
"section":"Behavior",
"label":"Button/Icon Specifications (JSON)",
"type":"String"},
{"hint":"A JSON object containing the page id for any reference column links",
"name":"refpage",
"default_value":"",
"section":"Behavior",
"label":"Reference Page Specifications (JSON)",
"type":"String"}]

Other than that, the only other modification to the code required was in the Server script where we needed to point to the SNH core widget instead of the stock core widget:

// Start: SNH Data Table enhancements
	data.dataTableWidget = $sp.getWidget('snh-data-table', options);
// End: SNH Data Table enhancements

Aside from that single line, the remainder of the widget, including the entire Client script, remains the same as the latest version of the original widget. Next, we need to take a look at the SNH Data Table from URL Definition, which was cloned from the stock Data Table from URL Definition widget. As with the previous widget, the Client script from the latest version of the source widget remains the same. However, the Server script needs a little more modification than just the ID of the embedded core widget.

// Start: SNH Data Table enhancements
	data.fields = $sp.getParameter('fields') || $sp.getListColumns(data.table, data.view);
	copyParameters(data, ['aggregates', 'buttons', 'refpage', 'bulkactions']);
	data.show_new = options.show_new == true || options.show_new == "true";
	data.show_breadcrumbs = options.show_breadcrumbs == true || options.show_breadcrumbs == "true";
	data.window_size = $sp.getParameter('maximum_entries');
	data.btns = data.buttons;
	data.dataTableWidget = $sp.getWidget('snh-data-table', data);
// End: SNH Data Table enhancements

I left the original code intact, even in areas where the new code reset the values established differently in the original code, mainly because that didn’t really hurt anything and I was trying to retain the original as closely as possible to the way that it was for future comparisons.

That leaves the SNH Data Table from JSON Configuration, which does not have a stock version, although I did clone it originally from the modified SNH Data Table from URL Definition widget. Since there were no changes needed in the Client script of the other two widgets cloned from stock widgets, I went ahead and just copied the latest version of the Client script from the stock Data Table from URL Definition widget and pasted it into he Client script of the SNH Data Table from JSON Configuration widget. The rest was mostly custom code anyway, so I just left that alone.

That takes care of the three wrapper widgets, so now everything has been brought up the latest versions and the code for handling the four added features has all be consolidated into the core widget using a common function. That cleaned things up quite nicely, but I’m still not quite ready to spin up a new Update Set just yet. There is one more thing that I think needs to addressed before we do that.

Aggregate List Columns, Part IX

“You have to finish things — that’s what you learn from, you learn by finishing things.”
Neil Gaiman

Last time, we attempted to wrap this whole thing up with the remaining modifications to the Content Selector Configuration Editor, but we ran into a problem with the code that we borrowed from the sn-record-picker Helper. Now that we have taken a quick detour to resolve that issue, we need to get back to our new pop-up aggregate column editor and apply the same fix to our pilfered code. As with the sn-record-picker Helper, we need to add some code to the server side to leverage the TableUtils object to get our list of tables in the extension hierarchy. Here is the new Server script, with basically the same function as the one that we added to the sn-record-picker Helper, with a few minor modifications due to the difference in our variable names.

(function() {
	if (input && input.tableName) {
		var tu = new TableUtils(input.tableName);
		var tableList = tu.getTables();
		data.tableList = j2js(tableList);
	}
})();

With that now in place, we can update the client-side buildFieldFilter function to call the server side to get the list, and then use that list to build the new filter.

$scope.buildFieldFilter = function() {
	c.data.fieldFilter = 'name=' + c.widget.options.shared.table.value;
	c.data.tableName = c.widget.options.shared.table.value;
	c.server.update().then(function(response) {
		if (response.tableList && Array.isArray(response.tableList) && response.tableList.length > 1) {
			c.data.fieldFilter = 'nameIN' + response.tableList.join(',');
		}
		c.data.fieldFilter += '^elementISNOTEMPTY^internal_type=reference';
	});
};

Now we just need to pop up the editor again and see if that actually fixes the problem that we were having earlier.

Pop-up aggregate column spec editor with field pick list corrected

That’s better! Now when I search for a field on the Incident table, I get to select from all of the fields on the table, not just the ones attached to the primary table. That’s what we wanted to see.

That takes care of the pop-up aggregate column specification editor that we cloned from the existing button/icon specification editor, so now all that is left for us to do is to tweak the code that actually saves the changes once all of the editing has been completed. The Save process actually rebuilds the entire script stored in the Script Include record, so we just need to add some code to create the aggregate column section for each table definition. Once again, we can leverage the existing buttons and icons code as a starting point, and then make the necessary changes to adapt it to use for aggregate columns. Here are the relevant lines from the Save() function in the widget’s Server script:

script += "',\n				btnarray: [";
var lastSeparator = '';
for (var b=0; b<tableTable[tableState.name].btnarray.length; b++) {
	var thisButton = tableTable[tableState.name].btnarray[b];
	script += lastSeparator;
	script += "{\n					name: '";
	script += thisButton.name;
	script += "',\n					label: '";
	script += thisButton.label;
	script += "',\n					heading: '";
	script += thisButton.heading;
	script += "',\n					icon: '";
	script += thisButton.icon;
	script += "',\n					color: '";
	script += thisButton.color;
	script += "',\n					hint: '";
	script += thisButton.hint;
	script += "',\n					page_id: '";
	script += thisButton.page_id;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

As we have done a number of times now, we can make a few global text replacements and alter a few variable names to align with our needs and come up with something that will work for aggregate columns specifications in very much the same way that it is currently working for buttons and icons.

script += "',\n				aggarray: [";
var lastSeparator = '';
for (var g=0; g<tableTable[tableState.name].aggarray.length; g++) {
	var thisAggregate = tableTable[tableState.name].aggarray[g];
	script += lastSeparator;
	script += "{\n					name: '";
	script += thisAggregate.name;
	script += "',\n					label: '";
	script += thisAggregate.label;
	script += "',\n					heading: '";
	script += thisAggregate.heading;
	script += "',\n					table: '";
	script += thisAggregate.table;
	script += "',\n					field: '";
	script += thisAggregate.field;
	script += "',\n					filter: '";
	script += thisAggregate.filter;
	script += "',\n					source: '";
	script += thisAggregate.source;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

And that’s all there is to that. That should be everything now, so all that is left to do is to bundle all of this into an Update Set so that folks can play along at home. This effort has been primarily focused on the components of the Customizing the Data Table Widget project, but it has also involved elements of the Configurable Data Table Widget Content Selector series as well as the Content Selector Configuration Editor. Additionally, many of the Service Portal pages that use these components, such as the Service Portal User Directory, also include the Dynamic Service Portal Breadcrumbs widget. Rather than create a new version for each and every one of these interrelated project, I think I will just lump everything together into a single Update Set and call it version 2.0 of the Customizing the Data Table Widget project. Since many of the widgets involved also utilize the Service Portal Form Fields, that will get pulled into the Update Set as well, and just for good measure, I think I will toss in the sn-record-picker Helper, too. That one is not actually directly related to all of the others, but we did steal some code from there, so there might be a few folks who may want to take a look at that one. You can download the whole lot from here. As always, if you have any comments, questions, concerns, or issues, please leave a comment below. All feedback is always welcome. And if you have made it this far, thanks for following along all the way to the end!

But wait … there’s more!