Docs
Views

Dashboard

Put saved views of any table, numbers and notes on one grid, with dashboard filters that reach every table.

A dashboard works like a Notion dashboard: YayawDashboard puts saved views of any of your tables, numbers and notes on a 4-column grid. Each view keeps its own display mode (table, list, board, gallery, calendar, chart, form) and loads its records from your server. Dashboard filters, such as a date range or a category, narrow every widget they apply to. Users with edit rights drag, resize and add widgets; everyone else sees a read-only dashboard. React and Vue behave the same.

Since v3.8.0 a dashboard can also be a screen of your application: sections in order (grids of cards and full-width flows), full-page tables with their toolbar and saved views, blocks your application renders, and sources loaded only when a widget reads them. See Screens. Since v3.9.0 people who may edit it do so in the screen's editor: sections, a widget dialog over your sources and blocks, and the live table as a view editor. See Edit a screen.

Dashboards are an optional registry item, so the table keeps no grid dependency until you install it. Both editions use gridstack.js, loaded on demand with the first desktop grid, never by the table itself or on phones.

Install

npx shadcn@latest add https://table.yayaw.app/r/yayaw-table-dashboard.json

This installs gridstack and the shadcn button, calendar, checkbox, dialog, dropdown-menu, input, native-select, popover and textarea components, and adds the files under components/ui/yayaw-table-dashboard/. The calendar and popover components draw the date range filter, as in the Form view. Like every table, the dashboard renders inside your app's QueryClientProvider (see Setup): its widgets share that one client.

npx shadcn-vue@latest add https://table.yayaw.app/r/yayaw-table-vue-dashboard.json

This installs gridstack and adds the files under components/ui/yayaw-table-vue/dashboard/. The controls use reka-ui, like the table.

Views shown as charts or calendars, and number widgets, need the optional Chart view and Calendar renderers: install them and pass them in displayModeRenderers, as for a table. A chart widget without the chart renderer shows an error instead of the chart.

Try a dashboard

"Openings overview" shows two numbers, a note, a bar chart, a list and a board: saved views of the same Projects table. Pick a category or a start-date range to narrow every widget. Choose Edit to drag, resize or add widgets, then Done to save the dashboard in memory. Open full view names the table and view a real application would open. Expand gives the grid a full browser tab. The dashboard, its saved views and storage are shared by React and Vue; see the example setup for the table.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Add a dashboard

List the tables widgets can show in tables, keyed by table id, and store dashboards through actions.dashboards:

projects-dashboard.tsx
"use client";

import { useRouter } from "next/navigation";
import { calendarRenderer } from "@/components/ui/yayaw-table-calendar/calendar-renderer";
import { chartRenderer } from "@/components/ui/yayaw-table-chart/chart-renderer";
import {
  type DashboardTableSource,
  YayawDashboard,
} from "@/components/ui/yayaw-table-dashboard/yayaw-dashboard";

const tables: Record<string, DashboardTableSource> = {
  projects: { name: "Projects", config: projectsConfig, actions: projectsActions },
  tasks: { name: "Tasks", config: tasksConfig, actions: tasksActions },
};

export function ProjectsDashboard({ canEdit }: { canEdit: boolean }) {
  const router = useRouter();
  return (
    <YayawDashboard
      actions={{ dashboards: dashboardStorage }}
      canEdit={canEdit}
      dashboardId="projects-overview"
      displayModeRenderers={{ calendar: calendarRenderer, chart: chartRenderer }}
      openView={(tableId, viewId) =>
        router.push(`/${tableId}${viewId ? `?view=${viewId}` : ""}`)
      }
      tables={tables}
    />
  );
}
ProjectsDashboard.vue
<script setup lang="ts">
import { useRouter } from "vue-router";
import { calendarRenderer } from "@/components/ui/yayaw-table-vue/calendar/calendar-renderer";
import { chartRenderer } from "@/components/ui/yayaw-table-vue/chart/chart-renderer";
import type { DashboardTableSource } from "@/components/ui/yayaw-table-vue/dashboard/dashboard-types";
import YayawDashboard from "@/components/ui/yayaw-table-vue/dashboard/YayawDashboard.vue";

defineProps<{ canEdit: boolean }>();
const router = useRouter();
const tables: Record<string, DashboardTableSource> = {
  projects: { name: "Projects", config: projectsConfig, actions: projectsActions },
  tasks: { name: "Tasks", config: tasksConfig, actions: tasksActions },
};
const openView = (tableId: string, viewId: string | null) =>
  router.push({ path: `/${tableId}`, query: viewId ? { view: viewId } : {} });
</script>

<template>
  <YayawDashboard
    :actions="{ dashboards: dashboardStorage }"
    :tables="tables"
    :can-edit="canEdit"
    dashboard-id="projects-overview"
    :display-mode-renderers="{ calendar: calendarRenderer, chart: chartRenderer }"
    :open-view="openView"
  />
</template>

Each entry of tables is { config, actions, views?, name? }: the table's config and actions, as you pass them to the table, its static saved views, and the name shown in pickers and titles (the config's title by default). Saved views come from views and from actions.views.list when the table has one, as in the table's view manager.

