Service Portal Form Fields, Broken

“Well, if it can be thought, it can be done, a problem can be overcome.”
E.A. Bucchianeri

The snh-form-field tag has been around for a while in various incarnations, and has been used on quite a few projects for one thing or another, all with relative success. Several snh-form-fields make up the initial set-up screen for the Collaboration Store, which also worked out quite well for that particular effort. At least, it was working out quite well until I tried to set up a new instance that was running the new Tokyo release. Here is what the initial set-up screen is supposed to look like:

Collaboration Store initial set-up screen

Here is what it looks like on a Tokyo instance:

Collaboration Store initial set-up screen on Tokyo

As you can clearly see, a couple of very important input fields are not on the screen in the Tokyo version. This appears to be due to a flaw in the snh-form-field tag that did not reveal itself in the earlier versions of the Now Platform. At this point, I do not know the exact nature of the flaw, only that it appears that you can only have one snh-form-field tag in any given container. I would categorize this as a flaw in the tag rather than an issue with the new release of ServiceNow, mainly because of the first rule of programming. It may take me a while to figure out exactly what might be going on here, but in the interim, I was at least able to come up with a viable work around. If you are using the snh-form-field tag on a project running on a Tokyo instance and you run into this issue before the flaw in the tag has been corrected, you can simply surround each snh-form-field tag with a span. That places each snh-form-field tag in its own personal container, eliminating the issue.

Here is how I used this technique to work around the problem that I was having with the initial set-up screen for the Collaboration Store.

<div id="nav_message" class="outputmsg_nav" ng-hide="c.data.validScope">
  <img role="presentation" src="images/icon_nav_info.png">
  <span class="outputmsg_nav_inner">
    &nbsp;
    The <strong>Collaboration Store Set-up</strong> cannot be completed because <strong>Collaboration Store</strong> is not selected in your application picker.
    <a onclick="window.location.href='change_current_app.do?app_id=5b9c19242f6030104425fcecf699b6ec&referrer=%24sp.do%3Fid%3Dcollaboration_store_setup'">
      Switch to <strong>Collaboration Store</strong>
    </a>.
  </span>
</div>
<div class="row" ng-show="c.data.phase != 1 && c.data.phase != 2 && c.data.phase != 3">
  <div class="col-sm-12">
    <h4>
      <i class="fa fa-spinner fa-spin"></i>
      ${Wait for it ...}
    </h4>
  </div>
</div>
<div class="row" ng-show="c.data.phase == 1">
  <div style="text-align: center;">
    <h3>${Collaboration Store Set-up}</h3>
  </div>
  <div>
    <p>
      Welcome to the Collaboration Store set-up process.
      There are two ways that you can set up the Collaboration Store on your instance:
      1) you can be the Host Instance to which all other instances connect, or
      2) you can connect to an existing Collaboration Store with their permission.
      To become the Host Instance of your own Collaboration Store, select <em>This instance will be
      the Host of the store</em> from the Installation Type choices below.
      If you are not the Host Instance, then you will need to provide the Instance ID of the
      Collaboration Store to which you would like to connect and the Host instance will need to be
      available to complete the set-up process.
    </p>
  </div>
  <form id="form1" name="form1" novalidate>
    <div class="row">
      <div class="col-xs-12 col-sm-6">
        <span>
          <snh-form-field
            snh-model="c.data.instance_type"
            snh-name="instance_type"
            snh-label="${Installation Type}"
            snh-type="select"
            snh-required="true"
            snh-choices='[{"value":"host", "label":"This instance will be the Host of the store"},
                          {"value":"client", "label":"This instance will connect to an existing store"}]'/>
        </span>
        <span>
          <snh-form-field
            snh-model="c.data.host_instance_id"
            snh-name="host_instance_id"
            snh-label="${Host Instance ID}"
            snh-help="Enter the instance ID only, not the full URL of the instance (https://{instance_id}.servicenow.com))"
            snh-required="c.data.instance_type == 'client'"
            ng-show="c.data.instance_type == 'client'"/>
        </span>
        <span>
          <snh-form-field
            snh-model="c.data.store_name"
            snh-name="store_name"
            snh-label="${Store Name}"
            snh-required="c.data.instance_type == 'host'"
            ng-show="c.data.instance_type == 'host'"/>
        </span>
        <span>
          <snh-form-field
            snh-model="c.data.instance_name"
            snh-name="instance_name"
            snh-label="${Instance Label}"
            snh-required="true"/>
        </span>
      </div>
      <div class="col-xs-12 col-sm-6 text-center">
        <div class="row" style="padding: 15px;">
          <div class="avatar-extra-large avatar-container" style="cursor:default;">
            <div class="avatar soloAvatar bottom">
              <div class="sub-avatar mia" ng-style="instanceLogoImage"><i class="fa fa-image"></i></div>
            </div>
          </div>
        </div>
        <div class="row" style="padding: 15px;">
          <input ng-show="false" type="file" accept="image/jpeg,image/png,image/bmp,image/x-windows-bmp,image/gif,image/x-icon,image/svg+xml" ng-file-select="attachFiles({files: $files})" />
          <button ng-click="uploadInstanceLogoImage($event)"
                  ng-keypress="uploadInstanceLogoImage($event)" type="button"
                  class="btn btn-default send-message">${Upload Instance Logo Image}</button>
        </div>
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12">
        <span>
          <snh-form-field
            snh-model="c.data.email"
            snh-name="email"
            snh-label="${Email}"
            snh-help="A verification email will be sent to this address as part of the set-up process"
            snh-type="email"
            snh-required="true"/>
        </span>
        <span>
          <snh-form-field
            snh-model="c.data.description"
            snh-name="description"
            snh-label="${Instance Description}"
            snh-type="textarea"
            snh-required="true"/>
        </span>
      </div>
    </div>
  </form>
  <div class="row">
    <div class="col-sm-12" style="text-align: center;">
      <button class="btn btn-primary" ng-disabled="!(form1.$valid) || !c.data.validScope" ng-show="c.data.instance_type == 'host'" ng-click="save();">${Create New Collaboration Store}</button>
      <button class="btn btn-primary" ng-disabled="!(form1.$valid) || !c.data.validScope" ng-show="c.data.instance_type == 'client'" ng-click="save();">${Complete Set-up and Request Access}</button>
    </div>
  </div>
