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 LIV

“Real happiness lies in the completion of work using your own brains and skills.”
Soichiro Honda

Last time, we finally got to the point where we were able to actually Commit the Update Set, installing the requested version of the application. That was a major milestone, but we are not done just yet. We still have to update the version and application records with the fact that this version has now been installed. That’s a server-side operation, so we will need to add yet one more client callable function to our installation utilities Script Include. As we did before, we will use the attachment ID to locate the version record and the associated application record and then update them both. We will also need to hunt down the installed application record so that we can link it to the application record in the Collaboration Store database. We can also use data extracted from the version and application records to build a final status message informing the operator that the installation process is now complete.

We’ll call our new function recordInstallation, and start out by creating a response object, defaulting the success property to false, and then grabbing the attachment ID parameter that was passed in the Ajax call.

recordInstallation: function() {
	var answer = {success: false};
	var sysId = this.getParameter('attachment_id');
	if (sysId) {
		...
	} else {
		answer.error = 'Missing required parameter: attachment_id';
	}
	return JSON.stringify(answer);
}

Assuming that we have a sys_id, we will use it to get the attachment record, and then use the data in the attachment record to fetch the version and application records.

var sysAttGR = new GlideRecord('sys_attachment');
if (sysAttGR.get(sysId)) {
	var versionGR = new GlideRecord(sysAttGR.getDisplayValue('table_name'));
	if (versionGR.get(sysAttGR.getDisplayValue('table_sys_id'))) {
		answer.versionId = versionGR.getUniqueValue();
		var applicationGR = versionGR.member_application.getRefRecord();
		...
	} else {
		answer.error = 'Version record not found for sys_id ' + sysAttGR.getDisplayValue('table_sys_id');
	}
} else {
	answer.error = 'Attachment record not found for sys_id ' + sysId;
}

Once we have the version record and the application record in hand, we will need to hunt down the newly installed system application record.

var sysAppGR = new GlideRecord('sys_app');
sysAppGR.addQuery('scope', applicationGR.getValue('scope'));
sysAppGR.query();
if (sysAppGR.next()) {
	...
} else {
	answer.error = 'System application record not found for scope ' + applicationGR.getValue('scope');
}

Once we have the system application record, then we know that the application has been installed, and we can format the final status message.

answer.statusMessage = 'Version <strong>' + versionGR.getDisplayValue('version') + '</strong> of application <strong>' + applicationGR.getDisplayValue('name') + '</strong> installed.';

Now we need to link the installed system application record to our application record.

applicationGR.application = sysAppGR.getUniqueValue();
applicationGR.update();

We also need to mark the version as being installed.

versionGR.installed = true;
versionGR.update();

One other thing that we will need to do is to go through any other version records associated with this application and make sure that none of those are any longer marked as installed.

versionGR.initialize();
versionGR.addQuery('member_application', applicationGR.getUniqueValue());
versionGR.addQuery('sys_id', '!=', answer.versionId);
versionGR.query();
while (versionGR.next()) {
	versionGR.installed = false;
	versionGR.update();
}

That completes the updates to the Collaboration Store records, so the only thing left to do at this point is to override our initial response object success value with true.

answer.success = true;

Putting it all together, the entire function looks like this:

recordInstallation: function() {
	var answer = {success: false};
	var sysId = this.getParameter('attachment_id');
	if (sysId) {
		var sysAttGR = new GlideRecord('sys_attachment');
		if (sysAttGR.get(sysId)) {
			var versionGR = new GlideRecord(sysAttGR.getDisplayValue('table_name'));
			if (versionGR.get(sysAttGR.getDisplayValue('table_sys_id'))) {
				answer.versionId = versionGR.getUniqueValue();
				var applicationGR = versionGR.member_application.getRefRecord();
				var sysAppGR = new GlideRecord('sys_app');
				sysAppGR.addQuery('scope', applicationGR.getValue('scope'));
				sysAppGR.query();
				if (sysAppGR.next()) {
					answer.statusMessage = 'Version <strong>' + versionGR.getDisplayValue('version') + '</strong> of application <strong>' + applicationGR.getDisplayValue('name') + '</strong> installed.';
					applicationGR.application = sysAppGR.getUniqueValue();
					applicationGR.update();
					versionGR.installed = true;
					versionGR.update();
					versionGR.initialize();
					versionGR.addQuery('member_application', applicationGR.getUniqueValue());
					versionGR.addQuery('sys_id', '!=', answer.versionId);
					versionGR.query();
					while (versionGR.next()) {
						versionGR.installed = false;
						versionGR.update();
					}
					answer.success = true;
				} else {
					answer.error = 'System application record not found for scope ' + applicationGR.getValue('scope');
				}
			} else {
				answer.error = 'Version record not found for sys_id ' + sysAttGR.getDisplayValue('table_sys_id');
			}
		} else {
			answer.error = 'Attachment record not found for sys_id ' + sysId;
		}
	} else {
		answer.error = 'Missing required parameter: attachment_id';
	}
	return JSON.stringify(answer);
}

That takes care of the server-side code; now we have to update the Client script in our UI Page to make the GlideAjax call to the function and then return the operator to the original version record where the Install button was first selected. At the end of the Commit process, we referenced an updateStoreData function, but did not create it. We will need to create this function now, and then we can make the call within that new function. Before we do that, though, I wanted to note a slight modification that I made to the HTML for that page to add an id tag to the H4 element that includes our status message. The reason that I wanted to do that was so that the final message would not only replace the wording, but it would also replace the loading image that indicated that there was an ongoing process. Once we get to this point, the processing is over, so I did not want to leave that spinning image up on the screen. Here is the modified version of that portion of the HTML:

<h4 style="padding: 30px;" id="final_status_text">
  &#160;
  <img src="/images/loading_anim4.gif" height="18" width="18"/>
  &#160;
  <span id="status_text">Previewing Uploaded Update Set ...</span>
</h4>

Now we can create our new updateStoreData function.

function updateStoreData() {
	document.getElementById('status_text').innerHTML = 'Updating Collaboration Store Database ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'recordInstallation');
	ga.addParam('attachment_id', attachmentId);
	ga.getXMLAnswer(finalizeInstallation);
}

Now we have referenced a finalizeInstallation function, so we will need to create that as well. This function simply adds that final status message to the page and then returns the operator back to the original version form page.

function finalizeInstallation(answer) {
	var result = JSON.parse(answer);
	document.getElementById('final_status_text').innerHTML = result.statusMessage;
	window.location.href = '/x_11556_col_store_member_application_version.do?sys_id=' + result.versionId;
}

Here is the full Client script for the UI Page from top to bottom.

var dataLossConfirmDialog;
var attachmentId = '';
var updateSetId = '';
var commitInProgress = false;

function onLoad() {
	attachmentId = document.getElementById('attachment_id').value;
	updateSetId = document.getElementById('remote_update_set_id').value;
	if (updateSetId) {
		previewRemoteUpdateSet();
	}
}

addLoadEvent(function() {
	onLoad();
});

