Introduction

If you’ve ever tried to trigger a workspace modal from a list action, you’ve probably hit some quirks, especially when using reference fields. For example, calling g_modal.showFields() with a reference field works fine on a form, but breaks when launched from a list.

On top of that, if you’re trying to build a nice modal experience in a UI Page—something more modern than alert() or confirm()—you’ll quickly notice the default browser prompts aren’t great and the workspace modal APIs aren’t usable inside Jelly.

This post walks through both of these issues and shows how to fix them.

Problem 1: Reference fields don’t work from list modals

Here’s a basic g_modal.showFields() call with a reference field, in a Workspace UI action / Action Assignment1.

This is in an Action Assignment of type List Action, Implemented as Client Scipt, and this script would go in the Client Script setion

var fields = [
  {
    type: 'reference',
    name: 'caller_id',
    label: getMessage('Who is the user?'),
    mandatory: true,
    reference: 'sys_user',
    referringTable: 'incident',
    referringRecordId: g_form.getUniqueValue(),
    value: g_form.getValue('caller_id'),
    displayValue: g_form.getDisplayValue('caller_id')
  }
];

g_modal.showFields({
  title: "Select a user",
  fields: fields,
  size: 'lg'
}).then(function(fieldValues) {
  g_form.setValue('caller_id', fieldValues.updatedFields[0].value);
  g_form.save();
});

The problem is that referringTable, referringRecordId, and name all assume the context of a form. If you trigger this from a list button, you’re missing the right record context, and it fails.

Solution: use a UI Page inside a modal

So, instead of using g_modal.showFields(), you can use g_modal.showFrame() to load a custom UI Page. That page gives you full control over inputs, including reference fields.

Step 1: create a UI page

To keep things simple, let’s start with native alert() and confirm().

Create a UI page, called in this example select_author, and add this to the HTML part:

<?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:ui_reference name="author" id="author" table="x_1588981_demo_authors" query="active=true" />
	<g:dialog_buttons_ok_cancel ok="return actionOK();" cancel="return cancel();" />
</j:jelly>

And then this to the Client Script:

function actionOK() {
    // Get the value from the reference field with ID 'author'
    var authorId = gel('author').value;

    // If no value is selected, show an alert and stop execution
    if (!authorId) {
        alert("Please select an author");
        return false;
    }

    // Ask the user for confirmation before proceeding
    if (confirm("Are you sure you want to proceed?")) {
        // In a real version, this would send the selected authorId back to the parent modal
        // For now, we just show a confirmation message
        alert("Confirmed with author: " + authorId);
    }

    // Prevent default form submission behavior
    return false;
}

function cancel() {
    // Show a cancellation message and prevent default behavior
    alert("Action cancelled");
    return false;
}

Your UI Page will also need a matching UI Page ACL to allow access. You will be asked when saving it the first time, but in case you want more information you can refer to ACL rule types documentation2 and the article “How to create ACL for UI page” from theServiceNow Community3 for details.

Step 2: Call the page from Workspace

On the UI Action / Action Assignment, replace the Client Script with the one below. It use the g_modal.showFrame() that use the UI page we created previously.

function onClick() {
    // Open a UI Page inside a modal using g_modal.showFrame
    g_modal.showFrame({
        url: '/x_1588981_demo_select_author.do',  // UI Page to load.  Note: the .do suffix is automatically added to the UI Page name
        title: "Select an author",                // Modal title
        size: 'xl',                               // Modal width size
        autoCloseOn: 'URL_CHANGED',               // Automatically close modal if iframe URL changes
        callback: function (ret, data) {
            // If the user confirmed the modal
            if (ret) {
                // Log the returned data (e.g., selected authorId)
                console.log(data);
            }
        }
    });
}

Concerning the part autoCloseOn: 'URL_CHANGED': the goal is to closes the modal automatically when the iframe navigates. This works nicely with the close pattern:

function close() {
  window.location.href = window.location.href + '&sysparm_next_pg=true';
}

This triggers the URL_CHANGED condition and closes the modal from within the iframe.

Problem 2: Returning values and closing the modal

If you try the button now, the UI page open in a modal, but if you select an author and click OK, nothing happens. This is because it lacks a mechanism to return values to the calling script.

Modal without resize

For this, the iframeHelper come to the rescue. This utility solves the communication with the Actoin Assignment using window.postMessage() to confirm or cancel modal interactions4.

iframeHelper: a postMessage helper for modals

