Admin JavaScript Library

4 views Markdown

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

js/default.js The request helper, the enhanced select wrapper, the automatic form submit, and the small helpers (escaping, money, query strings, cookies). The largest of the four and the one you call most.
js/modal.js Every dialog: the generic opener plus the three confirmation dialogs and their closers.
js/alert.js The overlay notification (one at a time, queued) and the stacked toasts in the bottom right corner.
js/table.js The controller behind every list: paging, search, sorting, filters, reload and row removal.
inc/footer.php Prints the scripts, the translated constants and whatever the page put in $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.

$plugins Array of optional libraries to load, e.g. ['datepicker', 'apexcharts', 'pdf-export']. The head loads the styles, the footer the scripts. Anything not listed is not on the page.
$page_scripts Raw markup printed near the end of the footer. This is where a page's own script goes, and where server values are handed to it.
$page_styles The same idea for the head. Assign it before including the head or it never appears.
$modals Dialog markup, printed at the end of the body. Append to it, never overwrite: several dialogs on one page all share this one variable.
ui:admin.head.js Return markup and it appears at the very end of the footer. This is how a module that owns no admin template puts a script on every page.
ui:admin.body.end The last output of the document, after the one above. Use it for markup a script needs to find, such as a dialog shell.
the top of an admin template
$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.

signature
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

method HTTP method, default 'GET'. Anything else sends the built form data as the body; GET turns the same set into a query string on the address.
data A plain object of values to send. Not a ready FormData: see the pitfall below. Nested objects, arrays and File values are all handled.
options Native fetch init merged over the method, for headers, credentials, an abort signal. Your own 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.
button The element to disable and put a loader on for the length of the request. Also the double-click guard, which is the reason to pass it even when you do not want a spinner. The loader is taken from the element's own 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.
buttonLoader Loader markup. Use one of the translated constants (saving_loader and friends), not hand-written spinner markup.
responseType 'json' (default), 'text' or 'blob'. A JSON content type from the server wins over this, so an operation's answer is always parsed.
followRedirect Default true. When the server itself redirected the request, the browser is sent to the final address and nothing else runs. false reads the redirected response as a normal answer.
redirectTarget '_self' (default) or '_blank'. With '_blank', a redirect in the answer opens a tab instead of navigating, and only done runs afterwards.
successToast · errorToast · alert_toast Report through the small corner toast instead of the overlay notice. alert_toast sets both at once. The server can override the success side by returning its own successToast.
beforeDone Runs with the raw Response before the body is read. For headers and status, not for data.
done Receives the parsed answer and replaces the automatic handling entirely: no notice, no redirect, no error alert. Only for an answer you build yourself.
afterDone Receives the parsed answer after the automatic handling. This is the normal place for extra work: reload a table, remove a row, close a dialog.
fail Receives the Error and replaces the automatic error alert. Both a network failure and an answer whose status is not successful arrive here.
afterFail Runs after the error has been reported, whether by the automatic alert or by your own fail.
finally Runs on every path and takes no arguments. It is called after the button's original markup is restored, which makes it the only correct place to repaint that button.

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.

KeyValueWhat the helper does with it
status"successful" or anything elseThe 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.
messagetextOn 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.
redirectaddress, "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_delaymillisecondsHow long the notice stays before the redirect fires. Default 5000 with a message, 1 without one.
scriptJavaScript sourceEvaluated when there is no message and no redirect. A last resort; prefer returning data and acting on it in afterDone.
successToastbooleanLets 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.

templates/admin/js/modal.js
// 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)
title · body · footer 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.
bodyClass · modalDialogExtraClass · modalFooterExtraClass · width Body padding (default 'p-4'), a width class such as 'modal-lg', extra footer classes, and an explicit maximum width like '600px'.
bgClass · textClass · backdrop · onClose 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.
attributes on the element itself 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.
modalId The three built dialogs default to a random id and delete any element already carrying it before building. Pass your own only when something else has to find the dialog: the closers already track the last one opened. open_modal takes its id as the first argument instead.
confirmButtonClick · confirmButtonOnClick 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.
title · message · confirmButtonText · confirmButtonClass · headerClass · confirmButtonExtraAttributes 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.
message · description · content 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.
icon · iconColor · buttonText · buttonClass · buttonIcon · width 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.
onConfirm · buttonLoader 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 · title · headerClass · confirmText · confirmButtonClass · confirmButtonIcon The heading, the header tone and the confirm button's label, class and icon. ? 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 · icon · iconColor · confirmTitle · confirmSubtitle The large icon disc and the two lines under it. The disc is on by default. The whole centred block is dropped only when you pass showIcon: false and leave both lines empty.
showServiceInfo · serviceInfoRows · serviceLabel · serviceName · clientLabel · clientName A details panel above the consequences. 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.
showBulkInfo · selectedLabel · selectedCountText · selectedCount · countClass The "how many are selected" panel, on by default. ? The number printed is selectedCountText; selectedCount is accepted but never rendered, so passing only the count leaves the panel blank.
info1Title · info1Desc · info1Icon · info1IconColor (and the same four for info2) Two consequence boxes side by side. Fill one and it spans the full width; fill neither and the divider and the row are dropped.
showApiSwitch · apiSwitchLabel · apiSwitchDesc · apiSwitchChecked The "apply on the provider as well" switch. It is shown by default and starts unticked unless apiSwitchChecked says otherwise. Turn it off for records with no module behind them, otherwise the operator is offered a choice that does nothing.
showPassword · passwordHtml A password check before the action runs. Pass the shared 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.
onConfirm · onCancel · data · processingText The confirm callback. A callback that fires only when the dialog is closed without confirming. An arbitrary payload handed back to the callback, and the label shown while the button is loading.

