Scripted Value Columns, Part V

“Make incremental progress; change comes not by the yard, but by the inch.”
Rick Pitino

Last time, we had enough parts cobbled together to demonstrate that the concept actually works. Of course, all we had to show for it was some random numbers, but that told us that the specified script was being called for each row, which is what we were after. Now that we know that the basic structure is performing as desired, we can revisit the configurable Script Include component and see if we can come up with some actual use cases that might be of value to someone.

One of the questions that triggered this idea was related to comments and work notes on Incidents. Assuming that the main record in the table is an Incident, we can clone our example Script Include to create one dedicated to pulling data out of the latest comment or work note on an Incident. We can call this new Script Include ScriptedJournalValueProvider.

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

	getScriptedValue: function(item, config) {
		return Math.floor(Math.random() * 100) + '';
	},

	type: 'ScriptedJournalValueProvider'
};

We will want to delete the example code in the getScriptedValue function and come up with our own, but other than that, the basic structure remains the same. Assuming that we want our script to be able to handle a number of attributes of an Incident Journal entry, we can use the name of the column to determine which function will fetch us our value.

getScriptedValue: function(item, config) {
	var response = '';

	var column = config.name;
	if (column == 'last_comment') {
		response = this.getLastComment(item, config);
	} else if (column == 'last_comment_by') {
		response = this.getLastCommentBy(item, config);
	} else if (column == 'last_comment_on') {
		response = this.getLastCommentOn(item, config);
	} else if (column == 'last_comment_type') {
		response = this.getLastCommentType(item, config);
	}

	return response;
}

This way, we can point to this same script in multiple columns and the name of the column will determine which value from the last comment or work note gets returned.

Since all of the functions will need the data for the last entry, we should create a shared function that they all can leverage to obtain the record. As with many things on the ServiceNow platform, there are a number of ways to go about this, but for our demonstration purposes, we will read the sys_journal_field table looking for the last entry for the Incident in the current row.

getLastJournalEntry: function(sys_id) {
	var journalGR = new GlideRecord('sys_journal_field');
	journalGR.orderByDesc('sys_created_on');
	journalGR.addQuery('name', 'incident');
	journalGR.addQuery('element_id', sys_id);
	journalGR.setLimit(1);
	journalGR.query();
	journalGR.next();
	return journalGR;
}

Now that we have a common way to obtain the GlideRecord for the latest entry, we can start building our functions that extract the requested data value. Here is the one for the comment text.

getLastComment: function(item, config) {
	var response = '';

	var journalGR = this.getLastJournalEntry(item.sys_id);
	if (journalGR.isValidRecord()) {
		response = journalGR.getDisplayValue('value');
	}

	return response;
}

The others will basically be copies of the above, modified to return different values based on their purpose. The whole thing, all put together, now looks like this.

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

	getScriptedValue: function(item, config) {
		var response = '';

		var column = config.name;
		if (column == 'last_comment') {
			response = this.getLastComment(item, config);
		} else if (column == 'last_comment_by') {
			response = this.getLastCommentBy(item, config);
		} else if (column == 'last_comment_on') {
			response = this.getLastCommentOn(item, config);
		} else if (column == 'last_comment_type') {
			response = this.getLastCommentType(item, config);
		}

		return response;
	},

	getLastComment: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('value');
		}

		return response;
	},

	getLastCommentBy: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('sys_created_by');
		}

		return response;
	},

	getLastCommentOn: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('sys_created_on');
		}

		return response;
	},

	getLastCommentType: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('element');
		}

		return response;
	},

	getLastJournalEntry: function(sys_id) {
		var journalGR = new GlideRecord('sys_journal_field');
		journalGR.orderByDesc('sys_created_on');
		journalGR.addQuery('name', 'incident');
		journalGR.addQuery('element_id', sys_id);
		journalGR.setLimit(1);
		journalGR.query();
		journalGR.next();
		return journalGR;
	},

	type: 'ScriptedJournalValueProvider'
};

Now that we have a Script Include to utilize, we need to put together a new page so that we can configure it to make use of it so that we can test it out. Let’s make a quick copy of the page that we were using for testing last time and call it scripted_value_test. Also, let’s make a quick copy of the test configuration script that we were using earlier and call it ScriptedValueConfig.

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

	perspective: [{
		name: 'all',
		label: 'all',
		roles: ''
	}],

	state: [{
		name: 'all',
		label: 'All'
	}],

	table: {
		all: [{
			name: 'incident',
			displayName: 'Incident',
			all: {
				filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe^active=true',
				fields: 'number,opened_by,opened_at,short_description',
				svcarray: [{
					name: 'last_comment_on',
					label: 'Last Comment',
					heading: 'Last Comment',
					script: 'global.ScriptedJournalValueProvider'
				},{
					name: 'last_comment_by',
					label: 'Last Comment By',
					heading: 'Last Comment By',
					script: 'global.ScriptedJournalValueProvider'
				}],
				aggarray: [],
				btnarray: [],
				refmap: {
					sys_user: 'user_profile'
				},
				actarray: []
			}
		}]
	},

	type: 'ScriptedValueConfig'
});

Now let’s pull up our new page in the Service Portal Designer and point the table widget to our new configuration script.

Configuring the new test page to use the new test configuration script

Once we save that, we can pop over to the Service Portal and pull up our new page to try it out.

First test of our first real world utilization of this feature

Beautiful! Our new scripted value provider Script Include was called by the core SNH Data Table widget and it returned the requested values, which were then displayed on the list with all of the other standard table columns. That wasn’t so hard, now was it?

Of course, we still have a couple more wrapper widgets to modify (and test!), and I would like to produce another example, maybe something to do with catalog item variables, but I think we are close. One thing I see that I never noticed before, though, is that the added columns don’t quite line up with the original columns. Maybe it is a CSS thing, or maybe it is something a little more diabolical, but I want to take a look at that and see what is going on there. All of the data in the columns should be displayed consistently; I don’t like it when things don’t all line up correctly. I need to figure out what is going on there and see what I can do about it.

Anyway, we still have a little more work to do before we can wrap this all up into a new Update Set and post a new version out on Share, but we will keep plugging along in our next installment.

Refactoring the SNH Data Table Widget, Part II

“An intuitive definition is that a safe refactoring is one that doesn’t break a program. Because a refactoring is intended to restructure a program without changing its behavior, a program should perform the same way after a refactoring as it does before.”
Martin Fowler

Last time, we began the work of cleaning up the SNH Data Table widgets by consolidating all of the added action functions and bringing in the latest version of the original widget. To complete the work, we need to do the same for the remaining wrapper widgets in the collection, two of which were cloned from existing stock components, with the third being a new addition having no original source (although it was actually cloned from the modified version of one of the other two). As usual, we will start with the easy one first, the SNH Data Table from Instance Definition, cloned from the stock Data Table from Instance Definition widget.

The biggest change here was the addition of four new options to allow the entry of JSON strings for configuring each of the four new features:

[{"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 specifications for aggregate data columns",
"name":"aggregates",
"default_value":"",
"section":"Behavior",
"label":"Aggregate Column Specifications (JSON)",
"type":"String"},
{"hint":"A JSON object containing the specifications for row-level buttons and action icons",
"name":"buttons",
"default_value":"",
"section":"Behavior",
"label":"Button/Icon Specifications (JSON)",
"type":"String"},
{"hint":"A JSON object containing the page id for any reference column links",
"name":"refpage",
"default_value":"",
"section":"Behavior",
"label":"Reference Page Specifications (JSON)",
"type":"String"}]

Other than that, the only other modification to the code required was in the Server script where we needed to point to the SNH core widget instead of the stock core widget:

// Start: SNH Data Table enhancements
	data.dataTableWidget = $sp.getWidget('snh-data-table', options);
// End: SNH Data Table enhancements

Aside from that single line, the remainder of the widget, including the entire Client script, remains the same as the latest version of the original widget. Next, we need to take a look at the SNH Data Table from URL Definition, which was cloned from the stock Data Table from URL Definition widget. As with the previous widget, the Client script from the latest version of the source widget remains the same. However, the Server script needs a little more modification than just the ID of the embedded core widget.

// Start: SNH Data Table enhancements
	data.fields = $sp.getParameter('fields') || $sp.getListColumns(data.table, data.view);
	copyParameters(data, ['aggregates', 'buttons', 'refpage', 'bulkactions']);
	data.show_new = options.show_new == true || options.show_new == "true";
	data.show_breadcrumbs = options.show_breadcrumbs == true || options.show_breadcrumbs == "true";
	data.window_size = $sp.getParameter('maximum_entries');
	data.btns = data.buttons;
	data.dataTableWidget = $sp.getWidget('snh-data-table', data);
// End: SNH Data Table enhancements

I left the original code intact, even in areas where the new code reset the values established differently in the original code, mainly because that didn’t really hurt anything and I was trying to retain the original as closely as possible to the way that it was for future comparisons.

That leaves the SNH Data Table from JSON Configuration, which does not have a stock version, although I did clone it originally from the modified SNH Data Table from URL Definition widget. Since there were no changes needed in the Client script of the other two widgets cloned from stock widgets, I went ahead and just copied the latest version of the Client script from the stock Data Table from URL Definition widget and pasted it into he Client script of the SNH Data Table from JSON Configuration widget. The rest was mostly custom code anyway, so I just left that alone.

That takes care of the three wrapper widgets, so now everything has been brought up the latest versions and the code for handling the four added features has all be consolidated into the core widget using a common function. That cleaned things up quite nicely, but I’m still not quite ready to spin up a new Update Set just yet. There is one more thing that I think needs to addressed before we do that.

Refactoring the SNH Data Table Widget

“Quality is never an accident; it is always the result of high intention, sincere effort, intelligent direction and skillful execution; it represents the wise choice of many alternatives.”
William A Foster

After I posted the SNH Data Table widget collection out on Share, I realized that there were a couple more features that really needed to be in there before it could truly be considered complete, so I started working on adding one of those features, the ability to click on an aggregate column value and see the records represented by that number. While testing out those new additions to the code, I discovered that I was not being consistent in the way in which these various features were being implemented, and decided to take a step back and review the entire set of additional features as a group. I don’t like to see one feature implemented one way and another, similar feature implemented some other way. Just to review, the SNH version of the Data Table widget adds the following configurable features:

  • Reference Field Links – For any field with a type of reference, the value is rendered as a link which can branch to another Portal Page or broadcast the details to a companion widget sharing the page.
  • Aggregate Columns – Aggregate columns are counts of related records, which can also now be rendered as a link which can branch to another Portal Page or broadcast the details to a companion widget sharing the page.
  • Buttons and Icons – Buttons and icons can also be configured, and clicking on a button or icon will branch to another Portal Page or broadcast the details to a companion widget sharing the page.
  • Bulk Actions – When one or more bulk actions are configured, checkboxes will be rendered at the beginning of each line, and a drop-down select list of actions will be rendered in the footer. Checking one or more rows of the table and selecting an action from the drop-down will branch to another Portal Page or broadcast the details to a companion widget sharing the page.

Although each of these features were unique, and developed for a specific purpose over a period of time, they all share a number of characteristics in common, particularly the fact that selecting an item will broadcast a message alerting other widgets of the selection and/or branch out to a different page on the portal. It seemed to me that, even though each feature had its own way of approaching this, and even though they all seemed to work just the way that they were, they all really needed to approach things in a consistent manner. So I decided to go back and review the various implementations and see if I could come up with a way for all of them to be handled in the same manner.

For the reference field links, I had leveraged the existing go() function from the original widget. For the buttons and icons, I had built a new function, which I also did later for the aggregate columns, but using a different approach. For the bulk actions, I also had a separate function, but it is a little different than the other three, as it deals with multiple records and the others all deal with a single record. Still, all four had some very similar characteristics.

The original go() function did a $scope.emit(), and then code in the wrapper widgets listened for that event and did the actual branching to the new page. I don’t want to disturb that existing code, as I would like the enhanced widget to be drop-in compatible with the original, but I don’t really see the need to involve the wrapper widgets in any of the things that I was doing. I had already done that in a few places by copying what was already there, but now that I look at it a little more closely, I would like to rip all of that out and handle everything in the core widget alone.

The easiest way to make sure that everyone uses the same approach is for everyone to share the same code. So I came up with a universal function that would take care of the broadcasting and the navigation for all four use cases, which turned out like this:

function processClick(sys_id, eventName, config, item) {
	spNavStateManager.onRecordChange(c.data.table).then(function() {
		var parms = {};
		parms.table = config.table || c.data.table;
		parms.sys_id = sys_id;
		parms.record = item;
		parms.config = config;
		$scope.ignoreLocationChange = true;
		for (var x in c.data.list) {
			c.data.list[x].selected = false;
		}
		$rootScope.$broadcast(eventName, parms);
		if (config.page_id) {
			var s = {id: config.page_id, table: parms.table, sys_id: parms.sys_id, view: $scope.data.view};
			var newURL = $location.search(s);
			spAriaFocusManager.navigateToLink(newURL.url());
		}
	});
}

The function accepts four arguments, sys_id, eventName, config, and item. The sys_id is the sys_id of the record, either the related record or the record of the active row. The eventName is one of the following:

referenceClick: 'data_table.referenceClick',
aggregateClick: 'data_table.aggregateClick',
buttonClick: 'data_table.buttonClick',
bulkAction: 'data_table.bulkAction'

The config is the configuration object for the reference, aggregate, button, icon, or bulk action, and the item is either the record for the current row, the related record, or in the case of bulk actions, the list of selected records. The function extracts certain elements from the passed arguments, broadcasts the message, and then if there is a portal page specified, branches to that page with URL parameters extracted from the data passed. This will now occur in the same manner, using the same technique for all four of the added features.

To invoke this function, I created four new functions, one for each of the four features. These functions establish the arguments in accordance with the needs of each feature. Here are the four new functions:

$scope.referenceClick = function(field, item) {
	var config = {};
	config.table = item[field].table;
	config.page_id = c.data.refmap[config.table] || 'form';
	var sys_id = item[field].value;
	processClick(sys_id, eventNames.referenceClick, config, item[field].record);
};

$scope.aggregateClick = function(aggregate, item) {
	var config = {};
	for (var a in c.data.aggarray) {
		if (c.data.aggarray[a].name == aggregate) {
			config = c.data.aggarray[a];
		}
	}
	var sys_id = item.sys_id;
	if (config.source && item[config.source].value) {
		sys_id = item[config.source].value;
	}
	processClick(sys_id, eventNames.aggregateClick, config, item);
};

$scope.buttonClick = function(button, item) {
	var config = {};
	for (var b in c.data.btnarray) {
		if (c.data.btnarray[b].name == button) {
			config = c.data.btnarray[b];
		}
	}
	processClick(item.sys_id, eventNames.buttonClick, config, item);
};

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

In addition to wanting to consistently handle the various actions, I also wanted to run out and get the latest copy of the original Data Table widget to make sure that I had not missed any useful updates since I initially cloned the widget. At the same time, I wanted to start fresh with the latest copy and then surgically insert the enhancements, disturbing the original code as little as possible, and marking my additions with comments to clearly delineate the new code from the original. Here is how that turned out with the HTML template:

