Periodic Review, Part XI

“Take a deep breath, pick yourself up, dust yourself off and start all over again.”
Frank Sinatra

Last time, we finished up the code for the function that runs the notice distribution process. Now we need to come up with the actual content of those notices, which should direct the recipient to some function where they can indicate the disposition of the artifacts to be reviewed. In its simplest form, this would be a binary choice between keeping or discarding each item on the review list. However, there may be other dispositions for certain configurations, and there may be a need for additional options such as This item is no longer my responsibility or Remove this account only if it has been inactive for 90 days. To support such custom responses to a review request, we would need to add yet another table to our application to store the configured response options and link them to the configuration record. This adds a little bit of complexity to the process, but I think it would be worth doing to make things as flexible and useful as possible, so let’s go ahead and build that table now.

New Review Statement table

We will call our new table Review Statement and give it four fields, the reference to the configuration, an order, and a description and short description. We will want to link this as a related table on the configuration form so that statements can be easily added when setting up the configuration.

With that out of the way, we can start to visualize the form or page that the notice recipient would use to respond to the review notification. We could list the items to be reviewed down the page, and the configured choice across the top of the page, with a checkbox for each configured statement on each line containing an item. If there is more than one item to be reviewed, then a master checkbox at the top would also be helpful, so the recipient could simply check one box for the entire list of items. On the Now Platform, there are a number of different ways to construct such a page, but I am still partial to the Service Portal, so let’s build a Portal Widget for a Portal Page.

That was the plan, anyway.

Unfortunately, I did a really stupid thing before I got a chance to get started on that. It all started when I received a notice that my instance had some technical issues and that I needed to get rid of it and start over. Fair enough. I have had that particular instance for longer than I can remember, and I am sure that it was well past time to retire it and start all over with a new one. The notice said to be sure and back everything up before I wiped it out, but I have pretty much published every single thing that I have ever worked on, so I didn’t see any point in going through that. So I didn’t. I killed the old one and started over with a new one. Easy peasy.

What I neglected to consider was that this current project that I am working on right at the moment has not gotten far enough along for me to produce any public Update Sets, so there was no back up of everything that I have done so far. Oops! So now I have to go back and recreate all of the work that has been done so far, just to catch up to this point. I don’t mind doing things; in fact, I actually enjoy most of the stuff that I do here, and I mainly do it just for the fun of it. But I absolutely hate doing things twice. Now I just have to find the motivation to go back and do all of this again, just to get back to where I already was!

Collaboration Store, Part LXXX

“Walk that walk and go forward all the time. Don’t just talk that talk, walk it and go forward. Also, the walk didn’t have to be long strides; baby steps counted too. Go forward.”
Chris Gardner

Last time, we added some code to our storefront to launch the application installation process. Today, we want to build a detail screen to show all of the detailed information for a specific app. Although we could create a new UI Page or Portal Page for that, and have the tile click branch to that page, it seems as if it would be better if we just had a simple modal pop-up screen so that we could remain on the main shopping experience page after closing the modal dialog. To begin, let’s just create a simple Service Portal widget that we can call up using spModal. We can call our widget Application Details and give it an ID of cs-application-details.

We have already gathered up the application data in the main widget, so we could simply pass everything that we have over to the pop-up widget; however, that would make the pop-up widget highly dependent on the main widget, which is something that I try to avoid. I think the better approach will be to pass in the sys_id of the app and let the pop-up widget fetch its own data from the database. For that, we can cut and paste most of the code that we already have in the main widget.

(function() {
	if (input) {
		data.sysId = input.sys_id;
		data.record = {};
		var appGR = new GlideRecord('x_11556_col_store_member_application');
		appGR.query();
		if (appGR.get(data.sysId)) {
			var item = {};
			data.record.name = appGR.getDisplayValue('name');
			data.record.description = appGR.getDisplayValue('description');
			data.record.logo = appGR.getValue('logo');
			data.record.version = appGR.getDisplayValue('current_version');
			data.record.provider = appGR.getDisplayValue('provider.name');
			data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
			data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
			data.record.state = 0;
			if (appGR.getValue('application')) {
				data.record.state = 1;
				data.record.installedVersion = appGR.getDisplayValue('application.version');
				if (data.record.version == data.record.installedVersion) {
					data.record.state = 2;
				}
			}
			if (!data.record.local && data.record.state != 2) {
				data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
			}
		}
	}

	function getAttachmentId(applicationId, version) {
		var attachmentId = '';

		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		versionGR.addQuery('member_application', applicationId);
		versionGR.addQuery('version', version);
		versionGR.query();
		if (versionGR.next()) {
			var attachmentGR = new GlideRecord('sys_attachment');
			attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
			attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
			attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
			attachmentGR.query();
			if (attachmentGR.next()) {
				attachmentId = attachmentGR.getUniqueValue();
			}
		}
		
		return attachmentId;
	}
})();

Before we get too excited about formatting all of this data for display, let’s just throw a single value onto the display and see if we can get the mechanics of bringing up the widget all working. Here is some simple HTML to get things started.

<div>
  <h3>{{c.data.record.name}}</h3>
</div>

That should be enough to get things going, so let’s pop back over to the main widget and see if we can set up a function in the Client script to call up this widget.

$scope.openApplicationModal = function(evt, item) {
	var modelOptions = {
		title: "${Application Details}",
		widget: "cs-application-details",
		widgetInput: {
			sys_id: item.sys_id
		},
		buttons: [],
		footerStyle: {
			display: 'none'
		},
		size: 'lg'
	};
	$scope.applicationModal = spModal.open(modelOptions);
};

