Collaboration Store, Part LXXXIV

“The power of one, if fearless and focused, is formidable, but the power of many working together is better.”
Gloria Macapagal Arroyo

Last time, we released yet another beta version of the app for testing, so now might be a good time to talk a little bit about what exactly needs to be tested, and maybe a little bit about where things stand and where we go from here. We have had a lot of good, quality feedback in the past, and I am hoping for even more from this version. Every bit helps drive out annoying errors and improves the quality of the product, so keep it coming. It is very much appreciated.

Installation

The first thing that needs to be tested, of course, is the installation itself. Just to review, you need to install three Update Sets in the appropriate order, SNH Form Fields, the primary Scoped Application, and the accompanying global components that could not be bundled with the scoped app. You can find the latest version of SNH Form Fields here, or you can simply grab the latest SNH Data Table Widgets from Share, which includes the latest version of the form field tag. Once that has been installed, you can then install collaboration_store_v0.7.5.xml, after which you can then install the globals, collaboration_store_globals_v0.7.xml.

There are two types of installations, a brand new installation and an upgrade of an existing installation. Both types of installs have had various issues reported with both Preview and Commit errors. On a brand new installation, just accept all updates and you should be good to go. I don’t actually know why some of those errors come up on a brand new install, but if anyone knows of any way to keep that from happening, I would love to hear about it. It doesn’t seem to hurt anything, but it would be so much better if those wouldn’t come up at all. On an upgrade to an existing installation, you will want to reject any updates related to system properties. The value of the application’s properties are established during the set-up process, and if you have already gone through the set-up process, you don’t want those values overlaid by the installation of a new version. Everything else can be accepted as is. Once again, if anyone has any ideas on how to prevent that kind of thing from happening, please let us all know in the comments below.

The Set-up Process

The Collaboration Store Set-up process

Once you have the software installed for the first time, you will need to go through the set-up process. This is another thing that needs to be tested thoroughly, both for a Host instance and a Client instance. It needs to tested with logo images and without, and for Client instances, you will need to check all of the other member instances in the community to ensure that the newly set-up instance now appears in each instance. During the set-up process, a verification email will be sent to the email address entered on the form, and if your instance does not actually send out mail, you will need to look in the system email logs for the code that you will need to complete the process.

The Publishing Process

Application Publishing Process

Once the software has been installed and the set-up process completed, you can now publish an application to the store. Both Client instances and Host instances can publish apps to the store. Publishing is accomplished on the system application form via a UI Action link at the bottom of the form labeled Publish to Application Store. Click on the link and follow the prompts to publish your application to the store. If you run into any issues, please report them in the comments below.

The Installation Process

Application Installation Process

Once published to the store, shared applications can be installed by any other Host or Client instance from either the version record of the version desired, or the Collaboration Store itself. Simply click on the Install button and the installation should proceed. Once again, if you run into any issues, please use the comments below to provide us with some detailed information on where things went wrong.

The Collaboration Store

The Collaboration Store

The Collaboration Store page is where you should see all of the applications shared with the community and there are a lot of things that need to be tested here. This is the newest addition to the application, so we will want to test this thing out thoroughly. One thing that hasn’t been tested at all is the paging, as I have never shared enough apps to my own test environment to exceed the page limit. The more the merrier as far as testing is concerned, so if you can add as many Client instances as possible, that would be helpful, and if each Client could share as many applications as possible, that would be helpful as well. Several pages worth, in varying states would help in the testing of the search widget as well as the primary store widget. And again, if you run into any problems, please report them in the comments.

The Periodic Sync Process

The periodic sync process is designed to recover from any previous errors and ensure that all Clients in the community have all of the same information that is stored in the Host. Testing the sync process is simply a matter of removing some artifacts from some Client instance and then running the sync process to see if those artifacts were restored. The sync process runs every day on the Host instance over the lunch hour, but you can also pull up the Flow and run it by hand.

Thanks in advance to those of you who have already contributed to the testing and especially to those of you who have decided to jump in at this late stage and give things a try. Your feedback continues to be quite helpful, and even if you don’t run into any issues, please leave us a comment and let us know that as well. Hopefully, we are nearing the end of this long, drawn out project, and your assistance will definitely help to wrap things up. Next time, we will talk a little bit more about where things go from here.

Collaboration Store, Part LXXXIII

“Many hands make light work.”
John Heywood

