Fun with Highcharts, Part II

“The only thing that endures over time is the Law of the Farm. You must prepare the ground, plant the seed, cultivate, and water it if you expect to reap the harvest.”
Stephen Covey

Now that I have my functioning generic chart widget, it’s time to start working on my chart object generator. My intent, as usual, is to start out small and then build up a library of various chart types over time. Conceptually, I want to create a utility function to which I can pass some chart data and a chart type, and have it return a fully configured chart object that I can pass to my generic chart widget which will then render the chart. In use, it would look something like this:

var chartObject = generator.getChartObject(chartData, chartType);

Theoretically, you could make the chartType parameter optional if you set up a default, and then you would only have to pass that in when you wanted something other than the the established standard. For my first chart, though, I think I will do something slightly more sophisticated, mainly because this is the kind of thing that I like to utilize when I’m looking at the distribution of work within and across teams. I call it a Workload Chart, and it’s just your typical three-value tracking of work coming in, work getting done, and work still left in the needs-to-get-done pile. It doesn’t matter what the work is, or who is doing it, or over what period you are tracking things — the concept is pretty universal and the chart is the chart, regardless of the underlying data. Here’s one that allows you to select the Team, the Type of work, the Frequency, and the Period from a series of drop-down selections:

Typical workload chart

The chart data is the stuff that will change: title, subtitle, data values, and the labels across the bottom. The chart object includes all of the chart data, plus all of those structural elements that are constant and independent of the data. The utility function will take in the chart data, add it to a predefined model, and then return the entire object back to the caller. Here is the basic structure of the Script Include, which I called the GenericChartUtil:

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

	getChartObject: function(chartData, chartType) {
		var chartObject = {};

		if (chartType == 'workload') {
			chartObject = this._getWorkloadChart(chartData);
		} else if (chartType == 'bar') {
			chartObject = this._getBarChart(chartData);
		} else {
			chartObject = this._getPieChart(chartData);
		}

		return chartObject;
	},

	_getPieChart: function(chartData) {
		return {
//			type-specific chart object w/data
		};
	},

	_getBarChart: function(chartData) {
		return {
//			type-specific chart object w/data
		};
	},

	_getWorkloadChart: function(chartData) {
		return {
//			type-specific chart object w/data
		};
	},

	type: 'GenericChartUtil'
};

For the workload chart, the dynamic data includes the title, the subtitle, an array of values for the received bars , an array of values for the completed bars , an array of values for the remaining work trend line (backlog), and an array of values for the time period labels. For the chart displayed in the previous image, the chartData object might look something like this:

{
   "title":"Incidents assigned to Hardware",
   "subtitle":"Quarterly through 9/30/2019",
   "labels":[
      "12/31/2017",
      "3/31/2018",
      "6/30/2018",
      "9/30/2018",
      "12/31/2018",
      "3/31/2019",
      "6/30/2019",
      "9/30/2019"
   ],
   "received":[0, 0, 7, 2, 0, 0, 0, 1],
   "completed":[0, 0, 6, 1, 0, 0, 0, 0],
   "backlog":[0, 0, 1, 2, 2, 2, 2, 3]
}

How you would compile that information is a subject for another time; today we just want to focus on how our chart object generator would take that information in and use it to create a complete chart object to be passed to Highcharts. It’s actually quite simple to do, and can be accomplished with a simple return statement that sends back a Javascript object that is a combination of hard-coded values and data pulled from the incoming chart data object. Here is the completed function for the workload chart:

	_getWorkloadChart: function(chartData) {
		return {
			chart: {
				zoomType: "xy"
			},
			title: {
				text: chartData.title
			},
			subtitle: {
				text: chartData.subtitle
			},
			xAxis: [
				{
					categories: chartData.labels,
					crosshair: true
				}
			],
			yAxis: [
				{
					labels: {
						format: "{value}",
						style: {}
					},
					title: {
						text: "Recieved/Completed",
						style: {}
					}
				},{
					labels: {
						format: "{value}",
						style: {}
					},
					title: {
						text: "Backlog",
						style: {}
					},
					opposite: true
				}
			],
			tooltip: {
				shared: true
			},
			legend: {
				layout: "vertical",
				align: "left",
				x: 120,
				verticalAlign: "top",
				y: 100,
				floating: true,
				backgroundColor: "rgba(255,255,255,0.25)"
			},
			series: [
				{
					name: "Received",
					type: "column",
					data: chartData.received,
					tooltip: {
						valueSuffix: " received"
					}
				},{
					name: "Completed",
					type: "column",
					data: chartData.completed,
					tooltip: {
						valueSuffix: " completed"
					}
				},{
					name: "Backlog",
					type: "spline",
					yAxis: 1,
					data: chartData.backlog,
					tooltip: {
						valueSuffix: " remaining"
					}
				}
			]
		};
	},

