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.

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.

Collaboration Store, Part III

“It is not enough to do your best: you must know what to do, and then do your best.”
W. Edwards Deming

Today we will build the widget for the initial set-up process for the Collaboration Store app. I always like to start with the visual portion and lay things out on the screen the way that I want to see them, but before we get into that, I should explain a little bit about my basic concept for the set-up process. There are actually three independent screens involved: 1) the initial screen where you enter all of the data about your installation, 2) an email verification screen where you enter a code that was sent to your email address to verify your access to the email account, and 3) a final completion screen that lets you know that you are all set up. The HTML for the widget will include all three screens, and I will use ng-show attributes to control which section is visible at any given stage of the process. Within the widget, I will refer to that as the phase, and set up a variable called c.data.phase to track the progress through the screens.

Here is what the initial data entry screen looks like:

Initial set-up data entry screen

… and here is the HTML for that initial (phase 1) screen:

<div class="row" ng-show="c.data.phase == 1">
  <div style="text-align: center;">
    <h3>${Collaboration Store Set-up}</h3>
  </div>
  <div>
    <p>
      Welcome to the Collaboration Store set-up process.
      There are two ways that you can set up the Collaboration Store on your instance:
      1) you can be the Host Instance to which all other instances connect, or
      2) you can connect to an existing Collaboration Store with their permission.
      To become the Host Instance of your own Collaboration Store, select <em>This instance will be
      the Host of the store</em> from the Installation Type choices below.
      If you are not the Host Instance, then you will need to provide the Instance ID of the
      Collaboration Store to which you would like to connect.
    </p>
  </div>
  <form id="form1" name="form1" novalidate>
    <div class="row">
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-model="c.data.instance_type"
          snh-name="instance_type"
          snh-label="Installation Type"
          snh-type="select"
          snh-required="true"
          snh-choices='[{"value":"host", "label":"This instance will be the Host of the store"},
                        {"value":"client", "label":"This instance will connect to an existing store"}]'/>
      </div>
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-model="c.data.host_instance_id"
          snh-name="host_instance_id"
          snh-label="Host Instance ID"
          snh-required="c.data.instance_type == 'client'"
          ng-show="c.data.instance_type == 'client'"/>
        <snh-form-field
          snh-model="c.data.store_name"
          snh-name="store_name"
          snh-label="Store Name"
          snh-required="c.data.instance_type == 'host'"
          ng-show="c.data.instance_type == 'host'"/>
      </div>
    </div>
    <div class="row">
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-model="c.data.instance_name"
          snh-name="instance_name"
          snh-label="Instance Display Name"
          snh-required="true"/>
      </div>
      <div class="col-xs-12 col-sm-6">
        <snh-form-field
          snh-model="c.data.email"
          snh-name="email"
          snh-label="Email"
          snh-type="email"
          snh-required="true"/>
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12">
        <snh-form-field
          snh-model="c.data.description"
          snh-name="description"
          snh-label="Instance Description"
          snh-type="textarea"
          snh-required="true"/>
      </div>
    </div>
  </form>
  <div class="row">
    <div class="col-sm-12" style="text-align: center;">
      <button class="btn btn-primary" ng-disabled="!(form1.$valid)" ng-show="c.data.instance_type == 'host'" ng-click="save();">${Create New Collaboration Store}</button>
      <button class="btn btn-primary" ng-disabled="!(form1.$valid)" ng-show="c.data.instance_type == 'client'" ng-click="save();">${Complete Set-up and Request Access}</button>
    </div>
  </div>
</div>

Basically, this is just a standard HTML form full of snh-form-fields organized into rows and columns. There are a couple of fields and a couple of buttons that are controlled by the value of that first SELECT, but other than that, it is pretty standard stuff, and there is no reason to get into any of that here.

The screen for the 2nd phase is much simpler, with only a single data entry field used to collect the code that was sent out in a Notification (more on that later) to verify the email address.

Email verification data entry screen

… and here is the HTML for the phase 2 screen:

<div class="row" ng-show="c.data.phase == 2">
  <div style="text-align: center;">
    <h3>${Email Verification}</h3>
  </div>
  <div>
    <p>
      A verification email has been sent to {{c.data.email}} with a one-time security code.
      Please enter the code below to continue.
    </p>
    <p>
      Cancelling this process will terminate the set-up process.
    </p>
  </div>
  <form id="form2" name="form2" novalidate>
    <div class="row">
      <div class="col-sm-12">
        <snh-form-field
          snh-model="c.data.security_code"
          snh-name="security_code"
          snh-label="Security Code"
          snh-required="true"
          placeholder="Enter the security code sent to you via email"/>
      </div>
    </div>
  </form>
  <div class="row">
    <div class="col-sm-12" style="text-align: center;">
      <button class="btn btn-default" ng-disabled="!(form2.$valid)" ng-click="cancel();">${Cancel}</button>
      <button class="btn btn-primary" ng-disabled="!(form2.$valid)" ng-click="verify();">${Submit Verification Code}</button>
    </div>
  </div>
</div>

The screen for the 3rd phase is even simpler, with no data entry fields at all. It is just a message indicating that set-up is complete and was successful.

Set-up completion screen

… and here is the HTML for the phase 3 screen:

<div class="row" ng-show="c.data.phase == 3">
  <div style="text-align: center;">
    <h3>${Set Up Complete!}</h3>
  </div>
  <div>
    <p>${Congratulations!}</p>
    <p>
      The Collaboration Store set-up is now complete. Your instance has been successfully registered with the
      <b class="text-primary">{{c.data.registeredHostName}}</b> ({{c.data.registeredHost}})
      Host and is now ready to utilize the Collaboration Store features.
    </p>
  </div>
</div>

That pretty much takes care of the visual portion of the widget, which is usually the easiest part. Now we have to put all of the code underneath to make everything do all of the things that we want to do during the set-up process. One of the first things that we will want to do is to make sure that the set-up process has not already been completed. We should be able to tell that right off the bat by looking to see if there is a record in the table for the instance. If there is, then things have already been set up, and so we can artificially advance things to phase 3 and put up that final screen in the event that someone tries to go through the process a second time.

Assuming that this is the first time through, though, we will want to validate the data, and assuming that all goes well, we will want to send out the notification with the random code and advance the phase so that we can verify the email address. Once that’s done, we will need to update the database, and in the case of a client instance, we will need to register the instance with the host. To make all of that work, we will need some REST services, and probably a Script Include to contain some code to handle both sides of those inter-instance communications. We are definitely not going to get through all of that in a single installment, but we can take them all on, one issue at a time, as we work our way through all of the parts and pieces that will need to be built. Probably the easiest thing to tackle next would be the client-side code of the widget, so let’s start with that next time out.

Collaboration Store, Part II

“Tell me and I forget. Teach me and I remember. Involve me and I learn.”
Benjamin Franklin

Now that I have gone and thrown the idea out there for all to see, it’s time to get to work and see if I can actually pull this off. To begin, I need to set up the Scoped Application, which is basically a repeat of what I went through to set up the Scoped Application for my little Webhooks project, so there is no need to repeat all of that here. Here is how it came out:

Initial Collaboration Store Scoped Application

With that out of the way, the next order of business is to create that first table in which to store all of the instances. Again, that is pretty standard stuff and not really worthy of a step by step walk-through of the process, but here is the associated form, which will give you an idea of the columns that I have selected at this point in the process:

Member Organization table input form

Now we have a place to store the information on the participating instances, so it’s time to build the initial set-up process that will populate this table. Before we dive into that, though, I should mention that when I set up the application, I also set up a few System Properties using the UI Action that I created for that purpose a couple of years ago.

UI Action to set up System Properties for a Scoped Application

That’s been a handy little tool that does a number of things under the hood, but we don’t need to get into all of that here. If you are interested in that for any reason, you can grab an Update Set from here. For this phase of the project, I came up with three properties that I think will be needed in order to do what I would like to do. That may change over time as I get more into the weeds, but for now, here is the list:

Initial System Properties for the Collaboration Store application

That should give us all of the artifacts that we should need to start working on the initial set-up process. As you might have noticed in the above screen shot, I have already created a menu item to launch the initial set-up process from the navigation side bar. Right now, you can also see some of the other menu options for the app, but sometime before things are ready for prime time, my plan is to make all other menu items inactive, and then once the set-up process has been completed, a final step in the process would activate all of the others and inactivate the set-up menu item. For now, though, you can see everything, and it will probably be that way for some time until we get much closer to the end of things.

