The CustomWidget lifecycle
The AMD module contract
script.js must call define() synchronously while it executes, and the factory must return a constructor function. The returned constructor is called with new, and the instance is expected to carry a callbacks object.
define(['jquery'], function ($) {
return function CustomWidget() {
this.callbacks = {
render: function (container) { return true },
init: function () { return true },
bind_actions: function () { return true },
initMenuPage: function (params) { return true },
settings: function ($modal) { return true },
onSave: function (settings) { return true },
destroy: function () { return true },
}
}
})The loader is deliberately minimal — it is not a general-purpose AMD implementation:
- The only dependency names that resolve are
'jquery'and'$', both to the jQuery 4 Bito ships. Every other name resolves toundefined— no CDN fetch, no module registry. - No named modules, no nested
require(), no module cache. - If the script loads but never calls
define(), ordefine()returns something that is not a function, the mount fails with an error card. - Widget scripts are loaded one at a time. A single
defineshim services one in-flight load, so two widgets on one screen load in sequence, not in parallel.
Nothing here is a sandbox. The script executes in the admin panel's origin; see the overview.
The callbacks
Every callback is optional. A missing callback is simply skipped — it is not an error.
| Callback | Receives | When it runs |
|---|---|---|
render | The DOM element your widget should render into | First, on mount |
init | — | After render |
bind_actions | — | After init |
initMenuPage | { location, item_code, subitem_code } | Left-menu pages only: after bind_actions, then again on each subitem switch — see below |
settings | The settings modal body, wrapped in the $ shim | When the user opens the widget's settings |
onSave | The collected settings object | When the user presses Save in that modal |
destroy | — | On unmount, always |
render → init → bind_actions run in that order on every mount, and stop early if the widget gets disabled part-way.
INFO
Like amoCRM, Bito hands settings a wrapped modal body — $modal is a collection object, not a raw DOM node, and it is the same $ your module's 'jquery' dependency resolves to. So $modal.find('input[name=api_token]').val() works as written, see jQuery compatibility.
What onSave receives
Bito looks for the first <form> inside the settings modal body and hands you its FormData entries as a plain object. If your settings markup has no <form>, you receive {} — build your form as a real <form> with named inputs.
onSave: function (settings) {
if (!settings.api_token) {
bito.notify({ message: bito.i18n('token_required'), severity: 'error' })
return false // blocks the save
}
return bito.settings.save(settings).then(function () { return true })
}initMenuPage
Runs only on left-menu pages — the widget_page surface. It receives the open page, the same object as bito.context.menu:
initMenuPage: function (params) {
// { location: "widget_page", item_code: "acme_crm", subitem_code: null }
// { location: "saleMain", item_code: "acme_crm", subitem_code: "order_sync" }
root.innerHTML = renderPage(params.subitem_code) // root: the container render() received
}- It is first called once, right after
bind_actions. - When the user opens another subitem of the same block (the same
location), Bito does not remount: your container and state stay,bito.context.menuis updated, andinitMenuPageis called again with the new object. Render the new page yourself. - If the widget does not define
initMenuPage, Bito remounts it on each subitem switch instead —destroy, thenrender→init→bind_actions— so a simple widget that readsbito.context.menuinrenderstill works. - Moving to another block or leaving the widget's pages unmounts the widget and runs
destroy. - A throw or rejection is caught, logged and counted toward the same two-throw budget as every other callback. The return value is ignored, and no error card is shown for it.
Failure containment
Every callback invocation is wrapped. Your widget failing must never white-screen the ERP, so:
- A callback that throws is caught and logged, and an error card is rendered in place of the widget. The widget keeps running.
- A second throw — of any callback, at any point — disables the widget for the rest of that mount. Nothing further is invoked.
- The flag lives on the mounted instance. Navigating away and back re-mounts the widget and resets the counter; it is not persisted.
A rejected promise counts exactly like a synchronous throw: callbacks are awaited.
onSave blocks the save
onSave is the one callback whose return value changes what the host does.
Your onSave | Result |
|---|---|
Returns false | The save is blocked with a readable message. This does not count toward the throw budget — returning false is the documented API working, not a bug. |
| Throws or rejects | The save is blocked, and it counts as a throw. |
| Takes longer than 30 seconds | The save is blocked with a timeout message, and it counts as a throw. |
| Returns anything else, or nothing | The save proceeds. |
If the widget is already disabled when the user presses Save, the save is blocked outright.
destroy always runs
destroy runs on unmount even after an earlier callback threw, and even once the widget is disabled — it is deliberately not gated on the disabled flag. Its own exceptions are swallowed and logged: teardown must never be the thing that breaks teardown.
Use it to clear timers, abort in-flight requests and detach anything you attached outside your own container. Nothing else cleans up after you — your style.css link and your <script> tag are removed, but global listeners, intervals and DOM you injected elsewhere are yours to undo.