Extend the admin UI
The admin UI is built from plugins, and so are its own features: the screens of entities, relations, the overlay, history and subscriptions. A plugin of yours adds to the same places, or replaces what's there, without forking the admin. Small tweaks, eg a button that opens a product in the ERP, can come from a browser extension instead.
There are two contracts:
- Plugins, written in React with
@commerce-mind/vox-admin, delivered as an npm package or a browser extension. window.vox, for scripts on the page: the state of the screen, its events and actions, and plain DOM elements in the slots.
The example in src/examples/erp-plugin adds an "Open in ERP" button to products and the margin next to their price,
and is delivered both ways.
Write a plugin
A plugin is a plain object:
import { defineVoxPlugin, voxPluginApiVersion, type VoxEntitySlotContext } from '@commerce-mind/vox-admin';
import { Button } from '@commerce-mind/vox-design-system';
function OpenInErp({ entityType, id }: VoxEntitySlotContext) {
return (
<Button outline href={`https://erp.example.com/${entityType.name}/${id}`} target="_blank">
Open in ERP
</Button>
);
}
export const erpPlugin = defineVoxPlugin({
id: 'acme.erp',
apiVersion: voxPluginApiVersion,
slots: {
'entity.header.actions': [
{ id: 'open-in-erp', component: OpenInErp, when: ({ entityType }) => entityType.name === 'Product' },
],
},
});
What a plugin can add:
| Field | What |
|---|---|
slots | components in named places on the screens, see below |
routes | screens inside the shell, eg { path: 'price-rules', element: <PriceRules /> } |
navigation | items of the sidebar, in sections, ordered by order |
fieldEditors | editors of fields, by editor hint ([UIHint]), path (Product.texts.name) or type |
valueCells | how values are shown in tables, by type or path |
views | views of lists, by the names [VoxView] uses |
icons | icons by the names [VoxIcon] uses |
providers | components that wrap the whole admin, eg a connection to your own backend |
formStore | where forms keep their values while they're edited, eg a store several people share |
richTextExtensions | Tiptap extensions of rich text fields |
The rules are the same for all of them:
- Order: plugins are added in order, and a contribution replaces one of an earlier plugin with the same key: the
same id in a slot, the same route path, or an editor of the same name. That's how a plugin replaces a built-in
feature, eg the Relations tab is
{ id: 'relations' }inentity.tabs. - Conditions:
when(context)decides where a contribution shows, from the entity type, the entity, the perspective and the user's permissions (can(permission)). - Failures: a contribution that throws is left out, with an error in the console, rather than breaking the screen.
- Versions:
apiVersionis the version of the plugin API the plugin is built for. The admin refuses a plugin of a version it doesn't support, with an error in the console. - A native look: use
@commerce-mind/vox-design-system, whose components and tokens are part of the contract.
Slots
Each slot gives its contributions a context as props: every slot has the user, list slots the entity type and the state of the list, and entity slots the entity type, id, entity, perspective, edit target and selected contexts.
| Slot | Where |
|---|---|
shell.providers | wraps the shell, inside the router |
shell.topbar, shell.navigation | the top of the content, the bottom of the sidebar |
shell.user-menu | items of the user menu |
shell.panel | panels at the right, eg comments, opened from the top bar |
list.toolbar, list.row.actions, list.empty | above a list, in each row of a table, below an empty list |
entity.header.actions | the buttons in the header of an entity |
entity.draft-bar, entity.sidebar | below the draft bar, sections of the side panel |
entity.tabs | tabs of an entity, with a label and a badge |
entity.form.before, entity.form.after | above and below the field groups |
entity.field-group.header | the header of a field group |
entity.field.adornment, entity.field.decorator | next to a field's label, and around its editor or value |
richtext.toolbar | buttons in the toolbar of a rich text field |
list.screen, entity.screen | replace the whole screen of an entity type or an entity |
Fields and forms
The field slots reach every field the admin shows: editable and read-only, nested in objects and lists, each language
of a varying value, rich text, and the fields of the create screen and of actions' dialogs. Their context has the
field's path in the form, eg texts.name.sv or variants[2].sku, its value, whether it's editable and the form's
store, so a plugin can anchor something to exactly that value.
A form keeps its values in a VoxFormStore: read and subscribe by path, set a value, the original values, what
changed, reset, and the events change and saved. A plugin's formStore replaces the admin's own store for the
forms its when allows, eg with a store that several people edit together. Saving is the same whichever store keeps
the values: the changes become one batch of actions.
Deliver a plugin as an npm package
Build the plugin as an ES module, with @commerce-mind/vox-admin, @commerce-mind/vox-client,
@commerce-mind/vox-design-system and React as peer dependencies, and add it to the admin after the built-in plugins:
const admin = createVoxAdmin({ vox, plugins: [...defaultPlugins, erpPlugin] });
This works in an admin you build, eg from src/admin-ui. The admin that Vox.UI serves doesn't load npm packages
yet: add-ons that bring their frontend in their .NET assembly come later.
window.vox
Scripts on the page of the admin get window.vox. It's set when the admin starts, which fires vox:ready on
window, and it gives the same state and actions as plugins get from useVoxAdmin().page. It never exposes access
tokens: requests go through Vox's client.
window.vox.apiVersion; // 1
window.vox.state; // { pathname, search, entityType, entityId, entity, perspective, selection, form, user }
const stop = window.vox.on('entity:loaded', ({ entityType, id, entity }) => {});
stop();
window.vox.navigate('/products');
window.vox.openEntity('Product', '1042');
await window.vox.refresh();
await window.vox.runAction('ScheduleCampaign', { startsAtUtc: '2026-11-01T00:00:00Z' });
window.vox.form.setValue('texts.name.sv', 'Kopp');
await window.vox.form.save();
window.vox.toast('Synced with the ERP');
window.vox.openPanel('comments');
- Events:
navigated,entity:loaded,entity:saved,selection:changedandform:changed. - Actions work where the admin can do them, eg saving where there's a form, and fail elsewhere: those that return
promises reject.
form.save()rejects if a value isn't valid, and the errors show on the screen.
DOM elements in slots
A script that doesn't use React adds plain DOM to the slots of the shell, lists and entities, and next to fields' labels:
window.vox.slots.mount('entity.header.actions', (element, context) => {
const button = document.createElement('button');
button.textContent = 'Open in ERP';
button.onclick = () => window.open(`https://erp.example.com/items/${context.id}`);
element.append(button);
return () => button.remove();
});
The admin renders an empty element for each, with data-vox-slot, data-vox-entity-type, data-vox-entity-id and,
for fields, data-vox-field-path, and never changes what's inside it. It mounts again in a new element when the
entity or field changes, and empties the element when it unmounts. mount returns a function that removes it.
Deliver a plugin as a browser extension
A browser extension can contain a full plugin, while experimenting or for a customer's own tweaks. It can do anything an npm plugin can, including replacing built-in screens, adding providers and replacing the form store.
The plugin must use the admin's own React and Vox packages, since its hooks read the admin's React context.
window.vox.modules has them: react, react/jsx-runtime, react-dom, @commerce-mind/vox-admin,
@commerce-mind/vox-client, @commerce-mind/vox-design-system, react-router and @tanstack/react-query.
Build it with the Vite preset
Write the plugin with normal imports, export it as the default export of an entry, and build it with
voxBrowserExtension. It maps the shared packages to window.vox.modules and wraps the script so that it registers
the plugin when the admin starts:
// vite.extension.config.ts
import { voxBrowserExtension } from '@commerce-mind/vox-admin/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), voxBrowserExtension({ entry: 'src/extension.ts' })],
build: { outDir: 'extension', emptyOutDir: false },
});
This builds extension/vox-plugin.js. The code of the plugin runs when the admin fires vox:init, so code at the top
of its modules can use the shared packages too.
Run it in the main world
A content script normally runs in an isolated world, which can't see the page's window. Run it in the page's main
world, and at document_start, so that it's there before the admin starts:
{
"manifest_version": 3,
"name": "Vox ERP example",
"version": "0.0.1",
"content_scripts": [
{
"matches": ["https://vox.example.com/*"],
"js": ["vox-plugin.js"],
"run_at": "document_start",
"world": "MAIN"
}
]
}
To try the example, run npm run build:extension in src/examples/erp-plugin, open chrome://extensions, turn on
Developer mode, choose Load unpacked and pick its extension folder. Its manifest matches localhost.
Without build tools
A script can register a plugin itself, in either of two ways. Both are synchronous, so the admin never waits:
// Before the admin starts
(window.voxPlugins ??= []).push(plugin);
// While the admin starts, with the shared modules
window.addEventListener('vox:init', ({ detail }) => {
const { createElement: h } = window.vox.modules['react'];
const { Button } = window.vox.modules['@commerce-mind/vox-design-system'];
detail.register(
window.vox.definePlugin({
id: 'acme.erp-link',
apiVersion: 1,
slots: {
'entity.header.actions': [
{
id: 'open-in-erp',
component: ({ id }) => h(Button, { outline: true, href: `https://erp.example.com/items/${id}` }, 'Open in ERP'),
},
],
},
}),
);
});
A plugin that arrives after the admin started is refused, with a warning in the console.
Turn it off
window.vox, its modules and the plugins of scripts on the page are on by default. Turn them all off with
ExposeWindowApi:
builder.Services.AddVox(...)
.AddAdminUI(options => options.ExposeWindowApi = false);
An admin you build takes createVoxAdmin({ exposeWindowApi: false }). Code in the page's main world can do anything on
the page anyway, so there's no separate setting for plugins from extensions.