Collaboration Store, Part LXIII

“One of my most productive days was throwing away 1,000 lines of code.”
Ken Thompson

Last time, we wrapped up the code to refactor the application publishing process. Now we need to do the same thing with the application distribution process, the process that runs on the Host instance to share recently published application versions to all of the other Client instances in the community. Currently, this involves three separate functions, publishNewVersion, publishVersionRecord, and publishVersionAttachment.

publishNewVersion: function(newVersion, targetInstance, attachmentId) {
	var targetGR = new GlideRecord('x_11556_col_store_member_organization');
	if (targetGR.get('instance', targetInstance)) {
		var token = targetGR.getDisplayValue('token');
		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		if (versionGR.get(newVersion)) {
			var canContinue = true;
			var targetAppId = '';
			var mbrAppGR = versionGR.member_application.getRefRecord();
			var request  = new sn_ws.RESTMessageV2();
			request.setHttpMethod('get');
			request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
			request.setRequestHeader("Accept", "application/json");
			request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + mbrAppGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(mbrAppGR.getDisplayValue('name')));
			var response = request.execute();
			if (response.haveError()) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
				canContinue = false;
			} else if (response.getStatusCode() == '200') {
				var jsonString = response.getBody();
				var jsonObject = {};
				try {
					jsonObject = JSON.parse(jsonString);
				} catch (e) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + jsonString);
					canContinue = false;
				}
				if (canContinue) {
					var payload = {};
					payload.name = mbrAppGR.getDisplayValue('name');
					payload.scope = mbrAppGR.getDisplayValue('scope');
					payload.description = mbrAppGR.getDisplayValue('description');
					payload.current_version = mbrAppGR.getDisplayValue('current_version');
					payload.active = 'true';
					request  = new sn_ws.RESTMessageV2();
					request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
					request.setRequestHeader("Accept", "application/json");
					if (jsonObject.result && jsonObject.result.length > 0) {
						targetAppId = jsonObject.result[0].sys_id;
						request.setHttpMethod('put');
						request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + targetAppId);
					} else {
						request.setHttpMethod('post');
						request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application');
						payload.provider = mbrAppGR.getDisplayValue('provider.instance');
					}
					request.setRequestBody(JSON.stringify(payload, null, '\t'));
					response = request.execute();
					if (response.haveError()) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
						canContinue = false;
					} else if (response.getStatusCode() != 200 && response.getStatusCode() != 201) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + response.getStatusCode());
						canContinue = false;
					} else {
						jsonString = response.getBody();
						jsonObject = {};
						try {
							jsonObject = JSON.parse(jsonString);
						} catch (e) {
							gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + jsonString);
							canContinue = false;
						}
						if (canContinue) {
							targetAppId = jsonObject.result.sys_id;
						}
					}
				}
			} else {
				gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + response.getStatusCode());
			}
			if (canContinue) {
				this.publishVersionRecord(targetInstance, token, versionGR, targetAppId, attachmentId);
			}
		} else {
			gs.error('CollaborationStoreUtils.publishNewVersion: Version record not found: ' + newVersion);
		}
	} else {
		gs.error('CollaborationStoreUtils.publishNewVersion: Target instance record not found: ' + targetInstance);
	}
},

publishVersionRecord: function(targetInstance, token, versionGR, targetAppId, attachmentId) {
	var canContinue = true;
	var payload = {};
	payload.member_application = targetAppId;
	payload.version = versionGR.getDisplayValue('version');
	payload.built_on = versionGR.getDisplayValue('built_on');
	var request  = new sn_ws.RESTMessageV2();
	request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
	request.setRequestHeader("Accept", "application/json");
	request.setHttpMethod('post');
	request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application_version');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	response = request.execute();
	if (response.haveError()) {
		gs.error('CollaborationStoreUtils.publishVersionRecord: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		canContinue = false;
	} else if (response.getStatusCode() != 201) {
		gs.error('CollaborationStoreUtils.publishVersionRecord: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + response.getStatusCode());
		canContinue = false;
	} else {
		jsonString = response.getBody();
		jsonObject = {};
		try {
			jsonObject = JSON.parse(jsonString);
		} catch (e) {
			gs.error('CollaborationStoreUtils.publishVersionRecord: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + jsonString);
			canContinue = false;
		}
		if (canContinue) {
			targetVerId = jsonObject.result.sys_id;
			this.publishVersionAttachment(targetInstance, token, targetVerId, attachmentId);
		}
	}
},

publishVersionAttachment: function(targetInstance, token, targetVerId, attachmentId) {
	var gsa = new GlideSysAttachment();
	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(attachmentId)) {
		var url = 'https://';
		url += targetInstance;
		url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
		url += targetVerId;
		url += '&file_name=';
		url += sysAttGR.getDisplayValue('file_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
		request.setRequestHeader('Content-Type', sysAttGR.getDisplayValue('content_type'));
		request.setRequestHeader('Accept', 'application/json');
		request.setHttpMethod('post');
		request.setEndpoint(url);
		request.setRequestBody(gsa.getContent(sysAttGR));
		response = request.execute();
		if (response.haveError()) {
			gs.error('CollaborationStoreUtils.publishVersionAttachment: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() != 201) {
			gs.error('CollaborationStoreUtils.publishVersionAttachment: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + response.getStatusCode());
		}
	} else {
		gs.error('CollaborationStoreUtils.publishVersionAttachment: Invalid attachment record sys_id: ' + attachmentId);
	}
}

The corresponding shared functions are pushApplication, pushVersion, and pushAttachment in the CollaborationStoreUtils Script Include. The pushApplication takes the application GlideRecord, the target instance GlideRecord, and the sys_id of the application providing instance on the target instance as arguments. The current function already has code to fetch the target instance GlideRecord and the version GlideRecord, and from the version GlideRecord, we can obtain the application GlideRecord. We also built a function earlier that fetched the sys_id of an instance record on the target instance, but that function assumed that you were looking for the local instance on the target instance. In this case, we are looking for the instance that provided the application, so if we want to leverage that function, we will have to alter it to accept the instance name as one of the arguments. Here is the start of the function as it stands right now.

getRemoteInstanceSysId: function(targetGR) {
	var sysId = '';

	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=sys_id&sysparm_query=instance%3D' + gs.getProperty('instance_name');
	...
}

To add the ability to pass in the instance name, we can change that to this:

getRemoteInstanceSysId: function(targetGR, instanceName) {
	var sysId = '';

	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=sys_id&sysparm_query=instance%3D' + instanceName;
	...
}

Then we just have to go back into the code and find where this function is called and add the appropriate instance name to the argument list. With that little modification out of the way, we should be able to gather up all of the required arguments to call the pushApplication function. That will make our replacement code look like this:

publishNewVersion: function(newVersion, targetInstance, attachmentId) {
	var targetGR = new GlideRecord('x_11556_col_store_member_organization');
	if (targetGR.get('instance', targetInstance)) {
		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		if (versionGR.get(newVersion)) {
			var applicationGR = versionGR.member_application.getRefRecord();
			var remoteSysId = this.getRemoteInstanceSysId(targetGR, applicationGR.getDisplayValue('provider.instance'));
			var result = this.pushApplication(applicationGR, targetGR, remoteSysId);
			if (result.error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
			} else if (result.parse_error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
			} else if (result.status != 200 && result.status != 201) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
			} else {
				var remoteAppId = result.obj.result.sys_id;
			}
		} else {
			gs.error('CollaborationStoreUtils.publishNewVersion: Version record not found: ' + newVersion);
		}
	} else {
		gs.error('CollaborationStoreUtils.publishNewVersion: Target instance record not found: ' + targetInstance);
	}
}

If all goes well, we will be able to obtain the sys_id of the transferred application record on the target system from the result object, which is the only other piece of data that we need in order to call the pushVersion function. In fact, we can make that call on the very next line and check the results right there.

var remoteAppId = result.obj.result.sys_id;
result = this.pushVersion(versionGR, targetGR, remoteAppId);
if (result.error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.parse_error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
} else if (result.status != 200 && result.status != 201) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
} else {
	var remoteVerId = result.obj.result.sys_id;
}

And again, if all goes well, we will be able to obtain the sys_id of the transferred version record on the target system from the result object, which is the other piece of data that we need in order to call the pushAttachment function. And once again, we can make that call on the very next line and check the results right there.

var remoteVerId = result.obj.result.sys_id;
result = this.pushAttachment(attachmentGR, targetGR, remoteVerId);
if (result.error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.parse_error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
} else if (result.status != 200 && result.status != 201) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
}

So putting it all together in this way, we can replace the original three functions with a single function that makes three calls to our shared functions. Once again, we have simplified things quite a bit and at the same time added functionality with the inclusion of the logo images and the REST API call logging. Here is the whole thing all put together:

publishNewVersion: function(newVersion, targetInstance, attachmentId) {
	var targetGR = new GlideRecord('x_11556_col_store_member_organization');
	if (targetGR.get('instance', targetInstance)) {
		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		if (versionGR.get(newVersion)) {
			var applicationGR = versionGR.member_application.getRefRecord();
			var remoteSysId = this.getRemoteInstanceSysId(targetGR, applicationGR.getDisplayValue('provider.instance'));
			var result = this.pushApplication(applicationGR, targetGR, remoteSysId);
			if (result.error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
			} else if (result.parse_error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
			} else if (result.status != 200 && result.status != 201) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
			} else {
				var remoteAppId = result.obj.result.sys_id;
				result = this.pushVersion(versionGR, targetGR, remoteAppId);
				if (result.error) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
				} else if (result.parse_error) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
				} else if (result.status != 200 && result.status != 201) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
				} else {
					var remoteVerId = result.obj.result.sys_id;
					result = this.pushAttachment(attachmentGR, targetGR, remoteVerId);
					if (result.error) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
					} else if (result.parse_error) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
					} else if (result.status != 200 && result.status != 201) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
					}
				}
			}
		} else {
			gs.error('CollaborationStoreUtils.publishNewVersion: Version record not found: ' + newVersion);
		}
	} else {
		gs.error('CollaborationStoreUtils.publishNewVersion: Target instance record not found: ' + targetInstance);
	}
}

