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, Enhanced, Part II

“Alone we can do so little; together we can do so much.”
Helen Keller

Last time, we tinkered with the Scripted Value Columns section of the SNH Data Table Widgets, and created an example Task list using an aggregation of labor hours as a demonstration of how you can include HTML with your returned value. Today we will have a look at another scripted column in that example, the Customer column.

There are quite a number of extensions to the core Task table in ServiceNow, all of which share the same core list of fields. One of those fields is called opened_by, and identifies the person who was signed on to the system when the task was initially opened. That person is not necessarily the customer, though. In the case of an Incident, for example, that is more likely to be a call center agent who took the call, not the person who is reporting the issue. There is no core table field to represent the customer. Each extension of the Task table has its own way of identifying the person awaiting the completion of the task. Using the scripted value column feature, though, you can create a “customer” column by looking at the type of task, and then grabbing the value of the field that is appropriate for that particular type.

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

	var table = item.sys_class_name.value;
	var field = 'opened_by';
	if (table == 'incident') {
		field = 'caller_id';
	} else if (table == 'sc_request') {
		field = 'requested_for';
	} else if (table == 'sc_req_item') {
		field = 'request.requested_for';
	} else if (table == 'sc_task') {
		field = 'request_item.request.requested_for';
	} else if (table == 'change_request') {
		field = 'requested_by';
	}
	var taskGR = new GlideRecord(table);
	if (taskGR.get(item.sys_id)) {
		if (taskGR.getValue(field) > '') {
			response = taskGR.getDisplayValue(field);
		}
	}

	return response;
}

This particular example handles incidents, catalog activity, and change requests, but could easily be modified to handle any number of other types of extensions to the core task table. This just returns the value, though. If you wanted to make the field a link to the user profile page, for example, you could do something like this.

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

	var table = item.sys_class_name.value;
	var field = 'opened_by';
	if (table == 'incident') {
		field = 'caller_id';
	} else if (table == 'sc_request') {
		field = 'requested_for';
	} else if (table == 'sc_req_item') {
		field = 'request.requested_for';
	} else if (table == 'sc_task') {
		field = 'request_item.request.requested_for';
	} else if (table == 'change_request') {
		field = 'requested_by';
	}
	var taskGR = new GlideRecord(table);
	if (taskGR.get(item.sys_id)) {
		if (taskGR.getValue(field) > '') {
			var sysId = taskGR.getValue(field);
			var name = taskGR.getDisplayValue(field);
			response += '<a href="?id=user_profile&table=sys_user&sys_id=';
			response += sysId;
			response += '" aria-label="${Click for more on }';
			response += name;
			response += '">';
			response += name;
			response += '</a>\n';
		}
	}

	return response;
}

That would provide an output such as this.

Customer column as link to user profile page

The one thing that the Customer column does not have that the Assigned to column, which is a standard SNH Data Table user reference, does have is the user avatar image. That’s easily done with the sn-avatar tag, but that tag is not simple HTML, and not processed using the ng-bind-html attribute. Theoretically, it can be processed using the sc-bind-html-compile attribute, but I have never had any luck in utilizing this component. I tried it, and couldn’t get it to work, so I gave up and went back to using the ng-bind-html attribute. So, there is no user avatar in this version. Maybe one day I will figure that out, but that day is not today.

Speaking of figuring things out one day, I still have not been able to get rid of that extra line that appears when there is an image available for the avatar. If you look at the example above, the lines with an avatar image are wider than the lines where that field is blank, or the initials are used in place of an image. This because there is an extra carriage return in there that expands the height of the line. Only when there is an actual image does this occur, but it shouldn’t occur at all. If there is some grand master CSS wizard out there who understands why this is happening and can explain it to me, or better yet, tell me what I can do to prevent it, I would be forever in your debt. I have not been able to figure that one out.

One other thing that you may have noticed in that image is that I got rid of the 0.00 value for tasks that had no hours charged to them. I like that better, and that was a pretty simple thing to do. I still want to have some kind of mouse-over display of who logged the hours, so maybe we will take a look at that next time out.

Scripted Value Columns, Part VI

“Do not be too timid and squeamish about your actions. All life is an experiment. The more experiments you make the better.”
Ralph Waldo Emerson

Last time, we used the new scripted value columns feature to create columns from the comments and work notes of an Incident. Today we are going to do something similar, but instead of working with journal entries, we will try something with the optional variables that can be associated with Service Catalog items. For our demonstration, we will use the Executive Desktop catalog item, which has a number of defined ordering options.

Executive Desktop catalog item

Let’s see if we can’t create a list of all orders for this item, and include some of the catalog item variables on the list. To begin, let’s make another copy of our example scripted value provider and call it ScriptedCatalogValueProvider.

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

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

	type: 'ScriptedCatalogValueProvider'
};

Let’s also make a copy of our last configuration script and change things over from the Incident table to the Requested Item table, and define some scripted value columns for some of the catalog item variables associated with this item.

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

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

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

	table: {
		all: [{
			name: 'sc_req_item',
			displayName: 'Requested Item',
			all: {
				filter: 'cat_item=e46305bdc0a8010a00645e608031eb0f',
				fields: 'number,request,requested_for',
				svcarray: [{
					name: 'cpu',
					label: 'CPU Speed',
					heading: 'CPU Speed',
					script: 'global.ScriptedCatalogValueProvider'
				},{
					name: 'memory',
					label: 'Memory',
					heading: 'Memory',
					script: 'global.ScriptedCatalogValueProvider'
				},{
					name: 'drive',
					label: 'Hard Drive',
					heading: 'Hard Drive',
					script: 'global.ScriptedCatalogValueProvider'
				},{
					name: 'os',
					label: 'Operating System',
					heading: 'Operating System',
					script: 'global.ScriptedCatalogValueProvider'
				}],
				aggarray: [],
				btnarray: [],
				refmap: {},
				actarray: []
			}
		}]
	},

	type: 'ScriptedValueConfig2'
});

