Collaboration Store, Part X

“It’s better to wait for a productive programmer to become available than it is to wait for the first available programmer to become productive.”
Steve McConnell

With the completion of the set-up widget, we can now turn our attention to the missing Script Include functions and the initial registration process that one of those functions will be calling on the Host instance. Since I always like to start with the easy/familiar stuff first (that checks more things off of the “to do” list faster than the other way around!), let’s jump into the createUpdateWorker() function that inserts or updates the Service Account in the sys_user table. But before we do that, we will need to create a special Role for these worker accounts that has limited privileges.

To create a new Role, navigate to System Security -> Roles and click on the New button to bring up the input form. The only field that we need to fill out is the Suffix, which we will set to worker.

Once we have saved our Role, we will want to bring up the form again and grab the sys_id using the Copy sys_id option on the hamburger menu. We will use that to set the value of one of the constants that we will define at the top of our Script Include.

WORKER_ROOT: 'csworker1.',
WORKER_ROLE: 'f1421a6c2fe430104425fcecf699b6a9',

The other constant is the prefix for the user ID for the Service Account, to which we will append the name of the instance. Now that we have defined our Role and set up our constants, we can build the code that will create the account.

var user_name = this.WORKER_ROOT + gs.getProperty('instance_name');
var userGR = new GlideRecord('sys_user');
if (!userGR.get('user_name', user_name)) {
	userGR.initialize();
	userGR.user_name = user_name;
	userGR.insert();
}
userGR.first_name = 'CS';
userGR.last_name = 'Worker';
userGR.title = 'Collaboration Store Worker';
userGR.active = true;
userGR.locked_out = false;
userGR.web_service_access_only  = true;
userGR.user_password.setDisplayValue(passwd);
userGR.update();

Since it is possible that an earlier failed attempt to set up the app already created the account, we first check for that, and if it isn’t already present in the sys_user table, then we create it. Then we set the appropriate fields to their current values and update the record. One thing to note is the way in which we update the user_password field, which is different than all of the others. Because the value of that field is one-way encrypted, we have to set the Display Value instead of the Value. It took me a bit of research to figure that out; it is not very well documented anywhere that I could find.

Once we create the account, we then have to assign it to the Role that we created earlier. Once again, this may have already been done in an earlier failed attempt, so we have to check for that before proceeding.

var userRoleGR = new GlideRecord('sys_user_has_role');
userRoleGR.addEncodedQuery('user=' + userGR.getUniqueValue() + '^role=' + this.WORKER_ROLE);
userRoleGR.query();
if (!userRoleGR.next()) {
	userRoleGR.initialize();
	userRoleGR.user = userGR.getUniqueValue();
	userRoleGR.role = this.WORKER_ROLE;
	userRoleGR.insert();
}

That takes care of creating/updating the account and applying the Role. Putting it all together, the entire function looks like this:

createUpdateWorker: function(passwd) {
	var user_name = this.WORKER_ROOT + gs.getProperty('instance_name');
	var userGR = new GlideRecord('sys_user');
	if (!userGR.get('user_name', user_name)) {
		userGR.initialize();
		userGR.user_name = user_name;
		userGR.insert();
	}
	userGR.first_name = 'CS';
	userGR.last_name = 'Worker';
	userGR.title = 'Collaboration Store Worker';
	userGR.active = true;
	userGR.locked_out - false;
	userGR.web_service_access_only  = true;
	userGR.user_password.setDisplayValue(passwd);
	userGR.update();

	var userRoleGR = new GlideRecord('sys_user_has_role');
	userRoleGR.addEncodedQuery('user=' + userGR.getUniqueValue() + '^role=' + this.WORKER_ROLE);
	userRoleGR.query();
	if (!userRoleGR.next()) {
		userRoleGR.initialize();
		userRoleGR.user = userGR.getUniqueValue();
		userRoleGR.role = this.WORKER_ROLE;
		userRoleGR.insert();
	}
},

Well, that takes care of the easy part. Next time, we will start digging into the more complex elements of the remaining work needed to complete the set-up process.

Collaboration Store, Part IX

“You can’t go back and make a new start, but you can start right now and make a brand new ending.”
James R. Sherman

Now that we have dealt with the two Script Include functions that were referenced in the Save process, we can return to our widget and address the Set-up process. A number of things have to happen in the Set-up process, including saving the user’s input in the database, creating a service account in the User table for authenticated REST API activities, and in the case of a client instance, we need to register the client with the specified Host instance. We will need to build out the other side of that registration process as well, which will include sharing the new instance information with all of the existing instances as well as sharing all of the existing instances with the new client. That’s quite a bit of work, but we’ll take it all on one piece at a time.

