Workspace Modals – Working with UI Pages

As workspace adoption grows, so does the need to migrate existing functionality from the Core UI to Workspace. As much as we’d all love to spend the time recreating all of our custom modals in UI Builder, it’s often just not practical during an MVP stage migration to Workspace. This means we are often stuck using our old GlideModal based implementations which rely upon UI pages.

Unfortunately, ServiceNow do not really provide details on how to effectively get this working. Sure, you can show a UI Page in the modal, but how do you get your UI page to interact with the modal it’s being displayed in, or how do you send data back to the UI action.

Well the answer is to use window.postMessage to communicate from the iframe (your UI Page) up to the parent window. Fortunately, ServiceNow have actually developed a solution for this. You’ll find a number of out-of-the-box UI Pages including some javascript with a variable named “iframeMsgHelper”. This bit of code is a simple copy and paste to use in your own UI Pages. However, we can do better than that and instead create a UI Script that we can import into our UI pages, reducing code duplication.

Are there improvements to be made?

If we are going to the trouble of creating a new UI Script to import into our UI Pages, we may as well asses if there are any opportunities for improvement over what is provided out of the box. Taking a look at a UI Page modal in workspace reveals a couple of things that we could improve.

The first improvement we could look to make is the removal of the “Close” button on bottom right of the modal. Although you can’t see it in the screenshot, there are actually some buttons included in the UI Page for the user to interact with. There is no option you can pass to g_modal.showFrame that will hide that button, so we’ll have to do some DOM manipulation. The following code might look a little complex, this is due to the use of shadow DOMs within the Next Experience framework, but essentially we need to navigate our way to the correct shadow DOM before we can get access to the close button and hide it.

IFrameHelper.prototype.hideCloseButton = function () {
    var iframeRootNode = this.window.frameElement?.getRootNode();
    if (iframeRootNode) {
        var scriptedModal = iframeRootNode.querySelector('sn-scripted-modal');
        if (scriptedModal) {
            var nowModal = scriptedModal.shadowRoot?.querySelector('now-modal');
            if (nowModal) {
                var modalFooter = nowModal.shadowRoot?.querySelector('.now-modal-footer');
                if (modalFooter) {
                    modalFooter.style = 'display: none;';
                }
            }
        }
    }
}

The second improvement we could make is regarding the scrollbar. It is possible to set a height with g_modal.showFrame, but that takes trial and error to get right. Instead, with a little DOM manipulation like we did before we could automatically set the height of the modal to match that of the content in the UI Page.

IFrameHelper.prototype.autoResize = function (add) {
    var iframeRootNode = this.window.frameElement?.getRootNode();
    var slotEl = iframeRootNode.querySelector('.slot-content');
    console.log(document.getElementById('autoResize').clientHeight)
    var body = document.body;
    var html = document.documentElement;

    var height = Math.max(body.scrollHeight, body.offsetHeight,
        html.clientHeight, html.scrollHeight, html.offsetHeight);
    slotEl.style.height = (height + 50) + 'px';
}

The Result

This is looking much better. You may have noticed in the script extracts above that I’ve been adding this functionality to the prototype of a class called IFrameHelper. The full class can be found at the end of this post, it mainly consists of the ServiceNow provided code for communicating information from the UI page back to the UI Action script.

Before I provide all that script, It’s worth showing how to use this in a UI page.

The first step it to include the class into your UI page by way of the g:requires Jelly tag.

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
	<g:requires name="iframeHelper.jsdbx"></g:requires>
	<div class="form-group">
		<label for="onHoldReason">On hold Reason</label>

By including the script, you’ll automatically gain access to a variable called iframeHelper, this is an instantiation of the IFrameHelper class.

To remove the close button from the modal and auto resize the modal to fit the content we can simply add the following to our UI Page Client Script. Notice that the method calls are contained within the jQuery document ready callback, this just ensure that the document is finished loading before we try to resize the modal.

$j( document ).ready(function() {
    iframeHelper.hideCloseButton();
    iframeHelper.autoResize();
});

OK, but how to we pass data back to the UI Action?

Passing data back to the UI Action is just as simple as the additional methods I showed above. see the highlighted code below.

