Workspace Modals in ServiceNow — Building a Native Agent Scheduling Experience

Haviesh
Tera Expert

The Problem We Were Solving

Our field operations team manages hundreds of agent site visits every week. Each visit is logged as a scheduled engagement in our custom Field Engagement application on ServiceNow. When an agent becomes unavailable, or a client requests a different time slot, a dispatcher needs to reschedule the engagement — updating the visit date and logging a reason for rescheduling.

Simple enough on paper. But our team had recently migrated to ServiceNow Workspace, and that's where things got complicated.


Why This Is Harder Than It Looks

The existing implementation used a classic GlideModal + UI Page pattern that had worked fine in Core UI for years. The moment we moved to Workspace, the button stopped working. No errors in the console — it just silently did nothing.

After digging in, we discovered the core issue:

GlideModal and GlideModalForm are Classic UI APIs. They are not supported in Workspace.

Workspace renders inside a Next Experience shell that uses shadow DOMs, web components, and a completely different event model. Classic modal APIs have no awareness of any of this.

We had three options:

  1. Keep the button Classic UI only — poor experience for Workspace users
  2. Rebuild everything in UI Builder — correct long-term answer, but not feasible in our sprint
  3. Find a bridge solution that works in both — what we actually did

The Bridge: window.postMessage + g_modal.showFrame

ServiceNow's own engineering team uses window.postMessage internally to communicate from iframe-based UI Pages up to the Workspace parent window. Buried inside several out-of-the-box UI Pages is a pattern using a variable called iframeMsgHelper that does exactly this.

We extracted this pattern, cleaned it up, and turned it into a reusable UI Script called iframeHelper. This script:

  • Sends a postMessage to the parent window when the user confirms or cancels
  • Hides the native Workspace modal close button (which cannot be removed via config)
  • Auto-resizes the modal to match the UI Page content height
  • Works as a no-op in Classic UI (graceful degradation)

On the UI Action side, we split the implementation:

  • Main Script (Classic UI): uses GlideModal as before
  • Workspace Client Script: uses g_modal.showFrame — the undocumented but functional Workspace modal API

Architecture Overview

┌─────────────────────────────────────────────────┐
│                  UI Action                       │
│                                                  │
│  Main Script          Workspace Client Script    │
│  (Classic UI)         (Workspace)                │
│  GlideModal           g_modal.showFrame          │
└──────────┬────────────────────┬─────────────────┘
           │                    │
           ▼                    ▼
┌─────────────────────────────────────────────────┐
│              UI Page (iframe)                    │
│         visit_reschedule_picker.do               │
│                                                  │
│  ┌─────────────────────────────────────────┐    │
│  │  g:requires → iframeHelper.jsdbx        │    │
│  │                                          │    │
│  │  Visit Date (date picker)               │    │
│  │  Reason     (text input)                │    │
│  │                                          │    │
│  │  [Cancel]    [Confirm Reschedule]       │    │
│  └─────────────────────────────────────────┘    │
└──────────────────┬──────────────────────────────┘
                   │ postMessage
                   ▼
┌─────────────────────────────────────────────────┐
│              Parent Window                       │
│                                                  │
│  callback(confirmed, data)                       │
│  → g_form.setValue('visit_date', data.new_date)  │
│  → g_form.setValue('reschedule_reason', data.reason) │
│  → g_form.save()                                 │
└──────────────────┬──────────────────────────────┘
                   │ record update
                   ▼
┌─────────────────────────────────────────────────┐
│           Business Rule (before update)          │
│                                                  │
│  reschedule_reason.changes()                     │
│  → status = 'Rescheduled'                        │
│  → work_notes = audit trail                      │
└─────────────────────────────────────────────────┘

Step 1 — The iframeHelper UI Script

This is the foundation of the entire solution. Create it once and reuse it across any UI Page that needs to communicate with a Workspace modal.

Where: System UI → UI Scripts → New

Field Value

NameiframeHelper
UI TypeAll
Active
Globalfalse

Note: ServiceNow will show a warning saying the script does not match the default pattern. Click OK — this is expected for IIFE-style scripts.