function previewRemoteUpdateSet() {
	var MESSAGE_KEY_DIALOG_TITLE = "Update Set Preview";
	var MESSAGE_KEY_CLOSE_BUTTON = "Close";
	var MESSAGE_KEY_CANCEL_BUTTON = "Cancel";
	var MESSAGE_KEY_CONFIRMATION = "Confirmation";
	var MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE = "Are you sure you want to cancel this update set preview?";
	var map = new GwtMessage().getMessages([MESSAGE_KEY_DIALOG_TITLE, MESSAGE_KEY_CLOSE_BUTTON, MESSAGE_KEY_CANCEL_BUTTON, MESSAGE_KEY_CONFIRMATION, MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");

	dd.setTitle(map[MESSAGE_KEY_DIALOG_TITLE]);
	dd.setPreference('sysparm_ajax_processor', 'UpdateSetPreviewAjax');
	dd.setPreference('sysparm_ajax_processor_function', 'preview');
	dd.setPreference('sysparm_ajax_processor_sys_id', updateSetId);
	dd.setPreference('sysparm_renderer_expanded_levels', '0');
	dd.setPreference('sysparm_renderer_hide_drill_down', true);
	dd.setPreference('focusTrap', true);
	dd.setPreference('sysparm_button_close', map["Close"]);
	dd.on("executionStarted", function(response) {
		var trackerId = response.responseXML.documentElement.getAttribute("answer");

		var cancelBtn = new Element("button", {
			'id': 'sysparm_button_cancel',
			'type': 'button',
			'class': 'btn btn-default',
			'style': 'margin-left: 5px; float:right;'
		}).update(map[MESSAGE_KEY_CANCEL_BUTTON]);

		cancelBtn.onclick = function() {
			var dialog = new GlideModal('glide_modal_confirm', true, 300);
			dialog.setTitle(map[MESSAGE_KEY_CONFIRMATION]);
			dialog.setPreference('body', map[MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
			dialog.setPreference('focusTrap', true);
			dialog.setPreference('callbackParam', trackerId);
			dialog.setPreference('defaultButton', 'ok_button');
			dialog.setPreference('onPromptComplete', function(param) {
				var cancelBtn2 = $("sysparm_button_cancel");
				if (cancelBtn2)
					cancelBtn2.disable();
				var ajaxHelper = new GlideAjax('UpdateSetPreviewAjax');
				ajaxHelper.addParam('sysparm_ajax_processor_function', 'cancelPreview');
				ajaxHelper.addParam('sysparm_ajax_processor_tracker_id', param);
				ajaxHelper.getXMLAnswer(_handleCancelPreviewResponse);
			});
			dialog.render();
			dialog.on("bodyrendered", function() {
				var okBtn = $("ok_button");
				if (okBtn) {
					okBtn.className += " btn-destructive";
				}
			});
		};

		var _handleCancelPreviewResponse = function(answer) {
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.remove();
		};

		var buttonsPanel = $("buttonsPanel");
		if (buttonsPanel)
			buttonsPanel.appendChild(cancelBtn);
	});

	dd.on("executionComplete", function(trackerObj) {
		dd.destroy();
		checkPreviewResults();
	});
	
	dd.render();
}

function checkPreviewResults() {
	document.getElementById('status_text').innerHTML = 'Evaluating Preview Results ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'evaluatePreview');
	ga.addParam('remote_update_set_id', updateSetId);
	ga.getXMLAnswer(commitUpdateSet);
}

function commitUpdateSet(answer) {
	var result = JSON.parse(answer);
	var message = '';
	if (result.accepted > 0) {
		if (result.accepted > 1) {
			message += result.accepted + ' Flagged Updates Accepted; ';
		} else {
			message += 'One Flagged Update Accepted; ';
		}
	}
	if (result.skipped > 0) {
		if (result.skipped > 1) {
			message += result.skipped + ' Flagged Updates Skipped; ';
		} else {
			message += 'One Flagged Update Skipped; ';
		}
	}
	message += 'Committing Update Set ...';
	document.getElementById('status_text').innerHTML = message;
	commitRemoteUpdateSet();
}

function commitRemoteUpdateSet() {
	if (commitInProgress) {
		return;
	}

	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'validateCommitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', updateSetId);
	ajaxHelper.getXMLAnswer(getValidateCommitUpdateSetResponse);
}

function getValidateCommitUpdateSetResponse(answer) {
	try {
		if (answer == null) {
			console.log('validateCommitRemoteUpdateSet answer was null');
			return;
		}
		console.log('validateCommitRemoteUpdateSet answer was ' + answer);
		var returnedInfo = answer.split(';');
		var sysId = returnedInfo[0];
		var encodedQuery = returnedInfo[1];
		var delObjList = returnedInfo[2];
		if (delObjList !== "NONE") {
			console.log('showing data loss confirm dialog');
			showDataLossConfirmDialog(sysId, delObjList, encodedQuery);
		} else {
			console.log('skipping data loss confirm dialog');
			runTheCommit(sysId);
		}
	} catch (e) {
		console.log(e);
	}
}

function runTheCommit(sysId) {
	console.log('running commit on ' + sysId);
	commitInProgress = true;
	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'commitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', sysId);
	ajaxHelper.getXMLAnswer(getCommitRemoteUpdateSetResponse);
}

function destroyDialog() {
	dataLossConfirmDialog.destroy();
}

function showDataLossConfirmDialog(sysId, delObjList, encodedQuery) {
	var dialogClass = typeof GlideModal != 'undefined' ? GlideModal : GlideDialogWindow;
	var dlg = new dialogClass('update_set_data_loss_commit_confirm');
	dataLossConfirmDialog = dlg;
	dlg.setTitle('Confirm Data Loss');
	if(delObjList == null) {
		dlg.setWidth(300);
	} else {
		dlg.setWidth(450);
	}
	dlg.setPreference('sysparm_sys_id', sysId);
	dlg.setPreference('sysparm_encodedQuery', encodedQuery);
	dlg.setPreference('sysparm_del_obj_list', delObjList);
	console.log('rendering data loss confirm dialog');
	dlg.render();
}

function getCommitRemoteUpdateSetResponse(answer) {
	try {
		if (answer == null) {
			return;
		}
		var map = new GwtMessage().getMessages(["Close", "Cancel", "Are you sure you want to cancel this update set?", "Update Set Commit", "Go to Subscription Management"]);
		var returnedIds = answer.split(',');
		var workerId = returnedIds[0];
		var sysId = returnedIds[1];
		var shouldRefreshNav = returnedIds[2];
		var shouldRefreshApps = returnedIds[3];
		var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
		var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");
		dd.setTitle(map["Update Set Commit"]);
		dd.setPreference('sysparm_renderer_execution_id', workerId);
		dd.setPreference('sysparm_renderer_expanded_levels', '0');
		dd.setPreference('sysparm_renderer_hide_drill_down', true);
		dd.setPreference('sysparm_button_subscription', map["Go to Subscription Management"]);
		dd.setPreference('sysparm_button_close', map["Close"]);
		dd.on("bodyrendered", function(trackerObj) {
			var buttonsPanel = $("buttonsPanel");
			var table = new Element("table", {cellpadding: 0, cellspacing: 0, width : "100%"});
			buttonsCell = table.appendChild(new Element("tr")).appendChild(new Element("td"));
			buttonsCell.align = "right";
			buttonsPanel.appendChild(table);
			var closeBtn = $("sysparm_button_close");
			if (closeBtn) {
				closeBtn.disable();
			}
			var cancelBtn = new Element("button");
			cancelBtn.id = "sysparm_button_cancel";
			cancelBtn.type = "button";
			cancelBtn.innerHTML = map["Cancel"];
			cancelBtn.onclick = function() {
				var response = confirm(map["Are you sure you want to cancel this update set?"]);
				if (response != true) {
					return;
				}
				var ajaxHelper = new GlideAjax('UpdateSetCommitAjax');
				ajaxHelper.addParam('sysparm_type', 'cancelRemoteUpdateSet');
				ajaxHelper.addParam('sysparm_worker_id', workerId);
				ajaxHelper.getXMLAnswer(getCancelRemoteUpdateSetResponse);
			};
			buttonsCell.appendChild(cancelBtn);
		});

		dd.on("executionComplete", function(trackerObj) {
			dd.destroy();
			updateStoreData();
		});

		dd.render();
	} catch (e) {
		console.log(e);
	}
}

function getCancelRemoteUpdateSetResponse(answer) {
	if (answer == null) {
		return;
	}
}

function updateStoreData() {
	document.getElementById('status_text').innerHTML = 'Updating Collaboration Store Database ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'recordInstallation');
	ga.addParam('attachment_id', attachmentId);
	ga.getXMLAnswer(finalizeInstallation);
}

function finalizeInstallation(answer) {
	var result = JSON.parse(answer);
	document.getElementById('final_status_text').innerHTML = result.statusMessage;
	window.location.href = '/x_11556_col_store_member_application_version.do?sys_id=' + result.versionId;
}

That completes the installation process, the third and final major component of the Collaboration Store application. All that is left now is to release a new Update Set to the testers and see what kinds of bugs we can shake out of this thing before we actually produce an official 1.0 version of the app. If you have not had an opportunity to participate in the testing just yet, now might be a good time to jump in and see what you can find. Feedback of any kind is always welcome. We’ll get more into all of that next time out.

Collaboration Store, Part LIII

“But ‘almost done’ is not done!”
Israelmore Ayivor

Well, we’re almost there! Last time, we wrapped up the code to handle any possible Preview issues, so now it is time to finally see if we can issue a Commit and actually get the version of the application installed. As we did with the Preview process, we can hunt down the UI Action that handles the Commit and see if we can steal much, if not all, of the code. Here is what I found:

var commitInProgress = false;

function commitRemoteUpdateSet(control) {
	if (commitInProgress)
		return;
	
	// get remoteUpdateSetId from g_form if invoked on remote update set page
	var rusId = typeof g_form != 'undefined' && g_form != null ? g_form.getUniqueValue() : null;
	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'validateCommitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', rusId);
	ajaxHelper.getXMLAnswer(getValidateCommitUpdateSetResponse);
}

function getValidateCommitUpdateSetResponse(answer) {
	try {
		if (answer == null) {
			console.log('validateCommitRemoteUpdateSet answer was null');
			return;
		}
		console.log('validateCommitRemoteUpdateSet answer was ' + answer);
		var returnedInfo = answer.split(';');
		
		var sysId = returnedInfo[0];
		var encodedQuery = returnedInfo[1];
		var delObjList = returnedInfo[2];

		if (delObjList !== "NONE") {
			console.log('showing data loss confirm dialog');
			showDataLossConfirmDialog(sysId, delObjList, encodedQuery);
		}
		else {
			console.log('skipping data loss confirm dialog');
			runTheCommit(sysId);
		}
	} catch (err) {

	}
}

function runTheCommit(sysId) {
	console.log('running commit on ' + sysId);
	commitInProgress = true;
	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'commitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', sysId);
	ajaxHelper.getXMLAnswer(getCommitRemoteUpdateSetResponse);
}

var dataLossConfirmDialog;
function destroyDialog() {
	dataLossConfirmDialog.destroy();
}