</div>
<div class="row" ng-show="c.data.phase == 2">
  <div style="text-align: center;">
    <h3>${Email Verification}</h3>
  </div>
  <div>
    <p>
      A verification email has been sent to {{c.data.email}} with a one-time security code.
      Please enter the code below to continue.
    </p>
    <p>
      Cancelling this process will terminate the set-up process.
    </p>
  </div>
  <form id="form2" name="form2" novalidate>
    <div class="row">
      <div class="col-sm-12">
        <span>
          <snh-form-field
            snh-model="c.data.security_code"
            snh-name="security_code"
            snh-label="Security Code"
            snh-required="true"
            placeholder="Enter the security code sent to you via email"/>
        </span>
      </div>
    </div>
  </form>
  <div class="row">
    <div class="col-sm-12" style="text-align: center;">
      <button class="btn btn-default" ng-disabled="!(form2.$valid)" ng-click="cancel();">${Cancel}</button>
      <button class="btn btn-primary" ng-disabled="!(form2.$valid)" ng-click="verify();">${Submit Verification Code}</button>
    </div>
  </div>
</div>
<div class="row" ng-show="c.data.phase == 3">
  <div style="text-align: center;">
    <h3>${Set Up Complete!}</h3>
  </div>
  <div>
    <p>${Congratulations!}</p>
    <p ng-if="c.data.instance_type == 'client'">
      The Collaboration Store set-up is now complete. Your instance has been successfully registered with the
      <b class="text-primary">{{c.data.registeredHostName}}</b> ({{c.data.registeredHost}})
      Host and is now ready to utilize the Collaboration Store features.
    </p>
    <p ng-if="c.data.instance_type == 'host'">
      The Collaboration Store set-up is now complete. Your instance has been successfully set up as a
      Host instance and is now ready to accept client registrations and utilize the Collaboration
      Store features.
    </p>
  </div>
</div>

It’s not the best, but it does work. If you are one of those kind souls helping out with the testing of the Collaboration Store, and you happen to be doing your testing on a Tokyo instance, you can just grab the code above and overlay the HTML for the set-up widget with this version. That should get you up and running again. This will definitely be included in the next early release for testing purposes, but if you don’t want to wait for that, just snag the code above.

The best solution, of course, is to continue digging until we find the source of the true problem here and release a new version of SNH Form Fields that does not contain this issue. Hopefully, it won’t be too long before we can make that happen.

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.

Scripted Value Columns, Part VIII

“Too many men work on parts of things. Doing a job to completion satisfies me.”
Richard Proenneke

Last time, we wanted to wrap up the modifications on the last two wrapper widgets and put out a new Update Set, but we discovered that we missed an important element in our list of things that would need to be modified, the Configurable Data Table Widget Content Selector widget. We need to take a look at that guy and see what needs to be done to accommodate scripted value columns, and then retest the third wrapper widget, which shares the page with this component.

A quick scan of the Server script for aggarray comes up empty, but in the Client script, we come across this:

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

Making a copy of that and doing a little string replacement here and there gives us an equivalent block of code for the new scripted value column configurations.

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

And that seems to be all there is to that. Now we can go back to our last test and run it again to see if that fixed our problem.

Successful test of the third wrapper widget

That’s better! Now we have a column for the Last Comment, and we even have a row with some data in it. Good deal. And just to check on the content selector widget, we can look at the URL that it built to see how the configuration options for the scripted value columns appeared in the URL.

/sp?id=my_things&table=incident&filter=caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe^active%3Dtrue&fields=number,opened_by,opened_at,short_description&scripteds=[{"heading":"Last Comment","name":"last_comment","label":"Last Comment","script":"global.ScriptedJournalValueProvider"}]&aggregates=&buttons=&refpage={"sys_user":"user_profile"}&px=requester&sx=open&spa=1&p=1&o=opened_at&d=asc

Well, that’s the whole thing, but we can zoom in on the part in which we have an interest.

scripteds=[{"heading":"Last Comment","name":"last_comment","label":"Last Comment","script":"global.ScriptedJournalValueProvider"}]

So that is the last of the wrapper widgets, and unless we have left something else out, that’s the last of the work to be done to implement this new feature. Now all that is left is to bundle the whole thing up into a new Update Set and post it out on Share as a new version.

Here is the new Update Set, and here is where you can find it on Share. If you happen to use it, find it to be of value, or run into any issues, please let us all know in the comments below.

Scripted Value Columns, Part VII

“Unplanned occurrences are reminders to check your tendency to think that you’re the one in control.”
James Martin

Last time, we created another example of how one might utilize the new scripted value column feature, this time with catalog item variables instead of Incident journal entries. There are a number of other things that we could try, but two examples should be enough to get the point across, and I’ll leave it to others to come up with additional examples of their own.

We still have two more wrapper widgets to update, though, and we still have that annoying misalignment between the original columns and the new. Here is the way things come out right now:

Misalignment of original columns and new columns

… and here is the way that it should look:

Correct alignment of original columns and new columns

I was able to capture that second image because I found and fixed the problem. I had to replace this HTML:

<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>   

… with this:

<span style="display: inline-flex;">
  <span style="display: inline-flex;" ng-if="item[field].value && item[field].type == 'reference' && item[field].table == 'sys_user'">
    <sn-avatar primary="item[field].value" class="avatar-small" show-presence="true" enable-context-menu="false"></sn-avatar>
    &nbsp;
  </span>
  <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>
</span>

Now, without getting into too much detail that no one really cares about, the source of the problem was the sn-avatar tag, which I added a while back so that user columns would have the avatar in front of the name. For some reason, the tag renders out a carriage return and a handful of spaces just before the avatar image. With the ng-if attribute set to false, this collection of white space is still rendered on the page, even when the avatar itself is not. I solved that problem by wrapping the avatar tag with a span and putting the ng-if attribute on the outer span rather than on the sn-avatar tag. That took care of things for columns where there was no avatar, but the user columns, which show the avatar, were still out of alignment with the rest of the columns. Adding style=”display: inline-flex; took care of that problem with the avatar, but then the user name ended up underneath the avatar instead of next to it. To solve that problem, I wrapped the whole thing in another span with the same style attribute. Now everything lines up the way that it should.

Now that that is out of the way, we still have two more wrapper widgets to update. Let’s jump into the SNH Data Table from Instance Definition and do the same kind of searching we did before, looking for some code that might need to be copied and modified. On this particular widget, such a search turns up nothing at all in either the Server script or the Client script, so the only thing that we really need to do is to add another entry to the Option schema for our new scripted value column specification.

{"hint":"A JSON object containing the specifications for scripted value columns",
"name":"scripteds",
"default_value":"",
"section":"Behavior",
"label":"Scripted Value Column Specifications (JSON)",
"type":"String"}