Now that we have a function to call, we need to go into the HTML and set up the call to the function when the operator clicks on the tile.

<a href="javascript:void(0);" ng-click="openApplicationModal($event, item)" class="panel-body block height-100" sn-focus="{{::item.highlight}}" aria-labelledby="cs_app_{{::item.sys_id}}" aria-describedby="cs_app_desc_{{::item.sys_id}}">
  <div>
    <h3 class="h4 m-t-none m-b-xs text-overflow-ellipsis" title="{{::item.name}}" style="padding-bottom:1px" id="cs_app_{{::item.sys_id}}">{{::item.name}}</h3>
    <img ng-src="{{::item.logo}}.iix?t=small" ng-if="item.logo" alt="" class="m-r-sm m-b-sm item-image pull-left" aria-hidden="true"/>
    <div class="text-muted item-short-desc catalog-text-wrap" id="cs_app_desc_{{::item.sys_id}}">{{::item.description}}</div>
  </div>
</a>

That should be enough to be able to open up the store and give things the old college try.

Simple application details pop-up

Not bad. OK, now that we have the basic mechanics working, we need to design the layout of the pop-up and also add whatever functionality we might want such as links to any forms or actions. At this point in the process, we have not added that much in the way of extra data. There are no categories or keywords or comments or user ratings or statistics or much of anything else in the way of interesting information outside of the name and description of the application. Some or all of that may come in some future version, but for now, about the best we can do to add detail would be to add the version history and to throw in a few useful links.

Store application detail pop-up

The above example is for an app pulled down from the store. For local apps that have been pushed up to the store, the look would be similar, but with a few differences.

Local application detail pop-up

For the local application, we should probably continue with the modified background, just to be consistent, but you get the idea. To pull this off, we will have to fetch more data from the database, and work out all of the associated HTML. Once that is done, that should be good enough to push out a new version so that folks can try it all out at home. That’s still a bit of work, so let’s deal with all of that next time out.

Collaboration Store, Part LXXVII

“Set your goals high, and don’t stop till you get there.”
Bo Jackson

Last time, we started hacking up a copy of the Service Portal page sc_category, beginning with the HTML. Now we need to update the Server script to pull in our data so that we can bring up the widget and see how it looks. For the header portion, we need to pull in data about the store, which we can get from the instance record for the Host instance.

function fetchStoreDetails() {
	var instanceGR = new GlideRecord('x_11556_col_store_member_organization');
	if (instanceGR.get('instance', gs.getProperty('x_11556_col_store.host_instance'))) {
		data.store = {};
		data.store.name = instanceGR.getDisplayValue('name');
		data.store.description = instanceGR.getDisplayValue('description');
		data.store.logo = instanceGR.getValue('logo');
		data.store.sys_id = instanceGR.getUniqueValue();
	}
}

For the apps, we look to the application table. At this point, we just want to pull in all of the data, so we will save any filtering for later in the development process. Right now, we just want to see how our presentation is coming out.

function fetchItemDetails(items) {
	var appGR = new GlideRecord('x_11556_col_store_member_application');
	appGR.query();
	while (appGR.next()) {
		var item = {};
		item.name = appGR.getDisplayValue('name');
		item.short_description = appGR.getDisplayValue('description');
		item.picture = appGR.getValue('logo');
		item.version = appGR.getDisplayValue('current_version');
		item.provider = appGR.getDisplayValue('provider.name');
		item.providerLogo = appGR.provider.getRefRecord().getValue('logo');
		item.sys_id = appGR.getUniqueValue();
		item.hasPrice = false;
		item.page = 'sc_cat_item';
		item.type = appGR.getValue('sys_class_name');
		item.order = 0;
		item.sys_class_name = appGR.getValue('sys_class_name');
		items.push(item);
	}
}

Some of that is still left over from the original widget, but we’ll clean that up later. For now, we just want to get to the point where we can throw this widget on a page and bring it up and see what we have. A lot of the other code from the original can be tossed, but we will need to retain a few things related to the paging, which leaves us with this.

(function() {
	data.items = [];
	data.show_more = false;
	fetchStoreDetails();
	var itemsInPage = options.limit_item || 9;

	data.limit = itemsInPage;
	if (input && input.new_limit) {
		data.limit = input.new_limit;
	}
	if (input && input.items) {
		data.items = input.items.slice();//Copy the input array
	}

	if (input && input.startWindow) {
		data.endWindow = input.endWindow;
	} else {
		data.startWindow = 0;
		data.endWindow = 0;
	}

	fetchItemDetails(data.items);

	if (data.items.length > data.limit) {
		data.show_more = true;
	}

	data.more_msg = gs.getMessage(" Showing {0} items", data.limit);

	function fetchStoreDetails() {
		var instanceGR = new GlideRecord('x_11556_col_store_member_organization');
		if (instanceGR.get('instance', gs.getProperty('x_11556_col_store.host_instance'))) {
			data.store = {};
			data.store.name = instanceGR.getDisplayValue('name');
			data.store.description = instanceGR.getDisplayValue('description');
			data.store.logo = instanceGR.getValue('logo');
			data.store.sys_id = instanceGR.getUniqueValue();
		}
	}

	function fetchItemDetails(items) {
		var appGR = new GlideRecord('x_11556_col_store_member_application');
		appGR.query();
		while (appGR.next()) {
			var item = {};
			item.name = appGR.getDisplayValue('name');
			item.short_description = appGR.getDisplayValue('description');
			item.picture = appGR.getValue('logo');
			item.version = appGR.getDisplayValue('current_version');
			item.provider = appGR.getDisplayValue('provider.name');
			item.providerLogo = appGR.provider.getRefRecord().getValue('logo');
			item.sys_id = appGR.getUniqueValue();
			item.hasPrice = false;
			item.page = 'sc_cat_item';
			item.type = appGR.getValue('sys_class_name');
			item.order = 0;
			item.sys_class_name = appGR.getValue('sys_class_name');
			items.push(item);
		}
	}

})();