<div class="panel panel-{{options.color}} b" ng-class="{'data-table-high-contrast': accessibilityModeEnabled}">
    <div class="panel-heading form-inline" ng-hide="options.hide_header">
      <span class="dropdown m-r-xs">
        <button aria-label="{{data.title || data.table_plural}} ${Context Menu}" class="btn dropdown-toggle glyphicon glyphicon-menu-hamburger" style="line-height: 1.4em" id="optionsMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button>
        <ul class="dropdown-menu" aria-labelledby="optionsMenu">
          <li ng-repeat="t in ::exportTypes">
            <a ng-if="!tinyUrlEnabled" ng-href="/{{data.table}}_list.do?{{::t.value}}&sysparm_query={{data.exportQueryEncoded}}&sysparm_view={{data.view}}&sysparm_fields={{data.fields}}" target="_new" tabindex="-1">${Export as} {{::t.label}}</a>
            <a ng-if="tinyUrlEnabled" ng-href="/{{data.table}}_list.do?{{::t.value}}&sysparm_tiny={{tinyUrl}}" target="_new" tabindex="-1">${Export as} {{::t.label}}</a>
          </li>
        </ul>
      </span>
      <h2 class="panel-title" style="display:inline"><i ng-if="options.glyph" class="fa fa-{{options.glyph}} m-r"></i>{{data.title || data.table_plural}}<span class="sr-only">${table} - ${page} {{data.p}}</span></h2>
      <button name="new" role="button" class="btn btn-primary btn-sm m-l-xs" ng-click="newRecord()" ng-if="options.show_new && data.canCreate && !data.newButtonUnsupported" aria-label="${Create new record}">${New}</button>
      <div class="pull-right" ng-if="options.show_keywords">
		<form ng-if="data.hasTextIndex" ng-submit="setSearch(true)">
        <div class="input-group" role="presentation">
          <input type="text" name="datatable-search" ng-model="data.keywords" ng-model-options="{debounce:250}" class="form-control" placeholder="${Keyword Search}" aria-label="${Keyword Search}">
          <span class="input-group-btn">
            <button name="search" class="btn btn-default" type="submit" aria-label="${Search}"><span class="glyphicon glyphicon-search"></span></button>
          </span>
        </div>
        </form>
      </div>
      <div class="clearfix"></div>
    </div>
    <!-- body -->
    <div class="panel-body">
      <div ng-if="options.show_breadcrumbs && (data.filter || data.enable_filter)" class="filter-breadcrumbs">
	    		<sp-widget widget="data.filterBreadcrumbs"></sp-widget>
      </div>
      <div class="clearfix"></div>
      <div class="alert alert-info" ng-if="!data.list.length && !data.num_pages && !data.invalid_table && !loadingData">
        ${No records in {{data.table_label}} <span ng-if="data.filter">using that filter</span>}
      </div>
      <div class="alert alert-info" ng-if="loadingData">
          <fa name="spinner" spin="true"></fa> ${Loading data}...
       </div>
      <table class="table table-striped table-responsive" ng-if="data.list.length">
        <caption class="sr-only">{{data.title || data.table_plural}}</caption>
        <thead>
          <tr>
<!-- Start: SNH Data Table enhancements -->
            <th ng-if="data.actarray.length > 0" class="text-nowrap center" tabindex="0">
              <input type="checkbox" ng-model="data.master_checkbox" ng-click="masterCheckBoxClick();"/>
            </th>
<!-- End: SNH Data Table enhancements -->
            <th ng-repeat="field in data.fields_array track by $index" class="text-nowrap" ng-click="setOrderBy(field)"
             scope="col" role="columnheader" aria-sort="{{field == data.o ? (data.d == 'asc'? 'ascending': 'descending') : 'none'}}">
              <div class="th-title" title="${Sort by} {{field == data.o ? (data.d == 'asc' ?  '${Descending}': '${Ascending}') : '${Ascending}'}}" role="button" tabindex="0" aria-label="{{data.column_labels[field]}}">{{data.column_labels[field]}}</div>
              <i class="fa" ng-if="field == data.o" ng-class="{'asc': 'fa-chevron-up', 'desc': 'fa-chevron-down'}[data.d]"></i>
            </th>
<!-- Start: SNH Data Table enhancements -->
            <th ng-repeat="aggregate in data.aggarray" class="text-nowrap center" tabindex="0">
              {{aggregate.heading || aggregate.label}}
            </th>
            <th ng-repeat="button in data.btnarray" class="text-nowrap center" tabindex="0">
              {{button.heading || button.label}}
            </th>
<!-- End: SNH Data Table enhancements -->
          </tr>
        </thead>
        <tbody>
        <tr ng-repeat="item in data.list track by item.sys_id">
<!-- Start: SNH Data Table enhancements -->
           <td ng-if="data.actarray.length > 0" role="cell" class="text-nowrap center" tabindex="0">
              <input type="checkbox" ng-model="item.selected"/>
            </td>
            <td role="{{$first ? 'rowheader' : 'cell'}}" class="sp-list-cell" ng-class="{selected: item.selected}" ng-repeat="field in ::data.fields_array" data-field="{{::field}}" data-th="{{::data.column_labels[field]}}">
              <sn-avatar ng-if="item[field].value && item[field].type == 'reference' && item[field].table == 'sys_user'" primary="item[field].value" class="avatar-small" show-presence="true" enable-context-menu="false"></sn-avatar>
              <a ng-if="$first" href="javascript:void(0)" ng-click="go(item.targetTable, item)" aria-label="${Open record}: {{::item[field].display_value}}">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</a>
              <a ng-if="!$first && item[field].type == 'reference' && item[field].value" href="javascript:void(0)" ng-click="referenceClick(field, item)" aria-label="${Click for more on }{{::item[field].display_value}}">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</a>
              <span ng-if="!$first && item[field].type != 'reference'">{{::item[field].display_value | limitTo : item[field].limit}}{{::item[field].display_value.length > item[field].limit ? '...' : ''}}</span>
            </td>
            <td ng-repeat="obj in item.aggValue" role="cell" class="text-right" ng-class="{selected: item.selected}" tabindex="0">
              <a ng-if="obj.name" href="javascript:void(0)" ng-click="aggregateClick(obj.name, item)">{{obj.value}}</a>
              <span ng-if="!obj.name">{{obj.value}}</span>
            </td>
            <td ng-repeat="button in data.btnarray" role="cell" class="text-nowrap center" ng-class="{selected: item.selected}" tabindex="0">
              <a ng-if="!button.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonClick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">{{button.label}}</a>
              <a ng-if="button.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{button.color || 'default'}}" ng-click="buttonClick(button.name, item)" title="{{button.hint}}" data-original-title="{{button.hint}}">
                <span class="icon icon-{{button.icon}}" aria-hidden="true"></span>
                <span class="sr-only">{{button.hint}}</span>
              </a>
            </td>
<!-- End: SNH Data Table enhancements -->
          </tr>
        </tbody>
      </table>
      <div ng-class="{'pruned-msg-filter-pad': (!options.show_breadcrumbs || !data.filter) && !data.list.length}" class="pruned-msg" ng-if="rowsWerePruned()">
        <span ng-if="rowsPruned == 1">${{{rowsPruned}} row removed by security constraints}</span>
        <span ng-if="rowsPruned > 1">${{{rowsPruned}} rows removed by security constraints}</span>
      </div>
    </div>
    <!-- footer -->
 <!-- Start: SNH Data Table enhancements -->
    <div class="panel-footer" ng-if="data.actarray.length > 0 && data.row_count">
      <div class="btn-toolbar m-r pull-left">
        <select class="form-control" ng-model="data.bulk_action" ng-click="bulkActionSelected();">
          <option value="">${Actions on selected rows ...}</option>
          <option ng-repeat="opt in data.actarray" value="{{opt.name}}">{{opt.label}}</option>
        </select>
      </div>
      <span class="clearfix"></span>
    </div>