To test this, we can modify our scripted_value_test_2 page to use this widget instead of the SNH Data Table from JSON Configuration widget, and then transfer our configuration options from the Script Include to the widget options.

Widget configuration options for the modified wrapper widget

Now all we need to do is to save it and then run out to the Service Portal and take a quick peek.

Testing the modified SNH Data Table from JSON Configuration widget

So that all looks good. And much, much better now that the column data all lines up as it should! It’s nice to finally have that fixed. That takes care of wrapper widget #2. Now let’s take a look at that last one, the SNH Data Table from URL Definition widget. The only line that appears to require modification is this one:

copyParameters(data, ['aggregates', 'buttons', 'refpage', 'bulkactions']);

… which we can convert to this to pick up our new configuration option:

copyParameters(data, ['scripteds', 'aggregates', 'buttons', 'refpage', 'bulkactions']);

Now we need to test it, so we will need to find or create a page that use this widget. Let’s take a look at the ones that are already out there by checking out the Related List down at the bottom of the form.

Pages that use the SNH Data Table from URL Definition widget

The page my_things looks like a good candidate, so we can take a look at the configuration script that it uses and then edit it to add one or more scripted value columns. One of the tables utilized on that page is the Incident table, so let’s go ahead and use our existing value provider script to add a journal entry column to one of those.

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

Now let’s take a look.

First test of the modified SNH Data Table from URL Definition widget

Well, that didn’t work! It’s always something. Even if there were no comments on any of these Incidents, we should still have a column heading for our new scripted value column. I don’t think this problem is in the widget that we just modified, however. This widget shares the page with the Configurable Data Table Widget Content Selector widget, and that is a widget that we have not even touched. That is going to have to be modified to accommodate our new feature as well, as it builds the URL that the SNH Data Table from URL Definition widget turns to for its configuration information. This was not on our list of things to do for this feature, but it definitely needs to be done.

I was hoping to wrap things up with this installment, but now we have a new widget to modify and more testing to do, so I think we will just save all of that, plus the Update Set creation, for our next time out.

Scripted Value Columns, Part III

“Every day you may make progress. Every step may be fruitful. Yet there will stretch out before you an ever-lengthening, ever-ascending, ever-improving path.”
Winston Churchill

Last time, we took care of the configuration script editor and now we need to turn our attention to the main SNH Data Table Widgets starting with the core widget, SNH Data Table. As we did with the editor, we can search the various sections of the widget for aggarray, copy the relevant code, and modify it to handle the new svcarrary. As usual, we can start with the HTML, where we find a couple of sections, one for the headings:

<th ng-repeat="scripted in data.svcarray" class="text-nowrap center" tabindex="0">
  {{scripted.heading || scripted.label}}
</th>

… and one for the data columns:

<td ng-repeat="obj in item.svcValue" role="cell" class="sp-list-cell" ng-class="{selected: item.selected}" tabindex="0">
  {{obj.value}}
</td>

That takes care of the HTML. Now we need to take a look at the Server script. The first thing that we come across is this added block of comments:

// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements

So we will modify that to include two new properties for our new feature.

// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.scripteds = the JSON string containing the scripted value column specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.svcarray = the array of scripted value column specifications
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements

The next reference to aggarray is this added variable copy statement:

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	...
// End: SNH Data Table enhancements

So we will add our new variables to this list.

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'scripteds', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'svcarray', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	...
// End: SNH Data Table enhancements

Shortly after that, we come to this code that validates and initializes the aggarray value.

if (data.aggregates) {
	try {
		var aggregateinfo = JSON.parse(data.aggregates);
		if (Array.isArray(aggregateinfo)) {
			data.aggarray = aggregateinfo;
		} else if (typeof aggregateinfo == 'object') {
			data.aggarray = [];
			data.aggarray[0] = aggregateinfo;
		} else {
			gs.error('Invalid aggregates option in SNH Data Table widget: ' + data.aggregates);
			data.aggarray = [];
		}
	} catch (e) {
		gs.error('Unparsable aggregates option in SNH Data Table widget: ' + data.aggregates);
		data.aggarray = [];
	}
} else {
	if (!data.aggarray) {
		data.aggarray = [];
	}
}

So we can copy that, and add a section just like it for the new svcarray.

if (data.scripteds) {
	try {
		var scriptedinfo = JSON.parse(data.scripteds);
		if (Array.isArray(scriptedinfo)) {
			data.svcarray = scriptedinfo;
		} else if (typeof scriptedinfo == 'object') {
			data.svcarray = [];
			data.svcarray[0] = scriptedinfo;
		} else {
			gs.error('Invalid scripteds option in SNH Data Table widget: ' + data.scripteds);
			data.svcarray = [];
		}
	} catch (e) {
		gs.error('Unparsable scripteds option in SNH Data Table widget: ' + data.scripteds);
		data.svcarray = [];
	}
} else {
	if (!data.svcarray) {
		data.svcarray = [];
	}
}

The next reference that we find is the code that actually adds the values to the records. For the aggregate columns, that code looks like this:

record.aggValue = [];
if (data.aggarray.length > 0) {
	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));
	}
}

We can make a copy of this section as well, but since the scripted values do not require a source property, our new section will be even simpler.

record.svcValue = [];
if (data.svcarray.length > 0) {
	for (var j=0; j<data.svcarray.length; j++) {
		record.svcValue.push(getScriptedValue(record, data.svcarray[j]));
	}
}

Of course, now we have referenced a function that doesn’t yet exist, but that is in fact the next and last reference that we come across. The function for the aggregates looks like this:

// Start: SNH Data Table enhancements
	function getAggregateValue(sys_id, config) {
		var value = 0;
		var ga = new GlideAggregate(config.table);
		ga.addAggregate('COUNT');
		var query = config.field + '=' + sys_id;
		if (config.filter) {
			query += '^' + config.filter;
		}
		ga.addEncodedQuery(query);
		ga.query();
		if (ga.next()) {
			value = parseInt(ga.getAggregate('COUNT'));
		}
		var response = {value: value};
		if (config.hint || config.page_id) {
			response.name = config.name;
		}
		return response;
	}
// End: SNH Data Table enhancements

Here is where we have to do something completely different from the original that we are copying. For the aggregate columns, we are actually doing the query to count the related records. For our new purpose, we are just going to grab an instance of the specified script and call the function on the script to get the value. Since we will be calling the same script for every row, it would be better to fetch the instance of the script once and hang on to it so that the same instance could be used again and again. To support that, we can establish a map of instances and an instance of the Instantiator up near the top.