Props

PropDescription
dashboardA document to show instead of loading one: fetched on the server, a draft to preview, a screen written in code. It is read like a stored document (any version, repaired) and shown again when it changes; an equal document keeps the edits in progress.
actions{ dashboards: { list, load, save, remove } }, where the host stores dashboards. See Store dashboards. Optional when dashboard is given and nobody edits: without save, Edit is not offered.
tablesRecord<tableId, { config, actions, views?, name?, tableProps?, renderTable? }>, the tables widgets can show, given up front. They win over sources.
sources{ list, load }, a lazy catalogue: only the sources the screen's widgets read are loaded. See Sources.
blocksYour application's blocks, by key. See Host blocks.
dashboardIdThe dashboard to load. By default, the first one list() returns.
canEditWhether the user may edit the name, sections, widgets, their views and the filters in the editor. Default false.
showTitleShow the dashboard's name as the screen's heading (h2). Default true; pass false when the page around it shows the title.
unavailableWidgets"show" (default) shows a muted notice in the widgets of an unavailable source and the blocks your application lacks; "hide" leaves them out of the view, grids closing their gaps. Edit mode shows every widget, and the document keeps them either way.
syncUrlKeep the values readers pick for the filters in the URL, and let full-page tables keep their own state there. Default true; false keeps both out of the URL.
openView(tableId, viewId | null, context?) => void. Adds Open full view to view and number widgets, for example to navigate to the table's page with that view; blocks may call it too. viewId is null for inline settings and the table's default view, and context.view holds a widget's inline view.
renderMarkdown(text) => node renders note text, for example as markdown: a React node, or a Vue VNodeChild. Notes are plain text by default.
displayModeRenderersOptional display modes widgets may use, such as { chart, calendar }.
locale"en" by default. French labels are used when it starts with fr. Every widget's table gets it too (dates, numbers, labels), and texts written in several languages show this one.
translationsLabel overrides keyed dashboard.<key>. See Translations.
tableTranslationsThe table labels every widget uses (pagination, empty states, menus), as the table's translations. It is passed with locale to every widget's table. React has no built-in French table labels, so pass your page's own; Vue picks its built-in French from locale and applies these as overrides.
getRowIdStable row id for the embedded tables.
onChange (React) / @change (Vue)Called with the dashboard (version 2) after each change of the document, saved or not. The values readers pick for the filters are not changes of the document.
className (React)Class names on the root.

Widgets

The widget dialog (see Edit a screen) offers numbers, views, table pages (flow sections only), notes and your application's blocks. The three kinds every dashboard has:

  • View of a table: a saved view of any table in tables, or the table's default view, shown in the view's own display mode with its filters, sorting, grouping and settings. It is an embedded table with no toolbar, no URL state and no row selection, with its own loading state, an error with Retry when list fails, and the table's empty state. A widget whose table or view no longer exists says so instead of breaking the page, and one failing widget never breaks the others.

  • Number: one figure over a table or one of its saved views: a count of records, or the sum, average, minimum or maximum of a number column, in the column's number format. The dashboard draws it itself (no chart renderer needed); it follows the view's filters and asks your server through aggregate when it can. It can compare periods and draw a trend line; see Numbers.

  • Note: text, rendered by renderMarkdown or as plain text. Notes are written by dashboard editors: if your renderMarkdown produces HTML, sanitize it as shown for Feed view bodies.

