Collaboration Store, Part XLIII

“You don’t start out writing good stuff. You start out writing crap and thinking it’s good stuff, and then gradually you get better at it. That’s why I say one of the most valuable traits is persistence.”
Octavia E. Butler

Last time, we built a function to send over an application record modeled after a similar function that we already had to send over and instance record. We can do the same thing now for a version record as well as the Update Set XML attachment. Since those are always inserts, we can skip the extra step of looking to see if the record is already there, and these functions will end looking an awful lot like the original function that sent over an instance record. Here is a completed function to send over a version record.

pushVersion: function(versionGR, targetGR, remoteAppId) {
	var result = {};

	var payload = {};
	payload.member_application = remoteAppId;
	payload.version = versionGR.getDisplayValue('version');
	payload.built_on = versionGR.getDisplayValue('built_on');
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	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();
	}

	return result;
}

And here is the completed function for sending over the Update Set XML file that is attached to the version.

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

	var gsa = new GlideSysAttachment();
	result.url = 'https://';
	result.url += targetGR.getDisplayValue('instance');
	result.url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
	result.url += remoteVerId;
	result.url += '&file_name=';
	result.url += attachmentGR.getDisplayValue('file_name');
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', attachmentGR.getDisplayValue('content_type'));
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(gsa.getContent(sysAttGR));
	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();
	}

	return result;
}

All together, the four complementary functions in the CollaborationStoreUtils now look like this.

pushInstance: function(instanceGR, targetGR) {
	var result = {};

	var payload = {};
	payload.instance = instanceGR.getDisplayValue('instance');
	payload.name = instanceGR.getDisplayValue('name');
	payload.description = instanceGR.getDisplayValue('description');
	payload.email = instanceGR.getDisplayValue('email');
	payload.accepted = instanceGR.getDisplayValue('accepted');
	payload.active = instanceGR.getDisplayValue('active');
	payload.host = instanceGR.getDisplayValue('host');
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	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();
	}

	return result;
},

pushApplication: function(applicationGR, targetGR, remoteSysId) {
	var result = {};
 
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + applicationGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(applicationGR.getDisplayValue('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();
	}
	if (!result.error && !result.parse_error && result.status == 200) {
		var remoteAppId = '';
		if (result.obj.result && result.obj.result.length > 0) {
			remoteAppId = result.obj.result[0].sys_id;
		}
		result = {};
		var payload = {};
		payload.name = applicationGR.getDisplayValue('name');
		payload.description = applicationGR.getDisplayValue('description');
		payload.current_version = applicationGR.getDisplayValue('current_version');
		payload.active = 'true';
		if (remoteAppId > '') {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + remoteAppId;
			result.method = 'PUT';
			result.remoteAppId = remoteAppId;
		} else {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application';
			result.method = 'POST';
			payload.provider = remoteSysId;
		}
		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');
		request.setRequestBody(JSON.stringify(payload, null, '\t'));
		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();
		}
	}
 
	return result;
},

pushVersion: function(versionGR, targetGR, remoteAppId) {
	var result = {};

	var payload = {};
	payload.member_application = remoteAppId;
	payload.version = versionGR.getDisplayValue('version');
	payload.built_on = versionGR.getDisplayValue('built_on');
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	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();
	}

	return result;
},

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

	var gsa = new GlideSysAttachment();
	result.url = 'https://';
	result.url += targetGR.getDisplayValue('instance');
	result.url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
	result.url += remoteVerId;
	result.url += '&file_name=';
	result.url += attachmentGR.getDisplayValue('file_name');
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', attachmentGR.getDisplayValue('content_type'));
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(gsa.getContent(sysAttGR));
	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();
	}

	return result;
}

Looking at them all side by side now, it appears that we could consolidate them even further by creating a single function that accepted a URL and a method, and maybe a payload, as that seems to be the only difference between them. I’m not really up for that at the moment, but it looks as if we could actually make that work one day when we had nothing better to do. For now, though, let’s just leave things as they are and get back to our InstanceSyncUtils and finish building out the code that will sync the version and attachment records in the same way that we did for the instances and the applications. These will utilize our new push functions when needed, but we still need to build out the logic that does the compare and determines what, if anything, needs to be sent over.

As you may recall, our strategy is to loop through the instances, and for each instance, loop through the applications provided by that instance. Similarly, for each application, we will loop through each version of that application, and for each version, we will make sure that there is an associated Update Set XML file attached. Whenever we find anything missing, we will utilize the appropriate push function above to send over the missing artifact. Since we have already done that for instances and applications, it should be relatively straightforward to copy that existing code and modify it as needed for the different types of records involved. Still, it is a little bit of work, so let’s save that for our next installment.

Collaboration Store, Part XLII

“You don’t get results by focusing on results. You get results by focusing on the actions that produce results.”
Mike Hawkins