That should be enough to make it work. Now let’s create a page for our widget, call it collaboration_store, and bring it up in the Service Portal Designer. To begin, let’s drag a 3/9 container onto the page.

Dragging a 3/9 container onto the new portal page

Once we have our container in place, let’s find our new widget and drag it into the 9 portion of the 3/9 container.

Dragging our new widget onto the page

Eventually, we will want to come up some kind of search/filter widget for the 3 section of the 3/9 container, but for now we will just leave that empty, save what we have, and pull up the page in the Service Portal to see how things are looking.

First look at our new store layout

Not bad! Of course, we still have a lot of work to do on the Client script to handle things like switching to the alternate view, clicking on a tile, or paging through lengthy results, but the look and feel seems to work so far. I think it might be good to put the logo of the store in the header, but other than that, I do not see any significant changes that I would like to make at this point. I think it actually looks pretty good.

We’ll need to start working on the Client script now, once again stripping out those things related to catalogs and categories, and making sure that the toggle functions still work for switching back and forth between the tiles layout and the table layout. Also, we will need to figure out what we want to do when the operator clicks on an app. We could bring up the details of the application in a modal pop-up, navigate to a detail page, or even go straight into launching an install. That will all end up in the Client script as well.

One thing that we will need to add to the view is the state of the app on the instance, whether it is up to date, needs to be upgraded to the latest version, or has never been installed. The state of the app may determine what happens when you click on the app, so we will need to figure all that out as well. So much to figure out, but still it is a pretty good start. Next time, we will just keep forging ahead unless we have some test results to address.

Collaboration Store, Part LXXVI

“Never look down to test the ground before taking your next step; only he who keeps his eye fixed on the far horizon will find the right road.”
Dag Hammarskjold

Last time, we took a look at a number of different examples of pages that could serve as a model for locating an app in the Collaboration Store. We decided to use the Service Portal page sc_category, the Service Catalog category browse page, as a starting point for our efforts. Taking a look at the page, we can see two containers containing three rows of various widgets.

sc_category page contents

The only thing that we really need off of this page at this point is our own copy of the SC Category Page widget, so let’s go make a copy of that and call it Collaboration Store, since this is basically going to be the storefront of our Collaboration Store.

Cloning the SC Category Page widget

Once we make the copy, we can update the Name, ID, and Description fields and save our new widget.

New Collaboration Store widget

Now that we have our own copy to play with, let’s take a look at that HTML and see what we want to keep and what we want to toss.