var instantiator = new Instantiator(this);
var scriptMap = {};

With that in place, we can add the following new function to support the new scripted value columns.

function getScriptedValue(record, config) {
	var response = {value: ''};
	var scriptName = config.script;
	if (scriptName) {
		if (scriptName.startsWith('global.')) {
			scriptName = scriptName.split('.')[1];
		}
		if (!scriptMap[scriptName]) {
			scriptMap[scriptName] = instantiator.getInstance(scriptName);
		}
		if (scriptMap[scriptName]) {
			response.value = scriptMap[scriptName].getScriptedValue(record, config);
		}
	}
	return response;
}

That’s it for the Server script. The whole thing now looks like this:

(function() {
	if (!input) // asynch load list
		return;

	data.msg = {};
	data.msg.sortingByAsc = gs.getMessage("Sorting by ascending");
	data.msg.sortingByDesc = gs.getMessage("Sorting by descending");

	/*
	 * data.table = the table
	 * data.p = the current page starting at 1
	 * data.o = the order by column
	 * data.d = the order by direction
	 * data.keywords = the keyword search term
	 * data.list = the table data as an array
	 * data.invalid_table = true if table is invalid or if data was not succesfully fetched
	 * data.table_label = the table's display name. e.g. Incident
	 * data.table_plural = the table's plural display name. e.g. Incidents
	 * data.fields = a comma delimited list of field names to show in the data table
	 * data.column_labels = a map of field name -> display name
	 * data.window_size = the number of rows to show
	 * data.filter = the encoded query
// Start: SNH Data Table enhancements
	 * data.bulkactions = the JSON string containing the bulk action specifications
	 * data.refpage = the JSON string containing the reference link specifications
	 * data.scripteds = the JSON string containing the scripted value column specifications
	 * data.aggregates = the JSON string containing the aggregate column specifications
	 * data.buttons = the JSON string containing the button specifications
	 * data.actarray = the bulk actions specifications object
	 * data.refmap = the reference link specifications object
	 * data.svcarray = the array of scripted value column specifications
	 * data.aggarray = the array of aggregate column specifications
	 * data.btnarray = the array of button specifications
// End: SNH Data Table enhancements
	 */
	// copy to data[name] from input[name] || option[name]
	optCopy(['table', 'p', 'o', 'd', 'filter', 'filterACLs', 'fields', 'keywords', 'view']);
	optCopy(['relationship_id', 'apply_to', 'apply_to_sys_id', 'window_size']);

// Start: SNH Data Table enhancements
	optCopy(['table_name', 'scripteds', 'aggregates', 'buttons', 'btns', 'refpage', 'bulkactions', 'svcarray', 'aggarray', 'btnarray', 'refmap', 'actarray', 'field_list']);

	// for some reason, 'buttons' and 'table' sometimes get lost in translation ...
	if (data.btns) {
		data.buttons = data.btns;
	}
	if (data.table_name) {
		data.table = data.table_name;
	}
// End: SNH Data Table enhancements

	if (!data.table) {
		data.invalid_table = true;
		data.table_label = "";
		return;
	}

// Start: SNH Data Table enhancements
	var instantiator = new Instantiator(this);
	var scriptMap = {};
	if (data.scripteds) {
		try {
			var scriptedinfo = JSON.parse(data.scripteds);
			if (Array.isArray(scriptedinfo)) {
				data.svcarray = scriptedinfo;
			} else if (typeof scriptedinfo == 'object') {
				data.svcarray = [];
				data.svcarray[0] = scriptedinfo;
			} else {
				gs.error('Invalid scripteds option in SNH Data Table widget: ' + data.scripteds);
				data.svcarray = [];
			}
		} catch (e) {
			gs.error('Unparsable scripteds option in SNH Data Table widget: ' + data.scripteds);
			data.svcarray = [];
		}
	} else {
		if (!data.svcarray) {
			data.svcarray = [];
		}
	}

	if (data.aggregates) {
		try {
			var aggregateinfo = JSON.parse(data.aggregates);
			if (Array.isArray(aggregateinfo)) {
				data.aggarray = aggregateinfo;
			} else if (typeof aggregateinfo == 'object') {
				data.aggarray = [];
				data.aggarray[0] = aggregateinfo;
			} else {
				gs.error('Invalid aggregates option in SNH Data Table widget: ' + data.aggregates);
				data.aggarray = [];
			}
		} catch (e) {
			gs.error('Unparsable aggregates option in SNH Data Table widget: ' + data.aggregates);
			data.aggarray = [];
		}
	} else {
		if (!data.aggarray) {
			data.aggarray = [];
		}
	}

	if (data.buttons) {
		try {
			var buttoninfo = JSON.parse(data.buttons);
			if (Array.isArray(buttoninfo)) {
				data.btnarray = buttoninfo;
			} else if (typeof buttoninfo == 'object') {
				data.btnarray = [];
				data.btnarray[0] = buttoninfo;
			} else {
				gs.error('Invalid buttons option in SNH Data Table widget: ' + data.buttons);
				data.btnarray = [];
			}
		} catch (e) {
			gs.error('Unparsable buttons option in SNH Data Table widget: ' + data.buttons);
			data.btnarray = [];
		}
	} else {
		if (!data.btnarray) {
			data.btnarray = [];
		}
	}

	if (data.refpage) {
		try {
			var refinfo = JSON.parse(data.refpage);
			if (typeof refinfo == 'object') {
				data.refmap = refinfo;
			} else {
				gs.error('Invalid reference page option in SNH Data Table widget: ' + data.refpage);
				data.refmap = {};
			}
		} catch (e) {
			gs.error('Unparsable reference page option in SNH Data Table widget: ' + data.refpage);
			data.refmap = {};
		}
	} else {
		if (!data.refmap) {
			data.refmap = {};
		}
	}

	if (data.bulkactions) {
		try {
			var actioninfo = JSON.parse(data.bulkactions);
			if (Array.isArray(actioninfo)) {
				data.actarray = actioninfo;
			} else if (typeof actioninfo == 'object') {
				data.actarray = [];
				data.actarray[0] = actioninfo;
			} else {
				gs.error('Invalid bulk actions in SNH Data Table widget: ' + data.bulkactions);
				data.actarray = [];
			}
		} catch (e) {
			gs.error('Unparsable bulk actions in SNH Data Table widget: ' + data.bulkactions);
			data.actarray = [];
		}
	} else {
		if (!data.actarray) {
			data.actarray = [];
		}
	}

	if (!data.fields) {
		if (data.field_list) {
			data.fields = data.field_list;
		} else if (data.view) {
			data.fields = $sp.getListColumns(data.table, data.view);
		} else {
			data.fields = $sp.getListColumns(data.table);
		}
	}
// End: SNH Data Table enhancements

	data.view = data.view || 'mobile';
	data.table = data.table || $sp.getValue('table');
	data.filter = data.filter || $sp.getValue('filter');
	data.keywords = data.keywords || $sp.getValue('keywords');
	data.p = data.p || $sp.getValue('p') || 1;
	data.p = parseInt(data.p);
	data.o = data.o || $sp.getValue('o') || $sp.getValue('order_by');
	data.d = data.d || $sp.getValue('d') || $sp.getValue('order_direction') || 'asc';
	data.useTinyUrl = gs.getProperty('glide.use_tiny_urls') === 'true';
	data.tinyUrlMinLength = gs.getProperty('glide.tiny_url_min_length');

// Start: SNH Data Table enhancements
	if (data.filter && data.filter.indexOf('{{sys_id}}')) {
		data.filter = data.filter.replace('{{sys_id}}', $sp.getParameter('sys_id'));
	}
// End: SNH Data Table enhancements


	var grForMetaData = new GlideRecord(data.table);

	if (input.setOrderUserPreferences) {
		// update User Preferences on a manual sort for UI consistency
		gs.getUser().savePreference(data.table + ".db.order", data.o);
		gs.getUser().savePreference(data.table + ".db.order.direction", data.d == "asc" ? "" : "DESC");
		data.setOrderUserPreferences = false;
	}
	// if no sort specified, find a default column for UI consistency
	if (!data.o)
		getOrderColumn();

	data.page_index = data.p - 1;
	data.show_new = data.show_new || options.show_new;
	var windowSize = data.window_size || $sp.getValue('maximum_entries') || 20;
	windowSize = parseInt(windowSize);
	if (isNaN(windowSize) || windowSize < 1)
		windowSize = 20;
	data.window_size = windowSize;

	var gr;
	// FilteredGlideRecord is not supported in scoped apps, so GlideRecordSecure will always be used in an application scope
	if (typeof FilteredGlideRecord != "undefined" && (gs.getProperty("glide.security.ui.filter") == "true" || grForMetaData.getAttribute("glide.security.ui.filter") != null)) {
		gr = new FilteredGlideRecord(data.table);
		gr.applyRowSecurity();
	} else
		gr = new GlideRecordSecure(data.table);
	if (!gr.isValid()) {
		data.invalid_table = true;
		data.table_label = data.table;
		return;
	}

	data.canCreate = gr.canCreate();
	data.newButtonUnsupported = data.table == "sys_attachment";
	data.table_label = gr.getLabel();
	data.table_plural = gr.getPlural();
	data.title = input.useInstanceTitle && input.headerTitle ? gs.getMessage(input.headerTitle) : data.table_plural;
	data.hasTextIndex = $sp.hasTextIndex(data.table);
	if (data.filter) {
		if (data.filterACLs)
			gr = $sp.addQueryString(gr, data.filter);
		else
			gr.addEncodedQuery(data.filter);
	}
	if (data.keywords) {
		gr.addQuery('123TEXTQUERY321', data.keywords);
		data.keywords = null;
	}

	data.filter = gr.getEncodedQuery();

	if (data.relationship_id) {
		var rel = GlideRelationship.get(data.relationship_id);
		var target = new GlideRecord(data.table);
		var applyTo = new GlideRecord(data.apply_to);
		applyTo.get("sys_id", data.apply_to_sys_id);
		rel.queryWith(applyTo, target); // put the relationship query into target
		data.exportQuery = target.getEncodedQuery();
		gr.addEncodedQuery(data.exportQuery); // get the query the relationship made for us
	}
	if (data.exportQuery)
		data.exportQuery += '^' + data.filter;
	else
		data.exportQuery = data.filter;
	data.exportQueryEncoded = encodeURIComponent(data.exportQuery);
	if (data.o){
		if (data.d == "asc")
			gr.orderBy(data.o);
		else
			gr.orderByDesc(data.o);
		if (gs.getProperty("glide.secondary.query.sysid") == "true")
			gr.orderBy("sys_id");
	}

	data.window_start = data.page_index * data.window_size;
	data.window_end = (data.page_index + 1) * data.window_size;
	gr.chooseWindow(data.window_start, data.window_end);
	gr.setCategory("service_portal_list");
	gr._query();

	data.row_count = gr.getRowCount();
	data.num_pages = Math.ceil(data.row_count / data.window_size);
	data.column_labels = {};
	data.column_types = {};
	data.fields_array = data.fields.split(',');

	// use GlideRecord to get field labels vs. GlideRecordSecure
	for (var i in data.fields_array) {
		var field = data.fields_array[i];
		var ge = grForMetaData.getElement(field);
		if (ge == null)
			continue;

		data.column_labels[field] = ge.getLabel();
		data.column_types[field] = ge.getED().getInternalType();
	}

	data.list = [];
	while (gr._next()) {
		var record = {};
		$sp.getRecordElements(record, gr, data.fields);
		if (typeof FilteredGlideRecord != "undefined" && gr instanceof FilteredGlideRecord) {
			// FilteredGlideRecord doesn't do field-level
			// security, so take care of that here
			for (var f in data.fields_array) {
				var fld = data.fields_array[f];
				if (!gr.isValidField(fld))
					continue;

				if (!gr[fld].canRead()) {
					record[fld].value = null;
					record[fld].display_value = null;
				}
			}
		}
		record.sys_id = gr.getValue('sys_id');

// Start: SNH Data Table enhancements
		for (var f in data.fields_array) {
			var fld = data.fields_array[f];
			if (record[fld].type == 'reference') {
				var refGr = gr;
				var refFld = fld;
				if (fld.indexOf('.') != -1) {
					var parts = fld.split('.');
					for (var x=0;x<parts.length-1;x++) {
						refGr = refGr[parts[x]].getRefRecord();
					}
					refFld = parts[parts.length-1];
				}
				if (refGr.isValidField(refFld)) {
					record[fld].table = refGr.getElement(refFld).getED().getReference();
					record[fld].record = {type: 'reference', sys_id: {value: record[fld].value, display_value: record[fld].value}, name: {value: record[fld].display_value, display_value: record[fld].display_value}};
				}
			}
		}
		record.svcValue = [];
		if (data.svcarray.length > 0) {
			for (var j=0; j<data.svcarray.length; j++) {
				record.svcValue.push(getScriptedValue(record, data.svcarray[j]));
			}
		}
		record.aggValue = [];
		if (data.aggarray.length > 0) {
			for (var k=0; k<data.aggarray.length; k++) {
				var config = data.aggarray[k];
				var sysId = record.sys_id;
				if (config.source) {
					sysId = gr.getValue(config.source);
				}
				record.aggValue.push(getAggregateValue(sysId, config));
			}
		}
// End: SNH Data Table enhancements

		record.targetTable = gr.getRecordClassName();
		data.list.push(record);
	}

	data.enable_filter = (input.enable_filter == true || input.enable_filter == "true" ||
		options.enable_filter == true || options.enable_filter == "true");
	var breadcrumbWidgetParams = {
		table: data.table,
		query: data.filter,
		enable_filter: data.enable_filter
	};
	data.filterBreadcrumbs = $sp.getWidget('widget-filter-breadcrumbs', breadcrumbWidgetParams);

	// copy to data from input or options
	function optCopy(names) {
		names.forEach(function(name) {
			data[name] = input[name] || options[name];
		})
	}

	// getOrderColumn logic mirrors that of Desktop UI when no sort column is specified
	function getOrderColumn() {
		// First check for user preference
		var pref = gs.getUser().getPreference(data.table + ".db.order");
		if (!GlideStringUtil.nil(pref)) {
			data.o = pref;
			if (gs.getUser().getPreference(data.table + ".db.order.direction") == "DESC")
				data.d = 'desc';
			return;
		}

		// If no user pref, check for table default using same logic as Desktop UI:
		// 1) if task, use number
		// 2) if any field has isOrder attribute, use that
		// 3) use order, number, name column if exists (in that priority)
		if (grForMetaData.isValidField("sys_id") && grForMetaData.getElement("sys_id").getED().getFirstTableName() == "task") {
			data.o = "number";
			return;
		}

		// Next check for isOrder attribute on any column
		var elements = grForMetaData.getElements();
		// Global and scoped GlideRecord.getElements return two different things,
		// so convert to Array if needed before looping through
		if (typeof elements.size != "undefined") {
			var elementArr = [];
			for (var i = 0; i < elements.size(); i++)
				elementArr.push(elements.get(i));
			elements = elementArr;
		}
		// Now we can loop through
		for (var j = 0; elements.length > j; j++) {
			var element = elements[j];
			if (element.getAttribute("isOrder") == "true") {
				data.o = element.getName();
				return;
			}
		}
		// As last resort, sort on Order, Number, or Name column
		if (grForMetaData.isValidField("order"))
			data.o = "order";
		else if (grForMetaData.isValidField("number"))
			data.o = "number";
		else if (grForMetaData.isValidField("name"))
			data.o = "name";
	}

// Start: SNH Data Table enhancements
	function getScriptedValue(record, config) {
		var response = {value: ''};
		var scriptName = config.script;
		if (scriptName) {
			if (scriptName.startsWith('global.')) {
				scriptName = scriptName.split('.')[1];
			}
			if (!scriptMap[scriptName]) {
				scriptMap[scriptName] = instantiator.getInstance(scriptName);
			}
			if (scriptMap[scriptName]) {
				response.value = scriptMap[scriptName].getScriptedValue(record, config);
			}
		}
		return response;
	}

	function getAggregateValue(sys_id, config) {
		var value = 0;
		var ga = new GlideAggregate(config.table);
		ga.addAggregate('COUNT');
		var query = config.field + '=' + sys_id;
		if (config.filter) {
			query += '^' + config.filter;
		}
		ga.addEncodedQuery(query);
		ga.query();
		if (ga.next()) {
			value = parseInt(ga.getAggregate('COUNT'));
		}
		var response = {value: value};
		if (config.hint || config.page_id) {
			response.name = config.name;
		}
		return response;
	}
// End: SNH Data Table enhancements

})();