Last time out, we threw together the code that will sync up the list of apps for a particular instance on the Host with the list of apps for that instance on the remote Client. We stopped short of building out the code that will send the missing applications over, though, and today we need to wrap that up. Those of you following along at home will recall that we already have a couple of functions that do that, and we really don’t want to create yet another one, so we need to see if we can somehow collapse those two into one that can serve the needs of all three use cases. The basic premise on the application updates is to first look and see if the application is already present on the target instance, and if so, update it; otherwise, insert it. Both of our existing functions take that approach, so we will want to maintain that functionality in the new shared version.

The other thing that we wanted to do was to model the existing publishApplication function’s approach to handling the interactions between the instance making the call and the instance that is being called. Basically, the shared code just makes the calls and returns the results in an object that can be evaluated by the caller. The shared code takes no action on any errors that might be encountered; it simply reports back to the calling module everything that happened. It is up to the caller to take whatever action might be necessary to respond to any issues reported. We will want to maintain that approach in our new function as well.

So, just as with the existing publishApplication function, we start out by creating the result object that we will be returning to the caller.

pushApplication: function(applicationGR, targetGR, remoteSysId) {
	var result = {};
 
	...
 
	return result;
}

Something that was not in the original version of the publishApplication function was the insertion of the URL and the method used in the result object. It occurred to me that this might be useful information to the caller, so I decided to add it into this one, and I think I will go back and add that into the original as well. For fetching the applications, which is the next thing that we need to do, that looks like this:

result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + applicationGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(applicationGR.getDisplayValue('name'));
result.method = 'GET';

Once we have set the value of those two properties in the result object, then we can use them to build our request object.

var request = new sn_ws.RESTMessageV2();
request.setEndpoint(result.url);
request.setHttpMethod(result.method);

And before we pull the trigger, we also have to set up the standard authentication and content headers.

request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader('Content-Type', 'application/json');
request.setRequestHeader('Accept', 'application/json');

Once our request object is fully configured, then we just need to pull the trigger and complete our result object with the data and/or errors returned.

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

At this point, if there was any kind of error, then we just want to return that back to the caller so that they can take whatever action is appropriate for that particular context. Assuming everything worked as it should, though, the next thing that we are going to want to do is to see if the application was returned in the array of JSON objects sent back in response, and if so, snag the sys_id from the data.

if (!result.error && !result.parse_error && result.status == 200) {
	var remoteAppId = '';
	if (result.obj.result && result.obj.result.length > 0) {
		remoteAppId = result.obj.result[0].sys_id;
	}
	...
}

At this point, we are about to make an entirely new request of the target instance, so we want to reset the result object back to an empty object and start the building process all over again from scratch. The fetching of the application was successful (even if the application was not found on the target instance, finding that out was a success), so we do not need to retain anything from that activity other than the application’s sys_id on the target instance, if it happens to be there. Once we reset the result object, we can start building our payload to send over from the data on the application record.

result = {};
var payload = {};
payload.name = applicationGR.getDisplayValue('name');
payload.description = applicationGR.getDisplayValue('description');
payload.current_version = applicationGR.getDisplayValue('current_version');
payload.active = 'true';

At this point, we need to determine if we are doing an insert or an update based on the presence of a remote sys_id for this application.

if (remoteAppId > '') {
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + remoteAppId;
	result.method = 'PUT';
	result.remoteAppId = remoteAppId;
} else {
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application';
	result.method = 'POST';
	payload.provider = remoteSysId;
}

Now we build our new request object, once again using the result.url and result.method along with all of the other standard elements of our other requests. Also, since this request will be shipping data over to the target instance, we will need to set the request body with the stringified payload.

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');
request.setRequestBody(JSON.stringify(payload, null, '\t'));

Then, as before, we need to execute the request and populate our result object with the response.

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

Once again, we do not take any action in response to any of these potential errors; we simply report them back to the caller in the result object and let them decide what, if anything, they want to do about it. These functions are not built to take action directly. They simply Observe and Report.

All together, this new function looks like this:

pushApplication: function(applicationGR, targetGR, remoteSysId) {
	var result = {};
 
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + applicationGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(applicationGR.getDisplayValue('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();
	}
	if (!result.error && !result.parse_error && result.status == 200) {
		var remoteAppId = '';
		if (result.obj.result && result.obj.result.length > 0) {
			remoteAppId = result.obj.result[0].sys_id;
		}
		result = {};
		var payload = {};
		payload.name = applicationGR.getDisplayValue('name');
		payload.description = applicationGR.getDisplayValue('description');
		payload.current_version = applicationGR.getDisplayValue('current_version');
		payload.active = 'true';
		if (remoteAppId > '') {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + remoteAppId;
			result.method = 'PUT';
			result.remoteAppId = remoteAppId;
		} else {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application';
			result.method = 'POST';
			payload.provider = remoteSysId;
		}
		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');
		request.setRequestBody(JSON.stringify(payload, null, '\t'));
		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();
		}
	}
 
	return result;
}