One of the first things that we will want to do, regardless of which type of instance we are setting up, is to save the user’s input. That’s pretty basic GlideRecord stuff, but let’s lay it all out here just the same.

mbrGR.initialize();
mbrGR.active = true;
mbrGR.instance = thisInstance;
mbrGR.name = data.instance_name;
mbrGR.email = data.email;
mbrGR.description = data.description;
mbrGR.name = data.instance_name;
if (data.instance_type == 'host') {
	mbrGR.host = true;
	mbrGR.accepted = new GlideDateTime();
	gs.setProperty('x_11556_col_store.host_instance', thisInstance);
} else {
	mbrGR.host = false;
	gs.setProperty('x_11556_col_store.host_instance', data.host_instance_id);
}
mbrGR.insert();

Most data fields are the same for both Host and Client instances, but a Host instance gets the host field set to true and the accepted date valued, while the Client instance gets the host field set to false and the accepted date is not valued until the registration process is successful.

Now that we have entered the user’s input into the database, we will want to perform additional actions depending on the type of instance. For a Client instance, we will want to register the Client with the Host, and for the purpose of this widget, we can just assume for now that there is a function in our Script Include that handles all of the heavy lifting for that operation.

var resp = csu.registerWithHost(mbrGR);

That simple function call represents a lot of work, but it’s code that we really don’t want cluttering up our widget, so we will stuff it all into the Script Include and worry about building it all out later. We will want to check on how it all came out though, because if it was successful, we will want to update the accepted date for the instance and add the Host instance to our database table as well. Again, this is all pretty standard GlideRecord stuff, so nothing really exciting to see here.

mbrGR.accepted = new GlideDateTime();
mbrGR.update();
mbrGR.initialize();
mbrGR.instance = input.store_info.instance;
mbrGR.accepted = input.store_info.accepted;
mbrGR.description = input.store_info.description;
mbrGR.name = input.store_info.name;
mbrGR.email = input.store_info.email;
mbrGR.active = true;
mbrGR.host = true;
mbrGR.insert();

If the registration process was not successful though, we will want delete the record that we just created, inform the user of the error, and cycle the phase back to 1 to start all over again.

mbrGR.deleteRecord();
var errMsg = resp.error;
if (resp.body && resp.body.result && resp.body.result.error) {
	errMsg = resp.body.result.error.message + ': ' + resp.body.result.error.detail;
}
gs.addErrorMessage(errMsg);
data.validationError = true;

One of the things that will happen during the registration process in the Script Include will be to create the Service Account to be used for authenticated REST API calls to the instance. Since the registration process is only called for Client instances, we will need to handle that directly when setting up a Host instance. Once again, we can assume that there is a Script Include function that handles that process, which greatly simplifies the code in the widget.

csu.createUpdateWorker(mbrGR.getUniqueValue());

Putting it all together, the entire server side Javascript code for the set-up process now looks like this:

function setupProcess() {
	gs.setProperty('x_11556_col_store.store_name', data.store_name);
	mbrGR.initialize();
	mbrGR.active = true;
	mbrGR.instance = thisInstance;
	mbrGR.name = data.instance_name;
	mbrGR.email = data.email;
	mbrGR.description = data.description;
	mbrGR.name = data.instance_name;
	if (data.instance_type == 'host') {
		mbrGR.host = true;
		mbrGR.accepted = new GlideDateTime();
		gs.setProperty('x_11556_col_store.host_instance', thisInstance);
	} else {
		mbrGR.host = false;
		gs.setProperty('x_11556_col_store.host_instance', data.host_instance_id);
	}
	mbrGR.insert();
	if (data.instance_type == 'host') {
		csu.createUpdateWorker(mbrGR.getUniqueValue());
	} else {
		var resp = csu.registerWithHost(mbrGR);
		if (resp.responseCode == '202') {
			mbrGR.accepted = new GlideDateTime();
			mbrGR.update();
			mbrGR.initialize();
			mbrGR.instance = input.store_info.instance;
			mbrGR.accepted = input.store_info.accepted;
			mbrGR.description = input.store_info.description;
			mbrGR.name = input.store_info.name;
			mbrGR.email = input.store_info.email;
			mbrGR.active = true;
			mbrGR.host = true;
			mbrGR.insert();
		} else {
			mbrGR.deleteRecord();
			var errMsg = resp.error;
			if (resp.body && resp.body.result && resp.body.result.error) {
				errMsg = resp.body.result.error.message + ': ' + resp.body.result.error.detail;
			}
			gs.addErrorMessage(errMsg);
			data.validationError = true;
		}
	}		
}

That’s it for the work on the widget itself. Of course, we still have a lot of work to do to both complete the referenced Script Include functions that do not yet exist, and to build out the registration process that one of those functions will be calling. There is a lot to choose from there for the subject of our next installment, but when the time comes, we will pick something and dive in.

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 VI