As for the set-up process itself, there are a number of different ways to go here. I could build something the main UI, where the primary technology is Apache Jelly. I could also build a Service Portal widget, where the primary technology is AngularJS. Both of those are considered Old School at this point, though, and all of the cool kids are now using the Now® Experience UI Framework and the ui-component extension for application development. While that seems like the appropriate way to go, my personal skill set does not yet include mastery of that particular technology, and I don’t really feel like this project would be a good place to address that particular shortcoming in my technical expertise. Since the initial set-up is just one small part of this effort, I am going to take the easy way out and just build a simple widget.

Building a brand new widget starting with a blank canvas is a little bit of a project, though, so that seems like something worthy of its own dedicated installment. Rather than start on that here, let’s take that up next time out.

Service Portal User Directory

“Do. Or do not. There is no try.”
Yoda

I have been playing around a lot lately with my hacked up Service Portal Data Table widget, but just about everything that I have done has been centered around some kind of Task. I have been wanting to do something a little different, so I decided that I would use my breadcrumbs widget, my content selector, and my custom data table to create a User Directory portal page. I started out by creating a new page called user_directory and opening it up in the Portal Designer.

First I dragged a 12-wide container over into the top of the page and then dragged the breadcrumbs widget into that. Then I dragged over a 3/9 container and put the content selector into the narrow portion and the data table into the wider portion. That gave me my basic layout.

user_directory page basic layout

Since this was going to be a list of Users on the portal, I decided that the page to which we will link should be the user_profile portal page, so I updated the table options to link to that page, and to add a user icon as the glyph.

Updating the data table widget options

We also need to update the content selector widget options, but it is going to ask for a configuration script, which we have not built just yet, so let’s do that now. For this, we can use the new Content Selector Configuration Editor to create a new configuration script called UserDirectoryConfig.

Creating a new configuration script using the Content Selector Configuration Editor

For this initial version of the directory, we will have two Perspectives: 1) a general public perspective for all authenticated users, and 2) a user administration perspective for those with the authority to update user records. We will also define 3 States: 1) active users, 2) inactive users, and 3) all users. For each Perspective, we will define a single Table, the User (sys_user) table.

Updating the new configuration script using the Content Selector Configuration Editor

For all of the public states, we will specify the following field list:

name,title,department,location,manager

Since I do not want regular users looking at inactive users, I used the following filter for both the Active state and the All state:

active=true

For the Inactive state, which I wanted to always come up empty, I used this:

active=true^active=false

For the admin states, we will drop the manager and add the user_name, and for the All state, we will also add the active flag:

user_name,name,title,department,location,active

And of course the filters for the admin states are all pretty self explanatory, so there’s no need to waste any space on those here. One little extra thing that I did want to add to all of the admin states, though, was an Edit button. Clicking on the user_name will get you to the user_profile page, but if you want to edit the user information, you need to go the form page, so I added a button for that purpose to all three of the admin states.

Administrative Edit button

After completing the configuration, saving the form produced the following configuration script:

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

	perspective: [{
		name: 'everyone',
		label: 'Public',
		roles: ''
	},{
		name: 'admin',
		label: 'User Admin',
		roles: 'user_admin,admin'
	}],

	state: [{
		name: 'active',
		label: 'Active'
	},{
		name: 'inactive',
		label: 'Inactive'
	},{
		name: 'all',
		label: 'All'
	}],

	table: {
		everyone: [{
			name: 'sys_user',
			displayName: 'User',
			active: {
				filter: 'active=true',
				fields: 'name,title,department,location,manager',
				btnarray: [],
				refmap: {},
				actarray: []
			},
			inactive: {
				filter: 'active=true^active=false',
				fields: 'name,title,department,location,manager',
				btnarray: [],
				refmap: {},
				actarray: []
			},
			all: {
				filter: 'active=true',
				fields: 'name,title,department,location,manager',
				btnarray: [],
				refmap: {},
				actarray: []
			}
		}],
		admin: [{
			name: 'sys_user',
			displayName: 'User',
			active: {
				filter: 'active=true',
				fields: 'user_name,name,title,department,location',
				btnarray: [{
					name: 'edit',
					label: 'Edit',
					heading: 'Edit',
					icon: 'edit',
					color: '',
					hint: '',
					page_id: 'form'
				}],
				refmap: {},
				actarray: []
			},
			inactive: {
				filter: 'active=false',
				fields: 'user_name,name,title,department,location',
				btnarray: [{
					name: 'edit',
					label: 'Edit',
					heading: 'Edit',
					icon: 'edit',
					color: '',
					hint: '',
					page_id: 'form'
				}],
				refmap: {},
				actarray: []
			},
			all: {
				filter: 'true',
				fields: 'user_name,name,title,department,location,active',
				btnarray: [{
					name: 'edit',
					label: 'Edit',
					heading: 'Edit',
					icon: 'edit',
					color: '',
					hint: '',
					page_id: 'form'
				}],
				refmap: {},
				actarray: []
			}
		}]
	},

	type: 'UserDirectoryConfig'
});