<div id="sc_category_page"  class="m-t-sm " ng-class="{'hidden-xs' : hideItemWidget, 'm-l-sm': !isMobile}">
  <h4 ng-if="data.error">{{data.error}}</h4>
  <div ng-init="spSearch.targetCatalog()">
    <div class="row">
      <div class="col-xs-9">
        	<button ng-click="showCategories()" class="visible-xs m-b-sm pointer btn-link" tabindex="0" id="all-categories-link">
            <i class="fa fa-chevron-left m-r-xs"></i> ${All Categories}
        	</button>
          <h2 class="h4 m-t-none break-word" aria-label="{{data.categoryPageAriaLabel}}">{{data.category.title}}</h2>
        	<p class="hidden-xs break-word">
            {{data.category.description}}
          </p>
      </div>
      <div class="col-xs-3" ng-if="!isMobile">
        <div role="tablist" class="pull-right padder-t-sm text-lg toggle" ng-show="!data.error && data.items.length > 0">
          <i id="tab-card" 
             role="tab"
             class="fa fa-th tab-card-padding" ng-click="changeView('card')" 
             ng-keydown="switchTab($event)" aria-label="${Card View}" 
             ng-class="{'active' : view == 'card'}"
             title="${Card View}"
             data-toggle="{{!isTouchDevice() ? 'tooltip' : undefined}}"
 	         data-placement="top"
             data-container="body"
             aria-selected="{{view == 'card'}}" 
             aria-label="${Card View}"
             ng-attr-aria-controls="{{view == 'card' ? 'tabpanel-card-' + (data.category_id ? data.category_id : '') : undefined}}" 
             tabindex="{{view == 'card' ? '0' : '-1'}}"></i>
          <span class="m-l-sm m-r-sm " aria-hidden="true"> | </span>
          <i id="tab-grid" 
             role="tab"
             class="fa fa-list-ul tab-card-padding" 
             ng-click="changeView('grid')" 
             ng-keydown="switchTab($event)" 
             ng-class="{'active' : view == 'grid'}"
             title="${Table View}"
             data-toggle="{{!isTouchDevice() ? 'tooltip' : undefined}}"
 	           data-placement="top"
             data-container="body"
             aria-selected="{{view == 'grid'}}"
             aria-label="${Table View}" 
             ng-attr-aria-controls="{{view == 'grid' ? 'tabpanel-grid-' + (data.category_id ? data.category_id : '') : undefined}}" 
             tabindex="{{view == 'grid' ? '0' : '-1'}}"></i>
        </div>
       </div>
    </div>
    <div class="row">
    	<div class="text-a-c" ng-if="showTopLoader">
      		<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
			<span class="sr-only">${Loading...}</span>
    	</div>
      <div class="col-sm-6 col-md-4" ng-if="!showTopLoader && !data.items.length && !data.error">
        ${No items in category}
      </div>
      <div id="tabpanel-grid-{{::data.category_id}}" role="tabpanel" aria-labelledby="{{'tab-grid'}}" ng-if="view == 'grid' && data.items.length > 0">
        <table class="table table-striped item-table" aria-label="{{::data.category.title}}" aria-describedby="id-caption-category">
        	<caption id="id-caption-category"><span class="sr-only">{{::data.category.title}}</span></caption>
          <thead>
            <tr>
              <th id="id-header-item" scope="col" colspan="2">${Item}</th>
              <th id="id-header-description" scope="col" colspan="3">${Description}</th>
            	  <th id="id-header-price" scope="col" ng-if="data.showPrices">${Price}</th>
            </tr>
          </thead>
          <tbody>
            <tr ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()">
              <td id="id-item-{{item.sys_id}}" headers="id-header-item" scope="row" colspan="2">
                <a target="{{::item.target}}" ng-href="{{::getItemHREF(item)}}" sn-focus="{{::item.highlight}}" ng-click="onClick($event, item)"> 
                	<div>
                    <img ng-src="{{::item.picture}}?t=small" ng-if="item.picture" alt="" class="m-r-sm m-b-sm item-image pull-left"/>
                    <span class="catalog-text-wrap catalog-item-name">{{::item.name}}</span>
                    <span ng-if="item.content_type == 'external'"><span class="sr-only">${External Link}</span> âžš</span>
                  </div>
                </a>
            	  </td>
            	  <td headers="id-header-description id-item-{{item.sys_id}}" class="catalog-text-wrap" colspan="3">{{::item.short_description}}</td>
            	  <td headers="id-header-price id-item-{{item.sys_id}}" ng-if="data.showPrices">{{::item.price}}</td>
            </tr>
          </tbody>
        </table>
      </div>
      <div id="tabpanel-card-{{::data.category_id}}" ng-if="view == 'card' && data.items.length > 0" role="tabpanel" aria-labelledby="{{'tab-card'}}">
      	<ul class="item-list-style-type-none item-card-row" role="list" aria-label="{{data.category.title}} ${items}">
      		<li class="item-card-column" ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()" role="listitem">
            <div class="panel panel-{{::options.color}} item-card b sc-panel"  data-original-title="{{::item.name}}">
              <a target="{{::item.target}}" ng-href="{{::getItemHREF(item)}}" ng-click="onClick($event, item)" class="panel-body block height-100" sn-focus="{{::item.highlight}}">
                <div>
                  <h3 class="h4 m-t-none m-b-xs text-overflow-ellipsis catalog-item-name" title="{{::item.titleTag}}" style="padding-bottom:1px">{{::item.name}}<span ng-if="item.content_type == 'external'"><span class="sr-only">${External Link}</span> âžš</span></h3>
                  <img ng-src="{{::item.picture}}?t=small" ng-if="item.picture" alt="" class="m-r-sm m-b-sm item-image pull-left" aria-hidden="true"/>
                  <div class="text-muted item-short-desc catalog-text-wrap">{{::item.short_description}}</div>
                </div>
              </a>
            </div>
            <div class="panel-footer b">
              <a aria-label="${View Details} {{::item.name}}" ng-if="item.sys_class_name != 'sc_cat_item_content' || item.content_type == 'kb' || item.content_type == 'literal'" ng-click="onClick($event, item)" ng-href="{{getItemHREF(item)}}" class="pull-left text-muted">${View Details}</a>
              <a aria-label="${View Details} {{::item.name}}" ng-if="item.sys_class_name == 'sc_cat_item_content' && item.content_type == 'external'" ng-click="onClick($event, item)" ng-href="{{getItemHREF(item)}}" target="_blank" class="pull-left text-muted">${View Details}</a>
              <span ng-if="data.showPrices && item.hasPrice" class="pull-right item-price font-bold">{{::item.price}}</span> &nbsp;
            </div>
      		</li>
      	</ul>  
      </div>
    </div>
    <div class="text-a-c" ng-if="!stopLoader && data.items.length > 0 && !data.error">
      <i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
			<span class="sr-only">${Loading...}</span>
    </div>
    <div ng-if="data.show_more && !show_popular_item">
      	<div class="text-a-c">
          {{data.more_msg}}
      	</div>
        <button class="m-t-xs btn btn-default btn-loadmore" ng-click="loadMore()">
          ${Show More Items}
        </button>
      </div>
  </div>
</div>
<now-message key="Catalogs" value="${Catalogs}"/>

Starting at the top, the All Categories button has no use in our scenario, so we can cut that part out. The category title and category description data can be replaced with the name and description of the store (the Host instance). The next block contains the pair of icons used to select between a display of tiles or a simple list. I like those options, so we will keep that section intact. Following that we have a loading block and a nothing to see here block, both of which would seem to have a valid use in our adaptation, so we will leave those there for now as well.

The next section is the table view, with columns for Item, Description, and Price. We will do something similar, but our columns will be Application, Description, Version, and Provider. The next section is the tile view, and we will work our same data points into the tile layout as well. The final block is all of the elements of the optional Show More Items section, and we can just leave that in place for now. That leaves our HTML looking something like this:

<div id="sc_category_page"  class="m-t-sm " ng-class="{'hidden-xs' : hideItemWidget, 'm-l-sm': !isMobile}">
  <h4 ng-if="data.error">{{data.error}}</h4>
  <div>
    <div class="row">
      <div class="col-xs-9">
        <h2 class="h4 m-t-none break-word" aria-label="{{data.store.name}}">{{data.store.name}}</h2>
       	<p class="hidden-xs break-word">
          {{data.store.description}}
        </p>
      </div>
      <div class="col-xs-3" ng-if="!isMobile">
        <div role="tablist" class="pull-right padder-t-sm text-lg toggle" ng-show="!data.error && data.items.length > 0">
          <i id="tab-card" 
             role="tab"
             class="fa fa-th tab-card-padding" ng-click="changeView('card')" 
             ng-keydown="switchTab($event)" aria-label="${Card View}" 
             ng-class="{'active' : view == 'card'}"
             title="${Card View}"
             data-toggle="{{!isTouchDevice() ? 'tooltip' : undefined}}"
 	         data-placement="top"
             data-container="body"
             aria-selected="{{view == 'card'}}" 
             aria-label="${Card View}"
             ng-attr-aria-controls="{{view == 'card' ? 'tabpanel-card-' + (data.category_id ? data.category_id : '') : undefined}}" 
             tabindex="{{view == 'card' ? '0' : '-1'}}"></i>
          <span class="m-l-sm m-r-sm " aria-hidden="true"> | </span>
          <i id="tab-grid" 
             role="tab"
             class="fa fa-list-ul tab-card-padding" 
             ng-click="changeView('grid')" 
             ng-keydown="switchTab($event)" 
             ng-class="{'active' : view == 'grid'}"
             title="${Table View}"
             data-toggle="{{!isTouchDevice() ? 'tooltip' : undefined}}"
 	           data-placement="top"
             data-container="body"
             aria-selected="{{view == 'grid'}}"
             aria-label="${Table View}" 
             ng-attr-aria-controls="{{view == 'grid' ? 'tabpanel-grid-' + (data.category_id ? data.category_id : '') : undefined}}" 
             tabindex="{{view == 'grid' ? '0' : '-1'}}"></i>
        </div>
       </div>
    </div>
    <div class="row">
      <div class="text-a-c" ng-if="showTopLoader">
        <i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
        <span class="sr-only">${Loading...}</span>
      </div>
      <div class="col-sm-6 col-md-4" ng-if="!showTopLoader && !data.items.length && !data.error">
        ${There are no applications in this store}
      </div>
      <div id="tabpanel-grid-{{::data.store.name}}" role="tabpanel" aria-labelledby="{{'tab-grid'}}" ng-if="view == 'grid' && data.items.length > 0">
        <table class="table table-striped item-table" aria-label="{{::data.store.name}}" aria-describedby="id-caption-category">
        	<caption id="id-caption-category"><span class="sr-only">{{::data.store.name}}</span></caption>
          <thead>
            <tr>
              <th id="id-header-name" scope="col" colspan="2">${Name}</th>
              <th id="id-header-description" scope="col" colspan="3">${Description}</th>
              <th id="id-header-version" scope="col" colspan="3">${Version}</th>
              <th id="id-header-provider" scope="col" colspan="3">${Provider}</th>
            </tr>
          </thead>
          <tbody>
            <tr ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()">
              <td id="id-item-{{item.sys_id}}" headers="id-header-item" scope="row" colspan="2">
                <a href="javascript:void(0)"> 
                  <div>
                    <img ng-src="{{::item.logo}}.iix?t=small" ng-if="item.picture" alt="" class="m-r-sm m-b-sm item-image pull-left"/>
                    <span class="catalog-text-wrap catalog-item-name">{{::item.name}}</span>
                  </div>
                </a>
              </td>
              <td headers="id-header-description id-item-{{item.sys_id}}" class="catalog-text-wrap" colspan="3">{{::item.description}}</td>
              <td headers="id-header-version id-item-{{item.sys_id}}">{{::item.version}}</td>
              <td headers="id-header-provider id-item-{{item.sys_id}}">{{::item.provider}}</td>
            </tr>
          </tbody>
        </table>
      </div>
      <div id="tabpanel-card-{{::data.category_id}}" ng-if="view == 'card' && data.items.length > 0" role="tabpanel" aria-labelledby="{{'tab-card'}}">
      	<ul class="item-list-style-type-none item-card-row" role="list" aria-label="{{data.category.title}} ${items}">
      	  <li class="item-card-column" ng-repeat="item in data.items | orderBy: 'order' | limitTo: data.limit track by item.sys_id" ng-init="startItemList()" role="listitem">
            <div class="panel panel-{{::options.color}} item-card b sc-panel">
              <a href="javascript:void(0);" class="panel-body block height-100" sn-focus="{{::item.highlight}}" aria-labelledby="sc_cat_item_{{::item.sys_id}}" aria-describedby="sc_cat_item_short_desc_{{::item.sys_id}}">
                <div>
                  <h3 class="h4 m-t-none m-b-xs text-overflow-ellipsis" title="{{::item.name}}" style="padding-bottom:1px" id="sc_cat_item_{{::item.sys_id}}">{{::item.name}}<span ng-if="item.content_type == 'external'"><span class="sr-only">${External Link}</span> âžš</span></h3>
                  <img ng-src="{{::item.picture}}.iix?t=small" ng-if="item.picture" alt="" class="m-r-sm m-b-sm item-image pull-left" aria-hidden="true"/>
                  <div class="text-muted item-short-desc catalog-text-wrap" id="sc_cat_item_short_desc_{{::item.sys_id}}">{{::item.short_description}}</div>
                </div>
              </a>
            </div>
            <div class="panel-footer b">
              <span ng-if="item.version" class="font-bold">v{{::item.version}}</span>
              <span style="display: inline-flex;" class="pull-right">
                <span ng-if="item.provider">{{::item.provider}}</span>
                &nbsp;
                <img ng-src="{{::item.providerLogo}}.iix?t=small" ng-if="item.providerLogo" alt="{{::item.provider}}" class="avatar-small" style="display: inline-flex; width: 16px; height: 16px;"/>
                &nbsp;
              </span>
            </div>
      	  </li>
      	</ul>  
      </div>
    </div>
    <div class="text-a-c" ng-if="!stopLoader && data.items.length > 0 && !data.error">
      <i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
      <span class="sr-only">${Loading...}</span>
    </div>
    <div ng-if="data.show_more && !show_popular_item">
      <div class="text-a-c">
        {{data.more_msg}}
      </div>
      <button class="m-t-xs btn btn-default btn-loadmore" ng-click="loadMore()">
        ${Show More Items}
      </button>
    </div>
  </div>