“To take the ‘easy way’ is to walk a path that doesn’t exist to the edge of a cliff that does.”
Craig D. Lounsbrough

Today we will set the widget aside for a bit and focus on one of the Scripted REST API services, the one needed to verify the Host instance entered by the user. We could use the stock REST API for the new instance table, but that would require authentication and would not give us the opportunity to inject some additional logic beyond just the value of the table fields. The scripted approach is a little more work, but in this case, it is work that needs to be done. Basically, we want to allow unauthenticated users to obtain information about the Host instance directly from the Host instance to verify that it really is a Collaboration Store Host. The format of the JSON string returned would look something like this:

{
    "result": {
        "status": "success",
        "info": {
            "instance": "dev00001",
            "accepted": "2021-07-22 21:59:48",
            "description": "Test Collaboration Store",
            "sys_id": "2be69501076130103457f2218c1ed02b",
            "name": "Test Collaboration Store",
            "email": "storekeeper@example.com"
        }
    }
}

To produce that kind of an output, you need to create a Scripted REST Resource, but before you can build a resource you have to first create a Scripted REST Service, which is the umbrella record under which you can then define multiple Scripted REST Resources. Here is the record for the service that I will be using for all of the resources needed for this app:

Scripted REST Service record for the Collaboration Store app

Normally, the API ID would be something a little descriptive to identify the purpose of the API, but since this is a Scoped Application, the application scope is already part of the URI, so it seemed rather redundant to put something like that in again. Instead, I just set the API ID to V1, indicating that this is Version #1 of this API.

With that out of the way, we can now go down to the Related Records list and use the New button to create our Scripted REST Resource. I called this one info, which also becomes part of the URI for the service, and with the application scope prefix and the APP ID from the parent service, the full URI for this resource becomes:

/api/x_11556_col_store/v1/info

I also set the HTTP Method to GET, since all we are doing is retrieving the information. Here is the basic information for the resource:

Scripted REST Resource record for the Host info API

For the script itself, I decided to place all of the relevant code in the Script Include, and just code a simple function call in the resource script definition itself:

(function process(request, response) {
	var result = new CollaborationStoreUtils().processInfoRequest();
	response.setBody(result.body);
	response.setStatus(result.status);
})(request, response);

Of course, that just means that we have a lot of work to do in the Script Include, but that’s a better place for all of that code, anyway. We already created the empty shell for the Script Include previously, so now we just need to add a new function called processInfoRequest that returns an object that contains a body and a status.

Underneath the script, there is series of tabs, and under the Security tab, you want to also make sure that the Requires authentication checkbox is unchecked. At this point in the set-up process the prospective client does not have any credentials to use for authentication, so we want this info-only service to be open to everyone. There isn’t any sensitive information contained here, so that shouldn’t present any kind of security risk.

As for the Script Include function itself, we will want to verify that this is, in fact, a Host instance, and then go get the details for the instance from the database table. We can easily tell if it is a Host instance by comparing the stock System Property instance_name with the application’s System Property x_11556_col_store.host_instance. If they match, then we can go fetch the record from the database, and if that operation is successful, we can build the response. If we fail to obtain the record for whatever reason, then something has gone horribly wrong with the installation, and we will respond with a generic 500 Internal Server Error. If the two properties do not match, then this is not a Host instance, and in that case, we respond with a 400 Bad Request, which we will call an Invalid instance error. Here is how the whole thing looks in code:

processInfoRequest: function() {
	var result = {body: {error: {}, status: 'failure'}};

	if (gs.getProperty('instance_name') == gs.getProperty('x_11556_col_store.host_instance')) {
		var mbrGR = new GlideRecord('x_11556_col_store_member_organization');
		if (mbrGR.get('instance', gs.getProperty('instance_name'))) {
			result.status = 200;
			delete result.body.error;
			result.body.status = 'success';
			result.body.info = {};
			result.body.info.instance = mbrGR.instance;
			result.body.info.accepted = mbrGR.accepted;
			result.body.info.description = mbrGR.description;
			result.body.info.sys_id = mbrGR.sys_id;
			result.body.info.name = mbrGR.name;
			result.body.info.email = mbrGR.email;
		} else {
			result.status = 500;
			result.body.error.message = 'Internal server error';
			result.body.error.detail = 'There was an error obtaining the requested information.';
		}
	} else {
		result.status = 400;
		result.body.error.message = 'Invalid instance error';
		result.body.error.detail = 'This instance is not a ServiceNow Collaboration Store host.';
	}

	return result;
}

