“Time, which alone makes the reputation of men, ends by making their defects respectable.” — Voltaire
Once again, things were not quite as bad as I had originally envisioned. I definitely disliked returning to my workload chart page and having it revert back to all of the original, default settings. This was not good. But how best to fix it? I didn’t have that problem with my Data Table Content Selector, which also has multiple options, but that was built using a little bit different approach. Although there are two independent widgets on the page, they don’t really communicate with one another. The selector on the current page communicates with the Data Table widget on the next page, passing all of the information via URL parameters when it builds the URL for the next page. Reconfiguring the workload chart widget and underlying generic chart widget to adopt this philosophy seemed like a lot more work that I really wanted take on right at the moment.
Instead, I decided to leverage the same hack that I used in my dynamic breadcrumbs widget, leveraging the User Preferences feature to preserve the selections and then picking them back up each time the page loads. Here is the additional code I added to save the user’s selections in a User Preference that I called workload_selections:
User Preferences are strings, so you have to convert the object to a string before you save it, and convert it back to an object when you fetch it back. Here is the code that I used to retrieve it once the page loads again:
You might have noticed that the value for the type selection was set twice, once to the default value and then again to the value saved in the User Preference. That’s because the getDefaultType function is actually a dual purpose function; not only does it return the default type value, but before it does so, it sets up the type options that are appropriate for the selected group (different groups are associated with different types of tasks). Since the group value preserved may be different than the default value, the appropriate list of type choices needs to be generated for that group before the preserved type value is established. Other than that, it’s pretty straightforward stuff.
That resolved the last known defect that I have been able to find so far, so it’s finally time to release another Update Set. If you happen to pull it down and try to use it, let me know if you find any others … thanks!
Update: There is a better (expanded) version here.
Well, it turns out that it wasn’t as bad as I had originally imagined. I converted my server side GenericChartUtilScript Include into a client side UI Script, then created a Widget Dependency referencing the UI Script, and then finally associated the Widget Dependency to my Generic Chart widget. That pushed the chart object generation from the server side to client side (where I would no longer lose any functions in the chart object), but to make it all work, I needed to pass the chart data and chart type around rather than the completed chart object.
On my Generic Chart widget, I removed the chartObject option and replaced it with two new widget options, chartType and chartData. Then I added a line of code in the client side script to pass the chartData and chartType to the new client side UI Script to generate the chartObject. The client side code for the widget now looks like this:
function($scope, $rootScope, $location) {
var c = this;
if (c.data.chartData) {
c.data.chartData.location = $location;
$scope.chartOptions = genericChartUtil.getChartObject(c.data.chartData, c.data.chartType);
}
if (c.options.listen_for) {
$rootScope.$on(c.options.listen_for, function (event, config) {
if (config.chartData) {
config.chartData.location = $location;
$scope.chartOptions = genericChartUtil.getChartObject(config.chartData, config.chartType);
}
});
}
}
On my Workload Chart widget, I removed all references to the deleted Script Include on the server side, and then modified the broadcast message on the client side to pass the chartData and chartType rather than the entire generated chartObject. That code now looks like this:
That solved my earlier problem of losing the functions built into the chart objects that were generated on the server side. Now I could get back to what I was trying to do in the first place, which was to set things up so that you could click on any given data point on the chart and pull up a list of the records that were represented by that value. This code was now working as it should:
For my link URL to show the list of records represented by the chart item clicked, I was going to need the name of the table and the filter used in the GlideAggregate that calculated the value. Neither one of those was currently passed in the chart data, so the first thing that I needed to do was to modify the code that generated the chartData object to include those values. That code now looks like this:
Since I need the filter value for multiple purposes, I first assigned that to a variable, and then used that variable wherever it was needed. Inside of Highcharts, my only reference to the series was the category name, which is why there is an odd capitalized property key for the chartData.filter properties (the category names are labels on the chart, so they are capitalized). These changes gave me the data to work with so that I could modify the onclick function to look like this:
plotOptions: {
series: {
cursor: 'pointer',
point: {
events: {
click: function (evt) {
var s = {id: 'snh_list', table: chartData.table, filter: chartData.filter[this.series.name][this.index]};
var newURL = chartData.location.search(s);
spAriaFocusManager.navigateToLink(newURL.url());
}
}
}
}
},
In order for that to work, I need to pass the $location object to Highcharts as well, so my client side GenericChart widget code ended up looking like this:
function($scope, $rootScope, $location) {
var c = this;
if (c.data.chartData) {
c.data.chartData.location = $location;
$scope.chartOptions = genericChartUtil.getChartObject(c.data.chartData, c.data.chartType);
}
if (c.options.listen_for) {
$rootScope.$on(c.options.listen_for, function (event, config) {
if (config.chartData) {
config.chartData.location = $location;
$scope.chartOptions = genericChartUtil.getChartObject(config.chartData, config.chartType);
}
});
}
}
To get back to the chart itself, once you clicked on a data point to see the underlying records, I added my dynamic breadcrumbs widget to the the top of the chart page and to the top of a new list page that I created for this purpose. Now it was time to test things out …
Well, as often seems to be the case with these things, there is good news and bad news. The good news is that clicking on the data points on the chart actually does bring up the list of records, which is very cool. Even though I had to do a considerable amount of restructuring of my initial concept, everything now seems to work. And I like the feature. Clicking on a bar or point on the line now takes you to a list of the records that make up the value for that data point. And when you are done, you can click on the breadcrumb for the chart and get back to the chart itself. All of that works beautifully.
Unfortunately, when you get back to the chart page, it reverts to the original default settings for all of the options. If you used any of the four selections at the top of the page to get to a specific chart configuration, and then clicked on a data point to see the underlying records, when you returned to the chart, you were no longer looking at the chart that you had selected. You were back to the original, default values for all of the selections. The page basically restarts from the beginning. That, I do not like at all.
The primary reason for that behavior is that your chart option selections are not part of the URL, so they are not preserved when you click on the breadcrumb to return. Making them part of the URL would mean another complete reconfiguration of the way in which that chart works, but something is going to have to be done. I don’t like it the way that it works now. Originally, I was going to release a new Update Set with all of these changes, but I’m not happy with the way things are working right now. I’m going to have to do a little bit more work before I’m ready to release another version. Hopefully, I can do that next time out.
“Lost wealth may be replaced by industry, lost knowledge by study, lost health by temperance or medicine, but lost time is gone forever.” — Samuel Smiles
Now that we have our generic chart widget, our generic chart object generator, and the choice lists and default selections for our example chart, we just need to come up with the code to gather up the data for the chart based on the selections. A number of elements are based on the period selected, so collecting that data would seem like an important first step. I organized all of that into a function that I called getPeriodData:
function getPeriodData() {
var periodData = {};
periodData.frequencyInfo = findOption(data.config.freqOptions, data.frequency);
periodData.endingDateInfo = findOption(data.config.endingOptions[data.frequency], data.ending);
periodData.labels = [];
periodData.endDate = [];
for (var i=0; i<=periodData.frequencyInfo.size; i++) {
var dt = new Date(periodData.endingDateInfo.label);
if (data.frequency == 'd') {
dt.setDate(dt.getDate() - (periodData.frequencyInfo.size - i));
} else if (data.frequency == 'w') {
dt.setDate(dt.getDate() - (periodData.frequencyInfo.size - i) * 7);
} else if (data.frequency == 'm') {
dt.setDate(1);
dt.setMonth(dt.getMonth() - (periodData.frequencyInfo.size - i));
dt = getLastDayOfMonth(dt);
} else if (data.frequency == 'q') {
dt.setDate(1);
dt.setMonth(dt.getMonth() - (periodData.frequencyInfo.size - i) * 3);
dt = getLastDayOfMonth(dt);
} else if (data.frequency == 'y') {
dt.setFullYear(dt.getFullYear() - (periodData.frequencyInfo.size - i));
}
var dtInfo = getDateValues(dt);
periodData.endDate.push(dtInfo.value);
if (i > 0) {
periodData.labels.push(dtInfo.label);
}
}
return periodData;
}
The function collects one more end date than it does labels because the end date of the previous period is used as the start of the current period. You need to be able to go back one extra period to get the end date of a period that you will not actually be using for the start date of the earliest period that you will.
Once you pull together all of the data for the selected frequency and period, you can then use that data to put together everything else needed for the chart. This takes care of the basics:
The last thing we need to deal with is the user making new selections from the four pick lists. That’s a client side issue, so we will need a client side script to detect the selections and call for a refresh of the chart.
I still want to play around with a few more different and interesting chart types, but there are enough parts and pieces now to warrant the assembly of a version 1.0Update Set. If I ever get a chance to do more, I can always put out a better one later on.
“It’s the little details that are vital. Little things make big things happen.” — John Wooden
So far, we have built a generic chart widget and a generic chart utility that produces chart objects from templates and dynamic data. Now it’s time to build the widget that will allow the user to pick and choose what they want to see, and then present the appropriate chart based on the user’s selections. Let’s start with a little HTML for the various pick lists that we will present to the user:
This particular layout leverages our snh-form-field tag, but you could do basically the same thing with a simple sn-choice-list. There is nothing too exotic here, except maybe the lists of choices for the last element (Period Ending), which are contained in an object keyed by the value of the previous selection (Frequency). When you change the Frequency, the list of period ending dates changes to a list that is appropriate for the selected Frequency. Other than that one little oddity, it’s pretty vanilla stuff.
There are several reasons that I chose choicelist, which implements the sn-choice-list tag, for the snh-type of each pick list rather than reference, which implements the sn-record-picker tag. It would have been relatively easy to set up a filter on the sys_user_group table to create a record picker of active user groups, but I wanted to limit the choices to just those groups who had tasks assigned in the task table. That requires a GlideAggregate rather than a GlideRecord query, and I’m not sure how you would set that up in an sn-record-picker. For a choice list, you run the query yourself and then just set the value of the specified variable to an array created from your query results. For this widget, I created a config object to hold all of the choice list arrays, and used the following code to populate the array of choices for the group pick list:
cfg.max = 0;
cfg.maxGroup = '';
cfg.groupOptions = [];
var group = new GlideAggregate('task');
group.addAggregate('COUNT');
group.groupBy('assignment_group');
group.ordderBy('assignment_group');
group.query();
while (group.next()) {
if (group.getDisplayValue('assignment_group')) {
cfg.groupOptions.push({label: group.getDisplayValue('assignment_group'), value: group.getValue('assignment_group'), size: group.getAggregate('COUNT')});
if (group.getAggregate('COUNT') > cfg.max) {
cfg.max = group.getAggregate('COUNT');
cfg.maxGroup = group.getValue('assignment_group');
}
}
}
For the choice list of task types, I wanted to wait until a group was selected, and then limit the choices to only those types that had been assigned to the selected group. This was another GlideAggregate, and that turned out to be very similar code:
var max = 0;
var defaultType = '';
data.config.typeOptions = [];
var type = new GlideAggregate('task');
type.addQuery('assignment_group', data.group);
type.addAggregate('COUNT');
type.groupBy('sys_class_name');
type.ordderBy('sys_class_name');
type.query();
while (type.next()) {
if (type.getDisplayValue('sys_class_name')) {
data.config.typeOptions.push({label: type.getDisplayValue('sys_class_name'), value: type.getValue('sys_class_name'), size: type.getAggregate('COUNT')});
if (type.getAggregate('COUNT') > max) {
max = type.getAggregate('COUNT') * 1;
defaultType = type.getValue('sys_class_name');
}
}
}
The frequency choices, on the other hand, were just a hard-coded list that I came up with on my own. I wanted to be able to display the chart on a daily, weekly, monthly, quarterly, or yearly basis, so that’s the list of choices that I put together:
The choices for period ending dates took a bit more code. For one thing, I needed a different list of choices for each frequency. For another, the methodology for determining the next date in the series was slightly different for each frequency. That meant that much of the code was not reusable, as it was unique to each use case. There is probably a way to clean this up a bit, but this is what I have working for now:
cfg.endingOptions = {d: [], w: [], m: [], q: [], y: []};
var todaysDateInfo = getDateValues(new Date());
var today = new Date(todaysDateInfo.label);
var nextSaturday = new Date(today.getTime());
nextSaturday.setDate(nextSaturday.getDate() + (6 - nextSaturday.getDay()));
dt = new Date(nextSaturday.getTime());
for (var i=0; i<52; i++) {
cfg.endingOptions['d'].push(getDateValues(dt));
cfg.endingOptions['w'].push(getDateValues(dt));
dt.setDate(dt.getDate() - 7);
}
dt = new Date(today.getTime());
dt.setMonth(11);
dt = getLastDayOfMonth(dt);
cfg.endingOptions['y'].push(getDateValues(dt));
dt = new Date(today.getTime());
dt.setDate(1);
dt.setMonth([2,2,2,5,5,5,8,8,8,11,11,11][dt.getMonth()]);
dt = getLastDayOfMonth(dt);
cfg.endingOptions['q'].push(getDateValues(dt));
dt = new Date(today.getTime());
for (var i=0; i<36; i++) {
dt = getLastDayOfMonth(dt);
var mm = dt.getMonth();
cfg.endingOptions['m'].push(getDateValues(dt));
if (mm == 2 || mm == 5 || mm == 8 || mm == 11) {
cfg.endingOptions['q'].push(getDateValues(dt));
}
if (mm == 11 && i != 0) {
cfg.endingOptions['y'].push(getDateValues(dt));
}
dt.setDate(1);
dt.setMonth(dt.getMonth() - 1);
}
That takes care of the four choice lists and the code to come up with the values for the four choice lists. We’ll want something selected when the page first loads, though, so we’ll need some additional code to come up with the initial values for each of the four selections. For the group, my thought was to start out with the group that had the most tasks, and if the user was a member of any groups, group of which the user was a member with the most tasks would be event better. Here’s what I came up with the handle that:
function getDefaultGroup() {
var defaultGroup = '';
var max = 0;
var group = new GlideAggregate('task');
if (data.usersGroups.size() > 0) {
var usersGroups = '';
var separator = '';
for (var i=0; i<data.usersGroups.size(); i++) {
usersGroups = separator + "'" + data.usersGroups.get(i) + "'";
separator = ',';
}
group.addQuery('assignment_group', 'IN', usersGroups);
}
group.addAggregate('COUNT');
group.groupBy('assignment_group');
group.ordderBy('assignment_group');
group.query();
while (group.next()) {
if (group.getDisplayValue('assignment_group')) {
if (group.getAggregate('COUNT') > max) {
max = group.getAggregate('COUNT') * 1;
defaultGroup = group.getValue('sys_class_name');
}
}
}
if (!defaultGroup) {
defaultGroup = data.config.maxGroup;
}
return defaultGroup;
}
Since the type choices are dependent on the group selected, that code is already built into the type selection list creation process (above). For the initial frequency, I just arbitrarily decided to start out with daily, and for the initial period ending date, I decided that the current period would be the best place to start as well. That code turned out to be pretty basic.
With the initial choices made, we now need to work out the process of gathering up the data for the chart based on the choice list selections. That’s a bit of a task as well, so let’s make that our focus the next time out.
“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.
To grab just the chart object, I copied the highlighted code from the page.
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.
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.
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.
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.
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 …
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 …
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:
“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 Portalbreadcrumbs, 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:
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.
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.
Clicking on the Approvals breadcrumb will take you back to the original screen, removing the single Approval record from the breadcrumb array.
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.
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.
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.
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.
“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.
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 AngularJSng-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.
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.
“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:
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:
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 …
“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:
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.
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.