Modal windows
A rule can open its own small app. The two halves, the bridge between them, and the whole API.
Fill the Modal tab of a script and it becomes that rule's user interface: your markup and your
JavaScript in an isolated frame, styled to match the panel, with an api object already bound.
The mental model is a Service Portal widget. A client side that renders, a server side with database access, and a bridge between them.
The two halves
The server script, the Server tab, runs on the instance in the global scope, through the platform's background script endpoint. It has the full server API.
The modal, the Modal tab, is your HTML and JavaScript. It cannot reach the server directly. It
goes through api.
The lifecycle
- The trigger fires, and the form context is snapshotted.
- If the server script uses the actions shape, it is called once with the action
init, and the result becomesapi.data. The modal shows a spinner meanwhile. apiis bound onto the frame, and then your markup is written, soapialready exists for inline scripts.- Your code runs. It can call the server again, read and write the live form, and close the modal.
- For a
beforeSaverule the held save resumes only when you callcontinueSave(). Closing the modal any other way leaves the save cancelled.
What the server script receives
| Variable | Holds |
|---|---|
params | What the client passed, plus the table and sys_id from the context, plus the action |
output | Starts empty. What you put in it comes back to the client as JSON |
action | The action the client asked for, or an empty string |
var gr = new GlideRecord('incident');
gr.get(params.sys_id);
output.number = gr.getValue('number');
output.caller = gr.caller_id.getDisplayValue();
Anything you print comes back as plain text beside the JSON and is shown in the modal's console and
the panel log. Useful for tracing, and not a substitute for output.
Keep the payload small. output travels back as a single line of JSON inside an HTML response.
A few hundred rows is fine. Ten thousand is not. Page it, or aggregate on the server.
Several entry points
Declare an object called actions and the wrapper dispatches into it instead of running the body
from top to bottom:
var actions = {
// called before the modal is shown; the result becomes api.data
init: function (p) {
var gr = new GlideRecord('incident');
gr.get(p.sys_id);
return { number: gr.getValue('number'), state: gr.getValue('state') };
},
close: function (p) {
var gr = new GlideRecord('incident');
if (!gr.get(p.sys_id)) throw new Error('Record vanished');
gr.setValue('state', '7');
gr.setValue('close_notes', p.notes);
gr.update();
return { ok: true };
}
};
And from the modal:
document.querySelector('h1').textContent = 'Closing ' + api.data.number;
btn.onclick = function () {
api.call('close', { notes: box.value })
.then(function () { api.toast('ok', 'Closed', 'Done.'); api.continueSave(); })
.catch(function (e) { api.toast('warn', 'Failed', e.message); });
};
The api object
Bound onto the modal's frame before your code runs. Everything is synchronous except the members that return a promise.
| Member | Returns | Does |
|---|---|---|
api.data | object | What init returned, ready before your code runs. {} when there is no init |
api.context | object | { table, sys_id, fields, preview }, a snapshot taken when the modal opened. Frozen: use api.form for live values |
api.call(action, params) | Promise | Runs one action of the server script. Resolves with what it returned, rejects with an Error carrying the server message and line |
api.runBE(params) | Promise | The older name. Runs the whole body with no action. Still supported |
api.form | object | The live form behind the modal. See below |
api.rest(path, options) | Promise | A request to the instance with your session, the CSRF token and JSON headers already set. Throws on a status outside 2xx, resolves to null on 204 |
api.toast(type, title, message) | nothing | A toast on the main page. type is ok, info or warn; the message takes simple HTML |
api.log(...args) | nothing | Into the modal console. console.log works too, and is mirrored there |
api.continueSave() | nothing | Closes the modal and lets a held save go through. On any other rule it just closes |
api.cancelSave() | nothing | Closes the modal and abandons the save |
api.close() | nothing | Closes the modal. A held save stays cancelled |
api.rest('/api/now/table/incident?sysparm_query=active=true&sysparm_limit=5')
.then(function (r) { console.log(r.result.length + ' open incidents'); });
api.form
api.context.fields is what the form held when the modal opened. For a beforeSave rule that is
rarely enough: you usually want to fix a field and then let the save through. api.form passes your
calls to the real form.
| Method | Does |
|---|---|
getValue(field) | The current value, as a string |
getDisplayValue(field) | The display value of a reference field |
setValue(field, value [, display]) | Sets a value. The third argument is the label for a reference |
clearValue(field) | Empties a field |
setMandatory, setReadOnly, setVisible, setDisplay | The usual form controls |
isMandatory(field), isNewRecord() | State checks |
getTableName(), getUniqueValue() | The table and sys_id of the open record |
addErrorMessage, addInfoMessage, clearMessages | Banners on the form |
showFieldMsg(field, message, type), hideFieldMsg(field) | Messages under one field |
snapshot() | A fresh read of every field, as an object |
// before-save rule: hold the save until a reason is given
var reason = api.form.getValue('u_reason');
if (!reason) {
api.form.showFieldMsg('u_reason', 'Required when closing early', 'error');
api.cancelSave();
} else {
api.form.setValue('u_validated', 'true');
api.continueSave();
}
When there is no form, in a preview or over a list, nothing throws. The call is written to the
console and returns an empty value, and snapshot() falls back to the one taken at open.
Building one
The Preview button opens your modal exactly as a rule would, using what is in the editor, saved or not. The context is faked from the Table field, or from the form behind the editor if there is one. While a preview is open the button becomes Reload, and editing either tab re-mounts it about a second after you stop typing. AltShiftR does the same from the keyboard.
A reload rebuilds the modal from scratch, so anything typed into it is lost. That is deliberate: it is the same fresh start a real trigger would give you.
Every modal has a Console button. It shows your own log lines, every server call with its parameters and how long it took, anything the server script printed, and thrown errors with the line number in your source. It opens itself on the first error and the button carries a count, so nothing fails silently.
Errors and line numbers
Your script does not run alone. It is wrapped in a harness, and any @use libraries are put in front
of it, so a raw line number from the engine points into the combined script, which is no use to you.
The extension keeps a map of which lines came from where and translates before it shows the error:
Error: gr.getValue is not a function (line 12 of script)
Error: Cannot read property 'x' of null (line 4 of library "dateUtils")
The same message reaches the modal console, the panel log, and the rejected promise from
api.call().
| Symptom | Usually means |
|---|---|
api is undefined in the modal frame | Your script ran before the bridge was bound, almost always a script loaded from outside the modal body |
Empty response from /sys.scripts.do | The session dropped, or another background script was running. Reload the page and retry |
Unknown action: x | api.call('x') with no x in the actions object. Check the spelling |
| Could not obtain a CSRF token | You are signed out of the instance in this tab |
A full example
Pick a group, reassign the record, confirm. The server tab:
// @use ticketHelpers
var actions = {
init: function (p) {
var groups = [], gr = new GlideRecord('sys_user_group');
gr.addActiveQuery();
gr.orderBy('name');
gr.setLimit(50);
gr.query();
while (gr.next()) groups.push({ id: gr.getUniqueValue(), name: gr.getValue('name') });
return { groups: groups };
},
reassign: function (p) {
var gr = new GlideRecord(p.table);
if (!gr.get(p.sys_id)) throw new Error('Record not found');
gr.setValue('assignment_group', p.group);
gr.setValue('assigned_to', '');
gr.update();
gs.print('reassigned ' + gr.getValue('number') + ' to ' + p.group);
return { number: gr.getValue('number') };
}
};
The modal tab:
<h1>Reassign</h1>
<select id="g" style="width:100%"></select>
<p><button class="primary" id="go">Reassign</button> <button id="no">Cancel</button></p>
<script>
// api.data is already here: no spinner, no first request
var sel = document.getElementById('g');
api.data.groups.forEach(function (grp) {
var o = document.createElement('option');
o.value = grp.id;
o.textContent = grp.name;
sel.appendChild(o);
});
document.getElementById('go').onclick = function () {
var btn = this;
btn.disabled = true;
api.call('reassign', { group: sel.value })
.then(function (r) { api.toast('ok', 'Reassigned', r.number + ' moved.'); api.close(); })
.catch(function (e) { api.toast('warn', 'Failed', e.message); btn.disabled = false; });
};
document.getElementById('no').onclick = function () { api.close(); };
</script>
Limits
One modal at a time. A second rule that tries to open one is skipped and logged. A held save is never left hanging because of it.
No streaming. A server call is one request and one response. A loop over thousands of records blocks until it ends, with no progress. Batch it yourself: return a cursor and call again.
Global scope only. The server half always runs in the global application scope, whatever scope your session is in.
No state between calls. Each call is a fresh execution. Pass what you need back through params.
Calls queue. Background executions wait behind one lock, so two modals cannot corrupt each other's session. A call may wait for another one to finish.
No dry run. A server script that writes, writes. There is no preview of the write and no undo.