I start out by building the result object with the expectation of failure, and then override that if everything works out. The main reason that I do that is because there are more failure conditions than success conditions, and so that simplifies the code in more places that if I had done it the other way around. That may not be the most efficient way to approach that, but it works.

That wraps up all of parts for providing the service, and since it is just a simple unauthenticated GET, you can even try it out by simply entering the full URL in a browser. Of course, it will come out formatted in XML instead of JSON, but at least you can see the result. This completes the Host instance side of the interface, but to complete our widget, we still need to build the Script Include function that will run on the instance being set up before all of this will work. That may end up being a little bit of work, so that sounds like a good subject for our next installment in this series.

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.

Bulk Actions in the Service Portal Data Table Widget

“Innovation comes from people who take joy in their work.”
W. Edwards Deming

Since the day that I first touched the Data Table widget, I have somehow kept finding reasons to go back and tinker with it some more for one reason or another. While I was playing around with the buttons and icons feature that I added, I noticed that one of the other features that was present in the primary UI, but missing in the Service Portal Data Table widget, was the ability to select more than one row and perform some action on all of the selected rows. That got the little wheels spinning around inside of my head and I started trying to figure out just what it would take to implement that feature in my hacked up version of the stock widget. It seemed to me that you would need a number of things:

  • A way to pass in one or more bulk actions as part of the configuration,
  • A master checkbox in the headings that you could use to toggle all of the individual checkboxes off and on,
  • A checkbox on every row,
  • A select statement in the footer with the choices being all of the specified bulk actions, and
  • Some client side scripts to handle the clicks on the master checkbox and the action selector.

We already pass in an Array of Buttons/Icons, so why not an Array of Bulk Actions? We should be able to copy most of that code wherever it lives, since this would pretty much be handled in the same way. Having at least one item in the Array could drive the visibility of the checkboxes and select statement, and as far as processing the Action is concerned, we could take the same approach that we took with the buttons and just broadcast the selected action and let some other widget deal with the actual response whenever a Bulk Action was clicked. It all seemed simple enough to give it a go, so I started hacking up the HTML, first for the master checkbox in the heading row:

<th ng-if="data.actarray.length > 0" class="text-nowrap center" tabindex="0">
  <input type="checkbox" ng-model="data.master_checkbox" ng-click="masterCheckBoxClick();"/>
</th>

Then I did the same for the individual checkboxes in the data rows:

<td ng-if="data.actarray.length > 0" class="text-nowrap center" tabindex="0">
  <input type="checkbox" ng-model="item.selected"/>
</td>

And then to finish out the HTML changes, I added an extra footer down at the bottom for the SELECT element:

<div class="panel-footer" ng-if="data.actarray.length > 0 && data.row_count">
  <div class="btn-toolbar m-r pull-left">
    <select class="form-control" ng-model="data.bulk_action" ng-click="bulkActionSelected();">
      <option value="">${Actions on selected rows ...}</option>
      <option ng-repeat="opt in data.actarray" value="{{opt.name}}">{{opt.label}}</option>
    </select>
  </div>
  <span class="clearfix"></span>
</div>

That took care of the HTML. On the server-side code, I added a couple of new items to the comments explaining all of the various options, and then added the bulk actions to the list of things that get copied in:

 * data.buttons = the JSON string containing the button specifications
 * data.btnarray = the array of button specifications
 * data.refpage = the JSON string containing the reference link specifications
 * data.refmap = the reference link specifications object
 * data.bulkactions = the JSON string containing the bulk action specifications
 * data.actarray = the bulk actions specifications object
 */
// copy to data[name] from input[name] || option[name]
optCopy(['table', 'buttons', 'btns', 'refpage', 'bulkactions', 'p', 'o', 'd', 'filter',
	'filterACLs', 'fields', 'field_list', 'keywords', 'view', 'relationship_id',
	'apply_to', 'apply_to_sys_id', 'window_size', 'show_breadcrumbs']);

I also copied the code that converts the buttons string into the btnarray array and hacked it up to convert the bulkactions string into an actarray array,

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

Over on the client side, I added two functions to the $scope, one for the master check box:

$scope.masterCheckBoxClick = function() {
	for (var i in c.data.list) {
		c.data.list[i].selected = c.data.master_checkbox;
	}
};

… and one for the bulk action selection:

$scope.bulkActionSelected = function() {
	if (c.data.bulk_action) {
		var parms = {};
		parms.table = c.data.table;
		parms.selected = [];
		for (var x in c.data.list) {
			if (c.data.list[x].selected) {
				parms.selected.push(c.data.list[x]);
			}
		}
		if (parms.selected.length > 0) {
			for (var b in c.data.actarray) {
				if (c.data.actarray[b].name == c.data.bulk_action) {
					parms.action = c.data.actarray[b];
				}
			}
			$rootScope.$emit(eventNames.bulkAction, parms);
		} else {
			spModal.alert('You must select at least one row for this action');
		}
	}
	c.data.bulk_action = '';
};