This should work now for our purpose, but if we want to also utilize this new function for the other two original purposes, we will need to refactor that code to call this function and then evaluate the response. Those actually work as they are right at the moment, so there is no rush to jump in and do that right now, but we don’t want to forget to take care of that one day, if for no other reason than to reduce the lines of code to be maintained.

That about wraps things up for the application records. Next time, we will see if we can do the same thing for the version records, and then we will jump into the Update Set attachments and that ought to wrap up this little side trip of creative avoidance that we took on so that we could ignore the issues with the application publishing for a while. One day, we need to get back into building out that third primary leg of this little stool, but now that we have headed down this intentional detour, we should finish this up first before we jump back onto the main road.

Collaboration Store, Part XLI

“If opportunity doesn’t knock, build a door.”
Milton Berle

Last time out we started work on the Script Include function that will sync up the data on a Client instance with that of the Host instance. We stopped short of consolidating a couple of cloned functions into a single shared function, but before we get to that effort, we need to add the code that syncs up the application table data. We already wrote the code that syncs up the instance table data, and since this operation is essentially the same process, we should be able lift most of the code from what we have already done.

As we did with the instances, we will need to gather up all of the applications on the Host associated with the instance being processed. This is basically the same process that we went through with the instance table, and the code will be very much the same except for the table and the query.

var applicationList = [];
var applicationGR = new GlideRecord('x_11556_col_store_member_application');
applicationGR.addQuery('provider.name', thisInstance);
applicationGR.query();
while (applicationGR.next()) {
	applicationList.push(applicationGR.getDisplayValue('name'));
}

We will also need to contact the Client instance and fetch all of the applications associated with the current instance being processed that are present on that system. Once again, we can turn to the REST API Explorer to generate an end point URL, and other than the actual URL, the request object needed for fetching the applications is again basically the same as that which we put together to fetch the instances from the Client.

var request  = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setBasicAuth(this.CSU.WORKER_ROOT + instance, token);
request.setRequestHeader("Accept", "application/json");
request.setEndpoint('https://' + instance + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId);

Now that we have build our request object, we need to execute the request and check the results just as we did when gathered up the Client’s list of instances.

var response = request.execute();
if (response.haveError()) {
	gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
} else if (response.getStatusCode() == '200') {
	var jsonString = response.getBody();
	var jsonObject = {};
	try {
		jsonObject = JSON.parse(jsonString);
	} catch (e) {
		gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list: ' + jsonString);
	}
	if (Array.isArray(jsonObject.result)) {
		...
	} else {
		gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list: ' + response.getBody());
	}
} else {
	gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list: ' + response.getStatusCode());
}

Assuming that all went well with gathering up all of the data on the Host and the Client, the next thing that we need to do is loop through all of the applications found on the Host for this instance and see if they are present on the Client as well. If not, we will need to push them over, which is where we will get into that code that we cloned that needs to be refactored into a single function so that we can have one process that can serve the two original purposes and our new purpose as well.

for (var i=0; i<applicationList.length; i++) {
	var thisApplication = applicationList[i];
	var remoteAppId = '';
	for (var j=0; j<jsonObject.result.length && remoteAppId == ''; j++) {
		if (jsonObject.result[j].name == thisApplication) {
			remoteAppId = jsonObject.result[j].sys_id;
		}
	}
	if (remoteAppId == '') {
		remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
	}
	this.syncVersions(targetGR, thisApplication, remoteAppId);
}

This code references two functions that we have not yet created, sendApplication and syncVersions. We should be able to cut and paste the sendInstance function that we created earlier to build the sendApplication function with just a few minor modifications. Similarly, we should be able to clone the syncApplications function to serve as the initial basis of the syncVersions function. At this point, we are assuming that we have collapsed our two cloned functions that send applications over into a single function in the CollaborationStoreUtils Script Include modeled after the publishInstance function that we were using to send over an instance record, and we are assuming that it will return the very same type of object in response.

sendApplication: function(targetGR, thisApplication, thisInstance, remoteSysId) {
	var sysId = '';
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	applicationGR.addQuery('name', thisApplication);
	applicationGR.addQuery('provider.name', thisInstance);
	applicationGR.query();
	if (applicationGR.next()) {
		var result = this.CSU.publishApplication(applicationGR, targetGR, remoteSysId);
		if (result.error) {
			gs.error('InstanceSyncUtils.sendApplication - Error occurred attempting to push application ' + thisApplication + ' : ' + result.errorCode + '; ' + result.errorMessage);
		} else if (result.status != 200) {
			gs.error('InstanceSyncUtils.sendApplication - Invalid HTTP response code returned from attempt to push application ' + thisApplication + ' : ' + result.status);
		} else if (!result.obj) {
			gs.error('InstanceSyncUtils.sendApplication - Invalid response body returned from attempt to push application ' + thisApplication + ' : ' + result.body);
		} else {
			sysId = result.obj.sys_id;
		}
	}
	return sysId;
}

