Customizing the UI

Three tiers, from no code to your own build. Use the lowest one that does the job.

  1. Descriptor and settings. Fields, filters, actions, widgets, navigation and the Branding settings. No JavaScript.

  2. Runtime extensions. ES modules the admin loads at startup, adding cells, inputs, widget renderers and pages. JavaScript, no rebuild.

  3. Your own build. A Nuxt application extending the layer, overriding any of its files (Nuxt layer semantics: the same path in your application wins), registering global components named in app.config, and generating its own bundle; in hosted mode, point VECTOR_UI_DIR at it (Deployment).

Application config

A Nuxt application extending @kinetis/vector-ui sets:

// app/app.config.ts
export default defineAppConfig({
    vector: {
        brand: { title: 'My Admin' },  // before the Branding settings load
        basePath: '',                  // a router prefix inside the app
        commandPalette: true,          // Ctrl/⌘+K search
        usersResource: 'users',        // enables Impersonate on those records
        idleMinutes: 30,               // sign out after this long unused; 0 never
        fields: {}, inputs: {}, widgets: {}, // component overrides
        locale: 'en', messages: {},    // see Languages
        pages: {},                     // custom pages, by name
    },
})

Cells, inputs and widget renderers

Three registries decide what renders a value: cells (lists and record pages, the layer’s VxCell/*), inputs (forms, VxInput/*) and widget renderers (VxWidget/*). The app.config overrides are globally registered component names, keyed by field type, by a field’s component name or by widget type. For a field, the lookup order is:

  1. an app.config override by the field’s component('name');

  2. a runtime extension registered under that name;

  3. an app.config override by field type;

  4. the built-in for the type.

A cell receives value, field, record, resource and context (index or show). An input receives modelValue, field, rules, errorMessages, record and resource and emits update:modelValue. A widget renderer receives data and widget.

Runtime extensions

An extension is a plain ES module. List it in VECTOR_UI_EXTENSIONS (hosted) or runtimeConfig.public.vector.extensions (standalone). Its default export receives window.Vector, which hands it Vue, a curated set of Vuetify components (VAlert, VAutocomplete, VBtn, VCard, VCheckbox, VChip, VIcon, VProgressLinear, VRating, VSelect, VSwitch, VTable, VTextField, VTextarea) and the registration functions. For a table, render VTable with your thead and tbody as its content: Vuetify’s table styles belong to its wrapper, so the class on a bare <table> leaves the columns unaligned.

export default function ({ vue, vuetify, defineCell }) {
    defineCell('stock-level', {
        props: { value: {}, field: {}, record: {}, resource: {}, context: {} },
        setup: props => () => vue.h(vuetify.VChip, { size: 'small' }, () => String(props.value)),
    })
}
Number::make('stock')->component('stock-level'),

An extension is served under public/, same-origin or CORS-enabled. The demo’s public/vector-extensions/stock-level.js is this one.

window.Vector also offers defineInput, defineWidget, definePage, defineMessages, defineRichTextExtension, api (the admin’s API client, with its credentials), notify, navigate, t (the admin’s translator) and tiptap (Node, Mark, Extension, mergeAttributes, to build a rich-text node without a bundler).

Rich-text extensions

A node or mark type — a callout, an embed — joins the rich-text schema on both sides:

export default function ({ tiptap, defineRichTextExtension }) {
    defineRichTextExtension('callout', tiptap.Node.create({
        name: 'callout', group: 'block', content: 'paragraph+',
        addAttributes: () => ({ tone: { default: 'info' } }),
        parseHTML: () => [{ tag: 'aside[data-callout]' }],
        renderHTML: ({ HTMLAttributes }) => ['aside', tiptap.mergeAttributes(HTMLAttributes, { 'data-callout': '' }), 0],
    }))
}
$app->instance(Document::class, (new Document())->extend(new NodeSpec('callout', ['tone'])));

Bind the extended Document in bootstrap.php. A NodeSpec may name its own HTML renderer for Document::toHtml() (new NodeSpec('callout', ['tone'], renderer: CalloutNode::class)). A field then lists callout in its features(). The server accepts the node only once registered, so both sides agree.

Custom pages

A custom page is a menu entry and a route, /p/{name}:

export default function ({ vue, definePage }) {
    definePage('stock-overview', {
        title: 'Stock overview', icon: 'mdi-warehouse', group: 'Catalog',
        component: {
            props: { vector: Object },
            setup(props) {
                const data = vue.ref(null)
                vue.onMounted(async () => { data.value = await props.vector.api('/stock-overview') })
                return () => vue.h('pre', JSON.stringify(data.value, null, 2))
            },
        },
    })
}

group places the page under a menu section (a resource group’s name, or its own); without one it goes under Pages. The component receives one prop, vector, with api (the admin’s API client, carrying the bearer token or the session cookie and CSRF token), notify.success/error and navigate(path). An application extending the layer can instead list the page in app.config vector.pages, naming a component it registered globally: { stock: { title: 'Stock', icon: 'mdi-warehouse', component: 'StockPage' } }.

An endpoint the page calls is your own code: put it in the vector middleware group (#[Middleware('@vector')]) and the admin’s authentication and access policy apply to it as to Vector’s own routes.

Forms in your own pages

VxResourceForm is the generated form. It tracks unsaved changes (dirty, exposed with reset()), and with variants — as the create and edit pages use it — offers the save variants (back to the record, continue editing, add another), remembers the last one as the user preference form.saveVariant, and saves with Ctrl/⌘+S. saved emits the record and the variant. The pages guard leaving a changed form through useLeaveGuard(), the same confirm dialog the list’s unsaved inline edits use.

Theming

The layer sets Vuetify’s defaults and both themes in its own nuxt.config.ts; your application overrides any of them. At runtime, the Branding settings apply over them.

Safety

Script authority is the deployer’s: VECTOR_SHELL_HEAD and the extension list are configuration. No setting, field or descriptor value is ever rendered as markup or executed.

See also