Tidenda

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-accent and text-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.

  1. Import shared libraries by their exact names, as listed in Shared libraries. The build leaves those imports for the application to resolve.

  2. 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.

  3. Check the build. List the libraries entry.js leaves to the application:

    grep -o 'from "[^"]*"' dist/entry.js | sort -u
    

    Every shared library the plugin uses belongs in that list. A library that ships React inside its own files needs replacing.

Release a new version

  1. Raise version in package.json. A published version never changes, so every release needs a new number.
  2. Build. hostApi in dist/manifest.json is the host SDK version the build used.
  3. 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.