That took care of the root Data Table widget, but I still needed to do a little work on the SNH Data Table from URL Definition widget to pull our new query parameter down from the URL. That turned out to be just a simple addition to this line to add the new parameter name to the list of parameters to be copied:

copyParameters(data, ['p', 'o', 'd', 'filter', 'buttons', 'refpage', 'bulkactions']);

I also needed to copy the button code in the Content Selector widget to create similar code for the new bulk actions:

if (tableInfo[state].actarray && Array.isArray(tableInfo[state].actarray) && tableInfo[state].actarray.length > 0) {
	s.bulkactions = JSON.stringify(tableInfo[state].actarray);
}

Now we can go into the ButtonTestConfig Script Include that we built the other day and add a couple of bulk actions so that we can test this out:

Adding a few bulk actions to the test configuration

Now, let’s pull up our button_test page and see what we’ve got.

First look at our bulk actions modifications

Not too bad … we have our master checkbox in the heading row, the individual check boxes in the data rows, and our bulk action selector in the new extra footer. Very nice. And we can even test for the requirement that you have to select at least one item by selecting an action without selecting any rows.

Selecting an action without selecting any rows

Well, that seems to work. The master checkbox also seemed to work as desired, and selecting a few rows and then selecting an action also seemed to work, but since there is currently no one listening on the other end, it’s kind of hard to tell if that actually did anything or not. Maybe we can modify our Button Click Handler Example widget to listen for bulk actions as well. Maybe add something like this:

$rootScope.$on('data_table.bulkAction', function(e, parms) {
	displayBulkActionDetails(parms);
});

function displayBulkActionDetails(parms) {
	var html = '<div>'; 
	html += ' <div class="center"><h3>You selected the ' + parms.action.name + ' bulk action</h3></div>\n';
	html += ' <table>\n';
	html += '  <tbody>\n';
	html += '   <tr>\n';
	html += '    <td class="text-primary">Table: &nbsp;</td>\n';
	html += '    <td>' + parms.table + '</td>\n';
	html += '   </tr>\n';
	html += '   <tr>\n';
	html += '    <td class="text-primary">Action: &nbsp;</td>\n';
	html += '    <td><pre>' + JSON.stringify(parms.action, null, 4) + '</pre></td>\n';
	html += '   </tr>\n';
	html += '   <tr>\n';
	html += '    <td class="text-primary">Records: &nbsp;</td>\n';
	html += '    <td><pre>' + JSON.stringify(parms.selected, null, 4) + '</pre></td>\n';
	html += '   </tr>\n';
	html += '  </tbody>\n';
	html += ' </table>\n';
	html += '</div>';
	spModal.alert(html);
}

Let’s give that a whirl …

Bulk action listener results

Beautiful. It all appears to work as intended. Clearly some additional testing is warranted, but it’s not bad for an initial effort. I think it’s good enough to release an Update Set with all of the code. Of course, now we have broken our new Content Selector Configuration Editor, since that was not built to handle bulk actions, but that’s a problem for another day.

Content Selector Configuration Editor, Part VII

“Always do more than is required of you.”
George S. Patton

Well, we have come a long way since we first set to out to build our little Content Selector Configuration Editor. We have built the primary widget and quite a few modal pop-up widgets and have pretty much built out everything that you would need to maintain the configuration. The only thing left at this point is to actually save the data now that it has been edited. Not too long ago, we built a tool that saved its data in a Script Include by rewriting the script and updating a Script Include record. We can use that same technique here by building the script from the user’s input, and then storing it in the script column of a new or updated Script Include record. The first order of business, then, would be to build the script, starting with the initial definition of the class and its prototype:

var script = "var ";
script += name;
script += " = Class.create();\n";
script += name;
script += ".prototype = Object.extendsObject(ContentSelectorConfig, {\n";
script += "	initialize: function() {\n";
script += "	},\n";
script += "\n";

Assuming the name of your script was MyTestConfig, that would generate the following starting lines for your Script Include:

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

Next, we need to build the Array of all of the defined Perspectives. This we can do via a simple loop through the Perspective data:

script += "	perspective: [";
var separator = '';
for (var p=0; p<input.config.perspective.length; p++) {
	var thisPerspective = input.config.perspective[p];
	script += separator;
	script += "{\n		name: '";
	script += thisPerspective.name;
	script += "',\n		label: '";
	script += thisPerspective.label;
	script += "',\n		roles: '";
	script += thisPerspective.roles;
	script += "'\n	}";
	separator = ",";
}
script += "],\n\n";

