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 XI

“When obstacles arise, you change your direction to reach your goal; you do not change your decision to get there.”
Zig Ziglar

Last time, I completed the changes that I wanted to make to support the ability to click on an aggregate column value and see a list of the records represented by the value. I fully intended to test everything out this time and wrap this up; however, once I started testing things out, I began to realize that there were some inconsistencies in the approach taken for the aggregate columns compared to that taken with the buttons and icons. I don’t really like seeing that, so now I am contemplating scrapping the whole thing and starting over. I’m not fully there just yet, but I really don’t like have two different solutions to virtually the same objective.

To begin my testing, I built a simple modal pop-up widget and then edited the original AggregateTestConfig Script Include to contain an action property with a value of broadcast. That produced the desired clickable link on the aggregate column, but I noticed that there was no tool tip on mouse-over like there is with the buttons and icons. That, of course, is because I did not add the hint property to the aggregate column specification object like there is in the button/icon specification object. That seems like an easy fix, but the other thing that I did not like was that the column was still a link when the value was 0. Since there is nothing to see when the value is 0, it seems to me that that column shouldn’t be clickable unless there are records to view. That should also be an easy fix, so I set both of those concerns aside and moved on to testing the other option, linking to a new page.

Not wanting to disturb my earlier testing, I pulled up the AggregateTestConfig2 Script Include to use the second test page for this effort. You may recall that the second test page is a list of sys_user_grmember table records, not sys_user table records. To make this work for counting up the related records, we added the optional source property to the aggregate column specification object, but the code that I added to support the clickable links did not reference that property. That needed to be addressed as well, but I still wanted to do some testing, so I decided to swap over to the AggregateTestConfig3 Script Include to use the third existing test page instead of the second. That allowed me to complete my testing, and everything seemed to work as it should, but in digging around in the code, I found a couple of things that really disturbed my sense of The Way Things Ought To Be.

For one, to provide a link for buttons and icons, we use the property page_id. To accomplish the same thing for aggregate columns, the property name is action. The first is simply the ID of an existing Portal Page, selected from an sn-record-picker of Portal Pages. The second is a URL query string that includes the ID of the desired Portal Page as well as any other parameters that you might want to include in your URL. Both achieve the desired objective, but again, I do not like to see two different approaches to the same issue in the same component. It does work, but I don’t like it.

The other thing that I discovered is that the code that I added to handle the link to the new page was in the core SHN Data Table widget, while the similar code for the buttons and icons was located in the various wrapper widgets. I am sure that I copied all of that from the original click handler for the entire row, but I am not sure why it is in the wrapper widgets, where it has to be replicated in each and every one, instead of in the core widget, where it would seem to belong. Maybe there was a reason for that when the stock items were first constructed, but I do not know what that reason might have been.

In the midst of all of that, I came across this conversation, which included a link to this article. While I happen to share the article author’s concerns about cloning stock widgets, and he does make a number of valid points in his reply to the original poster, some modifications that you would like to make are so extensive that it makes little sense to attempt to retain whatever might be left of the original artifact. Still, his approach of embedding the original widget underneath your enhancements, which is essentially what the wrapper widgets do with the core data table widget, is an intriguing idea. I still have to study the sample to see how that might be adapted to what I have been trying to do, but I think it might be worth a closer look.

All of things these piled on top of one another make me think that maybe I want to back up the truck and take another shot at this from a different perspective. I definitely want to be able to click on a non-zero aggregate column and see a list of the records represented there, either in a modal pop-up on a new page, but maybe the way that I jumped into this was just a little too hasty. I think I have to do a little more digging around before I fully commit to what I have started here. Maybe this could be done a little better than I have it now. Or maybe not. Hopefully, I can figure all of that out relatively soon and we can still wrap this up and put it behind us.

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.

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.

Aggregate List Columns, Part V

“The trouble you’re expecting never happens; it’s always something that sneaks up the other way.”
George R. Stewart

Last time, we took a little side trip on our journey to add this new functionality to the SNH Data Table Widget collection, but now we need to get back on track and finish up modifying the other wrapper widgets that still need to have some changes. Let’s start with the SNH Data Table from Instance Definition, which does not rely on an outside configuration object. When you configure this widget, you use the Edit feature of the Service Portal Page Designer. The edit dialog box that comes up can be customized for a widget using the Option schema field on the widget form, which already contains a number of customizations for the existing version of the widget. Here is how that looks right now:

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

In the current version, there is a string field for entering a JSON specification object for both Buttons/Icons and Reference Pages, so we will want to add one more just like that for our new aggregate columns. We can clean up the labels for all three while we are at it, just to make things a little clearer, so now it looks like this:

[{"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"}]

Looking over the rest of the code, it doesn’t look as if there is anything else that needs to be done, so the next thing that we need to do is to set up a test so that we can try it out. Once again, we can clone the original test page and then make our modifications to the cloned page. The first thing that we will need to do is to swap out the SNH Data Table from JSON Configuration widget with our modified SNH Data Table from Instance Definition widget. Once we do that, we can click on the Edit pencil in the Page Designer and fill out the modified form.

Top half of the Page Designer widget option editor