Now we can go back into the Page Designer and edit the content selector widget options to specify our new configuration script.

Editing the content selector widget options in the Page Designer

Now all that is left to do is to go out to the Service Portal and give it a go. Let’s see how it comes out.

First look at the new User Directory portal page

I like it! I clicked around and tried a few things. Overall, I think it came out pretty good, but I did come across one flaw already: clicking on someone’s department or location will take you to the user_profile page and get you a nasty error message because there is no user on file with the sys_id of a department or location. That’s not good. That also brings up an interesting point: I don’t really want to send regular users to the form page for departments or locations, either. I need to find a more appropriate destination for those links. I guess I won’t be wrapping this all up in a bow today after all. I’ll have to give this one some thought, so this looks like an issue that we will have to take up next time out.

Customizing the Data Table Widget, Again

“Work never killed anyone. It’s worry that does the damage. And the worry would disappear if we’d just settle down and do the work.”
Earl Nightingale

It’s been a while since I first set out to build a custom version of the stock Data Table widget, but since that time, I have used my hacked up version for a number of different projects, including sharing pages with my companion widget, the Configurable Data Table Widget Content Selector. Now that I have a way to edit the configuration scripts for the content selector, that has become my primary method for setting up the parameters for a data table. The content selector, though, was designed to give the end user the ability to select different Perspectives, different States, and different Tables. That’s a nice feature, but there are times when I just want to display a table of data without any options to look at any other data. I thought about setting up an option to make the content selector hidden for those instances, but then it occurred to me that the better approach was to cut out the middleman entirely and create a version of the Data Table widget that read the configuration script directly. This way, I wouldn’t have to put the content selector on the page at all.

So I cloned my SNH Data Table from URL Definition widget to create a new SNH Data Table from JSON Configuration widget. Then I opened up my Content Selector widget and started stealing parts and pieces of that guy and pasting them into my new Data Table widget, starting with the widget option for the name of the configuration script:

{
      "hint":"Mandatory configuration script that is an extension of ContentSelectorConfig",
     "name":"configuration_script",
     "section":"Behavior",
     "label":"Configuration Script",
     "type":"string"
}

I also threw in three new options so that you could override the default Perspective, Table, and State values.

{
   "hint":"Optional override of the default Perspective",
   "name":"perspective",
   "section":"Behavior",
   "label":"Perspective",
   "type":"string"
},{
   "hint":"Optional override of the default Table",
   "name":"table",
   "section":"Behavior",
   "label":"Table",
   "type":"string"
},{
   "hint":"Optional override of the default State",
   "name":"state",
   "section":"Behavior",
   "label":"State",
   "type":"string"
}

In the HTML section, I copied in the two warning messages and pasted them in with minimal modifications:

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

On the server side, I deleted the first several lines of code that dealt with grabbing the table and view from the URL and making sure that something was there, and replaced it with some code that I pretty much lifted intact from the content selector widget. Here is the code that I removed:

deleteOptions(['table','field_list','filter','order_by', 'order_direction','order','maximum_entries']);
if (input) {
	data.table = input.table;
	data.view = input.view;
} else {
	data.table = $sp.getParameter('table') || $sp.getParameter('t');
	data.view = $sp.getParameter('view');
}

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