Next, we will need to build the Array of all of the States, which will look very similar to what we just did for all of the Perspectives.

script += "	state: [";
separator = '';
for (var s=0; s<input.config.state.length; s++) {
	var thisState = input.config.state[s];
	script += separator;
	script += "{\n		name: '";
	script += thisState.name;
	script += "',\n		label: '";
	script += thisState.label;
	script += "'\n	}";
	separator = ",";
}
script += "],\n\n";

Now it is time for the Tables, which is where things get a little more complicated. Here we have to loop through every defined Perspective, and then loop through every Table defined for that Perspective, and then loop through every defined State, and then if there are Buttons/Icons and/or Reference Pages, we will need to loop through all of those as well.

script += "	table: {";
separator = '';
for (var tp=0; tp<input.config.perspective.length; tp++) {
	var tablePerspective = input.config.perspective[tp];
	script += separator;
	script += "\n		";
	script += tablePerspective.name;
	script += ": [";
	var tableSeparator = '';
	for (var tt=0; tt<input.config.table[tablePerspective.name].length; tt++) {
		var tableTable = input.config.table[tablePerspective.name][tt];
		script += tableSeparator;
		script += "{\n			name: '";
		script += tableTable.name;
		script += "',\n			displayName: '";
		script += tableTable.displayName;
		script += "'";
		for (var ts=0; ts<input.config.state.length; ts++) {
			var tableState = input.config.state[ts];
			script += ",\n			";
			script += tableState.name;
			script += ": {\n				filter: '";
			script += tableTable[tableState.name].filter;
			script += "',\n				fields: '";
			script += tableTable[tableState.name].fields;
			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				}";
				lastSeparator = ",";
			}
			script += "],\n				refmap: {";
			lastSeparator = '';
			for (var key  in tableTable[tableState.name].refmap) {
				script += lastSeparator;
				script += "\n					";
				script += key;
				script += ": '";
				script += tableTable[tableState.name].refmap[key];
				script += "'";
				lastSeparator = ",";
			}
			script += "\n				}\n			}";
		}
		script += "\n		}";
		tableSeparator = ",";
	}
	script += "]";
	separator = ",";
}
script += "\n	},\n\n";

And finally, we have to close out the script with the type property and all of the closing characters.

		script += "	type: '";
		script += name;
		script += "'\n});";

That completes the creation of the script from the user’s input. Now we have to save it. If this is an existing record, then all we need to do is fetch it and update the script column, but if this is a new record, then we have a little bit more work to do.

var scriptGR = new GlideRecord('sys_script_include');
if (data.newRecord) {
	scriptGR.initialize();
	scriptGR.name = name;
	scriptGR.api_name = 'global.' + name;
	scriptGR.description = name;
	scriptGR.access = 'public';
	scriptGR.insert();
} else {
	scriptGR.get('name', name);
}
scriptGR.setValue('script', script);
scriptGR.update();
data.sys_id = scriptGR.getUniqueValue();

The reason that we grab the sys_id there at the end after the record has been saved is so that we can take the user to the saved Script Include once the record has been inserted/updated. We do that on the client-side in the code that is launched by the Save button.

$scope.save = function() {
	var missingData = false;
	if (c.data.config.perspective.length == 0) {
		missingData = true;
	} else if (c.data.config.state.length == 0) {
		missingData = true;
	} else {
		for (var p in c.data.config.perspective) {
			if (c.data.config.table[c.data.config.perspective[p].name].length == 0) {
				missingData = true;
			}
		}
	}
	if ($scope.form1.$valid && !missingData) {
		c.data.action = 'save';
		c.server.update().then(function(response) {
			window.location.href = '/sys_script_include.do?sys_id=' + response.sys_id;
		});
	} else {
		$scope.form1.$setSubmitted(true);
		spModal.alert('You must correct all form validation errors before saving');
	}		
};

This assumes that we are doing all of this in the main UI and not on some Portal Page, and to facilitate that, we add a menu item to the Tools menu so that we can launch this widget from within the primary UI.

Content Selector Configurator menu item definition

That’s about it for all of the parts and pieces necessary to make this initial version work. Certainly there are a number of things that we could do to make it a little better here and there, but overall I think it is a pretty good start. We should play around with it a bit and try building out a few different configurations, but for those of you who like to play along at home, here is an Update Set with what should be everything that you need to make this work.

Content Selector Configuration Editor, Part II

“If you concentrate on small, manageable steps you can cross unimaginable distances.”
Shaun Hick

