Dashboard
Nine widget types under the dashboard key of config/admin.ts. Four read a collection for you. Three need a server loader you write.
Write no dashboard key and /admin shows a welcome card. Write one and the
panel becomes the numbers your team asks you for.
The nine types
type | Shows | Where the data comes from |
|---|---|---|
stats | One count, big | Counts a collection |
chart | Line, bar, area or pie | Groups a collection by one field |
recentItems | The latest rows, as a list | Reads a collection, newest first |
table | A small table with chosen columns | Reads a collection |
quickActions | Buttons | Nothing to fetch |
value | One metric, with an optional trend | A loader you write |
progress | A bar against a target | A loader you write |
timeline | An event stream | A loader you write |
custom | Your own React component | Your component, or a loader |
The shape
dashboard takes a title, header actions, and a flat list of items.
import { adminConfig } from "#questpie/factories";
export default adminConfig({
dashboard: {
title: "Operations",
description: "Content and workflow",
columns: 4,
actions: [
{
id: "new-post",
label: "New post",
href: "/admin/collections/posts/create",
icon: { type: "icon", props: { name: "ph:plus" } },
variant: "primary",
},
],
items: [
{
id: "published",
type: "stats",
collection: "posts",
label: "Published",
filter: { published: true },
span: 1,
},
{
id: "recent",
type: "recentItems",
collection: "posts",
label: "Recent posts",
dateField: "updatedAt",
limit: 6,
span: 2,
},
],
},
});Header actions are plain links. id, label and href are required. variant
takes default, primary, secondary, outline or ghost.
Loaders
Three types cannot count for themselves. value, progress and timeline each
take a loader. It is an async function and it runs on the server. It never
reaches the browser. The config route strips it out and sends hasLoader: true
in its place. The widget then asks the server for its own data, by id.
import { adminConfig } from "#questpie/factories";
import type { WidgetFetchContext } from "@questpie/admin/factories";
export default adminConfig({
dashboard: {
items: [
{
id: "draft-count",
type: "value",
label: "Drafts waiting",
loader: async ({ collections }: WidgetFetchContext) => {
const count = await collections.posts.count({
where: { status: "draft" },
});
return { value: count };
},
},
],
},
});The context is the app context plus db, collections and globals. So a
loader can do anything a route handler can do, including joining two
collections or reading a global.
A widget with a loader needs an `id` you wrote
The server finds the loader by widget id. Auto-assigned ids do not survive
that lookup for items written in the sectionId style, and the widget answers
with a not-found error. Give every loader-backed widget an explicit id.
stats, chart, recentItems, table and custom also accept a loader. It
replaces the built-in query, so match the shape it replaced. stats wants
{ count }. chart wants { name, value }[].
Options per type
Every widget takes id, label, span, rowSpan and access. These are the
rest.
stats
collection names what to count. filter becomes the where clause. icon
takes a component reference.
chart
chartType is line, bar, area or pie. collection and field say what
to group. timeRange is 7d, 30d, 90d or 1y, and only bites when the
field holds dates. Without a loader the widget reads up to 1000 rows and groups
them in the browser. Point it at a field with few distinct values.
recentItems
collection and dateField decide what to list and what to sort on, newest
first. dateField falls back to createdAt. limit caps the rows and defaults
to 5. Use a loader when the list needs filtering.
table
collection and columns are required. columns takes plain field names, or
{ key, label } when the header should read differently. limit defaults to 5.
sortBy with sortOrder orders the rows, and filter becomes the where.
quickActions
actions is a list of { label, icon, variant, action }. The action is one of
{ type: "create", collection }, { type: "link", href, external } or
{ type: "page", pageId }. layout is grid or list.
value
loader is required and returns { value } plus any of formatted,
subtitle, footer, icon and trend. trend is { value, icon }, so the
percentage and its arrow are yours to compute. cardVariant is default,
compact or featured.
progress
loader is required and returns { current, target } plus optional label and
subtitle. showPercentage is on already. color sets the bar.
timeline
loader is required and returns rows of { id, title, timestamp }. A row also
takes description, icon, href and a variant. The variants are default,
success, warning, error and info. maxItems caps the list at 10 unless
you say otherwise. timestampFormat is relative, absolute or datetime.
custom
widgetType names a widget you registered in src/questpie/admin/widgets/.
Your component reads props off its config prop. Add a loader when the
props are not enough. See Custom views.
Layout
columns sets the grid, 1 to 12, and defaults to 4. A widget's span is how
many of those columns it takes, and defaults to 1. rowSpan is how many rows,
and clamps between 1 and 8.
rowSpan defaults by type. stats, value and progress take one row.
Everything else takes two. rowHeight decides what a row is worth, 8.5rem if
you say nothing, and gap counts in quarter-rem steps.
A grid of three columns or more collapses as its container narrows, down to a single column on a phone. The one- and two-column presets stay as written.
Sections and tabs
An item with type: "section" groups widgets under a heading and can set its
own columns. wrapper is flat, card or collapsible, and a collapsible
section takes defaultCollapsed.
An item with type: "tabs" holds tabs, each with an id, a label and its
own items. defaultTab picks the one that opens.
There is a second way to write the same thing. Give an item a sectionId and it
joins that section from the flat list, the way sidebar items do. That form is
also how a module adds a widget to a dashboard you own.
Two shapes, two levels of type checking
An item carrying a sectionId is checked loosely, so extra keys pass. An item
nested in a section must match its widget type exactly. That is why the same
widget sometimes wants dateField and sometimes does not.
Who sees what
access on a widget is a boolean, or a function. The function is handed app,
db, session and locale. Return false and the widget never reaches that
person's browser. It is dropped from the config, not hidden with CSS.
{
id: "revenue",
type: "value",
access: ({ session }) => session?.user?.role === "owner",
loader: async ({ collections }) => ({
value: await collections.orders.count(),
}),
}Widgets bound to a collection are filtered too. A session that cannot read
posts gets no posts widget. A quickActions button that creates a posts
row is dropped from the list it sits in.
Staying current
refreshInterval on a widget is milliseconds, and refetches loader-backed data
on that interval. realtime: true on the dashboard makes collection-backed
widgets follow live updates, and a widget can override it.
Next
Theming is the other half of how the panel looks. Colours, fonts, dark mode, and the three components you can replace outright.
Preview
A live preview puts the page a row renders as in an iframe beside the form that edits it. One url builder turns it on, and a hook on the front end makes it update as somebody types.
Theming
Colours, fonts and radius are CSS variables, not config. Override them in one stylesheet, mirror two selectors, and the panel is yours.