… and here is what I replaced it with:

data.config = {};
data.user = {sys_id: gs.getUserID(), name: gs.getUserName()};
if (options) {
	if (options.configuration_script) {
		var instantiator = new Instantiator(this);
		var configurator = instantiator.getInstance(options.configuration_script);
		if (configurator != null) {
			data.config = configurator.getConfig($sp);
			data.config.authorizedPerspective = getAuthorizedPerspectives();
			establishDefaults(options.perspective, options.table, options.state);
		}
	}
}

if (data.config.defaults && data.config.defaults.perspective && data.config.defaults.table && data.config.defaults.state) {
	var tableList = data.config.table[data.config.defaults.perspective];
	var tbl = -1;
	for (var i in tableList) {
		if (tableList[i].name == data.config.defaults.table) {
			tbl = i;
		}
	}
	data.tableData = tableList[tbl][data.config.defaults.state];
	data.table = data.config.defaults.table;
} else {
	data.invalid_table = true;
	data.table_label = "";
	return;
}

I also grabbed a couple of the functions that were called from there and pasted those in down at the bottom:

function getAuthorizedPerspectives() {
	var authorizedPerspective = [];
	for (var i in data.config.perspective) {
		var p = data.config.perspective[i];
		if (p.roles) {
			var role = p.roles.split(',');
			var authorized = false;
			for (var ii in role) {
				if (gs.hasRole(role[ii])) {
					authorized = true;
				}
			}
			if (authorized) {
				authorizedPerspective.push(p);
			}
		} else {
			authorizedPerspective.push(p);
		}
	}
	return authorizedPerspective;
}

function establishDefaults(perspective, table, state) {
	data.config.defaults = {};
	var p = data.config.authorizedPerspective[0].name;
	if (perspective) {
		if (data.config.table[perspective]) {
			p = perspective;
		}
	}
	if (p) {
		data.config.defaults.perspective = p;
		for (var t in data.config.table[p]) {
			if (!data.config.defaults.table) {
				data.config.defaults.table = data.config.table[p][t].name;
			}
		}
		if (table) {
			for (var t1 in data.config.table[p]) {
			if (data.config.table[p][t1].name == table) {
					data.config.defaults.table = table;
				}
			}
		}
		data.config.defaults.state = data.config.state[0].name;
		if (state) {
			for (var s in data.config.state) {
				if (data.config.state[s].name == state) {
					data.config.defaults.state  = state;
				}
			}
		}
	}
}

I also reworked the area labeled widget parameters to get the data from the configuration instead of the URL. That area now looks like this:

// widget parameters
data.table_label = gr.getLabel();
data.filter = data.tableData.filter;
data.fields = data.tableData.fields;
data.btnarray = data.tableData.btnarray;
data.refmap = data.tableData.refmap;
data.actarray = data.tableData.actarray;
copyParameters(data, ['p', 'o', 'd', 'relationship_id', 'apply_to', 'apply_to_sys_id']);
data.filterACLs = true;
data.show_keywords  = true;
data.fromJSON = true;
data.headerTitle = (options.use_instance_title == "true") ? options.title : gr.getPlural();
data.enable_filter = options.enable_filter;
data.show_new = options.show_new;
data.show_breadcrumbs = options.show_breadcrumbs;
data.table_name = data.table;
data.dataTableWidget = $sp.getWidget('snh-data-table', data);

No modifications were needed on the client side, so now I just needed to create a new test page, drag the widget onto the page and then use the little pencil icon to configure the widget. I called my new page table_from_json and pulled it up in the Page Designer to drag in this new widget. Using the widget option editor, I entered the name of the Script Include that we have been playing around with lately and left all of the other options that I added blank for this first test.

SNH Data Table from JSON Configuration widget option editor

With that saved, all that was left to do was to go out to the Service Portal and bring up the page.

SNH Data Table from JSON Configuration widget on the new test page

Not bad! I played around with it for a while, trying out different options using the widget option editor in the Page Designer, and everything seems like it all works OK. I’m sure that I’ve hidden some kind of error deep in there somewhere that will come out one day, but so far, I have not stumbled across it in any of my feeble testing. For those of you who like to play along at home, here is an Update Set that I am hoping contains all of the needed parts.

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 VIII

