The Zurich release has arrived! Interested in new features and functionalities? Click here for more

Ashley Snyder1
Giga Guru

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:

  1. 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.
  2. 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.
  3. 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.

https://docs.servicenow.com/bundle/kingston-servicenow-platform/page/build/service-portal/concept/un...

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. 

find_real_file.png

2. Clicking the button without filling out the necessary fields

find_real_file.png

3. Filling out the necessary fields, and submitting the form.  Form will refresh after conditions have been met and Close Change updates the server:

find_real_file.png

5. Button no longer appears after automatic refresh:

find_real_file.png

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.

Comments
LaurentChicoine
Tera Guru

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

Ashley Snyder1
Giga Guru

Hi @Laurent Chicoine I think I read that article when I was researching, and while I need some things to happen when I'm submitting the form, I need the button in question to change the State field to Closed when I'm clicking the button in order to perform the necessary UI Policy checks. I didn't see how I could do this with the OOTB Server Side UI Actions for Save, and I also needed the button to state it was for closing the change, since the field would be read only for all users besides admin.   It looks like ServiceNow has also limited all of the Change Management UI Actions to Client, so they don't appear by default on the form widget.

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.

LaurentChicoine
Tera Guru

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.

Ashley Snyder1
Giga Guru

@Laurent Chicoine I've been playing with this a little bit, and I see where you're coming from on this, but I keep getting this error if I click the button 2-3 times, i.e. testing if close notes or close code fields are empty and then submitting and it seems to freeze up my window, one time I had to totally force quit Google Chrome just to deal with it. The server side code does seem to deal with it eventually if the session doesn't freeze (most of the time it does and Force Quit is needed) and close the change, but not sure why I'm getting the error.  

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();
})();
LaurentChicoine
Tera Guru

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:

  1. 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.
  2. 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
  3. 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());
	}
}
Ashley Snyder1
Giga Guru

@Laurent Chicoine for some reason when I tried it previously the server code kept submitting,  but it looks like it's working now.  I appreciate the information, you should write an article on this, I'm sure it would help a lot of others as the previous community question is 3 years old.  I'm going to leave mine up as well as they're both ways to accomplish the same objective, and in hopes that this will help others.

Jasmine6
Kilo Contributor

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?

Markus Kraus
Kilo Sage

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

Version history
Last update:
‎01-15-2020 12:00 PM
Updated by: