Admin JavaScript Library
The panel ships one request helper, one modal library, one notification stack and one table controller for every screen you add.
Overview
Every admin page loads the same four scripts, and everything in them is global: plain functions plus one class for tables.
The contract with the server is a single JSON envelope, the one every operation returns. The helper reads it, so a call site usually needs no success or error branch.
Prerequisites
The admin footer prints all of this: include inc/head.php and inc/footer.php, with an operator logged in. Without the footer you get none of the library or the constants.
On the other side of every call sits an operation, a method on the controller. The operation field dispatches it and the controller wraps it in error handling.
Bootstrap 5 is loaded and used directly (modals, dropdowns, toasts, tooltips). jQuery is present for older code but unused here.
Structure
Where Each Piece Lives
$page_scripts and $modals. This file is the load order.
Load Order and Its Trap
The footer prints them in this order. Constants block, table controller, enhanced select library, default.js, the Bootstrap bundle, your page script, then alert.js and modal.js.
So a page script runs before the alert and modal libraries exist. Declare functions freely, but do the work in a handler.
Adding Your Own Script
Four template variables are read by the layout, two by the head and two by the footer. A tab file further down the page can still append to the footer pair.
['datepicker', 'apexcharts', 'pdf-export']. The head loads the styles, the footer the scripts. Anything not listed is not on the page.
$clink = $links["controller"] ?? '';
$plugins = ['datepicker'];
// Text that JavaScript will assign to textContent or to an input value must arrive as
// JSON, not as HTML entities. See the pitfall at the end of this article.
$L = Utility::jencode([
'records' => Language::gc("admin/widgets/records"),
'confirm' => Language::g("needs/confirm-action-ok"),
]);
$page_scripts = <<<HTML
<script>
const clink = '{$clink}';
const L = {$L};
</script>
HTML;
include $template_dir . "inc" . DS . "head.php";
Reference
WcpRequest
The one call every operation goes through. It returns the promise chain, so you can await it, but the useful hooks are the callbacks.
function WcpRequest(url, preferences) // returns a Promise
WcpRequest(clink, {
method: 'POST',
data: { operation: 'update_widget', id: 5, name: 'Example' },
button: btn,
afterDone: (response) => WCPTable.get('widgetList')?.reload(),
});
The helper builds the body itself: a nested object becomes name[key], an array becomes name[0], and a File value is appended whole.
Every request also carries X-Requested-With: XMLHttpRequest. The dispatcher demands it and answers 403 without it. A plain ?operation= link or a hand-rolled fetch cannot run an operation.
Options Object Keys
'GET'. Anything else sends the built form data as the body; GET turns the same set into a query string on the address.
FormData: see the pitfall below. Nested objects, arrays and File values are all handled.
options.headers are kept; X-Requested-With is filled in only when you did not set it. A non-empty options.body makes data ignored entirely.
data-loader attribute first, then buttonLoader, then the bare spinner. A button with a direct icon child and a label keeps that label when the loader itself carries no text: only the icon is swapped. Otherwise the whole content is replaced, and the original markup is restored between afterDone and finally. Inside a row menu the button also keeps that menu open, and the menu closes two seconds after the answer lands.
saving_loader and friends), not hand-written spinner markup.
'json' (default), 'text' or 'blob'. A JSON content type from the server wins over this, so an operation's answer is always parsed.
false reads the redirected response as a normal answer.
'_self' (default) or '_blank'. With '_blank', a redirect in the answer opens a tab instead of navigating, and only done runs afterwards.
alert_toast sets both at once. The server can override the success side by returning its own successToast.
Response before the body is read. For headers and status, not for data.
Error and replaces the automatic error alert. Both a network failure and an answer whose status is not successful arrive here.
fail.
What the Answer May Contain
These are the keys the automatic handling reads. Anything else reaches afterDone untouched, which is how a call site gets data back.
| Key | Value | What the helper does with it |
|---|---|---|
status | "successful" or anything else | The switch. Anything other than "successful" turns message into a thrown error. The failure path is the same for a refused operation and a dead network. |
message | text | On success, shown as an overlay notice (or a toast with successToast). On failure, shown as the error. Treated as markup, so escape anything a person typed. |
redirect | address, "reload" or "script" | Navigates, reloads the page, or evaluates script. "script" is only honoured when a message is present as well. On its own it is treated as an address, and the page navigates to a page named "script". |
redirect_delay | milliseconds | How long the notice stays before the redirect fires. Default 5000 with a message, 1 without one. |
script | JavaScript source | Evaluated when there is no message and no redirect. A last resort; prefer returning data and acting on it in afterDone. |
successToast | boolean | Lets the operation choose the small toast over the overlay, overriding the call site. |
Modals
Never write dialog markup by hand. Four openers cover every case and all build the same shell. Its header close button means a footer never carries a Close or Cancel button.
// Generic dialog. First argument is a DOM id or an element; an unknown id creates one.
function open_modal(modal_id, options)
function close_modal(modal_id) // id or element
function destroy_modal(modal_id) // disposes the instance and removes the element
// Confirmation. Built, shown, and removed from the document when it hides.
function confirmModal(options = {}) function confirmModalClose()
function confirmDeleteModal(options = {}) function confirmDeleteModalClose()
function actionModal(options = {}) function actionModalClose()
// ^ the only one that returns a handle: { modal, modalElement, confirmBtn }
// The other two return nothing; use their closers.
// The header class for a tone. Anything containing "danger" gives a red title,
// everything else the primary one. Use it if you rewrite a header at runtime.
function wcpModalHeaderClass(tone)
The Three Simpler Dialogs
open_modal: heading, content markup and footer markup. An empty footer renders no footer element at all, which is what a read-only dialog wants.
'p-4'), a width class such as 'modal-lg', extra footer classes, and an explicit maximum width like '600px'.
bgClass only picks the header tone now, it no longer paints anything; textClass is still accepted and never rendered. backdrop takes 'true', 'false' or 'static'; leave it alone unless work would be lost by closing. onClose fires once, after the dialog has hidden.
open_modal reads data-bs-modal-title (or data-izimodal-title), data-bs-modal-bg-class, data-bs-modal-text-class and data-bs-modal-width off the element after merging your options. An attribute left on the markup quietly wins over the value you passed.
open_modal takes its id as the first argument instead.
confirmModal only. A function is bound to the click. A string is handed to eval immediately, while the dialog is being built, not on the click. confirmButtonOnClick is written into the button's inline onclick instead. Give neither and the button only closes the dialog, which is the only case where closing is automatic.
confirmModal only. The three texts default to the translated constants confirmActionTitle, confirmActionMsg and confirmActionOk, so a plain confirmation needs no strings at all. The two classes default to 'bg-danger' and 'btn-danger'. The last one is a raw attribute string appended to the confirm button.
confirmDeleteModal takes three. The name of the thing being deleted, a line of consequence under it, and any extra markup appended below the centred block.
confirmDeleteModal: the large icon and its tone, the confirm button's label (default confirmActionOk), class and icon, and the dialog width (default '420px'). ? buttonClass doubles as the header tone. The shell reads it to choose between a red title and the primary one.
confirmDeleteModal: the callback receives the confirm button, ready to hand to WcpRequest. buttonLoader is written onto that button as its data-loader. Supplying onConfirm means you close the dialog.
actionModal
The dialog for anything that touches several records or cannot be undone, a single delete included.
action ('delete', 'active', 'suspended', 'cancelled') is stored on the dialog but never read. It is the call site's own switch, so the tone still has to arrive through headerClass. headerTextClass and cancelText are accepted and never shown either.
showIcon: false and leave both lines empty.
serviceInfoRows is a list of { label, value, valueClass }. When given, it replaces the built-in pair. Leave it empty and the panel prints those two rows from the four label and name options instead.
selectedCountText; selectedCount is accepted but never rendered, so passing only the count leaves the panel blank.
apiSwitchChecked says otherwise. Turn it off for records with no module behind them, otherwise the operator is offered a choice that does nothing.
confirmActionRequirePassword constant as the markup; the field inside it carries the id the dialog reads, #confirmPassword. While that field is empty the confirm button stays disabled. Markup without it silently gets neither the guard nor a value in the callback.
The actionModal Callback Context
onConfirm receives one object, not a button and a modal as two arguments.
onConfirm: (ctx) => {
ctx.button; // the confirm button element (pass it to WcpRequest as `button`)
ctx.modal; // the Bootstrap Modal instance
ctx.modalElement; // the dialog's root element
ctx.applyApi; // boolean: state of the provider switch, false when it is hidden
ctx.password; // string: what was typed in the password field, '' when hidden
ctx.data; // exactly the object passed in as `data`
ctx.setLoading(true); // disable + spinner + processingText
ctx.setLoading(true, 'Deleting'); // ...with your own label
ctx.setLoading(false); // restore icon + confirmText
ctx.close(); // hide the dialog; nothing closes it for you
}
Use ctx.setLoading or button: ctx.button, not both: they both own the button's contents and the second write wins.
Tables
Every list is driven by a controller instance you can reach by name. The component, its presets and its filters have their own article.
const table = WCPTable.get('widgetList'); // null when the list is not on the page
const same = document.querySelector('.wcp-table[data-name="widgetList"]').wcp;
table.reload(url) // refetch; the optional address replaces the data address
table.activate() // first load of a deferred list; a no-op on a normal one
table.resetFilters() // clear every bound control, drop the filters from the address, reload
table.hasActiveCustomFilters() // boolean
table.addRow(rowHtml, prepend = false) // markup or a <tr> element
table.exportUrl('csv') // download address carrying the current filters, search and sort
// removeRow has three shapes. All three update the counts and repaint at once,
// without a round trip. A server-paged list refetches once afterwards to backfill.
table.removeRow('id', '5') // column key + the cell's exact text
table.removeRow((tr) => tr.dataset.uid === u) // a predicate over the row element
table.removeRow('example.com') // any cell whose text matches
// Useful state
table.name · table.ajax · table.isPartial · table.currentPage
table.perPage · table.searchQuery · table.totalEntries · table.totalFilter
Two events are raised, each on two targets: the instance itself and a twin on the document.
| Event | Where to listen | What detail carries |
|---|---|---|
ajax-load-after | the instance | table, name, isPartial, totalEntries, filteredData |
wcp-table-ajax-load-after | document | the same object |
table-update-after | the instance | table, name, currentPage, filteredData, animated |
wcp-table-update-after | document | the same object |
The twin lets a listener bind before the list exists. Binding to the wrapper element silently never fires; bind to WCPTable.get(name) or to the document and filter on detail.name.
Alerts and Toasts
// Overlay notice: one at a time, further calls queue behind it.
// Both take the same options object, and the same defaults.
alert_success(message, options = {}) // options: { timer: 3000, onClose: null }
alert_error(message, options = {}) // error does NOT default to a longer timer
alert_error(message, { timer: 0 }) // 0 = no timer, stays until it is dismissed
// The small corner toast, ready-made.
alert_success_toast(message) // 4 seconds
alert_error_toast(message) // 6 seconds
// The toast in full.
createStackedToast({
message, // markup, so escape anything a person typed
type: 'info', // success | error | info | warning; anything else falls back to info
icon: 'bi bi-info-circle', // only used by the titled form
autoHide: 10000, // milliseconds; 0 keeps it until it is closed
animation: true,
title: '', // empty = compact one-line form
subtitle: '', // a timestamp here also gets a relative-time tooltip
})
The compact form is dismissed by clicking anywhere on it; the titled form closes only from its own button. The stack sits bottom right and holds four toasts; a fifth closes the oldest. There is no position option.
Pass no title or subtitle unless you have a translated one. The defaults are empty so an untranslated heading is never printed.
Global Constants
Printed by the footer before any script runs, so they are available everywhere. They are const bindings, not window properties. The loaders are also mirrored onto window, so they resolve by name.
passwordHtml rather than writing a field yourself.
enabling_, disabling_, sending_, checking_, approving_, activating_, cancelling_, suspending_, downloading_, applying_, redirecting_ and formSubmit_loader.
{label} you replace: label_loader.replace('{label}', importingText).
Helper Functions
escHtml before it reaches innerHTML or an attribute.
symbol defaults to true). Read a typed amount back into a number whatever the separators, and convert between codes at the loaded rates.
null when absent, '' when present and empty. Return a new address with a value set or removed. None of them navigate.
data-required goes on the button and holds the comma-separated ids of the inputs. Putting it on the input instead leaves the button disabled forever.
Example
A row delete takes three pieces: the JavaScript that asks, the operation that answers, and the row that disappears without a reload.
function deleteWidget(id, name) {
confirmDeleteModal({
message: escHtml(name),
description: L.deleteWarning,
buttonLoader: deleting_loader,
// The callback is handed the confirm button, which is exactly what the request
// wants: it disables it, shows the loader on it, and re-enables it at the end.
onConfirm: (btn) => WcpRequest(clink, {
method: 'POST',
data: { operation: 'delete_widget', id: id },
button: btn,
// The success notice is small: a delete does not deserve a full overlay.
successToast: true,
// afterDone runs AFTER the automatic handling, so the message has been shown
// and an error has already been reported. Nothing here runs on failure.
afterDone: () => {
confirmDeleteModalClose();
WCPTable.get('widgetList')?.removeRow('id', String(id));
},
}),
});
}
public function delete_widget(Operation $operation): bool
{
$operation->demo();
$id = (int) Filter::init("POST/id", "rnumbers");
if (!$id) throw new Exception(Language::gc("admin/widgets/error-id-required"));
// A thrown exception becomes {"status":"error","message":"..."} on its own, which is
// the shape the helper turns back into an error notice. No error branch is needed.
if (!$this->model->delete($id))
throw new Exception(Language::gc("admin/widgets/error-delete-failed"));
// Deliberately no redirect: the page removes the row itself, so reloading would cost
// a round trip and lose the operator's place in the list.
return $operation->output([
'status' => "successful",
'message' => Language::gc("admin/widgets/deleted"),
]);
}
The same list in bulk. The dialog owns the button, so the request is not given one.
function bulkWidgets(action, ids) {
actionModal({
action: action,
title: L.bulkTitle,
headerClass: action === 'delete' ? 'bg-danger' : 'bg-success',
// selectedCountText is what gets printed. selectedCount alone renders nothing.
selectedCount: ids.length,
selectedCountText: ids.length + ' ' + L.records,
info1Title: L.permanentTitle, info1Desc: L.permanentDesc,
info2Title: L.providerTitle, info2Desc: L.providerDesc,
// Offer the provider switch only when at least one row has a module behind it.
showApiSwitch: ids.some(hasModule),
// Ask for the password once more than one record is at stake.
showPassword: ids.length > 1,
passwordHtml: confirmActionRequirePassword,
confirmText: L.confirm,
data: { ids: ids },
onConfirm: (ctx) => {
ctx.setLoading(true);
WcpRequest(clink, {
method: 'POST',
data: {
operation: 'bulk_actions',
action: action,
id: ctx.data.ids, // an array arrives as id[0], id[1], ...
apply_on_module: ctx.applyApi ? 1 : 0,
password: ctx.password,
},
afterDone: () => {
ctx.setLoading(false);
ctx.close(); // nothing closes it for you
WCPTable.get('widgetList')?.reload();
},
afterFail: () => ctx.setLoading(false),
});
},
});
}
Pitfalls
The helper walks the object's own properties, and a FormData instance has none. The request goes out carrying nothing, not even the operation name. The server answers with ordinary markup, console clean. Pass a plain object, or put a prepared body in options.body.
done means no success notice, no redirect and no error alert; you write each again at the call site. Extra work belongs in afterDone.
The dialogs bind an automatic close only when no callback was given. Forget it and the record is gone but the dialog stays on screen, with no error. Close it in afterDone, not on the click.
The original markup is put back between the two, so a label written in afterDone is silently erased. The colour changes and the text does not. Attributes survive; only the contents are restored.
Encoding a translation into HTML entities is right for innerHTML and wrong for everything else. textContent and an input's value do not decode, so the operator reads Düzenle. Hand such strings to JavaScript as JSON.
onclick="fn(' + JSON.stringify(v) + ')" puts JSON's double quotes inside a double-quoted attribute. The browser ends the attribute there and the handler is never bound, silently. Escape it (escHtml(JSON.stringify(v))) or set the attribute on the element.
The footer prints those two after yours. Defining functions is fine; calling open_modal or alert_success at parse time throws. Put the work inside a handler.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.