
- Post History
- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
on ‎01-15-2020 12:00 PM
Issue
Client side UI Actions are not supported on the Service Portal:
https://serviceportal.io/docs/documentation/form.md
UI Actions
All Server-side UI Actions are supported, with one important distinction - setRedirectURL() operations are ignored, due to the fact that Service Portal forms do not handle redirection the same way that forms in the platform do.
Any UI Actions marked Client are not compatible with Service Portal and will be ignored by the Form widget.
Use Case
I came across a requirement for a client that required I validate data on the form before making changes on the server, but the data that needed to be validated only became required after clicking the button. The example in this use case, is that I needed a button to close a change, making certain fields required only after the button was clicked, and then validating those fields were filled out on the form before sending an update to the server to close the change.
The inspiration for this article and the functionality came from: https://www.servicenowguru.com/system-ui/ui-actions-system-ui/client-server-code-ui-action/
All of the examples I found scouring the internet and community focused on creating a modal pop-up form and having the user enter information on the modal before submitting the form. I did not want to take this approach for a few reasons:
- I wanted the functionality to match the platform experience as much as possible, and a UI Page was not being used on the platform to gather required fields.
- I wanted to prompt for mandatory fields on the form, without having to code them into the modal form, then pre-populate them on the modal, or having to run a check to see if they were populated on the form before deciding whether to show them on the modal or not.
- I wanted to re-use the input/submit functionality of the form widget as much as possible to save input on the form as I did not want to re-invent the wheel with a modal. The form widget does a great job of taking user input and submitting it to the server when the Save/Update UI Actions are clicked, and the user experience of having the fields show red with the 'Required fields' section seemed more visually appealing to me than using a modal.
This article assumes the following:
- Records are opened using the 'form' widget instead of the out-of-the-box Ticket Conversations widget
- The 'form' widget has been cloned for customization
- There is a page route map that routes the tickets page to the newly created form page
- The record type being used for this functionality is a change_request, though the code can be altered to fit other record types.
- There is a UI Policy in place to set the following fields as mandatory when the state changes to Closed: Close Code, Close Notes
I used the following resources for assistance in creating the button:
https://serviceportal.io/create-custom-action-buttons-service-portal/
g_form
g_form: My saving grace on this functionality was the ability to use g_form within the client controller in the form widget in order to check current values on the form that had not yet been submitted to the server. There is basically nothing documented on g_form on the form widget, most documentation points to the Service Catalog, and I couldn't find any documentation on supported/un-supported methods. At this time I only know that getValue() and setValue() are supported within the form widget's client controller, I tried using setMandatory() but received errors in the console about it.
Here's what I was able to find out about g_form:
Embedded widgets & g_form
When using the Service Catalog variable type Macro and Macro with Label, you can pick a widget to embed in a catalog item form. Within the client controller for the embedded widget you can access the field object and catalog item g_form instance using:
$scope.page.field
$scope.page.g_form()
https://docs.servicenow.com/bundle/newyork-servicenow-platform/page/build/service-portal/concept/unsupported_client_scripts.html
https://serviceportal.io/g_form-api-in-embedded-widgets/
Good to know, but I wasn't doing anything with the Service Catalog or Macros for this use case.
g_form as a global object
g_form as a global object cannot be used in a widget client controller or in a UI script.
I could only find this in older documentation. From my understanding it meant that whatever was in g_form was only available to my form widget, and couldn't be accessed by other widgets i.e. embedded widgets, but the documentation isn't clear and seems to refer to the macro/catalog functionality. Either way, I wasn't able to get g_form to work with an embedded widget.
Customized Form Widget
For my functionality, I added a button to the customized form widget, that would only show when a change request was in a Review state. Once clicked, the button should look for any mandatory fields. Out of the box the following fields are mandatory when moving a change from Review to Closed:
Close Code
Close Notes
Your business may have other mandatory fields, and these can be coded into the Client Script/Server Script if you wish, and I suggest including them into an existing UI Policy or creating a new one so that the Mandatory fields footer appears for a better customer experience.
Users can't progress by clicking the button again until the mandatory fields are filled out. Once they are, the script checks the form values again to ensure they aren't empty, and then runs the server side code to update the record with the Close Notes/Close Code value and updates the state to Closed.
The trick to making sure the user can't progress without filling out the mandatory fields, is to set the state of the change request to Closed client side, so that the UI policies are always hit as defined in my code below. There's a lot of 'checks' on this code that will probably never be hit due to the client taking care of most of the work, like the server side code checking for input.close_notes and input.close_code but I put them in there just in case.
Finally, I use a $emit event within the $scope itself after the c.server.update() in order to tell the form widget that the server values have submitted, and to go ahead and reload to show the updated form to the user. There may be a cleaner way to do this, but I copied some out-of-the-box code for UI Actions already present in the form widget, and dropped it under my event listener:
/** Here's the event kicked off during the button, see code sections below: **/
$scope.$emit('closeChange.complete', 'data');
/** REFRESH FORM WIDGET AFTER CLOSING **/
$scope.$on('closeChange.complete', function (evt, response) {
$scope.submitting = false;
var sysID = $scope.data.sys_id;
loadForm($scope.data.table, sysID).then(constructResponseHandler(response));
});
This seemed to work well enough, and the button visibility code in the Server Script section hides the button right after this happens. If someone knows of a more eloquent way to do this, please comment below!
HTML Template
Here's the snippet of what I added, my code goes in the footer section, as I wanted the button to be aligned with any server side UI Actions that may be showing on the form widget.
<!-- Close Change Button -->
<button type="button" class="btn btn-primary action-btn pull-left" ng-click="c.uiAction('close')"
ng-if="data.showClose">Close Change</button>
Here's the HTML Template in full:
<div ng-if="::!data.isValid && !data.emptyStateTemplate" class="panel panel-default">
<div class="panel-body wrapper-lg text-center">
<span ng-if="!data.tableUnsupported">${Record not found}</span>
<span ng-if="data.tableUnsupported">${Form view not supported for requested table}</span>
</div>
</div>
<div ng-if="!data.isValid && data.emptyStateTemplate" class="panel-shift">
<div class="empty-state-wrapper panel panel-default" ng-include="data.emptyStateTemplate"></div>
</div>
<div ng-if="data.isValid" class="panel-shift">
<div class="" ng-if="!data.f._view.length && data.hideRelatedLists && data.emptyStateTemplate">
<div class="empty-state-wrapper panel panel-default" ng-include="data.emptyStateTemplate"></div>
</div>
<div class="" ng-if="!data.f._view.length && data.hideRelatedLists && !data.emptyStateTemplate">
<div class="panel panel-default">
<div class="panel-heading"><span class="panel-title">{{data.f.title}}</span> <span ng-if="::options.showFormView == 'true' && data.f.view != ''">[{{data.f.view_title}} view]</span></div>
<div class="panel-body wrapper-lg text-center">
${No elements to display}
</div>
</div>
</div>
<div ng-show="isPageReady" class="panel panel-default" ng-if="::data.f._view.length || !data.hideRelatedLists" >
<div class="panel-heading" ng-if="data.f.title.length" sp-context-menu="getUIActionContextMenu(event)">
<span class="dropdown m-r-xs" ng-if="(data.isAdmin || getUIActions('context').length > 0) && options.omitHeaderOptions != 'true'">
<button aria-label="${Form menu}" class="btn btn-form-menu dropdown-toggle glyphicon glyphicon-menu-hamburger" style="line-height: 1.4em" id="adminMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button>
<ul class="dropdown-menu" aria-labelledby="adminMenu">
<li ng-if="::data.isAdmin"><a ng-href="/{{::data.f.table}}.do?sys_id={{data.f.sys_id}}&sysparm_view={{data.f.view}}" target="_blank">${Open in platform}</a></li>
<li ng-if="::data.isAdmin" class="dropdown-header">${Configure}</li>
<li ng-if="::data.isAdmin"><a href="/slushbucket.do?sysparm_referring_url={{adminMenu.encodedPageUrl}}&sysparm_list={{data.f._sections[0].id}}&sysparm_form=section&sysparm_view={{data.f.view}}" target="_blank">${Form Layout}</a></li>
<li ng-if="::data.isAdmin"><a href="/slushbucket.do?sysparm_referring_url={{adminMenu.encodedPageUrl}}&sysparm_list={{data.f.table}}&sysparm_form=related_list&sysparm_view={{data.f.view}}" target="_blank">${Related Lists}</a></li>
<li ng-if="::data.isAdmin"><a href="?id=lf&table=sys_ui_policy&filter=table%3D{{data.f.table}}%5EORtableIN{{data.tableHierarchy}}%5Eactive%3Dtrue%5Eui_type%3D1%5EORui_type%3D10" ng-click="openRelatedList($event, {id:'lf', table: 'sys_ui_policy', filter: 'table%3D{{data.f.table}}%5EORtableIN{{data.f.table}},sys_metadata%5Eactive%3Dtrue%5Eui_type%3D1%5EORui_type%3D10'})">${UI Policies} <span class="badge pull-right" ng-if="f.policy.length">{{f.policy.length}}</span></a></li>
<li ng-if="::data.isAdmin"><a href="?id=lf&table=sys_script_client&filter=table%3D{{data.f.table}}%5EORtableIN{{data.tableHierarchy}}%5Eactive%3Dtrue%5Eui_type%3D1%5EORui_type%3D10" ng-click="openRelatedList($event, {id: 'lf', table: 'sys_script_client', filter: 'table%3D{{data.f.table}}%5EORtableIN{{data.f.table}},sys_metadata%5Eactive%3Dtrue%5Eui_type%3D1%5EORui_type%3D10'})">${Client Scripts} <span class="badge pull-right" ng-if="adminMenu.getClientScriptCount()">{{adminMenu.getClientScriptCount()}}</span></a></li>
<li ng-if="getUIActions('context').length > 0 && data.isAdmin" role="separator" class="divider"></li>
<li ng-repeat="action in getUIActions('context')"><a href="" ng-click="triggerUIAction(action)">{{action.name}}</a></li>
<li ng-if="::data.isAdmin || getUIActions('context').length > 0" role="separator" class="divider"></li>
<li><a target="_new" href="/{{data.f.table}}.do?PDF&sys_id={{data.sys_id}}&sysparm_view={{data.f.view}}">${Export to PDF}</a></li>
<li><a target="_new" href="/{{data.f.table}}.do?PDF&landscape=true&sys_id={{data.sys_id}}&sysparm_view={{data.f.view}}">${Export to PDF (landscape)}</a></li>
</ul>
</span>
<span class="panel-title" role="heading" aria-level="2">{{data.f.title}}</span> <span ng-if="::options.showFormView == 'true' && data.f.view != ''">[{{data.f.view_title}} view]</span>
<div ng-if="::attachmentHandler && data.canAttach" title="{{::data.addAttachmentMsg}}" class="pull-right attachment-button">
<sp-attachment-button></sp-attachment-button>
</div>
</div>
<div class="panel-body">
<!-- performance debug -->
<div ng-if="data.show_sql">
<div class="comment">
<span ng-if="data.f._perf.sql_count">${SQL Statements {{data.f._perf.sql_count}}}, </span>
<span>${Time {{data.f._perf.time}}}</span>
</div>
<div ng-repeat="s in data.f._perf.sql" class="{{s.type}}">
{{s.statement}}
</div>
</div>
<!-- attachments -->
<sp-attachment-manager table="::data.table" sys-id="data.f._attachmentGUID" omit-edit="::!data.canAttach"></sp-attachment-manager>
<!-- form -->
<div>
<sp-model form_model="data.f" mandatory="mandatory"></sp-model>
</div>
<!-- UI Action Links -->
<div ng-if="getUIActions('link').length > 0">
<label style="margin: 0;">${Related Links}</label>
<div ng-repeat="action in getUIActions('link')">
<a href ng-click="triggerUIAction(action)" gsft_id="{{::action.sys_id}}">{{::action.name}}</a>
</div>
</div>
<!-- related lists -->
<div ng-if="!data.hideRelatedLists">
<label style="margin: 0">${Related Lists}</label>
<div style="margin-bottom: 7px; padding-bottom: 7px; border-bottom: 1px solid #f5f5f5;">
<span ng-repeat="rl in data.f._related_lists" ng-if="rl.visible">
<a ng-if="rl.type != 'REL'" ng-href="?id=lf&table={{::rl.table}}&filter={{rl.field}}%3D{{data.f.sys_id}}&view={{data.f.view}}" ng-click="openRelatedList($event, {id: 'lf', table: '{{::rl.table}}', filter: '{{rl.field}}%3D{{data.f.sys_id}}'})">{{rl.plural}}
<span class="label label-as-badge label-primary" ng-if="rl.count">{{::rl.count}}</span>
</a>
<a ng-if="::rl.type == 'REL'" href="?id=lf&table={{::rl.table}}&relationship_id={{rl.relationship_id}}&apply_to={{rl.apply_to}}&apply_to_sys_id={{rl.apply_to_sys_id}}&view={{::data.f.view}}" ng-click="openRelatedList($event, {id: 'lf', table: '{{::rl.table}}', apply_to: '{{rl.apply_to}}', apply_to_sys_id: '{{rl.apply_to_sys_id}}', relationship_id: '{{rl.relationship_id}}'})">{{rl.label}}
<span class="label label-as-badge label-primary" ng-if="rl.count">{{rl.count}}</span>
</a>
<span ng-if="!$last" style="padding-left: .5em; padding-right: .5em;" aria-hidden="true"> | </span>
</span>
</div>
</div>
</div>
<div class="panel-footer">
<!-- Close Change Button -->
<button type="button" class="btn btn-primary action-btn pull-left" ng-click="c.uiAction('close')"
ng-if="data.showClose">Close Change</button>
<button ng-click="triggerUIAction(action)" ng-disabled="submitting" ng-repeat="action in getUIActions('button')" class="btn action-btn" ng-class="::getButtonClass(action)" gsft_id="{{::action.sys_id}}">{{action.name}}</button>
<span>{{status}}</span>
<button ng-if="getPrimaryAction()" type="submit" ng-click="triggerUIAction(getPrimaryAction())" ng-disabled="submitting" class="btn btn-primary action-btn pull-right" gsft_id="{{::getPrimaryAction().sys_id ? getPrimaryAction().sys_id : ''}}">${Save} <span ng-if="saveButtonSuffix">(${{{saveButtonSuffix}}})</span></button>
<div style="clear: both;"></div>
<div ng-if="mandatory.length" class="alert alert-info" style="margin-top: .5em" aria-live="polite" aria-atomic="true">
<span ng-if="mandatory.length > 0">${Required information} </span>
<span ng-repeat="f in mandatory" class="label sc-field-error-label" ng-bind="f.label"></span>
</div>
</div>
</div>
</div>
Client Script
Here's the snippet of what I added:
/* CHANGE REQUEST CUSTOM ACTIONS */
/*Make fields mandatory when closing or cancelling a change,
this code controls the button that sets the state field as mandatory
on the client side*/
var state = $scope.c.data.f._fields.state.value;
var chgTbl = $scope.data.table;
if (chgTbl == 'change_request' && state == 0) {
$scope.showClose = true;
} else {
$scope.showClose = false;
}
/* Set the state field as 'closed' in order to trigger all of the
client scripts, ui policies, etc. */
c.uiAction = function(action) {
//Get the form values we care about
var cc = g_form.getValue('close_code');
var cn = g_form.getValue('close_notes');
// If the conditions are met, perform a server update and close the change record
if (cc != '' && cn != '') {
c.data.close_code = cc;
c.data.close_notes = cn;
c.data.action = action;
c.server.update().then(function() {
c.data.action = undefined;
$scope.$emit('closeChange.complete', 'data');
})
} else {
/* Set the state field on the client to closed, so that the ui policies kick in and tell the user
what to fill out, without sending an update to the server */
g_form.setValue('state', 3);
}
}
/** REFRESH FORM WIDGET AFTER CLOSING **/
$scope.$on('closeChange.complete', function (evt, response) {
$scope.submitting = false;
var sysID = $scope.data.sys_id;
loadForm($scope.data.table, sysID).then(constructResponseHandler(response));
});
Here's the Client Script in full:
function($rootScope, $scope, $timeout, $location, $log, $window, spUtil, nowAttachmentHandler, spAriaUtil, spNavStateManager) {
var c = this;
$scope.submitting = false;
$scope.mandatory = [];
$scope.errorMessages = [];
$scope.data.show_sql = false;
$scope.saveButtonSuffix = spUtil.getAccelerator('s');
$scope.isPageReady = false;
$scope.adminMenu = {
encodedPageUrl: encodeURIComponent($location.url()),
getClientScriptCount: function() {
var count = 0;
if ($scope.data.f.client_script) {
count += $scope.data.f.client_script.onChange.length;
count += $scope.data.f.client_script.onLoad.length;
count += $scope.data.f.client_script.onSubmit.length;
}
return count;
}
};
var tableId = $scope.data.sys_id != -1 ? $scope.data.sys_id : ($scope.data.f ? $scope.data.f._attachmentGUID : -1);
spUtil.recordWatch($scope, "sys_attachment", "table_sys_id=" + tableId, function (response, data) {
$scope.attachmentHandler.getAttachmentList();
if (response.data) {
var options = {};
options.operation = response.data.operation;
options.filename = response.data.display_value;
options.sys_id = tableId;
options.table = $scope.data.table;
options.state = (response.data.record && response.data.record.state) ? response.data.record.state.value : "";
if (options.operation === 'update' && options.state === 'not_available')
$rootScope.$broadcast("attachment.updated", options);
}
});
$scope.$on('sn.attachment.scanned', function() {
updateAttachmentState($scope.data.table, $scope.data.sys_id);
});
function updateAttachmentState(table, sys_id) {
if (sys_id == -1)
return;
$scope.server.refresh();
}
$rootScope.$on('$sp.html.editor.progress', function(e, conf) {
$scope.submitting = conf.state;
});
$scope.getButtonClass = function(action) {
if (action.form_style == "destructive")
return "btn-danger";
if (action.form_style == "primary")
return "btn-primary";
return "btn-default";
};
$scope.getUIActions = function(type) {
if ($scope.data.disableUIActions)
return [];
if (type) {
return $scope.data.f._ui_actions.filter(function(action) {
//We handle the primary action button separately.
return !action.primary && action['is_' + type];
});
} else {
return $scope.data.f._ui_actions;
}
}
$scope.getPrimaryAction = function() {
var primaryActions = $scope.data.f._ui_actions.filter(function(action) {
return action.primary;
});
return (primaryActions.length) ? primaryActions[0] : null;
}
$scope.getUIActionContextMenu = function(event) {
var menu = [];
if (event.ctrlKey)
return menu;
var contextActions = $scope.getUIActions('context');
contextActions.forEach(function(action) {
menu.push([action.name, function() {
$scope.triggerUIAction(action);
}]);
});
if (contextActions.length > 0)
menu.push(null);
menu.push([$scope.data.exportPDFMsg, function() {
exportPDF("");
}]);
menu.push([$scope.data.exportPDFLandMsg, function() {
exportPDF('true');
}]);
return menu;
}
function exportPDF(landscape) {
$window.open("/" + $scope.data.f.table + ".do?PDF&landscape=" + landscape + "&sys_id=" + $scope.data.sys_id + "&sysparm_view=" + $scope.data.f.view);
}
//trigger the primary UI Action on save (if there is one)
var deregister = $scope.$on('$sp.save', function() {
var primaryAction = $scope.getPrimaryAction();
if (primaryAction)
$scope.triggerUIAction(primaryAction);
});
$scope.$on('$destroy', function() {
deregister()
});
$scope.triggerUIAction = function(action) {
if ($scope.data.disableUIActions && !action.primary) {
return;
}
var activeElement = document.activeElement;
if (activeElement) {
activeElement.blur();
}
$scope.$evalAsync(function() {
if (g_form) {
$scope.submitting = true;
if (!g_form.submit(action.action_name || action.sys_id))
$scope.submitting = false;
}
});
}
$scope.$on("spModel.uiActionComplete", function(evt, response) {
$scope.submitting = false;
var sysID = (response.isInsert) ? response.sys_id : $scope.data.sys_id;
loadForm($scope.data.table, sysID).then(constructResponseHandler(response));
});
function constructResponseHandler(response) {
return function() {
$rootScope.$broadcast("sp.form.submitted", {sys_id: (response.isInsert) ? response.sys_id : $scope.data.sys_id});
var message;
var eventName = "sp.form.record.updated";
if (response.isInsert) {
message = $scope.data.recordAddedMsg;
var search = $location.search();
search.sys_id = response.sys_id;
search.spa = 1;
$location.search(search).replace();
} else
message = $scope.data.updatedMsg;
$scope.data.hideRelatedLists = hideRelatedLists();
$scope.$emit(eventName, $scope.data.f._fields);
$rootScope.$broadcast(eventName, $scope.data.f._fields);
$scope.status = message;
spUtil.addTrivialMessage(message);
$timeout(clearStatus, 2000);
}
}
var ctrl = this;
// switch forms
var unregister = $scope.$on('$sp.list.click', onListClick);
$scope.$on("$destroy", function() {
unregister();
})
function _save() {
var primaryAction = $scope.getPrimaryAction();
if (primaryAction)
$scope.triggerUIAction(primaryAction);
}
function onListClick(evt, arg) {
loadForm(arg.table, arg.sys_id);
}
function loadForm(table, sys_id) {
var f = {};
$scope.data.table = f.table = table;
$scope.data.sys_id = f.sys_id = sys_id;
f.view = $scope.data.view;
return $scope.server.update().then(setupAttachmentHandler);
}
function openRelatedList(e, queryString) {
// todo: Open this in a modal
$location.search(queryString);
e.preventDefault();
}
$scope.$on('spModel.fields.rendered', function() {
if (ctrl.panels)
ctrl.panels.removeClass('shift-out').addClass('shift-in');
});
var g_form;
function initForm(gFormInstance) {
if (gFormInstance.getTableName() == $scope.data.f.table){
g_form = gFormInstance;
spNavStateManager.register($scope.data.table, _save, g_form);
$scope.isPageReady = true;
$timeout(function() {
$rootScope.$emit('spModel.gForm.rendered', g_form);
}, 175);
}
}
$scope.$on('spModel.gForm.initialized', function(e, gFormInstance) {
initForm(gFormInstance);
});
$scope.$on('spModel.gForm.env.created', function(e, gFormInstance) {
initForm(gFormInstance);
});
// update the comments or worknotes based on activity stream
$scope.$on("activity_stream_is_changed", function(event, data) {
if (g_form && g_form.hasField(data.fieldName)) {
g_form.setValue(data.fieldName, data.input);
if (data.fieldToClear != "" && g_form.hasField(data.fieldToClear))
g_form.setValue(data.fieldToClear, "");
}
})
// Show or hide related lists
$scope.$watch('data.f._related_lists', function() {
$scope.data.hideRelatedLists = hideRelatedLists();
}, true);
function hideRelatedLists() {
if (!$scope.data.f._related_lists)
return true;
if ($scope.options.hideRelatedLists == true)
return true;
if ($scope.data.sys_id == '-1')
return true;
// If all related lists are visible=false then hide
if ($scope.data.f._related_lists.length > 0) {
for (var i in $scope.data.f._related_lists) {
var list = $scope.data.f._related_lists[i];
if (list.visible) {
return false;
}
}
}
return true;
}
function clearStatus() {
$scope.status = "";
}
function setupAttachmentHandler() {
$scope.attachmentHandler = new nowAttachmentHandler(appendDone, appendError);
$scope.$evalAsync(function() {
$scope.attachmentHandler.setParams($scope.data.table, $scope.data.f._attachmentGUID, 1024 * 1024 * $scope.data.maxAttachmentSize);
});
$scope.$on('dialog.upload_too_large.show', function(e) {
$log.error($scope.data.largeAttachmentMsg);
spUtil.addErrorMessage($scope.data.largeAttachmentMsg);
});
}
setupAttachmentHandler();
function appendDone() {
// don't know here whether upload succeeded, so can't show msg either way
$scope.$broadcast("sp.attachments.update", $scope.data.f._attachmentGUID);
spAriaUtil.sendLiveMessage($scope.data.attachmentSuccessMsg);
}
function appendError(error) {
$scope.errorMessages.push(error);
spUtil.addErrorMessage(error.msg + error.fileName);
}
if ($scope.data.f.title) {
$scope.$emit('sp.widget-modal.set-aria-label', $scope.data.f.title);
}
/* CHANGE REQUEST CUSTOM ACTIONS */
/*Make fields mandatory when closing or cancelling a change,
this code controls the button that sets the state field as mandatory
on the client side*/
var state = $scope.c.data.f._fields.state.value;
var chgTbl = $scope.data.table;
if (chgTbl == 'change_request' && state == 0) {
$scope.showClose = true;
} else {
$scope.showClose = false;
}
/* Set the state field as 'closed' in order to trigger all of the
client scripts, ui policies, etc. */
c.uiAction = function(action) {
//Get the form values we care about
var cc = g_form.getValue('close_code');
var cn = g_form.getValue('close_notes');
// If the conditions are met, perform a server update and close the change record
if (cc != '' && cn != '') {
c.data.close_code = cc;
c.data.close_notes = cn;
c.data.action = action;
c.server.update().then(function() {
c.data.action = undefined;
$scope.$emit('closeChange.complete', 'data');
})
} else {
/* Set the state field on the client to closed, so that the ui policies kick in and tell the user
what to fill out, without sending an update to the server */
g_form.setValue('state', 3);
}
}
/** REFRESH FORM WIDGET AFTER CLOSING **/
$scope.$on('closeChange.complete', function (evt, response) {
$scope.submitting = false;
var sysID = $scope.data.sys_id;
loadForm($scope.data.table, sysID).then(constructResponseHandler(response));
});
}
Server Script
Here's the snippet of what I added:
/*CLOSE CHANGE SERVER SIDE CODE */
//Valid GlideRecord
var gr = new GlideRecord(data.table);
if (!gr.isValid())
return;
//Valid sys_id
if (!gr.get(data.sys_id))
return;
//Button visibility
if(data.table == 'change_request' && gs.hasRole('itil') && gr.active == true && gr.state == 0){
data.showClose = true;
} else {
data.showClose = false;
//Handle input
if (input && input.action) {
var action = input.action;
var gr = new GlideRecord(data.table);
gr.get(data.sys_id);
//Need to ensure we're only applying this on change requests, with the particular action, and the client side checks have passed
if (data.table == 'change_request' && action=='close' && input.close_notes != '' && input.close_code != ''){
//If so, go ahead and set these fields
gr.setValue('close_code', input.close_code);
gr.setValue('close_notes', input.close_notes);
gr.setValue('state', 3);
gr.update();
gs.addInfoMessage('This change request has been closed');
} else {
//Probably never going to get here due to our client side code, but just to be sure
gs.addErrorMessage('The following mandatory fields must be completed before closing a change: Close Notes, Close Code');
return;
}
}
Here's the Server Script in full:
// form functionality - URL parameter driven
(function($sp, input, data, options, gs) {
/* "use strict"; -linter issues */
// populate the 'data' variable
data.attachmentUploadSuccessMsg = gs.getMessage("Attachment upload was successful");
data.recordAddedMsg = gs.getMessage("Record Added");
data.updatedMsg = gs.getMessage("updated_uppercase");
data.exportPDFMsg = gs.getMessage("Export to PDF");
data.exportPDFLandMsg = gs.getMessage("Export to PDF (landscape)");
data.addAttachmentMsg = gs.getMessage("Add an attachment");
data.maxAttachmentSize = parseInt(gs.getProperty("com.glide.attachment.max_size", 1024));
if (isNaN(data.maxAttachmentSize))
data.maxAttachmentSize = 24;
data.largeAttachmentMsg = gs.getMessage("Attached files must be smaller than {0} - please try again", "" + data.maxAttachmentSize + "MB");
data.attachmentSuccessMsg = gs.getMessage("Attachment successfully uploaded");
data.isAdmin = gs.hasRightsTo('sp/configure.all/execute', null);
data.emptyStateTemplate = options.empty_state_template;
data.disableUIActions = options.disableUIActions === "true";
data.hideRelatedLists = options.hideRelatedLists || false;
if (input) {
data.table = input.table;
data.sys_id = input.sys_id;
data.view = input.view;
var result = {};
if (input._fields) {
result = $sp.saveRecord(input.table, input.sys_id, input._fields);
data.sys_id = result.sys_id;
}
if (input.sys_id == '-1')
data.isNewRecord = true;
} else {
data.table = options.table || $sp.getParameter("t") || $sp.getParameter("table") || $sp.getParameter("sl_table");
data.sys_id = options.sys_id || $sp.getParameter("sys_id") || $sp.getParameter("sl_sys_id") || "-1";
data.view = options.view || $sp.getParameter("view") || $sp.getParameter("v"); // no default
}
data.query = $sp.getParameter("query") || options.query || "";
data.f = {};
if (!data.table)
return;
// Form widget is not a supported way to view an attachment
if (data.table == "sys_attachment") {
data.tableUnsupported = true;
return;
}
if (!GlideTableDescriptor.isValid(data.table))
return;
if (!data.sys_id)
return;
var rec = $sp.getRecord(data.table, data.sys_id);
data.isValid = rec.isValid() || data.sys_id == "-1";
if (!data.isValid)
return;
data.table = rec.getRecordClassName();
data.tableHierarchy = GlideDBObjectManager.getTables(data.table).toArray().join();
data.canWrite = rec.canWrite();
var hasRecordAccess = data.sys_id == "-1" ? rec.canCreate() : data.canWrite;
data.canAttach = hasRecordAccess && gs.hasRole(gs.getProperty('glide.attachment.role')) && !GlideTableDescriptor.get(data.table).getED().getBooleanAttribute("no_attachment");
data.f = $sp.getForm(data.table, data.sys_id, data.query, data.view);
// Activity formatter is hardcoded to set specific options
for (var f in data.f._formatters) {
var fm = data.f._formatters[f];
if (fm.formatter == "activity.xml") {
fm.hardcoded = true;
fm.widgetInstance = $sp.getWidget('widget-ticket-conversation',
{table: data.table,
sys_id: data.sys_id,
includeExtended: true,
hideAttachmentBtn: true,
title: "${Activity}",
use_dynamic_placeholder: true,
btnLabel: "${Post}"});
} else if(fm.formatter == "com_glideapp_servicecatalog_veditor" || fm.formatter == "com_glideapp_questionset_default_question_editor") {
var qsConfig = $sp.getValue('quick_start_config');
if (qsConfig)
qsConfig = JSON.parse(qsConfig)[0];
fm.widgetInstance = $sp.getWidget(fm.widget, {table: data.table,
sys_id: data.sys_id,
readonly_variable_editor: qsConfig ? qsConfig.readonly_variable_editor : 'false'});
} else
fm.widgetInstance = $sp.getWidget(fm.widget, data);
}
/*CLOSE CHANGE SERVER SIDE CODE */
//Valid GlideRecord
var gr = new GlideRecord(data.table);
if (!gr.isValid())
return;
//Valid sys_id
if (!gr.get(data.sys_id))
return;
//Button visibility
if(data.table == 'change_request' && gs.hasRole('itil') && gr.active == true && gr.state == 0){
data.showClose = true;
} else {
data.showClose = false;
//Handle input
if (input && input.action) {
var action = input.action;
var gr = new GlideRecord(data.table);
gr.get(data.sys_id);
//Need to ensure we're only applying this on change requests, with the particular action, and the client side checks have passed
if (data.table == 'change_request' && action=='close' && input.close_notes != '' && input.close_code != ''){
//If so, go ahead and set these fields
gr.setValue('close_code', input.close_code);
gr.setValue('close_notes', input.close_notes);
gr.setValue('state', 3);
gr.update();
gs.addInfoMessage('This change request has been closed');
} else {
//Probably never going to get here due to our client side code, but just to be sure
gs.addErrorMessage('The following mandatory fields must be completed before closing a change: Close Notes, Close Code');
return;
}
}
})($sp, input, data, options, gs);
Walkthrough
Here's a walkthrough of the functionality:
1. Close Change button visible on Change Request forms that are in the Review state, the Close Change button is aligned to the left.
2. Clicking the button without filling out the necessary fields
3. Filling out the necessary fields, and submitting the form. Form will refresh after conditions have been met and Close Change updates the server:
5. Button no longer appears after automatic refresh:
Summary
I reviewed countless community posts and couldn't find any functionality similar to this, so I decided to go ahead and write an article about it in the hopes that it assists developers in the future with similar requirements. If there's a better way of doing this, please post in the comments section as I'm open to trying new things learning more about the Service Portal.
This post is in no way a definitive or best practice article on how to go about solving this problem, just how I solved it using my limited knowledge of widget scripting at the time.
- 14,654 Views
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
I did not read the full customization you provided, but quickly what I have in mind for the requirement of Client/Server UI Action on the portal is an onSubmit client script on the record to handle the Client side and a Server UI action for the server side.
Here is what I am referring to:https://community.servicenow.com/community?id=community_question&sys_id=8654c369dbd8dbc01dcaf3231f96...

- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi
Can you expand on that article a bit more, in scrolling down it looks like one would need to modify some OOTB functions in the client controller, that was something I was not interested in at the time, since there could be many use cases for the form widget, and I wanted my code to be specifically contained for this button.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
What I have in mind is a UI Action Close Change that could use the same script as the save button. Let's say that the action name of the UI action is "close_change"
The onSubmit client script would have an if condition at the start that checks whether the button used was close_change. If it is not close_change the scripts exit. After this, you can set the state to closed using the g_form.setValue function. After in your script, you can return true or false based on whether the required fields are completed. Something like this:
function onSubmit() {
if (g_form.getActionName() != 'close_change')
return;
if(g_form.getValue('state') != 3){
g_form.setValue('state', 3);
g_form.submit(g_form.getActionName()); //submit after state change to run mandatory checks after UI policies execution
}
}
With this script, on first close change it will set the state to closed if not already (and in that case resubmit with the same UI action to have the mandatory fields checked) otherwise it will submit the record and the UI action server script will run.
If you want to validate the data on the server side, you could do the validation inside the script of the Close Change UI Action.
However, with this you won't be able to make the UI action appear/disappear based on unsaved modification made on the form. The button condition would be defined on the UI action and only works server side.
Maybe there is something in your requirements that I'm missing but I think this would do the trick.

- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
js_includes_sp.jsx?v=09-24-2019_1701&lp=Tue_Jan_14_08_54_37_PST_2020&c=5_59:39972 (g_env) [SCRIPT:EXEC] Error while running Client Script "Close Change OnSubmit Portal": RangeError: Maximum call stack size exceeded
Here's my code, perhaps it's not as efficient as it needs to be in order to prevent that stack size error:
Client Script:
Basically I need to verify the close notes and close code fields are populated, if not, go ahead and switch the state client side to closed, to inform the user of mandatory fields (handled by UI Policies) and keep performing this check until both fields are populated, once satisfied we can move to the server side code.
function onSubmit() {
if (g_form.getActionName() != 'sp_close_change') {
return;
} else if (g_form.getActionName() == 'sp_close_change') {
if (g_form.getValue('close_code') != '' && g_form.getValue('close_notes') != '') {
g_form.setValue('state', 3);
g_form.submit(g_form.getActionName()); //submit after state change to run mandatory checks after UI policies execution
} else if (g_form.getValue('close_code') == '' || g_form.getValue('close_notes') == '') {
g_form.setValue('state', 3);
return;
}
}
}
UI Action:
I basically just grabbed the code from 'Save' and made some adjustments.
(function closeChange() {
current.setValue('state', 3);
action.setRedirectURL(current);
current.update();
})();
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
The problem seems to be that your client code is going into an infinite loop.
If the user provided the close_code and close_notes, state is set to Closed then your script calls the UI action and the same client script gets runned over and over. If one of the two fields is left empty, then it sets the state to closed and returns without mandatory check because it is executed before the onsubmit client scripts.
I'm unsure why you did not use the script I provided (other than replacing the action name).
Here is the use case it covers:
- State not updated both close code and notes are empty: The state is set to closed and record is submitted again which gets blocked by the mandatory fields enforced by UI policy.
- State was manually set to close (or by using the button first): Script does nothing because state is already closed and UI policy are enforced for mandatory fields
- State not updated, close code and notes are filled: The state is set to closed and record is submitted again, because the closure fields are filled, the records passes the mandatory check enforced by UI policy and the script lets the record be submitted.
Your script does not need to check for the close code and close notes to not be empty since it will be handled by the UI policies in place.
If you don't want to use the UI policies, you could simply set the fields as mandatory and not touch the state (it will be handled by the server script you provided). Something like:
function onSubmit() {
if (g_form.getActionName() != 'sp_close_change') {
return;
}
if (g_form.getValue('close_code') == '' || g_form.getValue('close_notes') == '') {
g_form.setMandatory('close_code', true);
g_form.setMandatory('close_notes', true);
g_form.submit(g_form.getActionName());
}
}

- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi Ashley, thank you for the comprehensive write-up! Do you think this would work with allowing users to fill out risk assessments in the widget?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Even though this is quite old, i have decided to answer this article as it still shows up as the first result on google when searching this topic.
I strongly advice against cloning the ootb widget as you will shift responsibility of maintaining the widget from ServiceNow to yourself. You also will not even receive a notification if the OOTB widget is beeing patched as cloned widgets do not show up in the Upgrade Records (this might have a security impact as you also dont receive hotfixes!).
Please use the Client Side Buttons Widget (less than 40 lines of code) which is added in parallel to the OOTB form widget: How to enable Client Side UI Actions on the Service Portal