Learn / Dashboards in Vue
Dashboards in Vue
<GrafloriaDashboard> turns a data description of a board — views, widgets, spans — into a live 12-column grid with drag, resize and undo wired in. Six widget kinds render from pure data with no charting library; anything richer is a #widget-<kind> slot, the same idiom as custom nodes on the flow.
When it's this, and when it's GrafloriaFlow
The dashboard kit is built on the same engine as the flow canvas, but it is a
different contract: widgets live in a pack grid (gravity, pushing, pinning), gestures
move and resize cells rather than free coordinates, and widget nodes are made
non-connectable — nobody draws an edge between two KPI tiles. Reach for
<GrafloriaFlow> when the point is nodes and edges; reach for
<GrafloriaDashboard> when the point is tiles on a board.
A board is data
This is the Vue demo's board, verbatim — note that the data shapes are yours, pre-formatted by you (the kit never invents numbers or formats your currency):
<script setup>
import { ref } from 'vue';
import { GrafloriaDashboard } from '@grafloria/vue';
const tab = ref('overview');
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 },
] } },
]},
];
</script>
<template>
<div style="height: 100vh">
<GrafloriaDashboard :views="views" v-model:active-view="tab" />
</div>
</template>
Widget geometry: span is columns (default 3), rows is
rows (default 1). Omit x/y and widgets flow in declaration
order, wrapping at the column count — the common case needs no coordinates at all.
pinned: true makes a widget refuse the mover and survive every reflow.
Board geometry lives in :options — columns (default 12),
gap (default 8), sizing: 'fit' | 'grow',
rowHeight (grow mode, default 130), width/height
(default 1180 × 660), float, rtl, and a
responsive column-count policy. For a single unnamed view,
:widgets is the shorthand; when both are given, :views
wins.
The six built-in kinds
With no slot and no renderer, kind selects one of six built-in
painters — hand-rolled inline SVG, zero dependencies. Their data contracts:
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)[][] }
Details that make them production-safe rather than demo-ware: a widget renderer
never throws — missing, partial or wrong-shaped data degrades to an empty-state note;
a bare number[] is the single-series shorthand for line;
kpi.value is a string you pre-format so units stay yours; per-slice
color wins over the built-in palette. A kind outside the six
falls back to a titled placeholder frame, so a layout is testable before any chart
exists.
Custom widgets: #widget-<kind> slots
Kinds with a matching slot render through it; kinds without one fall back to the
built-ins. Slot context is { widget, data } — the widget's declared spec
and its data payload:
<GrafloriaDashboard :views="views" v-model:active-view="tab">
<template #widget-orders="{ widget, data }">
<OrdersCard :title="widget.title" :orders="data.orders" style="height: 100%" />
</template>
<template #widget> <!-- wildcard: any kind without an exact slot -->
<div class="fallback-card" style="height: 100%">…</div>
</template>
</GrafloriaDashboard>
The same rendering mechanics as flow slots apply: content mounts with Vue's
low-level render() into a width:100%; height:100% host, so
give the slot root height: 100%. Painting happens when a widget mounts
(hosts are reused across re-renders, not repainted per frame) and again whenever the
kit repaints a widget — including WidgetHandle.update() below.
renderWidget (to resolve slots) and onLayoutChange (to emit
the event). A renderWidget or onLayoutChange passed inside
:options is overwritten — use slots and @layout-change;
they are the Vue spelling of those seams.Views and v-model:active-view
Multiple views are the tab pattern: only one is on-camera; the kit parks the others
far off-screen. There is no built-in tab bar — your tab strip is ordinary Vue
writing one ref, and the wrapper calls showView() for you:
<nav>
<button v-for="v in views" :key="v.id"
:class="{ active: tab === v.id }" @click="tab = v.id">{{ v.name }}</button>
</nav>
<div style="height: 80vh">
<GrafloriaDashboard :views="views" v-model:active-view="tab" @ready="onReady" />
</div>
On mount the wrapper emits update:activeView with the initial view
(honoring a pre-set tab), and @ready hands you the
DashboardHandle — the same object getHandle() returns.
Persisting layout: @layout-change
After any committed gesture — a drag, a resize, an undo of either — the component
emits layoutChange with { viewId, widgets }: the affected
view and its widget specs with their current cells. Write it wherever you keep
state:
<GrafloriaDashboard :views="views" @layout-change="onLayout" />
function onLayout({ viewId, widgets }) {
saveLayout(viewId, widgets); // specs with live x/y/span/rows
}
The handle, live data, and the snapshot round trip
The template ref exposes two members: getHandle() and
snapshot(). The handle is the board's typed façade — views
(showView, activeView, fit), live switches
(setSizing, setFloat, setColumns,
setRtl), widget access (widget(id),
widgetsOf(), addWidget() — one undoable step including the
re-pack), and refresh() to re-read the boards after an out-of-band
undo.
Per-widget handles make live data one call — update() replaces the
widget's data and repaints it through whatever painter owns it, built-in or slot:
const board = ref(); // template ref on <GrafloriaDashboard>
function tick(revenue) {
const h = board.value.getHandle();
h.widget('kpi-revenue')?.update({ data: { label: 'Total revenue', value: revenue } });
}
snapshot() is handle.toJSON(): the whole board as plain
data — every option except the function seams, plus the views with their live
layout. Values are read from the live board, so a column count or sizing mode
the user changed after mount is what you get back. And the output is
dashboard() input — the documented round trip:
// save
const saved = board.value.snapshot(); // DashboardSnapshot — JSON-safe
// restore, the Vue way: the snapshot is the options object
<GrafloriaDashboard :options="saved">
<template #widget-orders="{ widget, data }">…</template>
</GrafloriaDashboard>
The function seams (renderWidget, onLayoutChange) cannot
live in a JSON file and must be re-supplied on the way back in — and in Vue they
re-supply themselves: your slots are the painters, so declaring the same slots
on the restoring component completes the round trip.
export() frames the whole model — a two-view board can write a
~21,000px, almost entirely empty document with no warning. Scope the export with the
handle: api.export('pdf', { includeIds: handle.exportIds() }). The set
includes the view's group as well as its widgets; rolling it by hand from
toJSON() drops the group.Where next
- Kits — the dashboard kit next to its siblings: ER, UML, stencils.
- Custom nodes in Vue — the slot mechanics
#widget-<kind>inherits, in depth. - State & data flow in Vue — the flow component's state contract, refs and composables.
- Export — formats, scoping and fidelity warnings.