The actionModal Callback Context

onConfirm receives one object, not a button and a modal as two arguments.

what onConfirm is given
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.

templates/admin/js/table.js
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.

EventWhere to listenWhat detail carries
ajax-load-afterthe instancetable, name, isPartial, totalEntries, filteredData
wcp-table-ajax-load-afterdocumentthe same object
table-update-afterthe instancetable, name, currentPage, filteredData, animated
wcp-table-update-afterdocumentthe 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

templates/admin/js/alert.js
// 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.

APP_URI · dashboard_link · admin_id · is_logged · is_rtl · lang_code The base address, the address the panel polls, and the signed-in operator. Whether anyone is signed in at all, the writing direction and the active language code.
themeDarkMode · themePrimaryColor · themeSecondaryColor · developerMode · resources_url Theme state and brand seeds, whether debugging output is on, and the address of the shared assets directory.
currency_formats · currency_ids · currency_rates · currency_default · currencies A sample formatted amount per code, the identifier and rate per code, the installation's own currency, and the list in one array. The money helpers read these; without them they cannot work.
addText · actionsLabel · noResultsFound · copyText · copiedText · select_search_text Translated labels for markup built in JavaScript, so a generated control is not the one English word on a translated page.
confirmActionTitle · confirmActionMsg · confirmActionOk · confirmDeletionTitle · confirmActionRequirePassword The defaults behind the confirmation dialogs, plus the ready markup for a password check. Pass that last one as passwordHtml rather than writing a field yourself.
saving_loader · adding_loader · creating_loader · updating_loader · deleting_loader · removing_loader … A spinner plus a translated verb. Also enabling_, disabling_, sending_, checking_, approving_, activating_, cancelling_, suspending_, downloading_, applying_, redirecting_ and formSubmit_loader.
spinner_loader · label_loader The bare spinner, and a template whose {label} you replace: label_loader.replace('{label}', importingText).
dateTimeFormat · timeAgoLabels · passwordChars · passwordMinLength The operator's date format and the relative-time words. Also this installation's password policy, so a generated password passes the check the form will run.

Helper Functions

escHtml(str) · html_entities(str) · strip_tags(html) Escaping for markup you build in JavaScript. Run every value that came from a person through escHtml before it reaches innerHTML or an attribute.
money_formatter(amount, currency, symbol) · money_deformatter(amount) · money_exChange(amount, from, to) Format an amount for a currency code (symbol defaults to true). Read a typed amount back into a number whatever the separators, and convert between codes at the loaded rates.
_GET(name, url) · set_GET(param, value, url) · remove_GET(param, url) Read one query value: null when absent, '' when present and empty. Return a new address with a value set or removed. None of them navigate.
watchRequired(btnSelector) Keeps a submit button disabled until every watched field has a value. ? 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.
setCookie(name, value, days) · getCookie(name) · in_array(needle, haystack) · base64_encode(str) · base64_decode(str) Small conveniences that behave the way their names suggest; the base64 pair is safe for non-ASCII text.
toggleDisableFormElements(wrapper, checkbox) · previewImage(input, imgId) Enable or disable a whole block of fields from one checkbox. Show a local preview of a chosen file before it is uploaded.
the wcp-form class Forms built by the form builder carry it and are submitted over AJAX automatically. You get change tracking, a disabled submit until something changes, the loader, upload progress and the pinned submit bar. Do not add a submit listener or call the request helper yourself on such a form.
the dashboard grid Dashboard cards are laid out by a packing library, which re-measures every card on each pass. A card whose height changes after the first paint repacks the whole board, visibly. Reserve the height in CSS for anything that fills in late, such as a chart.

Example

A row delete takes three pieces: the JavaScript that asks, the operation that answers, and the row that disappears without a reload.

the page script
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));
            },
        }),
    });
}
the operation that answers it
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.

a bulk action
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

Do not hand a ready FormData to data

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 switches the automatic handling off

done means no success notice, no redirect and no error alert; you write each again at the call site. Extra work belongs in afterDone.

Supplying onConfirm makes closing your job

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.

Repaint a button in finally

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.

Text going to textContent must not be HTML-encoded

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&uuml;zenle. Hand such strings to JavaScript as JSON.

Never put raw JSON inside an inline event attribute

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.

Your page script runs before alert.js and modal.js

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.