Finally, let’s make a copy of our last test page and call it scripted_value_test_2, and then edit the widget options to use our new configuration file. Now let’s jump out to the Service Portal and pull up the page and see what we have so far.

First test of our new portal page

So far, so good. Everything seems to work, but of course all we have in our new columns is the random numbers that we threw in there for demonstration purposes earlier. But that just means that all we have left to do now is to figure out what kind of script we need to pull out the actual variable values that are associated with each catalog item order. To begin, let’s map our variable names, which are also our column names, to their catalog item variable (question) sys_ids.

questionMap: {
	cpu: 'e46305fbc0a8010a01f7d51642fd6737',
	memory: 'e463064ac0a8010a01f7d516207cd5ab',
	drive: 'e4630669c0a8010a01f7d51690673603',
	os: 'e4630688c0a8010a01f7d516f68c1504'
}

This will allow us to use some common code for all of the columns by passing in the correct sys_id for the catalog item variable associated with that column.

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

	var column = config.name;
	if (this.questionMap[column]) {
		response = this.getVariableValue(this.questionMap[column], item.sys_id);
	}

	return response;
}

Now we need to build the getVariableValue function, which takes the sys_id of the question and the sys_id of the requested item as arguments. To locate the value selected for this question for this order, we use the sc_item_option_mtom table, which maps requested items to individual question responses.

getVariableValue: function(questionId, itemId) {
	var response = '';

	var mtomGR = new GlideRecord('sc_item_option_mtom');
	mtomGR.addQuery('request_item', itemId);
	mtomGR.addQuery('sc_item_option.item_option_new', questionId);
	mtomGR.query();
	if (mtomGR.next()) {
		response = mtomGR.getDisplayValue('sc_item_option.value');
	}

	return response;
}

Now let’s save that and give the page another look.

Second test of our new portal page

Well, that’s better, but it is still not exactly what we want. The values that appear in the columns are the raw values for the variables, but what we would really like to see is the display value. To get that, we have to go all the way back to the original question choices and find the choice record that matches both the question and the answer. For that, you need the sys_id of the question and the value of the answer.

getDisplayValue: function(questionId, value) {
	var response = '';

	var choiceGR = new GlideRecord('question_choice');
	choiceGR.addQuery('question', questionId);
	choiceGR.addQuery('value', value);
	choiceGR.query();
	if (choiceGR.next()) {
		response = choiceGR.getDisplayValue('text');
	}

	return response;
}

To utilize this new function, we have to tweak our getVariableValue function just a little bit to make the call.

getVariableValue: function(questionId, itemId) {
	var response = '';

	var mtomGR = new GlideRecord('sc_item_option_mtom');
	mtomGR.addQuery('request_item', itemId);
	mtomGR.addQuery('sc_item_option.item_option_new', questionId);
	mtomGR.query();
	if (mtomGR.next()) {
		var value = mtomGR.getDisplayValue('sc_item_option.value');
		if (value) {
			response = this.getDisplayValue(questionId, value);
		}
	}

	return response;
}

Now let’s take one more look at that page now that we have made these modifications.

Third and final test of our new portal page

That’s better! Now we have the same text in our columns that the user saw when placing the order, and that’s what we really want to see here. Or at least, that’s what I wanted to see. You may be interested in something completely different, but that’s the whole point of this approach. You can basically script whatever you want to put whatever you want to put in whatever column or columns you would like to define. These are just a couple of examples, but there really is no limit to what you might be able to do with a little imagination and some Javascript.

So here is the final version of our second close-to-real-world example value provider script:

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

	questionMap: {
		cpu: 'e46305fbc0a8010a01f7d51642fd6737',
		memory: 'e463064ac0a8010a01f7d516207cd5ab',
		drive: 'e4630669c0a8010a01f7d51690673603',
		os: 'e4630688c0a8010a01f7d516f68c1504'
	},

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

		var column = config.name;
		if (this.questionMap[column]) {
			response = this.getVariableValue(this.questionMap[column], item.sys_id);
		}

		return response;
	},

	getVariableValue: function(questionId, itemId) {
		var response = '';

		var mtomGR = new GlideRecord('sc_item_option_mtom');
		mtomGR.addQuery('request_item', itemId);
		mtomGR.addQuery('sc_item_option.item_option_new', questionId);
		mtomGR.query();
		if (mtomGR.next()) {
			var value = mtomGR.getDisplayValue('sc_item_option.value');
			if (value) {
				response = this.getDisplayValue(questionId, value);
			}
		}

		return response;
	},

	getDisplayValue: function(questionId, value) {
		var response = '';

		var choiceGR = new GlideRecord('question_choice');
		choiceGR.addQuery('question', questionId);
		choiceGR.addQuery('value', value);
		choiceGR.query();
		if (choiceGR.next()) {
			response = choiceGR.getDisplayValue('text');
		}

		return response;
	},

	type: 'ScriptedCatalogValueProvider'
};

The added columns still do not align with the original columns, which I would like to fix before I build a new Update Set, and we still have a couple more wrapper widgets to address, but I think we are getting close. Maybe we can wrap this whole thing up in our next installment.