Of course, for this to work, we actually have to create a function in CollaborationStoreUtils called publishApplication, but we already have two functions that do that very thing and a publishInstance function to use as a model. It shouldn’t take all that much to collapse all of that together into a single function that will work for everyone, so let’s make that the focus of our next installment.

Collaboration Store, Part XXXVII

“Stay committed to your decisions, but stay flexible in your approach.”
Tony Robbins

After abandoning my earlier plans to jump into the application publishing process, I started to take a look at the missing error recovery needs for all of these inter-instance interactions. One thing that I had always planned to do at some point was to create an activity log of all transactions between instances. My idea was not to sync the logs across all instances, but to have a record in each instance of the things that went on with that particular instance. On the Host instance, such a log could provide the basis for some form of periodic recovery process that scanned the log for failed transactions and then made another attempt to put the transaction through again. After giving that some thought, though, I decided that a better approach would be to just scan each instance for instances, applications, and versions, and then attempt to push over anything that was missing compared to what was present on the Host. I still want to build an activity log at some point, but I don’t think that I want to use it as a basis for error recovery. I think it would be more straightforward to just compare the lists of records on each instance with the master lists on the Host and then try to correct any deficiencies.

That’s the plan today, anyway, but plans do have a way of changing over time, so who knows how things will come out once I start putting all of the pieces together. Right now, though, this seems like the better way to go.

One little thing that have been wanting to do for some time was to add another field to the version record to indicate the version of ServiceNow that was used to build the Update Set. There is a System Property that contains this information, so I all I really needed to do was to add the field and then add a line of code to the version record creation function to pull that value out of the property and stuff it into the new field. The name of the property is glide.buildtag.last, and the name of the field that I added is built_on.

New built_on field added to the version record

Once I added the new field to the version record, I opened up the ApplicationPublisher Script Include, which creates the version record, and added the following line to the function that builds the record:

versionGR.setValue('built_on', gs.getProperty('glide.buildtag.last'));

I also had to modify the function that sent the version record over to other instances to pass on the new value. Since I copied that code instead of consolidating the logic into a single, reusable function, I had to do that in two places, in the ApplicationPublisher and then again in the CollaborationStoreUtils (I really need to collapse that code into a single set of functions that will for both cases). In both places, I added the following line:

payload.built_on = versionGR.getDisplayValue('built_on');

This was not any kind of a major change, but it was something that I had been meaning to do for a long time, and so while I was in looking at the code, I decided to just go ahead and do it. Next time, we will focus on some real work to start building out some kind of error recovery process so that we can ensure that all of the instances in the community are always kept up to date, even if they miss an update in real time for whatever reason.

Collaboration Store, Part XXXI

“Someone’s sitting in the shade today because someone planted a tree a long time ago.”
Warren Buffett

While we wait for additional test results to come in from the corrected Update Set for our latest iteration, it would be a good time to take a look at what we will need to do to complete the last remaining step in the application publishing process. What we have completed so far pushes all of the new application version artifacts to the Host instance. Now we need to build out the process for the Host instance to send out all of those artifacts to all of the other Client instances. Fortunately, we already have in hand most of the code to do that; we just need to clone a few things and make a number of modifications to fit the new purpose.

We have already built a Subflow to push new Client instance information out to all of the existing Client instances during the new instance registration process. Pushing out a new version of an application to those same instances is essentially the same process, so we should be able to clone that guy, and the associated Action, to create our new process. We have also created functions to push over the application record, the version record, and the attached Update Set XML file, and we should be able to copy over those guys as well to complete the process. In fact, we should probably start with the JavaScript functions, as we will need to call the root function in the Action, which will need to be created before it can be referenced in the Subflow.

The functions as written are hard-coded to send everything over to the Host instance. We will want to do basically the same thing, but this time we will be sending things out to various Client instances. Ideally, I should just refactor the code to have the destination instance and credentials passed in so that one function would work in both circumstances, but at this point it was less complicated to just make copies of each function and hack them up for their new purpose. I don’t really like having two copies of essentially the same thing, though, so one day I am going to have to go back and rework the entire mess.

But for now, this is what I came up with when I copied what was once called processPhase5 to create the new publishNewVersion function:

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.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);
	}
},