That should cover the application distribution process, which should complete the list of features that should be relying on the shared functions. The only thing left at this point would be to hunt down all of the other REST API calls and throw in a call to the new shared logging function. Maybe we will take a look at that next time out.

Collaboration Store, Part LXII

“Now I’m a pretty lazy person and am prepared to work quite hard in order to avoid work.”
Martin Fowler

Last time, we modified the processPhase5 function in the ApplicationPublisher Script Include to use the shared functions for making REST API calls instead of its own code. Now we need to continue with that work and do the same for the processPhase6 and processPhase7 functions. Here is the current script for the processPhase6 function.

processPhase6: function(answer) {
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	if (versionGR.get(answer.versionId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var payload = {};
		payload.member_application = answer.hostAppId;
		payload.version = versionGR.getDisplayValue('version');
		payload.built_on = versionGR.getDisplayValue('built_on');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader("Accept", "application/json");
		request.setHttpMethod('post');
		request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application_version');
		request.setRequestBody(JSON.stringify(payload, null, '\t'));
		response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		} else {
			jsonString = response.getBody();
			jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
			}
			if (!answer.error) {
				answer.hostVerId = jsonObject.result.sys_id;
			}
		}
	} else {
		answer = this.processError(answer, 'Invalid version record sys_id: ' + answer.versionId);
	}

	return answer;
}

The corresponding shared function is pushVersion, which takes as arguments the version GlideRecord, the target instance GlideRecord, and the sys_id of the application record on the target system. We are already fetching the version GlideRecord, we can use the function that we built last time to go get the target instance GlideRecord, and the sys_id of the application record on the target system was stored in the shared answer object in phase 5, so we should have everything that we need to invoke the appropriate shared function and check the results.

processPhase6: function(answer) {
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	if (versionGR.get(answer.versionId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		var result = csu.pushVersion(versionGR, targetGR, answer.hostAppId);
		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 version record sys_id: ' + answer.versionId);
	}

	return answer;
}

That should take care of the processPhase6 function. Now, let’s take a look at that processPhase7 function.

processPhase7: function(answer) {
	var gsa = new GlideSysAttachment();
	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(answer.attachmentId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var url = 'https://';
		url += host;
		url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
		url += answer.hostVerId;
		url += '&file_name=';
		url += sysAttGR.getDisplayValue('file_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader('Content-Type', sysAttGR.getDisplayValue('content_type'));
		request.setRequestHeader('Accept', 'application/json');
		request.setHttpMethod('post');
		request.setEndpoint(url);
		request.setRequestBody(gsa.getContent(sysAttGR));
		response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		}
	} else {
		answer = this.processError(answer, 'Invalid attachment record sys_id: ' + answer.attachmentId);
	}

	return answer;
}

The corresponding shared function is pushAttachment, which takes as arguments the attachment GlideRecord, the target instance GlideRecord, and the sys_id of the version record on the target system. Once again, we are already fetching the attachment GlideRecord, we can use the function that we built last time to go get the target instance GlideRecord, and the sys_id of the version record on the target system was stored in the shared answer object in phase 6, so once again we should have everything that we need to invoke the appropriate shared function and check the results.

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

So that wraps up all of the refactoring for the application publishing process. All that is left to do now is to do the same thing for the application distribution process, where the Host instance sends out the artifacts for new application versions to all of the other Client instances in the community. We’ll jump right into that next time out.

Collaboration Store, Part LXI

“I think it is often easier to make progress on mega-ambitious dreams. Since no one else is crazy enough to do it, you have little competition.”
Larry Page

Last time, we wrapped up all of the code in the shared functions to include the logo images whenever an instance or application is transferred from one instance to another. Now we need to take a look at those places where the shared functions are not currently being used and replace the code in those existing functions with calls to the shared functions. This will not only consolidate the code and ensure that the logo images will be included, but since we also added a logging feature to the shared functions, it will also ensure that the REST API activity gets recorded.

Our ApplicationPublisher Script Include contains individual functions for the 7 independent phases of the application publishing process. The first four are all internal, but the last three move the artifacts from the Client instance to the the Host, so we will want to rework each one of those. Before we do that, though, we will want to snag the image from the sys_app record and attach it to our application record, which is something that we can do in Phase 2, once the application record is available. We can insert a line into this code:

mbrAppGR.setValue('name', sysAppGR.getValue('name'));
mbrAppGR.setValue('scope', sysAppGR.getValue('scope'));
mbrAppGR.setValue('description', sysAppGR.getValue('short_description'));
mbrAppGR.setValue('current_version', sysAppGR.getValue('version'));
mbrAppGR.setValue('active', true);
mbrAppGR.update();

… and make it look like this:

mbrAppGR.setValue('name', sysAppGR.getValue('name'));
mbrAppGR.setValue('scope', sysAppGR.getValue('scope'));
mbrAppGR.setValue('description', sysAppGR.getValue('short_description'));
mbrAppGR.setValue('current_version', sysAppGR.getValue('version'));
mbrAppGR.setValue('active', true);
if (sysAppGR.getValue('logo') && !mbrAppGR.getValue('logo')) {
	mbrAppGR.setValue('logo', this.copyLogoImage(answer));
}
mbrAppGR.update();

Of course, now that we have done that, we will need to build a new copyLogoImage function. We already have a function that copies an attachment (Phase 4), and we can steal most of the code from that guy.

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: ' + values);
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' + values);
	}

	return answer;
}

We want to return the attachment sys_id and not the answer object, and we need to swap out the table names, but other than that, it looks pretty similar:

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: ' + values);
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' + values);
	}

	return logoId;
}

That will get the logo image from the Scoped Application attached to our application record during the application publishing process. Now we need to take a look at those functions that send the local artifacts over to the Host instance and see if we can alter them to use the newly modified shared function. The first of such functions is processPhase5, and it moves over the application record.

processPhase5: function(answer) {
	var mbrAppGR = new GlideRecord('x_11556_col_store_member_application');
	if (mbrAppGR.get(answer.mbrAppId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var thisInstance = gs.getProperty('instance_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + thisInstance + '%5Ename%3D' + encodeURIComponent(mbrAppGR.getDisplayValue('name')));
		var response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
			}
			if (!answer.error) {
				var payload = {};
				payload.name = mbrAppGR.getDisplayValue('name');
				payload.scope = mbrAppGR.getDisplayValue('scope');
				payload.description = mbrAppGR.getDisplayValue('description');
				payload.current_version = mbrAppGR.getDisplayValue('current_version');
				payload.active = 'true';
				request  = new sn_ws.RESTMessageV2();
				request.setBasicAuth(this.WORKER_ROOT + host, token);
				request.setRequestHeader("Accept", "application/json");
				if (jsonObject.result && jsonObject.result.length > 0) {
					answer.hostAppId = jsonObject.result[0].sys_id;
					request.setHttpMethod('put');
					request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + answer.hostAppId);
				} else {
					request.setHttpMethod('post');
					request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application');
					payload.provider = thisInstance;
				}
				request.setRequestBody(JSON.stringify(payload, null, '\t'));
				response = request.execute();
				if (response.haveError()) {
					answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
				} else if (response.getStatusCode() != 200 && response.getStatusCode() != 201) {
					answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
				} else {
					jsonString = response.getBody();
					jsonObject = {};
					try {
						jsonObject = JSON.parse(jsonString);
					} catch (e) {
						answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
					}
					if (!answer.error) {
						answer.hostAppId = jsonObject.result.sys_id;
					}
				}
			}
		} else {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		}
	} else {
		answer = this.processError(answer, 'Invalid Member Application sys_id: ' + answer.appSysId);
	}

	return answer;
}