There are no changes needed to the Client script, or any other area, so we are done with the modifications to this widget. Now would be a good time to try it out, but we will need at least one of the three wrapper widgets to be updated before we can give things a try. That sounds like a good project for our next installment.

Scripted Value Columns, Part II

“Progress always involves risks. You can’t steal second base and keep your foot on first.”
Frederick B. Wilcox

Last time, we introduced the concept of Scripted Value Columns, created a sample script for testing, and built the pop-up editor widget. Now we need to modify the Content Selector Configurator widget to accommodate the new configuration option. As usual, we can start with the HTML, and as we have done before, we can copy one of the existing sections and then alter it to meet our new list of properties for this type of column.

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

Basically, this is just another table of configuration properties with labels and action buttons. To support the buttons we can copy similar functions for the other types of configurable columns, but before we do that, let’s jump into the Server script and see what we need to do there. We have called our new array of column configurations svcarray, and we can search the scripts for one of the existing arrays such as aggarray and basically cut and paste the existing code to support the new addition.

The only mention of aggarray on the server side is in the code that rebuilds the script being edited from data collected by the editor. We can make a quick copy of that section and then modify it for our purpose.

script += "',\n				svcarray: [";
var lastSeparator = '';
for (var v=0; v<tableTable[tableState.name].svcarray.length; v++) {
	var thisScriptedValue = tableTable[tableState.name].svcarray[v];
	script += lastSeparator;
	script += "{\nname: '";
	script += thisScriptedValue.name;
	script += "',\nlabel: '";
	script += thisScriptedValue.label;
	script += "',\nheading: '";
	script += thisScriptedValue.heading;
	script += "',\nscript: '";
	script += thisScriptedValue.script;
	script += "'\n				}";
	lastSeparator = ",";
}
script += "]";