<!-- End: SNH Data Table enhancements -->
    <div class="panel-footer" ng-hide="options.hide_footer" ng-if="data.row_count" role="navigation" aria-label="${Pagination}">
      <div class="btn-toolbar m-r pull-left">
        <div class="btn-group">
          <a href="javascript:void(0)" ng-click="setPageNum(data.p - 1)" ng-class="{'disabled': data.p == 1}" class="btn btn-default" aria-label="${Previous page} {{data.p == 1 ? '${disabled}' : ''}}" tabindex="{{(data.p == 1) ? -1 : 0}}"><i class="fa fa-chevron-left"></i></a>
        </div>
        <div ng-if="data.num_pages > 1 && data.num_pages < 20" class="btn-group">
          <a ng-repeat="i in getNumber(data.num_pages) track by $index" ng-click="setPageNum($index + 1)" href="javascript:void(0)" ng-class="{active: ($index + 1) == data.p}" type="button" class="btn btn-default" aria-label="${Page} {{$index + 1}}" ng-attr-aria-current="{{($index + 1) == data.p ? 'page' : undefined}}">{{$index + 1}}</a>
        </div>
        <div class="btn-group">
          <a href="javascript:void(0)" ng-click="setPageNum(data.p + 1)" ng-class="{'disabled': data.p == data.num_pages}" class="btn btn-default" aria-label="${Next page} {{data.p == data.num_pages ? '${disabled}' : ''}}" tabindex="{{(data.p == data.num_pages) ? -1 : 0}}"><i class="fa fa-chevron-right"></i></a>
        </div>
      </div>
      <div class="m-t-xs panel-title">${Rows {{data.window_start + 1}} - {{ mathMin(data.window_end,data.row_count) }} of {{data.row_count}}}</div>

      <span class="clearfix"></span>
    </div>
  </div>

All in all, I had to insert code in four different places, twice in the table header, once for the table rows, and once for the bulk action drop-down in the table footer. Everything else is just as it was in the original HTML template from the latest version of the widget. This I like much better than the way that I had it before, and each block of code that I inserted is clearly marked with a Start and an End comment. I took the same approach with the server script and the client script, attempting wherever possible to leave as much as the original work in place and marking those sections where I inserted additions.

Now that that is done, I need to go back into the wrapper widgets and basically do the same thing after I remove all of the code that is now being handled in the core widget. Once that is done, there is still one more thing that I would like to do before releasing another Update Set, but I need to finish up those wrapper widgets first before we jump into that.

Aggregate List Columns, Part X

“You’re always trying to get better. You’re always tinkering. You’re always learning new things.”
Kyle Korver

The previous installment in this series was supposed to be the last, as I wanted to get back to the Collaboration Store project that was just left hanging after the call for outside testing assistance. But then I decided to post the Update Set out on Share, and now that it is out there, it really disturbs my sense of The Way Things Ought To Be that you cannot click on an aggregate column and pull up a list of the records represented in that number. It doesn’t seem like it would take much to add that feature, and I am not sure why I never included it in the first place, but now that it is out there for all of the world to see, I feel as if I need to correct that omission. So here we go.

There are a couple of different ways to show a list of the records. One would be to simply link to a new page passing the sys_id of the row and the name of the table. Another would be some kind of modal pop-up containing a simple list or data table containing the records. To provide for all of these possibilities, we could add yet one more property to the configuration object for an aggregate column that we could call action. The value of the property could either be a URL query string for the new page, or the word broadcast, which would send out a broadcast message on click so that a companion widget could handle the modal dialog independently, outside the scope of the data table widget. We already have examples of this with the buttons and icons, and this would embrace that same strategy.

The easiest place to start with such a strategy would be with the Content Selector Configuration Editor, adding the new property to the table of aggregate column specifications as well as the pop-up aggregate column specification editor. Let’s start with the main widget and add the following to the list of column headings for that section:

<th style="text-align: center;">${Action}</th>

And then in the repeating rows of that table, let’s insert this line:

<td data-th="${Action}">{{agg.action}}</td>

On the server side, in the Save() function that rebuilds the script, let’s add these lines in the aggregate column specification section:

 script += "',\n                 action: '";
 script += thisAggregate.action;

Finally, on the client side, let’s add the following line to the new record section of the editAggregate() function:

shared.action = aggregate.action;

… and this line to the section that saves the edits in that same function:

aggregate.action = shared.action || '';

That takes care of the main widget, but we also need to add a new input field to the pop-up editor before this will actually work, so we need to add these lines to the HTML of the Aggregate Column Editor widget:

<snh-form-field
  snh-model="c.widget.options.shared.action"
  snh-name="action"/>

That updates the editor to now include a new aggregate column specification property called action. Of course, that doesn’t actually add any functionality to the Data Table widgets just yet, but at least now we can add a value to that property through the editor, which is a start. Now let’s take a look at the actual Data Table widgets and see what we need to do in order to leverage that new property.

Once again, the easiest place to start is with the HTML. Here is the current section of the HTML that deals with aggregate columns:

<td ng-repeat="obj in item.aggValue" class="text-right" ng-class="{selected: item.selected}" tabindex="0">
  {{obj.value}}
</td>

Let’s replace that with this:

<td ng-repeat="obj in item.aggValue" class="text-right" ng-class="{selected: item.selected}" tabindex="0">
  <a ng-if="obj.action" href="javascript:void(0)" ng-click="aggregateclick(obj.name, item)">{{obj.value}}</a>
  <span ng-if="!obj.action">{{obj.value}}</span>
</td>

Our new code now contains two mutually exclusive options, one an anchor and the other a span, which are rendered depending on whether or not there is a value in the new action property. The anchor tag does not directly launch the new page, which is only one of the possible responses to an action, but rather invokes a client-side script, passing the name of the aggregate column and the object containing the data for the row. Here is the code for this new client-side function:

$scope.aggregateclick = function(aggregate, item) {
	spNavStateManager.onRecordChange(c.data.table).then(function() {
		var parms = {};
		parms.table = c.data.table;
		parms.sys_id = item.sys_id;
		parms.record = item;
		for (var a in c.data.aggarray) {
			if (c.data.aggarray[a].name == aggregate) {
				parms.aggregate = c.data.aggarray[a];
			}
		}
		$scope.ignoreLocationChange = true;
		for (var x in c.data.list) {
			c.data.list[x].selected = false;
		}
		item.selected = true;
		$scope.$emit(eventNames.aggregateClick, parms);
		if (parms.aggregate.action && parms.aggregate.action != 'broadcast') {
			if (!parms.aggregate.action.startsWith('?')) {
				parms.aggregate.action = '?' + parms.aggregate.action;
			}
			if (parms.aggregate.action.indexOf('{{sys_id}}')) {
				parms.aggregate.action = parms.aggregate.action.replace('{{sys_id}}', parms.sys_id);
			}
			window.location.search = parms.aggregate.action;
		}
	}, function() {
		// do nothing in case of closing the modal by clicking on x
	});	
};

This function is basically a copy of the existing buttonclick function, adapted for use in the aggregate column section. In all cases where an action has been specified, the click information is broadcast to all of the other widgets on the page. Additionally, if the action is not the word broadcast, it is assumed to be a URL search string, and the window.location.search property is updated with the resolved value, invoking the new portal page.