var iframeHelper = (function() {

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

    function pm(win, payload) {
        if (win.parent === win) {
            console.warn('iframeHelper: parent missing. Inside an iFrame?');
            return;
        }
        win.parent.postMessage(payload, location.origin);
    }

    function IFrameHelper(win) {
        this.window         = win;
        this.src=location.href;
        this.modalId        = null;
        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) {
        pm(this.window, createPayload(
            'IFRAME_MODAL_ACTION_CONFIRMED',
            this.modalId,
            data
        ));
    };

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

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

    IFrameHelper.prototype.autoResize = function() {
        var iframeRootNode = this.window.frameElement
                           ? this.window.frameElement.getRootNode()
                           : null;
        if (!iframeRootNode) return;
        var slotEl = iframeRootNode.querySelector('.slot-content');
        if (!slotEl) return;
        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);

}());

Step 2 — The UI Page

Where: System UI → UI Pages → New

Field Value

Namevisit_reschedule_picker
Client callable
Categorygeneral

HTML Tab

<?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"/>

  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: "Source Sans Pro", "Segoe UI", Arial, sans-serif;
      font-size: 14px;
      color: #1b1d1f;
      background: #fff;
    }
    #rsc-container { padding: 20px 24px 16px; }
    .rsc-info-bar {
      background: #e8f4fd;
      border: 1px solid #b3d4f0;
      border-radius: 3px;
      padding: 10px 14px;
      margin-bottom: 20px;
      font-size: 13px;
      color: #004f8b;
    }
    .rsc-form-table { width: 100%; border-collapse: collapse; }
    .rsc-form-table tr { border-bottom: 1px solid #e8eaed; }
    .rsc-form-table tr:last-child { border-bottom: none; }
    .rsc-label-cell {
      width: 38%;
      padding: 10px 12px 10px 0;
      text-align: right;
      font-weight: 600;
      font-size: 13px;
      vertical-align: middle;
      white-space: nowrap;
    }
    .required-star { color: #d93025; margin-left: 2px; }
    .rsc-input-cell {
      width: 62%;
      padding: 10px 0 10px 12px;
      vertical-align: middle;
    }
    .rsc-readonly {
      display: block;
      padding: 6px 10px;
      background: #f4f4f4;
      border: 1px solid #e0e0e0;
      border-radius: 3px;
      font-size: 13px;
      color: #555;
    }
    .rsc-input {
      width: 100%;
      height: 32px;
      padding: 4px 8px;
      border: 1px solid #c8c8c8;
      border-radius: 3px;
      font-size: 14px;
      font-family: inherit;
      outline: none;
    }
    .rsc-input:focus {
      border-color: #0070d2;
      box-shadow: 0 0 0 2px rgba(0,112,210,0.2);
    }
    .rsc-input.error { border-color: #d93025; }
    .rsc-error {
      color: #d93025;
      font-size: 12px;
      margin-top: 4px;
      display: none;
    }
    .rsc-error.show { display: block; }
    .rsc-divider {
      border: none;
      border-top: 1px solid #e0e0e0;
      margin: 16px 0;
    }
    .rsc-btn-row {
      display: flex;
      justify-content: flex-end;
      gap: 8px;
    }
    .rsc-btn-secondary {
      height: 32px; padding: 0 16px;
      background: #fff; color: #0070d2;
      border: 1px solid #0070d2; border-radius: 3px;
      font-size: 14px; font-family: inherit; cursor: pointer;
    }
    .rsc-btn-secondary:hover { background: #f0f7ff; }
    .rsc-btn-primary {
      height: 32px; padding: 0 16px;
      background: #0070d2; color: #fff;
      border: 1px solid #0070d2; border-radius: 3px;
      font-size: 14px; font-weight: 600;
      font-family: inherit; cursor: pointer;
    }
    .rsc-btn-primary:hover { background: #005fb2; }
    .rsc-btn-primary:disabled {
      background: #c8c8c8; border-color: #c8c8c8; cursor: not-allowed;
    }
  </style>

  <j:set var="jvar_sys_id"       value="$[sysparm_sys_id]"/>
  <j:set var="jvar_current_date" value="$[sysparm_current_date]"/>

  <div id="rsc-container">

    <div class="rsc-info-bar">
      &#9432; Rescheduling visit currently set for
      <strong>$[jvar_current_date]</strong>
    </div>

    <input type="hidden" id="rsc-sys-id"       value="$[jvar_sys_id]"/>
    <input type="hidden" id="rsc-current-date" value="$[jvar_current_date]"/>

    <table class="rsc-form-table">

      <tr>
        <td class="rsc-label-cell">Current Visit Date</td>
        <td class="rsc-input-cell">
          <span class="rsc-readonly">$[jvar_current_date]</span>
        </td>
      </tr>

      <tr>
        <td class="rsc-label-cell">
          New Visit Date <span class="required-star">*</span>
        </td>
        <td class="rsc-input-cell">
          <g:date_time_specific
            name="rsc_new_date"
            id="rsc_new_date"
            value=""
            size="25"
          />
          <div class="rsc-error" id="err-date">
            New visit date is required and must be in the future.
          </div>
        </td>
      </tr>

      <tr>
        <td class="rsc-label-cell">
          Reason for Rescheduling <span class="required-star">*</span>
        </td>
        <td class="rsc-input-cell">
          <input
            type="text"
            id="rsc-reason"
            class="rsc-input"
            placeholder="Enter reason..."
            maxlength="500"
            autocomplete="off"
          />
          <div class="rsc-error" id="err-reason">
            Reason is required.
          </div>
        </td>
      </tr>

    </table>

    <hr class="rsc-divider"/>

    <div class="rsc-btn-row">
      <button type="button" class="rsc-btn-secondary" id="rsc-cancel">
        Cancel
      </button>
      <button type="button" class="rsc-btn-primary" id="rsc-confirm">
        Confirm Reschedule
      </button>
    </div>

  </div>

</j:jelly>

Client Script Tab

$j(document).ready(function() {

    iframeHelper.hideCloseButton();
    iframeHelper.autoResize();

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

    $j('#rsc-confirm').click(function() {
        if (!validate()) return;
        iframeHelper.confirm({
            new_date: getNewDate(),
            reason:   $j('#rsc-reason').val().trim()
        });
    });

    function validate() {
        var valid   = true;
        var newDate = getNewDate();
        var reason  = $j('#rsc-reason').val().trim();

        $j('#err-date').removeClass('show');
        $j('#err-reason').removeClass('show');
        $j('#rsc-reason').removeClass('error');

        if (!newDate) {
            $j('#err-date').addClass('show');
            valid = false;
        } else {
            var d = new Date(newDate.replace(' ', 'T'));
            if (isNaN(d.getTime()) || d <= new Date()) {
                $j('#err-date').addClass('show');
                valid = false;
            }
        }

        if (!reason) {
            $j('#rsc-reason').addClass('error');
            $j('#err-reason').addClass('show');
            valid = false;
        }

        return valid;
    }

    function getNewDate() {
        var valEl = document.getElementById('rsc_new_date_val');
        if (valEl && valEl.value && valEl.value.trim() !== '') {
            return valEl.value.trim();
        }
        var dispEl = document.getElementById('rsc_new_date');
        return dispEl ? dispEl.value.trim() : '';
    }
});

Step 3 — The UI Action

Where: System UI → UI Actions → New

Field Value

NameReschedule Visit
Tablex_yourscope_field_engagement
Client
OnclickrescheduleVisit()
Show update
Form button
Conditioncurrent.getValue('status') == 'scheduled'
Workspace Form Button
Format for Configurable Workspace

Main Script (Classic UI)

function rescheduleVisit() {

    var sysId       = g_form.getUniqueValue();
    var currentDate = g_form.getValue('visit_date');

    var gm = new GlideModal('visit_reschedule_picker');
    gm.setTitle('Reschedule Visit');
    gm.setWidth(550);
    gm.setPreference('sysparm_sys_id',       sysId);
    gm.setPreference('sysparm_current_date', currentDate);
    gm.render();
}

Workspace Client Script

function onClick(g_form) {

    var sysId       = g_form.getUniqueValue();
    var currentDate = g_form.getValue('visit_date');

    g_modal.showFrame({
        url:   'visit_reschedule_picker.do' +
               '?sysparm_sys_id='       + sysId +
               '&sysparm_current_date=' + encodeURIComponent(currentDate),
        size:  'lg',
        title: 'Reschedule Visit',
        callback: function(confirmed, data) {
            if (!confirmed) {
                g_form.setValue('visit_date', currentDate);
                return;
            }
            g_form.setValue('visit_date',         data.new_date);
            g_form.setValue('reschedule_reason',  data.reason);
            g_form.save();
        }
    });
}

Step 4 — The Business Rule

Where: System Definition → Business Rules → New

Field Value

NameSet Rescheduled Status and Audit Notes
Tablex_yourscope_field_engagement
Whenbefore
Update
Conditioncurrent.reschedule_reason.changes() && current.reschedule_reason != ''
(function executeRule(current, previous) {

    // Guard: only fire when reschedule_reason changes to a non-empty value
    if (!current.reschedule_reason.changes()) return;
    if (!current.getValue('reschedule_reason')) return;

    // Update status to Rescheduled
    current.setValue('status', 'rescheduled');

    // Build audit trail for work notes
    var oldDate = previous.getValue('visit_date')       || 'N/A';
    var newDate = current.getValue('visit_date')         || 'N/A';
    var reason  = current.getValue('reschedule_reason') || 'N/A';

    current.work_notes =
        'Visit rescheduled by   : ' + gs.getUserDisplayName() + '\n' +
        'Previous Visit Date    : ' + oldDate + '\n' +
        'New Visit Date         : ' + newDate + '\n' +
        'Reason                 : ' + reason;

})(current, previous);

Key Lessons Learned

1. GlideModal ≠ Workspace

This is the single most important thing to know. If your team is migrating to Workspace, audit every UI Action that uses GlideModal or GlideModalForm — they will silently fail.

2. g_modal.showFrame Is Your Friend

It is undocumented by ServiceNow but it works reliably. The API is straightforward — a URL, a size, a title, and a callback. That's all you need.

3. window.postMessage Is the Bridge

The iframe (your UI Page) and the parent Workspace window are in separate JavaScript contexts. postMessage is the only reliable way to pass data between them. The iframeHelper script wraps this complexity so you never have to think about it.

4. Shadow DOMs Make Button Hiding Hard

The Workspace modal footer (with its Close button) lives inside nested shadow DOMs — sn-scripted-modal → now-modal → .now-modal-footer. You cannot reach it with a normal querySelector. The hideCloseButton() method in iframeHelper navigates these shadow roots correctly.

5. Business Rule Condition Must Check Both changes() AND Non-Empty

A common mistake is writing current.reschedule_reason.changes() alone. If the field is cleared to empty (e.g. during a failed save), this still fires. Always add the non-empty check:

// Wrong — fires even when field is cleared to blank
if (!current.reschedule_reason.changes()) return;

// Correct — only fires when changed to a real value
if (!current.reschedule_reason.changes()) return;
if (!current.getValue('reschedule_reason')) return;

6. Why Not Declarative Actions?

Declarative Actions is the correct long-term answer for Workspace UI Actions. If you are starting a new application, use Declarative Actions + UI Builder modal + Flow Designer. The approach in this post is a pragmatic bridge for teams mid-migration who need a working solution without rebuilding everything in UI Builder.


Components Summary

Component Name Purpose

UI ScriptiframeHelperpostMessage bridge + Workspace helpers
UI Pagevisit_reschedule_pickerModal form with date picker + reason
UI ActionReschedule VisitOpens modal in Classic + Workspace
Business RuleSet Rescheduled Status and Audit NotesState change + work notes on save

Final Result

Feature Works in Classic UI Works in Workspace

Modal opens on button click
Native SN date picker renders
Current date pre-populated
Free text reason field
Validation before save
Cancel restores original date
Status updates to Rescheduled
Work notes audit trail
Workspace close button hiddenN/A
Modal auto-resizes to contentN/A

Have questions or improvements? Drop them in the comments below.

0 REPLIES 0