Last time, we built a little companion widget to share the page with our storefront widget to provide the ability to filter the list of applications displayed. Clicking on the search button on that widget reloads the page with the search criteria present in the query string of the URL. Now we need to modify the primary widget to pull that search criteria in from the URL and then use it when querying the database for applications to display. To begin, we can use basically the same code that we used in the search widget to bring in the values.

var search = $sp.getParameter('search');
var local = $sp.getParameter('local') == 'true';
var current = $sp.getParameter('current') == 'true';
var upgrade = $sp.getParameter('upgrade') == 'true';
var notins = $sp.getParameter('notins') == 'true';

Now that we have the information, we need to use it to filter the query results. For the search string, we want to look for that string in either the name or the description of the app. That one is relatively straightforward and can be handled with a single encoded query.

var appGR = new GlideRecord('x_11556_col_store_member_application');
if (search) {
	appGR.addEncodedQuery('nameLIKE' + search + '^ORdescriptionLIKE' + search);
}

The remaining parameters are all boolean values that relate to one another in some way, so we have to handle them as a group. First of all, if none of the boxes are checked or all of the boxes are checked, then there is no need for any filtering, so we can eliminate those cases right at the top.

if (local && current && upgrade && notins) {
		// everything checked -- no filter needed
} else if (!local && !current && !upgrade && !notins) {
	// nothing checked -- no filter needed
} else {
	...
}

After that, things get a little more complicated. Local apps are those where the provider instance is the local instance, and we don’t apply any other filter to that pool. You either want the local apps included or you do not. They are included by default, but if you start checking boxes then they are only included if you check the Local checkbox.

var query = '';
var separator = '';
if (local) {
	query += 'provider.instance=' + gs.getProperty('instance_name');
	separator = '^OR';
} else {
	query += separator + 'provider.instance!=' + gs.getProperty('instance_name');
	separator = '^';
}

The remaining three values relate to the apps pulled down from the store, which are classified based on whether they have been installed on the local instance (current and upgrade) or not (notins). Installed apps have a value in the application field and those that have not been installed do not. Additionally, installed apps are considered current if the installed version is the same as the current version; otherwise they are classified as having an upgrade available. We only need to check the version if you want one, but not the other. If you want both current and upgrade or neither current nor upgrade, then there is no point in making that distinction. So we first check both cases where the values are different.

if (current && !upgrade) {
	if (notins) {
		query += separator + 'applicationISEMPTY^ORversionSAMEASapplication.version';
	} else {
		query += separator + 'versionSAMEASapplication.version';
	}
} else if (!current && upgrade) {
	if (notins) {
		query += separator + 'applicationISEMPTY^ORversionNSAMEASapplication.version';
	} else {
		query += separator + 'versionNSAMEASapplication.version';
	}
...

And finally, we check the last two cases where current and upgrade are the same value.

} else if (current && upgrade && !notins) {
	query += separator + 'applicationISNOTEMPTY';
} else if (!current && !upgrade && notins) {
	query += separator + 'applicationISEMPTY';
}

That should do it. Putting that all together with the original fetchItemDetails function gives us this new version of that function.

function fetchItemDetails(items) {
	var search = $sp.getParameter('search');
	var local = $sp.getParameter('local') == 'true';
	var current = $sp.getParameter('current') == 'true';
	var upgrade = $sp.getParameter('upgrade') == 'true';
	var notins = $sp.getParameter('notins') == 'true';
	var appGR = new GlideRecord('x_11556_col_store_member_application');
	if (search) {
		appGR.addEncodedQuery('nameLIKE' + search + '^ORdescriptionLIKE' + search);
	}
	if (local && current && upgrade && notins) {
		// everything checked -- no filter needed
	} else if (!local && !current && !upgrade && !notins) {
		// nothing checked -- no filter needed
	} else {
		var query = '';
		var separator = '';
		if (local) {
			query += 'provider.instance=' + gs.getProperty('instance_name');
			separator = '^OR';
		} else {
			query += separator + 'provider.instance!=' + gs.getProperty('instance_name');
			separator = '^';
		}
		if (current && !upgrade) {
			if (notins) {
				query += separator + 'applicationISEMPTY^ORversionSAMEASapplication.version';
			} else {
				query += separator + 'versionSAMEASapplication.version';
			}
		} else if (!current && upgrade) {
			if (notins) {
				query += separator + 'applicationISEMPTY^ORversionNSAMEASapplication.version';
			} else {
				query += separator + 'versionNSAMEASapplication.version';
			}
		} else if (current && upgrade && !notins) {
			query += separator + 'applicationISNOTEMPTY';
		} else if (!current && !upgrade && notins) {
			query += separator + 'applicationISEMPTY';
		}
		appGR.addEncodedQuery(query);
	}
	appGR.orderBy('name');
	appGR.query();
	while (appGR.next()) {
		var item = {};
		item.name = appGR.getDisplayValue('name');
		item.description = appGR.getDisplayValue('description');
		item.logo = appGR.getValue('logo');
		item.version = appGR.getDisplayValue('current_version');
		item.provider = appGR.getDisplayValue('provider.name');
		item.providerLogo = appGR.provider.getRefRecord().getValue('logo');
		item.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
		item.sys_id = appGR.getUniqueValue();
		item.state = 0;
		if (appGR.getValue('application')) {
			item.state = 1;
			item.installedVersion = appGR.getDisplayValue('application.version');
			if (item.version == item.installedVersion) {
				item.state = 2;
			}
		}
		if (!item.local && item.state != 2) {
			item.attachmentId = getAttachmentId(item.sys_id, item.version);
		}
		items.push(item);
	}
}