Unlike the original application publication process, we are not bouncing back and forth between the client side and the server side, so with the successful completion of one artifact, we can simply move on to pushing over the next artifact. To create the next step in the process, the publishVersionRecord function, I started out with a copy of the original processPhase6 function. Here is the end result:

publishVersionRecord: function(targetInstance, token, versionGR, targetAppId, attachmentId) {
	var canContinue = true;
	var payload = {};
	payload.member_application = targetAppId;
	payload.version = versionGR.getDisplayValue('version');
	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);
		}
	}
},

The last step in the process, then, is to send over the Update Set XML file attachment. For that one, I started out with the processPhase7 function and ended up with this:

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

So that takes care of the functions. Now we have to create an Action in the Flow Designer that will call the function, and then produce a new Subflow that will leverage that action. That sounds like a good subject for our next installment.

Special Note to Testers

If you want to test the set-up process, you can do that with a single instance. You just have to select the Host option during the set-up process. If you want to test the Client set-up process, you will need at least two instances, one to serve as the Host, which you will need to reference when you set up the Client (you cannot be a Client of your own Host). But if you really want to test out the full registration process, including the Subflow that informs all existing instances of the new instance, you will need at least three participating instances: 1) the Host instance, 2) the new Client instance, and 3) an existing Client instance that will be notified of the new member of the community.

The same holds true for the application publishing process. You can test a portion of the publishing process by publishing an app on a single Host instance, but if you want to test out all of the parts and pieces, you will want to publish the app from a Client instance and make sure that it makes its way all the way back to the Host. Once we complete the application distribution process, full testing will require at least three instances to verify that the Host distributes the new version of the application to other Clients.

Collaboration Store, Part VIII

“Fit the parts together, one into the other, and build your figure like a carpenter builds a house. Everything must be constructed, composed of parts that make a whole.”
Henri Matisse

With the first of the two referenced Script Include methods now completed, we can turn our attention to the second one, which is responsible for sending out the Email Notification containing the email verification code. Before we get into that, though, we should first come up with a way to generate a random code. In Javascript, you can use the Math.random() function to generate a random number between 0 (inclusive), and 1 (exclusive). Coupled with the Math.floor() function and a little multiplication, you can generate a single numeric digit (0-9):

var singleDigit = Math.floor(Math.random() * 10);

So, if we want a random 6-digit numeric code, we can just loop through that process multiple times to build up a 6 character string:

var oneTimeCode = '';
for (var i=0; i<6; i++) {
	oneTimeCode += Math.floor(Math.random() * 10);
}

That was pretty easy. Now that we have the code, what should we do with it? Since our goal is to send out a notification, the easiest thing to do would be to trigger a System Event and include the recipient’s email address and our random code. In order to do that, we must first create the Event. To do that, navigate to System Policy > Events > Registry to bring up the list of existing Events and then click on the New button to bring up the form.

The Event Registry form

For our purposes, the only field that we need to fill out is the Suffix, which we will set to email_verification. That will generate the actual Event name, which will include the Application Scope. It is the full Event name that we will have to reference in our script when we trigger the Event. To do that, we will use the GlideSystem eventQueue() method. And that’s all we have to do other than to return the random code that we generated back to the calling widget. Here is the complete Script Include function:

verifyInstanceEmail: function(email) {
	var oneTimeCode = '';

	for (var i=0; i<6; i++) {
		oneTimeCode += Math.floor(Math.random() * 10);
	}
	gs.eventQueue('x_11556_col_store.email_verification', null, email, oneTimeCode);

	return oneTimeCode;
}

Of course, even though we have completed the missing Script Include function, our work is not complete. We still have to build a Notification that will be triggered by the Event. To create a new Notification, navigate to System Notification -> Email -> Notifications to bring up the list of existing Notifications, and then click on the New button to create a new Notification.

Collaboration Store Email Verification Notification

I named this one the Collaboration Store Email Verification, set the Type to EMAIL, and checked the Active checkbox. Under the When to send tab, we set the Send when value to Event is fired, and in the Event name field, we enter the full name of our new System Event.

Notification form When to send tab

In the Who will receive tab, we check the Exclude delegates, Event parm 1 contains recipient, and Send to event creator checkboxes. That last one is especially important, as it will usually be the person doing the set-up that owns the email address, and if that box is not checked, the email will not be sent.

Notification form Who will receive tab

The last tab to fill out is the What will it contain tab. Here is where we will build the subject and body of the email that will be sent out. We don’t really need all that much here, since all we are trying to do is to get them the code that we generated, but here is the text that I came up for the body:

Here is the security code that you will need to complete the set-up of the Collaboration Store application:

${event.parm2}

That last line pulls the code out of the Event that was fired and places it in the body of the message. For the Subject, I cut and pasted the same text that I had used for the name of the Notification, Collaboration Store Email Verification.

Notification form What will it contain tab