$j( document ).ready(function() {
    iframeHelper.hideCloseButton();
    iframeHelper.autoResize();

	$j('#cancelBtn').click(function() {
		iframeHelper.cancel();
	});

	$j('#confirmBtn').click(function() {
		iframeHelper.confirm({
			state: '3',
			on_hold_reason: $j('#onHoldReason').find(":selected").val(),
			comments: $j('#comments').val()
		});
	});
});

How about how we trigger this from a UI action?

Good question. Unfortunately ServiceNow have not yet documented the g_modal API, but below you’ll see an example of how to open a UI page based modal from within the “Workspace client script” field of a UI Action.

function onClick(g_form) {
    g_modal.showFrame({
        url: 'on_hold_picker.do',
        size: 'lg',
        title: 'On Hold Reason',
        callback: function(confirmed, data) {
            if (confirmed) {
                g_form.setValue('state', data.state);
                g_form.setValue('hold_reason', data.on_hold_reason);
                g_form.setValue('comments', data.comments);
				g_form.save();
            }
        }
    });
}

As you can see, it’s quite a simple schema to open a modal.

PropertyDescription
urlThe URL of the UI page. You can add parameters to this URL if you want.
sizeOne of:
sm – Small
lg – Large
fw – Full screen
titleTitle displayed in the header of the modal
callbackThe function you want to execute when the modal is either cancelled or confirmed by calling iframeHelper.cancel() or iframeHelper.confirm(). Two parameters are provided to your callback function.
parameter 1 = boolean – true if confirmed, false if cancelled
parameter 2 = Object – the data you pass back from the UI page using the confirm / cancel method.
heightThe height of the modal in whatever units you like (e.g. px or rem)

The IFrameHelper Class

Here’s the code you’ll want to add to a UI script.

FieldValue
NameiframeHelper
UI TypeDesktop
Activetrue
Globalfalse

Script

var iframeHelper = (function() {
    function createPayload(action, modalId, data) {
        return {
            messageType: 'IFRAME_MODAL_MESSAGE_TYPE',
            modalAction: action,
            modalId: modalId,
            data: (data ? data : {})
        };
    }

    function pm(window, payload) {
        if (window.parent === window) {
            console.warn('Parent is missing. Is this called inside an iFrame?');
            return;
        }
        window.parent.postMessage(payload, location.origin);
    }

    function IFrameHelper(window) {
        this.window = window;
        this.src = location.href;
        this.messageHandler = this.messageHandler.bind(this);
        this.window.addEventListener('message', this.messageHandler);
    }

    IFrameHelper.prototype.messageHandler = function(e) {
        if (e.data.messageType !== 'IFRAME_MODAL_MESSAGE_TYPE' || e.data.modalAction !== 'IFRAME_MODAL_ACTION_INIT') {
            return;
        }
        this.modalId = e.data.modalId;
    };

    IFrameHelper.prototype.confirm = function(data) {
        var payload = createPayload('IFRAME_MODAL_ACTION_CONFIRMED', this.modalId, data);
        pm(this.window, payload);
    };

    IFrameHelper.prototype.cancel = function(data) {
        var payload = createPayload('IFRAME_MODAL_ACTION_CANCELED', this.modalId, data);
        pm(this.window, payload);
    };

    IFrameHelper.prototype.hideCloseButton = function() {
        var iframeRootNode = this.window.frameElement?.getRootNode();
        if (iframeRootNode) {
            var scriptedModal = iframeRootNode.querySelector('sn-scripted-modal');
            if (scriptedModal) {
                var nowModal = scriptedModal.shadowRoot?.querySelector('now-modal');
                if (nowModal) {
                    var modalFooter = nowModal.shadowRoot?.querySelector('.now-modal-footer');
                    if (modalFooter) {
                        modalFooter.style = 'display: none;';
                    }
                }
            }
        }
    }

    IFrameHelper.prototype.autoResize = function(add) {
        var iframeRootNode = this.window.frameElement?.getRootNode();
        var slotEl = iframeRootNode.querySelector('.slot-content');
        var body = document.body;
        var html = document.documentElement;

        var height = Math.max(body.scrollHeight, body.offsetHeight,
            html.clientHeight, html.scrollHeight, html.offsetHeight);
        slotEl.style.height = (height + 40) + 'px';
    }

    return new IFrameHelper(window);
}());
Share this with your network

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Related Posts

Begin typing your search term above and press enter to search. Press ESC to cancel.

Back To Top