The corresponding shared function is pushApplication in the CollaborationStoreUtils Script Include, which takes the application GlideRecord, the target instance GlideRecord, and the sys_id of the local instance on the target instance as arguments. We already have the application GlideRecord, but will need to fetch the GlideRecord for the Host instance and grab the sys_id of local instance on the Host instance before we can make the call. Assuming that we can create a couple of functions to gather up the information that we need, we can reduce the new processPhase5 function to this:

processPhase5: function(answer) {
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	if (applicationGR.get(answer.mbrAppId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		answer.hostInstanceId = csu.getRemoteInstanceSysId(targetGR);
		var result = csu.pushApplication(applicationGR, targetGR, answer.hostInstanceId);
		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.hostAppId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid Member Application sys_id: ' + answer.mbrAppId);
	}

	return answer;
}

That simplifies the code quite a bit, and yet we will be doing more work, as we will be moving over the logo image and also logging all the REST API calls. Much better. Of course we still need to build out those function to gather up the required arguments, but those should both be fairly straightforward. To fetch the Host instance GlideRecord, I kept the function in the ApplicationPublisher Script Include, but I put the other one in the main CollaborationStoreUtils Script Include, as that involves another REST API call to the Host instance, and I want to keep all of the functions that do that together in the same place. Here is the getHostInstanceGR function in the ApplicationPublisher Script Include:

getHostInstanceGR: function() {
	var instanceGR = new GlideRecord('x_11556_col_store_member_organization');
	instanceGR.get('instance', gs.getProperty('x_11556_col_store.host_instance'));
	return instanceGR;
}

And here is the getRemoteInstanceSysId function in the CollaborationStoreUtils Script Include:

getRemoteInstanceSysId: function(targetGR) {
	var sysId = '';

	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=sys_id&sysparm_query=instance%3D' + gs.getProperty('instance_name');
	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('Content-Type', 'application/json');
	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();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	} else if (result.obj && result.obj.result && result.obj.result.length > 0) {
		sysId = result.obj.result[0].sys_id;
	}
	this.logRESTCall(targetGR, result);

	return sysId;
}

So that takes care of the first of the three functions that need to modified to use the shared REST API functions. Now we just need to do the same thing for the other two, processPhase6 and processPhase7. That should be a little simpler now that we have done the first one, but it’s still a bit of work, so let’s save all of that for our next installment.

Aggregate List Columns, Part XI

“When obstacles arise, you change your direction to reach your goal; you do not change your decision to get there.”
Zig Ziglar

Last time, I completed the changes that I wanted to make to support the ability to click on an aggregate column value and see a list of the records represented by the value. I fully intended to test everything out this time and wrap this up; however, once I started testing things out, I began to realize that there were some inconsistencies in the approach taken for the aggregate columns compared to that taken with the buttons and icons. I don’t really like seeing that, so now I am contemplating scrapping the whole thing and starting over. I’m not fully there just yet, but I really don’t like have two different solutions to virtually the same objective.

To begin my testing, I built a simple modal pop-up widget and then edited the original AggregateTestConfig Script Include to contain an action property with a value of broadcast. That produced the desired clickable link on the aggregate column, but I noticed that there was no tool tip on mouse-over like there is with the buttons and icons. That, of course, is because I did not add the hint property to the aggregate column specification object like there is in the button/icon specification object. That seems like an easy fix, but the other thing that I did not like was that the column was still a link when the value was 0. Since there is nothing to see when the value is 0, it seems to me that that column shouldn’t be clickable unless there are records to view. That should also be an easy fix, so I set both of those concerns aside and moved on to testing the other option, linking to a new page.

Not wanting to disturb my earlier testing, I pulled up the AggregateTestConfig2 Script Include to use the second test page for this effort. You may recall that the second test page is a list of sys_user_grmember table records, not sys_user table records. To make this work for counting up the related records, we added the optional source property to the aggregate column specification object, but the code that I added to support the clickable links did not reference that property. That needed to be addressed as well, but I still wanted to do some testing, so I decided to swap over to the AggregateTestConfig3 Script Include to use the third existing test page instead of the second. That allowed me to complete my testing, and everything seemed to work as it should, but in digging around in the code, I found a couple of things that really disturbed my sense of The Way Things Ought To Be.

For one, to provide a link for buttons and icons, we use the property page_id. To accomplish the same thing for aggregate columns, the property name is action. The first is simply the ID of an existing Portal Page, selected from an sn-record-picker of Portal Pages. The second is a URL query string that includes the ID of the desired Portal Page as well as any other parameters that you might want to include in your URL. Both achieve the desired objective, but again, I do not like to see two different approaches to the same issue in the same component. It does work, but I don’t like it.

The other thing that I discovered is that the code that I added to handle the link to the new page was in the core SHN Data Table widget, while the similar code for the buttons and icons was located in the various wrapper widgets. I am sure that I copied all of that from the original click handler for the entire row, but I am not sure why it is in the wrapper widgets, where it has to be replicated in each and every one, instead of in the core widget, where it would seem to belong. Maybe there was a reason for that when the stock items were first constructed, but I do not know what that reason might have been.

In the midst of all of that, I came across this conversation, which included a link to this article. While I happen to share the article author’s concerns about cloning stock widgets, and he does make a number of valid points in his reply to the original poster, some modifications that you would like to make are so extensive that it makes little sense to attempt to retain whatever might be left of the original artifact. Still, his approach of embedding the original widget underneath your enhancements, which is essentially what the wrapper widgets do with the core data table widget, is an intriguing idea. I still have to study the sample to see how that might be adapted to what I have been trying to do, but I think it might be worth a closer look.

All of things these piled on top of one another make me think that maybe I want to back up the truck and take another shot at this from a different perspective. I definitely want to be able to click on a non-zero aggregate column and see a list of the records represented there, either in a modal pop-up on a new page, but maybe the way that I jumped into this was just a little too hasty. I think I have to do a little more digging around before I fully commit to what I have started here. Maybe this could be done a little better than I have it now. Or maybe not. Hopefully, I can figure all of that out relatively soon and we can still wrap this up and put it behind us.

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!

Aggregate List Columns

“Get a good idea and stay with it. Do it, and work at it until it’s done right.”
Walt Disney

We have had a lot of fun with the Service Portal Data Table Widget on this site. So much so, in fact, that we had to make our own copy to avoid extensive modifications to a core component of the Service Portal. So far, we have created a Configurable Data Table Widget Content Selector, allowed individual columns to be links to referenced records, added buttons and icons, added User Avatars to user columns, set up the Configurable Data Table Widget Content Selector to use a JSON object to configure the widget, created an Editor for the JSON configuration object, added check boxes to the rows, added an additional extension of the base Data Table Widget to use the JSON configuration object directly, and built a User Directory using all of these custom components. That’s quite a bit of customization, but there is at least one more thing that we could do to make this all even better.

The feature that I have in mind is to have one or more columns that would include counts of related records. For example, on a list of Assignment Groups, you might want to include columns for how many people are in the group or how many open Incidents are assigned to the group. These are not columns on the Group table; these are counts of related records. It seems as if we could borrow some of the concepts from our Buttons and Icons strategy to come up with a similar approach to configuring Aggregate List Columns that could be defined when setting up the configuration for the table. You know what I always like to ask myself: How hard could it be?

Let’s take a quick look at what we need to configure a button or icon using the current version of the tools. Then we can compare that to what we would need to configure an Aggregate List Column using a similar approach. Here is the data that we collect when defining a button/icon:

  • Label
  • Name
  • Heading
  • Icon
  • Color
  • Hint
  • Page

The first three would appear to apply to our new requirement as well, but the rest are specific to the button/icon configuration. Still, we could snag those first three, and copy all of the code that deals with those first three, and then ditch the rest and replace them with data points that will be useful to our new purpose. Our list, then, would end up looking something like this:

  • Label
  • Name
  • Heading
  • Table
  • Field
  • Filter

The first three would be treated exactly the same way that their counterparts are treated in the button/icon code. The rest would be unique to our purpose. Table would be the name of the table that contains the related records to be counted. Field would be the name of the reference field on that table that would contain the sys_id of the current row. Filter would be an additional query filter that would be used to further limit the records to be counted. The purpose for the Filter would be to provide for the ability to count only those records desired. For example, a Table of Incident and a Field of assigned_to would count all of the Incidents ever assigned to that person, which is of questionable value. With the ability to add a Filter of active=true, that would limit the count to just those Incidents that were currently open. That is actually a useful statistic to add to a list.

One other thing that would be useful would be the addition of a link URL that would allow you to pull up a list of the records represented in the count. Although I definitely see the value in this additional functionality, anyone who has followed this web site for any length of time knows that I don’t really like to get too wild and crazy right out of the gate. My intent is to just see if I can get the count to appear on the list before I worry too much about other features that would be nice to have, regardless of their obvious value.

So, it seems as if the place to start here would be to pull up a JSON configuration object and see if we can add a configuration for an Aggregate List Column. When we built the User Directory, we added a simple configuration file to support both the Location Roster and the Department Roster. This looks like a good candidate to use as a starting point to see if we can set up a test case. Here is the original configuration file used in the User Directory project:

var RosterConfig = Class.create();
RosterConfig.prototype = Object.extendsObject(ContentSelectorConfig, {
	initialize: function() {
	},

	perspective: [{
		name: 'all',
		label: 'All',
		roles: ''
	}],

	state: [{
		name: 'department',
		label: 'Department'
	},{
		name: 'location',
		label: 'Location'
	}],

	table: {
		all: [{
			name: 'sys_user',
			displayName: 'User',
			department: {
				filter: 'active=true^department={{sys_id}}',
				fields: 'name,title,email,location',
				btnarray: [],
				refmap: {
					cmn_location: 'location_roster'
				},
				actarray: []
			},
			location: {
				filter: 'active=true^location={{sys_id}}',
				fields: 'name,title,department,email',
				btnarray: [],
				refmap: {
					cmn_department: 'department_roster'
				},
				actarray: []
			}
		}]
	},

	type: 'RosterConfig'
});

For our purpose, we don’t really need two state options, so we can simplify this even further by reducing this down to just one that we can call all. Then we can add our example aggregate configuration just above the button configuration. Also, since this is just a test, we will want to limit our list of people to just members of a single Assignment Group, so we can update the filter accordingly to limit the number of rows. Here is the configuration that I came up with for an initial test.

var AggregateTestConfig = Class.create();
AggregateTestConfig.prototype = Object.extendsObject(ContentSelectorConfig, {
	initialize: function() {
	},

	perspective: [{
		name: 'all',
		label: 'All',
		roles: ''
	}],

	state: [{
		name: 'all',
		label: 'All',
	}],

	table: {
		all: [{
			name: 'sys_user',
			displayName: 'User',
			all: {
				filter: 'active=true^sys_idIN46c4aeb7a9fe1981002bbd372644a37b,46d44a23a9fe19810012d100cca80666,5137153cc611227c000bbd1bd8cd2005,5137153cc611227c000bbd1bd8cd2007,9ee1b13dc6112271007f9d0efdb69cd0,f298d2d2c611227b0106c6be7f154bc8',
				fields: 'name,title,email,department,location',
				aggarray: [{
					label: 'Incidents',
					name: 'incidents',
					heading: 'Incidents',
					table: 'incident',
					field: 'assigned_to',
					filter: 'active=true'
				}],
				btnarray: [],
				refmap: {},
				actarray: []
			}
		}]
	},

	type: 'AggregateTestConfig'
});

For now, I just created a simple filter using all of the sys_ids of all of the members of the Hardware group rather than bringing in the group member table and filtering on the group itself. This is not optimum, but this is just a test, and it will avoid other issues that we can discuss at a later time. The main focus at this point, though is the new aggarray property, which includes all of the configuration information that we listed earlier.

aggarray: [{
   label: 'Incidents',
   name: 'incidents',
   heading: 'Incidents',
   table: 'incident',
   field: 'assigned_to',
   filter: 'active=true'
}]

Now that we have a configuration script, we can create a page and try it out, even though we have not yet done any coding to support the new configuration attributes. At this point, we just want to see if the list comes up, and since nothing will be looking for our new data, that portion of the configuration object will just be ignored. I grabbed a copy of the Location Roster page from the User Directory project and then cloned it to create a page that I called Aggregate Test. Then I edited the configuration for the table to change the Configuration Script value to our new Script Include, AggregateTestConfig. I also removed the value that was present in the State field, as we only have one state, so no value is needed.

Updating the Configuration Script on the cloned portal page

With that saved, we can run out to the Service Portal and pull up the new page and see how it looks.

First look at the new test page using our new configuration script

Well, that’s not too bad for just a few minutes of effort. Of course, that was just the easy part. Now we have to tinker with the widgets to actually do something with this new configuration data that we are passing into the modules. That’s going to be a bit of work, of course, so we’ll start taking a look at how we want to do that next time out.

Collaboration Store, Part LIV

“Real happiness lies in the completion of work using your own brains and skills.”
Soichiro Honda

Last time, we finally got to the point where we were able to actually Commit the Update Set, installing the requested version of the application. That was a major milestone, but we are not done just yet. We still have to update the version and application records with the fact that this version has now been installed. That’s a server-side operation, so we will need to add yet one more client callable function to our installation utilities Script Include. As we did before, we will use the attachment ID to locate the version record and the associated application record and then update them both. We will also need to hunt down the installed application record so that we can link it to the application record in the Collaboration Store database. We can also use data extracted from the version and application records to build a final status message informing the operator that the installation process is now complete.

We’ll call our new function recordInstallation, and start out by creating a response object, defaulting the success property to false, and then grabbing the attachment ID parameter that was passed in the Ajax call.

recordInstallation: function() {
	var answer = {success: false};
	var sysId = this.getParameter('attachment_id');
	if (sysId) {
		...
	} else {
		answer.error = 'Missing required parameter: attachment_id';
	}
	return JSON.stringify(answer);
}

Assuming that we have a sys_id, we will use it to get the attachment record, and then use the data in the attachment record to fetch the version and application records.

var sysAttGR = new GlideRecord('sys_attachment');
if (sysAttGR.get(sysId)) {
	var versionGR = new GlideRecord(sysAttGR.getDisplayValue('table_name'));
	if (versionGR.get(sysAttGR.getDisplayValue('table_sys_id'))) {
		answer.versionId = versionGR.getUniqueValue();
		var applicationGR = versionGR.member_application.getRefRecord();
		...
	} else {
		answer.error = 'Version record not found for sys_id ' + sysAttGR.getDisplayValue('table_sys_id');
	}
} else {
	answer.error = 'Attachment record not found for sys_id ' + sysId;
}

Once we have the version record and the application record in hand, we will need to hunt down the newly installed system application record.

var sysAppGR = new GlideRecord('sys_app');
sysAppGR.addQuery('scope', applicationGR.getValue('scope'));
sysAppGR.query();
if (sysAppGR.next()) {
	...
} else {
	answer.error = 'System application record not found for scope ' + applicationGR.getValue('scope');
}

Once we have the system application record, then we know that the application has been installed, and we can format the final status message.

answer.statusMessage = 'Version <strong>' + versionGR.getDisplayValue('version') + '</strong> of application <strong>' + applicationGR.getDisplayValue('name') + '</strong> installed.';

Now we need to link the installed system application record to our application record.

applicationGR.application = sysAppGR.getUniqueValue();
applicationGR.update();

We also need to mark the version as being installed.

versionGR.installed = true;
versionGR.update();

One other thing that we will need to do is to go through any other version records associated with this application and make sure that none of those are any longer marked as installed.

versionGR.initialize();
versionGR.addQuery('member_application', applicationGR.getUniqueValue());
versionGR.addQuery('sys_id', '!=', answer.versionId);
versionGR.query();
while (versionGR.next()) {
	versionGR.installed = false;
	versionGR.update();
}

That completes the updates to the Collaboration Store records, so the only thing left to do at this point is to override our initial response object success value with true.

answer.success = true;

Putting it all together, the entire function looks like this:

recordInstallation: function() {
	var answer = {success: false};
	var sysId = this.getParameter('attachment_id');
	if (sysId) {
		var sysAttGR = new GlideRecord('sys_attachment');
		if (sysAttGR.get(sysId)) {
			var versionGR = new GlideRecord(sysAttGR.getDisplayValue('table_name'));
			if (versionGR.get(sysAttGR.getDisplayValue('table_sys_id'))) {
				answer.versionId = versionGR.getUniqueValue();
				var applicationGR = versionGR.member_application.getRefRecord();
				var sysAppGR = new GlideRecord('sys_app');
				sysAppGR.addQuery('scope', applicationGR.getValue('scope'));
				sysAppGR.query();
				if (sysAppGR.next()) {
					answer.statusMessage = 'Version <strong>' + versionGR.getDisplayValue('version') + '</strong> of application <strong>' + applicationGR.getDisplayValue('name') + '</strong> installed.';
					applicationGR.application = sysAppGR.getUniqueValue();
					applicationGR.update();
					versionGR.installed = true;
					versionGR.update();
					versionGR.initialize();
					versionGR.addQuery('member_application', applicationGR.getUniqueValue());
					versionGR.addQuery('sys_id', '!=', answer.versionId);
					versionGR.query();
					while (versionGR.next()) {
						versionGR.installed = false;
						versionGR.update();
					}
					answer.success = true;
				} else {
					answer.error = 'System application record not found for scope ' + applicationGR.getValue('scope');
				}
			} else {
				answer.error = 'Version record not found for sys_id ' + sysAttGR.getDisplayValue('table_sys_id');
			}
		} else {
			answer.error = 'Attachment record not found for sys_id ' + sysId;
		}
	} else {
		answer.error = 'Missing required parameter: attachment_id';
	}
	return JSON.stringify(answer);
}

That takes care of the server-side code; now we have to update the Client script in our UI Page to make the GlideAjax call to the function and then return the operator to the original version record where the Install button was first selected. At the end of the Commit process, we referenced an updateStoreData function, but did not create it. We will need to create this function now, and then we can make the call within that new function. Before we do that, though, I wanted to note a slight modification that I made to the HTML for that page to add an id tag to the H4 element that includes our status message. The reason that I wanted to do that was so that the final message would not only replace the wording, but it would also replace the loading image that indicated that there was an ongoing process. Once we get to this point, the processing is over, so I did not want to leave that spinning image up on the screen. Here is the modified version of that portion of the HTML:

<h4 style="padding: 30px;" id="final_status_text">
  &#160;
  <img src="/images/loading_anim4.gif" height="18" width="18"/>
  &#160;
  <span id="status_text">Previewing Uploaded Update Set ...</span>
</h4>

Now we can create our new updateStoreData function.

function updateStoreData() {
	document.getElementById('status_text').innerHTML = 'Updating Collaboration Store Database ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'recordInstallation');
	ga.addParam('attachment_id', attachmentId);
	ga.getXMLAnswer(finalizeInstallation);
}

Now we have referenced a finalizeInstallation function, so we will need to create that as well. This function simply adds that final status message to the page and then returns the operator back to the original version form page.

function finalizeInstallation(answer) {
	var result = JSON.parse(answer);
	document.getElementById('final_status_text').innerHTML = result.statusMessage;
	window.location.href = '/x_11556_col_store_member_application_version.do?sys_id=' + result.versionId;
}

Here is the full Client script for the UI Page from top to bottom.

var dataLossConfirmDialog;
var attachmentId = '';
var updateSetId = '';
var commitInProgress = false;

function onLoad() {
	attachmentId = document.getElementById('attachment_id').value;
	updateSetId = document.getElementById('remote_update_set_id').value;
	if (updateSetId) {
		previewRemoteUpdateSet();
	}
}

addLoadEvent(function() {
	onLoad();
});

function previewRemoteUpdateSet() {
	var MESSAGE_KEY_DIALOG_TITLE = "Update Set Preview";
	var MESSAGE_KEY_CLOSE_BUTTON = "Close";
	var MESSAGE_KEY_CANCEL_BUTTON = "Cancel";
	var MESSAGE_KEY_CONFIRMATION = "Confirmation";
	var MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE = "Are you sure you want to cancel this update set preview?";
	var map = new GwtMessage().getMessages([MESSAGE_KEY_DIALOG_TITLE, MESSAGE_KEY_CLOSE_BUTTON, MESSAGE_KEY_CANCEL_BUTTON, MESSAGE_KEY_CONFIRMATION, MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");

	dd.setTitle(map[MESSAGE_KEY_DIALOG_TITLE]);
	dd.setPreference('sysparm_ajax_processor', 'UpdateSetPreviewAjax');
	dd.setPreference('sysparm_ajax_processor_function', 'preview');
	dd.setPreference('sysparm_ajax_processor_sys_id', updateSetId);
	dd.setPreference('sysparm_renderer_expanded_levels', '0');
	dd.setPreference('sysparm_renderer_hide_drill_down', true);
	dd.setPreference('focusTrap', true);
	dd.setPreference('sysparm_button_close', map["Close"]);
	dd.on("executionStarted", function(response) {
		var trackerId = response.responseXML.documentElement.getAttribute("answer");

		var cancelBtn = new Element("button", {
			'id': 'sysparm_button_cancel',
			'type': 'button',
			'class': 'btn btn-default',
			'style': 'margin-left: 5px; float:right;'
		}).update(map[MESSAGE_KEY_CANCEL_BUTTON]);

		cancelBtn.onclick = function() {
			var dialog = new GlideModal('glide_modal_confirm', true, 300);
			dialog.setTitle(map[MESSAGE_KEY_CONFIRMATION]);
			dialog.setPreference('body', map[MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
			dialog.setPreference('focusTrap', true);
			dialog.setPreference('callbackParam', trackerId);
			dialog.setPreference('defaultButton', 'ok_button');
			dialog.setPreference('onPromptComplete', function(param) {
				var cancelBtn2 = $("sysparm_button_cancel");
				if (cancelBtn2)
					cancelBtn2.disable();
				var ajaxHelper = new GlideAjax('UpdateSetPreviewAjax');
				ajaxHelper.addParam('sysparm_ajax_processor_function', 'cancelPreview');
				ajaxHelper.addParam('sysparm_ajax_processor_tracker_id', param);
				ajaxHelper.getXMLAnswer(_handleCancelPreviewResponse);
			});
			dialog.render();
			dialog.on("bodyrendered", function() {
				var okBtn = $("ok_button");
				if (okBtn) {
					okBtn.className += " btn-destructive";
				}
			});
		};

		var _handleCancelPreviewResponse = function(answer) {
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.remove();
		};

		var buttonsPanel = $("buttonsPanel");
		if (buttonsPanel)
			buttonsPanel.appendChild(cancelBtn);
	});

	dd.on("executionComplete", function(trackerObj) {
		dd.destroy();
		checkPreviewResults();
	});
	
	dd.render();
}

function checkPreviewResults() {
	document.getElementById('status_text').innerHTML = 'Evaluating Preview Results ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'evaluatePreview');
	ga.addParam('remote_update_set_id', updateSetId);
	ga.getXMLAnswer(commitUpdateSet);
}

function commitUpdateSet(answer) {
	var result = JSON.parse(answer);
	var message = '';
	if (result.accepted > 0) {
		if (result.accepted > 1) {
			message += result.accepted + ' Flagged Updates Accepted; ';
		} else {
			message += 'One Flagged Update Accepted; ';
		}
	}
	if (result.skipped > 0) {
		if (result.skipped > 1) {
			message += result.skipped + ' Flagged Updates Skipped; ';
		} else {
			message += 'One Flagged Update Skipped; ';
		}
	}
	message += 'Committing Update Set ...';
	document.getElementById('status_text').innerHTML = message;
	commitRemoteUpdateSet();
}

function commitRemoteUpdateSet() {
	if (commitInProgress) {
		return;
	}

	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'validateCommitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', updateSetId);
	ajaxHelper.getXMLAnswer(getValidateCommitUpdateSetResponse);
}

function getValidateCommitUpdateSetResponse(answer) {
	try {
		if (answer == null) {
			console.log('validateCommitRemoteUpdateSet answer was null');
			return;
		}
		console.log('validateCommitRemoteUpdateSet answer was ' + answer);
		var returnedInfo = answer.split(';');
		var sysId = returnedInfo[0];
		var encodedQuery = returnedInfo[1];
		var delObjList = returnedInfo[2];
		if (delObjList !== "NONE") {
			console.log('showing data loss confirm dialog');
			showDataLossConfirmDialog(sysId, delObjList, encodedQuery);
		} else {
			console.log('skipping data loss confirm dialog');
			runTheCommit(sysId);
		}
	} catch (e) {
		console.log(e);
	}
}

function runTheCommit(sysId) {
	console.log('running commit on ' + sysId);
	commitInProgress = true;
	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'commitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', sysId);
	ajaxHelper.getXMLAnswer(getCommitRemoteUpdateSetResponse);
}

function destroyDialog() {
	dataLossConfirmDialog.destroy();
}

function showDataLossConfirmDialog(sysId, delObjList, encodedQuery) {
	var dialogClass = typeof GlideModal != 'undefined' ? GlideModal : GlideDialogWindow;
	var dlg = new dialogClass('update_set_data_loss_commit_confirm');
	dataLossConfirmDialog = dlg;
	dlg.setTitle('Confirm Data Loss');
	if(delObjList == null) {
		dlg.setWidth(300);
	} else {
		dlg.setWidth(450);
	}
	dlg.setPreference('sysparm_sys_id', sysId);
	dlg.setPreference('sysparm_encodedQuery', encodedQuery);
	dlg.setPreference('sysparm_del_obj_list', delObjList);
	console.log('rendering data loss confirm dialog');
	dlg.render();
}

function getCommitRemoteUpdateSetResponse(answer) {
	try {
		if (answer == null) {
			return;
		}
		var map = new GwtMessage().getMessages(["Close", "Cancel", "Are you sure you want to cancel this update set?", "Update Set Commit", "Go to Subscription Management"]);
		var returnedIds = answer.split(',');
		var workerId = returnedIds[0];
		var sysId = returnedIds[1];
		var shouldRefreshNav = returnedIds[2];
		var shouldRefreshApps = returnedIds[3];
		var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
		var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");
		dd.setTitle(map["Update Set Commit"]);
		dd.setPreference('sysparm_renderer_execution_id', workerId);
		dd.setPreference('sysparm_renderer_expanded_levels', '0');
		dd.setPreference('sysparm_renderer_hide_drill_down', true);
		dd.setPreference('sysparm_button_subscription', map["Go to Subscription Management"]);
		dd.setPreference('sysparm_button_close', map["Close"]);
		dd.on("bodyrendered", function(trackerObj) {
			var buttonsPanel = $("buttonsPanel");
			var table = new Element("table", {cellpadding: 0, cellspacing: 0, width : "100%"});
			buttonsCell = table.appendChild(new Element("tr")).appendChild(new Element("td"));
			buttonsCell.align = "right";
			buttonsPanel.appendChild(table);
			var closeBtn = $("sysparm_button_close");
			if (closeBtn) {
				closeBtn.disable();
			}
			var cancelBtn = new Element("button");
			cancelBtn.id = "sysparm_button_cancel";
			cancelBtn.type = "button";
			cancelBtn.innerHTML = map["Cancel"];
			cancelBtn.onclick = function() {
				var response = confirm(map["Are you sure you want to cancel this update set?"]);
				if (response != true) {
					return;
				}
				var ajaxHelper = new GlideAjax('UpdateSetCommitAjax');
				ajaxHelper.addParam('sysparm_type', 'cancelRemoteUpdateSet');
				ajaxHelper.addParam('sysparm_worker_id', workerId);
				ajaxHelper.getXMLAnswer(getCancelRemoteUpdateSetResponse);
			};
			buttonsCell.appendChild(cancelBtn);
		});

		dd.on("executionComplete", function(trackerObj) {
			dd.destroy();
			updateStoreData();
		});

		dd.render();
	} catch (e) {
		console.log(e);
	}
}

function getCancelRemoteUpdateSetResponse(answer) {
	if (answer == null) {
		return;
	}
}

function updateStoreData() {
	document.getElementById('status_text').innerHTML = 'Updating Collaboration Store Database ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'recordInstallation');
	ga.addParam('attachment_id', attachmentId);
	ga.getXMLAnswer(finalizeInstallation);
}

function finalizeInstallation(answer) {
	var result = JSON.parse(answer);
	document.getElementById('final_status_text').innerHTML = result.statusMessage;
	window.location.href = '/x_11556_col_store_member_application_version.do?sys_id=' + result.versionId;
}

That completes the installation process, the third and final major component of the Collaboration Store application. All that is left now is to release a new Update Set to the testers and see what kinds of bugs we can shake out of this thing before we actually produce an official 1.0 version of the app. If you have not had an opportunity to participate in the testing just yet, now might be a good time to jump in and see what you can find. Feedback of any kind is always welcome. We’ll get more into all of that next time out.

Collaboration Store, Part LII

“Long is the road from conception to completion.”
Molière

Last time, we finished up the Update Set Preview process and it looked like all that was left was to code out the Commit process and we would be done with the last major component of this long drawn-out project. Unfortunately, that’s not entirely true. Before we can move on to the Commit process, we have to deal with the fact that the Preview process may have uncovered some issues with the Update Set. In the manual process, these issues are reported to the operator, and the operator is required to deal them all before the Commit option is available. Not only do we need to address that possibility, we also have to add code to update the application and version records to reflect the version that was just installed and to link the newly installed application with the application record. So we have a little more work to do beyond just launching the Commit process before we can declare project completion.

First of all, we need to decide what to do with any Preview issues that may have been detected. Ideally, you would want to give the operator the opportunity to review these issues and make the appropriate decisions based on their knowledge of their instance and the application. However, since we are trying to make this first version as automated as possible, I have decided to have the software make arbitrary decisions about each reported problem, at least for now. In some future version, I may want to pop up a dialog and ask the operator whether they want to do their own review or trust the system to do it for them, but for now, that’s a little more sophisticated than I am ready to tackle. This may not be the best approach, but it is the simplest, and I am trying wrap up the work on this initial version.

My plan is to add yet another client-callable function to our existing ApplicationInstaller Script Include that will hunt down all of the problems and resolve them. The problem records have a field called available_actions that contains a list of all of the actions available for the problem, so I am going to use that as a guide to Accept Remote Update if I can, or Skip Remote Update if I cannot. I also want to keep track of the number of problems found, the number of updates accepted, and the number of updates skipped so that I can report that information back to the caller. In reviewing the code behind the UI Actions that accept and skip updates, I found a call to a global component called GlidePreviewProblemAction, but when I tried to access that component in my scoped Script Include, I got a security violation error. To work around that, I had to add the following new function to our global utilities, where I could make the call without error.

fixRemoteUpdateIssue: function(remUpdGR) {
	var resolution = 'accepted';
	var ppa = new GlidePreviewProblemAction(gs.action, remUpdGR);
	if (remUpdGR.available_actions.contains('43d7d01a97b00100f309124eda2975e4')) {
		ppa.ignoreProblem();
	} else {
		ppa.skipUpdate();
		resolution = 'skipped';
	}
	return resolution;
}

With that out of the way, I was able to put the rest of the code where it belonged, and just called out to the global component for the part that I was unable to do in the scoped component.

evaluatePreview: function() {
	var answer = {problems: 0, accepted: 0, skipped: 0};
	var sysId = this.getParameter('remote_update_set_id');
	if (sysId) {
		problemId = [];
		var remUpdGR = new GlideRecord('sys_update_preview_problem');
		remUpdGR.addQuery('remote_update_set', sysId);
		remUpdGR.query();
		while (remUpdGR.next()) {
			problemId.push(remUpdGR.getUniqueValue());
			answer.problems++;
		}
		var csgu = new global.CollaborationStoreGlobalUtils();
		for (var i=0; i<problemId.length; i++) {
			remUpdGR.get(problemId[i]);
			var resolution = csgu.fixRemoteUpdateIssue(remUpdGR);
			if (resolution == 'accepted') {
				answer.accepted++;
			} else {
				answer.skipped++;
			}
		}
	}
	return JSON.stringify(answer);
}

Now we just need make the GlideAjax call to that function from the client side before we attempt to launch the Commit process. Right now, when the Preview process is complete, a Close button appears on the progress dialog, and when you click on the Close button, our new UI Page reloads and starts all over again because the script that we lifted from the UI Action on the Update Set form was set up to reload that form. For our purposes, we do not want our own page reloaded, and in fact, we don’t even want a Close button; we just want to move on to the process of reviewing the results of the Preview. The relevant portion of the script that we stole looks like this:

dd.on("executionComplete", function(trackerObj) {
	var cancelBtn = $("sysparm_button_cancel");
	if (cancelBtn)
		cancelBtn.remove();
         
	var closeBtn = $("sysparm_button_close");
	if (closeBtn) {
		closeBtn.onclick = function() {
			dd.destroy();
		};
	}
});
     
dd.on("beforeclose", function() {
	reloadWindow(window);
});

Since we do not want to wait for operator action, we can short-cut this entire operation and just move on as soon as execution has been completed. I replaced all of the above with the following:

dd.on("executionComplete", function(trackerObj) {
	dd.destroy();
	checkPreviewResults();
});

Since the Preview process is now complete at this point, and we are now looking at the results, I decided to wrap the original message on the page with a span that had an id attribute so that I could change the message as things moved along. That line of HTML now looks like this:

<span id="status_text">Previewing Uploaded Update Set ...</span>

With that in place, I was able to update the message with the new status before I made the Ajax call to our new Script Include function.

function checkPreviewResults() {
	document.getElementById('status_text').innerHTML = 'Evaluating Preview Results ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'evaluatePreview');
	ga.addParam('remote_update_set_id', updateSetId);
	ga.getXMLAnswer(commitUpdateSet);
}

function commitUpdateSet(answer) {
	alert(answer);
}

I’m not ready to take on the Commit process just yet, so I stubbed out the commitUpdateSet function with a simple alert of the response from our Ajax call. That was enough to let me know that everything was working up to this point, which is what I needed to know before I attempted to move on.

Now that we have dealt with the possibility of Preview problems, we can finally take a look at what it will take to Commit the Update Set. That’s obviously a bit of work, so we’ll leave all of that for our next episode.

Collaboration Store, Part LI

“Plodding wins the race.”
Aesop

Last time, we ended with yet another unresolved fork in the road, whether to launch the Preview process from the upload.do page or to build yet another new page specific to the application installation process. At the time, it seemed as if there were equal merits to either option, but today I have decided that building a new page would be the preferable alternative. For one thing, that keeps the artifacts involved within the scope of our application (our global UI Script to repurpose the upload.do page had to be in the global scope), and it keeps the alterations to upload.do to the bare minimum.

Before we go off and build a new page, though, we will need to figure out how we are going to get there without the involvement of the operator (we want this whole process to be as automatic as possible). Digging through the page source of the original upload.do page, I found something that looks as if it might be relevant to our needs:

<input value="sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set" name="sysparm_referring_url" type="hidden"></input>

Now, the name of this element is sysparm_referring_url, which sounds an awful lot like it would be the URL from which we came; however, this is actually the URL where we end up after the Update Set XML file is uploaded, so I am thinking that if we replaced this value with a link to our own page, maybe we would end up there instead. Only one way to find out …

Those of you following along at home may recall that this value, which appears in the HTML source, actually disappeared somehow before the form was submitted, so I had to add this line of code to our script to put it back:

document.getElementsByName('sysparm_referring_url')[0].value = 'sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set';

Assuming that we create a new UI Page for the remainder of the process and that we want to pass to it the attachment ID, we should be able to replace that line with something like this:

document.getElementsByName('sysparm_referring_url')[0].value = 'ui_page.do?sys_id=<sys_id of our new page>&sysparm_id=' + window.location.search.substring(15);

Now all we need to do is create the page, put something on it, and then add the code that we stole from the UI Action that launches the Update Set Preview. After we hacked up the upload.do page, the end result turned out looking like this:

Modified upload.do page

To keep things looking consistent, we can steal some of the HTML from that page and make our new page look something like this:

New page layout

To make that happen, we can snag most of the HTML from a quick view frame source and then format it and stuff it into a new UI Page called install_application:

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="true" xmlns:j="jelly:core" xmlns:g="glide" xmlns:g2="null">

<div>
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button class="btn btn-default icon-chevron-left navbar-btn" onclick="history.back();">
          <span class="sr-only">Back</span>
        </button>
        <h1 style="display:inline-block;" class="navbar-title">Install Application</h1>
      </div>
    </div>
  </nav>
  <div class="section-content">
    <div id="output_messages" class="outputmsg_container outputmsg_hide">
      <button aria-label="Close Messages" id="close-messages-btn" class="btn btn-icon close icon-cross" onclick="GlideUI.get().clearOutputMessages(this); return false;"></button>
      <div class="outputmsg_div" aria-live="polite" role="region" data-server-messages="false"></div>
    </div>
    <div class="row">
      <div class="col-sm-12">
        <h4 style="padding: 30px;">
          &#160;
          <img src="/images/loading_anim4.gif" height="18" width="18"/>
          &#160;
          Previewing Uploaded Update Set ...
        </h4>
      </div>
    </div>
  </div>
</div>
</j:jelly>

That takes care how the page looks. Now we need to deal with how it works. To Preview an uploaded Update Set, you need the Remote Update Set‘s sys_id. We have a URL parameter that contains the sys_id of the Update Set XML file attachment, but that’s not the sys_id that we need at this point. We will have to build a process that uses the attachment sys_id to locate and return the sys_id that we will need. We can just add another function to our existing ApplicationInstaller Script Include.

getRemoteUpdateSetId: function(attachmentId) {
	var sysId = '';

	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(attachmentId)) {
		var versionGR = new GlideRecord(sysAttGR.getDisplayValue('table_name'));
		if (versionGR.get(sysAttGR.getDisplayValue('table_sys_id'))) {
			var updateSetGR = new GlideRecord('sys_remote_update_set');
			updateSetGR.addQuery('application_name', versionGR.getDisplayValue('member_application'));
			updateSetGR.addQuery('application_scope', versionGR.getDisplayValue('member_application.scope'));
			updateSetGR.addQuery('application_version', versionGR.getDisplayValue('version'));
			updateSetGR.addQuery('state', 'loaded');
			updateSetGR.query();
			if (updateSetGR.next()) {
				sysId = updateSetGR.getUniqueValue();
			}
		}
	}

	return sysId;
}

Basically, we use the passed attachment record sys_id to get the attachment record, then use data found on the attachment record to get the version record, and then use data found on the version record and associated application record to get the remote update set record, and then pull the sys_id that we need from there. Those of you who have been paying close attention may notice that one of the application record fields being used to find the remote update set is scope. The scope of the application was never included in the original list of data fields for the application record, so I had to go back and add it everywhere in the system where an application record was referenced, modified, or moved between instances. That was a bit of work, and hopefully I have found them all, but I think that was everything.

Anyway, now we have a way to turn an attachment record sys_id into a remote update set record sys_id, so we need to add some code to our UI Page to snag the attachment record sys_id from the URL, use it to get the sys_id that we need, and then stick that value on the page somewhere so that it can be picked up by the client-side code. At the top of the HTML for the page, I added this:

<g2:evaluate jelly="true">

var ai = new ApplicationInstaller();
var attachmentId = gs.action.getGlideURI().get('sysparm_id');
var sysId = ai.getRemoteUpdateSetId(attachmentId);

</g2:evaluate>

Then in the body of the page, just under the text, I added this hidden input element:

<input type="hidden" id="remote_update_set_id" value="$[sysId]"/>

That took care of things on the server side. Now we need to build some client-side code that will run when the page is loaded. We can do that with an addLoadEvent like so:

addLoadEvent(function() {  
	onLoad();
});

Our onLoad function can then grab the value from the hidden field and pass it on to the function that we lifted from the Preview Update Set UI Action earlier (which we need to paste into the client code section of our new UI Page).

function onLoad() {
	var sysId = document.getElementById('remote_update_set_id').value;
	if (sysId) {
		previewRemoteUpdateSet(sysId);
	}
}

That’s all there is to that. The entire Client script portion of the new UI Page, including the code that we lifted from the UI Action, now looks like this:

function onLoad() {
	var sysId = document.getElementById('remote_update_set_id').value;
	if (sysId) {
		previewRemoteUpdateSet(sysId);
	}
}

addLoadEvent(function() {  
	onLoad();
});

function previewRemoteUpdateSet(sysId) {
	var MESSAGE_KEY_DIALOG_TITLE = "Update Set Preview";
	var MESSAGE_KEY_CLOSE_BUTTON = "Close";
	var MESSAGE_KEY_CANCEL_BUTTON = "Cancel";
	var MESSAGE_KEY_CONFIRMATION = "Confirmation";
	var MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE = "Are you sure you want to cancel this update set preview?";
	var map = new GwtMessage().getMessages([MESSAGE_KEY_DIALOG_TITLE, MESSAGE_KEY_CLOSE_BUTTON, MESSAGE_KEY_CANCEL_BUTTON, MESSAGE_KEY_CONFIRMATION, MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");

	dd.setTitle(map[MESSAGE_KEY_DIALOG_TITLE]);
	dd.setPreference('sysparm_ajax_processor', 'UpdateSetPreviewAjax');
	dd.setPreference('sysparm_ajax_processor_function', 'preview');
	dd.setPreference('sysparm_ajax_processor_sys_id', sysId);
	dd.setPreference('sysparm_renderer_expanded_levels', '0');
	dd.setPreference('sysparm_renderer_hide_drill_down', true);
	dd.setPreference('focusTrap', true);
	dd.setPreference('sysparm_button_close', map["Close"]);
    dd.on("executionStarted", function(response) {
		var trackerId = response.responseXML.documentElement.getAttribute("answer");

		var cancelBtn = new Element("button", {
			'id': 'sysparm_button_cancel',
			'type': 'button',
			'class': 'btn btn-default',
			'style': 'margin-left: 5px; float:right;'
		}).update(map[MESSAGE_KEY_CANCEL_BUTTON]);

        cancelBtn.onclick = function() {
			var dialog = new GlideModal('glide_modal_confirm', true, 300);
			dialog.setTitle(map[MESSAGE_KEY_CONFIRMATION]);
			dialog.setPreference('body', map[MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
			dialog.setPreference('focusTrap', true);
			dialog.setPreference('callbackParam', trackerId);
			dialog.setPreference('defaultButton', 'ok_button');
			dialog.setPreference('onPromptComplete', function(param) {
				var cancelBtn2 = $("sysparm_button_cancel");
				if (cancelBtn2)
					cancelBtn2.disable();
				var ajaxHelper = new GlideAjax('UpdateSetPreviewAjax');
				ajaxHelper.addParam('sysparm_ajax_processor_function', 'cancelPreview');
				ajaxHelper.addParam('sysparm_ajax_processor_tracker_id', param);
				ajaxHelper.getXMLAnswer(_handleCancelPreviewResponse);
			});
			dialog.render();
			dialog.on("bodyrendered", function() {
				var okBtn = $("ok_button");
				if (okBtn) {
					okBtn.className += " btn-destructive";
				}
			});
        };

		var _handleCancelPreviewResponse = function(answer) {
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.remove();
		};

        var buttonsPanel = $("buttonsPanel");
        if (buttonsPanel)
			buttonsPanel.appendChild(cancelBtn);
	});

	dd.on("executionComplete", function(trackerObj) {
		var cancelBtn = $("sysparm_button_cancel");
		if (cancelBtn)
			cancelBtn.remove();
		
		var closeBtn = $("sysparm_button_close");
		if (closeBtn) {
			closeBtn.onclick = function() {
				dd.destroy();
			};
		}
	});
	
	dd.on("beforeclose", function() {
		reloadWindow(window);
	});
	
	dd.render();
}

Now all we need to do is pull up the old version record and push that Install button one more time, which I did.

So, there is good news and there is bad news. The good news is that it actually worked! That is to say that clicking on the Install button pulls down the Update Set XML file data, posts it back to the server via the modified upload.do page, and then goes right into previewing the newly created Update Set. That part is very cool, and something that I wasn’t sure that I was going to be able to pull off when I first started thinking about doing this. The bad news is that, once the Preview is complete, the stock code reloads the page and the whole Preview process starts all over again. That’s not good! However, that seems like a minor issue with which we should be able to deal relatively easy. All in all, then, it seems like mostly good news.

Of course, we are still not there yet. Once an Update Set has been Previewed, it sill has to be Committed before the application is actually installed. Rather than continuously reloading the page then, our version of the UI Action code is going to need to launch the Commit process. We should be able to examine the Commit UI Action as we did the Preview UI Action and steal some more code to make that happen. That sounds like a little bit of work, though, so let’s save all of that for our next installment.

Collaboration Store, Part XLVIII

“Most times, the way isn’t clear, but you want to start anyway. It is in starting that other steps become clearer.”
Israelmore Ayivor

Last time, we created a process to retrieve the Update Set XML data from the server side and then built a UI Action to launch the installation process. At the time that we left off, I was vacillating back and forth between hacking up the original upload.do page and creating a customized copy of my own. Since that time, though, I have decided that I am much too lazy to try to build one of my own, so I am just going to attempt to hack up the one that already exists with as minimal amount of intervention as I can muster. The one way that I know how to do that is to create a global UI Script that modifies the page on the fly without actually altering the source of the page itself. We have already used this technique with our earlier incident email hack, so at least we know that this approach is one that will work.

Unfortunately, you cannot create global UI Scripts in a Scoped Application; the script has to be in the global scope, so this component will be yet another addition to our global components Update Set. I don’t really like having all of these parts outside of the application, but that’s just the way that these things go sometimes. These global scripts run on every single page load in the system, so to be a minimally intrusive as possible, the very first thing that you want to check is whether or not you are running on a page in which this code is needed. For our purposes, we only want this code to run on the upload.do page, and only if our attachment_id parameter is present in the URL.

if (window.location.pathname == '/upload.do' && window.location.search.startsWith('?attachment_id=')) {
	alert('So far, so good ...');
}

We can test this out by going into a version record and clicking on the new Install form button.

First test of the new global UI Script

OK, that works. In fact, that also proves out the code on the UI Action that we created last time. As the alert says, so far, so good. One thing that you will notice, however, is that there is nothing on the underlying screen. This code runs as soon as it is loaded, and the rest of the page has yet to be delivered. Since our plan is to tinker with that page, we really don’t want our code to be running just yet. We will need to wait to make sure that the rest of the page is there as well before we attempt to alter it. We can accomplish that with a little recursive loop that will look for an important field such as the file to be uploaded, and until that element is present, just loop back and check again. Here is a modified version of the script that will accomplish that.

if (window.location.pathname == '/upload.do' && window.location.search.startsWith('?attachment_id=')) {
	waitForPageLoad();
}

function waitForPageLoad() {
	if (document.getElementById('attachFile')) {
		installApplication();
	} else {
		setTimeout(waitForPageLoad, 100);
	}
}

function installApplication() {
	alert('So far, so good ...');
}

If that works as intended, the alert should not pop until at least the parts of the page in which we are interested have arrived.

Second test of the new global UI Script

That’s better. Now at least the stuff that we want to play with is all present in the DOM. The first thing that we will want to do is to hide the original form and then replace it with some kind of message indicating that things are happening in the background and there is nothing for the operator to do right at the moment. Here is a little code that will find the DIV that contains the major components, hides it, and replaces it with something else.

var originalContent = document.getElementsByClassName('section-content')[0];
originalContent.style.visibility = 'hidden';
var newContent = document.createElement('div');
newContent.innerHTML = '<h4 style="padding: 30px;">&nbsp;<img src="/images/loading_anim4.gif" height="18" width="18">&nbsp;Uploading Update Set XML file ...</h4>';
originalContent.parentNode.insertBefore(newContent, originalContent);

There are a couple of things to note on the above code. For one, DOM manipulation is frowned upon in the ServiceNow environment. You will get tagged for that in an Instance Scan as a bad practice, and you should really try to avoid doing things like that if at all possible. Still, sometimes you have to break the rules to get something done; there is a reason that this site is called ServiceNow Hackery and not ServiceNow By The Book. Sometimes you have to step outside of the lines in order to do what you want to do. But again, this should be a last resort and not adopted as a routine way of doing things. The other thing to note is the use of the innerHTML method. Again, the preferred way of doing things would be to create each DOM node individually, set all of the appropriate values on each node, and then link them all up to each other before inserting them into the active DOM. That’s the way that it should be done, but I was just too lazy to go through all of that and I took the easy way out instead. But that’s another thing to which folks might take exception in certain circles.

To test all of this out, we can go back to our version page and click on the new Install button one more time.

Third test of the new global UI Script

With all of that basic housekeeping out of the way, we can now focus on what we are here for. The first thing that we need to do in order to accomplish our goal is to pull down the Update Set details using GlideAjax to access the Script Include that we created last time. Before we do that, though, we need to snag the attachment record sys_id from the URL parameter. With that in hand, we can then make our Ajax call.

var attachmentId = window.location.search.substring(15);
var ga = new GlideAjax('x_11556_col_store.ApplicationInstaller');
ga.addParam('sysparm_name', 'getXML');
ga.addParam('attachment_id', attachmentId);
ga.getXMLAnswer(submitForm);

Now we just need to build a submitForm function that will parse the returned JSON string to access the file name and file contents, and then somehow use that as if it were a file on the local system so that we can submit the form. That sounds like a bit of work in and of itself, and I’m still not exactly sure how I am going to pull that off, so let’s save that exercise for our next exciting installment.