Skip to main content

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:

FieldWhat
slotscomponents in named places on the screens, see below
routesscreens inside the shell, eg { path: 'price-rules', element: <PriceRules /> }
navigationitems of the sidebar, in sections, ordered by order
fieldEditorseditors of fields, by editor hint ([UIHint]), path (Product.texts.name) or type
valueCellshow values are shown in tables, by type or path
viewsviews of lists, by the names [VoxView] uses
iconsicons by the names [VoxIcon] uses
providerscomponents that wrap the whole admin, eg a connection to your own backend
formStorewhere forms keep their values while they're edited, eg a store several people share
richTextExtensionsTiptap 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' } in entity.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: apiVersion is 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.

SlotWhere
shell.providerswraps the shell, inside the router
shell.topbar, shell.navigationthe top of the content, the bottom of the sidebar
shell.user-menuitems of the user menu
shell.panelpanels at the right, eg comments, opened from the top bar
list.toolbar, list.row.actions, list.emptyabove a list, in each row of a table, below an empty list
entity.header.actionsthe buttons in the header of an entity
entity.draft-bar, entity.sidebarbelow the draft bar, sections of the side panel
entity.tabstabs of an entity, with a label and a badge
entity.form.before, entity.form.afterabove and below the field groups
entity.field-group.headerthe header of a field group
entity.field.adornment, entity.field.decoratornext to a field's label, and around its editor or value
richtext.toolbarbuttons in the toolbar of a rich text field
list.screen, entity.screenreplace 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:changed and form: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.