Here’s a UI Script (iframeHelper) that includes confirm, cancel methods. It is from the snpro blog4, but I copied it here for convenience.

Simply create a new UI script:

  • Script name: iframeHelper
  • UI Script: All" types.
  • Script: copy/paste the code below
/**
 *  Script from 
 *  "Workspace Modals - Working with UI Pages - SNPro.dev"
 *   - https://snpro.dev/2023/11/20/workspace-modals-working-with-ui-pages/
 *  (c) Callum Ridley
 *  
 */
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);
}());

You will get a warning message like this one, but you can safely click “OK”:

Message when saving the UI Script

Update the UI Page

After creating the helper, update the the UI Page, by adding this line in the HTML part, in the jelly:

<g:requires name="x_1588981_demo.iframeHelper.jsdbx"></g:requires>

Then adapt the Client Script part as follow:

// When the DOM is fully loaded, auto-resize the modal using the iframeHelper
$j(document).ready(function() {
    iframeHelper.autoResize();
});

function actionOK() {
    var authorId = gel('author').value;
    if (!authorId) {
        alert("Please select an author");
        return false;
    }
    if (confirm("Are you sure you want to proceed?")) {
        // Send the selected authorId back to the parent modal using iframeHelper
        iframeHelper.confirm({
            authorId: authorId
        });
    }
    return false;
}

function cancel() {
    // Cancel the modal and close it without returning data
    iframeHelper.cancel();
    return false;
}

As you might have noticed, we now:

  • use the autoresize at the start of the Client Script, which resize the modal dynamically;
  • use cancel to close the modal (and return control to the calling button).
  • use confirm to send back the data to the calling button;

As a reminder, the object you pass to iframeHelper.confirm() (e.g., { authorId }) is returned directly to the callback function in your Action Assignment. as data in this example:

callback: function (ret, data) {
    // If the user confirmed the modal
    if (ret) {
        // Log the returned data (e.g., selected authorId)
        console.log(data);
    }
}

This data is passed directly to your callback. If you later use it in server-side code (like GlideAjax), be sure to validate it. Never trust client input blindly.

Problem 3: Building custom alert and confirm modals in UI Pages

At this point, you probably noticed that the alert and confirm message box are the standard one. Those native alert() and confirm() calls are functional but clunky. They don’t match the workspace UX, and you can’t use workspace modals directly in UI Pages.

Modal with the standard browser alert message

Here’s how to build nicer modals with inline HTML and JavaScript.

Add the following code in the HTML part of the UI page:

<!-- Alert modal -->
<div id="alertModal" style="display:none; position:fixed; left:0; top:0; width:100vw; height:100vh; background:rgba(0,0,0,0.4); z-index:9999;">
  <div style="background:white; padding:2em; margin:10% auto; width:300px; border-radius:10px; box-shadow:0 4px 16px #333;">
    <div id="modalMessage"></div>
    <button onclick="hideAlertModal()">OK</button>
  </div>
</div>

<!-- Confirm modal -->
<div id="confirmModal" style="display:none; position:fixed; left:0; top:0; width:100vw; height:100vh; background:rgba(0,0,0,0.4); z-index:9999;">
  <div style="background:white; padding:2em; margin:10% auto; width:320px; border-radius:10px; box-shadow:0 4px 16px #333;">
    <div id="confirmMessage"></div>
    <button id="confirmOKBtn">OK</button>
    <button id="confirmCancelBtn">Cancel</button>
  </div>
</div>

These modal blocks use inline styles for layout and appearance. You can adjust the width or make them dynamic with JS if needed, but the fixed size works well in most cases.

Then add those functions in the Client Script

// Show the alert modal with a given message
function showAlertModal(message) {
  // Set the alert message content
  document.getElementById('modalMessage').innerText = message;
  // Make the alert modal visible
  document.getElementById('alertModal').style.display = 'block';
}

// Hide the alert modal
function hideAlertModal() {
  document.getElementById('alertModal').style.display = 'none';
}

// Show a custom confirm modal with a message and a callback for the user's choice
function showConfirmModal(message, callback) {
  var modal = document.getElementById('confirmModal');

  // Set the confirm message content
  document.getElementById('confirmMessage').innerText = message;
  // Make the confirm modal visible
  modal.style.display = 'block';

  // Internal function to clean up after user makes a choice
  function cleanUp(result) {
    modal.style.display = 'none';
    // Remove previous click handlers to avoid stacking
    document.getElementById('confirmOKBtn').onclick = null;
    document.getElementById('confirmCancelBtn').onclick = null;
    // Run the callback with the user's choice (true or false)
    if (typeof callback === 'function') callback(result);
  }

  // OK button confirms the action
  document.getElementById('confirmOKBtn').onclick = function() { cleanUp(true); };
  // Cancel button cancels the action
  document.getElementById('confirmCancelBtn').onclick = function() { cleanUp(false); };
}