Once you Save the Notification, you can test it out using a generated Event or an actual Event, just to see how it all comes out. To really test everything end to end, you can pull up the Set-up widget and enter in the details for a new instance. The email should then go out if all is working as it should. Just don’t fill the code in on the form just yet, as that would trigger the final set-up process, and we haven’t built that just yet. But we should get started on that, next time out.

Collaboration Store, Part VII

“The best way to predict your future is to create it.”
Abraham Lincoln

Now that we have built a REST end point that will run on a Host instance, we need to complete the function referenced in our widget to utilize the API from our potential client instance. Like many things on the Now Platform, there are a number of ways that this could be accomplished. One way would be to create a new Outbound REST Service using the input form for that purpose.

New Outbound REST Service input form

That will definitely work, but for our simple unauthenticated GET info service, that really is more work than is necessary. Using a new RESTMessageV2 object, you can essentially build the same thing from a blank slate, and of the two properties that we have to value, one of them (the URL or Endpoint) would have to be overwritten anyway, since the instance name portion of the URL is provided by the user’s input. The entire set-up is three simple lines of code:

var request  = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setEndpoint('https://' + host + '.service-now.com/api/x_11556_col_store/v1/info');

Once you have the completed the set-up, the response object is obtained by calling the execute method:

var response = request.execute();

Because we just built the service that we will be calling, we already know what responses could be coming from the service itself; however, there may be other responses as well if the service is not installed on the instance or if the instance name provided is not a valid instance or if it is a PDI that is currently asleep. Our function should be equipped to handle all of that, so there are several things that we will need to check.

One of the methods on the RESTResponseV2 object is haveError(), which we need to check before anything else. If there some kind of error with the process, then there is no need to check the HTTP Response Code, since we never made it that far. At the very top of our new getStoreInfo() method, I created an empty object for holding all of the information that we will return to the caller:

var respObj = {};

In the event that the haveError() method returns true, we then populate that object with the error details:

if (response.haveError()) {
	respObj.error = response.getErrorMessage();
	respObj.errorCode = response.getErrorCode();
	respObj.body = response.getBody();
}

If there was no error, then the next thing that we want to check is the HTTP Response Code using the response object’s getStatusCode() method. If the code is 200, then we have a valid response. If not, then we are not talking to an active Collaboration Store Host, and we need to populate our response object with that information:

respObj.error = 'Invalid HTTP Response Code: ' + respObj.responseCode;
respObj.body = response.getBody();

If we do receive a 200, though, then we need to parse the JSON string and return all of the information about the Host so that it can be processed by the widget:

respObj.storeInfo = JSON.parse(response.getBody());
respObj.name = respObj.storeInfo.result.info.name;

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

getStoreInfo: function(host) {
	var respObj = {};

	var request  = new sn_ws.RESTMessageV2();
	request.setHttpMethod('get');
	request.setEndpoint('https://' + host + '.service-now.com/api/x_11556_col_store/v1/info');
	var response = request.execute();
	respObj.responseCode = response.getStatusCode();
	if (response.haveError()) {
		respObj.error = response.getErrorMessage();
		respObj.errorCode = response.getErrorCode();
		respObj.body = response.getBody();
	} else if (respObj.responseCode == '200') {
		respObj.storeInfo = JSON.parse(response.getBody());
		respObj.name = respObj.storeInfo.result.info.name;
		gs.setProperty('x_11556_col_store.active_token', respObj.storeInfo.result.info.sys_id);
	} else {
		respObj.error = 'Invalid HTTP Response Code: ' + respObj.responseCode;
		respObj.body = response.getBody();
	}

	return respObj;
}

That completes all of the work for the first Script Include function that we referenced in the server side code of the widget. Next, we’ll get started on that second one that sends out the email verification code to the email address entered on the form.

Collaboration Store, Part V

“Do not be embarrassed by your failures; learn from them and start again.”
Richard Branson

With the completion of the client side code, it is now time to turn our attention to a much bigger effort, all of the things that will need to go on over on the server side. This will involve a number of items beyond just the widget itself, but we can start with the widget and branch out from there. One thing that I know I will need for sure is a Script Include to house all of the various common routines, so I built out an empty shell of that, just to get things started.

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

	type: 'CollaborationStoreUtils'
};

That’s enough to reference the script in the widget, which we should do right out of the gate, along with gathering up a couple of our application’s properties and checking to make sure that the set-up process hasn’t already been completed:

var csu = new CollaborationStoreUtils();
data.registeredHost = gs.getProperty('x_11556_col_store.host_instance');
data.registeredHostName = gs.getProperty('x_11556_col_store.store_name');
var thisInstance = gs.getProperty('instance_name');
var mbrGR = new GlideRecord('x_11556_col_store_member_organization');
if (mbrGR.get('instance', thisInstance)) {
	data.phase = 3;
}