For every property in the returned chart object, there is either a hard-coded constant value that is an element of the chart template/design, or there is data pulled from the incoming chart data object. This idea can be repeated for any type or design of chart desired, and you can make independent decisions on a chart by chart basis as to what is a standard, fixed value and what has to be provided in the chart data object. As you get more sophisticated, you can even set up default values for certain things that can be overridden by values present in the chart data. If there is a value in the chart data, you can use that; otherwise, you can fall back to the predetermined default value. But before we start making more chart types, we’ll need to figure out how to obtain all of the values for the chart data object to support our workload chart example. That sounds like a bit of work though, so I think we should just call that a great topic for a future installment

Fun with Highcharts

“Most of the important things in the world have been accomplished by people who have kept on trying when there seemed to be no hope at all.”
Dale Carnegie

Long before I had ever heard of ServiceNow, I stumbled across a cool little product called Highcharts. In their own words, “Highcharts is a charting library written in pure JavaScript, offering an easy way of adding interactive charts to your web site or web application.” ServiceNow comes bundled with Highcharts included, which I thought was pretty nifty when I figured that out, because you can do quite a bit with Highcharts with very minimal effort. In fact, that’s one of the things that I really like about both products: both allow you to accomplish quite a bit with just a very small investment of time and energy.

To make things even easier, it always helps to have a few ready-made parts at your disposal to speed things along. Highcharts uses simple Javascript objects to configure their charts, and the contents of those objects can be broken down into two rather distinct categories: 1) elements that control the type, look, and feel of the chart and 2) elements that contain the data to be presented in the chart. Once you design a particular chart with the style, colors, and appearance that you find acceptable, you can save that off and reuse it again and again with different data or data from different periods. For the ServiceNow Service Portal, my thought was to create a generic chart widget that could take any chart configuration object as a passed parameter, and then also create a generic chart configuration object generator that would take a chart type and chart data as parameters and return a chart configuration object that could be then passed to the generic chart widget.

To start things off, I decided to build a simple generic chart widget and pass it a hard-coded chart configuration object, just to prove that everything about the widget was functional. There are a lot of sample Highcharts chart objects floating around the Internet, but for my test I just went out to their Your First Chart tutorial page and grabbed the configuration object for that simple example.

Rendered Highchart as displayed on their tutorial page

To grab just the chart object, I copied the highlighted code from the page.

Chart configuration object (highlighted)

Unfortunately, this is not a valid JSON string, which I would need if I was going to pass this around as a parameter, but I could paste it into simple Javascript routine as the value of a variable, and print out a JSON.stringify of that variable and then I ended up with a usable JSON string.

{
   "chart":{
      "type":"bar"
   },
   "title":{
      "text":"Fruit Consumption"
   },
   "xAxis":{
      "categories":[
         "Apples",
         "Bananas",
         "Oranges"
      ]
   },
   "yAxis":{
      "title":{
         "text":"Fruit eaten"
      }
   },
   "series":[
      {
         "name":"Jane",
         "data":[1, 0, 4]
      },{
         "name":"John",
         "data":[5, 7, 3]
      }
   ]
}

With my new hard-coded chart specification object in hand, I set out to create my generic widget. Every Highchart needs a place to live, so I started out with the HTML portion and created a simple chart DIV inside of a basic panel.

<div class="panel panel-default">
  <div class="panel-body form-horizontal">
    <div class="col-sm-12">
      <div id="chart-container" style="height: 400px;"></div>
    </div>
  </div>
</div>

On the server side, I wanted to be able to accept a chart configuration object from either input or options, so I ended up with this:

(function() {
	if (input && input.chartObject) {
		data.chartObject = input.chartObject;
	} else {
		if (options && options.chart_object) {
			try {
				data.chartObject = JSON.parse(options.chart_object);
			} catch (e) {
				gs.addErrorMessage("Unable to parse chart specification: " + e);
			}
		}
	}
})();

On the client side, I also wanted to be able to accept a chart configuration object from a broadcast message from another widget, and I also needed to add the code to actually render the chart, so that turned out to be this:

function ($scope) {
	var c = this;
	if (c.data.chartObject) {
		var myChart = new Highcharts.chart('chart-container', c.data.chartObject);
	}
	if (c.options.listen_for) {
		$scope.$on(c.options.listen_for, function (event, config) {
			if (config.chartObject) {
				var myChart = new Highcharts.chart('chart-container', config.chartObject);
			}
		});
	}
}

One more thing to do was to set up the Option schema for my two widget options, which is just another JSON string.

[{"hint":"An optional JSON object containing the Chart specifications and data",
"name":"chart_object",
"default_value":"",
"section":"Behavior",
"label":"Chart Object",
"type":"string"},
{"hint":"The name of an Event that will provide a new Chart specification object",
"name":"listen_for",
"default_value":"",
"section":"Behavior",
"label":"Listen for",
"type":"string"}]

