Octibiz
Demo

Durchsucht Website und Dokumentation gemeinsam. Enter zeigt alle Treffer, Esc schließt.

Plugin interface

A plugin brings views of its own and hooks into existing ones. Both run through a single file: frontend/plugin.manifest.ts.

The manifest

The file exports an object. Every key is optional — a plugin without an interface leaves the file out entirely.

import type { OctibizPluginManifest } from '@/plugins/manifest'

const manifest: OctibizPluginManifest = {
  routes: [
    {
      path: 'acme-shipping',
      name: 'acme-shipping',
      component: () => import('./views/ListView.vue'),
      meta: { permission: 'acme.shipping.view' },
    },
  ],

  routeModules: { 'acme-shipping': 'acme-shipping' },

  localeLoaders: {
    de: () => import('./locales/de'),
    en: () => import('./locales/en'),
  },
}

export default manifest

The file is found at build time, from three sources: plugins in the source tree, those installed as a package, and those pulled in through the package manager. All three are merged into the same interface.

Four rules that are not negotiable

1. Core building blocks only through @/sdk. That is the stable surface. Deep imports into other core modules are rejected, because they break at the next rebuild. Additionally allowed are pure type imports of the manifest surface.

2. Components always as lazy loaders, that is () => import('./views/FooView.vue'). A direct import pulls the component into every user's initial bundle, even if they never open the module.

3. When merging, the first one wins. The core before the plugins, plugins in a stable order. A conflict produces a warning, not an abort: a faulty plugin must never take the interface apart.

4. No look of your own. There is a binding component catalogue. A plugin that defines its own buttons looks wrong after two system updates.

The most important keys

KeyWhat for
routesPages of your own, paths without a leading slash
navA new navigation group
navLinksA link into an existing group, including the core's
routeModulesModule lock for your own routes
settingsTilesTile in the settings
widgetsBuilding block for the home dashboard
viewExtensionsContributions to extension points in foreign views
columnExtensionsAdditional columns in foreign lists
filterExtensionsAdditional filters in foreign lists
viewOverridesReplace or hide a view
searchTypesObjects of your own in the global search
tagScopesObjects of your own as a target for tags
localeLoadersTranslations, see Translations
portalRoutes, portalNavContributions to the customer portal

The interface's module lock

routeModules: { 'acme-shipping': 'acme-shipping' }

On the left stands the route prefix, on the right the short name of the module switch without module..

Mixing up those two is a real mistake, and one that has become expensive. If the right-hand value does not hit an actual switch, it never appears in the list of disabled modules — and the lock therefore always considers the module enabled. It then never takes effect.

Extension points

The core renders named slots to which plugins attach contributions. Without a contribution the slot renders nothing.

In the code these are called slots: you will meet PluginSlot, the slot registry and slot names such as customer-detail.tabs. Same thing, two words.

viewExtensions: {
  'customer-detail.tabs': [
    {
      id: 'acmeShipping.customerShipments',
      component: () => import('./components/CustomerShipmentsTab.vue'),
      permission: 'acme.shipping.view',
      module: 'acme-shipping',
      order: 40,
      meta: { labelKey: 'acmeShipping.tab.shipments' },
    },
  ],
}

Every contribution is locked twice: through the permission and through the module switch. Both fail-closed. A disabled plugin disappears from the interface without the core view knowing about it.

The names follow a fixed scheme: <object>-detail.actions, .tabs, .panels for detail views, <object>-list.actions for lists. The complete list of all 84 slots with the values they hand over is in the extension point reference. It is generated from the code and therefore cannot go stale.

Columns, row actions, filters, form fields

Lists and dialogues have slots of their own with contracts of their own:

  • Columns through columnExtensions, the row is handed over.
  • Actions per row through viewExtensions on <list>.row-actions.
  • Filters through filterExtensions. The value flows into the same filter structure as a core

filter.

  • Form fields through viewExtensions on <object>-form.fields. The contribution reads and

writes the form model directly.

Replacing instead of extending

viewOverrides: {
  'customer-detail.panels': { mode: 'hide' },
}

An override is authoritative: it displaces every additive contribution at that slot. replace swaps in a component of your own, hide blanks it out.

That is a sharp tool. Two plugins that override the same slot do not get along — here again the first one wins.

The customer portal is locked differently

Portal contributions run through slots of their own and without the lock through permission and module. The portal has a login of its own; it knows neither the staff's permissions nor the list of disabled modules.

There the contribution locks itself: it calls its own endpoint and hides silently if that returns nothing. With the module disabled the endpoint answers with 404 anyway.

The stable interface surface

Everything a plugin needs from the core comes out of @/sdk: building blocks, form elements, data access, permission check, notifications, formatting.

Whatever is not in there is deliberately not part of the promise. If something is missing, that is a reason to extend the surface, not a reason for a deep import.

Load translations lazily, do not import them

localeLoaders: {
  de: () => import('./locales/de'),
  en: () => import('./locales/en'),
}

Not as a direct import. A measurement showed that static language files contributed 651 KB compressed to the first render, for languages most users never see. Details in Translations.

What goes wrong, and why

SymptomCause
The lock never takes effect, the module always counts as onIn routeModules the right-hand side holds the route segment instead of the switch short name
The contribution does not appearPermission missing, module off, or an override displaces it
The initial bundle is suddenly largeComponent or language file imported directly instead of loaded lazily
The build aborts with a rejected importDeep access into a core module instead of going through @/sdk
After a system update the plugin looks out of placeOutput elements of your own instead of the catalogue's building blocks

Next

plugin.frontend · Available from version 0.5.0