The last thing that we need to do is to add a couple of new properties to the object returned by the getAggregateValue function on the server side. To support the new HTML, we just need to change this:

return {value: value};

… to this:

return {value: value, name: config.name, action: config.action};

That should do it for the changes. Now all we have to do is to build some test cases for both the new page and the pop-up dialog options to see if this all works. That sounds like a good project for our next installment.

SNH Data Table Widgets on Share

“The first time you do a thing is always exciting.”
Agatha Christie

I’ve never used Share before, but after completing the work on the Aggregate List Columns and bundling that work up with all of the other related projects and artifacts, I decided that I would go ahead and post the whole thing out there. I have always hesitated to throw stuff from here out there, mainly because most of the things that you will find on this site are not very well documented, at least not from the user’s perspective. But, I have considered doing it anyway on a number of occasions. I was pretty close to sharing the My Delegates Widget until I discovered that someone else had already beat me to it. I also thought about tossing out a number of other items such as the Dynamic Service Portal Breadcrumbs and the Service Portal Widget Help, but like quite a number of other things, those were just thoughts that never turned into any kind of action. This time, though, quite a number of things were all bundled together into a single Update Set, and I thought that maybe there just might be a thing or two buried in there somewhere that someone somewhere might find to be of value. We’ll see.

My other hesitation to posting this on Share was the fact that these are all Service Portal components, and ServiceNow has made it pretty clear that they would like to see folks abandon the Service Portal in favor of their latest approach to application development. While it may be true that the Service Portal is on the way out, it has been my experience that such transitions usually take some time to be fully realized, so there still may be an active Service Portal or two floating around out there for a while. Still, everyone always likes to jump on the new stuff, so the interest in Service Portal components is something that is bound to start dropping off over time. On the other hand, that actually serves as an argument for shoving it out there now, as waiting around would just mean even less relevance to the environment of the future.

Anyway, it’s done now. Share obviously has a much broader reach than this little blog, so it will be interesting to see if anyone happens to come across it out there with all of the other artifacts on the site. I did take a quick peek this morning, and it does look like a couple of brave souls have already hit the download button, but I don’t see any feedback posted as yet. That will probably take a little more time. Who knows; if it all works out, maybe one day I will throw something else out there. Only time will tell.

Aggregate List Columns, Part IX

“You have to finish things — that’s what you learn from, you learn by finishing things.”
Neil Gaiman

Last time, we attempted to wrap this whole thing up with the remaining modifications to the Content Selector Configuration Editor, but we ran into a problem with the code that we borrowed from the sn-record-picker Helper. Now that we have taken a quick detour to resolve that issue, we need to get back to our new pop-up aggregate column editor and apply the same fix to our pilfered code. As with the sn-record-picker Helper, we need to add some code to the server side to leverage the TableUtils object to get our list of tables in the extension hierarchy. Here is the new Server script, with basically the same function as the one that we added to the sn-record-picker Helper, with a few minor modifications due to the difference in our variable names.

(function() {
	if (input && input.tableName) {
		var tu = new TableUtils(input.tableName);
		var tableList = tu.getTables();
		data.tableList = j2js(tableList);
	}
})();

With that now in place, we can update the client-side buildFieldFilter function to call the server side to get the list, and then use that list to build the new filter.

$scope.buildFieldFilter = function() {
	c.data.fieldFilter = 'name=' + c.widget.options.shared.table.value;
	c.data.tableName = c.widget.options.shared.table.value;
	c.server.update().then(function(response) {
		if (response.tableList && Array.isArray(response.tableList) && response.tableList.length > 1) {
			c.data.fieldFilter = 'nameIN' + response.tableList.join(',');
		}
		c.data.fieldFilter += '^elementISNOTEMPTY^internal_type=reference';
	});
};

Now we just need to pop up the editor again and see if that actually fixes the problem that we were having earlier.

Pop-up aggregate column spec editor with field pick list corrected

That’s better! Now when I search for a field on the Incident table, I get to select from all of the fields on the table, not just the ones attached to the primary table. That’s what we wanted to see.

That takes care of the pop-up aggregate column specification editor that we cloned from the existing button/icon specification editor, so now all that is left for us to do is to tweak the code that actually saves the changes once all of the editing has been completed. The Save process actually rebuilds the entire script stored in the Script Include record, so we just need to add some code to create the aggregate column section for each table definition. Once again, we can leverage the existing buttons and icons code as a starting point, and then make the necessary changes to adapt it to use for aggregate columns. Here are the relevant lines from the Save() function in the widget’s Server script:

script += "',\n				btnarray: [";
var lastSeparator = '';
for (var b=0; b<tableTable[tableState.name].btnarray.length; b++) {
	var thisButton = tableTable[tableState.name].btnarray[b];
	script += lastSeparator;
	script += "{\n					name: '";
	script += thisButton.name;
	script += "',\n					label: '";
	script += thisButton.label;
	script += "',\n					heading: '";
	script += thisButton.heading;
	script += "',\n					icon: '";
	script += thisButton.icon;
	script += "',\n					color: '";
	script += thisButton.color;
	script += "',\n					hint: '";
	script += thisButton.hint;
	script += "',\n					page_id: '";
	script += thisButton.page_id;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

As we have done a number of times now, we can make a few global text replacements and alter a few variable names to align with our needs and come up with something that will work for aggregate columns specifications in very much the same way that it is currently working for buttons and icons.

script += "',\n				aggarray: [";
var lastSeparator = '';
for (var g=0; g<tableTable[tableState.name].aggarray.length; g++) {
	var thisAggregate = tableTable[tableState.name].aggarray[g];
	script += lastSeparator;
	script += "{\n					name: '";
	script += thisAggregate.name;
	script += "',\n					label: '";
	script += thisAggregate.label;
	script += "',\n					heading: '";
	script += thisAggregate.heading;
	script += "',\n					table: '";
	script += thisAggregate.table;
	script += "',\n					field: '";
	script += thisAggregate.field;
	script += "',\n					filter: '";
	script += thisAggregate.filter;
	script += "',\n					source: '";
	script += thisAggregate.source;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

And that’s all there is to that. That should be everything now, so all that is left to do is to bundle all of this into an Update Set so that folks can play along at home. This effort has been primarily focused on the components of the Customizing the Data Table Widget project, but it has also involved elements of the Configurable Data Table Widget Content Selector series as well as the Content Selector Configuration Editor. Additionally, many of the Service Portal pages that use these components, such as the Service Portal User Directory, also include the Dynamic Service Portal Breadcrumbs widget. Rather than create a new version for each and every one of these interrelated project, I think I will just lump everything together into a single Update Set and call it version 2.0 of the Customizing the Data Table Widget project. Since many of the widgets involved also utilize the Service Portal Form Fields, that will get pulled into the Update Set as well, and just for good measure, I think I will toss in the sn-record-picker Helper, too. That one is not actually directly related to all of the others, but we did steal some code from there, so there might be a few folks who may want to take a look at that one. You can download the whole lot from here. As always, if you have any comments, questions, concerns, or issues, please leave a comment below. All feedback is always welcome. And if you have made it this far, thanks for following along all the way to the end!

But wait … there’s more!

sn-record-picker Helper, Corrected (again!)

“The biggest room in the world is the room for improvement.”
Helmut Schmidt

The other day I was working on adding Aggregate List Columns to my SNH Data Table collection and I lifted some code from the sn-record-picker Helper that looked like it might be fairly close to what I needed at the time. Unfortunately, when I tested it all out, I came to realize that the code that I lifted contained an error, and a pretty serious one at that. The problem is with the filter used on all of the table field drop-downs, which is supposed to include all of fields on the selected table. As it turns out, it includes all of the fields on the primary table, but does not include all of the other fields from any other table in the table hierarchy. That is definitely not good!

Tables can be extended from other tables, and in cases such as the CMDB, there can be quite a number of tables in the extension hierarchy. For example, the cmdb_ci_win_server table extends the cmdb_ci_server table, which extends the cmdb_ci_computer table, which extends the cmdb_ci_hardware table, which extends the cmdb_ci table, which extends the cmdb table. That’s a lot of missing fields if you only include the fields from the primary table. Clearly, this needs to be addressed, and should have been addressed long ago.

In digging through the code, the problem appears to be in this function that creates the filter for all of the field pickers:

$scope.buildFieldFilter = function() {
	c.data.ready = false;
	c.data.fieldFilter = 'elementISNOTEMPTY^name=' + c.data.table.value;
};

The current filter that is built turns out to be:

name=(name of selected table)

… when what it really needs to be is:

nameIN(list of all tables in the heirarchy)

That is simple enough to do, but then, the question becomes, where do we get a list of all of the tables in the hierarchy? As usual, the Now Platform has anticipated that someone might need that, and includes a way to get that very information. The solution to our query is the TableUtils object. That object includes a method called getTables() that will return just such a list. Unfortunately for us, this object is only available on the server side, and up until now, our widget did not need to have any server-side code. Well, things change.

Here is how we can use this object to get a Javascript Array of table names:

var tu = new TableUtils(input.table.value);
var tableList = tu.getTables();
data.tableList = j2js(tableList);

The object returned by the getTables() method is actually a Java object, so we have to use the j2js (Java to Javascript) function to turn it into an Array that we can use. Here is the entire new widget Server script with this functionality included:

(function() {
	if (input && input.table.value) {
		var tu = new TableUtils(input.table.value);
		var tableList = tu.getTables();
		data.tableList = j2js(tableList);
	}
})();

With that now available on the server side, we need to go into our client-side function and have it call the server to get the list, and then use that list to rebuild the filter in a way that will include all of the fields on all of the tables involved. Here is the new buildFieldFilter function:

$scope.buildFieldFilter = function() {
	c.data.ready = false;
	c.data.fieldFilter = 'name=' + c.data.table.value;
	c.server.update().then(function(response) {
		if (response.tableList && Array.isArray(response.tableList) && response.tableList.length > 1) {
			c.data.fieldFilter = 'nameIN' + response.tableList.join(',');
		}
		c.data.fieldFilter += '^elementISNOTEMPTY';
	});
};

If there is not more than one table involved, then we can leave the filter with the straight equals condition, but if there is more than one table in the returned list, then we overlay that with the IN condition instead. Either way, we tack on the rest of the original filter, and we should be good to go.

Well, that’s all there is to that. Now I just need to pull all of the parts together for a new Update Set, and we should be good until we come across the next previously undetected problem!

Aggregate List Columns, Part VIII

“We must accept responsibility for a problem before we can solve it. We cannot solve a problem by saying ‘It’s not my problem.’ We cannot solve a problem by hoping that someone else will solve it for us. I can solve a problem only when I say ‘This is my problem and it’s up to me to solve it.'”
M. Scott Peck

Last time, we completed the required modifications to the Configurable Data Table Widget Content Selector and started to tackle changes to the Content Selector Configuration Editor. Now we need to create a new Service Portal widget to handle the editing of new and existing aggregate column specifications. To begin, I cloned the existing Button/Icon Editor widget, and then dug into the HTML to see what it was that we had to work with.

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="the_name"
      snh-label="Name"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.heading"
      snh-name="heading"/>
    <snh-form-field
      snh-model="c.widget.options.shared.icon"
      snh-name="icon"
      snh-type="icon"/>
    <snh-form-field
      snh-model="c.widget.options.shared.color"
      snh-name="color"
      snh-type="select"
      snh-choices='[{"label":"default","value":"default"},{"label":"primary","value":"primary"},{"label":"secondary","value":"secondary"},{"label":"success","value":"success"},{"label":"danger","value":"danger"},{"label":"warning","value":"warning"},{"label":"info","value":"info"},{"label":"light","value":"light"},{"label":"dark","value":"dark"},{"label":"muted","value":"muted"},{"label":"white","value":"white"}]'/>
    <snh-form-field
      snh-model="c.widget.options.shared.hint"
      snh-name="hint"/>
    <snh-form-field
      snh-type="reference"
      snh-model="c.widget.options.shared.page_id"
      snh-name="page_id"
      placeholder="Choose a Portal Page"
      table="'sp_page'"
      display-field="'title'"
      display-fields="'id'"
      value-field="'id'"
      search-fields="'id,title'"/>
    <div id="element.example" class="form-group">
      <div id="label.example" class="snh-label" nowrap="true">
        <label for="example" class="col-xs-12 col-md-4 col-lg-6 control-label">
          <span id="status.example"></span>
          <span class="text-primary" title="Example" data-original-title="Example">Example</span>
        </label>
      </div>
      <span class="input-group">
        <a ng-if="!c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">{{c.widget.options.shared.label}}</a>
        <a ng-if="c.widget.options.shared.icon" href="javascript:void(0)" role="button" class="btn-ref btn btn-{{c.widget.options.shared.color || 'default'}}" title="{{c.widget.options.shared.hint}}" data-original-title="{{c.widget.options.shared.hint}}">
          <span class="icon icon-{{c.widget.options.shared.icon}}" aria-hidden="true"></span>
          <span class="sr-only">{{c.widget.options.shared.hint}}</span>
        </a>
      </span>
    </div>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

Basically, it is just a DIV full of SNH Form Fields for all of the properties of a Button/Icon configuration. Since many of the properties of an Aggregate Column specification are the same as those of a Button/Icon configuration, we only need to make the changes for those that are different. Here is what I came up with for my initial version:

<div>
  <form name="form1">
    <snh-form-field
      snh-model="c.widget.options.shared.label"
      snh-name="label"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.name"
      snh-name="the_name"
      snh-label="Name"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.heading"
      snh-name="heading"/>
    <snh-form-field
      snh-model="c.widget.options.shared.table"
      snh-name="table"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.field"
      snh-name="field"
      snh-required="true"/>
    <snh-form-field
      snh-model="c.widget.options.shared.filter"
      snh-name="filter"/>
    <snh-form-field
      snh-model="c.widget.options.shared.source"
      snh-name="source"/>
  </form>
  <div style="width: 100%; padding: 5px 50px; text-align: right;">
    <button ng-click="cancel()" class="btn btn-default ng-binding ng-scope" role="button" title="Click here to cancel this edit">Cancel</button>
    &nbsp;
    <button ng-click="save()" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to save your changes">Save</button>
  </div>
</div>

In looking through the rest of the code in the widget, it does not appear that there are any other changes necessary to make this work, so the next thing to do would be to fire up the modified Content Selector Configuration Editor and see how things look.

New pop-up editor for aggregate columns

We can make sure that it actually works by adding an ‘x’ to each value on the form and clicking on the Save button. That should update the values displayed on the main screen.

Modified aggregate column values after saving the edits

The reason that all seems to work, even though the only changes that we made were to the HTML, is that the main widget and the pop-up editor widget share a common object called, appropriately enough, shared. Clicking on the Save button simply blows away the pop-up widget and returns control to the parent widget, where the values entered on the pop-up form are still available in the shared object, even though the pop-up widget is now gone. The new editAggregate function in the parent widget that we copied from the existing editButton function creates and populates this shared object, and then when the pop-up widget closes, uses the values in the shared object to update the edited row.

$scope.editAggregate = function(aggregate, aggArray) {
	var shared = {};
	if (aggregate != 'new') {
		shared.label = aggregate.label;
		shared.name = aggregate.name;
		shared.heading = aggregate.heading;
		shared.table = aggregate.table;
		shared.field = aggregate.field;
		shared.filter = aggregate.filter;
		shared.source = aggregate.source;
	}
	spModal.open({
		title: 'Aggregate Column Editor',
		widget: 'aggregate-column-editor',
		shared: shared
	}).then(function() {
		if (aggregate == 'new') {
			aggregate = {};
			aggArray.push(aggregate);
		}
		aggregate.label = shared.label || '';
		aggregate.name = shared.name || '';
		aggregate.heading = shared.heading || '';
		aggregate.table = shared.table || '';
		aggregate.field = shared.field || '';
		aggregate.filter = shared.filter || '';
		aggregate.source = shared.source || '';
	});
};

At this point, this would all be good enough to get the job done, but for the table and field properties, it would be nice to have a pick list of tables and a pick list of reference fields present on that table rather than just open entry fields. We should be able to do that with a couple of sn-record-pickers, so I could fire up our old friend, the sn-record-picker Helper and configure them, but we can do even better than that. The sn-record-picker Helper is actually built with a bunch of of sn-record-pickers, and it already contains one for a list of tables and several for lists of fields dependent on the table selected. We should be able to just steal most of that in its entirety to get what we want. Here is the SNH Form Field for the table picker:

<snh-form-field
  snh-model="c.data.table"
  snh-name="table"
  snh-type="reference"
  snh-help="Select the ServiceNow database table that will contain the options from which the user will select their choice or choices."
  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>

With just a few simple modifications, we can adapt this to our needs:

<snh-form-field
  snh-model="c.widget.options.shared.table"
  snh-name="table"
  snh-type="reference"
  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'"/>

All I did here was to change the model to our model, drop the help (it would be helpful, but would make the pop-form way too long if all of the fields included help text), and dropped the closing tag in favor of a closing / at the end of the main tag. Easy peasy. Of course, we have now referenced a buildFieldFilter() function that doesn’t exist, but again, we should be able to just steal that from the sn-record-picker Helper widget. Here it is:

$scope.buildFieldFilter = function() {
	c.data.ready = false;
	c.data.fieldFilter = 'elementISNOTEMPTY^name=' + c.data.table.value;
};

We don’t need the c.data.ready indicator for our use case, so we can just snag the whole thing, delete that line, and update the table variable name to match the one that we are using in our widget. Again, pretty simple stuff.

Now we just need to do the same for the field picker. There are several of these in the sn-record-picker Helper widget, but a few them allow multiple selections, so we will want to pick one that is just a single select, such as the Primary Display Field.

<snh-form-field
  snh-model="c.data.displayField"
  snh-name="displayField"
  snh-label="Primary Display Field"
  snh-type="reference"
  snh-help="Select the primary display field."
  snh-required="true"
  snh-change="c.data.ready=false"
  placeholder="Choose a Field"
  table="'sys_dictionary'"
  display-field="'column_label'"
  display-fields="'element'"
  value-field="'element'"
  search-fields="'column_label,element'"
  default-query="c.data.fieldFilter">
</snh-form-field>

Here is my adaptation of that tag for our widget.

<snh-form-field
  snh-model="c.widget.options.shared.field"
  snh-name="field"
  snh-type="reference"
  snh-required="true"
  placeholder="Choose a Field"
  table="'sys_dictionary'"
  display-field="'column_label'"
  display-fields="'element,reference'"
  value-field="'element'"
  search-fields="'column_label,element'"
  default-query="c.data.fieldFilter"/>

That should take care of that. Let’s have a look and see what we have done.

New data pickers for the Table and Field fields

Well, that is really disturbing. Everything is working as intended as far as the relationship between the pickers is concerned, but when I went to select assigned_to from the drop-down, it was not on the list. It appears as if the fields on the list are limited to just those fields on the primary table, and the list does not include any of the fields for any of the tables from which that primary table may have been extended. This is particularly distressing news, as that means that the sn-record-picker Helper widget, from which we lifted this code, has had this issue all along. Not good! We are going to need to stop and fix that right away, and then we can revisit this guy next time out.

Aggregate List Columns, Part VII

“The most effective way to do it is to do it.”
Amelia Earhart

Last time, we completed the modifications to the last of the wrapper widgets, so now it is time to tackle the Content Selector Configuration Editor. Before we do that, though, we need to make one small addition to the Configurable Data Table Widget Content Selector itself. This widget builds the URL for the SNH Data Table from URL Definition widget, and so we need to add support for aggregate columns to the process that constructs that URL. I found these lines in the Client controller related to the buttons and icons:

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

So I just made a copy of those lines and then modified the copy to work with aggregates instead of buttons.

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

That was another simple and easy fix. Now onto the editor, which I am sure will be quite a bit more involved. Starting with the easy part, which I always like to do, we can take a look at the HTML and hunt for a section related to the buttons and icons, and then copy it to use as a base for creating a similar section for aggregate columns. Here is the relevant code:

<div id="label.btnarray" class="snh-label" nowrap="true">
  <label for="btnarray" class="col-xs-12 col-md-4 col-lg-6 control-label">
    <span id="status.btnarray"></span>
    <span class="text-primary" title="Buttons/Icons" data-original-title="Buttons/Icons">${Buttons/Icons}</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;">${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>
<div style="width: 100%; text-align: right;">
  <button ng-click="editButton('new', tbl[state.name].btnarray, tbl);" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new Button/Icon">Add a new Button/Icon</button>
</div>

Copying that code, making a few text replacements here and there, and then adjusting some of the columns to meet our needs resulted in the following new section of HTML, inserted above the buttons and icons section:

<div id="label.aggarray" class="snh-label" nowrap="true">
  <label for="aggarray" class="col-xs-12 col-md-4 col-lg-6 control-label">
    <span id="status.aggarray"></span>
    <span class="text-primary" title="Aggregate Columns" data-original-title="Aggregate Columns">${Aggregate Columns}</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;">${Heading}</th>
      <th style="text-align: center;">${Table}</th>
      <th style="text-align: center;">${Field}</th>
      <th style="text-align: center;">${Filter}</th>
      <th style="text-align: center;">${Source}</th>
      <th style="text-align: center;">${Edit}</th>
      <th style="text-align: center;">${Delete}</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="agg in tbl[state.name].aggarray" ng-hide="agg.removed">
      <td data-th="${Label}">{{agg.label}}</td>
      <td data-th="${Name}">{{agg.name}}</td>
      <td data-th="${Heading}">{{agg.heading}}</td>
      <td data-th="${Icon Name}">{{agg.table}}</td>
      <td data-th="${Color}">{{agg.field}}</td>
      <td data-th="${Hint}">{{agg.filter}}</td>
      <td data-th="${Page}">{{agg.source}}</td>
      <td data-th="${Edit}" style="text-align: center;"><img src="/images/edittsk_tsk.gif" ng-click="editAggregate(agg)" alt="Click here to edit this Aggregate Column" title="Click here to edit this Aggregate Column" style="cursor: pointer;"/></td>
      <td data-th="${Delete}" style="text-align: center;"><img src="/images/delete_row.gif" ng-click="deleteAggregate(agg, tbl[state.name].aggarray)" alt="Click here to delete this Aggregate Column" title="Click here to delete this Aggregate Column" style="cursor: pointer;"/></td>
    </tr>
  </tbody>
</table>
<div style="width: 100%; text-align: right;">
  <button ng-click="editAggregate('new', tbl[state.name].aggarray, tbl);" class="btn btn-primary ng-binding ng-scope" role="button" title="Click here to add a new Aggregate Column">Add a new Aggregate Column</button>
</div>

Now we have referenced a few nonexistent functions at this point, but that should not prevent us from pulling up the page and seeing how it looks so far. We can’t really click on anything in the new aggregates section at the moment, but let’s just take a peek and see how it all renders on the screen.

Configuration file selection screen

Let’s select our second test configuration, since that one has a source property defined, and see how things look.

New HTML section for aggregate column definitions

So far, so good. Now we need to create those missing client-side functions referenced in the new HTML, which we should be able to do relatively easily by copying corresponding functions used for the buttons and icons. Let’s start with the Delete function, since that one should be the easiest. Here is the code to delete a button configuration:

$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);
		}
	});
};