Finally, I had to go down to the bottom of the widget page where the related records tabs are found and Edit the dependencies to add Highcharts as a dependency. This will bring in the Highcharts code and make all of the magic happen.

That pretty much completes the widget, so now I just needed to create a page on which to put the widget so that I could test it out. Once I created the page, I was able to pull up the widget options using the pencil icon in the page designer and enter in my JSON object for the chart configuration.

Adding the JSON chart object as a widget option

At that point, all that was left was for me to click on the Try it button and look at my beautiful new chart. Unfortunately, all I got to see was a nice big blank chunk of whitespace where my chart should have appeared. Digging into the console error messages, I came across the following:

Highcharts not defined

Well, either my dependency did not load, or my widget couldn’t see the code. I tried $rootScope.Highcharts and $window.Highcharts and $scope.Highcharts, but nothing worked. Then I thought that maybe it was a timing issue and so I put in a setTimeout to wait a few seconds for everything to load, but that didn’t work either. So then I added a script tag to the HTML thinking that maybe the dependency wasn’t pulling in the script after all. Nothing.

At that point, I decided to give up just trying different things on my own and see if I could hunt down some other widget that might already be working with Highcharts and see how those were set up. From the Dependencies list, I used the hamburger menu next to the Dependency column label to select Configure > Table, and then on the Table page, I scrolled down to where I could click on Show list to bring up the list of dependencies. Then I filtered the list for Highcharts to find that there were quite a few existing widgets that pulled in Highcharts as a dependency.

The secret, it appeared, was some additional code in the Link section of the widget, a section that I have always ignored, mainly because I have never understood its purpose or how it worked.

function(scope, element, attrs, controllers) {
	scope.$watch('chartOptions', function() {
		if (scope.chartOptions) {
			$('#chart-container').highcharts(scope.chartOptions);
			scope.chart = $('#chart-container').highcharts();
		}
	});
}

Now this is all AngularJS mystical nonsense sprinkled with Highcharts fairy dust as far as I was concerned, because I have no idea what the Link section of a widget is for or what all of this code actually does. However, it is attached to widgets that actually work, so I got busy cutting and pasting. The other thing that I had to do based on the examples that I was examining was to modify my client-side code a little, which now looked like this:

function ($scope) {
	var c = this;
	if (c.data.chartObject) {
		$scope.chartOptions = c.data.chartObject;
	}
	if (c.options.listen_for) {
		$scope.$on(c.options.listen_for, function (event, config) {
			if (config.chartObject) {
				$scope.chartOptions = config.chartObject;
			}
		});
	}
}

Yes, we are now deep into the realm of Cargo Cult Programming, but I’ve never been too proud to copy other people’s working code, even if I didn’t understand it. Now let’s hit that Try it button one more time …

That’s better!

Well, look at that! I’m not sure how all of that works, but it does work, so I’m good. Now that I have my functioning generic chart widget, I can start working on my chart object generator and then see if I can’t wire the two of them together somehow …

My Delegates Widget

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
Martin Fowler

A while back I was tinkering with creating a universal Help infrastructure for my Service Portal widgets, and one of my example images used my My Delegates widget for the demonstration.

Widget Help example using the My Delegates widget

While the concept of delegation is an out-of-the-box feature, the widget is a custom component that I built to allow Service Portal users to manage their delegates. It’s really just a portalized version of the same functionality available inside the UI, but there was no way to do that within the Service Portal itself, so I threw together a simple widget to do so. Here is the HTML:

<snh-panel rect="rect" title="'${My Delegates}'">
  <div style="width: 100%; padding: 5px 50px;">
    <table class="table table-hover table-condensed">
      <thead>
        <tr>
          <th style="text-align: center;">Delegate</th>
          <th style="text-align: center;">Approvals</th>
          <th style="text-align: center;">Assignments</th>
          <th style="text-align: center;">CC notifications</th>
          <th style="text-align: center;">Meeting invitations</th>
          <th style="text-align: center;">Remove</th>
        </tr>
      </thead>
      <tbody>
        <tr ng-repeat="item in c.data.listItems track by item.id | orderBy: 'delegate'" ng-hide="item.removed">
          <td data-th="Delegate">
            <sn-avatar class="avatar-small-medium" primary="item.id" show-presence="true"/>
             
            {{item.delegate}}
          </td>
          <td data-th="Approvals" style="text-align: center;"><input type="checkbox" ng-model="item.approvals"/></td>
          <td data-th="Assignments" style="text-align: center;"><input type="checkbox" ng-model="item.assignments"/></td>
          <td data-th="CC notifications" style="text-align: center;"><input type="checkbox" ng-model="item.notifications"/></td>
          <td data-th="Meeting invitations" style="text-align: center;"><input type="checkbox" ng-model="item.invitations"/></td>
          <td data-th="Remove" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="removePerson($index)" alt="Click here to remove this person as a delegate" title="Click here to remove this person from the list" style="cursor: pointer;"/></td>
        </tr>
      </tbody>
    </table>
    <p>To add a delegate to the list, select a person from below:</p>
    <sn-record-picker id="snrp" field="data.personToAdd" ng-change="addSelected()" table="'sys_user'" display-field="'name'" display-fields="'title,department,location,email'" value-field="'sys_id'" search-fields="'name'" page-size="20"></sn-record-picker>
    <br/>
    <p>To remove a delegate from the list, click on the Remove icon.</p>
  </div>

  <div style="width: 100%; padding: 5px 50px; text-align: center;">
    <button ng-click="saveDelegates()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
     
    <button ng-click="returnToProfile()" class="btn ng-binding ng-scope" role="button" title="Click here to cancel your changes">Cancel</button>
  </div>