We get the instance name from a stock system property (instance_name) and then see if we can fetch a record from the database for that instance. If we can, then the set-up process has already been completed, and we advance the phase to 3 to bring up the set-up completion screen. The next thing that we do is check for input, and if there is input, then we grab the data that we need coming in from the client side and check the input.action variable (c.data.action on the client side) to see what it is that we have been asked to do.

if (input) {
	data.registeredHost = gs.getProperty('x_11556_col_store.host_instance');
	data.registeredHostName = gs.getProperty('x_11556_col_store.store_name');
	data.phase = input.phase;
	data.instance_type = input.instance_type;
	data.host_instance_id = input.host_instance_id;
	data.store_name = input.store_name;
	data.instance_name = input.instance_name;
	data.email = input.email;
	data.description = input.description;
	data.validationError = false;
	if (input.action == 'save') {
		// save logic goes here ...
	} else if (input.action == 'setup') {
		// set-up logic goes here ...
	}
}

That is the basic structure of the widget, but of course, the devil is in the details. Since the save process comes before the set-up process, we’ll take that one on first.

If you elected to set up a Host instance, then there is nothing more to do at this point other than to send out the email verification notice and advance the phase to 2 so that we can collect the value of the code that was sent out and entered by the user. However, if you elected to set up a Client instance, then we have a little bit of further work to do before we proceed. For one thing, we need to make sure that you did not specify your own instance name as the host instance, as you cannot be a client of your own host. Assuming that we passed that little check, the next thing that we need to do is to check to see if the host that you specified is, in fact, an actual Collaboration Store host. That will take a bit of REST API work, but for today, we will assume that there is a function in our Script Include that can make that call. To complete the save action, we can also assume that there is another Script Include function that handles the sending out of the Notification, which will allow us to wrap up the save action logic as far as the widget is concerned.

if (data.instance_type == 'client') {
	if (data.host_instance_id == thisInstance) {
		gs.addErrorMessage('You cannot specify your own instance as the host instance');
		data.validationError = true;
	} else {
		var resp = csu.getStoreInfo(data.host_instance_id);
		if (resp.responseCode == '200' && resp.name > '') {
			data.store_name = resp.name;
			data.store_info = resp.storeInfo.result.info;
		} else {
			gs.addErrorMessage(data.host_instance_id + ' is not a valid Collaboration Store instance');
			data.validationError = true;
		}
	}
}
if (!data.validationError) {
	data.oneTimeCode = csu.verifyInstanceEmail(data.email);
}

So now we have referenced two nonexistent Script Include functions to get through this section of code. We should build those out next, just to complete things, but neither one is an independent function. The getStoreInfo function needs to call a REST service, which also doesn’t exist, and the verifyInstanceEmail function needs to trigger a notification, which does not exist at this point, either. We should create those underlying services first, and make sure that they work, and then we can build the Script Include functions that invoke them to finish things up.

That seems like quite a bit of work in and of itself, so this looks like a good place to wrap things up for now. We can jump on that initial web service first thing next time out.

Collaboration Store, Part IV

“Thinking is easy, acting is difficult, and to put one’s thoughts into action is the most difficult thing in the world.”
Johann Wolfgang von Goethe

Now that we have the visual portion of our widget all laid out the way that we want it, it’s time to crawl under the hood and start laying down some code that will make it all work. The easiest pace to start on that would be client side code. There isn’t that much of it, since the bulk of the action will take place on the server side, and it is all pretty basic standard stuff. For the most part, the client side code is just there to handle the various button clicks and to kick things over to the server side for processing.

The first button click to handle will be the save buttons on the initial data entry screen. There are actually two of them, but only one appears on the screen at any given time, and they invoke the same client side function. Here it is:

$scope.save = function() {
	c.data.phase = 0;
	c.data.action = 'save';
	c.server.update().then(function(response) {
		if (response.oneTimeCode) {
			c.data.oneTimeCode = response.oneTimeCode;
			c.data.phase = 2;
		} else {
			c.data.phase = 1;
		}
	});
};

Before throwing things to the server side for processing, we first set the phase to 0 and the action to save. This will control the logic flow on the server side, where we do all of the actual work. Once the server side code has completed, we check for the presence of a one-time code in the response, which basically tells us that all went well and now we are in the process of verifying the email address. If we don’t get a one-time code, then something went off of the rails, and we kick the phase back to 1 to start the process all over again.

The next button click to handle is the email verification, which we can do right here on the client side, as we are now in possession of the one-time code and we can compare that to the code entered on the screen. If the codes match, the we advance things to the set-up process, and if not, we just leave things as they are until the correct code is entered or the process is cancelled.

