Widgets
Dashboard primitives — KPI cards, charts, embedded tables, and an
escape hatch to drop into any React component you want. Widgets are
schema Elements (not a separate hierarchy), so they compose inside
Page.schema(), Resource.headerSchema() / footerSchema(), and any
container element (Group, Card, Section, Split, Tabs, …)
without special wiring.
Five built-in elements:
| Element | Purpose |
|---|---|
StatsOverview |
Row of KPI cards, each described by a Stat |
Chart |
Line / bar / pie / doughnut chart with optional filter |
TableWidget |
Slim "5 newest …" list (no filters / pagination) |
View |
Mount any React component, fed by getData(ctx) |
Stat |
Fluent value object emitted by StatsOverview |
Chart lives in @pilotiq/recharts — opt-in install so the core
package doesn't carry the recharts dependency.
#Quick example
// app/Pilotiq/Dashboard.ts
import { Page, Heading, Grid, Section, Card, Alert } from '@pilotiq/pilotiq'
import { UsersStats } from './widgets/UsersStats.js'
import { PostsChart } from './widgets/PostsChart.js'
import { RecentPosts } from './widgets/RecentPosts.js'
import { ActivityFeedView } from './widgets/ActivityFeedView.js'
export class MyDashboard extends Page {
static slug = ''
static label = 'Dashboard'
static icon = 'layout-dashboard'
static schema() {
return [
Heading.make('Dashboard'),
UsersStats.make(),
Card.make('Posts over time').schema([
PostsChart.make().poll(30),
]),
Grid.make().columns(2).schema([
Section.make('Recent posts').schema([RecentPosts.make()]),
Section.make('Activity feed').schema([ActivityFeedView.make()]),
]),
]
}
}// AdminPanel.ts
panel
.resources([...])
.pages([MyDashboard, OtherPages])
.dashboard(MyDashboard) // marks MyDashboard as the panel rootpanel.dashboard(P) registers the page, auto-adds it to cfg.pages,
collapses its sidebar URL to ${base} (no trailing slug segment), and
routes ${base} to its schema().
#StatsOverview and Stat
A row of KPI cards. Subclass StatsOverview and return an array of
Stats from getStats(ctx):
import { StatsOverview, Stat } from '@pilotiq/pilotiq'
export class UsersStats extends StatsOverview {
static override columns = 3
static override async getStats() {
return [
Stat.make('Users')
.value(await User.query().count())
.description('total registered')
.icon('users')
.color('primary')
.url('/admin/users'),
Stat.make('Posts')
.value(await Post.query().count())
.icon('file-text')
.color('success')
.chart([3, 5, 4, 7, 8, 6, 9]), // inline-SVG sparkline
Stat.make('Revenue (MTD)')
.value('$' + total)
.description('+12% vs last month')
.descriptionIcon('trending-up')
.icon('dollar-sign'),
]
}
}Stat fluent surface:
.value(v)— main number / string.description(t)/.descriptionIcon(name, position?)— supplementary line ('before' | 'after', default'after').icon(name)— main card icon.color(c)—default | primary | success | warning | destructive | info.chart([n, n, …])— inline SVG sparkline (no chart-lib dep).url(href)/.openUrlInNewTab(true)— wraps the card in<a>.extraAttributes({...})— pass-through HTML attrs
#Chart
Lives in @pilotiq/recharts. Install once:
pnpm add @pilotiq/recharts rechartsRegister the renderer on your panel (typically app/Pilotiq/AdminPanel.ts):
import { Pilotiq } from '@pilotiq/pilotiq'
import { recharts } from '@pilotiq/recharts'
Pilotiq.make('Admin').plugins([recharts()])Without that call, Chart widgets render an inline error pointing at
the install command — silent rendering would let a missing
plugin registration slip into production.
import { Chart } from '@pilotiq/recharts'
export class PostsChart extends Chart {
static override label = 'Posts per day'
static override type = 'line' as const
static override color = 'primary' as const
static override maxHeight = 280
static override filters = {
today: 'Today',
week: 'Last 7 days',
month: 'Last 30 days',
}
static override defaultFilter = 'week'
static override async getData(ctx) {
const days = ctx.filter === 'today' ? 1
: ctx.filter === 'month' ? 30
: 7
const rows = await Post.query()
.where('createdAt', '>', new Date(Date.now() - days * 86_400_000))
.orderBy('createdAt')
// Bucket by day, then return Chart.js-shaped { labels, datasets }
return {
labels: bucketLabels(rows, days),
datasets: [{ label: 'Posts', data: bucketCounts(rows, days) }],
}
}
// Escape hatch: raw props passed through to the Recharts component.
static override options = { strokeWidth: 2, dot: false }
}Selecting a different filter key re-fetches via the same widget endpoint
with { filter } in the request body. ctx.filter carries the
selection into getData.
Chart types (8): line / bar / pie / doughnut / radar / polar / scatter / bubble. v1 ships renderers for line / bar / pie / doughnut;
the rest paint a "type not yet supported" panel. Calling .type('unknown')
throws at construction.
Data shape:
{
labels: ['Mon', 'Tue', 'Wed'],
datasets: [
{ label: 'Posts', data: [3, 5, 4] },
{ label: 'Drafts', data: [1, 2, 1], color: 'warning' },
],
}Chart.js-shaped on purpose — the renderer normalizes to Recharts row shape internally so existing data-shaping code doesn't have to change when you swap libraries.
#TableWidget
Slim list of records — no filters, no bulk actions, no pagination. The
"5 newest posts" pattern. Distinct from the schema-element Table,
which drives the full Resource list page.
import { TableWidget, Column, type ModelQuery } from '@pilotiq/pilotiq'
import { Post } from '#models/Post.js'
export class RecentPosts extends TableWidget {
static override label = 'Recent posts'
static override viewAllUrl = '/admin/posts' // "View all →" header link
static override model = Post
static override async query(q: ModelQuery) {
return q.orderBy('createdAt', 'DESC').paginate(1, 5)
}
static override columns() {
return [
Column.make('title').label('Title').limit(40),
Column.make('status').label('Status'),
Column.make('createdAt').label('Created').since(),
]
}
}Resolution falls through:
instance.records(fn)setterstatic records(ctx)instance.model(M).query(fn)settersstatic model + static query?(q)(defaultq => q.paginate(1, 5))- throws if none configured
Column.formatStateUsing runs server-side per row and stamps results
under row._formatted[colName] — same convention as the full Table.
#View (escape hatch)
Drops down to a user-supplied React component. Useful for one-offs (calendar heatmaps, map embeds, custom dataviz) where adding a new built-in would be overkill.
// app/Pilotiq/widgets/ActivityFeedView.ts
import { View } from '@pilotiq/pilotiq'
export class ActivityFeedView extends View {
static override componentName = 'ActivityFeed'
static override async getData() {
const recent = await Post.query()
.orderBy('createdAt', 'DESC')
.paginate(1, 8)
return { rows: recent.data.map(toRow) }
}
}Register the matching component in your client entry:
// pages/+Layout.tsx
import { registerWidgetComponents } from '@pilotiq/pilotiq/widgets'
import { ActivityFeed } from '../app/Pilotiq/widgets/ActivityFeed.js'
registerWidgetComponents({ ActivityFeed })Components must accept { data?: unknown } (WidgetComponent shape) and
type-guard the payload they actually expect:
export function ActivityFeed({ data }: { data?: unknown }) {
const rows = readRows(data)
return <ol>{rows.map(/* … */)}</ol>
}
function readRows(data: unknown): ActivityRow[] {
if (!data || typeof data !== 'object') return []
const rows = (data as { rows?: unknown }).rows
return Array.isArray(rows) ? (rows as ActivityRow[]) : []
}The componentName is a registry-lookup key — it does not need to
match the JS class name. Renaming the View subclass is safe.
#Lazy loading and polling
Every server-data widget element (StatsOverview, Chart,
TableWidget, View) is lazy by default: the server stamps
_widgetData[id] = null, the client paints a skeleton, and the widget
fetches its data on mount via POST {base}/_widget/:id.
Opt-out per widget:
StatsOverview.make().lazy(false) // resolves synchronously on first paintAuto-refresh by polling on a schedule:
PostsChart.make().poll(30) // re-fetch every 30 secondsPolling pauses while document.visibilityState !== 'visible' so an
inactive tab doesn't hammer the server. Latest-wins seq tracking drops
stale responses if a slower request resolves after a faster one.
Hooks errors stamp _widgetData[id] = { error: '…' } so one flaky
widget doesn't blank out the whole page; the renderer paints an inline
banner and the next polling tick attempts to recover.
#Authorization
Widgets inherit Element.visible(rule) from Plan #8 — no new
canView predicate. Both the SSR pass and the polling endpoint
re-evaluate the rule:
StatsOverview.make().visible(({ user }) => user?.role === 'admin')The polling route 403s when the predicate fails, so a hidden widget
can't be re-fetched by URL probing. The rule's context exposes user
(opaque, set via panel.user(req => …)), record / records, and the
shared RenderContext.
Page.canView(user) (Plan #10) gates the page itself before any widget
resolves.
#Resource header / footer schemas
Drop widgets above or below the list table for a Resource:
export class PostResource extends Resource {
static override headerSchema() {
return [
Grid.make().columns(2).schema([
PostsThisWeek.make(),
DraftCount.make(),
]),
Alert.make('Editorial calendar locked through Friday').info(),
]
}
static override footerSchema() {
return [LongestRunningPostsTable.make()]
}
}Both hooks are async-aware. Widget endpoints are scoped to the resource
(POST {base}/{slug}/_widget/:id) so R.canAccess + R.canViewAny run
in front of the per-widget visibility check.
#Custom widgets
For a truly bespoke widget that doesn't fit the existing built-ins —
e.g. a calendar heatmap with its own polling cadence — extend View
or, if you need the widget surface (filters dropdown, multiple
renderers, a registry), extend ServerDataElement directly:
import { ServerDataElement, registerWidgetRenderer } from '@pilotiq/pilotiq'
export class CalendarHeatmap extends ServerDataElement {
getType() { return 'calendarHeatmap' }
toMeta() { return { type: 'calendarHeatmap' } }
async resolveServerData(ctx) {
return { days: await Activity.query().last(365).countByDay() }
}
}
// pages/+Layout.tsx (or any client entry)
import { CalendarHeatmapRenderer } from './CalendarHeatmapRenderer'
registerWidgetRenderer('calendarHeatmap', CalendarHeatmapRenderer)Renderers consume the same useWidgetData(meta) hook the built-ins
use:
import { useWidgetData } from '@pilotiq/pilotiq/react'
export function CalendarHeatmapRenderer({ meta }) {
const { data, error, isLoading } = useWidgetData(meta)
if (isLoading) return <Skeleton />
if (error) return <ErrorPanel msg={error} />
return <Heatmap days={data.days} />
}The same renderer registry powers @pilotiq/recharts. Extend it the
same way to ship a @pilotiq/echarts or @pilotiq/chartjs adapter.
#What's not in v1
- Page-level filter form (Filament's
HasFiltersForm/persistsFiltersInSession). Workaround: drop aForm.live()element at the top ofPage.schema()and have widgets read upstream filter state via$get. - Drag-to-rearrange dashboards and per-user saved layouts.
- Responsive
columns([md => 2, xl => 4])— int-only for v1 (mirrors Repeatergrid()posture). - Widget caching layer (
Element.cache(ttl)) — wrap your owngetDatabody in@rudderjs/cacheif a slow query is blocking page render. - Remaining 4 chart types (radar / polar / scatter / bubble) — v1 ships line / bar / pie / doughnut renderers; the rest land as Recharts mappings.