function showDataLossConfirmDialog(sysId, delObjList, encodedQuery) {
	var dialogClass = typeof GlideModal != 'undefined' ? GlideModal : GlideDialogWindow;
    var dlg = new dialogClass('update_set_data_loss_commit_confirm');
	dataLossConfirmDialog = dlg;
    dlg.setTitle('Confirm Data Loss');
    if(delObjList == null) {
        dlg.setWidth(300);
    } else {
        dlg.setWidth(450);
    }
    
    dlg.setPreference('sysparm_sys_id', sysId);
	dlg.setPreference('sysparm_encodedQuery', encodedQuery);
    dlg.setPreference('sysparm_del_obj_list', delObjList);
	console.log('rendering data loss confirm dialog');
    dlg.render();
}


function getCommitRemoteUpdateSetResponse(answer) {
	try {
		if (answer == null)
			return;
		
		var map = new GwtMessage().getMessages(["Close", "Cancel", "Are you sure you want to cancel this update set?", "Update Set Commit", "Go to Subscription Management"]);
		
		var returnedIds = answer.split(',');
		
		var workerId = returnedIds[0];
		var sysId = returnedIds[1];
		var shouldRefreshNav = returnedIds[2];
		var shouldRefreshApps = returnedIds[3];
		var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
		var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");
		dd.setTitle(map["Update Set Commit"]);
		dd.setPreference('sysparm_renderer_execution_id', workerId);
		dd.setPreference('sysparm_renderer_expanded_levels', '0'); // collapsed root node by default
		dd.setPreference('sysparm_renderer_hide_drill_down', true);
		
		dd.setPreference('sysparm_button_subscription', map["Go to Subscription Management"]);
		dd.setPreference('sysparm_button_close', map["Close"]);
		

		dd.on("bodyrendered", function(trackerObj) {
			var buttonsPanel = $("buttonsPanel");
			var table = new Element("table", {cellpadding: 0, cellspacing: 0, width : "100%"});
			buttonsCell = table.appendChild(new Element("tr")).appendChild(new Element("td"));
			buttonsCell.align = "right";
			buttonsPanel.appendChild(table);
			
			var closeBtn = $("sysparm_button_close");
			if (closeBtn)
				closeBtn.disable();

			var cancelBtn = new Element("button");
			cancelBtn.id = "sysparm_button_cancel";
			cancelBtn.type = "button";
			cancelBtn.innerHTML = map["Cancel"];
			cancelBtn.onclick = function() {
				var response = confirm(map["Are you sure you want to cancel this update set?"]);
				if (response != true)
					return;
				
				var ajaxHelper = new GlideAjax('UpdateSetCommitAjax');
				ajaxHelper.addParam('sysparm_type', 'cancelRemoteUpdateSet');
				ajaxHelper.addParam('sysparm_worker_id', workerId);
				ajaxHelper.getXMLAnswer(getCancelRemoteUpdateSetResponse);
			};
			buttonsCell.appendChild(cancelBtn);
		});
		
		dd.on("executionComplete", function(trackerObj) {
			
			var subBtn = $("sysparm_button_subscription");
			var tableCount = Number(trackerObj.result.custom_table_count)
			
			if (tableCount > 0) {
				  if (subBtn) {
					  subBtn.enable();
					  subBtn.onclick = function() {
						  window.open(trackerObj.result.inventory_uri);
					  };
				 }
			} else {
				subBtn.hide();
			}
			
			var closeBtn = $("sysparm_button_close");
			if (closeBtn) {
				closeBtn.enable();
				closeBtn.onclick = function() {
					dd.destroy();
				};
			}
			
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.hide();
		});
		
		dd.on("beforeclose", function() {
			if (shouldRefreshNav)
				refreshNav();
			
			var top = getTopWindow();
			if (shouldRefreshApps && typeof top.g_application_picker != 'undefined')
				top.g_application_picker.fillApplications();
			
			reloadWindow(window); //reload current form after closing the progress viewer dialog
		});
		
		dd.render();
	} catch (err) {

	}
}

function getCancelRemoteUpdateSetResponse(answer) {
	if (answer == null)
		return;
	
	// Nothing really to do here.
}

Once again, I cannot claim to understand every single thing that is going on here, but that doesn’t mean that I can’t snag the code and see if I can make it work. As with the Preview logic, there is code in there to grab the sys_id of the Remote Update Set from the form. Since our process does not run on that form, that isn’t going to work, but we have already determined the sys_id when we were doing the Preview, so we can rip that line out and use the value that we have already established. Since we are going to need that in more than one function, and there are other global variables present in this script, I decided to make that a global variable as well and not pass it in to each function as an argument. I ended up with the following list of variables and then modified our onLoad function accordingly.

var dataLossConfirmDialog;
var attachmentId = '';
var updateSetId = '';
var commitInProgress = false;

function onLoad() {
	attachmentId = document.getElementById('attachment_id').value;
	updateSetId = document.getElementById('remote_update_set_id').value;
	if (updateSetId) {
		previewRemoteUpdateSet();
	}
}

I pasted in the rest of the Commit code from the UI Action down at the bottom of the Client script of our UI Page and then deleted those global variables that were embedded amongst the various functions. Then I updated our earlier commitUpdateSet function to update the status message with the results of our earlier review of the Preview results and then launch the Commit.

function commitUpdateSet(answer) {
	var result = JSON.parse(answer);
	var message = '';
	if (result.accepted > 0) {
		if (result.accepted > 1) {
			message += result.accepted + ' Flagged Updates Accepted; ';
		} else {
			message += 'One Flagged Update Accepted; ';
		}
	}
	if (result.skipped > 0) {
		if (result.skipped > 1) {
			message += result.skipped + ' Flagged Updates Skipped; ';
		} else {
			message += 'One Flagged Update Skipped; ';
		}
	}
	message += 'Committing Update Set ...';
	document.getElementById('status_text').innerHTML = message;
	commitRemoteUpdateSet();
}