</snh-panel>

Basically, it is just a table of delegates followed by an sn-record-picker from which you can choose additional people to add to the list. The source of the data is the same as that used by the internal delegate maintenance form, which you can see gathered up in server-side script here:

(function() {
	data.userID = gs.getUser().getID();
	if (input) {
		data.listItems = input.listItems || fetchList();
		if (input.personToAdd && input.personToAdd.value > '') {
			addPersonToList(input.personToAdd.value);
		}
		if (input.button == 'save') {
			saveList();
		}
	} else {
		if (!data.listItems) {
			data.listItems = fetchList();
		}
	}

    function fetchList() {
		var list = [];
		var gr = new GlideRecord('sys_user_delegate');
		gr.addQuery('user', data.userID);
		gr.orderBy('delegate.name');
		gr.query();
		while (gr.next()) {
			var thisDelegate = {};
			thisDelegate.sys_id = gr.getValue('sys_id');
			thisDelegate.id = gr.getValue('delegate');
			thisDelegate.delegate = gr.getDisplayValue('delegate');
			thisDelegate.approvals = (gr.getValue('approvals') == 1);
			thisDelegate.assignments = (gr.getValue('assignments') == 1);
			thisDelegate.notifications = (gr.getValue('notifications') == 1);
			thisDelegate.invitations = (gr.getValue('invitations') == 1);
			list.push(thisDelegate);
		}
		return list;
	}

    function saveList() {
		for (var i=0; i<data.listItems.length; i++) {
			var thisDelegate = data.listItems[i];
			if (thisDelegate.removed) {
				if (thisDelegate.sys_id != 'new') {
					var gr = new GlideRecord('sys_user_delegate');
					gr.get(thisDelegate.sys_id);
					gr.deleteRecord();
				}
			} else {
				var gr = new GlideRecord('sys_user_delegate');
				if (thisDelegate.sys_id != 'new') {
					gr.get(thisDelegate.sys_id);
				} else {
					gr.initialize();
					gr.user = data.userID;
					gr.delegate = thisDelegate.id;
					gr.starts = new Date();
				}
				gr.approvals = thisDelegate.approvals;
				gr.assignments = thisDelegate.assignments;
				gr.notifications = thisDelegate.notifications;
				gr.invitations = thisDelegate.invitations;
				gr.update();
			}
		}
		gs.addInfoMessage('Your Delegate information has been updated.');
	}

    function addPersonToList(selected) {
		var existing = -1;
		for (var i=0; i<data.listItems.length && existing == -1; i++) {
			if (data.listItems[i].id == selected) {
				existing = i;
			}
		}
		if (existing == -1) {
			var thisDelegate = {};
			thisDelegate.sys_id = 'new';
			thisDelegate.id = selected;
			var gr = new GlideRecord('sys_user');
			gr.get(selected);
			thisDelegate.delegate = gr.getDisplayValue('name');
			thisDelegate.approvals = true;
			thisDelegate.assignments = true;
			thisDelegate.notifications = true;
			thisDelegate.invitations = true;
			data.listItems.push(thisDelegate);
		} else {
			data.listItems[existing].removed = false;
		}
		input.personToAdd = {};
	}
})();

All of the changes are held in the session until you decide to Save or Cancel, and if you elect to save, then things are updated on the database at that time. On the client side of things, we just have functions to add and remove people from the list, and to handle the two buttons:

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

	$scope.addSelected = function() {
		$scope.server.update().then(function(response) {
			$('#snrp').select2("val","");
		});
	};

	$scope.removePerson = function(i) {
		c.data.listItems[i].removed = true;
	};

	$scope.saveDelegates = function() {
		c.data.button = 'save';
		$scope.server.update().then(function(response) {
			reloadPage();
		});
	};

	$scope.returnToProfile = function() {
		reloadPage();
	};

	function reloadPage() {
		$window.location.reload();
	}
}