$scope.verify = function() {
	if (c.data.security_code == c.data.oneTimeCode) {
		c.data.phase = 0;
		c.data.action = 'setup';
		c.server.update().then(function(response) {
			if (response.validationError) {
				c.data.phase = 1;
			} else {
				c.data.phase = 3;
			}
		});
	}
};

It is still possible at this point for things to go south, so we have to check for that as well, and once again, cycle things back to phase 1 if there is a problem. Otherwise, we advance to phase 3, which simply hides the email verification screen and reveals the set-up completion confirmation screen.

The last thing that we need to handle on the client side is the Cancel button. I just stole this code from an earlier project, and it just returns you to the home page of the main UI if you are in the main UI, or returns you to the home page of the portal, if you are running in the Service Portal.

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

The only other code on the client side is a simple check to see if the server set an initial phase, which can happen if you try to run the set-up process more than once. Here is the entire client side script, with everything included:

api.controller=function($scope) {
	var c = this;

	c.data.phase = c.data.phase || 1;

	$scope.save = function() {
		c.data.phase = 0;
		c.data.action = 'save';
		c.server.update().then(function(response) {
			if (response.oneTimeCode) {
				c.data.oneTimeCode = response.oneTimeCode;
				c.data.phase = 2;
			} else {
				c.data.phase = 1;
			}
		});
	};

	$scope.verify = function() {
		if (c.data.security_code == c.data.oneTimeCode) {
			c.data.phase = 0;
			c.data.action = 'setup';
			c.server.update().then(function(response) {
				if (response.validationError) {
					c.data.phase = 1;
				} else {
					c.data.phase = 3;
				}
			});
		}
	};

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

That takes care of the easy part. All of the real work is over on the server side, and there is actually a lot that needs to go on over there, and much of it outside of the scope of the widget itself. That is undoubtedly a multi-installment activity, but we will start taking that on piece by piece next time out.

Content Selector Configuration Editor, Part IX

“Plans are only good intentions unless they immediately degenerate into hard work.”
Peter Drucker

It seemed as if we had this topic all wrapped up a while back, but then we had to play around with the Buttons and Icons. That should have been the end of that, but then we went and added Bulk Actions to the hacked up Data Table widget, which has now broken our new Content Selector Configuration Editor. So now we have to add support for the Bulk Action configuration to the editor to put things back together again. Fortunately, this is all very similar stuff, so it is mainly just a matter of making some copies of things that we have already built and then hacking them up just a bit to fit the current need.

To start with, we will need a Bulk Action Editor pop-up very similar to all of the other pop-up editors that we have been creating, and since Bulk Actions only have a name and a label for properties, the State Editor seems like the best choice for something to copy, since it too only has a name and a label for properties. Once we pull that widget up on the widget form, change the name, and then select Insert and Stay from the context menu, we can start working on our new copy. There is literally nothing to change with the HTML:

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="persp"
      snh-label="Name"
      snh-required="true"/>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

… and only a function name change on the client script:

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

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

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

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

As you may recall, there is no server-side code for these guys, so that’s all there is to that. Now we have a Bulk Action Editor. There is a little more work involved in the main Content Selector Configurator widget. On the HTML, we need to define a table for the Bulk Actions:

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

On the client side, we need to add a couple more functions to handle the editing and deleting of the actions, which we can basically copy from the same functions we already have for editing and deleting buttons and icons.

$scope.editAction = function(action, actArray) {
	var shared = {page_id: {value: '', displayValue: ''}};
	if (action != 'new') {
		shared.label = action.label;
		shared.name = action.name;
	}
	spModal.open({
		title: 'Bulk Action Editor',
		widget: 'bulk-action-editor',
		shared: shared
	}).then(function() {
		if (action == 'new') {
			action = {};
			actArray.push(action);
		}
		action.label = shared.label || '';
		action.name = shared.name || '';
	});
};

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

On the server side, we just need to add some code to format the Bulk Action section of the table configurations, which we can copy from the code that formats the Buttons/Icons section, and then delete all of the extra properties that are not needed for Bulk Actions.

script += ",\n				actarray: [";
lastSeparator = '';
for (var a=0; a<tableTable[tableState.name].actarray.length; a++) {
	var thisAction = tableTable[tableState.name].actarray[a];
	script += lastSeparator;
	script += "{\n					name: '";
	script += thisAction.name;
	script += "',\n					label: '";
	script += thisAction.label;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

Now all we need to do is pull up our modified ButtonTestConfig script in the editor and see how we did.

Content Selector Configuration Editor with new Bulk Actions section

So far, so good. Now let’s pull up that new Bulk Action Editor and see how that guy works:

Bulk Action Editor

Not bad. A little bit of testing here and there, just to make sure that we didn’t break anything along the way, and we should be good to go. Now if we can just stop tinkering with things, this Update Set should be the final release and we can move on to other exciting adventures.