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):

dashboard-builder.vue
<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>

Live: the dashboard builder →

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 :optionscolumns (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.

The wrapper owns the kit's function seams: it installs its own 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.

Exporting a multi-view board: inactive views are parked far off-camera, and 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.