“Tinkering is something we need to know how to do in order to keep something like the space station running. I am a tinkerer by nature.”
Leroy Chiao

I know what you are thinking: Didn’t we wrap this series up last time with the release of the final Update Set? Well, technically you would be correct in that we finished building all of the parts and bundled them all up in an Update Set for those of you who like to play along at home, but … we really did not spend a whole lot of time on the purpose for all of this, so I thought it might be a good time to back up the truck just a tad and demo some of the features of the customized data table widget and the associated content selector widget. Mainly, I want to talk about the buttons and icons and how all of that works, but before we do that, let’s go all the way back and talk about the basic idea behind these customizations of some pretty cool stock products.

I really liked the stock Data Table widget that comes bundled with the Service Portal, but the one thing that annoyed me was that each row was one huge clickable link, which was a departure from the primary UI, where every column could potentially be a link if it contained a Reference field. So, I rewired it. Of course, once you start playing with something then you end up doing all kinds of other crazy things and before you know it, you have this whole subset of trinkets and doodads that only you know anything about. So, on occasion, I feel the need to stop talking about what I am building and how it is constructed, and spend a little time talking about how to use it and what it is good for. Hence, today’s discussion of buttons and icons on the customized Data Table widget.

Before we get started, though, I do need to confess that in all of the excitement around creating a way to edit the JSON configuration object for the Configurable Data Table Widget Content Selector, I completely forgot about one of the options for handling a button click: opening up a new portal page. When we first introduced the idea of having buttons and icons on the rows of the Data Table widget, we made allowances for adding a page_id property to the button definition, and if that property were valued, we would link to that page on a click; otherwise, we would broadcast the click details. We did not include the page_id property in either the Content Selector Configuration Editor widget or the Button/Icon Editor widget, so let’s correct that oversight right now. First, we will need to add that property to the HTML for the table of Buttons/Icons.

<table class="table table-hover table-condensed">
  <thead>
    <tr>
      <th style="text-align: center;">${Label}</th>
      <th style="text-align: center;">${Name}</th>
      <th style="text-align: center;">${Heading}</th>
      <th style="text-align: center;">${Icon}</th>
      <th style="text-align: center;">${Icon Name}</th>
      <th style="text-align: center;">${Color}</th>
      <th style="text-align: center;">${Hint}</th>
      <th style="text-align: center;">${Page}</th>
      <th style="text-align: center;">${Edit}</th>
      <th style="text-align: center;">${Delete}</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="btn in tbl[state.name].btnarray" ng-hide="btn.removed">
      <td data-th="${Label}">{{btn.label}}</td>
      <td data-th="${Name}">{{btn.name}}</td>
      <td data-th="${Heading}">{{btn.heading}}</td>
      <td data-th="${Icon}" style="text-align: center;">
        <a ng-if="btn.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{btn.color || 'default'}}" title="{{btn.hint}}" data-original-title="{{btn.hint}}">
          <span class="icon icon-{{btn.icon}}" aria-hidden="true"></span>
          <span class="sr-only">{{btn.hint}}</span>
         </a>
       </td>
       <td data-th="${Icon Name}">{{btn.icon}}</td>
       <td data-th="${Color}">{{btn.color}}</td>
       <td data-th="${Hint}">{{btn.hint}}</td>
       <td data-th="${Page}">{{btn.page_id}}</td>
       <td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editButton(btn)" alt="Click here to edit this Button/Icon" title="Click here to edit this Button/Icon" style="cursor: pointer;"/></td>
       <td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteButton(btn, tbl[state.name].btnarray)" alt="Click here to delete this Button/Icon" title="Click here to delete this Button/Icon" style="cursor: pointer;"/></td>
     </tr>
   </tbody>
 </table>

… and then we will need to pass it back and forth between the main widget and the pop-up Button/Icon Editor widget:

$scope.editButton = function(button, btnArray) {
	var shared = {page_id: {value: '', displayValue: ''}};
	if (button != 'new') {
		shared.label = button.label;
		shared.name = button.name;
		shared.heading = button.heading;
		shared.icon = button.icon;
		shared.color = button.color;
		shared.hint = button.hint;
		shared.page_id = {value: button.page_id, displayValue: button.page_id};
	}
	spModal.open({
		title: 'Button/Icon Editor',
		widget: 'button-icon-editor',
		shared: shared
	}).then(function() {
		if (button == 'new') {
			button = {};
			btnArray.push(button);
		}
		button.label = shared.label || '';
		button.name = shared.name || '';
		button.heading = shared.heading || '';
		button.icon = shared.icon || '';
		button.color = shared.color || '';
		button.hint = shared.hint || '';
		button.page_id = shared.page_id.value || '';
	});
};

… and then, of course, we need to update the Button/Icon Editor itself by dragging in the page selector form field from the Reference Page Editor widget and adding it to the fields on the Button/Icon Editor form.

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="the_name"
      snh-label="Name"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.heading"
      snh-name="heading"/>
    <snh-form-field
      snh-model="c.widget.options.shared.icon"
      snh-name="icon"
      snh-type="icon"/>
    <snh-form-field
      snh-model="c.widget.options.shared.color"
      snh-name="color"
      snh-type="select"
      snh-choices='[{"label":"default","value":"default"},{"label":"primary","value":"primary"},{"label":"secondary","value":"secondary"},{"label":"success","value":"success"},{"label":"danger","value":"danger"},{"label":"warning","value":"warning"},{"label":"info","value":"info"},{"label":"light","value":"light"},{"label":"dark","value":"dark"},{"label":"muted","value":"muted"},{"label":"white","value":"white"}]'/>
    <snh-form-field
      snh-model="c.widget.options.shared.hint"
      snh-name="hint"/>
    <snh-form-field
      snh-type="reference"
      snh-model="c.widget.options.shared.page_id"
      snh-name="page_id"
      placeholder="Choose a Portal Page"
      table="'sp_page'"
      display-field="'title'"
      display-fields="'id'"
      value-field="'id'"
      search-fields="'id,title'"/>
    <div id="element.example" class="form-group">
      <div id="label.example" class="snh-label" nowrap="true">
        <label for="example" class="col-xs-12 col-md-4 col-lg-6 control-label">
          <span id="status.example"></span>
          <span title="Example" data-original-title="Example">Example</span>
        </label>
      </div>
      <span class="input-group">
        <a ng-if="!c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">{{c.widget.options.shared.label}}</a>
        <a ng-if="c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">
          <span class="icon icon-{{c.widget.options.shared.icon}}" aria-hidden="true"></span>
          <span class="sr-only">{{c.widget.options.shared.hint}}</span>
        </a>
      </span>
    </div>
  </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>

That should take care of that little oversight. Now, let’s get back to showing off the various ways in which you can use buttons and icons on the customized Data Table widget.

To start out, let’s use the tool that we just made to create a brand new configuration for a sample page where we can demonstrate how the buttons and icons work. Let’s click on our new Menu Item to bring up the tool, click on the Create a new Content Selector Configuration button, enter the name of our new Script Include, and then click on the OK button.

Creating a new configuration object using the new tool

When our new empty configuration comes up in the tool, let’s define a single Perspective called Button Test, and a couple of different States, Active and Not Active. We want to keep things simple at this point, mainly so as not to distract from our primary purpose here, which is to show how the buttons and icons work. Once we have defined our Perspective and States, click on the Add new Table button and select the Incident table from the list.

Selecting the Incident table from the pop-up selection list

Select just a couple of fields, say Number and Short Description, and set the filter for the Active State to active=true. Then we can start adding Buttons and Icons using the Add new Button/Icon button.

Adding a new Button/Icon

Those of you paying close attention will notice that the image above was taken before we added the page_id property to the Button/Icon configuration. The newer version has a pick list of portal pages below the Hint and above the Example. You will want to define at least one Button/Icon with a page value. Keep adding different buttons and icons until you have a representative sample of different things and then we can see how it all renders out in actual use.

New configuration with several different buttons and icons

