# Admin JavaScript Library

https://dev.wisecp.com/es/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

- **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.

```php
$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.

```js
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.

| 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.

```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)
```

### The Three Simpler Dialogs

- **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.

```js
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.

```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.

| 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

```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.

```js
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));
            },
        }),
    });
}
```

```php
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.

```js
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.

## Related Articles

- [Interface Components](https://dev.wisecp.com/en/interface-components)
- [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder)
- [Operations](https://dev.wisecp.com/en/operations)
- [Views and Templates](https://dev.wisecp.com/en/views-and-templates)
- [Adding a Dashboard Widget](https://dev.wisecp.com/en/adding-a-dashboard-widget)