A view or number widget shows a saved view (viewId) or inline settings (view, a saved view's configuration: display mode and its settings, filters, sorts, columns), else the table's default view. Documents may also hold full-page tables (table) and your application's blocks (block), written in JSON or by your code: see Screens. Titles may be written in several languages ({ "en": "Revenue", "fr": "Chiffre d’affaires" }); the dashboard shows the one of its locale.

Widgets without inner scrollbars

View widgets take settings.overflow:

  • "fit" (default): tables, lists, galleries, boards and feeds have no pagination. They load enough records to fill the widget (or the view's own page size, for example a "Top 5" view) and hide the records, board lanes and table columns that do not fit, again on resize. A footer reads "+N more · View all" and opens the full view through openView.

  • "scroll": the view keeps its pagination inside a scrolling widget.

The widget picker offers the choice as "Records that do not fit". Chart widgets fill their widget: the dashboard sets the chart's fill setting, so the chart drops its title and table toggle, and places its legend and labels where they fit.

Numbers

A number widget's settings may add a period comparison and a trend line, both reading a date column:

{
  "metric": "sum",
  "metricColumn": "price",
  "label": "Revenue",
  "dateColumn": "start",
  "compare": { "period": "previous", "days": 30, "better": "up" },
  "sparkline": { "bucket": "month", "buckets": 6 }
}
  • dateColumn: the date column the periods and trend buckets read. compare and sparkline need it.

  • compare: { period: "previous", days?, better? }, or true. The widget shows the change against the period just before, for example "+12% vs previous period", with a positive or negative tone. The current period is the dashboard's date range on dateColumn when a date filter targets it, otherwise the last days days (default 30; the picker offers 7, 30, 90 and 365). better is "up" (default) or "down" when a fall is good.

  • sparkline: { bucket?, buckets? }, or true. A small trend line of the metric over the last buckets date buckets (bucket "day" to "year", default "month"; buckets 2 to 24, default 6). Very narrow widgets reserve their width for the number; the trend line appears when there is room.

Every widget can have its own title. Otherwise, a view widget shows the view's name (or "Projects › Default view"), a number widget its label or the table's name, and a note "Note". Refresh all in the header reloads every widget.

A number widget also accepts settings.valueFormat, with the same display conversion contract as charts:

{
  "metric": "avg",
  "metricColumn": "bitrate",
  "valueFormat": { "scale": 0.000001, "unit": "Mbit/s", "decimals": 2 }
}

The figure and sparkline's accessible values use the conversion. Aggregation, period filters, comparison percentages and sparkline geometry remain raw. The widget editor preserves the format when changing its title or other settings; configure the conversion in the document rather than in the editor. dashboardJsonSchema() advertises the bounded format, and validateDashboard() reports an invalid format as an error before publication.

Layout

A dashboard's sections come in order, each with an optional title (an h3; the dashboard's name is the h2). Two kinds of section place the widgets:

  • Grid: a 4-column grid of cards, with rows of 120px. Widgets are 1 to 4 columns wide and 1 to 12 rows tall. New widgets take the first free spot of the first grid section, with a size by type and display mode: numbers 1×1, notes 1×2, tables, lists, charts and maps 2×2, boards, galleries, calendars, feeds and forms 2×3, file trees 1×3, Gantt charts 4×3. When a widget moves or grows onto others, they make room, and every widget rises to fill the space above it.

  • Flow: widgets stacked at full width and at the height of their content, in the order the section lists them. Record views keep their pagination, charts take a 16:10 body, and a block that renders nothing collapses. Full-page tables only go in a flow.

In edit mode, drag a grid widget by its handle to move it, and drag its corner to resize it. Each widget's menu gives keyboard alternatives to dragging: Move left, Move right, Move up, Move down, Wider, Narrower, Taller, Shorter and Remove; flow widgets move up and down. A move swaps the widget with its neighbor, and entries that cannot apply are disabled. Screen readers announce each move and resize.

Until gridstack.js has loaded (or if it fails to load), a stylesheet places the widgets from their saved positions, so nothing jumps. Grids narrower than 640px, such as phones, stack the widgets in one column, full width and in reading order, without drag; the widget menu still moves them. Stacked numbers and notes take their content's height, charts a 16:10 body from the phone's width, and record widgets keep their rows' height.

Edit and read-only

With canEdit and somewhere to save (actions.dashboards.save), the header shows Edit. In edit mode, the name becomes an input, Add widget, Add section and Add filter appear, each section gets its bar and each widget its drag handle and menu. Done checks the dashboard, saves it through actions.dashboards.save and confirms with a toast, or shows the error if the save fails. See Edit a screen.

Without canEdit, the dashboard is read-only: no Edit button, drag handles or widget menus. Everyone can still pick dashboard filter values, refresh the widgets and open full views; changing a filter value does not save the dashboard. canEdit only hides the controls: check the user's rights again in save and remove on your server.

Dashboard filters

A dashboard filter narrows every widget it applies to. There are two kinds:

  • Date range: a start day, an end day, or both, picked in the library's popover calendar, or a relative period: the last 7, 30 or 90 days (up to today), this month, last month or this year. The button reads "Any date", "From Sep 1, 2026", "Until Sep 10, 2026", "Sep 1, 2026 – Sep 10, 2026" or the period's name.

  • Select: one or more options, picked from a dropdown with "All" and a checkbox per option, and shown as the table's tags. The options are the filter's own, or those of its first column.

In edit mode, Add filter asks for the type, a name, and, for each table on the dashboard, the column the filter applies to (a date column for a date range; a select, multi-select, status or radio column for a select), or Not applied. Under each filter, "Applies to Projects › Due, Tasks › Deadline" lists its columns. A filter's definition and its value in the document, the default readers start from, are saved with the dashboard; edit mode shows and changes the defaults.

The values a reader picks are view state, never written to the document: each one is kept in the URL as <dashboardId>.<filterId>, beside the tables' own keys, so a link shares them. A value equal to the default leaves the URL.

ValueIn the URL
A relative period?projects-overview.due=last30Days
Days?projects-overview.due=2026-09-01..2026-09-30, 2026-09-01.., ..2026-09-30
Options?projects-overview.category=Retail&projects-overview.category=Office
Cleared while the document has a default?projects-overview.due=

A relative period stored as a default ({ "preset": "last30Days" }) is turned into days in the reader's time zone when the widgets query, so every rule still sends days. A number comparing periods compares the period's days with as many days before them.

How filters reach your server

Each active dashboard filter becomes an advanced filter rule on the widget's column:

FilterRule
Date range, both days{ type: "date", operator: "between", values: [start, end] }
Date range, start only{ type: "date", operator: "greaterThanOrEqual", values: [start] }
Date range, end only{ type: "date", operator: "lessThanOrEqual", values: [end] }
Select{ type: "select", operator: "isAnyOf", values: [...] }

Rules also carry id: "dashboard-<filterId>", columnId and isActive: true, and date values are calendar days (YYYY-MM-DD), a relative period's resolved first. The dashboard wraps each widget's list and aggregate and:

  1. adds the rules to the view's own advanced filters, joined with AND (advancedFilterJoin: "and");

  2. also sends them alone as requiredFilters.

A view whose own filters match any rule (OR) cannot take the dashboard's rules in the same flat list, since "(A or B) and C" does not fit in it. For those views, advancedFilters keeps the view's rules and the dashboard's rules come only in requiredFilters. Your list and aggregate must therefore apply requiredFilters with AND on top of everything else. For AND views, the rules are in both places, and applying them twice changes nothing.

{
  advancedFilters: [
    { id: "hot", columnId: "priority", type: "select", operator: "isAnyOf", values: ["High"], isActive: true },
    { id: "late", columnId: "status", type: "select", operator: "isAnyOf", values: ["Late"], isActive: true },
  ],
  advancedFilterJoin: "or",
  requiredFilters: [
    {
      id: "dashboard-due",
      columnId: "dueDate",
      type: "date",
      operator: "between",
      values: ["2026-09-01", "2026-09-30"],
      isActive: true,
    },
  ],
  // search, sorting, page, pageSize…
}

A list built on your existing filter code only needs one more condition:

server/list-projects.ts
type Rule = { columnId: string; operator: string; values?: unknown[] };

/** A dashboard rule as a database condition. */
function requiredCondition({ columnId, operator, values = [] }: Rule) {
  switch (operator) {
    case "isAnyOf":
      return { [columnId]: { in: values } };
    case "between":
      return { [columnId]: { gte: startOfDay(values[0]), lte: endOfDay(values[1]) } };
    case "greaterThanOrEqual":
      return { [columnId]: { gte: startOfDay(values[0]) } };
    case "lessThanOrEqual":
      return { [columnId]: { lte: endOfDay(values[0]) } };
    default:
      throw new Error(`Unsupported dashboard filter: ${operator}`);
  }
}

export async function listProjects(params: Record<string, unknown>) {
  const required = Array.isArray(params.requiredFilters)
    ? (params.requiredFilters as Rule[])
    : [];
  const where = {
    AND: [
      buildWhere(params), // the view's search, filters and advanced filters (AND or OR)
      ...required.map(requiredCondition), // always AND
    ],
  };
  const [rows, totalCount] = await Promise.all([
    db.project.findMany({ where, ...buildPaging(params) }),
    db.project.count({ where }),
  ]);
  return { data: rows, meta: { totalCount } };
}

Check that columnId is one of the table's filterable columns before using it in a query, as for any filter coming from the browser. Apply the same conditions in aggregate so number and chart widgets agree with the lists.

Dashboard JSON

A dashboard is plain JSON, so you can store it in any database column or file:

{
  "version": 2,
  "id": "projects-overview",
  "name": { "en": "Projects overview", "fr": "Vue d’ensemble des projets" },
  "sections": [
    {
      "id": "numbers",
      "type": "grid",
      "layout": [
        { "widgetId": "projects-count", "x": 0, "y": 0, "w": 1, "h": 1 },
        { "widgetId": "revenue", "x": 1, "y": 0, "w": 1, "h": 1 },
        { "widgetId": "welcome", "x": 2, "y": 0, "w": 2, "h": 1 },
        { "widgetId": "status-board", "x": 0, "y": 1, "w": 4, "h": 4 }
      ]
    },
    { "id": "tasks", "type": "flow", "title": "Tasks", "widgetIds": ["open-tasks"] }
  ],
  "widgets": [
    { "id": "projects-count", "type": "kpi", "tableId": "projects", "settings": { "metric": "count", "label": "Projects" } },
    { "id": "revenue", "type": "kpi", "tableId": "projects", "settings": { "metric": "sum", "metricColumn": "price", "label": "Revenue" } },
    { "id": "welcome", "type": "note", "title": { "en": "About this dashboard", "fr": "À propos" }, "settings": { "text": "Projects and tasks at a glance." } },
    { "id": "status-board", "type": "view", "tableId": "projects", "viewId": "status-board", "settings": {} },
    {
      "id": "open-tasks",
      "type": "table",
      "tableId": "tasks",
      "view": { "displayMode": "table", "sorting": [{ "id": "deadline", "desc": false }] },
      "settings": {}
    }
  ],
  "filters": [
    {
      "id": "due",
      "type": "dateRange",
      "label": "Due date",
      "targets": [
        { "tableId": "projects", "columnId": "dueDate" },
        { "tableId": "tasks", "columnId": "deadline" }
      ],
      "value": { "preset": "last30Days" }
    },
    { "id": "category", "type": "select", "label": "Category", "targets": [{ "tableId": "projects", "columnId": "category" }] }
  ]
}
  • version: 2. id (200 characters at most), name and an optional description: texts, a string for every language or one per language ({ "en": "Sales", "fr": "Ventes" }), 120 characters per title and 500 per description.

  • sections: in display order, 12 at most. A grid has a layout of { widgetId, x, y, w, h } (x from 0 to 3, w 1 to 4, h 1 to 12 rows); a flow has widgetIds. Both take an optional title.

  • widgets: 50 at most, placed by id by the sections. id, type ("view", "kpi" for a number, "note", "table" for a full-page table, "block" for your application's block), an optional title, tableId (views, numbers and tables), viewId (a saved view) or view (inline settings; it wins when both are given), block and props (blocks), and settings: { overflow? } for views, { metric, metricColumn?, label?, dateColumn?, compare?, sparkline? } for numbers (metric is count, sum, avg, min or max), { text } for notes (20,000 characters at most), {} for tables and blocks.

  • filters: 12 at most. id, type ("dateRange" or "select"), label, targets ([{ tableId, columnId, widgetIds? }], where widgetIds limits a target to some widgets of that table), optional options ([{ value, label }]) for a select, and value, the default: days ({ start?, end? }) or a relative period ({ preset }: last7Days, last30Days, last90Days, thisMonth, lastMonth, thisYear) for a date range, a list of option values for a select.

  • Ids of widgets, sections and filters are letters, digits, - and _, starting with a letter or digit, 64 characters at most; block keys may also hold . and : (home.summary).

  • updatedAt is optional; the dashboard writes it on save.

Versions and validation

version is the format version, currently 2. validateDashboard(input, { limits?, blocks? }) checks and repairs a document of any version and returns it as version 2: { dashboard?, issues, ok, migratedFrom? }. It never throws.

  • Version 1 (layout and widgets, before sections) becomes one grid section main holding its layout. JSON without version (version 0) is version 1 with react-grid-layout items keyed i.

  • A newer version is refused (unsupportedVersion) rather than guessed, and a widget or section type the document's version does not know is dropped: a new type bumps the version, so an older reader refuses a newer document instead of losing its widgets. v3.7.0 and earlier refuse version 2.

  • Each issue has a code, a severity and a JSON path in the input, such as widgets[2].view.sorting[0].id. Errors mean the document lost something it asked for: invalid widgets, sections, filters and values, lists and texts cut to their limits, a document or block properties too large, properties a block rejected. Warnings are repairs that keep its meaning: a layout put back in the grid, orphan and misplaced widgets moved to a section that takes them, ids made valid and unique, unknown keys removed, an inline view preferred to a saved one, a block your application lacks (with blocks). ok is true when there is a document and no error.

  • Limits (DASHBOARD_LIMITS, each overridable in limits): 12 sections, 50 widgets, 12 filters, 120 characters per title, 500 per description, 20,000 per note, 16,384 characters of block properties nested 8 deep, 50 rules per list of an inline view, 262,144 bytes per document. Issues stop at 200.

normalizeDashboard(input) is the lenient reading the dashboard itself uses: the repaired document whatever its issues, or an error when the input is not a dashboard or too new. Inline views go through sanitizeViewConfig (the table's utils/view-config.ts): unknown keys removed, values type-checked, hostile JSON harmless.

On your server, checkDashboardReferences(dashboard, { sources, blocks? }) checks a validated document against what this user may see: sources (unknownSource, or unavailableSource when listed but unavailable), saved views (unknownView), the columns of sorts, filters, visibility, grouping, numbers, display mode settings and filter targets (unknownColumn), display modes (unsupportedDisplayMode) and blocks (unknownBlock). A source summary's columns, views and displayModes are only checked when given. dashboardJsonSchema({ sourceIds?, blocks?, limits? }) is the JSON Schema of a version 2 document for AI tool inputs (MCP): with sourceIds, widgets and filters may only name those sources; with blocks, each block is a widget variant carrying its propsSchema. canonicalDashboardJson(input) and dashboardFingerprint(input) (SHA-256, the same in browsers and on servers) compare documents whatever their version, key order or save time.

The builders return new documents and never change their input: createDashboard, addDashboardSection, addDashboardWidget, moveWidgetToSection, removeDashboardWidget, moveDashboardWidget, resizeDashboardWidget and applyDashboardSectionLayout.

These functions live in dashboard-schema.ts (components/ui/yayaw-table-dashboard/ in React, components/ui/yayaw-table-vue/dashboard/ in Vue), with the sources' contract in dashboard-sources.ts: both import no React, Vue or CSS, so a server can use them. dashboard-model.ts still exports what moved there.

Store dashboards

actions.dashboards has four functions:

FunctionDescription
list()The dashboards the user may see, as [{ id, name }]. Without dashboardId, the first one is shown.
load(id)The dashboard's JSON, of any version: it is migrated to version 2 before use.
save(dashboard)Stores the dashboard, as version 2 (Done in edit mode).
remove(id)Deletes a dashboard.

For example, with Next.js server actions:

app/dashboards/actions.ts
"use server";

import {
  type Dashboard,
  validateDashboard,
} from "@/components/ui/yayaw-table-dashboard/dashboard-schema";

export async function listDashboards() {
  const user = await requireUser();
  return db.dashboard.findMany({
    where: { organizationId: user.organizationId },
    select: { id: true, name: true },
    orderBy: { name: "asc" },
  });
}

export async function loadDashboard(id: string) {
  const user = await requireUser();
  const row = await db.dashboard.findFirstOrThrow({
    where: { id, organizationId: user.organizationId },
  });
  return row.content; // JSON column
}

export async function saveDashboard(input: Dashboard) {
  const user = await requireDashboardEditor(); // the same rule as canEdit
  const { dashboard, issues, ok } = validateDashboard(input);
  if (!(ok && dashboard)) {
    const error = issues.find((issue) => issue.severity === "error");
    throw new Error(error?.message ?? "Not a dashboard.");
  }
  const content = { ...dashboard, updatedAt: new Date().toISOString() };
  await db.dashboard.upsert({
    where: { id: dashboard.id },
    create: { id: dashboard.id, organizationId: user.organizationId, name: dashboard.name, content },
    update: { name: dashboard.name, content },
  });
  return content;
}

export async function removeDashboard(id: string) {
  const user = await requireDashboardEditor();
  await db.dashboard.deleteMany({ where: { id, organizationId: user.organizationId } });
}
const dashboardStorage = {
  list: listDashboards,
  load: loadDashboard,
  save: saveDashboard,
  remove: removeDashboard,
};

Keep dashboardStorage stable (outside the component, or memoized), since the dashboard reloads when it changes. With a REST API, each function is a fetch to your routes, such as GET /api/dashboards, GET, PUT and DELETE /api/dashboards/:id. A dashboard stores widget layouts and view ids, never rows: each widget still loads its records through its table's list, with that table's access rules.

Screens

A screen is a dashboard that makes up a page of your application: its sections, full-page tables and your own blocks, shown with dashboard from a document you load on the server (a stored copy, or a default written in code) and usually showTitle={false}.

<YayawDashboard
  dashboard={screen} // or actions={{ dashboards }} and dashboardId
  sources={catalogue} // DashboardSources<DashboardTableSource>
  blocks={blocks} // your blocks, by key
  showTitle={false} // the page around it shows the title
  unavailableWidgets={canManage ? "show" : "hide"}
  openView={(tableId, viewId, context) => router.push(listPage(tableId, viewId, context?.view))}
  locale="fr"
/>

Sources

sources is a lazy catalogue of the tables widgets may read: list() gives their summaries (for editors and AI tools), and load(id) gives one source, { config, actions, views?, name?, tableProps?, renderTable? }, or { unavailable: true, reason?, message? } when this user may not see it (forbidden), it is not configured (notConfigured) or it no longer exists (notFound). The dashboard loads only the sources its widgets read, once each, sharing concurrent loads; tables given up front win over the catalogue. A widget shows "Loading…", then its content, or an error with Retry when the load failed, or a muted notice when the source is unavailable: your message, else a text for its reason. Unavailable widgets are never removed: edit mode shows them and Done saves them with their settings. createDashboardSourceLoader in dashboard-sources.ts gives the same loading to your own code.

Full-page tables

A table widget (flow sections only) is the source's list page: the table with its toolbar, saved views, selection, bulk actions and URL state, without a card. Your code stays out of the document:

  • tableProps gives the table the host's own props: row, toolbar and bulk actions, getFormConfig, record details, file tree hooks (React Partial<DataTableProps>, Vue YayawDataTable props in camelCase).

  • renderTable(props) wraps or replaces the table: it receives the props the dashboard would give DataTable (Vue YayawDataTable) and must pass them on. The dashboard keeps the table's id, config, actions and starting views: the widget's inline view becomes a default view of the table, screen:<dashboardId>:<widgetId>, after the reader's favorite, and screen filters reach every request as requiredFilters.

  • The screen's first table keeps the table's own URL keys (view, <tableId>-…), so links to its list page keep working; the others use their widget's id.

Host blocks

A block widget places your application's code by key, with JSON properties: { "id": "storage", "type": "block", "block": "media.storage", "props": { "unit": "GB" }, "settings": {} }. Pass the blocks as blocks, each one a DashboardBlockSchema (label, description, group, placement "grid", "flow" or "any", defaultSize, defaultProps, propsSchema, validateProps) with its component:

import type { DashboardBlockRegistry } from "@/components/ui/yayaw-table-dashboard/dashboard-block";

const blocks: DashboardBlockRegistry = {
  "media.storage": {
    label: { en: "Storage", fr: "Stockage" },
    placement: "flow",
    defaultProps: { unit: "GB" },
    propsSchema: { type: "object", properties: { unit: { enum: ["GB", "MB"] } } },
    component: StorageBlock,
  },
};

A block receives { widgetId, props, size?, editing, locale, revision, filters, refresh, setFilter, filterRules, openView? }: its props over its defaultProps, its size in a grid (none in a flow), the screen's filter values by filter id (a relative period resolved to its days), revision, which changes with Refresh all and after changes to the screen's data, and since v3.9.0 setFilter and filterRules. A block may also give a settings component, which the widget dialog shows to edit its props. A block that throws shows its error in its widget only; a block that renders nothing collapses in a flow. A key your application lacks shows "Unavailable block" and is kept on save. Block properties are data: validate them with validateProps (it also runs in validateDashboard) and never execute them.

Blocks that set filters

A block can drive the screen. Its props also hold setFilter(filterId, value), which changes a screen filter exactly as the filter bar does (the reader's value goes in the URL; in edit mode it becomes the document's default), and filterRules(tableId, { exclude? }), the rules the screen's filters give a source, to join its own list or aggregate requests as requiredFilters. setFilter checks the value first and answers { ok: true, value }, or { ok: false, code, message } without changing anything:

FilterTakesRefused with
selectA text or number, or a list of them, among the filter's options (else the options of the column it targets)invalidValue
dateRange{ start?, end? } days (YYYY-MM-DD, in order), or a known { preset }invalidValue
anyundefined, null, [] or {}: clears the filter
a filter the screen does not havenothingunknownFilter

The library ships one such block, the facet list: a column's values with their numbers of records under the screen's other filters, whose clicks set a select filter ("All" clears it). Counts come from the source's aggregate, else from the rows its list returns (2,000 at most). Register it with createFacetBlock:

// React: "@/components/ui/yayaw-table-dashboard/dashboard-facet-block"
// Vue: "@/components/ui/yayaw-table-vue/dashboard/dashboard-facet-block"
import { createFacetBlock } from "@/components/ui/yayaw-table-dashboard/dashboard-facet-block";

const blocks: DashboardBlockRegistry = {
  "pages.sections": createFacetBlock({
    filterId: "section", // a select filter of the screen
    tableId: "pages", // the source it counts
    column: { id: "section", header: "Section", type: "select", options },
    actions: pageActions, // the source's list and aggregate
    label: { en: "Sections", fr: "Rubriques" },
    layout: "chips", // or "list"
  }),
};

The widget's props are { filterId?, layout?: "chips" | "list", showCounts? }, checked by the block's validateProps.

Notices and refreshes

A list or aggregate answer may carry meta.notice (TableNotice: { code?, message? } or a text) when the source has nothing to show for a reason, such as { code: "notConfigured", message: "Connect an analytics provider." }. Numbers, views and full-page tables then show a muted notice instead of empty data: its message, else the text of a known code (forbidden, notConfigured, notFound, error).

Refresh all reloads every widget: numbers, views, blocks (their revision) and full-page tables, and retries the sources that failed to load. A change made in a full-page table (create, update, delete, duplicate, the bulk actions, an import, a file tree move or new folder) reloads the other widgets of that source and the blocks, once for a burst of changes.

Edit a screen

With canEdit and actions.dashboards.save, Edit turns the screen into its editor, the same in React and Vue. Your application decides who may edit and stores what Done saves; an AI tool can draft documents with the same grammar (dashboardJsonSchema) for a person to publish. The editor is a chunk of its own, loaded when edit mode starts, so readers never download it.

Sections

Add section adds a Grid of cards or a Full width section at the end, up to 12. In edit mode each section has a bar with its title (the current language's text; an empty title removes it) and a menu: Move up, Move down, Add widget here and Remove, which asks first when the section still holds widgets. Empty sections show in edit mode only. A widget moves to another section with Move to section in its menu, which lists the sections that take it (a table page goes to flows only, a block where its placement allows): it takes the first free spot of a grid, or the last place of a flow. Within a section, grid cards still drag, move and resize, and flow widgets move up and down.

The widget dialog

Add widget (the header), Add widget here (a section) and Edit… (a widget's menu) open one dialog in three steps:

  1. What: a number, a view, a table page (flows only), a note, then your blocks that the section takes, under their group, with their description.

  2. Source (numbers, views and table pages): your catalogue, asked once with sources.list(), searched by name, id, description, group and keywords, grouped, with the sources this user cannot use listed disabled with their reason. Picking one loads it (sources.load, once); if it turns out unavailable, the dialog says why.

  3. Settings: the title first, then the source and its Start from view (default, saved or custom; Edit view… opens the view editor). Numbers group their measure and column under Calculation, then their date column, comparison and trend line under Period and comparison. Views expose their overflow, notes their text, and blocks their props.

The dialog header and actions stay visible while its fields scroll. On narrow screens, comparison fields and footer actions stack vertically. Widget headings sit below section headings; a long widget title can use two lines. These presentation changes preserve saved documents and settings.

Editing a widget opens on the settings step, and Apply keeps its id and place. A block with a settings component shows it: it receives { widgetId, props, locale, onChange }, with props over the block's defaultProps. Otherwise the props are JSON in a text area. Either way they are checked before Add or Apply (checkDashboardBlockProps, then the block's validateProps): invalid JSON and errors are refused and listed with their paths.

The view editor

Edit view… (a view, number or table page widget's menu, or a custom view in the dialog) opens a near full-screen dialog whose editor is the source's live table: its toolbar, search, filters, sort, columns, display modes and every mode's settings, without URL sync, saved views, row selection or record changes. Apply stores the view the table shows in the widget's inline view, without the page size unless you changed it. Closing with changes asks first.

The table reports its view for this: React DataTable takes onViewConfigChange(config), Vue YayawDataTable emits view-config-change and exposes getViewConfig(), and toolbar actions get getViewConfig() in their context. The config is canonicalViewConfig(view), the view's settings sanitized like saved views, without the select and actions columns and with its keys in one order, reported when the table starts and after each change.

Views: copies and the screen default

  • Use a copy of this view (a widget naming a saved view): the saved view's settings become the widget's inline view, so later changes to the saved view no longer reach the screen.

  • Make the current view the screen default (table pages): the view the page table shows now becomes the widget's inline view, the screen's own default view (screen:<dashboardId>:<widgetId>).

Saving

Done checks the document with validateDashboard(document, { blocks }). With errors nothing is saved: the screen stays in edit mode and lists each one above the sections, named after its widget, section or filter. Otherwise the version 2 document is saved with updatedAt. Your server should check it again (validateDashboard, checkDashboardReferences and your own rules) before it stores it.

Several tables on one page

Every view and number widget is its own table instance: URL sync off, its own state, and its saved view applied before its first request. It uses the table props instanceId and initialView, which you can also use to put several tables on one page yourself. See Several tables on one page. Full-page tables keep their URL state, the first one under the table's own keys.

Translations

The dashboard has built-in English and French labels; French is used when locale starts with fr. Override any of them with dashboard.<key> keys in the translations prop, in React and Vue, for example "dashboard.refresh": "Reload".

  • Header and states: dashboard, edit, done, saving, saved, saveError, loadError (both with {error}), loading, notFound, refresh, empty, emptyEditable.

  • Add widget: addWidget, addWidgetTitle, widgetType, typeView, typeKpi, typeNote, typeTable, typeBlock, table, view, defaultView, metric, metricCount, metricSum, metricAvg, metricMin, metricMax, metricColumn, widgetTitle, noteText, add, cancel.

  • Widgets: widgetMenu, dragHandle (both with {title}), moveLeft, moveRight, moveUp, moveDown, wider, narrower, taller, shorter, remove, openFullView, widgetLoading, widgetError (with {error}), retry, missingTable, missingView, emptyNote, kpiCount, moved, resized (both with {title}), moreCount (with {count}), viewAll, overflow, overflowFit, overflowScroll, screenDefaultView, unknownBlock.

  • Sources and notices: unavailableForbidden, unavailableNotConfigured, unavailableNotFound, unavailableError, noticeDefault.

  • Numbers: dateColumn, noDateColumn, compare, compareDays, lastDays (with {count}), compareBetter, compareUp, compareDown, sparkline, compareChange (with {change}), compareNoPrevious, comparePeriods (with {current} and {previous}), trendTitle (with {values}).

  • Filters: filters, from, to, any, clear, appliesTo (with {targets}), addFilter, addFilterTitle, filterType, filterDateRange, filterSelect, filterName, filterColumn (with {table}), notApplied, removeFilter (with {name}), noFilterColumns, anyDate, fromDate, untilDate (both with {date}), dateRange (with {start} and {end}), presets, presetLast7Days, presetLast30Days, presetLast90Days, presetThisMonth, presetLastMonth, presetThisYear.

  • Sections (v3.9.0): addSection, sectionGrid, sectionFlow, sectionTitle, sectionNumber, sectionMenu, addWidgetHere, emptySection, removeSectionTitle, removeSectionOne, removeSectionMany, sectionRemoved, moveToSection, movedToSection.

  • The widget dialog and the view editor (v3.9.0): editWidget, editWidgetTitle, editView, useViewCopy, viewCopied, makeScreenDefault, screenDefaultSet, stepWhat, stepSource, stepSettings, stepOf, chooseKind, chooseSource, kindKpi, kindKpiHint, kindView, kindViewHint, kindTable, kindTableHint, kindNote, kindNoteHint, blocks, searchSources, noSources, loadingSources, sourcesError, sourceLoading, back, apply, startFrom, savedViews, customView, customViewHint, blockProps, invalidJson, propsRefused, viewEditorTitle, viewEditorDescription, unsavedChanges, close, discardTitle, discardDescription, keepEditing, discard, applyAndClose, saveIssues, dismiss.

  • Filters set by blocks and the facet list (v3.9.0): unknownFilter, invalidDateRange, invalidSelect, unknownOption, facetBlock, facetBlockDescription, facetAll, facetClear, facetLoading, facetEmpty, facetError.

Changes you may notice

The dashboard release also aligns a few details of the React and Vue tables, visible outside dashboards too:

  • Booleans render the same way in every cell (table, list, board and gallery cards): a checked box for true and an empty box for false, named "True" or "False" for screen readers (common.true, common.false). React used to show green or red "True"/"False" badges, and Vue a ✓ or "—".

  • Board cards: compact cards (without property labels) leave out properties with nothing to show (empty, blank text, empty list; false and 0 are still shown). Vue board cards now show the visible columns by default, as React does, instead of every column.

  • Card pagination (list, gallery and board) appears when there is more than one page, by the server's page count or by the number of rows, in both editions. Vue used the server's page count only.

  • Vue number charts now show the figure and what it counts, as in React.