While we are in the Server script, there is one more thing that we should do. Since this is a new configuration item, if you attempt to edit an existing item that was built using an earlier version, this array will not be present in the existing script that you will be editing. To make sure that we do not run into any null pointer issues with code expecting this array to exist, we should do a quick check when we load the script for editing and make sure that all of the configuration objects are present. There is already code in there to help initialize the data once the script has been loaded. Right now it looks like this:

var instantiator = new Instantiator(this);
var configScript = instantiator.getInstance(data.scriptInclude);
if (configScript != null) {
	data.config = configScript.getConfig($sp);
	for (var persp in data.config.table) {
		for (var tbl in data.config.table[persp]) {
			if (!data.config.table[persp][tbl].displayName) {
				data.config.table[persp][tbl].displayName = getItemName(data.config.table[persp][tbl].name);
			}
		}
	}
}

We are looping through every table in every perspective already, so all that we need to do is loop through every state in every table and check all of the objects that will need to be there for things to work. That will make the above section of code now look like this:

var instantiator = new Instantiator(this);
var configScript = instantiator.getInstance(data.scriptInclude);
if (configScript != null) {
	data.config = configScript.getConfig($sp);
	for (var persp in data.config.table) {
		for (var tbl in data.config.table[persp]) {
			if (!data.config.table[persp][tbl].displayName) {
				data.config.table[persp][tbl].displayName = getItemName(data.config.table[persp][tbl].name);
			}
			for (var st in data.config.state) {
				var thisState = data.config.table[persp][tbl][data.config.state[st].name];
				if (!thisState) {
					thisState = {};
					data.config.table[persp][tbl][data.config.state[st].name] = thisState;
				}
				thisState.svcarray = thisState.svcarray || [];
				thisState.aggarray = thisState.aggarray || [];
				thisState.btnarray = thisState.btnarray || [];
				thisState.refmap = thisState.refmap || {};
				thisState.actarray = thisState.actarray || [];
			}
		}
	}
}