That’s the whole thing in all of its glory. If you want a copy of your own, here’s a little Update Set.

Update: There is an even better version here.

Dynamic Service Portal Breadcrumbs

“Do not wait; the time will never be ‘just right.’ Start where you stand, and work with whatever tools you may have at your command, and better tools will be found as you go along.”
George Herbert

I’ve had this idea for a while to attempt a different approach to Service Portal breadcrumbs, and I finally quit tinkering with my Data Table clones and Configurable Content Selector long enough to actually throw something together. My issue with the out-of-the-box breadcrumb widget is that you have to tell it what the breadcrumbs are on every page rather than the system keeping track of where you are and how you got there. It seemed to me that it would not only be easier to set up, but it would also be more accurate, since there are often times more than one path to get to a specific page.

To keep track of the current page stack for the breadcrumbs, I decided to leverage the existing User Preferences infrastructure. User Preferences are accessible in the Service Portal via built-in GlideSystem functions, and provide a convenient means to keep track of a user’s path through the various screens in the portal. To fetch a User Preference, you use the gs.getPreference(key) method, and to update a User Preference, the script is gs.getUser().setPreference(key, value).

To begin, I pulled up the existing breadcrumb widget and created a clone that I called SNH Breadcrumbs. I did not want to change the way the breadcrumbs were displayed, so I left the HTML portion of the widget intact. I did not want to set the value of the breadcrumbs via widget option anymore, though, so I removed the option. Then I modified the server-side script to create a label for the current page and pull the current breadcrumbs out of the User Preferences. I also provided the means to update the breadcrumbs when an update was invoked on the client side. The complete server-side script now looks like this:

(function() {
	if (input) {
		if (input.breadcrumbs) {
			gs.getUser().setPreference('snhbc', JSON.stringify(input.breadcrumbs));
		}
	} else {
		data.table = $sp.getParameter('table');
		data.sys_id = $sp.getParameter('sys_id');
		if (data.table) {
			var rec = new GlideRecord(data.table);
			if (data.sys_id) {
				rec.get(data.sys_id);
				data.page = rec.getDisplayValue('number');
				if (!data.page) {
					data.page = rec.getDisplayValue('name');
				}
				if (!data.page) {
					data.page = rec.getDisplayValue('short_description');
				}
				if (!data.page) {
					data.page = rec.getLabel();
				}
			} else {
				data.page = rec.getPlural();
			}
		}
		data.breadcrumbs = [];
		var snhbc = gs.getPreference('snhbc');
		if (snhbc) {
			data.breadcrumbs = JSON.parse(snhbc);
		}
	}
})();

The page label is based on the URL parameters table and sys_id. If both are present, I go ahead and grab the record and attempt to obtain a label from the data. If only the table parameter is present, then I assume that we are talking about multiple records, so I grab the Plural label for the table itself. If there is no table parameter, then I let the client-side script handle the label for the page. On the client side, I build a breadcrumb entry for the current page, and then loop through the existing breadcrumbs to see if this page is already in the list. If it is, then that’s where we will stop; otherwise, we will just tack the new current page entry on to the end of the existing stack of pages. Here is the complete client-side script:

function($scope, $rootScope, $location, spUtil) {
	var c = this;
	c.expanded = !spUtil.isMobile();
	c.expand = function() {
		c.expanded = true;
	};
	c.breadcrumbs = [];
	var thisPage = {url: $location.url(), id: $location.search()['id'], label: c.data.page || document.title};
	
	if (thisPage.id != $rootScope.portal.homepage_dv) {
		var pageFound = false;
		for (var i=0;i<c.data.breadcrumbs.length && !pageFound; i++) {
			if (c.data.breadcrumbs[i].id == thisPage.id) {
				c.breadcrumbs.push(thisPage);
				pageFound = true;
			} else {
				c.breadcrumbs.push(c.data.breadcrumbs[i]);
			}
		}
		if (!pageFound) {
			c.breadcrumbs.push(thisPage);
		}
	}
	c.data.breadcrumbs = c.breadcrumbs;
	c.server.update();
}

That’s really all there is to it. Here’s one example of how it looks in practice:

Dynamic breadcrumbs example

In the example above, the URL for the page contains a table parameter, but no sys_id parameter. This generates a page label from the table’s getPlural() method. If we select a different perspective, which uses a different table, we will still be on the same page, but the page label will reflect the current table in use for that perspective.

Breadcrumbs example using a different perspective/table

Now, if you click on one of the items in the table, you will see that the breadcrumb list grows, and this time the URL has both a table and a sys_id parameter, but the record in question (sysapproval_approver) has no number, name, or short_description fields, so the label is defaulted to the generic label for the record.

