“It’s really complex to make something simple.” — Jack Dorsey
Last time, we wrapped up an initial version of the storefront and released a new Update Set in the hopes that a few folks out there would pull it down and take it for a spin. While we wait patiently to hear back from anyone who might have been willing to download the new version and run it through its paces, let’s see if we can’t add a little more functionality to our shopping experience. The page that we laid out had a place for two widgets, but we only completed the primary display widget so far. The other, smaller portion of the screen was reserved for some kind of search/filter widget to help narrow down the results for installations where a large number of applications have been shared with the community. Adding that second widget to the page would give us something like this:
Rather than have the two widgets speak directly to one another, my thought was that we could use the same technique that was used with the Configurable Data Table Widget Content Selector bundled with the SNH Data Table Widgets. Instead of having one widget broadcast messages and the other widget listen for those messages, the Content Selector communicates with the Data Table widget via the URL. Whenever a selection is made, a URL is constructed from the selections, and then the Content Selector branches to that URL where both the Content Selector and the Data Table widget pull their control information from the shared URL query parameters. We can do exactly the same thing here for the same reasons.
For this initial attempt, I laid out a generic search box and a number of checkboxes for various states of applications on the local instance. To support that, we can use the following URL parameters: search, local, current, upgrade, and notins. In the widget, we can also use the same names for the variables used to store the information, and we can populate the variables from the URL. In fact, that turns out to be the entirety of the server-side code.
There is one client-side function referenced in the HTML for the button click, and that’s pretty much all there is to the widget’s Client script.
api.controller = function($scope, $location) {
var c = this;
$scope.search = function() {
var search = '?id=' + $location.search()['id'];
if (c.data.search) {
search += '&search=' + encodeURIComponent(c.data.search);
}
if (c.data.local) {
search += '&local=true';
}
if (c.data.current) {
search += '¤t=true';
}
if (c.data.upgrade) {
search += '&upgrade=true';
}
if (c.data.notins) {
search += '¬ins=true';
}
window.location.search = search;
};
};
The search() function builds up a URL query string based on the operator’s input and then updates the current location with the new query string, essentially branching to the new location. When the new page loads, both the search widget and the storefront widget can then pull their information from the current URL. We can test all of this out now by saving the new widget, pulling up our collaboration_store page in the Service Portal Designer, and then dragging our new widget onto the page in the space already reserved for that purpose.
With that completed, we can now try out the page and see that entering some data and clicking on the Search button actually does reload the page with a new URL. However, at this point the content of the primary page never changes because we have yet to add code to the main widget to pull in the URL parameters and then use that data to adjust the database query. That sounds like a good subject for our next installment.
“The trouble with programmers is that you can never tell what a programmer is doing until it’s too late.” — Seymour Cray
Last time, we started building a widget for the application details pop-up and today we need to wrap that up. We left off with a rough layout of what the pop-up might contain, and now we need to gather up all of the data necessary to populate the screen. The first thing that we need to do is get the primary application record.
Then we need the functions that fetch the attachment ID and all of the version records.
function getAttachmentId(applicationId, version) {
var attachmentId = '';
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.addQuery('version', version);
versionGR.query();
if (versionGR.next()) {
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
attachmentId = attachmentGR.getUniqueValue();
}
}
return attachmentId;
}
function getVersionRecords(applicationId) {
var versionList = [];
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.orderByDesc('sys_created_on');
versionGR.query();
while (versionGR.next()) {
var thisVersion = {};
thisVersion.date = formatDate(versionGR.getDisplayValue('sys_created_on'));
thisVersion.builtOn = versionGR.getDisplayValue('built_on');
thisVersion.version = versionGR.getDisplayValue('version');
versionList.push(thisVersion);
}
return versionList;
}
The version records are dated, and the date format that I have chosen is month day, year (‘MMM d, yyyy’); however, for today’s date and yesterday’s date, I replace the date with the words Today and Yesterday. To pull that off, I need to create some variables for those two dates right at the top.
var gd = new GlideDate();
var today = gd.getByFormat('MMM d, yyyy');
var gdt = new GlideDateTime();
gdt.addDaysLocalTime(-1);
gd.setValue(gdt.getDate());
var yesterday = gd.getByFormat('MMM d, yyyy');
Once those values have been establish, I can reference them in the date format function.
function formatDate(dateString) {
var response = '';
if (dateString) {
var date = new GlideDate();
date.setValue(dateString);
response = date.getByFormat('MMM d, yyyy');
if (response == today) {
response = 'Today';
} else if (response == yesterday) {
response = 'Yesterday';
}
}
return response;
}
That’s pretty much it for the server side code. Here is the whole thing all put together.
(function() {
var gd = new GlideDate();
var today = gd.getByFormat('MMM d, yyyy');
var gdt = new GlideDateTime();
gdt.addDaysLocalTime(-1);
gd.setValue(gdt.getDate());
var yesterday = gd.getByFormat('MMM d, yyyy');
if (input) {
data.sysId = input.sys_id;
data.record = {};
var appGR = new GlideRecord('x_11556_col_store_member_application');
appGR.query();
if (appGR.get(data.sysId)) {
var item = {};
data.record.name = appGR.getDisplayValue('name');
data.record.description = appGR.getDisplayValue('description');
data.record.applicationId = appGR.getValue('application');
data.record.logo = appGR.getValue('logo');
data.record.version = appGR.getDisplayValue('current_version');
data.record.provider = appGR.getDisplayValue('provider.name');
data.record.providerId = appGR.getValue('provider');
data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
data.record.state = 0;
if (data.record.applicationId) {
data.record.state = 1;
data.record.installedVersion = appGR.getDisplayValue('application.version');
if (data.record.version == data.record.installedVersion) {
data.record.state = 2;
}
}
if (!data.record.local && data.record.state != 2) {
data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
}
data.record.versionList = getVersionRecords(data.sysId);
}
}
function getAttachmentId(applicationId, version) {
var attachmentId = '';
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.addQuery('version', version);
versionGR.query();
if (versionGR.next()) {
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
attachmentId = attachmentGR.getUniqueValue();
}
}
return attachmentId;
}
function getVersionRecords(applicationId) {
var versionList = [];
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.orderByDesc('sys_created_on');
versionGR.query();
while (versionGR.next()) {
var thisVersion = {};
thisVersion.date = formatDate(versionGR.getDisplayValue('sys_created_on'));
thisVersion.builtOn = versionGR.getDisplayValue('built_on');
thisVersion.version = versionGR.getDisplayValue('version');
versionList.push(thisVersion);
}
return versionList;
}
function formatDate(dateString) {
var response = '';
if (dateString) {
var date = new GlideDate();
date.setValue(dateString);
response = date.getByFormat('MMM d, yyyy');
if (response == today) {
response = 'Today';
} else if (response == yesterday) {
response = 'Yesterday';
}
}
return response;
}
})();
To format all of this data, we use the following HTML.
This is another drop-in replacement for any previous 0.7.x version. If you have been already been testing with any other version, just install this one over the one that you have been using. If you installing for the first time, you will need the other prerequisites, which you can read about here and here and here. As always, feedback of any kind in the comments section is welcome, encouraged, and very much appreciated. Also, any ideas on the shopping experience in general, or on the search widget that we have yet to add to the other side of the page, would be great as well. Next time, we may start taking a look at that unless we have some test results to review. Thanks to everyone who has taken the time to take this out for a spin, and if you haven’t done it yet, please give it a try and let us know what you find.
“Walk that walk and go forward all the time. Don’t just talk that talk, walk it and go forward. Also, the walk didn’t have to be long strides; baby steps counted too. Go forward.” — Chris Gardner
Last time, we added some code to our storefront to launch the application installation process. Today, we want to build a detail screen to show all of the detailed information for a specific app. Although we could create a new UI Page or Portal Page for that, and have the tile click branch to that page, it seems as if it would be better if we just had a simple modal pop-up screen so that we could remain on the main shopping experience page after closing the modal dialog. To begin, let’s just create a simple Service Portalwidget that we can call up using spModal. We can call our widget Application Details and give it an ID of cs-application-details.
We have already gathered up the application data in the main widget, so we could simply pass everything that we have over to the pop-up widget; however, that would make the pop-up widget highly dependent on the main widget, which is something that I try to avoid. I think the better approach will be to pass in the sys_id of the app and let the pop-up widget fetch its own data from the database. For that, we can cut and paste most of the code that we already have in the main widget.
(function() {
if (input) {
data.sysId = input.sys_id;
data.record = {};
var appGR = new GlideRecord('x_11556_col_store_member_application');
appGR.query();
if (appGR.get(data.sysId)) {
var item = {};
data.record.name = appGR.getDisplayValue('name');
data.record.description = appGR.getDisplayValue('description');
data.record.logo = appGR.getValue('logo');
data.record.version = appGR.getDisplayValue('current_version');
data.record.provider = appGR.getDisplayValue('provider.name');
data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
data.record.state = 0;
if (appGR.getValue('application')) {
data.record.state = 1;
data.record.installedVersion = appGR.getDisplayValue('application.version');
if (data.record.version == data.record.installedVersion) {
data.record.state = 2;
}
}
if (!data.record.local && data.record.state != 2) {
data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
}
}
}
function getAttachmentId(applicationId, version) {
var attachmentId = '';
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.addQuery('version', version);
versionGR.query();
if (versionGR.next()) {
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
attachmentId = attachmentGR.getUniqueValue();
}
}
return attachmentId;
}
})();
Before we get too excited about formatting all of this data for display, let’s just throw a single value onto the display and see if we can get the mechanics of bringing up the widget all working. Here is some simple HTML to get things started.
<div>
<h3>{{c.data.record.name}}</h3>
</div>
That should be enough to get things going, so let’s pop back over to the main widget and see if we can set up a function in the Client script to call up this widget.
That should be enough to be able to open up the store and give things the old college try.
Not bad. OK, now that we have the basic mechanics working, we need to design the layout of the pop-up and also add whatever functionality we might want such as links to any forms or actions. At this point in the process, we have not added that much in the way of extra data. There are no categories or keywords or comments or user ratings or statistics or much of anything else in the way of interesting information outside of the name and description of the application. Some or all of that may come in some future version, but for now, about the best we can do to add detail would be to add the version history and to throw in a few useful links.
The above example is for an app pulled down from the store. For local apps that have been pushed up to the store, the look would be similar, but with a few differences.
For the local application, we should probably continue with the modified background, just to be consistent, but you get the idea. To pull this off, we will have to fetch more data from the database, and work out all of the associated HTML. Once that is done, that should be good enough to push out a new version so that folks can try it all out at home. That’s still a bit of work, so let’s deal with all of that next time out.
“You just have to keep driving down the road. It’s going to bend and curve and you’ll speed up and slow down, but the road keeps going.” — Ellen DeGeneres
Last time, we got the ability to toggle between the card/tile view and table view working as it should, and we made an initial stab at making the local apps look a little different from the apps that could be or have been pulled down from the Host. Now we need to figure out what we want to happen when the operator clicks on any of the links that are present on the tiles or table rows. Currently, the main portion of the tile is clickable, and in the footer, the version number could be clickable as well and could potentially launch the install process. In fact, let’s take a look at that first.
Right now, if you want to install a version of a store application, you go to the version record and click on the Install button. Let’s take a quick peek at that UI Action and see how that works.
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', current.sys_id);
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
action.setRedirectURL('/upload.do?attachment_id=' + attachmentGR.getUniqueValue());
} else {
gs.addErrorMessage('No Update Set XML file found attached to this version');
}
Basically, it just links to the stock upload.do page (which we hacked up a bit sometime back) with the attachment sys_id as a parameter. Assuming that we had the attachment sys_id included along with the rest of the row data, we could simply build an anchor tag to launch the install such as the following.
To get the attachment sys_id, we could steal some of the UI Action code above to build a function for that purpose.
function getAttachmentId(applicationId, version) {
var attachmentId = '';
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.addQuery('version', version);
versionGR.query();
if (versionGR.next()) {
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
attachmentId = attachmentGR.getUniqueValue();
}
}
return attachmentId;
}
For local apps, or apps that are already installed and up to date, there is no action to take. But for apps that have not been installed, or those that have, but are not on the latest version, a link to the install process would be appropriate. Two mutually exclusive tags would cover both cases.
To test this out, we will need to publish an app on our Host instance and bring up the store on our Client instance to see how this looks.
That takes care of the install link. For the main action when clicking on the tile itself (or on the app name in the table view), we should have some way of displaying all of the details of the app. We could link to the existing form for the application table, but we might want something a little more formatted, and maybe even just a pop-up so that you don’t actually leave the shopping experience. Let’s see if we can throw something like that together next time out.
“Don’t dwell on what went wrong. Instead, focus on what to do next. Spend your energies on moving forward toward finding the answer.” — Denis Waitley
Last time, we were able to get to the point where we could bring up our new storefront and take a quick peek at how things were looking. It was a good start, but there are still a number of things that we need to do to, and some important decisions to be made before we can wrap this up. Visually, I think we want to distinguish between the apps that were developed on the local instance, and the apps that we pulled in from the other members of the community. Also, of the apps that have been pulled in from other member instances, we want to somehow distinguish between those that have been installed locally and those that have not, and of those that have been installed, which are running the most current version and which are eligible for an upgrade.
Before we get into all of that, though, let’s jump into something easy. There are two views on the original widget, the tile/card view and the table view. The default is the tile/card view, which we were able to bring up, but to switch views, we will need some client-side code. Taking a quick peek at the HTML for the view selectors, we can see which function is being called.
There are actually two functions referenced here, one for the ng-click and a different one for the ng-keydown. We should be able to locate both of those in the original widget, and we should be able to use them just the way that that appear in the original.
With those in place, we should be able to pull up our new page and use the selectors to toggle over to the other view.
So that works, which is good. One other thing that you might have noticed is that we added the Host instance logo to the header of the store. That just took a little bit of extra HTML that we copied from the application list.
Now, back to our earlier dilemma of differentiating between the various states of the applications from the store. The first thing that we will need to do is to pull the data that we need from the database and also get rid of all of that left-over catalog related stuff that we neglected to strip out earlier. Building up the item object now looks like this.
Now that we have all of the data that we need, the next question is how do we want things to behave. For the local applications, maybe just a slightly different background would make those visually distinct. Let’s add the following class to the widget’s CSS:
.local-app {
background-color: #b5ebd4;
}
Then, in the HTML for the card/tile layout, we can tweak the first DIV in the list item to look like this:
<div class="panel item-card b sc-panel{{item.local?' local-app':''}}">
This will add the local-app class to the DIV if the item is a local application. We can pull up the store and take a quick peek to see how that looks.
Not bad. We may still want to tinker with the CSS a bit to get things to our liking, but at least we have a method now to make the local apps look different than the other apps in the store.
Beyond looking different, though, we are also going to want store apps to behave differently than local apps, since local apps are published to the store, and store apps that are not local are meant to be pulled down from the store and installed on the local instance. Those that are already installed will have different options than those that are installed and up to date, and those that are not up to date will have different options compared with those that are. Let’s dive into all of that next time out.
“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:
Here is what it looks like on a Tokyo instance:
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">
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.
“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 Portalpagesc_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.
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.
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.
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.
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.
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.
“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 Portalpagesc_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.
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:
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.
“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.
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
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
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.
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.
“It’s not about ideas. It’s about making ideas happen.” — Scott Belsky
Last time, we played around with the Customer column in our new example data table, and today we will jump back over to the Labor Hours column and see if we can create that mouse-over breakdown of who put in the hours. The first thing that we will need to do is to gather up the data, so will need to add a groupBy to our GlideAggregate so that we can obtain the hours per technician.
var timeGA = new GlideAggregate('time_card');
timeGA.addQuery('task', item.sys_id);
timeGA.addAggregate('SUM', 'total');
timeGA.groupBy('user');
timeGA.orderBy('user');
timeGA.query();
Once we have the data, we will need to format it for display. There are a number of different ways that we can approach this, including straight CSS and many creative variations, but the easiest thing to do is to just use the title attribute of a span, which seems to accomplish the same thing.
To create the text, we can establish some variables before we go into the while loop, and then inside the loop we can build up the text, one person’s hours at a time.
var hours = 0;
var tooltip = '';
var separator = '';
var timeGA = new GlideAggregate('time_card');
timeGA.addQuery('task', item.sys_id);
timeGA.addAggregate('SUM', 'total');
timeGA.groupBy('user');
timeGA.orderBy('user');
timeGA.query();
while (timeGA.next()) {
var total = timeGA.getAggregate('SUM', 'total') * 1;
hours += total;
tooltip += separator + timeGA.getDisplayValue('user') + ' - ' + total.toFixed(2);
separator = '\n';
}
Once we accumulate the total hours and build up the tooltip text, we can then format the HTML we want, but only when there are hours charged to the task.
Putting it all together, our new function to provide right-justified total hours with a mouseover breakdown looks like this:
getScriptedValue: function(item, config) {
var response = '';
var hours = 0;
var tooltip = '';
var separator = '';
var timeGA = new GlideAggregate('time_card');
timeGA.addQuery('task', item.sys_id);
timeGA.addAggregate('SUM', 'total');
timeGA.groupBy('user');
timeGA.orderBy('user');
timeGA.query();
while (timeGA.next()) {
var total = timeGA.getAggregate('SUM', 'total') * 1;
hours += total;
tooltip += separator + timeGA.getDisplayValue('user') + ' - ' + total.toFixed(2);
separator = '\n';
}
if (hours > 0) {
response = '<span style="text-align: right; width: 100%;" title="' + tooltip + '">' + hours.toFixed(2) + '</span>';
}
return response;
}
I think that is enough examples for folks to get the basic idea. Adding the ability to include HTML with a scripted value opens up a number of possibilities. We have just explored a couple of them here, but I am sure that specific requirements will drive many other variations from those willing to give it a try and see what they can do with it.
Here is an Update Set with the modifications, including this new example page. Feedback can be left here in the comments, or in the discussion area where it has been posted out on Share. If you have been able to utilize this feature for anything interesting, a screenshot would definitely be something in which folks would have an interest, so please let us all in on what you were able to accomplish.