That takes care of the Server script. Now let’s do the same sort of searching on the Client script. The first thing that we find is actually a problem that was created when we added the aggregate columns. Whenever the user selects the Add a New Table button, we create an object to store the configuration for the new table. That code currently looks like this:

$scope.newTable = function(perspective) {
	var shared = {};
	spModal.open({
		title: 'Table Selector',
		widget: 'table-selector',
		shared: shared
	}).then(function() {
		for (var st=0; st<c.data.config.state.length; st++) {
			shared[c.data.config.state[st].name] = {btnarray: [], refmap: {}, actarray: []};
		}
		c.data.config.table[perspective].push(shared);
	});
};

For each state of the new table, we are creating empty objects for the buttons, the reference map, and the bulk actions. When we added the aggregate columns, we neglected to add an empty object for that new array, which may actually cause a problem with certain actions. So we need to add the new svcarray as well as the aggarray to make things right. That will make this code now look like this:

$scope.newTable = function(perspective) {
	var shared = {};
	spModal.open({
		title: 'Table Selector',
		widget: 'table-selector',
		shared: shared
	}).then(function() {
		for (var st=0; st<c.data.config.state.length; st++) {
			shared[c.data.config.state[st].name] = {svcarray: [], aggarray: [], btnarray: [], refmap: {}, actarray: []};
		}
		c.data.config.table[perspective].push(shared);
	});
};

The next thing that you fill find on the client side are the functions that handle the clicks for the Edit and Delete buttons. Copying those and modifying them for Scripted Value Columns gives us this new block of code:

$scope.editScriptedValueColumn = function(scripted_value, svcArray) {
	var shared = {script: {value: '', displayValue: ''}};
	if (scripted_value != 'new') {
		shared.label = scripted_value.label;
		shared.name = scripted_value.name;
		shared.heading = scripted_value.heading;
		shared.script = {value: scripted_value.script, displayValue: scripted_value.script};
	}
	spModal.open({
		title: 'Scripted Value Column Editor',
		widget: 'scripted-value-column-editor',
		shared: shared
	}).then(function() {
		if (scripted_value == 'new') {
			scripted_value = {};
			svcArray.push(scripted_value);
		}
		scripted_value.label = shared.label || '';
		scripted_value.name = shared.name || '';
		scripted_value.heading = shared.heading || '';
		scripted_value.script = shared.script.value || '';
	});
};

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

It looks like there is nothing else after that, so all we need to do at this point is to save the changes and give it a try. Let’s pull up one of our test configuration scripts in the editor and see what the new Scripted Value Columns section looks like.

New Scripted Value Columns section of the editor

Well, it appears on the screen, so that’s a good start. Now let’s hit that Add button and see if our pop-up editor works.

Pop-up Scripted Value Column editor

That looks good as well. Now let’s hit that Save button, which should return us to the main editor and our new scripted value column should show up on the list.

Scripted Value Columns section of the editor after adding a new entry

And there it is. Good deal. After saving the script and examining the resulting Script Include, everything looks good. So now we can create configurations for our new scripted value columns. Of course, the actual data table widgets have no idea what to do with this information, but we can start working on that in our next installment.

Scripted Value Columns in the SNH Data Table Widgets

“Daring ideas are like chessmen moved forward; they may be beaten, but they may start a winning game.”
Johann Wolfgang von Goethe

Every once in a while, I go out to the Developer’s Forum, just to see what other folks are talking about or struggling with. I rarely comment; I leave that to people smarter or faster than myself. But occasionally, the things that people ask about will trigger a thought or idea that sounds interesting enough to pursue. The other day there were a couple of posts related to the Data Table widget, something that I spent a little time playing around with over the years, and it got me to wondering if I could do a little something with my SNH Data Table Widgets to address some of those requirements. One individual was looking to add catalog item variables to a table and another was looking to add some data from the last comment or work note on an Incident. In both cases, the response was essentially that it cannot be done with the out-of-the-box data table widgets. I never did like hearing that as an answer.

It occurred to me that these and other similar columns just needed a little code to fish out the values that they wanted to have displayed on the table with the rest of the standard table fields. We are already doing something like that with the aggregate columns, so it didn’t seem that much of a stretch to clone some of that code and adapt it to handle these types of requirements. If we took the stock properties for aggregate columns and buttons & icons (label, name, and heading) and added one more for the name of a Script Include, as the data was loaded from the base table, we could pass the row data and the column configuration to a standard function in that Script Include to obtain the value for that column. You know what I always ask myself: How hard could it be?

So here is the plan: create a new configuration option called Scripted Value Columns, update the configuration file editor to maintain the properties for those columns, and then update the Data Table widgets to process them based on the configuration. When the data for the table is loading, we’ll get an instance of the Script Include specified in the configuration and then call the function on that instance to get the value for the column. Sounded simple enough to me, but let’s see if we can actually make it work.

To begin, let’s create an example Script Include that we can use for testing purposes. At this point, it doesn’t matter where or how we get the values. We can simply return random values for now; we just want something that returns something so that we can demonstrate the concept. Here is the ScriptedValueExample that I came up with for this purpose.

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

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

	type: 'ScriptedValueExample'
};

The function that we will be calling from the core Data Table widget will be getScriptedValue. In the editor, our pick list of Script Includes can be limited to just those that contain the following text:

getScriptedValue: function(item, config)