Once you have a few set up for testing, save the new configuration and take a look at the resulting script, which should look something like this:

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

	perspective: [{
		name: 'button_test',
		label: 'Button Test',
		roles: ''
	}],

	state: [{
		name: 'active',
		label: 'Active'
	},{
		name: 'not_active',
		label: 'Not Active'
	}],

	table: {
		button_test: [{
			name: 'incident',
			displayName: 'Incident',
			active: {
				filter: 'active=true',
				fields: 'number,short_description',
				btnarray: [{
					name: 'button1',
					label: 'Button #1',
					heading: '',
					icon: '',
					color: 'info',
					hint: 'This is Button #1',
					page_id: ''
				},{
					name: 'button2',
					label: 'Button #2',
					heading: 'Button #2',
					icon: '',
					color: 'warning',
					hint: 'This is Button #2',
					page_id: ''
				},{
					name: 'button3',
					label: 'Button #3',
					heading: 'B3',
					icon: '',
					color: '',
					hint: 'This is Button #3',
					page_id: 'ticket'
				},{
					name: 'icon1',
					label: 'Icon #1',
					heading: '-',
					icon: 'cross',
					color: 'danger',
					hint: 'Danger Will Robinson!',
					page_id: ''
				},{
					name: 'icon2',
					label: 'Icon #2',
					heading: 'I2',
					icon: 'download',
					color: 'success',
					hint: 'Click here to do something ...',
					page_id: ''
				}],
				refmap: {}
			},
			not_active: {
				filter: 'active=false',
				fields: 'number,short_description',
				btnarray: [],
				refmap: {}
			}
		}]
	},

	type: 'ButtonTestConfig'
});

Now that we have that all ready to rock and roll, we need to configure a new test page so that we can put it to use. From the list of Portal Pages, click on the New button and create a page called button_test and save it so that we can pull it up in the Page Designer.

Setting up the new page in the Page Designer

From the container list, drag over a 3/9 container and drag the Content Selector widget into the narrow portion and the SNH Data Table from URL Configuration widget into the wider portion. You shouldn’t have to edit the Data Table widget, but you will need to click on the pencil icon on the Content Selector widget so that you enter the name of your new configuration script.

Entering the name of the configuration script in the widget options

Once that has been completed, you should be able to pull up your new page in the Service Portal and see how it renders out. You can click on any of your buttons or icons at this point, and if you click on one with a page_id value, that page should come up with the record from that row. If you click on any of the other buttons or icons, through, nothing will happen, because we have not set up anything to react to the button clicks at this point. However, if everything is working as it should be, we are now ready to do just that.

When a button or icon is clicked on the customized Data Table widget, and there is no page_id value defined, all that happens is that the details are broadcast out. Some other widget on the page needs to be listening for that broadcast in order for something to happen. The secret, then, is to have some client-side code somewhere that looks something like this:

$rootScope.$on('button.click', function(e, parms) {
	// do something 
});

For demonstration purposes we can build a simple test widget that will do just that, and in response to a click event, display all of the details of that event in an spModal alert. Let’s call this widget Button Click Handler Example, and give it the following client-side code:

function ButtonClickHandlerExample(spModal, $rootScope) {
	var c = this;

	$rootScope.$on('button.click', function(e, parms) {
		displayClickDetails(parms);		
	});

	function displayClickDetails(parms) {
		var html = '<div>'; 
		html += ' <div class="center"><h3>You clicked on the ' + parms.button.name + ' button</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">Sys ID: &nbsp;</td>\n';
		html += '    <td>' + parms.sys_id + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Button: &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.button, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Record: &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.record, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '  </tbody>\n';
		html += ' </table>\n';
		html += '</div>';
		spModal.alert(html);
	}
}

Now that we have our widget, we can pop back into the Page Designer and drag the widget anywhere on the screen. It has no displayable content, so it really doesn’t matter where you put it as long as it is present on the page somewhere. Once that’s done, we can pull up our page on the portal again and start clicking around again to see what we get.

Button click results

In addition to the details of the record on that row and the button that was clicked, you also get the name of the table and the sys_id of the record. Any of this data can be used to perform any number of potential actions, all of which are completely outside of the two reusable components, the customized Data Table and the configurable Content Selector. You shouldn’t have to modify either of those widgets to create custom functionality; just configure the Content Selector with an appropriate JSON config object, and then add any custom click handlers to your page for any button actions that you would like to configure. Here is an Update Set that includes the page_id corrections as well as the button test widget so that you can click around on your own and see how everything plays together. There is an even better version here.

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.