Documentation

PluginsAdminCP

Dashboard widgets

Add a configurable AdminCP dashboard widget to a VitNode plugin, with real data, a settings form, translated strings, sizing and an optional staff permission.

A dashboard widget is a card a plugin adds to the AdminCP dashboard. Each administrator decides which widgets to show, where, and how big. In this guide the site-notes plugin gets a Notes widget that shows how many notes exist and the newest one, with a setting to hide the newest note.

Before you begin

  • The site-notes AdminCP page from AdminCP page. This guide reuses its src/pages/notes-query.ts, its @tanstack/react-query dependency and its src/admin/nav.tsx.

Build the widget component

Create src/admin/notes-widget.tsx. It reads the same notes query as the AdminCP page, so the count is real and refreshes with the list:

plugins/site-notes/src/admin/notes-widget.tsx
import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin'

import { useQuery } from '@tanstack/react-query'
import { Skeleton } from '@vitnode/core/components/ui/skeleton'
import { useTranslations } from 'use-intl'

import { notesQueryOptions } from '@/pages/notes-query'

export const NotesWidget = ({ settings }: AdminDashboardWidgetProps) => {
  const t = useTranslations('site-notes.admin.dashboard.widgets.notes')
  const { data: notes, isError, isPending } = useQuery(notesQueryOptions())
  const showLatest = settings.showLatest !== false

  if (isPending) return <Skeleton className="m-2 h-9 w-24" />
  if (isError) return <p className="text-destructive p-2 text-sm">{t('load_error')}</p>

  return (
    <div className="flex flex-col gap-1 p-2">
      <strong className="text-3xl tabular-nums">
        {t('count', { count: notes.length })}
      </strong>
      {showLatest && notes[0] ? (
        <p className="text-muted-foreground text-sm leading-relaxed text-pretty">
          {t('latest', { title: notes[0].title })}
        </p>
      ) : null}
    </div>
  )
}

settings holds what an administrator saved for this copy of the widget, and is {} until they save anything, so the widget treats a missing showLatest as on.

Add a settings form

Create src/admin/notes-widget-settings.tsx. The dashboard renders it in the widget's panel while an administrator edits the layout. useWidgetSettingsDialog() gives it save, which stores the settings, shows a toast and refreshes the widget:

plugins/site-notes/src/admin/notes-widget-settings.tsx
import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin'

import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormSwitch } from '@vitnode/core/components/form/fields/switch'
import { useWidgetSettingsDialog } from '@vitnode/core/views/admin/views/core/dashboard/grid/widget-settings-context'
import { useTranslations } from 'use-intl'
import { z } from 'zod'

export const NotesWidgetSettings = ({ settings }: AdminDashboardWidgetProps) => {
  const t = useTranslations('site-notes.admin.dashboard.widgets.notes')
  const { save } = useWidgetSettingsDialog()

  const formSchema = z.object({
    showLatest: z.boolean().default(settings.showLatest !== false),
  })

  return (
    <AutoForm
      fields={[
        {
          id: 'showLatest',
          component: props => (
            <AutoFormSwitch {...props} label={t('settings.show_latest')} />
          ),
        },
      ]}
      formSchema={formSchema}
      onSubmit={async values => {
        await save(values)
      }}
      submitButtonProps={{ children: t('settings.save') }}
    />
  )
}

Skip this step, and settingsComponent in the next one, if your widget has nothing to configure.

Register it on the plugin's adminNav

Add a dashboard.widgets list to the adminNav in src/admin/nav.tsx, next to the sidebar nav from the previous lesson. VitNode collects every plugin's adminNav into the app's generated admin-nav.gen.ts, and the AdminCP shell hands the widgets to the dashboard.

plugins/site-notes/src/admin/nav.tsx
import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin'

import { StickyNoteIcon } from 'lucide-react'

import { NotesWidget } from '@/admin/notes-widget'
import { NotesWidgetSettings } from '@/admin/notes-widget-settings'
import { manifest } from '@/manifest.gen'

export const adminNav = {
  pluginId: manifest.id,
  admin: {
    dashboard: {
      widgets: [
        {
          id: 'notes',
          component: NotesWidget,
          settingsComponent: NotesWidgetSettings,
          icon: <StickyNoteIcon />,
          defaultEnabled: true,
          defaultSpan: 1,
          defaultRows: 1,
        },
      ],
    },
    nav: [
      // ...the Notes link from the previous lesson
    ],
  },
} satisfies AdminNavPluginSource

The board namespaces the id as site-notes:notes, so notes never collides with another plugin's idea of a notes card. With defaultEnabled, the dashboard puts the widget at the end of the board for every administrator who has not seen it yet. Anyone who removes it keeps it removed.

Add the strings