Approval record breadcrumb example

Clicking on the Approvals breadcrumb will take you back to the original screen, removing the single Approval record from the breadcrumb array.

Using the breadcrumb to return to the initial screen

Now, if you click on the Change record instead of the Approval record, the Change record actually does have a number field, so the label for that page is the actual number of the record.

Breadcrumbs example with numbered record

And finally, if you click on the Opened by column, which is configured to take you to the User Profile page, there is no number, but there is a name, so that becomes the label.

Breadcrumbs example from the Opened by column

The reason that you can find the record to fetch the name in the above example is because the Data Table widget arbitrarily passes both the table and sys_id parameters to the User Profile page, even though table is not needed (the table sys_user is assumed by the User Profile page — you don’t have to pass it). When you pull down the User menu and select Profile, no table name is passed in the URL, so the label defaults to the name of the page.

Breadcrumbs example from the User Profile selection

One thing to keep in mind is that the trail will only build as you pass through pages that contain the widget. Any pages that you pass through that do not contain the widget will not get added to the running list of pages, as there will be no code running that pushes the page onto the stack. Other than that, it seems to work in most other cases. If you want to try it out for yourself, here’s an Update Set that contains the custom widget.

Update: There is a better (working!) version here.

Configurable Data Table Widget Content Selector, Part IV

“It has long been an axiom of mine that the little things are infinitely the most important.”
Sir Arthur Conan Doyle

When we last left this particular widget, I was experiencing problems with the companion button-handling widget executing more than one time per click. While my little conditional work-arounds seem to have hidden the unwanted results of that unfortunate behavior, I am still no closer to understanding how or why that is happening. In my simple mind, one click on the button should result in one execution of the activated code. Given that I don’t really know what I am doing, I may never understand why that isn’t the case, but it annoys my sense of The Way Things Ought To Be. Still, it’s probably long past time to simply move on.

Before I do, though, there is one more little enhancement that I wanted to squeeze in. Whenever I lay out a page where the content selector is on the top, instead of on the left- or right-hand side of the actual Data Table, it takes up too much vertical space and leaves a lot of unused screen real estate on either side. To change that, I added a new option to the widget called display_inline that changes the way the elements of the widget are laid out. Below is a sample use case where display_inline has been set to true.

Content selector widget in “in-line” mode

The magic to pull that off was just the addition of conditional class attributes on each of the three primary DIV elements. There was already a class attribute on each, so I could have just thrown some logic in there, but in the end I decided to leverage the AngularJS ng-class attribute instead. By adding the Bootstrap class col-sm-4 to the DIV whenever data.inline is true, the DIVs end up side by side instead of their normal stacked configuration.

    <div class="panel panel-heading" ng-class="{'col-sm-4': c.data.inline}" ng-show="c.data.config.authorizedPerspective.length > 1">
      <div align='center' class="u-space-bottom u-align-center">
        <b>Perspective</b>
        <br/>
        <span ng-repeat="p in c.data.config.authorizedPerspective" style="margin: 5px;">
          <input style="display: inline;" type="radio" name="perspective" id="{{p.name}}Perspective" value="{{p.name}}" ng-model="c.data.perspective" ng-click="selectPerspective(p.name)">
          <label style="display: inline;" for="{{p.name}}Perspective">{{p.label}}</label>
        </span>
      </div>
    </div>

    <div class="panel panel-heading" ng-class="{'col-sm-4': c.data.inline}">
      <div align='center' class="u-space-bottom u-align-center">
        <button ng-repeat="s in c.data.config.state" ng-click="selectState(s.name)" role="button" ng-class="(c.data.state==s.name) ? 'btn btn-primary btn-pressed btn-md' : 'btn btn-default btn-sm'" style="margin: 2px;">{{s.label}}</button>
      </div>
    </div>
  
    <div class="panel" ng-class="{'col-sm-4': c.data.inline, 'panel-heading': c.data.inline, 'panel-default': !c.data.inline}">
      <ul class="list-group">
        <li class="list-group-item" ng-repeat="t in c.data.list" ng-class="(t.name==c.data.table)?'highlight':''" ng-click="selectTable(t.name)">{{t.label}}
          <span class="badge">{{t.value}}</span>
        </li>
      </ul>
    </div>

To process the option, I initially default the variable value to false, and the reset it to true if the option was set to the string ‘true’.

data.inline = false;
if (options && options.display_inline == 'true') {
	data.inline = true;
}

Of course, I had to define the Display Inline option as well, but that was just a matter of updating the widget’s Option schema will a little JSON object:

[{"hint":"If selected, will display the widget content in a single row rather than a stacked block",
"name":"display_inline",
"default_value":"false",
"section":"Behavior",
"label":"Display Inline",
"type":"boolean"}]