</div>

Now that we have the HTML all roughed out, it would nice to bring it up and see how it looks, but we are going to need some data first. For that, we are going to have take a look at the widget’s server-side script. Let’s take a quick peek and see what it is that we have to work with.

(function() {
	if (input && input.category_id)
		data.category_id = input.category_id;
	else
		data.category_id = $sp.getParameter("sys_id");

	data.catalog_id = $sp.getParameter("catalog_id") ? $sp.getParameter("catalog_id") + "" : "-1";
	var catalogsInPortal = ($sp.getCatalogs().value + "").split(",");
	var isCatalogAccessibleViaPortal = data.catalog_id == -1 ? true : false;
	catalogsInPortal.forEach(function(catalogSysId) {
		if (data.catalog_id == catalogSysId) {
			isCatalogAccessibleViaPortal = true;
		}
	});
	data.categorySelected = gs.getMessage('category selected');
	if(!isCatalogAccessibleViaPortal) {
		data.error = gs.getMessage("You do not have permission to see this catalog");
		return;
	}
	var catalogDisplayValue;
	if (data.catalog_id && data.catalog_id !== "-1") {
		var catalogObj = new sn_sc.Catalog(data.catalog_id);
		if (catalogObj) {
			if (!catalogObj.canView()) {
				data.error = gs.getMessage("You do not have permission to see this catalog");
				return;
			}
			catalogDisplayValue = catalogObj.getTitle();
		}
	}
	if (options && options.sys_id)
		data.category_id = options.sys_id;
	data.showPrices = $sp.showCatalogPrices();
	data.sc_catalog_page = $sp.getDisplayValue("sc_catalog_page") || "sc_home";
	data.sc_category_page = $sp.getDisplayValue("sc_category_page") || "sc_category";
	catalogDisplayValue = catalogDisplayValue ? catalogDisplayValue : $sp.getCatalogs().displayValue + "";
	var catalogIDs = (data.catalog_id && data.catalog_id !== "-1") ? data.catalog_id : $sp.getCatalogs().value + "";
	var catalogArr = catalogDisplayValue.split(",");
	var catalogIDArr = catalogIDs.split(",");
	data.sc_catalog = catalogArr.length > 1 ? "" : catalogArr[0];

	data.show_more = false;
	if (GlideStringUtil.nil(data.category_id)) {
		 data.items = getPopularItems();
		 data.show_popular_item = true;
		 data.all_catalog_msg = (($sp.getCatalogs().value + "").split(",")).length > 1 ? gs.getMessage("All Catalogs") : "";
		 data.all_cat_msg = gs.getMessage("All Categories");
		 data.category = {title: gs.getMessage("Popular Items"),
									 description: ''};
		return;
	}

	data.show_popular_item = false;
	// Does user have permission to see this category?
	var categoryId = '' + data.category_id;
	var categoryJS = new sn_sc.CatCategory(categoryId);
	if (!categoryJS.canView()) {
		data.error = gs.getMessage("You do not have permission to see this category");
		return;
	}
	data.category = {title: categoryJS.getTitle(),
									 description: categoryJS.getDescription()};

	var catalog = $sp.getCatalogs().value;

	data.items = [];
	var itemsInPage = options.limit_item || 9;

	data.limit = itemsInPage;
	if (input && input.new_limit)
		data.limit = input.new_limit;
	if (input && input.items) {
		data.items = input.items.slice();//Copy the input array
	}

	if (input && input.startWindow) {
		data.endWindow = input.endWindow;
	}
	else {
		data.startWindow = 0;
		data.endWindow = 0;
	}

	while (data.items.length < data.limit + 1) {
		data.startWindow = data.endWindow;
		data.endWindow = data.endWindow + itemsInPage;
		var itemGR = queryItems(catalog, categoryId, data.startWindow, data.endWindow);
		if (!itemGR.hasNext())
			break;
		fetchItemDetails(itemGR, data.items);
	}

	if (data.items.length > data.limit)
			data.show_more = true;

	data.more_msg = gs.getMessage(" Showing {0} items", data.limit);

	data.categories = [];
	while(categoryJS && categoryJS.getParent()) {
		var parentId =  categoryJS.getParent();
		categoryJS = new sn_sc.CatCategory(parentId);
		var category = {
			label: categoryJS.getTitle(),
			url: '?id='+data.sc_category_page+'&sys_id=' + parentId
		};
		data.categories.unshift(category);
	}

	data.all_catalog_msg = (($sp.getCatalogs().value + "").split(",")).length > 1 ? gs.getMessage("All Catalogs") : "";

	function fetchItemDetails(itemRecord, items) {
		while (itemRecord.next()) {
				var catalogItemJS = new sn_sc.CatItem(itemRecord.getUniqueValue());
				if (!catalogItemJS.canView())
					continue;

				var catItemDetails = catalogItemJS.getItemSummary();
				var item = {};
				item.name = catItemDetails.name;
				item.short_description = catItemDetails.short_description;
				item.picture = catItemDetails.picture;
				item.price = catItemDetails.price;
				item.sys_id = catItemDetails.sys_id;
				item.hasPrice = catItemDetails.show_price;
				item.page = 'sc_cat_item';
				item.type = catItemDetails.type;
				item.order = catItemDetails.order;
				item.sys_class_name = catItemDetails.sys_class_name;
				item.titleTag = catItemDetails.name;
				if (item.type == 'order_guide') {
					item.page = 'sc_cat_item_guide';
				} else if (item.type == 'content_item') {
					item.content_type = catItemDetails.content_type;
					item.url = catItemDetails.url;
					if (item.content_type == 'kb') {
						item.kb_article = catItemDetails.kb_article;
						item.page = 'kb_article';
					} else if (item.content_type == 'external') {
						item.target = '_blank';
						item.titleTag = catItemDetails.name + " âžš";
					}
				}
				items.push(item);
			}
	}

	function queryItems(catalog, categoryId, startWindow, endWindow) {
			var scRecord = new sn_sc.CatalogSearch().search(catalog, categoryId, '', false, options.show_items_from_child != 'true');
			scRecord.addQuery('sys_class_name', 'NOT IN', 'sc_cat_item_wizard');
			scRecord.addEncodedQuery('hide_sp=false^ORhide_spISEMPTY^visible_standalone=true');
			scRecord.chooseWindow(startWindow, endWindow);
			scRecord.orderBy('order');
			scRecord.orderBy('name');
			scRecord.query();
			return scRecord;
	}

	function getPopularItems() {
        return new SCPopularItems().useOptimisedQuery(gs.getProperty('glide.sc.portal.popular_items.optimize', true) + '' == 'true')
            .baseQuery(options.popular_items_created + '')
            .allowedItems(getAllowedCatalogItems())
            .visibleStandalone(true)
            .visibleServicePortal(true)
            .itemsLimit(6)
            .restrictedItemTypes('sc_cat_item_guide,sc_cat_item_wizard,sc_cat_item_content,sc_cat_item_producer'.split(','))
            .itemValidator(function(item, itemDetails) {
                if (!item.canView() || !item.isVisibleServicePortal())
                    return false;

                return true;
            })
            .responseObjectFormatter(function(item, itemType, itemCount) {
                return {
                    order: 0 - itemCount,
                    name: item.name,
                    short_description: item.short_description,
                    picture: item.picture,
                    price: item.price,
                    sys_id: item.sys_id,
                    hasPrice: item.price != 0,
                    page: itemType == 'sc_cat_item_guide' ? 'sc_cat_item_guide' : 'sc_cat_item'
                };
            })
            .generate();
    }

	function getAllowedCatalogItems () {
		var allowedItems = [];
		catalogIDArr.forEach(function(catalogID) {
			var catalogObj = new sn_sc.Catalog(catalogID);
			var catItemIds = catalogObj.getCatalogItemIds();
			for(var i=0; i<catItemIds.length; i++) {
				if (!allowedItems.includes(catItemIds[i]))
					 allowedItems.push(catItemIds[i]);
			}
		});
		return allowedItems;
	}

})();