… and here is the modified copy that we can use for the aggregates section:

$scope.deleteAggregate = function(aggregate, aggArray) {
	var confirmMsg = '<b>Delete Aggregate Column</b>';
	confirmMsg += '<br/>Are you sure you want to delete the ' + aggregate.label + ' Aggregate Column?';
	spModal.confirm(confirmMsg).then(function(confirmed) {
		if (confirmed) {
			var a = -1;
			for (var b=0; b<aggArray.length; b++) {
				if (aggArray[b].name == aggregate.name) {
					a = b;
				}
			}
			aggArray.splice(a, 1);
		}
	});
};

Next is the Edit function, which is a little more complicated. Once again, we can use the Edit function for the button configurations as a starting point.

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

As we did with the HTML, we can make a few text replacements here and there and then adjust some of the columns to come up with a suitable function for aggregate specifications.

$scope.editAggregate = function(aggregate, aggArray) {
	var shared = {};
	if (aggregate != 'new') {
		shared.label = aggregate.label;
		shared.name = aggregate.name;
		shared.heading = aggregate.heading;
		shared.table = aggregate.table;
		shared.field = aggregate.field;
		shared.filter = aggregate.filter;
		shared.source = aggregate.source;
	}
	spModal.open({
		title: 'Aggregate Column Editor',
		widget: 'aggregate-column-editor',
		shared: shared
	}).then(function() {
		if (aggregate == 'new') {
			aggregate = {};
			aggArray.push(aggregate);
		}
		aggregate.label = shared.label || '';
		aggregate.name = shared.name || '';
		aggregate.heading = shared.heading || '';
		aggregate.table = shared.table || '';
		aggregate.field = shared.field || '';
		aggregate.filter = shared.filter || '';
		aggregate.source = shared.source || '';
	});
};

That was relatively painless, but now we have code that references an entire widget that does not yet exist. Once again, we can create the missing widget by cloning the existing widget for the buttons and icons, but that seems like it might involve a little bit of work. Let’s jump into that in our next installment.

Aggregate List Columns, Part VI

“There are three qualities that make someone a true professional. These are the ability to work unsupervised, the ability to certify the completion of a job or task and, finally, the ability to behave with integrity at all times.”
Subroto Bagchi

Last time, we wrapped up the changes for the SNH Data Table from Instance Definition widget, and fixed a little issue with the core SNH Data Table widget in the process. Today, we need to do the same for the remaining wrapper widget, SNH Data Table from URL Definition. As with the previous widget, there isn’t that much code here that needs to be addressed, but I did come across these lines in the widget’s Server script:

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

It looks like I need to add the aggregates parameter to the list, so I threw that in and collapsed everything into a single line.

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

That was the only thing that I could find, so the next thing that I did was to clone the original test page once again and swap out the table widget for the one that I wanted to test. The problem with testing this one, though, is that all of the options that drive the process are URL parameters, and I did not want to type or paste in that huge URL every time I wanted to test. To eliminate that, I added an HTML widget above the table with a link that would contain the URL needed for testing. This way, all I needed to do to perform a test would be to click on that link.

Now I need to build a test URL using the same parameters that we used for the previous widget testing. Here is the list:

table: sys_user_group
filter: typeCONTAINS1cb8ab9bff500200158bffffffffff62
fields: name,manager
order_by: name
maximum_entries: 7
aggregates: [{ "label": "Members", "name": "members", "heading": "Members", "table": "sys_user_grmember", "field": "group", "filter": "user.active=true" },{ "label": "Incidents", "name": "incidents", "heading": "Incidents", "table": "incident", "field": "assignment_group", "filter": "active=true" },{ "label": "Catalog Tasks", "name": "sc_tasks", "heading": "Catalog Tasks", "table": "sc_task", "field": "assignment_group", "filter": "active=true" }]
refpage: {"sys_user": "user_profile"}

Using all of that to create a URL for the test page results in this lengthy string:

/sp?id=aggregate_test_4&table=sys_user_group&filter=typeCONTAINS1cb8ab9bff500200158bffffffffff62&fields=name,manager&order_by=name&maximum_entries=7&aggregates=[{"label":"Members","name":"members","heading":"Members","table":"sys_user_grmember","field":"group","filter":"user.active%3Dtrue"},{"label":"Incidents","name":"incidents","heading":"Incidents","table":"incident","field":"assignment_group","filter":"active%3Dtrue"},{"label":"CatalogTasks","name":"sc_tasks","heading":"CatalogTasks","table":"sc_task","field":"assignment_group","filter":"active%3Dtrue"}]&refpage={"sys_user":"user_profile"}

Once I constructed the URL, I added the following HTML to the HTML widget that I inserted on the page:

<div class="text-center" style="padding: 15px;">
  <a href="/sp?id=aggregate_test_4&amp;table=sys_user_group&amp;filter=typeCONTAINS1cb8ab9bff500200158bffffffffff62&amp;fields=name,manager&amp;order_by=name&amp;maximum_entries=7&amp;aggregates=[{&quot;label&quot;:&quot;Members&quot;,&quot;name&quot;:&quot;members&quot;,&quot;heading&quot;:&quot;Members&quot;,&quot;table&quot;:&quot;sys_user_grmember&quot;,&quot;field&quot;:&quot;group&quot;,&quot;filter&quot;:&quot;user.active%3Dtrue&quot;},{&quot;label&quot;:&quot;Incidents&quot;,&quot;name&quot;:&quot;incidents&quot;,&quot;heading&quot;:&quot;Incidents&quot;,&quot;table&quot;:&quot;incident&quot;,&quot;field&quot;:&quot;assignment_group&quot;,&quot;filter&quot;:&quot;active%3Dtrue&quot;},{&quot;label&quot;:&quot;CatalogTasks&quot;,&quot;name&quot;:&quot;sc_tasks&quot;,&quot;heading&quot;:&quot;CatalogTasks&quot;,&quot;table&quot;:&quot;sc_task&quot;,&quot;field&quot;:&quot;assignment_group&quot;,&quot;filter&quot;:&quot;active%3Dtrue&quot;}]&amp;refpage={&quot;sys_user&quot;:&quot;user_profile&quot;}">
    <button class="btn btn-primary">Click to Test</button>
  </a>
</div>

Now, all I needed to do was to bring up the Service Portal and pull up the page.

First look at the fourth test page

OK, that’s not all I needed to do. I also needed to click on the button to invoke that long URL with all of the parameters included.

Fourth test page after clicking on the Click to Test button

There, that’s better. So, it looks like everything seems to be in working order. That takes care of the last of the wrapper widgets. We still need to update the configuration file editor to support the new aggregate columns, which should be relatively straightforward, so maybe we can jump right into that next time out, wrap this whole thing up, and put out a new Update Set.