The last thing to do, then, is to modify what happens after the Commit, which in our case will be the updating of the Collaboration Store records to reflect the installation of this version. Once again, we do not want to wait for the operator to hit the Close button, so we can take the same approach that we took with the Preview code and modify the dd.on(“executionComplete” function to be simply this:

dd.on("executionComplete", function(trackerObj) {
	dd.destroy();
	updateStoreData();
});

Of course, we will have to build an updateStoreData function, which should update the version and application records and then return the operator back to the version record form where all of this started, but that’s a job that we will have to take on in our next installment.

Collaboration Store, Part LII

“Long is the road from conception to completion.”
Molière

Last time, we finished up the Update Set Preview process and it looked like all that was left was to code out the Commit process and we would be done with the last major component of this long drawn-out project. Unfortunately, that’s not entirely true. Before we can move on to the Commit process, we have to deal with the fact that the Preview process may have uncovered some issues with the Update Set. In the manual process, these issues are reported to the operator, and the operator is required to deal them all before the Commit option is available. Not only do we need to address that possibility, we also have to add code to update the application and version records to reflect the version that was just installed and to link the newly installed application with the application record. So we have a little more work to do beyond just launching the Commit process before we can declare project completion.

First of all, we need to decide what to do with any Preview issues that may have been detected. Ideally, you would want to give the operator the opportunity to review these issues and make the appropriate decisions based on their knowledge of their instance and the application. However, since we are trying to make this first version as automated as possible, I have decided to have the software make arbitrary decisions about each reported problem, at least for now. In some future version, I may want to pop up a dialog and ask the operator whether they want to do their own review or trust the system to do it for them, but for now, that’s a little more sophisticated than I am ready to tackle. This may not be the best approach, but it is the simplest, and I am trying wrap up the work on this initial version.

My plan is to add yet another client-callable function to our existing ApplicationInstaller Script Include that will hunt down all of the problems and resolve them. The problem records have a field called available_actions that contains a list of all of the actions available for the problem, so I am going to use that as a guide to Accept Remote Update if I can, or Skip Remote Update if I cannot. I also want to keep track of the number of problems found, the number of updates accepted, and the number of updates skipped so that I can report that information back to the caller. In reviewing the code behind the UI Actions that accept and skip updates, I found a call to a global component called GlidePreviewProblemAction, but when I tried to access that component in my scoped Script Include, I got a security violation error. To work around that, I had to add the following new function to our global utilities, where I could make the call without error.

fixRemoteUpdateIssue: function(remUpdGR) {
	var resolution = 'accepted';
	var ppa = new GlidePreviewProblemAction(gs.action, remUpdGR);
	if (remUpdGR.available_actions.contains('43d7d01a97b00100f309124eda2975e4')) {
		ppa.ignoreProblem();
	} else {
		ppa.skipUpdate();
		resolution = 'skipped';
	}
	return resolution;
}

With that out of the way, I was able to put the rest of the code where it belonged, and just called out to the global component for the part that I was unable to do in the scoped component.

evaluatePreview: function() {
	var answer = {problems: 0, accepted: 0, skipped: 0};
	var sysId = this.getParameter('remote_update_set_id');
	if (sysId) {
		problemId = [];
		var remUpdGR = new GlideRecord('sys_update_preview_problem');
		remUpdGR.addQuery('remote_update_set', sysId);
		remUpdGR.query();
		while (remUpdGR.next()) {
			problemId.push(remUpdGR.getUniqueValue());
			answer.problems++;
		}
		var csgu = new global.CollaborationStoreGlobalUtils();
		for (var i=0; i<problemId.length; i++) {
			remUpdGR.get(problemId[i]);
			var resolution = csgu.fixRemoteUpdateIssue(remUpdGR);
			if (resolution == 'accepted') {
				answer.accepted++;
			} else {
				answer.skipped++;
			}
		}
	}
	return JSON.stringify(answer);
}

Now we just need make the GlideAjax call to that function from the client side before we attempt to launch the Commit process. Right now, when the Preview process is complete, a Close button appears on the progress dialog, and when you click on the Close button, our new UI Page reloads and starts all over again because the script that we lifted from the UI Action on the Update Set form was set up to reload that form. For our purposes, we do not want our own page reloaded, and in fact, we don’t even want a Close button; we just want to move on to the process of reviewing the results of the Preview. The relevant portion of the script that we stole looks like this:

dd.on("executionComplete", function(trackerObj) {
	var cancelBtn = $("sysparm_button_cancel");
	if (cancelBtn)
		cancelBtn.remove();
         
	var closeBtn = $("sysparm_button_close");
	if (closeBtn) {
		closeBtn.onclick = function() {
			dd.destroy();
		};
	}
});
     
dd.on("beforeclose", function() {
	reloadWindow(window);
});

Since we do not want to wait for operator action, we can short-cut this entire operation and just move on as soon as execution has been completed. I replaced all of the above with the following:

dd.on("executionComplete", function(trackerObj) {
	dd.destroy();
	checkPreviewResults();
});

Since the Preview process is now complete at this point, and we are now looking at the results, I decided to wrap the original message on the page with a span that had an id attribute so that I could change the message as things moved along. That line of HTML now looks like this:

<span id="status_text">Previewing Uploaded Update Set ...</span>

With that in place, I was able to update the message with the new status before I made the Ajax call to our new Script Include function.

function checkPreviewResults() {
	document.getElementById('status_text').innerHTML = 'Evaluating Preview Results ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'evaluatePreview');
	ga.addParam('remote_update_set_id', updateSetId);
	ga.getXMLAnswer(commitUpdateSet);
}

function commitUpdateSet(answer) {
	alert(answer);
}

I’m not ready to take on the Commit process just yet, so I stubbed out the commitUpdateSet function with a simple alert of the response from our Ajax call. That was enough to let me know that everything was working up to this point, which is what I needed to know before I attempted to move on.

Now that we have dealt with the possibility of Preview problems, we can finally take a look at what it will take to Commit the Update Set. That’s obviously a bit of work, so we’ll leave all of that for our next episode.

Collaboration Store, Part LI

“Plodding wins the race.”
Aesop

Last time, we ended with yet another unresolved fork in the road, whether to launch the Preview process from the upload.do page or to build yet another new page specific to the application installation process. At the time, it seemed as if there were equal merits to either option, but today I have decided that building a new page would be the preferable alternative. For one thing, that keeps the artifacts involved within the scope of our application (our global UI Script to repurpose the upload.do page had to be in the global scope), and it keeps the alterations to upload.do to the bare minimum.

Before we go off and build a new page, though, we will need to figure out how we are going to get there without the involvement of the operator (we want this whole process to be as automatic as possible). Digging through the page source of the original upload.do page, I found something that looks as if it might be relevant to our needs:

<input value="sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set" name="sysparm_referring_url" type="hidden"></input>

Now, the name of this element is sysparm_referring_url, which sounds an awful lot like it would be the URL from which we came; however, this is actually the URL where we end up after the Update Set XML file is uploaded, so I am thinking that if we replaced this value with a link to our own page, maybe we would end up there instead. Only one way to find out …

Those of you following along at home may recall that this value, which appears in the HTML source, actually disappeared somehow before the form was submitted, so I had to add this line of code to our script to put it back:

document.getElementsByName('sysparm_referring_url')[0].value = 'sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set';

Assuming that we create a new UI Page for the remainder of the process and that we want to pass to it the attachment ID, we should be able to replace that line with something like this:

document.getElementsByName('sysparm_referring_url')[0].value = 'ui_page.do?sys_id=<sys_id of our new page>&sysparm_id=' + window.location.search.substring(15);

Now all we need to do is create the page, put something on it, and then add the code that we stole from the UI Action that launches the Update Set Preview. After we hacked up the upload.do page, the end result turned out looking like this:

Modified upload.do page

To keep things looking consistent, we can steal some of the HTML from that page and make our new page look something like this:

New page layout

To make that happen, we can snag most of the HTML from a quick view frame source and then format it and stuff it into a new UI Page called install_application:

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="true" xmlns:j="jelly:core" xmlns:g="glide" xmlns:g2="null">

<div>
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button class="btn btn-default icon-chevron-left navbar-btn" onclick="history.back();">
          <span class="sr-only">Back</span>
        </button>
        <h1 style="display:inline-block;" class="navbar-title">Install Application</h1>
      </div>
    </div>
  </nav>
  <div class="section-content">
    <div id="output_messages" class="outputmsg_container outputmsg_hide">
      <button aria-label="Close Messages" id="close-messages-btn" class="btn btn-icon close icon-cross" onclick="GlideUI.get().clearOutputMessages(this); return false;"></button>
      <div class="outputmsg_div" aria-live="polite" role="region" data-server-messages="false"></div>
    </div>
    <div class="row">
      <div class="col-sm-12">
        <h4 style="padding: 30px;">
          &#160;
          <img src="/images/loading_anim4.gif" height="18" width="18"/>
          &#160;
          Previewing Uploaded Update Set ...
        </h4>
      </div>
    </div>
  </div>
</div>
</j:jelly>

That takes care how the page looks. Now we need to deal with how it works. To Preview an uploaded Update Set, you need the Remote Update Set‘s sys_id. We have a URL parameter that contains the sys_id of the Update Set XML file attachment, but that’s not the sys_id that we need at this point. We will have to build a process that uses the attachment sys_id to locate and return the sys_id that we will need. We can just add another function to our existing ApplicationInstaller Script Include.

getRemoteUpdateSetId: function(attachmentId) {
	var sysId = '';

	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(attachmentId)) {
		var versionGR = new GlideRecord(sysAttGR.getDisplayValue('table_name'));
		if (versionGR.get(sysAttGR.getDisplayValue('table_sys_id'))) {
			var updateSetGR = new GlideRecord('sys_remote_update_set');
			updateSetGR.addQuery('application_name', versionGR.getDisplayValue('member_application'));
			updateSetGR.addQuery('application_scope', versionGR.getDisplayValue('member_application.scope'));
			updateSetGR.addQuery('application_version', versionGR.getDisplayValue('version'));
			updateSetGR.addQuery('state', 'loaded');
			updateSetGR.query();
			if (updateSetGR.next()) {
				sysId = updateSetGR.getUniqueValue();
			}
		}
	}

	return sysId;
}

Basically, we use the passed attachment record sys_id to get the attachment record, then use data found on the attachment record to get the version record, and then use data found on the version record and associated application record to get the remote update set record, and then pull the sys_id that we need from there. Those of you who have been paying close attention may notice that one of the application record fields being used to find the remote update set is scope. The scope of the application was never included in the original list of data fields for the application record, so I had to go back and add it everywhere in the system where an application record was referenced, modified, or moved between instances. That was a bit of work, and hopefully I have found them all, but I think that was everything.

Anyway, now we have a way to turn an attachment record sys_id into a remote update set record sys_id, so we need to add some code to our UI Page to snag the attachment record sys_id from the URL, use it to get the sys_id that we need, and then stick that value on the page somewhere so that it can be picked up by the client-side code. At the top of the HTML for the page, I added this:

<g2:evaluate jelly="true">

var ai = new ApplicationInstaller();
var attachmentId = gs.action.getGlideURI().get('sysparm_id');
var sysId = ai.getRemoteUpdateSetId(attachmentId);

</g2:evaluate>

Then in the body of the page, just under the text, I added this hidden input element:

<input type="hidden" id="remote_update_set_id" value="$[sysId]"/>

That took care of things on the server side. Now we need to build some client-side code that will run when the page is loaded. We can do that with an addLoadEvent like so:

addLoadEvent(function() {  
	onLoad();
});

Our onLoad function can then grab the value from the hidden field and pass it on to the function that we lifted from the Preview Update Set UI Action earlier (which we need to paste into the client code section of our new UI Page).

function onLoad() {
	var sysId = document.getElementById('remote_update_set_id').value;
	if (sysId) {
		previewRemoteUpdateSet(sysId);
	}
}

That’s all there is to that. The entire Client script portion of the new UI Page, including the code that we lifted from the UI Action, now looks like this:

function onLoad() {
	var sysId = document.getElementById('remote_update_set_id').value;
	if (sysId) {
		previewRemoteUpdateSet(sysId);
	}
}