Before I was sidetracked by my self-inflicted issues with my Configurable Data Table Widget Content Selector, I was just about to dive into the red meat of my new Content Selector Configuration Editor. Now that those issues have been resolved, we can get back to the fun stuff. As those of you who have been following along at home will recall, there are three distinct sections of the JSON object used to configure the content selector: 1) Perspective, 2) State, and 3) Table. Both the Perspective and State sections are relatively simple arrays of objects, but the Table section is much more complex, having properties for every State of every Table in every Perspective. Since I like to start out with the simple things first, my plan is to build out the Perspective section first, work out all of the kinks, and then pretty much clone that working model to create the State section. Once we get through all of that, then we can deal with the more complicated Table section.

As usual, we will start out with the visual components first and try to get things to look halfway decent before we crawl under the hood and wire everything together. Perspectives have only three properties, a Label, a Name, and an optional list of Roles to limit access to the Perspective. We should be able to lay all of this out in a relatively simple table, with one row for each Perspective. Here is what I came up with:

<div>
  <h4 class="text-primary">${Perspectives}</h4>
</div>
<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;">Roles</th>
        <th style="text-align: center;">Edit</th>
        <th style="text-align: center;">Delete</th>
      </tr>
    </thead>
    <tbody>
      <tr ng-repeat="item in c.data.config.perspective">
        <td data-th="Name">{{item.label}}</td>
        <td data-th="Label">{{item.name}}</td>
        <td data-th="Roles">{{item.roles}}</td>
        <td data-th="Edit" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editPerspective($index)" alt="Click here to edit the details of this Perspective" title="Click here to edit the details of this Perspective" style="cursor: pointer;"/></td>
        <td data-th="Delete" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deletePerspective($index)" alt="Click here to permanently delete this Perspective" title="Click here to permanently delete this Perspective" style="cursor: pointer;"/></td>
      </tr>
    </tbody>
  </table>
</div>
<div style="width: 100%; text-align: right;">
  <button ng-click="editPerspective('new')" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new Perspective">Add a new Perspective</button>
</div>

In addition to the columns for the three properties, I also added a column for a couple of action icons, one to edit the Perspective and one to delete the Perspective. I thought about putting input elements directly in the table for editing the values, but I decided that I would prefer to keep everything read-only unless you specifically asked to edit one of the rows. If you do want to edit one of the rows, then I plan on popping up a simple modal dialog where you can make your changes (we’ll get to that a little later).

I also added a button down at the bottom that you can use to add a new Perspective, which should pop up the same modal dialog without any existing values. Here’s what the layout looks like so far:

Perspective section of the configuration object editor

That’s not too bad. I think that it looks good enough for now. Our action icons reference nonexistent client-side functions right now, though, so next we ought to build those out. The Delete process looks like it might be the simplest of the two, so let’s say we start there. For starters, it’s always a good practice to pop up a confirm dialog before actually deleting anything, and I always like to use the spModal confirm option for that as opposed to a simple Javascript confirm. Since deleting the Perspective will also wipe out any Table information defined for that Perspective, we will want to warn them of that as well. Here is what I came up with:

$scope.deletePerspective = function(i) {
	var confirmMsg = '<b>Delete Perspective</b>';
	confirmMsg += '<br/>Deleting the ';
	confirmMsg += c.data.config.perspective[i].label;
	confirmMsg += ' Perspective will also delete all information for every State of every Table in the Perspective.';
	confirmMsg += '<br/>Are you sure you want to delete this Perspective?';
	spModal.confirm(confirmMsg).then(function(confirmed) {
		if (confirmed) {
			c.data.config.table[c.data.config.perspective[i].name] = null;
			c.data.config.perspective.splice(i, 1);
		}
	});
};

If the operator confirms the delete action, then we first null out all of the Table information for that Perspective, and then we slice out the Perspective from the list. We have to do things in that order. If we removed the Perspective first, then we would lose access to the name, which is needed to null out the Table data. Here is what the confirm dialog looks like on the page:

Perspective Delete Confirmation pop-up

That takes care of the easy one. Now on to the Edit action. For this one, we will use spModal as well, but instead of the confirm method we will be using the open method to launch a small Perspective Editor widget. The open method has an argument called shared that we can use to pass data to and from the widget. Here is the code to launch the widget and collect the updated data when it closes:

$scope.editPerspective = function(i) {
	var shared = {roles:{}};
	if (i != 'new') {
		shared.label = c.data.config.perspective[i].label;
		shared.name = c.data.config.perspective[i].name;
		shared.roles.value = c.data.config.perspective[i].roles;
		shared.roles.displayValue = c.data.config.perspective[i].roles;
	}
	spModal.open({
		title: 'Perspective Editor',
		widget: 'b83b9f342f3320104425fcecf699b6c3',
		shared: shared
	}).then(function() {
		if (i == 'new') {
			c.data.config.table[shared.name] = [];
			i = c.data.config.perspective.length;
			c.data.config.perspective.push({});
		} else {
			if (shared.name != c.data.config.perspective[i].name) {
				c.data.config.table[shared.name] = c.data.config.table[c.data.config.perspective[i].name];
				c.data.config.table[c.data.config.perspective[i].name] = null;
			}
		}
		c.data.config.perspective[i].name = shared.name;
		c.data.config.perspective[i].label = shared.label;
		c.data.config.perspective[i].roles = shared.roles.value;
	});
};