Now we can fire up the storefront page and start clicking around and see what we have. Or better yet, we can push out yet another Update Set and let all of you following along at home click around and see if everything works as it should. I always like it when folks with a little different perspective take the time to pull stuff down and give it a whirl, so here you go:

If this is your first time, you will want to take a peek here and here and here. For the rest of you, this is just another 0.7.x drop-in replacement, and you should know what to do by now. Please let us all know what you find in the comments below. Feedback is always welcome and always very much appreciated. Hopefully, we will get some interesting results and we can take a look at those next time out.

Collaboration Store, Part LXXXII

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

Storefront with new search/filter widget added

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.

(function() {
	data.search = $sp.getParameter('search') ? decodeURIComponent($sp.getParameter('search')) : '';
	data.local = $sp.getParameter('local') == 'true';
	data.current = $sp.getParameter('current') == 'true';
	data.upgrade = $sp.getParameter('upgrade') == 'true';
	data.notins = $sp.getParameter('notins') == 'true';
})();

And here is the HTML that I put together to display the information.

<div class="panel">
  <div class="row">
    <div class="col">
      <p>&nbsp;</p>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.search" name="search" id="search" type="text" class="form-control" placeholder="&#x1F50D;"/>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.local" name="local" id="local" type="checkbox"/>
      <label for="local">${Local}</label>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.current" name="current" id="current" type="checkbox"/>
      <label for="current">${Current}</label>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.upgrade" name="upgrade" id="upgrade" type="checkbox"/>
      <label for="upgrade">${Upgrade Available}</label>
    </div>
  </div>
  <div class="row">
    <div class="col">
      <input ng-model="c.data.notins" name="new" id="new" type="checkbox"/>
      <label for="notins">${Not Installed}</label>
    </div>
  </div>
  <div class="row">
    <div class="col text-center">
      <button ng-click="search()" class="btn btn-primary" title="${Click to search the Collaboration Store}">${Search}</button>
    </div>
  </div>
</div>

To format things a little nicer, and to kick that little magnifying glass search icon over to the right, we also need to throw in some CSS.

.col {
  padding: .5vw;
}

label {
  margin-left: .5vw;
}

::placeholder {
  text-align: right;
}

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 += '&current=true';
		}
		if (c.data.upgrade) {
			search += '&upgrade=true';
		}
		if (c.data.notins) {
			search += '&notins=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.

Dragging the new widget onto the existing page

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.

Collaboration Store, Part LXXXI

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

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);
}

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.