That’s about it for allowing this to stretch out across the page rather than be stacked up in the corner. Hopefully, this will be the last you see of this unless I happen to figure out my other issue. For those of you who are interested in seeing all of the parts and pieces in detail, I have assembled everything into yet another Update Set, which you can grab from here.

Configurable Data Table Widget Content Selector, Part III

“Everyone thinks of changing the world, but no one thinks of changing himself.”
Leo Tolstoy

After all of that work on customizing the Data Table widget(s), I realized that my Data Table Content Selector widget didn’t support all of the new features. If I wanted to have buttons or icons or customized reference links, I needed to tweak the code a little bit to provide that capability. Primarily, I needed to expand the schema for my configuration object to include options for buttons and reference pages for every table configuration. That would make the typical state value for a given table look something like this:

open: {
    filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe%5Eactive%3Dtrue',
    fields: 'number,opened_by,opened_at,short_description',
    btnarray: [],
    refmap: {sys_user: 'user_profile'}
},

To maintain backwards compatibility, both of the new options, btnarray and refmap, would need to be optional. Since the content selector widget relies on the URL-based version of the Data Table widget, implementing the new features was simply a matter of including them, if present, in the new URL at every page refresh:

function refreshPage(table, perspective, state) {
	var tableInfo = getTableInfo(table, perspective);
	var s = {};
	s.id = $location.search().id;
	s.table = tableInfo.name;
	s.filter = tableInfo[state].filter;
	s.fields = tableInfo[state].fields;
	s.buttons = '';
	if (tableInfo[state].btnarray && Array.isArray(tableInfo[state].btnarray) && tableInfo[state].btnarray.length > 0) {
		s.buttons = JSON.stringify(tableInfo[state].btnarray);
	}
	s.refpage = '';
	if (tableInfo[state].refmap) {
		s.refpage = JSON.stringify(tableInfo[state].refmap);
	}
	s.px = perspective;
	s.sx = state;
	var newURL = $location.search(s);
	spAriaFocusManager.navigateToLink(newURL.url());
}

That was basically all there was to it. Now I just need to create a new configuration object that takes advantage of these new features. My original example configuration contained two perspectives, Requester and Fulfiller. To show off the new buttons feature, I decided to add a third perspective, Approver, and then include three separate icons, one to Approve, one to Approve with comments, and another to Reject. The button configuration that I created to support this turned out like this:

btnarray: [
	{
		name: 'approve',
		label: 'Approve',
		heading: '-',
		icon: 'workflow-approved',
		color: 'success',
		hint: 'Click here to approve'
	},{
		name: 'approvecmt',
		label: 'Approve w/Comments',
		heading: '-',
		icon: 'comment-hollow',
		color: 'success',
		hint: 'Click here to approve with comments'
	},{
		name: 'reject',
		label: 'Reject',
		heading: '-',
		icon: 'workflow-rejected',
		color: 'danger',
		hint: 'Click here to reject'
	}
]

After entering some additional modifications to the configuration to add the new perspective, the resulting page ended up looking like this:

Approver perspective with Action Icons

That took care of the look and feel, but to make the buttons actually work, I needed to create another button handling widget to process the button clicks on the three icons that I had configured. For that, I just grabbed the example that I had created earlier and cloned it to create a new Approval Click Handler widget. Here is the client script:

function(spModal, $rootScope) {
	var c = this;
	$rootScope.$on('button.click', function(e, parms) {
		if (!c.data.inProgress) {
			c.data.inProgress = true;
			c.data.sys_id = parms.record.sys_id;
			c.data.action = parms.button.name;
			c.data.comments = '';
			if (c.data.action == 'reject' || c.data.action == 'approvecmt') {
				getComments(c.data.action);
			} else if (c.data.action == 'approve') {
				processDecision();
			}
			c.data.inProgress = false;
		}
	});

	function getComments(state) {
		var msg = 'Approval comments:';
		if (state == 'reject') {
			msg = 'Please enter the reason for rejection:';
		}
		spModal.prompt(msg, '').then(function(comments) {
			c.data.comments = comments;
			processDecision();
		});
	}

	function processDecision() {
		c.server.update().then(function(response) {
			window.location.reload(true);
		});
	}
}

… and here is the server side script:

(function() {
	if (input) {
		var current = new GlideRecord('sysapproval_approver');
		current.get(input.sys_id);
		if (current.state == 'requested') {
			current.state = 'approved';
			if (input.action == 'reject') {
				current.state = 'rejected';
			}
			var comments = 'Approval response from ' + gs.getUserDisplayName() + ':';
			comments += '\n\nDecision: ' + current.getDisplayValue('state');
			if (input.comments) {
				comments += '\nReason: ' + input.comments;
			}
			current.comments = comments;
			current.update();
		}
	}
})();

