“I want to try it to see what it’s like and see what my stuff looks like when I take it from inception to completion.” — Charlie Kaufman
Last time, we started the work of modifying the core widget of the data table widget collection. We took care of all of the code that imports the configuration data from the various wrapper widgets in the collection, and now we need to get down to the business of actually putting real data in the new aggregate list columns. To begin, we need to take a look at the code that pulls in the regular data for each row in the list.
data.list = [];
while (gr._next()) {
var record = {};
$sp.getRecordElements(record, gr, data.fields);
if (typeof FilteredGlideRecord != 'undefined' && gr instanceof FilteredGlideRecord) {
// FilteredGlideRecord doesn't do field-level
// security, so take care of that here
for (var f in data.fields_array) {
var fld = data.fields_array[f];
if (!gr.isValidField(fld))
continue;
if (!gr[fld].canRead()) {
record[fld].value = null;
record[fld].display_value = null;
}
}
}
for (var f in data.fields_array) {
var fld = data.fields_array[f];
if (record[fld].type == 'reference') {
var refGr = gr;
var refFld = fld;
if (fld.indexOf('.') != -1) {
var parts = fld.split('.');
for (var x=0;x<parts.length-1;x++) {
refGr = refGr[parts[x]].getRefRecord();
}
refFld = parts[parts.length-1];
}
if (refGr.isValidField(refFld)) {
record[fld].table = refGr.getElement(refFld).getED().getReference();
record[fld].record = {type: 'reference', sys_id: {value: record[fld].value, display_value: record[fld].value}, name: {value: record[fld].display_value, display_value: record[fld].display_value}};
}
}
}
record.sys_id = gr.getValue('sys_id');
record.targetTable = gr.getRecordClassName();
data.list.push(record);
}
Basically, this code creates an array called data.list and then for each row creates an object called record, populates the record object from the GlideRecord, and then pushes the record object into the list. What we will want to do is add data to the record object before it gets pushed onto the list. The sys_id of the record will actually be useful to us, so it seems as if the best place to insert our code would be after the sys_id is established, but before the data.list.push(record) occurs. Here is what I came up with:
record.aggValue = [];
if (data.aggarray.length > 0) {
for (var j=0; j<data.aggarray.length; j++) {
record.aggValue.push(getAggregateValue(record.sys_id, data.aggarray[j]));
}
}
To store the values, I added a value list to the record object. This is done whether or not any aggregate columns have been defined, just so there is something there regardless. Then we check to see if there actually are any aggregate columns defined, and if so, we loop through the definitions and then push a value onto the array for each definition. The value itself will be determined by a new function called getAggregateValue that takes the sys_id of the row and the aggregate column definition as arguments. We will need to build out that function, but for now, we can just return a hard-coded value, just to make sure that all is working before we dive into that.
function getAggregateValue(sys_id, config) {
return 10;
}
Now that we have an array of values, we will need to go back into the HTML and modify the aggregate column section to pull data from this array. That section of the HTML now looks like this:
<td ng-repeat="aggValue in item.aggValue" class="text-right" ng-class="{selected: item.selected}" tabindex="0">
{{aggValue}}
</td>
That should be enough to take it out for a spin and see if we broke anything. Let’s have a look.
Cool! So far, so good. Now we just need to go back into that getAggregateValue function definition and replace the hard-code value of 10 with some actual logic to pull the real data out of the database. For that, we will use a GlideAggregate on the configured table using the configured field and the sys_id of the current row. And if there is an optional filter present, we simply concatenate that to the end of the primary query.
function getAggregateValue(sys_id, config) {
var value = 0;
var ga = new GlideAggregate(config.table);
ga.addAggregate('COUNT');
var query = config.field + '=' + sys_id;
if (config.filter) {
query += '^' + config.filter;
}
ga.addEncodedQuery(query);
ga.query();
if (ga.next()) {
value = parseInt(ga.getAggregate('COUNT'));
}
return value;
}
Now we can take another quick look and see if that finally gets us what we have been after all along.
Voila! There it is — a column containing a count of selected related records. Now that wasn’t so hard, was it? Of course, we are not done quite yet. The SNH Data Table from JSON Configuration widget is not the only wrapper widget in the collection. We will need to add support for aggregate columns to the SNH Data Table from Instance Definition widget, as well as the SNH Data Table from URL Definition widget, the widget designed to work with the Configurable Data Table Widget Content Selector. Also, we have the Content Selector Configuration Editor, which allows you to create the JSON configuration files for both the Configurable Data Table Widget Content Selector and the SNH Data Table from JSON Configuration widget. That will have to be modified to support aggregate column specifications as well. None of that is super challenging now that we have things working, but it all needs to be done, so we will jump right into that next time out.
“If you have an apple and I have an apple and we exchange these apples then you and I will still each have one apple. But if you have an idea and I have an idea and we exchange these ideas, then each of us will have two ideas.” — George Bernard Shaw
Last time, we introduced the idea of a new column type for our modified version of the Service PortalData Table Widget, built a sample configuration object, and then built a test page to try it all out. That was all relatively simple to do, but now we need to start digging around under the hood and see what coding changes we will need to make to the various table widgets in order to make this work. Our new test page uses the SNH Data Table from JSON Configuration widget, so that’s a good place to start looking for places that will need to be updated.
Virtually all of the heavy lifting for the data table widget collection is done in the core widget, SNH Data Table. All of the other widgets in the collection just provide a thin veneer over the top to gather up the configuration data from different sources. The SNH Data Table from JSON Configuration widget is no exception, and in digging through the code, it looks like there is really only one small place in the Server script that will need a slight modification.
The above code moves each section of the configuration individually, and since we have added a new section to the configuration, we will need to insert a new line to include that new section. Copying the btnarray line to create a new aggarray line should take care of that.
One more easy part checked off of the To Do list. Now we need to take a look at that core widget, where we are going to find the bulk of the work to make this all happen. Still, we can start with the easy stuff, which will be found in the HTML portion of the widget. There are two areas on which we will need to focus, the column headings and the data rows. Once again, we can take a look at what is being done with the button array data and pretty much copy it verbatim to handle the new aggregate array data. Here is the relevant section for the column headings:
Inserting a copy of that code right above it and replacing the button references with the equivalent aggregate references yields the following new block of code:
A little bit lower in the HTML is the code for the individual rows. Here again, we can take a look at the existing code dedicated to the buttons and icons.
Since we have not done any work on gathering up any actual data at this point, we can skip the value portion of the cell for now and just throw in a hard-coded zero so that we can fire it up and take a look. Later, once we figure out how to insert the values, we can come back around and clean that up a bit, but for now, we just want to be able to pull up the page and see how the UI portion comes out on the screen. So our temporary code for the aggregate columns will start out looking like this:
<td ng-repeat="aggregate in data.aggarray" class="text-right" ng-class="{selected: item.selected}" tabindex="0">
0
</td>
That should be enough to take a quick peek and see how things are working out. Here is the new test page with the column headings and columns, but no actual data.
Beautiful! That takes care of another one of the easy parts. Now we are going to have to dig a little deeper and start figuring out how we can replace those zeroes with some real data. To begin, I got into the Server script section of the widget and did a Find on the word btnarray. My thought here was that anywhere there was code related to the buttons and icons, there should probably be similar code for the aggregates. The first thing that I came across was this comment:
* data.btnarray = the array of button specifications
So, I added a new comment right above that one.
* data.aggarray = the array of aggregate column specifications
The next thing I found was this line used to copy in all of the widget options:
if (data.buttons) {
try {
var buttoninfo = JSON.parse(data.buttons);
if (Array.isArray(buttoninfo)) {
data.btnarray = buttoninfo;
} else if (typeof buttoninfo == 'object') {
data.btnarray = [];
data.btnarray[0] = buttoninfo;
} else {
gs.error('Invalid buttons option in SNH Data Table widget: ' + data.buttons);
data.btnarray = [];
}
} catch (e) {
gs.error('Unparsable buttons option in SNH Data Table widget: ' + data.buttons);
data.btnarray = [];
}
} else {
if (!data.btnarray) {
data.btnarray = [];
}
}
So, I copied that and altered the copy to be this:
if (data.aggregates) {
try {
var aggregateinfo = JSON.parse(data.aggregates);
if (Array.isArray(aggregateinfo)) {
data.aggarray = aggregateinfo;
} else if (typeof aggregateinfo == 'object') {
data.aggarray = [];
data.aggarray[0] = aggregateinfo;
} else {
gs.error('Invalid aggregates option in SNH Data Table widget: ' + data.aggregates);
data.aggarray = [];
}
} catch (e) {
gs.error('Unparsable aggregates option in SNH Data Table widget: ' + data.aggregates);
data.aggarray = [];
}
} else {
if (!data.aggarray) {
data.aggarray = [];
}
}
And that was it. None of that, of course, has anything to do with calculating the values for the new aggregate columns, but we won’t find that code by hunting for the btnarray variable. We are going to have to root around in the code that fetches the records to figure out where we need to add some logic to get the new values. That sounds like a good place to start in our next installment.
The feature that I have in mind is to have one or more columns that would include counts of related records. For example, on a list of Assignment Groups, you might want to include columns for how many people are in the group or how many open Incidents are assigned to the group. These are not columns on the Group table; these are counts of related records. It seems as if we could borrow some of the concepts from our Buttons and Icons strategy to come up with a similar approach to configuring Aggregate List Columns that could be defined when setting up the configuration for the table. You know what I always like to ask myself: How hard could it be?
Let’s take a quick look at what we need to configure a button or icon using the current version of the tools. Then we can compare that to what we would need to configure an Aggregate List Column using a similar approach. Here is the data that we collect when defining a button/icon:
Label
Name
Heading
Icon
Color
Hint
Page
The first three would appear to apply to our new requirement as well, but the rest are specific to the button/icon configuration. Still, we could snag those first three, and copy all of the code that deals with those first three, and then ditch the rest and replace them with data points that will be useful to our new purpose. Our list, then, would end up looking something like this:
Label
Name
Heading
Table
Field
Filter
The first three would be treated exactly the same way that their counterparts are treated in the button/icon code. The rest would be unique to our purpose. Table would be the name of the table that contains the related records to be counted. Field would be the name of the reference field on that table that would contain the sys_id of the current row. Filter would be an additional query filter that would be used to further limit the records to be counted. The purpose for the Filter would be to provide for the ability to count only those records desired. For example, a Table of Incident and a Field of assigned_to would count all of the Incidents ever assigned to that person, which is of questionable value. With the ability to add a Filter of active=true, that would limit the count to just those Incidents that were currently open. That is actually a useful statistic to add to a list.
One other thing that would be useful would be the addition of a link URL that would allow you to pull up a list of the records represented in the count. Although I definitely see the value in this additional functionality, anyone who has followed this web site for any length of time knows that I don’t really like to get too wild and crazy right out of the gate. My intent is to just see if I can get the count to appear on the list before I worry too much about other features that would be nice to have, regardless of their obvious value.
So, it seems as if the place to start here would be to pull up a JSON configuration object and see if we can add a configuration for an Aggregate List Column. When we built the User Directory, we added a simple configuration file to support both the Location Roster and the Department Roster. This looks like a good candidate to use as a starting point to see if we can set up a test case. Here is the original configuration file used in the User Directory project:
For our purpose, we don’t really need two state options, so we can simplify this even further by reducing this down to just one that we can call all. Then we can add our example aggregate configuration just above the button configuration. Also, since this is just a test, we will want to limit our list of people to just members of a single Assignment Group, so we can update the filter accordingly to limit the number of rows. Here is the configuration that I came up with for an initial test.
For now, I just created a simple filter using all of the sys_ids of all of the members of the Hardware group rather than bringing in the group member table and filtering on the group itself. This is not optimum, but this is just a test, and it will avoid other issues that we can discuss at a later time. The main focus at this point, though is the new aggarray property, which includes all of the configuration information that we listed earlier.
Now that we have a configuration script, we can create a page and try it out, even though we have not yet done any coding to support the new configuration attributes. At this point, we just want to see if the list comes up, and since nothing will be looking for our new data, that portion of the configuration object will just be ignored. I grabbed a copy of the Location Roster page from the User Directory project and then cloned it to create a page that I called Aggregate Test. Then I edited the configuration for the table to change the Configuration Script value to our new Script Include, AggregateTestConfig. I also removed the value that was present in the State field, as we only have one state, so no value is needed.
With that saved, we can run out to the Service Portal and pull up the new page and see how it looks.
Well, that’s not too bad for just a few minutes of effort. Of course, that was just the easy part. Now we have to tinker with the widgets to actually do something with this new configuration data that we are passing into the modules. That’s going to be a bit of work, of course, so we’ll start taking a look at how we want to do that next time out.
“If you take care of the small things, the big things take care of themselves. You can gain more control over your life by paying closer attention to the little things.” — Emily Dickinson
So, I have had this annoying little bug in my Service Portal Form Fields feature that just wasn’t quite annoying enough to compel me to fix it. Lately, however, I have been doing some testing with a form that included an email field, and I finally got irritated enough to hunt down the problem and put an end to it.
The issue came about when I added the reference type fields. Under the hood, a reference type field is just a form field wrapper around an snRecordPicker. Prior to adding the reference type to the list of supported types, I displayed validation messages only if the field had been $touched or the form had been $submitted. For a reference field, though, the field is never $touched (it’s not even visible on the screen), so I added $dirty as well. That solved my problem for reference fields, but it had the unfortunate side effect of displaying the validation messages while you were still filling out the field on all other types. For fields that are simply required, that’s not a problem (as soon as you start typing, you satisfy the criteria, so there is no validation error). On fields such as email addresses, though, the first character that you type is not a valid email address, so up pops the error message before you even finish entering the data. That’s just annoying!
Anyway, the solution is obviously to only include $dirty on reference fields and leave the others as they were. Unfortunately, that is a generic line that applies all form fields of any type, so I had to includes some conditional logic in there. Here is the offending line of code:
Now, that is a huge, long line of code, so I really did not want to make it any longer by throwing some conditional logic in for the $dirty attribute. I ended up shortening it to this:
That was it. Problem solved. It turns out that it was a pretty simple fix, and something that should have been done a long, long time ago. Well, at least it’s done now. Here’s an Update Set for those of you who are into that kind of thing.
“Work never killed anyone. It’s worry that does the damage. And the worry would disappear if we’d just settle down and do the work.” — Earl Nightingale
It’s been a while since I first set out to build a custom version of the stock Data Table widget, but since that time, I have used my hacked up version for a number of different projects, including sharing pages with my companion widget, the Configurable Data Table Widget Content Selector. Now that I have a way to edit the configuration scripts for the content selector, that has become my primary method for setting up the parameters for a data table. The content selector, though, was designed to give the end user the ability to select different Perspectives, different States, and different Tables. That’s a nice feature, but there are times when I just want to display a table of data without any options to look at any other data. I thought about setting up an option to make the content selector hidden for those instances, but then it occurred to me that the better approach was to cut out the middleman entirely and create a version of the Data Table widget that read the configuration script directly. This way, I wouldn’t have to put the content selector on the page at all.
So I cloned my SNH Data Table from URL Definition widget to create a new SNH Data Table from JSON Configuration widget. Then I opened up my Content Selector widget and started stealing parts and pieces of that guy and pasting them into my new Data Table widget, starting with the widget option for the name of the configuration script:
{
"hint":"Mandatory configuration script that is an extension of ContentSelectorConfig",
"name":"configuration_script",
"section":"Behavior",
"label":"Configuration Script",
"type":"string"
}
I also threw in three new options so that you could override the default Perspective, Table, and State values.
{
"hint":"Optional override of the default Perspective",
"name":"perspective",
"section":"Behavior",
"label":"Perspective",
"type":"string"
},{
"hint":"Optional override of the default Table",
"name":"table",
"section":"Behavior",
"label":"Table",
"type":"string"
},{
"hint":"Optional override of the default State",
"name":"state",
"section":"Behavior",
"label":"State",
"type":"string"
}
In the HTML section, I copied in the two warning messages and pasted them in with minimal modifications:
<div ng-hide="options && options.configuration_script">
<div class="alert alert-danger">
${You must specify a configuration script using the widget option editor}
</div>
</div>
<div ng-show="options && options.configuration_script && !data.config.defaults">
<div class="alert alert-danger">
{{options.configuration_script}} ${is not a valid Script Include}
</div>
</div>
On the server side, I deleted the first several lines of code that dealt with grabbing the table and view from the URL and making sure that something was there, and replaced it with some code that I pretty much lifted intact from the content selector widget. Here is the code that I removed:
data.config = {};
data.user = {sys_id: gs.getUserID(), name: gs.getUserName()};
if (options) {
if (options.configuration_script) {
var instantiator = new Instantiator(this);
var configurator = instantiator.getInstance(options.configuration_script);
if (configurator != null) {
data.config = configurator.getConfig($sp);
data.config.authorizedPerspective = getAuthorizedPerspectives();
establishDefaults(options.perspective, options.table, options.state);
}
}
}
if (data.config.defaults && data.config.defaults.perspective && data.config.defaults.table && data.config.defaults.state) {
var tableList = data.config.table[data.config.defaults.perspective];
var tbl = -1;
for (var i in tableList) {
if (tableList[i].name == data.config.defaults.table) {
tbl = i;
}
}
data.tableData = tableList[tbl][data.config.defaults.state];
data.table = data.config.defaults.table;
} else {
data.invalid_table = true;
data.table_label = "";
return;
}
I also grabbed a couple of the functions that were called from there and pasted those in down at the bottom:
function getAuthorizedPerspectives() {
var authorizedPerspective = [];
for (var i in data.config.perspective) {
var p = data.config.perspective[i];
if (p.roles) {
var role = p.roles.split(',');
var authorized = false;
for (var ii in role) {
if (gs.hasRole(role[ii])) {
authorized = true;
}
}
if (authorized) {
authorizedPerspective.push(p);
}
} else {
authorizedPerspective.push(p);
}
}
return authorizedPerspective;
}
function establishDefaults(perspective, table, state) {
data.config.defaults = {};
var p = data.config.authorizedPerspective[0].name;
if (perspective) {
if (data.config.table[perspective]) {
p = perspective;
}
}
if (p) {
data.config.defaults.perspective = p;
for (var t in data.config.table[p]) {
if (!data.config.defaults.table) {
data.config.defaults.table = data.config.table[p][t].name;
}
}
if (table) {
for (var t1 in data.config.table[p]) {
if (data.config.table[p][t1].name == table) {
data.config.defaults.table = table;
}
}
}
data.config.defaults.state = data.config.state[0].name;
if (state) {
for (var s in data.config.state) {
if (data.config.state[s].name == state) {
data.config.defaults.state = state;
}
}
}
}
}
I also reworked the area labeled widget parameters to get the data from the configuration instead of the URL. That area now looks like this:
No modifications were needed on the client side, so now I just needed to create a new test page, drag the widget onto the page and then use the little pencil icon to configure the widget. I called my new page table_from_json and pulled it up in the Page Designer to drag in this new widget. Using the widget option editor, I entered the name of the Script Include that we have been playing around with lately and left all of the other options that I added blank for this first test.
With that saved, all that was left to do was to go out to the Service Portal and bring up the page.
Not bad! I played around with it for a while, trying out different options using the widget option editor in the Page Designer, and everything seems like it all works OK. I’m sure that I’ve hidden some kind of error deep in there somewhere that will come out one day, but so far, I have not stumbled across it in any of my feeble testing. For those of you who like to play along at home, here is an Update Set that I am hoping contains all of the needed parts.
“Plans are only good intentions unless they immediately degenerate into hard work.” — Peter Drucker
It seemed as if we had this topic all wrapped up a while back, but then we had to play around with the Buttons and Icons. That should have been the end of that, but then we went and added Bulk Actions to the hacked up Data Table widget, which has now broken our new Content Selector Configuration Editor. So now we have to add support for the Bulk Action configuration to the editor to put things back together again. Fortunately, this is all very similar stuff, so it is mainly just a matter of making some copies of things that we have already built and then hacking them up just a bit to fit the current need.
To start with, we will need a Bulk Action Editor pop-up very similar to all of the other pop-up editors that we have been creating, and since Bulk Actions only have a name and a label for properties, the State Editor seems like the best choice for something to copy, since it too only has a name and a label for properties. Once we pull that widget up on the widget form, change the name, and then select Insert and Stay from the context menu, we can start working on our new copy. There is literally nothing to change with the HTML:
<div>
<form name="form1">
<snh-form-field
snh-model="c.widget.options.shared.label"
snh-name="label"
snh-required="true"/>
<snh-form-field
snh-model="c.widget.options.shared.name"
snh-name="persp"
snh-label="Name"
snh-required="true"/>
</form>
<div style="width: 100%; padding: 5px 50px; text-align: right;">
<button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
<button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
</div>
</div>
… and only a function name change on the client script:
function BulkActionEditor($scope, $timeout) {
var c = this;
$scope.cancel = function() {
$timeout(function() {
angular.element('[ng-click*="buttonClicked"]').get(0).click();
});
};
$scope.save = function() {
if ($scope.form1.$valid) {
$timeout(function() {
angular.element('[ng-click*="buttonClicked"]').get(1).click();
});
} else {
$scope.form1.$setSubmitted(true);
}
};
$timeout(function() {
angular.element('[class*="modal-footer"]').css({display:'none'});
}, 100);
}
As you may recall, there is no server-side code for these guys, so that’s all there is to that. Now we have a Bulk Action Editor. There is a little more work involved in the main Content Selector Configurator widget. On the HTML, we need to define a table for the Bulk Actions:
<div id="label.actarray" class="snh-label" nowrap="true">
<label for="actarray" class="col-xs-12 col-md-4 col-lg-6 control-label">
<span id="status.actarray"></span>
<span title="Bulk Actions" data-original-title="Bulk Actions">${Bulk Actions}</span>
</label>
</div>
<table class="table table-hover table-condensed">
<thead>
<tr>
<th style="text-align: center;">${Label}</th>
<th style="text-align: center;">${Name}</th>
<th style="text-align: center;">${Edit}</th>
<th style="text-align: center;">${Delete}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="act in tbl[state.name].actarray" ng-hide="btn.removed">
<td data-th="${Label}">{{act.label}}</td>
<td data-th="${Name}">{{act.name}}</td>
<td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editAction(act)" alt="Click here to edit this Bulk Action" title="Click here to edit this Bulk Action" style="cursor: pointer;"/></td>
<td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteAction(act, tbl[state.name].actarray)" alt="Click here to delete this Bulk Action" title="Click here to delete this Bulk Action" style="cursor: pointer;"/></td>
</tr>
</tbody>
</table>
<div style="width: 100%; text-align: right;">
<action ng-click="editAction('new', tbl[state.name].actarray, tbl);" class="btn btn-primary ng-binding ng-scope" role="action" title="Click here to add a new Bulk Action">Add a new Bulk Action</action>
</div>
On the client side, we need to add a couple more functions to handle the editing and deleting of the actions, which we can basically copy from the same functions we already have for editing and deleting buttons and icons.
$scope.editAction = function(action, actArray) {
var shared = {page_id: {value: '', displayValue: ''}};
if (action != 'new') {
shared.label = action.label;
shared.name = action.name;
}
spModal.open({
title: 'Bulk Action Editor',
widget: 'bulk-action-editor',
shared: shared
}).then(function() {
if (action == 'new') {
action = {};
actArray.push(action);
}
action.label = shared.label || '';
action.name = shared.name || '';
});
};
$scope.deleteAction = function(action, actArray) {
var confirmMsg = '<b>Delete Bulk Action</b>';
confirmMsg += '<br/>Are you sure you want to delete the ' + action.label + ' Bulk Action?';
spModal.confirm(confirmMsg).then(function(confirmed) {
if (confirmed) {
var a = -1;
for (var b=0; b<actArray.length; b++) {
if (actArray[b].name == action.name) {
a = b;
}
}
actArray.splice(a, 1);
}
});
};
On the server side, we just need to add some code to format the Bulk Action section of the table configurations, which we can copy from the code that formats the Buttons/Icons section, and then delete all of the extra properties that are not needed for Bulk Actions.
Now all we need to do is pull up our modified ButtonTestConfig script in the editor and see how we did.
So far, so good. Now let’s pull up that new Bulk Action Editor and see how that guy works:
Not bad. A little bit of testing here and there, just to make sure that we didn’t break anything along the way, and we should be good to go. Now if we can just stop tinkering with things, this Update Set should be the final release and we can move on to other exciting adventures.
“Tinkering is something we need to know how to do in order to keep something like the space station running. I am a tinkerer by nature.” — Leroy Chiao
I know what you are thinking: Didn’t we wrap this series up last time with the release of the final Update Set? Well, technically you would be correct in that we finished building all of the parts and bundled them all up in an Update Set for those of you who like to play along at home, but … we really did not spend a whole lot of time on the purpose for all of this, so I thought it might be a good time to back up the truck just a tad and demo some of the features of the customized data table widget and the associated content selector widget. Mainly, I want to talk about the buttons and icons and how all of that works, but before we do that, let’s go all the way back and talk about the basic idea behind these customizations of some pretty cool stock products.
I really liked the stock Data Table widget that comes bundled with the Service Portal, but the one thing that annoyed me was that each row was one huge clickable link, which was a departure from the primary UI, where every column could potentially be a link if it contained a Reference field. So, I rewired it. Of course, once you start playing with something then you end up doing all kinds of other crazy things and before you know it, you have this whole subset of trinkets and doodads that only you know anything about. So, on occasion, I feel the need to stop talking about what I am building and how it is constructed, and spend a little time talking about how to use it and what it is good for. Hence, today’s discussion of buttons and icons on the customized Data Table widget.
Before we get started, though, I do need to confess that in all of the excitement around creating a way to edit the JSON configuration object for the Configurable Data Table Widget Content Selector, I completely forgot about one of the options for handling a button click: opening up a new portal page. When we first introduced the idea of having buttons and icons on the rows of the Data Table widget, we made allowances for adding a page_id property to the button definition, and if that property were valued, we would link to that page on a click; otherwise, we would broadcast the click details. We did not include the page_id property in either the Content Selector Configuration Editor widget or the Button/Icon Editor widget, so let’s correct that oversight right now. First, we will need to add that property to the HTML for the table of Buttons/Icons.
<table class="table table-hover table-condensed">
<thead>
<tr>
<th style="text-align: center;">${Label}</th>
<th style="text-align: center;">${Name}</th>
<th style="text-align: center;">${Heading}</th>
<th style="text-align: center;">${Icon}</th>
<th style="text-align: center;">${Icon Name}</th>
<th style="text-align: center;">${Color}</th>
<th style="text-align: center;">${Hint}</th>
<th style="text-align: center;">${Page}</th>
<th style="text-align: center;">${Edit}</th>
<th style="text-align: center;">${Delete}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="btn in tbl[state.name].btnarray" ng-hide="btn.removed">
<td data-th="${Label}">{{btn.label}}</td>
<td data-th="${Name}">{{btn.name}}</td>
<td data-th="${Heading}">{{btn.heading}}</td>
<td data-th="${Icon}" style="text-align: center;">
<a ng-if="btn.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{btn.color || 'default'}}" title="{{btn.hint}}" data-original-title="{{btn.hint}}">
<span class="icon icon-{{btn.icon}}" aria-hidden="true"></span>
<span class="sr-only">{{btn.hint}}</span>
</a>
</td>
<td data-th="${Icon Name}">{{btn.icon}}</td>
<td data-th="${Color}">{{btn.color}}</td>
<td data-th="${Hint}">{{btn.hint}}</td>
<td data-th="${Page}">{{btn.page_id}}</td>
<td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editButton(btn)" alt="Click here to edit this Button/Icon" title="Click here to edit this Button/Icon" style="cursor: pointer;"/></td>
<td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteButton(btn, tbl[state.name].btnarray)" alt="Click here to delete this Button/Icon" title="Click here to delete this Button/Icon" style="cursor: pointer;"/></td>
</tr>
</tbody>
</table>
… and then we will need to pass it back and forth between the main widget and the pop-up Button/Icon Editor widget:
… and then, of course, we need to update the Button/Icon Editor itself by dragging in the page selector form field from the Reference Page Editor widget and adding it to the fields on the Button/Icon Editor form.
That should take care of that little oversight. Now, let’s get back to showing off the various ways in which you can use buttons and icons on the customized Data Table widget.
To start out, let’s use the tool that we just made to create a brand new configuration for a sample page where we can demonstrate how the buttons and icons work. Let’s click on our new Menu Item to bring up the tool, click on the Create a new Content Selector Configuration button, enter the name of our new Script Include, and then click on the OK button.
When our new empty configuration comes up in the tool, let’s define a single Perspective called Button Test, and a couple of different States, Active and Not Active. We want to keep things simple at this point, mainly so as not to distract from our primary purpose here, which is to show how the buttons and icons work. Once we have defined our Perspective and States, click on the Add new Table button and select the Incident table from the list.
Select just a couple of fields, say Number and Short Description, and set the filter for the Active State to active=true. Then we can start adding Buttons and Icons using the Add new Button/Icon button.
Those of you paying close attention will notice that the image above was taken before we added the page_id property to the Button/Icon configuration. The newer version has a pick list of portal pages below the Hint and above the Example. You will want to define at least one Button/Icon with a page value. Keep adding different buttons and icons until you have a representative sample of different things and then we can see how it all renders out in actual use.
Once you have a few set up for testing, save the new configuration and take a look at the resulting script, which should look something like this:
Now that we have that all ready to rock and roll, we need to configure a new test page so that we can put it to use. From the list of Portal Pages, click on the New button and create a page called button_test and save it so that we can pull it up in the Page Designer.
From the container list, drag over a 3/9 container and drag the Content Selector widget into the narrow portion and the SNH Data Table from URL Configuration widget into the wider portion. You shouldn’t have to edit the Data Table widget, but you will need to click on the pencil icon on the Content Selector widget so that you enter the name of your new configuration script.
Once that has been completed, you should be able to pull up your new page in the Service Portal and see how it renders out. You can click on any of your buttons or icons at this point, and if you click on one with a page_id value, that page should come up with the record from that row. If you click on any of the other buttons or icons, through, nothing will happen, because we have not set up anything to react to the button clicks at this point. However, if everything is working as it should be, we are now ready to do just that.
When a button or icon is clicked on the customized Data Table widget, and there is no page_id value defined, all that happens is that the details are broadcast out. Some other widget on the page needs to be listening for that broadcast in order for something to happen. The secret, then, is to have some client-side code somewhere that looks something like this:
$rootScope.$on('button.click', function(e, parms) {
// do something
});
For demonstration purposes we can build a simple test widget that will do just that, and in response to a click event, display all of the details of that event in an spModal alert. Let’s call this widget Button Click Handler Example, and give it the following client-side code:
function ButtonClickHandlerExample(spModal, $rootScope) {
var c = this;
$rootScope.$on('button.click', function(e, parms) {
displayClickDetails(parms);
});
function displayClickDetails(parms) {
var html = '<div>';
html += ' <div class="center"><h3>You clicked on the ' + parms.button.name + ' button</h3></div>\n';
html += ' <table>\n';
html += ' <tbody>\n';
html += ' <tr>\n';
html += ' <td class="text-primary">Table: </td>\n';
html += ' <td>' + parms.table + '</td>\n';
html += ' </tr>\n';
html += ' <tr>\n';
html += ' <td class="text-primary">Sys ID: </td>\n';
html += ' <td>' + parms.sys_id + '</td>\n';
html += ' </tr>\n';
html += ' <tr>\n';
html += ' <td class="text-primary">Button: </td>\n';
html += ' <td><pre>' + JSON.stringify(parms.button, null, 4) + '</pre></td>\n';
html += ' </tr>\n';
html += ' <tr>\n';
html += ' <td class="text-primary">Record: </td>\n';
html += ' <td><pre>' + JSON.stringify(parms.record, null, 4) + '</pre></td>\n';
html += ' </tr>\n';
html += ' </tbody>\n';
html += ' </table>\n';
html += '</div>';
spModal.alert(html);
}
}
Now that we have our widget, we can pop back into the Page Designer and drag the widget anywhere on the screen. It has no displayable content, so it really doesn’t matter where you put it as long as it is present on the page somewhere. Once that’s done, we can pull up our page on the portal again and start clicking around again to see what we get.
In addition to the details of the record on that row and the button that was clicked, you also get the name of the table and the sys_id of the record. Any of this data can be used to perform any number of potential actions, all of which are completely outside of the two reusable components, the customized Data Table and the configurable Content Selector. You shouldn’t have to modify either of those widgets to create custom functionality; just configure the Content Selector with an appropriate JSON config object, and then add any custom click handlers to your page for any button actions that you would like to configure. Here is an Update Set that includes the page_id corrections as well as the button test widget so that you can click around on your own and see how everything plays together. There is an even better version here.
Well, we have come a long way since we first set to out to build our little Content Selector Configuration Editor. We have built the primary widget and quite a few modal pop-up widgets and have pretty much built out everything that you would need to maintain the configuration. The only thing left at this point is to actually save the data now that it has been edited. Not too long ago, we built a tool that saved its data in a Script Include by rewriting the script and updating a Script Include record. We can use that same technique here by building the script from the user’s input, and then storing it in the script column of a new or updated Script Include record. The first order of business, then, would be to build the script, starting with the initial definition of the class and its prototype:
Now it is time for the Tables, which is where things get a little more complicated. Here we have to loop through every defined Perspective, and then loop through every Table defined for that Perspective, and then loop through every defined State, and then if there are Buttons/Icons and/or Reference Pages, we will need to loop through all of those as well.
That completes the creation of the script from the user’s input. Now we have to save it. If this is an existing record, then all we need to do is fetch it and update the script column, but if this is a new record, then we have a little bit more work to do.
The reason that we grab the sys_id there at the end after the record has been saved is so that we can take the user to the saved Script Include once the record has been inserted/updated. We do that on the client-side in the code that is launched by the Save button.
$scope.save = function() {
var missingData = false;
if (c.data.config.perspective.length == 0) {
missingData = true;
} else if (c.data.config.state.length == 0) {
missingData = true;
} else {
for (var p in c.data.config.perspective) {
if (c.data.config.table[c.data.config.perspective[p].name].length == 0) {
missingData = true;
}
}
}
if ($scope.form1.$valid && !missingData) {
c.data.action = 'save';
c.server.update().then(function(response) {
window.location.href = '/sys_script_include.do?sys_id=' + response.sys_id;
});
} else {
$scope.form1.$setSubmitted(true);
spModal.alert('You must correct all form validation errors before saving');
}
};
This assumes that we are doing all of this in the main UI and not on some Portal Page, and to facilitate that, we add a menu item to the Tools menu so that we can launch this widget from within the primary UI.
That’s about it for all of the parts and pieces necessary to make this initial version work. Certainly there are a number of things that we could do to make it a little better here and there, but overall I think it is a pretty good start. We should play around with it a bit and try building out a few different configurations, but for those of you who like to play along at home, here is an Update Set with what should be everything that you need to make this work.
“It is amazing what you can accomplish if you do not care who gets the credit.” — Harry Truman
At the end of our last installment in this series we had coded all of the client-side functions to add and remove Buttons/Icons and Reference Pages, but still needed to create the modal widgets needed to edit the values for those. The Button/Icon editor is the more complex of the two, plus it’s the first one that we encounter on the page, so let’s start with that one, and as usual, let’s start with the HTML.
Pretty standard stuff. There are just a few more fields here than with some of the other things that we have been editing in a pop-up window, and one of them happens to be the new icon field type. Also, there is an “Example” field to show you what the Button/Icon will look like based on the values that you have entered. Here’s how it looks in action:
Other than the additional fields, it’s pretty much the same as the others, and the client-side code is virtually identical to what we have used in the past, with the one exception of having to assign spModal to the $scope so that it can be available to the Icon Picker.
As with our other pop-up editor widgets, there is no server-side code, so that’s the entire widget. The Reference Page editor is even simpler.
<div>
<form name="form1">
<snh-form-field
snh-type="reference"
snh-model="c.widget.options.shared.table"
snh-name="table"
snh-change="buildFieldFilter();"
snh-required="true"
placeholder="Choose a Table"
table="'sys_db_object'"
display-field="'label'"
display-fields="'name'"
value-field="'name'"
search-fields="'name,label'"/>
<snh-form-field
snh-type="reference"
snh-model="c.widget.options.shared.page"
snh-name="page"
snh-required="true"
placeholder="Choose a Portal Page"
table="'sp_page'"
display-field="'title'"
display-fields="'id'"
value-field="'id'"
search-fields="'id,title'"/>
</form>
<div style="width: 100%; padding: 5px 50px; text-align: right;">
<button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
<button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
</div>
</div>
Here, we just have two sn-record-pickers, one for the reference table and the other for the portal page that you want to bring up whenever someone clicks on a reference value from that table. And once again, the client-side code looks very familiar.
function ReferencePageEditor($scope, $timeout) {
var c = this;
$scope.cancel = function() {
$timeout(function() {
angular.element('[ng-click*="buttonClicked"]').get(0).click();
});
};
$scope.save = function() {
if ($scope.form1.$valid) {
$timeout(function() {
angular.element('[ng-click*="buttonClicked"]').get(1).click();
});
} else {
$scope.form1.$setSubmitted(true);
}
};
$timeout(function() {
angular.element('[class*="modal-footer"]').css({display:'none'});
}, 100);
}
That takes care of everything that we discussed last time, but last time we neglected to make allowances for a couple of very import features: the ability to add a new Table and the ability to remove a Table. Since each Perspective has its own list of Tables, we will need to add a New Table button at the bottom of the list for each Perspective. To remove a Table, we can just add a Delete icon next to the Table name for that purpose. That will change the basic HTML structure to now look like this:
<div>
<h4 class="text-primary">${Tables}</h4>
</div>
<uib-tabset active="active">
<uib-tab ng-repeat="persp in c.data.config.perspective track by persp.name" heading="{{persp.label}}">
<div ng-repeat="tbl in c.data.config.table[persp.name] track by tbl.name" style="padding-left: 25px;">
<h4 style="color: darkblue">
{{tbl.displayName}}
({{tbl.name}})
<img src="/images/delete_row.gif" ng-click="deleteTable(persp.name, tbl.name)" alt="Click here to permanently delete table {{tbl.name}} from this Perspective" title="Click here to permanently delete table {{tbl.name}} from this Perspective" style="cursor: pointer;"/>
</h4>
<uib-tabset active="active">
<uib-tab ng-repeat="state in c.data.config.state track by state.name" heading="{{state.label}}">
<div style="padding-left: 25px;">
<!----- all of the specific properties are defined here ----->
</div>
</uib-tab>
</uib-tabset>
</div>
<div style="width: 100%;">
<button ng-click="newTable(persp.name)" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new table to the {{persp.label}} perspective">Add a new Table</button>
</div>
</uib-tab>
</uib-tabset>
And of course, we will need functions to handle both the Table Add and Table Remove processes. For the Add, let’s use a pop-up Table selector that will allow you to select a Table from a list. For the Delete, we can adapt one of the other Delete functions that we have already written.
$scope.deleteTable = function(perspective, tableName) {
var confirmMsg = '<b>Delete Table</b>';
confirmMsg += '<br/>Deleting the ';
confirmMsg += tableName;
confirmMsg += ' Table will also delete all information for that Table in every State in this Perspective.';
confirmMsg += '<br/>Are you sure you want to delete this Table?';
spModal.confirm(confirmMsg).then(function(confirmed) {
if (confirmed) {
var a = -1;
for (var b=0; b<c.data.config.table[perspective].length; b++) {
if (c.data.config.table[perspective][b].name == tableName) {
a = b;
}
}
c.data.config.table[perspective].splice(a, 1);
}
});
};
The function to pop open the modal Table Selector widget is quite familiar as well.
… and the widget itself we can clone from the Reference Page widget, which already had a Table picker defined.
<div>
<form name="form1">
<snh-form-field
snh-type="reference"
snh-model="c.data.table"
snh-name="table"
snh-change="tableSelected();"
snh-required="true"
snh-help="Choose a Table to be added to this Perspective"
placeholder="Choose a Table"
table="'sys_db_object'"
display-field="'label'"
display-fields="'name'"
value-field="'name'"
search-fields="'name,label'"/>
</form>
</div>
Since all we want them to do is to select a table, we can use an snh-change to call the function and send back the selection.
function TableSelector($scope, $timeout) {
var c = this;
$scope.tableSelected = function() {
if (c.data.table.value > '') {
c.server.update().then(function(response) {
c.widget.options.shared.name = response.table.value;
c.widget.options.shared.displayName = response.table.displayValue;
$timeout(function() {
angular.element('[ng-click*="buttonClicked"]').get(1).click();
});
});
}
};
}
On server side, we just make sure that we have both a table name and a display name.
(function() {
data.table = {};
if (input && input.table && input.table.value) {
data.table = input.table;
if (!data.table.displayValue) {
data.table.displayValue = getItemName(data.table.value);
}
}
function getItemName(key) {
var ciGR = new GlideRecord(key);
return ciGR.getLabel();
}
})();
That pretty much wraps up all of the editing functions. We still need to throw in some code to save all of the changes, but that might get pretty involved, so let’s say we save that for our next installment.
“It takes considerable knowledge just to realize the extent of your own ignorance.” — Thomas Sowell
At the end of our last installment in this series, we had the HTML for the Tables section, but none of the underlying code to make it all work. Now it’s time to create that code by building all of the client-side functions needed to support all of the ng-click attributes sprinkled throughout the HTML. The first one that you come to in this section is the editButton function that passes the button object as an argument. This should pop open a modal editor just like we did with the Perspectives and the States, so this function could be loosely modeled after those other two. Something like this should do the trick:
We had to add a second argument to the function in this case, but only when creating a new Button/Icon. For an existing object, it is already in place in the object tree, but in the case of a new Button/Icon that we create from a new blank object, we need to know where to put it so that it lives with all of its siblings. Other than that one little wrinkle, the rest is pretty much the same as we have built before. The deleteButton function is also a little different in that we do not have a specified index, so we have to spin through the list to determine the index. Once we have that, though, all we need to do is remove the Button/Icon from the list, as there are no other dependent objects that have to be removed, unlike the Perspectives and the States.
$scope.deleteButton = function(button, btnArray) {
var confirmMsg = '<b>Delete Button/Icon</b>';
confirmMsg += '<br/>Are you sure you want to delete the ' + button.label + ' Button/Icon?';
spModal.confirm(confirmMsg).then(function(confirmed) {
if (confirmed) {
var a = -1;
for (var b=0; b<btnArray.length; b++) {
if (btnArray[b].name == button.name) {
a = b;
}
}
btnArray.splice(a, 1);
}
});
};
That takes care of the Buttons/Icons … now we have to do essentially the same thing for the Reference Pages. Once again, we have a few little wrinkles that make things not quite exactly the same. For one thing, the Button/Icon data is stored in an Array, but the Reference Page data is stored in a Map. Also, both of the properties for a Reference Page (Table and Portal Page) have values that come from a ServiceNow database table, so the pop-up editor can use an sn-record-picker for both fields. That means their values will be stored in objects, not strings, so again, not an exact copy of the other functions. Still, it looks pretty close to all of its cousins:
… and because of those differences, the deleteRefMap function turns out to be the most simplest of all:
$scope.deleteRefMap = function(table, refMap) {
var confirmMsg = '<b>Delete Reference Page</b>';
confirmMsg += '<br/>Are you sure you want to delete the ' + table + ' table reference page mapping?';
spModal.confirm(confirmMsg).then(function(confirmed) {
if (confirmed) {
delete refMap[table];
}
});
};
So now we are done with all of the client-side functions, but our work is still not finished. Both of the edit functions pop open modal editor widgets, so we are going to need to build those. We already have a couple of different models created for the Perspective and State editors, so it won’t be as if we have to start out with a blank canvas. Still, there is a certain amount of work involved, so that sounds like a good place to start out in our next installment.