There is a lot here to digest, and an awful lot that is not relevant to our purpose, particularly all of those things that are related to catalogs and categories. We may want to just toss this out and replace it with some simple logic to pull in the Host information for the header and then all of the apps for the main section. Either way, this seems like a little more work than just rearranging the HTML, so let’s save all of that for our next installment.

Collaboration Store, Part LXXV

“Imagination is the beginning of creation. You imagine what you desire, you will what you imagine and at last you create what you will.”
George Bernard Shaw

While we wait patiently for some comments from the folks gracious enough to do some testing with the latest release, let’s take a quick peek ahead something that we might want to tackle next. We are rapidly winding down the initial goal of getting all of the basic mechanics working for installing, setting up, and using the app to move artifacts between instances. Once we have all of that working satisfactorily, we will want to expand our efforts to include some of those other items on our list of things that we would still like to do. One of the major items on that list is the shopping experience, or the way in which a developer would locate an app in the store. Just focusing on the presentation itself for just a moment, let’s take a look at some of the many similar experiences for that there are right now.

All Applications

If you select plugins on the primary navigation, you get redirected to the All Applications page.

All Applications Page

This page features a left-hand panel for various filter options and then a right-hand panel of full-width application tiles that include details about the app and two installation options, scheduled and immediate. In the upper right-hand corner there is a Find in Store button, which will take you to our next example.

App Store

ServiceNow App Store

This one also has a left-hand filter column and a right-hand column of application tiles, but on this one the tiles are not full-width and there are no install options. There is a cool mouse-over feature, though, and this one includes user-ratings, which can also be used as filter and sort options.

My Company Applications

Now Platform Application Manager

The application manager built into the Now Platform uses tabs rather than filters, and the application tiles are full width, but do not include the description of the app. Rather than install options there is an Edit button, which makes it look similar in appearance to the first example.

Service Catalog

Not an application shopping experience, but a shopping experience just the same, and with many characteristics similar to those of the other examples.

ServiceNow Service Catalog

This one has a left-hand panel for selecting a category from a category tree and the tiles in the right-hand panel are more like those in the App Store. Obviously, the content of the tiles would have to be adjusted to include the relevant data for applications, but the one benefit of this one over the others is that we have access to the source code. This is a standard Service Portal Page, which we could potentially clone and then modify for our purpose. The same is also true of the individual widgets that make up the page, as they could also be either cloned or replaced.