addLoadEvent(function() {  
	onLoad();
});

function previewRemoteUpdateSet(sysId) {
	var MESSAGE_KEY_DIALOG_TITLE = "Update Set Preview";
	var MESSAGE_KEY_CLOSE_BUTTON = "Close";
	var MESSAGE_KEY_CANCEL_BUTTON = "Cancel";
	var MESSAGE_KEY_CONFIRMATION = "Confirmation";
	var MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE = "Are you sure you want to cancel this update set preview?";
	var map = new GwtMessage().getMessages([MESSAGE_KEY_DIALOG_TITLE, MESSAGE_KEY_CLOSE_BUTTON, MESSAGE_KEY_CANCEL_BUTTON, MESSAGE_KEY_CONFIRMATION, MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");

	dd.setTitle(map[MESSAGE_KEY_DIALOG_TITLE]);
	dd.setPreference('sysparm_ajax_processor', 'UpdateSetPreviewAjax');
	dd.setPreference('sysparm_ajax_processor_function', 'preview');
	dd.setPreference('sysparm_ajax_processor_sys_id', sysId);
	dd.setPreference('sysparm_renderer_expanded_levels', '0');
	dd.setPreference('sysparm_renderer_hide_drill_down', true);
	dd.setPreference('focusTrap', true);
	dd.setPreference('sysparm_button_close', map["Close"]);
    dd.on("executionStarted", function(response) {
		var trackerId = response.responseXML.documentElement.getAttribute("answer");

		var cancelBtn = new Element("button", {
			'id': 'sysparm_button_cancel',
			'type': 'button',
			'class': 'btn btn-default',
			'style': 'margin-left: 5px; float:right;'
		}).update(map[MESSAGE_KEY_CANCEL_BUTTON]);

        cancelBtn.onclick = function() {
			var dialog = new GlideModal('glide_modal_confirm', true, 300);
			dialog.setTitle(map[MESSAGE_KEY_CONFIRMATION]);
			dialog.setPreference('body', map[MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
			dialog.setPreference('focusTrap', true);
			dialog.setPreference('callbackParam', trackerId);
			dialog.setPreference('defaultButton', 'ok_button');
			dialog.setPreference('onPromptComplete', function(param) {
				var cancelBtn2 = $("sysparm_button_cancel");
				if (cancelBtn2)
					cancelBtn2.disable();
				var ajaxHelper = new GlideAjax('UpdateSetPreviewAjax');
				ajaxHelper.addParam('sysparm_ajax_processor_function', 'cancelPreview');
				ajaxHelper.addParam('sysparm_ajax_processor_tracker_id', param);
				ajaxHelper.getXMLAnswer(_handleCancelPreviewResponse);
			});
			dialog.render();
			dialog.on("bodyrendered", function() {
				var okBtn = $("ok_button");
				if (okBtn) {
					okBtn.className += " btn-destructive";
				}
			});
        };

		var _handleCancelPreviewResponse = function(answer) {
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.remove();
		};

        var buttonsPanel = $("buttonsPanel");
        if (buttonsPanel)
			buttonsPanel.appendChild(cancelBtn);
	});

	dd.on("executionComplete", function(trackerObj) {
		var cancelBtn = $("sysparm_button_cancel");
		if (cancelBtn)
			cancelBtn.remove();
		
		var closeBtn = $("sysparm_button_close");
		if (closeBtn) {
			closeBtn.onclick = function() {
				dd.destroy();
			};
		}
	});
	
	dd.on("beforeclose", function() {
		reloadWindow(window);
	});
	
	dd.render();
}

Now all we need to do is pull up the old version record and push that Install button one more time, which I did.

So, there is good news and there is bad news. The good news is that it actually worked! That is to say that clicking on the Install button pulls down the Update Set XML file data, posts it back to the server via the modified upload.do page, and then goes right into previewing the newly created Update Set. That part is very cool, and something that I wasn’t sure that I was going to be able to pull off when I first started thinking about doing this. The bad news is that, once the Preview is complete, the stock code reloads the page and the whole Preview process starts all over again. That’s not good! However, that seems like a minor issue with which we should be able to deal relatively easy. All in all, then, it seems like mostly good news.

Of course, we are still not there yet. Once an Update Set has been Previewed, it sill has to be Committed before the application is actually installed. Rather than continuously reloading the page then, our version of the UI Action code is going to need to launch the Commit process. We should be able to examine the Commit UI Action as we did the Preview UI Action and steal some more code to make that happen. That sounds like a little bit of work, though, so let’s save all of that for our next installment.

Collaboration Store, Part L

“Time is what keeps everything from happening at once.”
Ray Cummings

Welcome to installment #50 of this seemingly never-ending series! That’s a milestone to which we have never even come close on this site. But then, we have never taken on a project of this magnitude before, either. Still, you would think that we would have been done with this endeavor long before now. That’s the way these things go, though. When you strike out into the darkness with just a vague idea of where you want to go, you never really know where you will end up or how long it will take. There are those who would tell you, though, that it’s all about the journey, not the destination! Still, I try to stay focused on the destination. I think we are getting close.

Last time, we wrapped up the coding on our global UI Script that allowed us to repurpose the upload.do page for installing a version of an application. We never really tested it all the way through, though, so we should probably do that before we attempt to go any further. Just to back up a bit, the way that we try this thing out is to pull up a version record for an application and click on the Install button that we added a few episodes back.

Using the Install button to test out the installation process

That should launch the upload.do page, and with the added URL parameter for the attachment sys_id, that should trigger our UI Script, which should then turn that page into this:

Altered upload.do page

Meanwhile, the script should call back to the server for the Update Set XML file information, update the form on the page using that information, and then submit the form. After the form has been submitted, the natural process related to the upload.do page takes you here:

End result of hijacking the upload.do page, a Loaded Update Set from our XML file

So, it looks like it all works, which is good. Unfortunately, the application has still not been installed. From here it is a manual process to first Preview the Update Set, and then Commit it. We don’t really want that to be a manual process, though, so let’s see what we can do to make that all happen without the operator having to click on anything or take any action to move things along. To begin, we should probably take a look how it is done manually, which should help guide us into how we might be able to do it programmatically. If you click on the Update Set in the above screen to bring up the details, you will see a form button, which is just another UI Action, called Preview Update Set.

Preview Update Set UI Action

Using the hamburger menu, we can select Configure -> UI Actions to pull up the list of UI Actions related to this form, and then select the Preview Update Set action and take a peek under the hood. It looks like all of the work is done on the client side with the following script:

function previewRemoteUpdateSet(control) {
	var MESSAGE_KEY_DIALOG_TITLE = "Update Set Preview";
	var MESSAGE_KEY_CLOSE_BUTTON = "Close";
	var MESSAGE_KEY_CANCEL_BUTTON = "Cancel";
	var MESSAGE_KEY_CONFIRMATION = "Confirmation";
	var MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE = "Are you sure you want to cancel this update set preview?";
	var map = new GwtMessage().getMessages([MESSAGE_KEY_DIALOG_TITLE, MESSAGE_KEY_CLOSE_BUTTON, MESSAGE_KEY_CANCEL_BUTTON, MESSAGE_KEY_CONFIRMATION, MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
	var sysId = typeof g_form != 'undefined' && g_form != null ? g_form.getUniqueValue() : null;
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");

	dd.setTitle(map[MESSAGE_KEY_DIALOG_TITLE]);
	dd.setPreference('sysparm_ajax_processor', 'UpdateSetPreviewAjax');
	dd.setPreference('sysparm_ajax_processor_function', 'preview');
	dd.setPreference('sysparm_ajax_processor_sys_id', sysId);
	dd.setPreference('sysparm_renderer_expanded_levels', '0'); // collapsed root node by default
	dd.setPreference('sysparm_renderer_hide_drill_down', true);
	dd.setPreference('focusTrap', true);

	dd.setPreference('sysparm_button_close', map["Close"]);
	// response from UpdateSetPreviewAjax.previewAgain is the progress worker id
    dd.on("executionStarted", function(response) {
		var trackerId = response.responseXML.documentElement.getAttribute("answer");

		var cancelBtn = new Element("button", {
			'id': 'sysparm_button_cancel',
			'type': 'button',
			'class': 'btn btn-default',
			'style': 'margin-left: 5px; float:right;'
		}).update(map[MESSAGE_KEY_CANCEL_BUTTON]);

        cancelBtn.onclick = function() {
			var dialog = new GlideModal('glide_modal_confirm', true, 300);
			dialog.setTitle(map[MESSAGE_KEY_CONFIRMATION]);
			dialog.setPreference('body', map[MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
			dialog.setPreference('focusTrap', true);
			dialog.setPreference('callbackParam', trackerId);
			dialog.setPreference('defaultButton', 'ok_button');
			dialog.setPreference('onPromptComplete', function(param) {
				var cancelBtn2 = $("sysparm_button_cancel");
				if (cancelBtn2)
					cancelBtn2.disable();
				var ajaxHelper = new GlideAjax('UpdateSetPreviewAjax');
				ajaxHelper.addParam('sysparm_ajax_processor_function', 'cancelPreview');
				ajaxHelper.addParam('sysparm_ajax_processor_tracker_id', param);
				ajaxHelper.getXMLAnswer(_handleCancelPreviewResponse);
			});
			dialog.render();
			dialog.on("bodyrendered", function() {
				var okBtn = $("ok_button");
				if (okBtn) {
					okBtn.className += " btn-destructive";
				}
			});
        };

		var _handleCancelPreviewResponse = function(answer) {
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.remove();
		};

        var buttonsPanel = $("buttonsPanel");
        if (buttonsPanel)
        	buttonsPanel.appendChild(cancelBtn);
	});

	dd.on("executionComplete", function(trackerObj) {
		var cancelBtn = $("sysparm_button_cancel");
		if (cancelBtn)
			cancelBtn.remove();
		
		var closeBtn = $("sysparm_button_close");
		if (closeBtn) {
			closeBtn.onclick = function() {
				dd.destroy();
			};
		}
	});
	
	dd.on("beforeclose", function() {
		reloadWindow(window);
	});
	
	dd.render();
}

I’m not going to attempt to pretend that I understand all that is going on here. I will say, though, that it looks to me as if we could steal this entire script and launch it from a location of our own choosing without having to have the operator click on any buttons. The one line that I see that would need to be modified is the one that gets the sys_id of the Update Set.

var sysId = typeof g_form != 'undefined' && g_form != null ? g_form.getUniqueValue() : null;

I think to start with, I would just delete that line entirely and pass the sys_id in as an argument to the function. Right now, a variable called control is passed in to the function, but I don’t see where that is used anywhere, so I think that I would just change this:

function previewRemoteUpdateSet(control) {

… to this:

function previewRemoteUpdateSet(sysId) {

… and see where that might take us. Maybe that will work and maybe it won’t, but you never know until you try. Of course, not everyone is a big proponent of that Let’s pull the lever and see what happens approach; once I was told that the last words spoken on Earth will be something like “Gee, I wonder what this button does.” Still, it’s just my nature to try things and see how it all turns out. But first we have to figure out where we can put our stolen script.

I can see two ways to go here: 1) we can just add it to our hack of the upload.do page and keep everything all in one place, or 2) since the upload.do page has done it’s job at this point and we don’t want to hack up a stock component any more than is absolutely necessary, let’s create a UI Page of our own and put the rest of the process in there where we can control everything and keep it within the scope of the application. There are, as usual, pros and cons for both approaches. I don’t know if one way is any better than the other, but we don’t have to decide right this minute. Let’s save that for our next installment.

Collaboration Store, Part XXII

“Doubt is an uncomfortable condition, but certainty is a ridiculous one.”
Voltaire

Now that we have the face of our new pop-up window laid out, it’s time to build out the code underneath that will do all of the work. Since this is all server-side activity, we will need some kind of client accessible Script Include that we can call from the client side for each step of the process. Just to do a little testing on the structure and the process, I created a stubbed-out version of a Script Include that I can call from the client-side code to do a little testing before we get too far into the weeds.

var ApplicationPublisher = Class.create();
ApplicationPublisher.prototype = Object.extendsObject(global.AbstractAjaxProcessor, {

    processPhaseClient: function() {
		var phase = this.getParameter('sysparm_phase');
		var mbrAppId = this.getParameter('sysparm_mbr_app_id');
		var appSysId = this.getParameter('sysparm_app_sys_id');
		var updSetId = this.getParameter('sysparm_upd_set_id');
		var origAppId = this.getParameter('sysparm_orig_app_id');
		return this.processPhase(phase, mbrAppId, appSysId, updSetId, origAppId);
	},

    processPhase: function(phase, mbrAppId, appSysId, updSetId, origAppId) {
		return 'success';
	},

    type: 'ApplicationPublisher'
});

At this point, all it does is return the value ‘success’ each time that it is called. This will be enough to prove out all of the client-side code before we dig into the actual work on the server side. For now, we just want to prove that the design is functional before we invest too much time in the actual tasks ahead of us.

On the client side, we will want to create a generic function that will work essentially the same for every step of the process. To keep track of where we are, I created a variable called phase, which is basically just the number of the current step. I called this function processPhase.

function processPhase(phase, mbrAppId, appSysId, updSetId, origAppId) {
	var ga = new GlideAjax('ApplicationPublisher');
	ga.addParam('sysparm_name', 'processPhaseClient');
	ga.addParam('sysparm_phase', phase);
	ga.addParam('sysparm_mbr_app_id', mbrAppId);
	ga.addParam('sysparm_app_sys_id', appSysId);
	ga.addParam('sysparm_upd_set_id', updSetId);
	ga.addParam('sysparm_orig_app_id', origAppId);
	ga.getXMLAnswer(function (answer) {
		if (answer == 'success') {
			hideElement('loading_' + phase);
			showElement('success_' + phase);
			phase++;
			if (phase < 7) {
				showElement('phase_' + phase);
				processPhase(phase, mbrAppId, appSysId, updSetId, origAppId);
			} else {
				showElement('done_button');
			}
		} else {
			showElement('done_button');
		}
	});
}

This is a recursive function that calls itself for every step until all six steps have been completed or there was some kind of an error. If the response from the Ajax call is ‘success’, then the loading icon for that step is hidden and replaced with the success icon, and then the phase is incremented. If we haven’t reached the end of the steps, then the HTML block for the next step is revealed and the process is repeated. If we reach the end of the steps, or if the Ajax call returns anything other than success, then the process comes to an end and the Done button is revealed. There is no effort on the client side to communicate any error or completion message to the operator. The assumption is that all messaging will be handled on the server side.

For the hideElement and showElement functions, I just stole some old code from some other component where I needed to do the same thing. There’s not much exciting here, but just for the sake of including everything, here it is:

function showElement(elementName) {
	var elem = gel(elementName);
	elem.style.visibility = 'visible';
	elem.style.display = '';
}

function hideElement(elementName) {
	var elem = gel(elementName);
	elem.style.visibility = 'hidden';
	elem.style.display = 'none';
}

Even though the processPhase function calls itself once it gets going, something has to initially get the ball rolling to kicks things off. For that, we can use an onload function to initialize all of the values and make that first call.

function onLoad() {
	var phase = 1;
	var mbrAppId = gel('mbr_app_id').value;
	var appSysId = gel('app_sys_id').value;
	var updSetId = gel('upd_set_id').value;
	var origAppId = gel('mbr_app_id').value;
	processPhase(phase, mbrAppId, appSysId, updSetId, origAppId);
}

Although that seemed like a good plan, when I ran my first test run, nothing happened. After doing a little online research, I discovered that the onLoad function is not a natural thing for a UI Page, and if I wanted something to run on load, I needed to add just a bit more code.

addLoadEvent(function() {  
	onLoad();
});

That was much better! Now after calling the server side six times in succession, the final rendition of the pop-up screen looked like this:

Pop-up structure test results

That proves that the client-side process functions as we intended and the stubbed-out Script Include is getting called and is returning the hard-coded ‘success’ value. This should now complete the work on the UI Page itself, so all that is left is to finish out the server-side Script Include to perform all of the tasks itemized in our list of steps. Next time, we can get started on the fist step, which will involve calling the global function that we created earlier to convert the Update Set to XML.

Collaboration Store, Part XXI

“Slow, steady progress is better than daily excuses.”
Robin Sharma

Now that we have managed to create an Update Set from our UI Action, it is time to figure out how to best accomplish the rest of the tasks needed to complete the publication of our Scoped Application to the Host instance. We will need another pop-up window to control the rest of the activities and to report the status to the operator, as the original pop-up went away when the progress bar appeared, and the progress bar goes away once you click on the Done button (an unnecessary click for our purposes, which I would like to avoid at some point, but for now, we will leave it alone and focus on other, more important activities). The next pop-up window should be the last, as it will be a fully custom component and we should be able to control all of the events from this point forward.

Here is what I am thinking at this point: We can create a simple UI that lists all of the steps necessary to complete our objective, and reveal them one at a time as we progress through the work involved. We can have three different icons ahead of each item on the list, one for in progress, one for success, and another for failure, and then show/hide the icons based on the current status of each step in the process. To make that work, we would have to contact the server side for each step along the way, updating the UI on the client side with the status returned from each server call. Once we successfully complete the final step, we can reveal a Done button to close the pop-up.

To find some appropriate icons, I entered image_picker.do in the left-hand navigator to bring up the list of all of the images currently in the /images/ directory of the platform.

image_picker.do results

I selected the following icons to represent the associated stages for each step:

  • In progress: /images/loading_anim4.gif
  • Complete: /images/check32.gif
  • Error: /images/delete_row.gif

As with many things on the Now Platform, there are many ways in which we can develop a pop-up dialog. I am most comfortable with building Service Portal widgets, and I have used widgets for UI pop-ups before, so that was a tempting way to go. However, the initial pop-up that we customized for our UI Action was a straight UI Page, so I decided that, if I wanted to keep things consistent, I should build this new pop-up the same way. So I cloned our original publish_app_dialog_cs UI Page to create publish_app_dialog_cs_2. Then I went in to our original UI Page and removed this code:

var notification = {"getAttribute": function(name) {return 'true';}};
CustomEvent.fireTop(GlideUI.UI_NOTIFICATION + '.update_set_change', notification);
window.location.href = "sys_update_set.do?sys_id=" + updateSetId;

… which took you to the newly created Update Set when everything was finished, and replaced it with this:

var dialogClass = GlideModal ? GlideModal : GlideDialogWindow;
var dd2 = new dialogClass("x_11556_col_store_publish_app_dialog_cs_2");
dd2.setTitle(new GwtMessage().getMessage('Publish to Collaboration Store'));
dd2.setPreference('sysparm_mbr_app_id', this.mbrAppId);
dd2.setPreference('sysparm_app_sys_id', this.appId);
dd2.setPreference('sysparm_upd_set_id', updateSetId);
dd2.setWidth(500);
dd2.render();

… which now pops up our newly cloned UI Page in a new modal dialog. Now we actually have to build out the page. To begin, as I usually do, I started with the HTML of the presentation layer, which came out like this:

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="true" xmlns:j="jelly:core" xmlns:g="glide" xmlns:g2="null">

<g2:evaluate jelly="true">

var existingVersions = '';
var mbrAppId = jelly.sysparm_mbr_app_id;
var appSysId = jelly.sysparm_app_sys_id;
var updSetId = jelly.sysparm_upd_set_id;
var appAction = 'Updating';
if (mbrAppId == 'new') {
	appAction = 'Creating';
}

</g2:evaluate>

<g:ui_form>
	<style type="text/css">
		#publish_to_update_set_dialog_container input {
			width: 100%;
			margin-bottom: 10px;
		}
		#publish_to_update_set_dialog_container label {
			text-align: right;
		}
	</style>
	<div id="publish_to_collaboration_store_dialog_container">
		<div class="modal-body">
			${gs.getMessage('Completing the process of publishing the application to the Collaboration Store')}
			<input id="mbr_app_id" type="hidden" value="$[mbrAppId]"/>
			<input id="app_sys_id" type="hidden" value="$[appSysId]"/>
			<input id="upd_set_id" type="hidden" value="$[updSetId]"/>
		</div>

		<div style="padding-left: 50px;">
			<div class="row" id="phase_1">
				<image id="loading_1" src="/images/loading_anim4.gif" style="width: 16px; height: 16px;"/>
				<image id="success_1" src="/images/check32.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<image id="error_1" src="/images/delete_row.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<span style="margin-left: 5px; font-weight:bold;">
					Converting Update Set to XML
				</span>
			</div>
			<div class="row" id="phase_2" style="visibility: hidden; display: none;">
				<image id="loading_2" src="/images/loading_anim4.gif" style="width: 16px; height: 16px;"/>
				<image id="success_2" src="/images/check32.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<image id="error_2" src="/images/delete_row.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<span style="margin-left: 10px; font-weight:bold;">
					$[appAction] the Application record
				</span>
			</div>
			<div class="row" id="phase_3" style="visibility: hidden; display: none;">
				<image id="loading_3" src="/images/loading_anim4.gif" style="width: 16px; height: 16px;"/>
				<image id="success_3" src="/images/check32.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<image id="error_3" src="/images/delete_row.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<span style="margin-left: 10px; font-weight:bold;">
					Creating the Version record
				</span>
			</div>
			<div class="row" id="phase_4" style="visibility: hidden; display: none;">
				<image id="loading_4" src="/images/loading_anim4.gif" style="width: 16px; height: 16px;"/>
				<image id="success_4" src="/images/check32.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<image id="error_4" src="/images/delete_row.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<span style="margin-left: 10px; font-weight:bold;">
					Sending the Application record to the Host instance
				</span>
			</div>
			<div class="row" id="phase_5" style="visibility: hidden; display: none;">
				<image id="loading_5" src="/images/loading_anim4.gif" style="width: 16px; height: 16px;"/>
				<image id="success_5" src="/images/check32.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<image id="error_5" src="/images/delete_row.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<span style="margin-left: 10px; font-weight:bold;">
					Sending the Version record to the Host instance
				</span>
			</div>
			<div class="row" id="phase_6" style="visibility: hidden; display: none;">
				<image id="loading_6" src="/images/loading_anim4.gif" style="width: 16px; height: 16px;"/>
				<image id="success_6" src="/images/check32.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<image id="error_4" src="/images/delete_row.gif" style="width: 16px; height: 16px; visibility: hidden; display: none;"/>
				<span style="margin-left: 10px; font-weight:bold;">
					Sending the Update Set XML to the Host instance
				</span>
			</div>
		</div>
	</div>
</g:ui_form>
</j:jelly>

Basically, it is the same block of code repeated 6 times for the six remaining tasks needed to complete the process. Each item in the list contains a description of the step along with our three icons, only one of which should appear at any given time. Initially, the in progress icon is visible as soon as the step becomes visible, and my plan is to hide that one and reveal one of the other two, depending on how things came out once the step has been completed.

Here is how it looks when it first appears:

New pop-up dialog box

That takes care of the easy part. Now we have to put all of the code underneath of the UI to actually do all of the things that need to be done. That’s quite a bit of work, so let’s say we will get started on that next time out.

Collaboration Store, Part XX

“It’s always too soon to quit!”
Norman Vincent Peale

Now that we have the needed global Script Include out of the way, we can turn our attention to building the Publish to Collaboration Store UI Action. Before we do that, though, we are going to need a couple more tables, one for the published applications, and another for the application versions. I won’t waste a lot of time here on the process of building a new table; that’s pretty basic Now Platform stuff that you can find just about anywhere. I also did not spend a lot of time creating the tables. At this point, they are very basic and just enough to get things going. I am sure at some point I will be coming back around and adding more valuable fields, but for right now, I just included the bare necessities to create this initial publication process.

Here is the form for the base application table:

Base Member Application table input form

… and here is the form for the application versions:

Member Application Version table input form

There is an obvious one-to-many relationship between the application record and all of the various version records for an application. In practice, all of the versions would be listed under an application as a Related List on the application form. We can show examples of that once we build up some data.

Now that that is done, we can get back to our UI Action. Since our action is going to be very similar to the stock Publish to Update Set… action, the easiest way to create ours is to pull up the stock action and use the context menu to select Insert and Stay to clone the action.

Select Insert and Stay from the context menu to clone the UI Action

Now we just have to rename the action and update the script, which actually turned out to be more modifications that I had anticipated, primarily because our action is not in the global scope. First, we had to rename the function to avoid a conflict with the original action with which we will be sharing the page. Also, the gel function and the window object are not available to scoped actions, and of course, we needed to point to our own UI Page instead of the original and put in our own title.. Here is the modified script:

function publishToCollaborationStore() {
	var sysId = g_form.getUniqueValue();
	var dialogClass = GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("x_11556_col_store_publish_app_dialog_cs");
	dd.setTitle(new GwtMessage().getMessage('Publish to Collaboration Store'));
	dd.setPreference('sysparm_sys_id', sysId);
	dd.setWidth(500);
	dd.render();
}

I also removed all of the conditionals for now, since some of that code was not available to a scoped UI Action, and we will end up having our own conditions, anyway, with which we can deal later on. Right now, I am still just focused on seeing if I can get all of this to work.

The next thing that we need to do is to build our own UI Page, on which we can also get a head start by cloning the original using the same Insert and Stay method. The modifications here are a little more extensive, as we want to do much more than just create an Update Set. One thing that we will want to do is check to make sure that the version number is not one that has already been published. To support that, I added some code at the top to pull all of the current versions so that I can stick them into a hidden field.

var existingVersions = '';
var mbrAppId = 'new';
var appSysId = jelly.sysparm_sys_id;
var appGR = new GlideRecord('sys_app');
appGR.get(appSysId);
var appName = appGR.getDisplayValue();
var appVersion = appGR.version || '';
var appDescription = appGR.short_description;
var mbrAppGR = new GlideRecord('x_11556_col_store_member_application');
if (mbrAppGR.get('application', appSysId)) {
	mbrAppId = mbrAppGR.getUniqueValue();
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery();
	versionGR.query('member_application', mbrAppId);
	while (versionGR.next()) {
		existingVersions += '|' + versionGR.getDisplayValue('version');
	}
	existingVersions += '|';
}

To pass this value to the client side for validation, I added a hidden field to the HTML for the page:

<input id="existing_versions" type="hidden" value="$[existingVersions]"/>

To check the validity on the client side, I retained the original version validity check and then added my own check of the existing versions right underneath using the same alert approach to delivering the bad news:

this.existingVersions = gel('existing_versions').value;
...
var vd = g_form.validators.version;
if (!vd)
	console.log("WARNING.  Cannot find validator for version field in parent form.");
else {
	var answer = vd.call(g_form, this.versionField.value);
	if (answer != true) {
		alert(answer);
		return null;
	}
}
if (this.existingVersions.indexOf('|' + versionField.value + '|') != -1) {
	alert('Version ' + versionField.value + ' has already been published to the Collaboration Store');
	return null;
}

The last thing that we have to change is what happens once the Update Set has been created. In the original version that we cloned, once the Update Set has been produced, the operator is then taken to the Update Set form to see the newly created Update Set. We don’t want to do that, as we still have much work to do to, including the following:

  • Update or Create an Application record
  • Create a new Version record linked to the Application record
  • Convert the Update Set to XML
  • Attach the XML to the Version Record
  • Send all of the updated/created records over to the Host instance, including the attached XML

We will need some kind of vehicle in which to perform all of the these tasks and keep the operator updated as to the progress and success or failure of each one of the steps. That sounds like quite a bit of work, so I think that will be a good topic for our next installment.

Collaboration Store, Part XVII

“I believe in a world where all these things can happen, even if I have to do them myself.”
Adrian Lamo

While we wait for the hordes of fellow developers currently running their extensive tests on the initial version of the Collaboration Store app to report their findings, I thought it might be a good time to give a little bit of thought as to where things should go from here. As I mentioned earlier, there are a number of other improvements that I would like to make to the set-up process once we are sure that everything is working as it should. That would definitely be one specific area towards which we could direct our attentions next. On the other hand, I am quite anxious to see if I can actually do some real sharing of artifacts between instances, and I am not quite sure how I am going to do that, so I would like to start focusing on that little project as well. I have managed to cobble together enough parts to get multiple instances aware of one another, so even though the initial set-up process is not quite ready for prime time, maybe it is good enough for now and I can start trying to work towards accomplishing the actual purpose of this application. Besides, we have not received any results from any of those selfless volunteers who have been doing the outside testing as yet, so we really don’t want to disturb that code at this point.

I would like to share all kinds of different artifacts at some point, but to start out, I am going to focus on sharing Scoped Applications in much the same way that applications are shared to internal app stores or the public ServiceNow App Store. There are two sides to the process of sharing: 1) publishing your app to the desired target, and 2) retrieving an app from the desired source and installing it in your instance. Since I can only do one thing at a time, and one obviously has to take place before you can do the other, my initial focus will be to see if I can publish an app to the Host instance. Once we clear that hurdle, then maybe we can see if we can do the flip side and pull something down from the Host and get it installed. But that’s nothing that we need to worry about today, particularly since we don’t even know yet if we can pull off putting something out there!

The first thing that we will need is a couple of tables, one to store the published applications and another to store each version of the application. These are just meta data tables containing information about the app and the versions. I envision that the application itself will be an XML Update Set file, which I plan to attach to the application version table record for each version published. The question is, How do we do that?

There are already built-in capabilities to turn an app into an Update Set and to turn an Update Set into an XML file. Both of those are UI Actions, so taking a peek under the hood of those artifacts would seem like a good place to start. Before you can turn an Update Set into an XML file, you first have to have an Update Set, so let’s first take a peek at the code under the UI Action that does that. The easiest way to do that would be to pull up a Scoped Application and then use the hamburger menu to pull up the list of UI Actions for that form.

Select Configure -> UI Actions from the context menu to pull up the list of UI Actions

The one that we are looking for is called Publish to Update Set… and what we are looking for is the code that is executed when the action is selected.

function publishIt() {
	var sysId = gel('sys_uniqueValue').value;	
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("publish_app_dialog");
	dd.setTitle(new GwtMessage().getMessage('Publish to Update Set'));
	dd.setPreference('sysparm_sys_id', sysId);
	dd.setWidth(500);
	dd.render();
}

Well, that didn’t tell us much. All this code does is launch a dialog box. We will need to look at the UI Page that will appear in the pop-up window, which we can see from the code is called publish_app_dialog. The key element there is the publishApp() function in the Client script of that UI Page.

function publishApp(updateSetId) {
	var dd = new GlideModal("hierarchical_progress_viewer");
	dd.on("beforeclose", function () {
               var notification = {"getAttribute": function(name) {return 'true';}};
               CustomEvent.fireTop(GlideUI.UI_NOTIFICATION + '.update_set_change', notification);
               window.location.href = "sys_update_set.do?sys_id=" + updateSetId;
	});

	dd.setTitle("Progress");
	dd.setPreference('sysparm_function', 'publishToUpdateSet');
	dd.setPreference('sysparm_update_set_id', updateSetId);
	dd.setPreference('sysparm_sys_id', this.appId);
	dd.setPreference('sysparm_name', this.inferredUsName);
	dd.setPreference('sysparm_version', this.versionField.value);
	dd.setPreference('sysparm_description', this.descriptionField.value);
	dd.setPreference('sysparm_include_data', this.includeDataField.checked);
	dd.setPreference('sysparm_progress_name', "Publishing application");
	dd.setPreference('sysparm_ajax_processor', 'com.snc.apps.AppsAjaxProcessor');
	dd.setPreference('sysparm_show_done_button', 'true');

	dd.render();
	GlideDialogWindow.get().destroy();

	return dd;
}

Basically, this code replaces the original pop-up with a progress bar driven off of the server side publishToUpdateSet function of the com.snc.apps.AppsAjaxProcessor. This is probably something that we still want to do. We just don’t want to be sent to the Update Set form when it all over. Instead, we want to go ahead and turn it into an XML file, and then we want to attach that file to a new version record for the application. Also, if the application has never been published before, we will need to create the master application record as well. But it looks like we can steal most of this code so far. We will need to clone both the UI Action and the UI Page, and then point our cloned action to our cloned page, which is where we will make the majority of the changes. So far, so good.

Now we need to turn the Update Set into XML. For that, we take a peek at the other UI Action for that purpose, found on the Update Set form. To hunt that guy down, we pull up any local Update Set and use the same hamburger menu options to pull up the UI Actions for that form and look for the one called Export to XML. Here is the relevant script:

var updateSetExport = new UpdateSetExport();
var sysid = updateSetExport.exportUpdateSet(current);

action.setRedirectURL("export_update_set.do?sysparm_sys_id=" + sysid + "&sysparm_delete_when_done=true");

The first couple of lines look important, but that last one looks like it pops up the XML file for downloading, which we definitely do not want to do in our adaptation. It turns out that export_update_set is a Processor, which also has code of its own, so we better dig into that guy and see if does anything important that we might need to retain. We can find that guy by entering the word processors in the left-hand navigation menu and then select the lone menu item that appears to bring up the list. Once we have the list, we just need to search for the one where the Path is export_update_set. Here is the relevant code:

(function process(g_request, g_response, g_processor) {
    var sysid = g_request.getParameter('sysparm_sys_id');
    var remoteUpdateGR = new GlideRecord('sys_remote_update_set');
    if (!remoteUpdateGR.get(sysid)) {
        gs.addErrorMessage(gs.getMessage('Update Set is invalid'));
        return;
    }
    if (!gs.hasRole('admin') && !sn_app_api.AppStoreAPI.canPublishToUpdateSet(remoteUpdateGR.application)) {
        gs.addErrorMessage(gs.getMessage('You do not have permission to perform this operation'));
        g_response.setStatus(403);
        return;
    }
    var exporter = new ExportWithRelatedLists('sys_remote_update_set', sysid);
    exporter.addRelatedList('sys_update_xml', 'remote_update_set');
    exporter.exportRecords(g_response);
    var del = g_request.getParameter('sysparm_delete_when_done');
    if (del == "true" && remoteUpdateGR.canDelete())
        remoteUpdateGR.deleteRecord();
})(g_request, g_response, g_processor);

So it looks like code in which we are interested is the stuff going on with the variable exporter. Unfortunately, there is no output from the exportRecords function of that object and it takes as input the global g_response object. That will send the output to the browser, which we definitely do not want to do. Maybe we can hack it by sending it a fake g_response object, and then pull our output from there. If you right click on the class name (ExportWithRelatedLists), you can use the resulting context menu to pull up the definition and see exactly what it does. There is quite a bit of code in there, but here is the relevant function:

exportRecords: function(response){
    this.setHeaders(response);
    var outputStream = response.getOutputStream();
    this.hd = this.beginExport(outputStream);
    var gr = new GlideRecord(this.parent_table);
    gr.get(this.sys_id);
    this.exportRecord(gr);
    this.exportChildren();
    this._exportQuerySets();
    this.endExport(outputStream);
}

It basically sends everything to the outputStream that it obtains from the response object that you send it. So, we could build our own response object with our own outputStream, use this component right out of the box, and then grab the outputStream from our phony g_response object. Or, we just steal the code from this guy and use it to build our own exporter and have our version of the exportRecords function simply return the XML. Either way, we should be able to leverage all of this code to do what it is that we want to do. That should give us our XML file, anyway.

Of course, generating the XML from the Scoped Application is just the start of the process. Once we have it in hand, we have to create a version record, attach the XML to the version record as a file, and then ship the thing over to the Host instance. Once it arrives at the Host, we will also have to send the version record and the attached XML file out to all of the other instances. That’s all quite a bit of work, but so was the set-up process, and we just trudged through that effort one piece at a time until we got to the end of the to-do list. Hopefully, we can do the same here. We may jump right into that next time, if we don’t yet have any feedback from the set-up testing.