<div class="panel{{c.data.record.local?' local-app':''}}">
  <img ng-src="{{::c.data.record.logo}}.iix?t=small" ng-if="c.data.record.logo" alt="" class="m-r-sm m-b-sm pull-left" aria-hidden="true"/>
  <h3>{{c.data.record.name}}</h3>
  <div>
    <p>{{::c.data.record.description}}</p>
    <strong>${Version History}</strong>
    <table>
      <thead>
        <tr>
          <th>${Version}</th>
          <th>${Published}</th>
          <th>${Built on}</th>
          <th>${Install}</th>
        </tr>
      </thead>
      <tbody>
        <tr ng-repeat="version in c.data.record.versionList">
          <td>{{::version.version}}</td>
          <td>{{::version.date}}</td>
          <td>{{::version.builtOn}}</td>
          <td ng-if="version.version == c.data.record.installedVersion">${Installed}</td>
          <td ng-if="version.version == c.data.record.version && c.data.record.state != 2">
            <button ng-click="alert('OK');">${Install version} {{::version.version}}</button>
          </td>
        </tr>
      </tbody>
    </table>
    <p>
      <a href="/x_11556_col_store_member_application.do?sys_id={{::c.data.sysId}}">${View Collaboration Store application record}</a><br/>
      <a ng-if="c.data.record.state > 0" href="/sys_app.do?sys_id={{::c.data.record.applicationId}}">${View installed application record}</a>
    </p>
    <p ng-if="!c.data.record.local">
      <span style="display: inline-flex;" class="pull-right">
        <span ng-if="c.data.record.provider">${This application provided by} <a href="/x_11556_col_store_member_organization.do?sys_id={{::c.data.record.providerId}}">{{::c.data.record.provider}}</a></span>
        &nbsp;
        <img ng-src="{{::c.data.record.providerLogo}}.iix?t=small" ng-if="c.data.record.providerLogo" alt="{{::c.data.record.provider}}" class="avatar-small" style="display: inline-flex; width: 16px; height: 16px;"/>
        &nbsp;
      </span>
    </p>
    <p>&nbsp;</p>
  </div>
</div>

We don’t need any client side code, but to pretty things up just a bit, we do need a wee bit of CSS.

  padding: 5px;
}

th {
  color: #ccc;
  font-style: italic;
  text-align: center;
  border-bottom: 1px solid #ccc;
}

.local-app {
  background-color: #f5f5f5;
  padding: 5px;
}

Someone who actually knows what they are doing could probably do a much better job with the prettying up part, but this will do for now.

So now all that is left is to bundle the whole thing up into yet another pre-release Update Set for testing purposes, so here you go:

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.

Collaboration Store, Part LXXX

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

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

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

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

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

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

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

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

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

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

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

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

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

Simple application details pop-up

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

Store application detail pop-up

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

Local application detail pop-up

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

Collaboration Store, Part LXXIX

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

<a href="/upload.do?attachment_id={{::item.attachmentId}}">

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.

<span ng-if="item.local || item.state == 2" class="current-version" title="${Version} {{::item.version}}">v{{::item.version}}</span>
<a ng-if="!item.local && item.state != 2" class="{{['not-installed','earlier-version'][item.state]}}" href="/upload.do?attachment_id={{::item.attachmentId}}" title="${Click here to} {{item.state == 0?'install':'upgrade to'}} ${version} {{::item.version}}">v{{::item.version}}</a>

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.

Install/Upgrade link on nonlocal applications

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.

Collaboration Store, Part LXXVIII

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

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

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.

$scope.changeView = function (view) {
	$scope.view = view;
};

$scope.switchTab = function($event) {
	if ($event.which == 37 || $event.which == 39) {
		$event.stopPropagation();
		var layout = $scope.view === 'card' ? 'grid' : 'card';
		$scope.changeView(layout);
		$('#tab-' + layout).focus();
	}
};

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.

Collaboration Store table 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.

<div class="col-xs-9">
  <img ng-src="{{::c.data.store.logo}}.iix?t=small" ng-if="c.data.store.logo" alt="" class="m-r-sm m-b-sm item-image pull-left" aria-hidden="true"/>
  <h2 class="h4 m-t-none break-word">{{c.data.store.name}} Collaboration Store</h2>
  <p class="hidden-xs break-word">
    {{c.data.store.description}}
  </p>
</div>

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.

var item = {};
item.name = appGR.getDisplayValue('name');
item.description = appGR.getDisplayValue('description');
item.logo = appGR.getValue('logo');
item.version = appGR.getDisplayValue('current_version');
item.provider = appGR.getDisplayValue('provider.name');
item.providerLogo = appGR.provider.getRefRecord().getValue('logo');
item.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
item.sys_id = appGR.getUniqueValue();
item.state = 0;
if (appGR.getValue('application')) {
	item.state = 1;
	item.installedVersion = appGR.getDisplayValue('application.version');
	if (item.version == item.installedVersion) {
		item.state = 2;
	}
}

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.

Collaboration Store with local apps visually distinguished

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.

Collaboration Store, Part LXXVII

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

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

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

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

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

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

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

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

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

	fetchItemDetails(data.items);

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

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

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

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

})();

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

Dragging a 3/9 container onto the new portal page

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

Dragging our new widget onto the page

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

First look at our new store layout

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

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

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