At this stage of the process, we do not have many of the features that make up much of the content in some of these examples. Today, there are no categories, no tags, no reviews, no ratings, and no other helpful ways to filter the list down to the things in which you might have an interest. Introducing any such features would be a project in and of itself, but we can still attempt to build a rudimentary shopping experience without those, and then throw those in later as the needs arise. For now, there aren’t that many test apps in the working model, so filtering is not yet a pressing need.

Just to see what we can do, let’s grab a copy of the catalog portal page next time and start hacking it up to meet our needs. It won’t necessarily be everything that it could be right at the start, but it will be a beginning.

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.

Service Portal User Directory

“Do. Or do not. There is no try.”
Yoda

I have been playing around a lot lately with my hacked up Service Portal Data Table widget, but just about everything that I have done has been centered around some kind of Task. I have been wanting to do something a little different, so I decided that I would use my breadcrumbs widget, my content selector, and my custom data table to create a User Directory portal page. I started out by creating a new page called user_directory and opening it up in the Portal Designer.

First I dragged a 12-wide container over into the top of the page and then dragged the breadcrumbs widget into that. Then I dragged over a 3/9 container and put the content selector into the narrow portion and the data table into the wider portion. That gave me my basic layout.

user_directory page basic layout

Since this was going to be a list of Users on the portal, I decided that the page to which we will link should be the user_profile portal page, so I updated the table options to link to that page, and to add a user icon as the glyph.

Updating the data table widget options

We also need to update the content selector widget options, but it is going to ask for a configuration script, which we have not built just yet, so let’s do that now. For this, we can use the new Content Selector Configuration Editor to create a new configuration script called UserDirectoryConfig.

Creating a new configuration script using the Content Selector Configuration Editor

For this initial version of the directory, we will have two Perspectives: 1) a general public perspective for all authenticated users, and 2) a user administration perspective for those with the authority to update user records. We will also define 3 States: 1) active users, 2) inactive users, and 3) all users. For each Perspective, we will define a single Table, the User (sys_user) table.

Updating the new configuration script using the Content Selector Configuration Editor

For all of the public states, we will specify the following field list:

name,title,department,location,manager

Since I do not want regular users looking at inactive users, I used the following filter for both the Active state and the All state:

active=true

For the Inactive state, which I wanted to always come up empty, I used this:

active=true^active=false

For the admin states, we will drop the manager and add the user_name, and for the All state, we will also add the active flag:

user_name,name,title,department,location,active

And of course the filters for the admin states are all pretty self explanatory, so there’s no need to waste any space on those here. One little extra thing that I did want to add to all of the admin states, though, was an Edit button. Clicking on the user_name will get you to the user_profile page, but if you want to edit the user information, you need to go the form page, so I added a button for that purpose to all three of the admin states.

Administrative Edit button

After completing the configuration, saving the form produced the following configuration script:

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

	perspective: [{
		name: 'everyone',
		label: 'Public',
		roles: ''
	},{
		name: 'admin',
		label: 'User Admin',
		roles: 'user_admin,admin'
	}],

	state: [{
		name: 'active',
		label: 'Active'
	},{
		name: 'inactive',
		label: 'Inactive'
	},{
		name: 'all',
		label: 'All'
	}],

	table: {
		everyone: [{
			name: 'sys_user',
			displayName: 'User',
			active: {
				filter: 'active=true',
				fields: 'name,title,department,location,manager',
				btnarray: [],
				refmap: {},
				actarray: []
			},
			inactive: {
				filter: 'active=true^active=false',
				fields: 'name,title,department,location,manager',
				btnarray: [],
				refmap: {},
				actarray: []
			},
			all: {
				filter: 'active=true',
				fields: 'name,title,department,location,manager',
				btnarray: [],
				refmap: {},
				actarray: []
			}
		}],
		admin: [{
			name: 'sys_user',
			displayName: 'User',
			active: {
				filter: 'active=true',
				fields: 'user_name,name,title,department,location',
				btnarray: [{
					name: 'edit',
					label: 'Edit',
					heading: 'Edit',
					icon: 'edit',
					color: '',
					hint: '',
					page_id: 'form'
				}],
				refmap: {},
				actarray: []
			},
			inactive: {
				filter: 'active=false',
				fields: 'user_name,name,title,department,location',
				btnarray: [{
					name: 'edit',
					label: 'Edit',
					heading: 'Edit',
					icon: 'edit',
					color: '',
					hint: '',
					page_id: 'form'
				}],
				refmap: {},
				actarray: []
			},
			all: {
				filter: 'true',
				fields: 'user_name,name,title,department,location,active',
				btnarray: [{
					name: 'edit',
					label: 'Edit',
					heading: 'Edit',
					icon: 'edit',
					color: '',
					hint: '',
					page_id: 'form'
				}],
				refmap: {},
				actarray: []
			}
		}]
	},

	type: 'UserDirectoryConfig'
});

Now we can go back into the Page Designer and edit the content selector widget options to specify our new configuration script.

Editing the content selector widget options in the Page Designer

Now all that is left to do is to go out to the Service Portal and give it a go. Let’s see how it comes out.

First look at the new User Directory portal page

I like it! I clicked around and tried a few things. Overall, I think it came out pretty good, but I did come across one flaw already: clicking on someone’s department or location will take you to the user_profile page and get you a nasty error message because there is no user on file with the sys_id of a department or location. That’s not good. That also brings up an interesting point: I don’t really want to send regular users to the form page for departments or locations, either. I need to find a more appropriate destination for those links. I guess I won’t be wrapping this all up in a bow today after all. I’ll have to give this one some thought, so this looks like an issue that we will have to take up next time out.