The dashboard loads the site-notes.admin.dashboard.widgets namespace. The widget's title is notes.title and its optional desc shows in the widget panel; the other keys are the ones the two components use. Add them under site-notes.admin in src/locales/en.json, next to nav and notes:

plugins/site-notes/src/locales/en.json
{
  "site-notes": {
    "admin": {
      // ...nav and notes from the previous lesson
      "dashboard": {
        "widgets": {
          "notes": {
            "title": "Notes",
            "desc": "How many notes exist, and the newest one.",
            "count": "{count, plural, one {# note} other {# notes}}",
            "latest": "Latest: {title}",
            "load_error": "The notes could not be loaded.",
            "settings": {
              "show_latest": "Show the newest note",
              "save": "Save settings"
            }
          }
        }
      }
    }
  }
}

Check the result

Let the plugin rebuild (or run its build:plugins script), then reload the AdminCP dashboard at /admin/core.

  1. A Notes card sits at the end of the board, showing the number of notes and "Latest:" with the newest title. With no notes yet it reads 0 notes.
The site-notes Notes widget at the end of the AdminCP dashboard, reading 6 notes and Latest: Lift maintenance, next to core's Send a notification widget
  1. Press Edit layout, click the card, switch off Show the newest note and choose Save settings. A toast confirms the save and the card shows only the count.
  2. Create a note on the plugin's AdminCP page, then come back. The count has gone up by one.

Show a widget only to some staff

permission hides a widget from administrators without that staff permission:

plugins/site-notes/src/admin/nav.tsx
{
  id: 'notes',
  component: NotesWidget,
  permission: { module: 'notes', permission: 'can_create' }, 
  // ...
}
  • It only hides the card. The data still comes from the API, so a widget that shows private data must read it from a route guarded with adminStaffPermission, as the create endpoint in Permissions is. The notes list is public, so the Notes widget needs no guard.
  • Declare the permission in buildApiPlugin({ permissionStaff }), as site-notes already does for notes.can_create. An undeclared permission cannot be granted in the staff editor, so only root administrators would see the widget. vitnode plugin validate checks this for sidebar links, not for widgets.

Widget options

NameTypeRequiredDefaultDescription
idstringYes-Unique within the plugin; the board uses {pluginId}:{id}. Also names the strings under admin.dashboard.widgets.{id}
componentComponentType<AdminDashboardWidgetProps>Yes-Renders the widget's body. Gets settings and widgetId (the id of this copy on the board)
iconReactNodeNo-Shown in the widget panel
defaultSpan1 | 2 | 3NominSpanWidth in grid columns when first placed
defaultRows1 | 2 | 3No1Height in grid rows when first placed
minSpan1 | 2 | 3No1The narrowest an administrator can resize it to
defaultEnabledbooleanNofalsePlaced automatically for administrators who have never seen it
allowMultiplebooleanNofalseLets an administrator place more than one copy, each with its own settings
categorystringNothe pluginGroups the widget in the panel, titled by admin.dashboard.widgets.categories.{category} (the raw value when that key is missing)
permission{ module, permission }No-Hides the widget from administrators without this staff permission of the declaring plugin
settingsComponentComponentType<AdminDashboardWidgetProps>No-A form for this copy's settings, shown while an administrator edits the dashboard. Saves with useWidgetSettingsDialog().save

How admins arrange them

Your widget does not need any code for this - it is what the dashboard does with every widget. Press Edit layout and the Widgets panel slides in beside the grid.

To…Do this
Add a widgetClick it in the panel to put it at the end, or drag it onto the grid. While you drag, the grid opens a gap the widget's real size, right where it will land.
Change your mindDrag it back over the panel and let go. Nothing is added, and the card glides back to where it came from.
Move a widgetDrag the card from anywhere on it, or Tab to it and use Space and the arrow keys. The spot it came from turns into a dashed slot.
Resize a widgetGrab the little bracket in the card's bottom corner and pull. Sideways snaps the width between 1 and 3 columns, down and up snap the height between 1 and 3 rows, and the other widgets glide out of the way. A dashed outline follows your pointer so you can see where you are pulling. From the keyboard, Tab to the corner and use the arrow keys.
Edit a widgetClick the card (or Tab to it and press Enter). The panel switches to that widget: a width switch, its own settings form if it has one, and a bin in the header. Save settings stores the settings straight away and leaves the rest of your unsaved arrangement alone. The arrow at the top goes back to the list.
Remove a widgetThe bin on the card's toolbar, or in its panel.
FinishThe panel footer says whether anything changed. Save keeps the arrangement; Cancel throws it away and leaves edit mode.

New widgets fade in and scroll into view, and screen readers hear where a widget was picked up and dropped in the admin's own language. With reduce motion turned on in the operating system, all of it happens instantly.

The drop gap uses defaultSpan and defaultRows, so pick them to match what the widget looks like the moment it is added.