When I first pulled this up and tested the various buttons, a single click appeared to launch multiple iterations of the code. After entering comments in the modal pup-up box, another comment entry box would pop-up as if I had clicked on the icon a second time. Looking at the resulting records, the entered comments would often appear multiple times. On one example, it was entered 10 times! I never did figure out why that was happening, but I added conditionals to both the client side and server side scripts in an effort to put a stop to that behavior. That seems to have stopped it, but that still doesn’t explain to me why that is happening.

Looks like I have a little more testing to do before I put together a final Update Set

Customizing the Data Table Widget, Part VII

“Never give up on something that you can’t go a day without thinking about.”
Winston Churchill

Those of us who develop software for a living always like to blame the customer for the inevitable scope creep that works its way into an assignment or project. The real truth of the matter, though, is that a lot of that comes right from the developers themselves. Often, just when you think you are about to wrap something up and call it complete, you get that nagging you-know-it-would-be-even-better-if-we-added-this feeling that just won’t go away.

It was my intention to make my previous installment on the Custom Data Table widget my last and final offering on the subject. I had this idea to create a custom Service Portal breadcrumbs widget that didn’t require you to specify the entire page hierarchy on every page where it was included, and I was ready to dive into that little adventure. But there were still a couple of things pulling at me on the Data Table widget(s), and I just felt compelled to wrap those up before I moved on. For one, I never really implemented the buttons and icons as URL parameters in the version of the wrapper widget that was based on the URL. On top of that, I never really liked reusing the same page for all reference links; I wanted to create the capability to have different pages for different references. None of that was really super critical, but I couldn’t really call it complete until I took care of that, so here we are.

For the reference links, I decided to create a simple JSON object that mapped reference tables to portal pages. Primarily, I wanted to send all user references to the user_profile page, but I also wanted to create possibility of sending any reference to any page. So I added yet another configuration option, much like the other two that are already there:

[
   {
      "hint":"If enabled, show the list filter in the breadcrumbs of the data table",
      "name":"enable_filter",
      "default_value":"false",
      "section":"Behavior",
      "label":"Enable Filter",
      "type":"boolean"
   },{
      "hint":"A JSON object containing the specification for row-level buttons and action icons",
      "name":"buttons",
      "default_value":"",
      "section":"Behavior",
      "label":"Buttons",
      "type":"String"
   },{
      "hint":"A JSON object containing the page id for any reference column links",
      "name":"refpage",
      "default_value":"",
      "section":"Behavior",
      "label":"Reference Pages",
      "type":"String"
   }
]

This creates yet another text input on the widget configuration page just under the one that we created for the button specifications:

Reference Page specification input field for the JSON object

The JSON object itself is just a simple mapping of table name to portal page name (id). To support my intent to bring up all users via the User Profile page, I used the following JSON object:

{
    "sys_user": "user_profile"
}

To add more options, you just add more properties to the object using the table name as the property name and the associated page name/id as the property value. To make all of that work, I had to pull in the JSON string and then parse it out to create the actual object to be used.

if (data.refpage) {
	try {
		var refinfo = JSON.parse(data.refpage);
		if (typeof refinfo == 'object') {
			data.refmap = refinfo;
		} else {
			gs.error('Invalid reference page option in SNH Data Table widget: ' + data.refpage);
			data.refmap = {};
		}
	} catch (e) {
		gs.error('Unparsable reference page option in SNH Data Table widget: ' + data.refpage);
		data.refmap = {};
	}
} else {
	data.refmap = {};
}

Once you have all of that tucked away for future reference, whenever a reference link is clicked, we simply refer to the map to see if there is a specific page associated to the table specified in the reference link. If there is, we simply pass that along with the rest of the parameters when we broadcast the reference click.

if (c.data.refmap[table]) {
	parms.page_id = c.data.refmap[table];
}

For those listening for the reference click, all we needed to do was to check for the presence of a page_id in the parms before we check for a page_id in the options, which we were already doing before we settled on the default page of ‘form’.

var p = parms.page_id || $scope.data.page_id || 'form';

That’s about it for supporting table-specific reference link pages. The other thing that I wanted to do was to make sure that my Data Table from URL Definition widget also supported buttons and icons as well as the new reference link page specifications. That turned out to be a simple matter of just adding those two extra options to the existing copyParameters function call to have those values pulled in from the URL and added to the data passed to the core Data Table widget.

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

With that in place, you can add something like &refpage={“sys_user”:”user_profile”} to the URL for the page and have that picked up by the Data Table from URL Definition widget and processed just as if it were specified in the widget options. I’ve wrapped all of that up into yet another Update Set for those of you who are into that sort of thing. Hopefully, this will bring this little adventure to a close now, and I can move on to other things.