Since this function is intended to be used for both new and existing Perspectives, we have to check to see which one it is in a couple of places. Before we open the widget dialog, we will need to build a new object if this is a new addition, and after the dialog closes, we will have to establish a Table array for the new Perspective as well as establish an index value and an empty object at that index in the Perspective array. Also, if this is an existing Perspective and they have changed the name of the Perspective, then we need to move all of the associated Table information from the old name to the new name and get rid of everything under the old name. Other than that, the new and existing edit processes are pretty much the same.

This takes care of the client side function to launch the widget, but we still need to build the widget. That might get a little involved, and we have already covered quite a bit, so this may be a good place to stop for now. We’ll tackle that Perspective Editor widget first thing next time out, which should wrap up the Perspective section. Maybe we will even have time to clone it all and finish up the State section as well.

Configurable Data Table Widget Content Selector, Corrected

“No great thing is created suddenly.”
Epictetus

While playing around with my new Content Selector Configuration Editor, I ran into a few errors in my Configurable Data Table Widget Content Selector when working in the Portal Page Designer. The problem that I ran into was that errors in widget prevented the widget from appearing in the container, which then prevented you from accessing the widget controls that let you edit the widget options or delete the widget. I had run into something similar before with my Dynamic Service Portal Breadcrumbs, so I pretty much knew what was going on — I just needed to hunt down the specific error. In this particular case, it turned out to me more than one error, and fixing the first one still did not solve the problem completely.

The problem occurs when first dragging the widget onto a page. Before you have an opportunity to edit the widget options and specify a configuration script,the widget tries to run without a configuration script and then it crashes because it has no configuration script. Here is the offending code:

if (!c.data.table) {
	if (c.data.config.defaults.table) {
		refreshPage(c.data.config.defaults.table, c.data.config.defaults.perspective, c.data.config.defaults.state);
	} else {
		window.location.search = '';
	}
}

The problem is that second line that wants to grab the default table value from the defaults object in the configuration. Since we haven’t had a chance to specify a configuration script just yet, there is no defaults object in the configuration, and attempting to access the table property of that nonexistent object will earn you a NullPointerException. That’s not good! Before checking for the table property, we need to first check to see if the defaults object exists. This modification should do the trick:

if (!c.data.table) {
	if (c.data.config.defaults && c.data.config.defaults.table) {
		refreshPage(c.data.config.defaults.table, c.data.config.defaults.perspective, c.data.config.defaults.state);
	} else {
		$location.search('');
	}
}

That keeps the widget from crashing, but there is still no content displayed on the screen in the Page Designer, so I decided to add a little something to the top of the HTML that would only show if there was no configuration script specified. That code looks like this:

<div ng-hide="options && options.configuration_script">
  <div class="alert alert-danger">
    ${You must specify a configuration script using the widget option editor}
  </div>
</div>

Now when you drag a brand new copy of the widget onto the canvas in the Page Designer, you get this:

Content Selector widget with no configuration script specified

That takes care of an empty configuration script name, but what if you enter a name for script that isn’t a valid Script Include? Well, that crashes the widget code as well, so we will need to fix that, too. This time, the issue is on the server side, where we were assuming that we would always get an instance of whatever script was specified. As you can see from the following code snippet, we don’t even bother to check to see if something was returned from the Instantiator:

var configurator = instantiator.getInstance(options.configuration_script);
data.config = configurator.getConfig($sp);
data.config.authorizedPerspective = getAuthorizedPerspectives();
establsihDefaults();

That’s another easy fix, though. We just need to check to make sure that it is there before we attempt to use it.

var configurator = instantiator.getInstance(options.configuration_script);
if (configurator != null) {
	data.config = configurator.getConfig($sp);
	data.config.authorizedPerspective = getAuthorizedPerspectives();
	establishDefaults();
}

Here is another instance where it would be good to let the developer know that something is amiss, so I added yet anothe DIV to the top of the HTML:

<div ng-show="options && options.configuration_script && !data.config.defaults">
  <div class="alert alert-danger">
    {{options.configuration_script}} ${is not a valid Script Include}
  </div>
</div>

Here’s how that looks in action:

Error message when a invalid Script Include is specified

That should resolve all of the errors that I have discovered so far. Here is the corrected Update Set, which should replace all of the broken parts in the last one and get things working again.