Learn / Dashboards in React

Dashboards in React

<GrafloriaDashboard> is the dashboard kit with React idioms on top: views declare the whole board as data, the kit wires the 12-column pack grid, drag/resize gestures and undo, and widgetTypes is the nodeTypes pattern applied to widgets.

Declare the board, mount the component

A widget is { id, kind, span, rows, data } — plus optional title, pinned, and an explicit cell (x/y). Omit the coordinates and widgets flow in declaration order, wrapping at the column count, so the common case needs no geometry at all. span defaults to 3 columns, rows to 1:

Board.tsx
import { GrafloriaDashboard } from '@grafloria/react';

const views = [
  { id: 'overview', name: 'Overview', widgets: [
    { id: 'kpi-revenue', kind: 'kpi', span: 3, rows: 1,
      data: { label: 'Total revenue', value: '$6.81M', delta: 12.4, deltaLabel: 'vs last qtr',
              spark: [42, 45, 47, 51, 50, 55, 59, 61, 60, 65, 71, 76] } },
    { id: 'trend', kind: 'line', span: 6, rows: 2, title: 'Revenue trend',
      data: { series: [{ name: 'Revenue', values: [42, 45, 47, 51, 50, 55, 59, 61, 60, 65, 71, 76] }],
              labels: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'] } },
    { id: 'mix', kind: 'donut', span: 6, rows: 2, title: 'Revenue by region',
      data: { slices: [
        { label: 'EMEA', value: 2.9, color: '#3B52D9' },
        { label: 'AMER', value: 2.4, color: '#94A5F0' },
        { label: 'APAC', value: 1.5, color: '#059669' },
      ], centerLabel: '$6.8M' } },
    { id: 'bars', kind: 'bar', span: 6, rows: 2, title: 'Deals by quarter',
      data: { bars: [
        { label: 'Q1', value: 210 }, { label: 'Q2', value: 260 },
        { label: 'Q3', value: 245 }, { label: 'Q4', value: 292 },
      ] } },
  ] },
];

export default function Board() {
  return (
    <div style={{ height: '100vh' }}>
      <GrafloriaDashboard views={views} activeView="overview" />
    </div>
  );
}

That renders a working board — drag widgets between cells, resize them from the corner, undo any of it — with real KPI, line, donut and bar widgets drawn from the data above and not a charting library in sight. views is the multi-view (tabbed) form; widgets is the single-view shorthand, and the two props are mutually exclusive.

Live: the board above →

The six built-in kinds

When a widget's kind has no entry in widgetTypes, the kit's built-in painters draw it from your own data — hand-rolled inline SVG, zero dependencies, no sample dataset. The shapes they read:

kind: 'kpi'     data: { label?, value?, delta?, deltaLabel?, spark?: number[] }
kind: 'line'    data: { series?: number[] | { name?, values: number[] }[], labels?: string[] }
kind: 'bar'     data: { bars?: { label?, value? }[] }
kind: 'donut'   data: { slices?: { label?, value?, color? }[], centerLabel?, centerCaption? }
kind: 'funnel'  data: { stages?: { label?, value? }[] }
kind: 'table'   data: { columns?: string[], rows?: (string | number)[][] }

Every field is optional because a widget renderer must never throw — it paints into a live board, mid-gesture, on every reflow — so missing or partial data degrades to an empty-state note instead. A kind nobody knows falls back to a titled placeholder frame, which makes a layout testable before any chart exists. Colors cycle a built-in categorical palette; a per-slice color wins. KPI value is a string on purpose: you pre-format it, so units and currency stay yours.

Custom widgets: widgetTypes and WidgetProps

widgetTypes maps a widget kind to a real React component. Components are portal-mounted into the kit's host elements, so hooks, context and state work inside — the exact custom-node idiom, applied to boards. The component receives { widget, data }: the full spec, and its data payload:

OrdersCard.tsx
import { useState } from 'react';
import type { WidgetProps } from '@grafloria/react';

function OrdersCard({ widget, data }: WidgetProps<{ title: string }>) {
  const [expanded, setExpanded] = useState(false);   // real component: state survives gestures
  return (
    <div onClick={() => setExpanded(!expanded)}>
      <b>{data.title}</b> {expanded ? '▾' : '▸'}
    </div>
  );
}

<GrafloriaDashboard
  views={views}
  widgetTypes={{ orders: OrdersCard as never }} />

Kinds with a component render through React; kinds without one fall back to the built-in painters. Mixing both on one board is the normal case, not a trick.

In React, custom painting goes through widgetTypes — not options.renderWidget. The wrapper installs its own renderWidget to power the portal mechanism, so a function you pass inside options is shadowed. Reserve options for board behavior and geometry.

Props: options, activeView, and the mount-once rule

options carries the board's physics — columns (default 12), gap (default 8), sizing ('fit' squeezes rows into the board height, 'grow' extends the board at rowHeight, default 130), width/height, float (off means gravity packs upward), rtl (mirrors pixels, never cells), responsive (width-driven column count, via columnWidth or named breakpoints) and binder, the escape hatch to the gesture layer below.

activeView is the tab pattern: a controlled prop that drives showView, so switching tabs is just re-rendering with a different id — inactive views park off-camera, they are not unmounted.

The board mounts once. Like the flow, the component reads views, widgets and options at mount and does not rebuild on prop changes — activeView is the one prop that stays live. After mount, data flows through the handle: widget updates, adds, removals, sizing switches. Changing the views array on a later render does nothing.

Live: gravity packing, pinning, RTL, responsive columns →

The handle: your runtime API

onReady hands you the typed DashboardHandle once the board is live; onLayoutChange mirrors every committed gesture — drag, resize, add, remove — with the affected view's widgets, ready to persist:

Handle.tsx
import type { DashboardHandle } from '@grafloria/element';

const handleRef = useRef<DashboardHandle | null>(null);

<GrafloriaDashboard
  views={views}
  onReady={(h) => { handleRef.current = h; }}
  onLayoutChange={({ viewId, widgets }) => persist(viewId, widgets)} />

// later — everything is a method on the handle:
const h = handleRef.current!;
h.widget('kpi-revenue')?.update({ data: { label: 'Total revenue', value: '$7.02M', delta: 3.1 } });
h.addWidget({ id: 'funnel', kind: 'funnel', span: 4, rows: 2,
              data: { stages: [{ label: 'Leads', value: 900 }, { label: 'Won', value: 210 }] } });
h.setSizing('grow');
h.showView('ops');

The highlights: widget(id) returns a per-widget handle (update, repaint, resize, moveTo, pin, remove — the destructive ones are single undoable steps, re-pack included). addWidget creates the node and its board membership as one undo step. setSizing / setFloat / setColumns / setRtl flip board behavior live, and refresh() re-reads the boards after an undo/redo or any out-of-band model change. binderOf() is the documented escape hatch to the raw grid binder.

Persistence: the toJSON round-trip

// save — the whole board as plain data (views, cells, columns, gap, sizing, float, rtl)
localStorage.setItem('board', JSON.stringify(handle.toJSON()));

// restore — the function seams cannot be serialized, so supply them again:
import type { DashboardSnapshot } from '@grafloria/element';
const { views, ...options } = JSON.parse(saved) as DashboardSnapshot;
<GrafloriaDashboard views={views} options={options} widgetTypes={{ orders: OrdersCard as never }} />

toJSON() reads the live board, not the authored literal — a sizing mode or column count the user changed after mount is what you get back. And it serializes from the engine's widest cached column layout, so a board squeezed to one column on a phone still saves the 12-column layout its user authored. It is also what JSON.stringify(handle) calls.

Exporting a multi-view board? Tabs park inactive views far off-camera, and export() frames the whole model — a two-view board can silently write a ~21,000px document that is almost entirely empty. Scope it: api.export('pdf', { includeIds: handle.exportIds() }). The set includes the view's group, which hand-rolling from toJSON() would drop.

Dashboard or flow?

Use <GrafloriaDashboard> when the thing you are building is a board: cells, spans, gravity packing, pinning — and no wires. Widgets are deliberately not connectable (their ports are stripped), and the board has no edges at all. Use <GrafloriaFlow> when nodes connect to nodes — free positions, edges, routing, validation. And if you are rendering any kit spec — erDiagram(...), umlDiagram(...), dashboard(...), DSL text — <GrafloriaDiagram spec={...}> is the one generic host component; <GrafloriaDashboard> is that, plus the React widget idioms.

Where next

  • Kits — the data-first authoring layer the dashboard kit belongs to.
  • Custom nodes in React — the same portal mechanism, one level down.
  • Commands and undo — why widget add/remove is one Ctrl-Z, not two.
  • Export — PNG, SVG and real vector PDF, with includeIds scoping.