“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.
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.
Once we make the copy, we can update the Name, ID, and Description fields and save our new 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>
</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>
<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;"/>
</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.