Web side
// plugins/dev/web/index.tsx
import { lazy, Suspense } from 'react';
import { SquareKanban } from 'lucide-react';
import { defineWebPlugin, PageLoading } from '@desk/web/plugin-sdk';
import './dev.css';
const Board = lazy(() => import('./Board')); // its own chunk, loaded when opened
export default defineWebPlugin({
id: 'dev',
// Pages at /app/x/dev/… ('' is /app/x/dev).
routes: [{ path: '', element: <Suspense fallback={<PageLoading />}><Board /></Suspense> }],
// The work sidebar, under Plugins: the plugin by its name, with a count loaded like the inbox counts. Items below it
// are fixed (`items`) or loaded from the plugin's API (`itemsFrom: { url, pick }`, like a list of projects).
nav: { icon: SquareKanban, count: { url: '/api/v1/x/dev/count', pick: (d) => (d as { mine: number }).mine || undefined } },
// Tabs of the plugin's page in Settings (admins), at /app/admin/x/dev; an About tab is added.
settings: [{ path: 'columns', label: 'Board columns', element: <ColumnsSettings /> }],
// A tab on Reports, by the plugin's name; each report gets the period (`days`) the page shows.
reports: [{ id: 'tasks', label: 'Dev tasks', element: DevReportTab }],
// Labels for its audit log entries; without one the entry's sentence is shown.
auditLabels: { 'dev.column.added': 'Added a dev board column' },
// Sections in the case's side panel, and where they go (below).
casePanels: [{ id: 'tasks', element: CaseTasksPanel, order: 40 }],
// Actions in the case's menu: each opens its dialog.
caseActions: [{ id: 'create-dev-task', label: 'Create dev task…', icon: <SquareKanban />, dialog: CreateFromCase }],
// Actions in the inbox's bulk bar (from the incidents plugin): each opens its dialog with the selected cases.
// bulkActions: [{ id: 'link-incident', label: 'Link to incident', icon: <Siren />, dialog: BulkLink }],
// How the plugin's timeline entries look on the case.
timeline: {
task_done: (data, { author }) => ({ icon: <CircleCheck />, text: <>{author?.name} finished dev task {String(data.key)}</> }),
},
});
| Extension point | Gets | Notes |
|---|---|---|
routes | Under /app/x/<id>/. While the plugin is off the address shows "This page isn't available". | |
icon | Its icon on Settings → Plugins and its settings page, for a plugin without a sidebar item (otherwise nav.icon). | |
nav | One item under Plugins in the sidebar, by the plugin's name (from the manifest), with icon; it links to to (default /app/x/<id>). Items below it (items, itemsFrom) are indented; one with a ? in to is current when the address matches exactly. Counts and itemsFrom are refreshed when the page changes and every minute; call refreshPluginNav() (from the SDK) after a change they show, like a project starred, to refresh them at once. | |
settings | Tabs of the plugin's page in Settings, listed by its name in the Plugins group (admins). The page is there for every plugin that is on, with an About tab: version, permissions, webhook events, what it needs. Don't give the sections their own page header. | |
portalPages | { slug } | A public page on the portals at /p/<slug>/<path> (or /<path> on a portal's domain), linked from the portal when the server's ctx.portalPages lists it for that portal. |
reports | { days } | A tab on Reports by the plugin's name, with the period buttons (7, 30, 90 days). Use Stat, LineChart and ShareBar from the SDK, and reportPeriod and median on the server, so the numbers look and count like the desk's. |
auditLabels | <id>.<action> → a label for the audit log, also while the plugin is off. | |
casePanels | { case, version, onChanged } (CasePanelProps) | Sections in the case's side panel: { id, element, place?, order? } (see The side panel); a bare component goes under About, after the others. case is a CaseSummary (id, ref, subject, status, unit and organization, assignee, the request as text). version changes after one of your case actions finishes, to reload; call onChanged after the panel changed something the page shows. |
caseActions | { case, onClose, onDone } | The dialog; call onDone after a change, so panels and the case reload. |
bulkActions | { refs, cases, onClose, onDone } | A button in the inbox's bulk bar while rows are selected; it opens the dialog. refs are the selected cases' references, cases their ref, subject, status and unit. Do the work through your own route, within the access scope (skip cases the user can't see, as the desk's bulk actions do); toast the result, then call onDone, which reloads the inbox and clears the selection. |
timeline | (data, { author }) | data is what addTimelineEntry stored. |
inboxType | { q, unitId, assignee, compact, done, serviceId } (InboxListProps) | Another kind of work in the inbox's switch, by label; see Another kind of work in the inbox. |
approvals | { url }: your records on the Approvals page. GET {url}?for=me lists what waits for the user's approval and for=others the user's own records waiting for someone else's, as { items: ApprovalItem[] } (id, source, ref, title, question, waitingOn, href, askedAt). Only list what the user may see; the sidebar's Approvals count adds up for=me. | |
ruleTemplates | Suggested automations in New rule, under the plugin's name: { id, title, text, subject?, draft() }. subject is what it runs on (case, the default, or one of the plugin's rule subjects); draft() returns the rule's name, trigger, conditions and actions, with values left empty for the admin to pick. | |
slots | what the slot says | Parts on pages that have a slot for them, usually another plugin's (below). |
Another kind of work in the inbox
inboxType adds another kind of work to the inbox's switch, by label. Its props are
{ q, unitId, assignee, compact, done, serviceId } (InboxListProps).
| Part | What |
|---|---|
List | The full list (compact: false, with description under the title) or a short strip above the cases with All (compact: true; build it with the SDK's WorkStrip, as Change and Problem Management do). |
done | Set in Resolved and closed: show what is finished, most recent first. |
counts?: { url, pick } | Adds yours to the sidebar counts of All open, Assigned to me and Unassigned (pick returns { open, mine, unassigned }, any of them). |
q, unitId, serviceId | The inbox's search, unit and Service filter: narrow your list by them too. |
assignee | Whose work the view shows (me in Assigned to me, none in Unassigned, a user id from an assignee filter, or null), so narrow your list the same way (Incident Management reads it as the commander). |
[!TIP] Keep your own filters in the address under a prefix of your own (Incident Management uses
incStatus,incPriority, …), so they don't clash with the case filters.
The side panel
A case, an incident, a problem and a change keep their side panel in one order, so agents find things in the same place:
- what matters now (status, priority, who has it, when it's due, who asked), never folded;
- then what the record is about (service, account, linked records);
- then the rest, folded.
A plugin's part says where it goes with place and order:
place | Where |
|---|---|
now | Under the record's own first sections, for something as urgent as the status, like a major incident's roles. |
about | The default: the service, linked records, a status page. |
more | Folded at the bottom. |
order: lower first within its place, 100 when left out. The desk's own case panels use 10 (service), 20 (incident),
30 (problem) and 40 (dev tasks).
Build the part as a SideSection from the SDK: <SideSection id="dev.case" title="Dev tasks" count={n} actions={…}>.
Its heading folds it, and the fold is remembered per person under id (use <plugin>.<something>); sections in more
start folded. Outside a side panel (a tab, a dialog) a SideSection doesn't fold.
[!TIP] Return
nullwhen there is nothing to show, rather than an empty section.
Slots
A page can leave room for other plugins: <PluginSlot name="incidents.incident.side" props={…} /> (from the SDK)
renders the parts every plugin that is on gives for that name, each in its own error boundary; usePluginSlot(name)
returns them for pages that place them themselves (as tabs). A part is { id, label?, element, show?, place?, order? } (place
and order for side panels); its id becomes <plugin>.<id>.
// plugins/major/web/index.tsx
slots: {
'incidents.incident.tab': [{ id: 'room', label: 'Major incident', element: MajorRoom, show: (p) => p.incident.tags.some((t) => t.id === 'major') }],
'incidents.incident.side': [{ id: 'side', element: MajorSide, place: 'now', order: 10 }],
},
Incident Management's slots
Incident Management's incident page has these slots. Their props are IncidentSlotProps
(plugins/incidents/web/slots.ts): the incident, reload() and openTab(id).
| Slot | Where |
|---|---|
incidents.incident.header | Under the status steps. |
incidents.incident.tab | Beside Timeline, Linked cases and Review; the page opens on ?tab=<plugin>.<id>. |
incidents.incident.side | In the side panel, by place and order, as in the side panel. |
incidents.incident.review | Under the review. |
incidents.incident.menu | Items in its … menu, which also get close. |
Its dialogs have slots for fields that go into their request; what a part sets is sent with the dialog's own fields. The Status Page plugin uses both.
| Slot | Dialog | Props |
|---|---|---|
incidents.declare.fields | Declare incident | DeclareSlotProps: orgId, title, values, set(patch) |
incidents.update.options | Send update | UpdateSlotProps: the incident, the preview, values, set(patch) |
The incident lists (the Incidents page and the inbox's Incidents type) have incidents.list.bulk: buttons in the bar
that shows while incidents are selected (a <Button size="sm" variant="ghost">), as Problem Management's Link to
problem. Their props are IncidentBulkProps: the selected incidents (their rows), clear() to clear the
selection, and reload().
[!NOTE] With no part for the
incidents.list.bulkslot, rows can't be selected.
The desk's own slots
| Slot | Where | Props | Used by |
|---|---|---|---|
desk.account.main | The account page, under its contacts. | AccountSlotProps: the account's id, name and orgId | Service Configuration shows the services an account uses and its approvers. |
desk.unit.settings | Settings → Organization: cards under the selected unit's settings. | UnitSlotProps: { unit: { id, name, orgId, parentId } } | Service Configuration puts Services on cases. |
desk.list.filters | Every work list: filter pills next to the list's own. | ListFilterProps: list, the list's name (cases, incidents, major, problems, known-errors, changes), and get(param) and set(param, value) on the list's address | Service Configuration puts its Service filter there. |
A part in desk.unit.settings saves on its own and checks on the server who may change the unit (unit.settings).
The Service filter in desk.list.filters sets serviceId; each list's API takes it, and Clear filters clears
it. A list of your own can render the slot the same way (<PluginSlot name="desk.list.filters" props={…} />) when
its API takes serviceId.
Which plugins are on, and styling
The app learns which plugins are on from GET /api/v1/plugins (everyone signed in), once per sign-in and when an
admin turns one on or off; only the parts of plugins that are on are shown.
[!TIP] Use the app's CSS tokens (
apps/web/src/styles/tokens.css) so pages work in light and dark mode, and lay pages out for phones too.