This will mean that the function in your custom Script Include will need to have the exact same spacing and argument naming conventions in order for it to show up on the list, but if you clone the script from the example, that shouldn’t be a problem.

Now that we have our example script, we can jump over to the list of portal widgets and pull up one of the existing pop-up configuration editors and clone it to create our new Scripted Value Column Editor. For the HTML portion, we can keep the label, name, and heading fields, delete the rest, and then add our new script property.

<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.script"
      snh-name="script"
      snh-type="reference"
      snh-required="true"
      placeholder="Choose a Script Include"
      table="'sys_script_include'"
      display-field="'name'"
      value-field="'api_name'"
      search-fields="'name'"
      default-query="'scriptLIKEgetScriptedValue: function(item, config)'"/>
  </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>

The default-query attribute of the sn-record-picker will limit our list of Script Includes to just those that will work as scripted value providers. Other than that, this pop-up editor will be very similar to all of the others that we are using for the other types of configurable columns.

There is no Server script needed for this one, and the Client script is the same as the one from which we made our copy, so there are no changes needed there.

function ScriptedValueColumnEditor($scope, $timeout, spModal) {
	var c = this;

	$scope.spModal = spModal;

	$scope.cancel = function() {
		$timeout(function() {
			angular.element('[ng-click*="buttonClicked"]').get(0).click(); 
		});
	};

	$scope.save = function() {
		if ($scope.form1.$valid) {
			$timeout(function() {
				angular.element('[ng-click*="buttonClicked"]').get(1).click(); 
			});
		} else {
			$scope.form1.$setSubmitted(true);
		}
	};

	$timeout(function() {
		angular.element('[class*="modal-footer"]').css({display:'none'});
	}, 100);
}

So that takes care of that. Now we need to modify the main configuration editor to utilize this new pop-up widget. That sounds like a good project for our next installment.

Refactoring the SNH Data Table Widget, Part IV

“Testing is an infinite process of comparing the invisible to the ambiguous in order to avoid the unthinkable happening to the anonymous.”
James Bach

Last time, we tested a number of the features of the refactored SNH Data Table collection, but there is still much to do before we can bundle this all up into a new Update Set and stuff it out on Share. Now that we know that the initial version that I put out there is missing a critical component, I’d like to wrap this up and replace it with the refactored version, so let’s get to it.

To test the bulk actions and other clicks that do not result in navigating to a new portal page, I took my original Button Click Handler Example widget, renamed it the Table Click Handler Example widget, and then reworked it to handle all four of the clickable features, reference links, aggregate columns, buttons and icons, and bulk actions. Here is the new Client script for the repurposed widget:

function (spModal, $rootScope) {
	var c = this;
	var eventNames = {
		referenceClick: 'data_table.referenceClick',
		aggregateClick: 'data_table.aggregateClick',
		buttonClick: 'data_table.buttonClick',
		bulkAction: 'data_table.bulkAction'
	};

	$rootScope.$on(eventNames.referenceClick, function(e, parms) {
		displayClickDetails(eventNames.referenceClick, parms);
	});

	$rootScope.$on(eventNames.aggregateClick, function(e, parms) {
		displayClickDetails(eventNames.aggregateClick, parms);
	});

	$rootScope.$on(eventNames.buttonClick, function(e, parms) {
		displayClickDetails(eventNames.buttonClick, parms);
	});

	$rootScope.$on(eventNames.bulkAction, function(e, parms) {
		displayClickDetails(eventNames.bulkAction, parms);
	});

	function displayClickDetails(eventName, parms) {
		var html = '<div>'; 
		html += ' <table>\n';
		html += '  <tbody>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Event: &nbsp;</td>\n';
		html += '    <td>' + eventName + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Table: &nbsp;</td>\n';
		html += '    <td>' + parms.table + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Sys ID: &nbsp;</td>\n';
		html += '    <td>' + parms.sys_id + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Config: &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.config, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Item(s): &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.record, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '  </tbody>\n';
		html += ' </table>\n';
		html += '</div>';
		spModal.alert(html);
	}
}

This will allow me to test a number of things, all with the same companion widget, which should save a little bit of time. The original button_test page has a number of options in the configuration, so let’s pull that guy up and click around and see what we can find out.

Original button_test page

One thing that I noticed right away was that the master checkbox was not working. It seems that when I rebuilt the core SNH Data Table widget from the latest version of the stock Data Table widget, I neglected to paste back in the following added function:

$scope.masterCheckBoxClick = function() {
	for (var i in c.data.list) {
		c.data.list[i].selected = c.data.master_checkbox;
	}
};

Putting that back solved that problem, but then I also discovered that I needed to add spModal to the function arguments so that the error message would come up when you tried to select a bulk action from the drop-down without selecting any of the records in the table. Once I got all that out of the way, I got back to testing the buttons and icons.

Clicking on the Status Check icon

So now when I click on a button or icon, the alert pops up with all of the information that is passed with the broadcast message, which gives you an idea of what you have to work with in your companion widget to take whatever action that you would like to take based on that information. We should be able to do the same thing when selecting one or more rows and then choosing a bulk action.

Selecting a bulk action

Looks like that is working as well, so I think things are looking pretty good at this point. There is another old test page that we can pull called snh_data_table that has a companion widget for one of the icons.

snh_data_table test page

Once again, we will need to update the Client script in the click handler widget to adapt to our new approach to click handling.

function(spModal, $rootScope) {
	var c = this;
	var eventNames = {
		referenceClick: 'data_table.referenceClick',
		aggregateClick: 'data_table.aggregateClick',
		buttonClick: 'data_table.buttonClick',
		bulkAction: 'data_table.bulkAction'
	};

	$rootScope.$on(eventNames.buttonClick, function(e, parms) {
		if (parms.config.name == 'icon') {
			spModal.open({widget: 'snh-user-profile', widgetInput: {sys_id: parms.sys_id}}).then(function() {
				//
			});
		}
	});
}

Since the button is configured to launch a new page and the icon is configured to pop up a modal dialog, we need to check to make sure that the button click is for the icon and not the button. Other than that, it is very similar to the other examples.

Clicking on the icon to bring up the modal dialog

So that works. Very nice. Obviously, there is a lot more that we could do here to check out every little thing, but I think that we have covered most of the high points, and given that the version that is out on Share right now contains a pretty significant flaw, I think I would like to roll the dice and toss this out there as is to resolve that issue. Hopefully, I will not miss any important artifacts this time! Here is the Update Set for those of you who would like to check it out. As always, please feel free to leave any feedback of any kind in the comments. Thanks!