Plugin recipes
Settings sections, bulk operations, icons, styling, one copy of React, releases and troubleshooting.
On this page
Short recipes for plugin tasks beyond the first one. Each assumes a plugin set up as in Your first plugin; exact signatures are in the Plugin reference.
Add a settings section
A settings section is a route, an activity gated on a permission, and
a SETTINGS_MENU item. Inside register():
const WEATHER_SETTINGS = 'com.example.weather.settings'
registerRoute({
path: '/settings/weather',
component: WeatherSettings,
meta: { type: 'weather-settings' }
})
intentRegistry.register({
id: 'com.example.weather.settings',
filter: {
action: WEATHER_SETTINGS,
when: { all: [{ key: 'can.settings:manage', eq: true }] }
},
route: '/settings/weather',
activeMatch: 'weather-settings'
})
registerActionPresentation(WEATHER_SETTINGS, { label: 'Weather' })
registerMenuItem(SETTINGS_MENU, {
id: 'com.example.weather.settings',
intent: { action: WEATHER_SETTINGS },
group: 'system'
})
group is organisation, system or workspace. Gate once, on the
activity: the menu item appears only when its action opens something
for the user. The gate only hides; the server checks permissions on
every change the section makes.
Add an operation on selected documents
Register the operation with multiple: true so it accepts several
documents:
const MARK_USABLE = 'com.example.desk.mark-usable'
intentRegistry.register({
id: 'com.example.desk.mark-usable',
filter: {
action: MARK_USABLE,
types: ['core/article'],
multiple: true,
when: { all: [{ key: 'can.document:write', eq: true }] }
},
operation: () => import('./markUsable')
})
registerActionPresentation(MARK_USABLE, { label: 'Mark as usable' })
The implementation, src/markUsable.ts, loads on first use:
import { DOC_TRANSITION, exec, type OperationFn } from '@dotvoid/void-host-sdk'
const markUsable: OperationFn = async (subjects) => {
const perRow = await Promise.all(subjects.map(async (subject) => {
const result = await exec({ action: DOC_TRANSITION, subject, extras: { to: 'usable' } })
return { id: subject.id, ok: result?.ok === true, error: result?.error }
}))
return { ok: perRow.every((row) => row.ok), perRow }
}
export default markUsable
Fire it from the plugin's own view, with the documents the user has selected:
const exec = useExec()
const onMarkUsable = async (selected: DocRef[]) => {
const result = await exec({ action: MARK_USABLE, subject: selected })
if (result && !result.ok) setError('Some articles were not changed.')
}
Document menus never list operations; to offer one there, register
an expansion. ok from DOC_TRANSITION means the request was sent:
the server applies only transitions the workflow allows the user.
Use a custom icon
Any lucide-react icon works as it is. A hand-drawn icon accepts
size and strokeWidth, draws on a 24-unit grid and strokes in
currentColor:
import type { SVGProps } from 'react'
type Props = Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> & { size?: number | string }
export const SunIcon = ({ size = 24, strokeWidth = 2, ...props }: Props) => (
<svg
viewBox='0 0 24 24'
width={size}
height={size}
fill='none'
stroke='currentColor'
strokeWidth={strokeWidth}
{...props}
>
<circle cx={12} cy={12} r={4} />
<path d='M12 2v2M12 20v2M2 12h2M20 12h2' />
</svg>
)
Pass it as icon to registerActionPresentation.
Styling rules
- Use the theme's names, such as
text-muted,text-subtle,bg-surface,text-accentandtext-danger-ink, rather than fixed colours. They follow the light and dark modes. - Write class names whole. Classes are found by reading the
source, so
`text-${tone}`produces nothing. Use a lookup of complete names. - Scope plain CSS under a class of the plugin's own. A rule without one applies to the whole application.
- Never import a reset or a theme. The application already has them, and a second copy restyles it.
Avoid a second copy of React
A second copy of a shared library breaks silently: hooks fail, and the plugin sees none of the application's state.
Import shared libraries by their exact names, as listed in Shared libraries. The build leaves those imports for the application to resolve.
Never import a path inside one, such as
react-dom/server. The build stops with... is a path into <package>, which the host shares, but not one it provides.Check the build. List the libraries
entry.jsleaves to the application:grep -o 'from "[^"]*"' dist/entry.js | sort -uEvery shared library the plugin uses belongs in that list. A library that ships React inside its own files needs replacing.
Release a new version
- Raise
versioninpackage.json. A published version never changes, so every release needs a new number. - Build.
hostApiindist/manifest.jsonis the host SDK version the build used. - Publish, as in Your first plugin.
Organisations are offered Update to the most recently published compatible version. Nothing changes for them until an administrator chooses it. There is no unpublish, and administrators cannot pick an older version: to undo a release, publish the earlier code under a new version number.
When the application moves to an interface version the plugin is not
compatible with, the plugin stops loading: update
@dotvoid/void-host-sdk, rebuild and publish.
Troubleshooting
The browser console has one [plugins] line per plugin when the
application starts, such as
[plugins] com.example.desk@1.0.0: skipped - built for host API 0.2.0, this host is 0.1.1.
| Symptom | Cause and fix |
|---|---|
The development server stops with has no "voidPlugin" block |
VOID_DEV_PLUGINS names a directory without the plugin's package.json. Relative paths start at the root of the checkout. |
skipped - built for host API ... |
Rebuild against the current host SDK and publish a new version. |
failed - entry exports no register() |
The entry module has no exported register. |
failed - [intents] unknown filter key "..." |
A filter has a field the application does not know. Check its spelling against the reference. |
Nothing happens, and the console says no handler for "..." |
The action name differs between the handler and the caller, a when condition fails, or several documents were passed to a handler without multiple. |
| "Invalid hook call", or a hook returns nothing | A second copy of React or the host SDK. See above. |
| A class has no effect | The class name is assembled from pieces, or @source "./" is missing. |
| The view shows "Extension failed" | The view threw an error. The console has it. |
Related pages
- Your first plugin: the scaffold these recipes build on.
- Plugin reference: every field, type, function and hook.
- Managing plugins: what administrators see.