Browse documentation

Drawer

The row side panel — layout, tabs and actions.

A drawer is the fast path to one row: it opens over the list without losing the operator's place, their filters or their scroll position. Declare it on a resource and set rowClick: "drawer" to open it by clicking the row.

resource(orders, {
  columns: ["id", "customerEmail", "status"],
  rowClick: "drawer",
  drawer: {
    width: "xl",
    header: (row) => `Order ${row.id}`,
    tabs: [
      { key: "summary", label: "Summary", fields: "*" },
      { key: "items", label: "Items", resource: "orderItems", filter: (row) => ({ orderId: row.id }) },
    ],
    actions: [
      {
        key: "refund",
        label: "Refund",
        variant: "destructive",
        requireRole: "admin",
        confirm: "Refund this order?",
        form: [{ name: "reason", type: "textarea", required: true }],
        run: async (row, formData, ctx) => {
          await refund(ctx.db, row.id, formData.reason as string);
          return { ok: true, message: "Refunded", refresh: true };
        },
      },
    ],
  },
});

DrawerConfig

Prop

Type

DrawerWidth is "sm" | "md" | "lg" | "xl" | "2xl" | "full", defaulting to "lg".

Without tabs, the panel renders fields as a flat key/value list. Field labels come from the matching column's label, and any column with a render function is rendered with it — so a status badge looks the same in the drawer as in the table.

fields is DrawerFieldList<Row> — the same shape as detail.fields: (keyof Row | FieldDef<Row>)[] | "*". A key your row does not have is a compile error, not an empty row in the panel.

Tabs

DrawerTab is a union of three shapes, discriminated by which key you set.

DrawerTabFields

The row's own fields.

Prop

Type

DrawerTabResource

Rows of a related resource, filtered by the open row.

Prop

Type

The related resource's own requireRole still applies. A tab the operator may not read comes back empty rather than failing the whole panel.

A resource tab shows the first 20 rows and nothing more. There is no pagination, no "load more", and no count of what was left out — the drawer route reads one page of 20 and serializes it. For a relation that can exceed that, link to the target resource's own list (filtered) instead of leaning on the tab.

DrawerTabWidgets

Dashboard widgets, scoped to the open row.

Prop

Type

custom() widgets are rejected here at compile time. The drawer payload is serialized over the wire, and a React component cannot cross it — use a widget tab of metrics/charts/tables, or a detail tab, which renders on the server.

Each widget in the tab is flattened to a SerializedWidget before it crosses: a discriminated union over kind, one variant per widget type.

type SerializedWidget =
  | { kind: "metric"; label: string; value: number | string; /* format, sublabel, tone, span, realtime */ }
  | { kind: "table"; rows: Record<string, unknown>[]; columns: { field: string; label?: string }[]; /* … */ }
  | { kind: "statGroup"; stats: { label: string; value: unknown; /* … */ }[]; /* … */ }
  | { kind: "chart"; subkind: "area" | "bar" | "line" | "pie"; label: string; dataPoints: number; /* … */ }
  | { kind: "unsupported"; label?: string; reason: string; span?: number };

The queries have already run by then — a metric carries its resolved value, a chart carries only how many points it produced. A widget whose query throws comes back as { kind: "unsupported", reason } with a client-safe message, so one failing widget does not take the panel down with it.

Actions

Buttons in the panel footer. See Actions for DrawerAction and its form spec.

Drawer actions go through the same guard pipeline as every other write: the admin-wide role gate, the resource gate, the action's own requireRole, then input validation — all before run. An action the current operator cannot run is omitted from the drawer payload as well as blocked server-side. Setting palette: true also surfaces an authorized action in ⌘K.

Prop

Type

The payload

What GET /api/flowpanel/drawer/<resource>/<id> answers with — worth knowing if you render the drawer yourself.

Prop

Type

What crosses to the client

The drawer payload carries only the resource's declared surface — the columns and fields you listed, never the raw adapter row. Columns with a render function are pre-rendered to HTML on the server, so your renderer can touch server-only data without shipping it.