And replace the alert and confirm calls with the new functions:

if (!authorId) {
    //alert("Please select an author");
    showAlertModal("Please select an author");
    return false;
}
/*if (confirm("Are you sure you want to proceed?")) {
    // Send the selected authorId back to the parent modal using iframeHelper
    iframeHelper.confirm({
        authorId: authorId
    });
}
*/
showConfirmModal("Are you sure you want to continue?", function(confirmed) {
    if (confirmed) {
        iframeHelper.confirm({
            authorId: authorId
        });
    } else {
        // Do nothing
    }
});

Et voilà: you now have a nice message:

New modal message

Complete, working example of the UI page

Here is the complete and working example. This is this part you may want to copy/paste and adapt.

HTML

<?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="x_1588981_demo.iframeHelper.jsdbx"></g:requires>
    <g:ui_reference name="author" id="author" table="x_1588981_demo_authors" query="active=true" />
    <g:dialog_buttons_ok_cancel ok="return actionOK();" cancel="return cancel();" />

    <!-- Alert modal -->
    <div id="alertModal" style="display:none; position:fixed; left:0; top:0; width:100vw; height:100vh; background:rgba(0,0,0,0.4); z-index:9999;">
        <div style="background:white; padding:2em; margin:10% auto; width:300px; border-radius:10px; box-shadow:0 4px 16px #333;">
            <div id="modalMessage"></div>
            <button onclick="hideAlertModal()">OK</button>
        </div>
    </div>

    <!-- Confirm modal -->
    <div id="confirmModal" style="display:none; position:fixed; left:0; top:0; width:100vw; height:100vh; background:rgba(0,0,0,0.4); z-index:9999;">
        <div style="background:white; padding:2em; margin:10% auto; width:320px; border-radius:10px; box-shadow:0 4px 16px #333;">
            <div id="confirmMessage"></div>
            <button id="confirmOKBtn">OK</button>
            <button id="confirmCancelBtn">Cancel</button>
        </div>
    </div>
</j:jelly>

client script

// When the DOM is fully loaded, auto-resize the modal using the iframeHelper
$j(document).ready(function() {
    iframeHelper.autoResize();
});

function actionOK() {
    var authorId = gel('author').value;
    if (!authorId) {
        showAlertModal("Please select an author");
        return false;
    }

    showConfirmModal("Are you sure you want to continue?", function(confirmed) {
        if (confirmed) {
            iframeHelper.confirm({
                authorId: authorId
            });
        } else {
            // Do nothing
        }
    });

return false;
}

function cancel() {
    // Cancel the modal and close it without returning data
    iframeHelper.cancel();
    return false;
}

// Show the alert modal with a given message
function showAlertModal(message) {
    // Set the alert message content
    document.getElementById('modalMessage').innerText = message;
    // Make the alert modal visible
    document.getElementById('alertModal').style.display = 'block';
}

// Hide the alert modal
function hideAlertModal() {
    document.getElementById('alertModal').style.display = 'none';
}
// Show a custom confirm modal with a message and a callback for the user's choice
function showConfirmModal(message, callback) {
    var modal = document.getElementById('confirmModal');

    // Set the confirm message content
    document.getElementById('confirmMessage').innerText = message;
    // Make the confirm modal visible
    modal.style.display = 'block';

    // Internal function to clean up after user makes a choice
    function cleanUp(result) {
        modal.style.display = 'none';
        // Remove previous click handlers to avoid stacking
        document.getElementById('confirmOKBtn').onclick = null;
        document.getElementById('confirmCancelBtn').onclick = null;
        // Run the callback with the user's choice (true or false)
        if (typeof callback === 'function') callback(result);
    }
    // OK button confirms the action
    document.getElementById('confirmOKBtn').onclick = function() {
        cleanUp(true);
    };
    // Cancel button cancels the action
    document.getElementById('confirmCancelBtn').onclick = function() {
        cleanUp(false);
    };
}

Final Notes

  • The UI Page uses iframeHelper.autoResize() to dynamically adjust height.
  • This pattern works well for modal workflows launched from Workspace list buttons5.
  • It’s a reliable but not officially documented pattern—test thoroughly.