For this test, I decided to use the Group table instead of the User table, and for our little test, I decided to limit the list to just ITIL groups. The type field can be more than one type, though, so I had to use a CONTAINS filter rather than an = filter, and the values are sys_ids, not names, so I had to look up the sys_id for the ITIL type. I just added a couple of fields, Name and Manager, and then ordered the list by Name. That pretty much took care of the top half of the widget option editor, so then I scrolled down to the bottom half, where our customizations appear.

Bottom half of the Page Designer widget option editor

Here I left the default color, selected an appropriate glyph icon for a group, and left the Link to this page and Enable Filter fields intentionally blank. In the Aggregate Column Specifications field, I entered a JSON String defining three different aggregate columns:

[{
 "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"
}]

I did not define any buttons or icons, but I did set up a reference configuration to send the links on the managers to the User Profile page. All that was left to do at that point was to Save the options, jump out to the Service Portal, and give this baby a try.

First test of the new widget modifications

Now that’s disturbing. There is no data in any of the aggregate columns. I hate it when that happens! Now what? Well, I always try to keep this in mind whenever these things suddenly crop up.

After quite a few trials and tribulations, I finally tracked down the source of the problem. The ng-repeat for the aggregate columns is actually nested underneath another ng-repeat for the rows in the table. Apparently, ng-repeat has some issues with arrays of primitives or strings, and prefers to deal with objects. Why that did not present itself when I was working with an array length of 1 earlier, and only now caused a problem with an array length of 3 is still a mystery to me. Fortunately, I never need to know or understand the why; I just need to know what to do about it. The solution was to convert my integer value to an object containing an integer value. So, in the core SNH Data Table widget, I changed this:

return value;

… to this:

return {value: value};

Of course, that meant that I also had to change the HTML accordingly, so I changed this:

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

… to this:

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

Now let’s have another look at that new test page.

Second test of the new widget modifications

That’s better! OK, so that takes care of wrapper widget #2. Now we just need to handle the third wrapper widget, SNH Data Table from URL Definition. That sounds like a good project for our next installment.

Aggregate List Columns, Part IV

“Everything can be improved.”
Clarence W. Barron

Last time, I promised that we would get into providing aggregate column support to the remaining data table wrapper widgets and the Content Selector Configuration Editor, but before we do that, there is one little extra feature that I would like insert. As you may recall, when I set up the filter for the initial test page, I hard-coded the list of sys_ids for the members of the Hardware group. It would have been much easier to use the sys_user_grmember table and set the filter to group.name=Hardware, but the problem with that would be that the sys_id of the row would then be the sys_id of the sys_user_grmember record, and not the sys_id of the sys_user record. The reason that that is a problem is because there are no Incidents assigned to sys_user_grmember records, so the aggregate column values would all be 0.

There is, however, a way to make that work. We just need to add one more property to our aggregate column configuration object. If we add an optional source property, then we can allow developers to configure an alternate source for the sys_id rather than just relying on the sys_id of the primary record of the row. It could be optional, as we could always default to the sys_id of the row if you did not specify an alternate source, but if you did enter a value, then the contents of that field would be the sys_id that we would use in place of the standard behavior. Using that approach, we could develop a configuration that could look like this:

name: 'sys_user_grmember',
displayName: 'User',
all: {
	filter: 'group.name=Hardware',
	fields: 'group,user,user.title,user.email,user.department,user.location',
	aggarray: [{
		label: 'Incidents',
		name: 'incidents',
		heading: 'Incidents',
		table: 'incident',
		field: 'assigned_to',
		filter: 'active=true',
		source: 'user'
	}],
	btnarray: [],
	refmap: {},
	actarray: []
}

Of course, in order to do that, we would have to add some additional logic inside the for loop that puts together the data to be sent to the getAggregateValue function, but the changes should not be that significant. I simply created a new variable called sysId, set it initially to the sys_id of the record, then checked to see if there was a source defined, and if so, overrode the initial value with the value found in the source field of the current record.

for (var j=0; j<data.aggarray.length; j++) {
	var config = data.aggarray[j];
	var sysId = record.sys_id;
	if (config.source) {
		sysId = gr.getValue(config.source);
	}
	record.aggValue.push(getAggregateValue(sysId, config));
}

That’s all there is to that. Now we just need to put together a test case to see if it will actually work. Rather than muck up my original test page, I decided to clone the page and the configuration script so that I could run tests both with and without a source defined. I called my copy of the configuration script AggregateTestConfig2, and it turned out like this:

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

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

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

	table: {
		all: [{
			name: 'sys_user_grmember',
			displayName: 'User',
			all: {
				filter: 'group.name=Hardware',
				fields: 'group,user,user.title,user.email,user.department,user.location',
				aggarray: [{
					label: 'Incidents',
					name: 'incidents',
					heading: 'Incidents',
					table: 'incident',
					field: 'assigned_to',
					filter: 'active=true',
					source: 'user'
				}],
				btnarray: [],
				refmap: {},
				actarray: []
			}
		}]
	},

	type: 'AggregateTestConfig2'
});

Now all we have to do is pull up the new test page and see how it looks.

Test page #2 with alternate sys_id source feature

So now you can define an alternate source for the sys_id that drives the aggregate, or you can choose not to and have the original functionality remain intact. Sweet! I like it. We still don’t have a way to click on the aggregate and pull up a list of the records represented there, but that’s something that we can always add in a future version. For now, we still have work to do involving the remainder of the core data table widget wrappers and the configuration file editor. I know that I said this last time, but this time for sure, we will jump right into that next time out.