Workspace Modals in ServiceNow — Building a Native Agent Scheduling Experience
- Mark as New
- Bookmark
- Subscribe
- Mute
- Subscribe to RSS Feed
- Permalink
- Report Inappropriate Content
3 hours ago
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:
- Keep the button Classic UI only — poor experience for Workspace users
- Rebuild everything in UI Builder — correct long-term answer, but not feasible in our sprint
- 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
| Name | iframeHelper |
| UI Type | All |
| Active | ✅ |
| Global | false |
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
| Name | visit_reschedule_picker |
| Client callable | ✅ |
| Category | general |
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">
ⓘ 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
| Name | Reschedule Visit |
| Table | x_yourscope_field_engagement |
| Client | ✅ |
| Onclick | rescheduleVisit() |
| Show update | ✅ |
| Form button | ✅ |
| Condition | current.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
| Name | Set Rescheduled Status and Audit Notes |
| Table | x_yourscope_field_engagement |
| When | before |
| Update | ✅ |
| Condition | current.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 Script | iframeHelper | postMessage bridge + Workspace helpers |
| UI Page | visit_reschedule_picker | Modal form with date picker + reason |
| UI Action | Reschedule Visit | Opens modal in Classic + Workspace |
| Business Rule | Set Rescheduled Status and Audit Notes | State 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 hidden | N/A | ✅ |
| Modal auto-resizes to content | N/A | ✅ |
Have questions or improvements? Drop them in the comments below.