@svgrid/mcp 3.0.13 → 3.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data.js +457 -22
- package/dist/preview.d.ts +1 -1
- package/dist/preview.js +1 -1
- package/dist/validate.js +24 -0
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/data.js
CHANGED
|
@@ -3063,6 +3063,54 @@ export const examples = [
|
|
|
3063
3063
|
"blurb": "503. Big data: up to 10,000,000 rows x 10,000 columns One SvGrid on the Enterprise server row model. The rows live behind a Web Worker that answers the model's requests like a database would: it sorts",
|
|
3064
3064
|
"source": "<script lang=\"ts\">\n /**\n * 503. Big data: up to 10,000,000 rows x 10,000 columns\n * ------------------------------------------------------\n * One SvGrid on the Enterprise server row model. The rows live behind a Web\n * Worker that answers the model's requests like a database would: it sorts\n * (radix, exact), filters, groups and applies edits over a generated\n * dataset, and sends back only the ids of the rows asked for. The page\n * builds just the rows on screen, so the grid's memory and per-scroll work\n * do not grow with the row count.\n *\n * Pick a size, then sort a column, type in a filter, group, or edit a cell\n * (balance, rating, status, game, bought, the months and the metrics are\n * editable). \"Run benchmark\" times each of those against the current size\n * and lists the results. Every number on the page is measured live in this\n * browser.\n */\n import { onDestroy, onMount } from 'svelte'\n import {\n SvGrid,\n renderComponent,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type ConditionalFormat,\n type SvGridApi,\n } from '@svgrid/grid'\n import { createServerRowModel, serverGroupText, SvGroupCell, type ServerRowModelGridRow } from '@svgrid/enterprise'\n import { createBigDataClient, type Activity, type BigRow } from '../shared/bigdata/client'\n import {\n columnsFor,\n COUNTRY_BY_NAME,\n CONTINENTS,\n GAMES,\n LANGUAGES,\n STATUSES,\n type BigColumn,\n } from '../shared/bigdata/data'\n\n // What the grid holds: a data row or a group row, each carrying __group.\n type GridRow = ServerRowModelGridRow<BigRow>\n\n const ROW_OPTIONS = [10_000, 100_000, 1_000_000, 10_000_000]\n const COL_OPTIONS = [10, 25, 100, 1_000, 10_000]\n const GROUPINGS: Array<{ label: string; by: string[] }> = [\n { label: 'None', by: [] },\n { label: 'Continent > Country', by: ['continent', 'country'] },\n { label: 'Country', by: ['country'] },\n { label: 'Game', by: ['game'] },\n { label: 'Status', by: ['status'] },\n { label: 'Rating', by: ['rating'] },\n ]\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })\n const full = new Intl.NumberFormat('en-US')\n const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })\n const fmtMs = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : ms >= 10 ? `${Math.round(ms)} ms` : `${ms.toFixed(1)} ms`)\n const sizeLabel = (n: number) => (n >= 1_000_000 ? `${n / 1_000_000}M` : n >= 1_000 ? `${n / 1_000}K` : String(n))\n\n let rowCount = $state(1_000_000)\n let colCount = $state(25)\n let grouping = $state(0)\n let search = $state('')\n let activity = $state<Activity>({})\n let ctl = $state<ReturnType<typeof createServerRowModel<BigRow>> | null>(null)\n let columns = $state<ColumnDef<any, GridRow>[]>([])\n let pinning = $state<{ left: string[]; right: string[] }>({ left: [], right: [] })\n let api: SvGridApi<any, GridRow> | null = null\n let gridKey = $state(0)\n let readyMs = $state<number | null>(null)\n let editNote = $state('')\n\n const client = createBigDataClient((a) => (activity = a))\n\n // ---- columns ---------------------------------------------------------------\n\n const chip = (labels: readonly string[]) => ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const i = labels.indexOf(String(value))\n return i < 0 ? 'bd-chip' : `bd-chip bd-c${i % 8}`\n }\n const moneyClass = ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const n = Number(value)\n if (!Number.isFinite(n)) return ''\n return n < 0 ? 'bd-neg' : n >= 15_000 ? 'bd-pos3' : n >= 8_000 ? 'bd-pos2' : n >= 2_000 ? 'bd-pos1' : ''\n }\n const heatClass = ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const n = Number(value)\n return Number.isFinite(n) ? `bd-h${Math.min(9, Math.max(0, Math.floor(n / 100)))}` : ''\n }\n const stars = (v: unknown) => {\n const n = Math.round(Number(v))\n return Number.isFinite(n) && n >= 1 ? '★'.repeat(Math.min(5, n)) + '☆'.repeat(Math.max(0, 5 - n)) : String(v ?? '')\n }\n\n /** A data row, as opposed to a group row of the server row model. */\n const isLeaf = (row: { original?: unknown } | undefined) =>\n (row?.original as { __group?: { kind?: string } } | undefined)?.__group?.kind === 'leaf'\n /** Format data rows; a group row has no value in this column, so show nothing. */\n const leafOnly =\n (fmt: (value: unknown) => string) =>\n ({ value, row }: { value: unknown; row: { original?: unknown } }) =>\n isLeaf(row) ? fmt(value) : ''\n const editableLeaf = (ctx: { row: { original?: unknown } }) => isLeaf(ctx.row)\n\n function toColumnDef(c: BigColumn): ColumnDef<any, GridRow> {\n const base = { field: c.field, header: c.header, width: c.width } as ColumnDef<any, GridRow>\n switch (c.field) {\n case 'id':\n return { ...base, align: 'right', cellClass: 'bd-id', editable: false, formatter: leafOnly((v) => full.format(Number(v))) }\n case 'name':\n // The group column too: a group row shows its chevron, label and row\n // count here; a data row shows the person's name.\n return {\n id: 'name',\n header: 'Name',\n width: 250,\n editable: false,\n cellClass: 'bd-name',\n fieldFn: (row) => serverGroupText(row as never, 'name'),\n cell: (ctx) =>\n renderComponent(SvGroupCell, {\n row: ctx.row.original as never,\n onToggle: () => ctl?.group.onToggle(ctx.row.original as never),\n leafField: 'name',\n }),\n }\n case 'email':\n return { ...base, cellClass: 'bd-muted', editable: false, formatter: leafOnly((v) => String(v)) }\n case 'country':\n return { ...base, editable: false, formatter: leafOnly((v) => `${COUNTRY_BY_NAME.get(String(v))?.flag ?? ''} ${String(v)}`) }\n case 'language':\n return { ...base, editable: false, cellClass: chip(LANGUAGES), formatter: leafOnly((v) => String(v)) }\n case 'continent':\n return { ...base, editable: false, cellClass: chip(CONTINENTS), formatter: leafOnly((v) => String(v)) }\n case 'game':\n return { ...base, cellClass: chip(GAMES), editable: editableLeaf, editorType: 'select', editorOptions: [...GAMES], formatter: leafOnly((v) => String(v)) }\n case 'bought':\n // A boolean renders as a checkbox on data rows; group rows have none.\n return { ...base, align: 'center', editable: editableLeaf, formatter: leafOnly((v) => (v ? 'Yes' : 'No')) }\n case 'rating':\n return {\n ...base,\n cellClass: 'bd-stars',\n editable: editableLeaf,\n editorType: 'number',\n // On a group row the value is the average rating.\n formatter: ({ value, row }) => (isLeaf(row) ? stars(value) : value == null ? '' : `avg ${Number(value).toFixed(1)}`),\n }\n case 'balance':\n return { ...base, align: 'right', editable: editableLeaf, editorType: 'number', formatter: ({ value }) => (value == null ? '' : usd.format(Number(value))) }\n case 'status':\n return { ...base, editable: editableLeaf, editorType: 'select', editorOptions: [...STATUSES], cellClass: ({ getValue }) => { const value = getValue(); return value ? `bd-pill bd-s-${String(value).toLowerCase()}` : '' }, formatter: leafOnly((v) => String(v)) }\n case 'joined':\n return { ...base, editable: false, cellClass: 'bd-muted', formatter: leafOnly((v) => String(v)) }\n case 'total':\n return { ...base, align: 'right', editable: false, cellClass: 'bd-total', formatter: ({ value }) => (value == null ? '' : usd.format(Number(value))) }\n default:\n if (c.kind === 'money') {\n return { ...base, align: 'right', editable: editableLeaf, editorType: 'number', cellClass: moneyClass, formatter: leafOnly((v) => usd.format(Number(v))) }\n }\n // A metric is not on the row object: read it from the row index, so\n // 10,000 columns cost nothing until a cell is on screen.\n return {\n id: c.field,\n header: c.header,\n width: c.width,\n fieldFn: (row) => client.cell(row, c.field),\n align: 'right',\n editable: editableLeaf,\n editorType: 'number',\n cellClass: heatClass,\n formatter: ({ value }) => (value == null ? '' : Number(value).toFixed(2)),\n }\n }\n }\n\n // Data bars with fixed ranges: the grid never scans the 10M rows for a min\n // and max, it scales each cell against the range the data is drawn from.\n const conditionalFormats: ConditionalFormat<GridRow>[] = [\n { type: 'dataBar', columns: ['balance'], color: '#3b82f6', gradient: true, minValue: 0, maxValue: 999_999 },\n { type: 'dataBar', columns: ['total'], color: '#10b981', negativeColor: '#ef4444', minValue: -60_000, maxValue: 240_000 },\n ]\n\n // ---- the dataset -----------------------------------------------------------\n\n async function rebuild() {\n readyMs = null\n editNote = ''\n const t0 = performance.now()\n ctl?.dispose()\n ctl = null\n await client.configure(rowCount, colCount)\n const cols = columnsFor(colCount)\n columns = cols.map(toColumnDef)\n const fields = new Set(cols.map((c) => c.field))\n pinning = {\n left: fields.has('name') ? ['name'] : [],\n right: fields.has('total') ? ['total'] : [],\n }\n ctl = createServerRowModel<BigRow>(client.source, {\n blockSize: 100,\n maxBlocksInCache: 80,\n blockLoadDebounceMs: 30,\n keepRowsWhileLoading: 800,\n groupBy: GROUPINGS[grouping]!.by.filter((f) => fields.has(f)),\n aggregations: [\n { col: 'balance', fn: 'sum' },\n { col: 'total', fn: 'sum' },\n { col: 'rating', fn: 'avg' },\n ],\n childCount: (r) => Number((r as Record<string, unknown>).__count) || undefined,\n getRowId: (r) => String(r.id),\n })\n gridKey += 1\n await waitPainted()\n readyMs = performance.now() - t0\n }\n\n function setGrouping(i: number) {\n grouping = i\n ctl?.setGroupBy(GROUPINGS[i]!.by.filter((f) => columns.some((c) => c.field === f)))\n }\n\n let searchTimer: ReturnType<typeof setTimeout> | null = null\n function onSearch(value: string) {\n search = value\n if (searchTimer) clearTimeout(searchTimer)\n searchTimer = setTimeout(() => {\n if (ctl) ctl.setFilter({ ...ctl.getState().filterModel, global: value })\n }, 250)\n }\n\n async function onEdit(e: { row: GridRow; columnId: string; newValue: unknown; oldValue: unknown }) {\n const meta = (e.row as Record<string, unknown>).__group as { kind?: string } | undefined\n if (meta && meta.kind !== 'leaf') return\n if (Object.is(e.newValue, e.oldValue)) return\n const value = typeof e.oldValue === 'number' ? Number(e.newValue) : e.newValue\n await ctl?.updateRow(String(e.row.id), { [e.columnId]: value } as Partial<BigRow>)\n const r = activity.edit\n editNote = r ? `Saved in ${fmtMs(r.ms)} in the worker; ${r.patchedViews} cached view${r.patchedViews === 1 ? '' : 's'} patched in place` : ''\n }\n\n // ---- measuring -------------------------------------------------------------\n\n /** Resolve once the rows on screen are real rows, not loading placeholders. */\n function waitPainted(timeout = 180_000): Promise<void> {\n const start = performance.now()\n return new Promise((resolve) => {\n const check = () => {\n const host = document.querySelector('.bd-grid')\n const rows = host ? [...host.querySelectorAll('tbody tr')] : []\n const busy = rows.some((r) => r.getAttribute('aria-busy') === 'true')\n const loading = !!host?.querySelector('.sv-grid-loading-overlay, .sv-grid-skeleton-row')\n if ((rows.length > 0 && !busy && !loading) || performance.now() - start > timeout) {\n requestAnimationFrame(() => requestAnimationFrame(() => resolve()))\n return\n }\n requestAnimationFrame(check)\n }\n requestAnimationFrame(check)\n })\n }\n\n // Frame meter: frames per second and the longest frame in the last second.\n let fps = $state(0)\n let longest = $state(0)\n let domCells = $state(0)\n let heapMb = $state<number | null>(null)\n let raf = 0\n onMount(() => {\n let frames = 0\n let worst = 0\n let last = performance.now()\n let windowStart = last\n const loop = (t: number) => {\n frames += 1\n worst = Math.max(worst, t - last)\n last = t\n if (t - windowStart >= 1000) {\n fps = Math.round((frames * 1000) / (t - windowStart))\n longest = worst\n frames = 0\n worst = 0\n windowStart = t\n domCells = document.querySelectorAll('.bd-grid td').length\n const mem = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory\n heapMb = mem ? Math.round(mem.usedJSHeapSize / 1e6) : null\n }\n raf = requestAnimationFrame(loop)\n }\n raf = requestAnimationFrame(loop)\n void rebuild()\n })\n onDestroy(() => {\n cancelAnimationFrame(raf)\n ctl?.dispose()\n client.dispose()\n })\n\n // ---- benchmark -----------------------------------------------------------------\n\n type BenchRow = { step: string; ms: number; worker?: number; note: string }\n let bench = $state<BenchRow[]>([])\n let benchRunning = $state(false)\n\n /**\n * Resolve when the step is really done: no request is waiting on the worker\n * and the rows on screen are real, for four frames running (so a debounced\n * request has had its chance to start). Returns the time of the first of\n * those frames, so the wait itself is not counted.\n */\n function waitSettled(timeout = 600_000): Promise<number> {\n const start = performance.now()\n let quietSince = 0\n let quietFrames = 0\n return new Promise((resolve) => {\n const check = (t: number) => {\n const host = document.querySelector('.bd-grid')\n const rows = host ? [...host.querySelectorAll('tbody tr')] : []\n const busy = rows.some((r) => r.getAttribute('aria-busy') === 'true')\n const quiet = client.pending() === 0 && rows.length > 0 && !busy\n if (quiet) {\n if (quietFrames === 0) quietSince = t\n quietFrames += 1\n } else {\n quietFrames = 0\n }\n if (quietFrames >= 4 || t - start > timeout) {\n resolve(quietFrames >= 4 ? quietSince : t)\n return\n }\n requestAnimationFrame(check)\n }\n requestAnimationFrame(check)\n })\n }\n\n async function timed(step: string, note: string, action: () => unknown): Promise<void> {\n const seq = activity.compute?.seq ?? 0\n const t0 = performance.now()\n await action()\n const settled = await waitSettled()\n const ms = settled - t0\n // The worker's compute time, if this step made the worker compute anything.\n const w = activity.compute\n bench = [...bench, { step, ms, worker: w && w.seq > seq ? w.ms : undefined, note }]\n }\n\n function scroller(): HTMLElement | null {\n return document.querySelector('.bd-grid .sv-grid-container')\n }\n\n async function runBenchmark() {\n if (benchRunning) return\n benchRunning = true\n bench = []\n grouping = 0\n search = ''\n try {\n await timed('Load the dataset', `${full.format(rowCount)} rows x ${full.format(colCount)} columns`, () => rebuild())\n await timed('Jump to the middle', `row ${full.format(Math.floor(rowCount / 2))}`, () => {\n const s = scroller()\n if (s) s.scrollTop = (s.scrollHeight - s.clientHeight) / 2\n })\n await timed('Jump to the last row', `row ${full.format(rowCount)}`, () => {\n const s = scroller()\n if (s) s.scrollTop = s.scrollHeight\n })\n if (colCount >= 100) {\n await timed('Scroll to the last column', `${full.format(colCount)} columns`, () => {\n const s = scroller()\n if (s) s.scrollLeft = s.scrollWidth\n })\n }\n await timed('Sort by bank balance', 'radix sort, every row', () => api?.setSort('balance', 'desc'))\n await timed('Filter: country is Japan', 'one pass over every row', () =>\n api?.setFilter('country', { operator: 'equals', value: 'Japan' } as never),\n )\n await timed('Clear the filter', 'back to the sorted view, cached', () => api?.clearAllFilters?.())\n await timed('Group by continent and country', 'counts and sums in one pass', () => setGrouping(1))\n await timed('Expand Europe', 'the first level of children', () => ctl?.expandGroup(['Europe']))\n await timed('Ungroup', '', () => setGrouping(0))\n } finally {\n benchRunning = false\n }\n }\n\n const cells = $derived(rowCount * colCount)\n const opLabel: Record<string, string> = {\n identity: 'Served in id order',\n cached: 'Served from the cached view',\n scan: 'Filtered',\n sort: 'Sorted',\n 'scan+sort': 'Filtered and sorted',\n group: 'Grouped',\n }\n</script>\n\n<section class=\"bd-wrap\">\n <div class=\"bd-controls\">\n <div class=\"bd-field\">\n <span class=\"bd-label\">Rows</span>\n <div class=\"bd-seg\" role=\"radiogroup\" aria-label=\"Rows\">\n {#each ROW_OPTIONS as n (n)}\n <button role=\"radio\" aria-checked={rowCount === n} class:on={rowCount === n} disabled={benchRunning} onclick={() => { rowCount = n; void rebuild() }}>{sizeLabel(n)}</button>\n {/each}\n </div>\n </div>\n <div class=\"bd-field\">\n <span class=\"bd-label\">Columns</span>\n <div class=\"bd-seg\" role=\"radiogroup\" aria-label=\"Columns\">\n {#each COL_OPTIONS as n (n)}\n <button role=\"radio\" aria-checked={colCount === n} class:on={colCount === n} disabled={benchRunning} onclick={() => { colCount = n; void rebuild() }}>{sizeLabel(n)}</button>\n {/each}\n </div>\n </div>\n <div class=\"bd-field\">\n <span class=\"bd-label\">Group by</span>\n <select value={grouping} disabled={benchRunning} onchange={(e) => setGrouping(Number(e.currentTarget.value))}>\n {#each GROUPINGS as g, i (g.label)}<option value={i}>{g.label}</option>{/each}\n </select>\n </div>\n <div class=\"bd-field bd-grow\">\n <span class=\"bd-label\">Search</span>\n <input type=\"search\" placeholder=\"name, email, country, game, status\" value={search} oninput={(e) => onSearch(e.currentTarget.value)} />\n </div>\n <button class=\"bd-run\" onclick={runBenchmark} disabled={benchRunning}>{benchRunning ? 'Running...' : 'Run benchmark'}</button>\n </div>\n\n <div class=\"bd-stats\">\n <div class=\"bd-stat\"><span class=\"k\">Rows</span><span class=\"v\">{full.format(rowCount)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Columns</span><span class=\"v\">{full.format(colCount)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Cells</span><span class=\"v\">{compact.format(cells)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Cells in the DOM</span><span class=\"v\">{full.format(domCells)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Frame rate</span><span class=\"v\">{fps} fps</span><span class=\"s\">longest frame {fmtMs(longest)}</span></div>\n {#if heapMb != null}<div class=\"bd-stat\"><span class=\"k\">JS heap</span><span class=\"v\">{heapMb} MB</span></div>{/if}\n <div class=\"bd-stat bd-wide\">\n <span class=\"k\">Last query</span>\n {#if activity.progress}\n <span class=\"v\">{activity.progress.phase}...</span>\n <span class=\"bd-bar\"><span style={`width:${Math.round((activity.progress.done / Math.max(1, activity.progress.total)) * 100)}%`}></span></span>\n {:else if activity.last}\n <span class=\"v\">{fmtMs(activity.last.ms)}</span>\n <span class=\"s\">{opLabel[activity.last.op] ?? activity.last.op}{activity.last.scanned ? `, ${compact.format(activity.last.scanned)} rows read` : ''} - worker time</span>\n {:else}\n <span class=\"v\">-</span>\n {/if}\n </div>\n {#if readyMs != null}<div class=\"bd-stat\"><span class=\"k\">Ready in</span><span class=\"v\">{fmtMs(readyMs)}</span></div>{/if}\n </div>\n\n {#if editNote}<p class=\"bd-note\">{editNote}</p>{/if}\n\n {#if bench.length}\n <div class=\"bd-bench\">\n <table>\n <thead><tr><th>Step</th><th>On screen</th><th>Worker</th><th></th></tr></thead>\n <tbody>\n {#each bench as b (b.step)}\n <tr><td>{b.step}</td><td class=\"num\">{fmtMs(b.ms)}</td><td class=\"num\">{b.worker != null ? fmtMs(b.worker) : '-'}</td><td class=\"dim\">{b.note}</td></tr>\n {/each}\n </tbody>\n </table>\n </div>\n {/if}\n\n <div class=\"bd-grid\">\n {#if ctl}\n {#key gridKey}\n <SvGrid\n rowModel={ctl}\n {columns}\n {features}\n {conditionalFormats}\n initialColumnPinning={pinning}\n sortable\n filterable\n filterMode=\"menu\"\n editable\n columnResize\n stickyGroupRows\n rowHeight={34}\n containerHeight=\"100%\"\n onCellValueChange={onEdit}\n onApiReady={(next) => (api = next)}\n />\n {/key}\n {/if}\n </div>\n</section>\n\n<style>\n .bd-wrap {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 10px;\n color: var(--sg-fg, #0f172a);\n }\n .bd-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-end;\n gap: 12px;\n }\n .bd-field { display: flex; flex-direction: column; gap: 4px; }\n .bd-grow { flex: 1 1 200px; }\n .bd-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); }\n .bd-seg {\n display: inline-flex;\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 8px;\n overflow: hidden;\n }\n .bd-seg button {\n font: inherit;\n font-size: 13px;\n padding: 6px 11px;\n border: 0;\n border-right: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n cursor: pointer;\n font-variant-numeric: tabular-nums;\n }\n .bd-seg button:last-child { border-right: 0; }\n .bd-seg button.on { background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); font-weight: 600; }\n .bd-seg button:disabled { cursor: default; opacity: 0.6; }\n .bd-controls select,\n .bd-controls input {\n font: inherit;\n font-size: 13px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n }\n .bd-run {\n font: inherit;\n font-weight: 600;\n font-size: 13px;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid var(--sg-fg, #0f172a);\n background: var(--sg-fg, #0f172a);\n color: var(--sg-bg, #fff);\n cursor: pointer;\n }\n .bd-run:disabled { opacity: 0.6; cursor: default; }\n .bd-seg button:focus-visible,\n .bd-run:focus-visible,\n .bd-controls select:focus-visible,\n .bd-controls input:focus-visible { outline: 2px solid var(--sg-accent, #ea580c); outline-offset: 1px; }\n\n .bd-stats { display: flex; flex-wrap: wrap; gap: 8px; }\n .bd-stat {\n display: flex;\n flex-direction: column;\n min-width: 120px;\n padding: 8px 12px;\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 10px;\n background: var(--sg-bg, #fff);\n }\n .bd-stat.bd-wide { min-width: 260px; flex: 1 1 260px; }\n .bd-stat .k { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); }\n .bd-stat .v { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.25; }\n .bd-stat .s { font-size: 12px; color: var(--sg-muted, #64748b); }\n .bd-bar { display: block; height: 6px; margin-top: 6px; border-radius: 3px; background: var(--sg-header-bg, #f1f5f9); overflow: hidden; }\n .bd-bar > span { display: block; height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width 120ms linear; }\n .bd-note { margin: 0; font-size: 13px; color: var(--sg-muted, #64748b); }\n\n .bd-bench { overflow-x: auto; }\n .bd-bench table { border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }\n .bd-bench th, .bd-bench td { padding: 4px 12px; border-bottom: 1px solid var(--sg-border, #e2e8f0); text-align: left; white-space: nowrap; }\n .bd-bench th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); font-weight: 600; }\n .bd-bench td.num { text-align: right; font-weight: 600; }\n .bd-bench td.dim { color: var(--sg-muted, #64748b); }\n\n .bd-grid { flex: 1; min-height: 320px; display: flex; flex-direction: column; }\n\n /* ---- cell palette (data colours, not chrome) ------------------------------- */\n .bd-grid :global(.bd-id) { color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }\n .bd-grid :global(.bd-muted) { color: var(--sg-muted, #64748b); }\n .bd-grid :global(.bd-name) { font-weight: 600; }\n .bd-grid :global(.bd-total) { font-weight: 700; }\n .bd-grid :global(.bd-stars) { color: #f59e0b; letter-spacing: 1px; }\n .bd-grid :global(.bd-yes) { color: #10b981; font-weight: 700; }\n .bd-grid :global(.bd-no) { color: var(--sg-muted, #94a3b8); }\n .bd-grid :global(.bd-neg) { color: #ef4444; }\n .bd-grid :global(.bd-pos1) { color: #16a34a; }\n .bd-grid :global(.bd-pos2) { color: #15803d; font-weight: 600; }\n .bd-grid :global(.bd-pos3) { color: #047857; font-weight: 700; }\n .bd-grid :global(.bd-chip),\n .bd-grid :global(.bd-pill) { font-weight: 600; font-size: 12px; }\n .bd-grid :global(.bd-c0) { color: #2563eb; }\n .bd-grid :global(.bd-c1) { color: #9333ea; }\n .bd-grid :global(.bd-c2) { color: #db2777; }\n .bd-grid :global(.bd-c3) { color: #ea580c; }\n .bd-grid :global(.bd-c4) { color: #0891b2; }\n .bd-grid :global(.bd-c5) { color: #65a30d; }\n .bd-grid :global(.bd-c6) { color: #ca8a04; }\n .bd-grid :global(.bd-c7) { color: #4f46e5; }\n .bd-grid :global(.bd-s-active) { color: #16a34a; }\n .bd-grid :global(.bd-s-churned) { color: #dc2626; }\n .bd-grid :global(.bd-s-pending) { color: #d97706; }\n .bd-grid :global(.bd-s-vip) { color: #7c3aed; }\n .bd-grid :global(.bd-h0) { background: color-mix(in srgb, #8b5cf6 4%, transparent); }\n .bd-grid :global(.bd-h1) { background: color-mix(in srgb, #8b5cf6 8%, transparent); }\n .bd-grid :global(.bd-h2) { background: color-mix(in srgb, #8b5cf6 12%, transparent); }\n .bd-grid :global(.bd-h3) { background: color-mix(in srgb, #8b5cf6 16%, transparent); }\n .bd-grid :global(.bd-h4) { background: color-mix(in srgb, #8b5cf6 20%, transparent); }\n .bd-grid :global(.bd-h5) { background: color-mix(in srgb, #ec4899 18%, transparent); }\n .bd-grid :global(.bd-h6) { background: color-mix(in srgb, #ec4899 24%, transparent); }\n .bd-grid :global(.bd-h7) { background: color-mix(in srgb, #f97316 24%, transparent); }\n .bd-grid :global(.bd-h8) { background: color-mix(in srgb, #f97316 32%, transparent); }\n .bd-grid :global(.bd-h9) { background: color-mix(in srgb, #ef4444 34%, transparent); }\n\n @media (max-width: 640px) {\n .bd-stat { min-width: calc(50% - 4px); }\n .bd-seg button { padding: 6px 8px; }\n }\n</style>\n"
|
|
3065
3065
|
},
|
|
3066
|
+
{
|
|
3067
|
+
"id": "504-project-tracker",
|
|
3068
|
+
"path": "examples/src/demos/504-project-tracker.svelte",
|
|
3069
|
+
"title": "Project Tracker",
|
|
3070
|
+
"category": "Base",
|
|
3071
|
+
"blurb": "504. Project tracker A base with two tables, Tasks and Milestones, as tabs. The tasks are built from field types (`@svgrid/enterprise/base`). Each",
|
|
3072
|
+
"source": "<script lang=\"ts\">\n /**\n * 504. Project tracker\n * --------------------\n * A base with two tables, Tasks and Milestones, as tabs. The tasks are\n * built from field types (`@svgrid/enterprise/base`). Each\n * column says what it is with `fieldType` - single select with colored\n * options, multiple select, a date, a rating, a URL, an email, currency,\n * a checkbox - and the module fills in its editor, format and alignment,\n * and draws the cell for the types that need one (stars, links).\n *\n * More types: autonumber (No), a user field (Assignee, from setFieldUsers),\n * duration (Estimate), a number in percent format drawn as a bar\n * (Progress), a number in currency format (Budget), a link to the\n * Milestones table and a lookup of the linked milestone's date. Milestones\n * links back: each one lists its tasks, their average progress and their\n * budget, and linking a task to a milestone lists it there.\n *\n * Files is an attachment field: images as thumbnails, other files as chips.\n * Double-click it (or press Enter) to add, remove, reorder and preview.\n *\n * The All tasks view is colored by Status: a thin stripe at each row's\n * start (hover the row number for the status). A single select's column\n * menu can color the rows by it instead (\"Color rows by this field\").\n *\n * Task is the primary field (`primaryField`): always first and pinned, the\n * record's name. Drag the line at the pinned columns' edge (`pinSplitter`)\n * to pin more columns or fewer; it snaps to column edges.\n *\n * `SvBase` puts the view bar over the grid. Views: a grid, a Kanban\n * stacked by Status, a calendar by Due, a gallery with the first file as\n * the cover, and a form that adds a task. In any of them: Hide fields,\n * Filter (conditions per field type), Group, Sort, Color, Row height and\n * search; each view keeps its own. Space on a cell expands the record,\n * which also lists its changes and takes comments.\n *\n * A milestone chip opens that record (hover it for a preview, \"Open in\n * Milestones\" to go there); the link picker can create a milestone. Grouped, each group header has \"+\" for a\n * task already in it. Drop files on a Files cell to attach them; drag a\n * row by its number to reorder while nothing is sorted or grouped.\n *\n * Hover a row number for its checkbox and expand button. Ticking rows\n * brings up the selection bar: edit a field on all of them at once, or\n * delete them (it asks first).\n *\n * \"+ Add row\" under the last row (`rowAdder`) appends a task with the\n * defaults below and starts editing it. Double-click any cell to edit it.\n *\n * \"+\" after the last column (`columnAdder`) opens the field editor: a name,\n * a type and its properties. Each column's menu (`fieldMenu`) can edit,\n * insert, duplicate, hide or delete a field; changing a type converts the\n * values and says first how many cells would be cleared.\n *\n * Automations (`automations` on the Tasks table), opened from \"5\n * automations\" in the view bar:\n * - an overdue task (due before today, not Done) gets a red Due cell and a\n * notice;\n * - a task that turns Blocked raises a notice naming its assignee;\n * - setting Status to Done ticks Done and sets Progress to 100%;\n * - Start in the Kick off column sets the task In progress from today;\n * - a task sent with the Request a task form goes to Todo.\n * The dialog edits them (When, Only for records where, Do) and lists every\n * run with what each action did.\n */\n import { SvToaster, type SvGridApi } from '@svgrid/grid'\n import { SvBase, enableBase, type Attachment, type BaseAutomation, type BaseTable } from '@svgrid/enterprise/base'\n import { installEnterprise } from '@svgrid/enterprise'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n // The people for the Assignee field (shared by the Base demos), and \"you\".\n usePeople()\n const TEAM = ['u1', 'u2', 'u3', 'u4']\n\n type Task = {\n id: number\n task: string\n files?: Attachment[]\n status: string\n tags: string[]\n owner: string\n due: string\n start?: string\n dependsOn?: string[]\n priority: number\n budget: number\n spec: string\n done: boolean\n no?: number\n assignee?: string\n estimate?: number\n progress?: number\n milestone?: string[]\n }\n\n const STATUS = [\n { value: 'Todo', color: '#64748b' },\n { value: 'In progress', color: '#2563eb' },\n { value: 'Blocked', color: '#dc2626' },\n { value: 'Done', color: '#16a34a' },\n ]\n const TAGS = [\n { value: 'Design', color: '#9333ea' },\n { value: 'Backend', color: '#0891b2' },\n { value: 'Frontend', color: '#ea580c' },\n { value: 'Docs', color: '#65a30d' },\n ]\n\n // Placeholder images drawn here, so the demo needs no image host. With no\n // uploader set, files you add stay in this page as blob: URLs.\n function swatch(name: string, color: string, label: string): Attachment {\n const c = document.createElement('canvas')\n c.width = 160\n c.height = 120\n const g = c.getContext('2d')!\n g.fillStyle = color\n g.fillRect(0, 0, 160, 120)\n g.fillStyle = 'rgba(255,255,255,0.9)'\n g.font = '600 22px system-ui, sans-serif'\n g.fillText(label, 14, 68)\n return { id: name, url: c.toDataURL('image/png'), name, type: 'image/png', width: 160, height: 120 }\n }\n function doc(name: string, type: string, text: string): Attachment {\n const blob = new Blob([text], { type })\n return { id: name, url: URL.createObjectURL(blob), name, type, size: blob.size }\n }\n const FILES: Record<number, Attachment[]> = {\n 1: [doc('registry-notes.txt', 'text/plain', 'Field types fill in the column.')],\n 2: [swatch('cell-mock.png', '#9333ea', 'Cell'), swatch('dialog-mock.png', '#0891b2', 'Dialog'), doc('cell-spec.txt', 'text/plain', 'Thumbnails, chips, +N.')],\n 4: [swatch('popup.png', '#ea580c', 'Popup')],\n 6: [swatch('colors.png', '#16a34a', 'Colors'), swatch('dark.png', '#0f172a', 'Dark')],\n }\n\n let nextId = 13\n const DEPENDS_ON: Record<number, string[]> = { 3: ['1'], 5: ['3'], 7: ['6'], 8: ['12'], 9: ['5'], 11: ['8'] }\n /** A 'YYYY-MM-DD' date `days` later (or earlier). */\n const shift = (iso: string, days: number) => {\n const d = new Date(`${iso}T00:00:00Z`)\n d.setUTCDate(d.getUTCDate() + days)\n return d.toISOString().slice(0, 10)\n }\n // The dates below are written for a plan where today is 20 October 2026;\n // they move with the real today, so one task is always a day overdue.\n const now = new Date()\n const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`\n const PLAN_DAY = Date.UTC(2026, 9, 20)\n const onPlan = (iso: string) => shift(today, Math.round((Date.parse(`${iso}T00:00:00Z`) - PLAN_DAY) / 86_400_000))\n const tasks: Task[] = [\n { id: 1, task: 'Field type registry', status: 'Done', tags: ['Backend'], owner: 'ana@example.com', due: '2026-10-14', priority: 5, budget: 1800, spec: 'svgrid.com/docs', done: true },\n { id: 2, task: 'Attachment cell', status: 'In progress', tags: ['Frontend', 'Design'], owner: 'ben@example.com', due: '2026-10-21', priority: 4, budget: 3200, spec: 'svgrid.com/docs/help/fields', done: false },\n { id: 3, task: 'Upload hook + adapters', status: 'Todo', tags: ['Backend'], owner: 'cleo@example.com', due: '2026-10-24', priority: 3, budget: 2400, spec: '', done: false },\n { id: 4, task: 'Column adder popup', status: 'Blocked', tags: ['Frontend'], owner: 'dev@example.com', due: '2026-10-28', priority: 4, budget: 2900, spec: 'javascript:alert(1)', done: false },\n { id: 5, task: 'Field editor: select options', status: 'Todo', tags: ['Design', 'Frontend'], owner: 'ana@example.com', due: '2026-10-30', priority: 3, budget: 1500, spec: '', done: false },\n { id: 6, task: 'Row colors by status', status: 'In progress', tags: ['Frontend'], owner: 'ben@example.com', due: '2026-10-19', priority: 2, budget: 900, spec: 'svgrid.com', done: false },\n { id: 7, task: 'Row adder in groups', status: 'Todo', tags: ['Frontend'], owner: 'cleo@example.com', due: '2026-11-03', priority: 2, budget: 700, spec: '', done: false },\n { id: 8, task: 'Docs page', status: 'Todo', tags: ['Docs'], owner: 'dev@example.com', due: '2026-11-05', priority: 3, budget: 600, spec: '', done: false },\n { id: 9, task: 'Lightbox preview', status: 'Todo', tags: ['Design'], owner: 'ana@example.com', due: '2026-11-07', priority: 1, budget: 1100, spec: '', done: false },\n { id: 10, task: 'Paste URLs as attachments', status: 'Todo', tags: ['Frontend'], owner: 'ben@example.com', due: '2026-11-10', priority: 2, budget: 800, spec: '', done: false },\n { id: 11, task: 'CSV export of attachments', status: 'Todo', tags: ['Backend'], owner: 'cleo@example.com', due: '2026-11-12', priority: 1, budget: 500, spec: '', done: false },\n { id: 12, task: 'e2e coverage', status: 'In progress', tags: ['Backend', 'Frontend'], owner: 'dev@example.com', due: '2026-10-26', priority: 5, budget: 1300, spec: '', done: false },\n ].map((t, i) => ({\n ...t,\n due: onPlan(t.due),\n files: FILES[t.id] ?? [],\n no: t.id,\n assignee: TEAM[i % TEAM.length]!,\n estimate: [5400, 10800, 7200, 14400, 3600, 9000][i % 6],\n progress: t.done ? 1 : [0.6, 0.15, 0.3, 0, 0.45, 0.8][i % 6],\n milestone: [i < 6 ? 'm1' : 'm2'],\n // Work starts a few days to two weeks before it is due.\n start: shift(onPlan(t.due), -[4, 9, 6, 12, 3, 7][i % 6]!),\n // The tasks this one waits for (a link to this same table).\n dependsOn: DEPENDS_ON[t.id] ?? [],\n }))\n\n // The second table: the Milestone field links to it, and it links back with\n // the tasks of each milestone, their average progress and their budget.\n // \"+ Create\" in the Milestone picker adds a milestone.\n const milestones = [\n { id: 'm1', name: 'Beta', date: onPlan('2026-10-31'), tasks: tasks.filter((t) => t.milestone?.[0] === 'm1').map((t) => String(t.id)) },\n { id: 'm2', name: 'Release', date: onPlan('2026-11-30'), tasks: tasks.filter((t) => t.milestone?.[0] === 'm2').map((t) => String(t.id)) },\n ]\n\n // The schema lives here, not in the grid: SvBase hands back every change\n // to the tables (`bind:tables`), fields and records alike.\n const taskColumns: any[] = [\n { field: 'task', header: 'Task', width: 230, fieldType: 'text' },\n { field: 'no', header: 'No', width: 92, fieldType: 'autoNumber' },\n { field: 'status', header: 'Status', width: 130, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'kickoff', header: 'Kick off', width: 104, fieldType: 'button', fieldProps: { label: 'Start', color: '#2563eb' } },\n { field: 'tags', header: 'Tags', width: 190, fieldType: 'multiSelect', fieldProps: { options: TAGS } },\n { field: 'files', header: 'Files', width: 180, fieldType: 'attachment', fieldProps: { maxFiles: 10 } },\n { field: 'assignee', header: 'Assignee', width: 160, fieldType: 'user' },\n { field: 'owner', header: 'Owner', width: 170, fieldType: 'email' },\n { field: 'start', header: 'Start', width: 180, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'due', header: 'Due', width: 200, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'estimate', header: 'Estimate', width: 100, fieldType: 'duration', fieldProps: { durationFormat: 'h:mm' } },\n { field: 'progress', header: 'Progress', width: 150, fieldType: 'number', fieldProps: { format: 'percent', precision: 0, showBar: true, summary: 'avg' } },\n { field: 'milestone', header: 'Milestone', width: 130, fieldType: 'link', fieldProps: { table: 'milestones', multiple: false, inverse: 'tasks' } },\n { field: 'milestoneDate', header: 'Milestone date', width: 160, fieldType: 'lookup', fieldProps: { linkField: 'milestone', field: 'date' } },\n { field: 'dependsOn', header: 'Depends on', width: 200, fieldType: 'link', fieldProps: { table: 'tasks', multiple: true } },\n { field: 'priority', header: 'Priority', width: 120, fieldType: 'rating', fieldProps: { max: 5, icon: 'flag', color: '#f82b60' } },\n { field: 'budget', header: 'Budget', width: 136, description: 'Approved spend for the task', fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0, summary: 'sum' } },\n { field: 'spec', header: 'Spec', width: 200, fieldType: 'url' },\n { field: 'done', header: 'Done', width: 80, fieldType: 'checkbox', fieldProps: { icon: 'check', color: '#20c933' } },\n ]\n\n // What happens to tasks on its own, in the view bar's Automations. A\n // condition is the view bar's filter; a message names fields as {Task}.\n const notDone = { column: 'status', kind: 'select', op: 'isNot', value: 'Done' } as const\n const statusIs = (value: string) => ({ kind: 'fieldFilter' as const, conjunction: 'and' as const, conditions: [{ column: 'status', kind: 'select' as const, op: 'is' as const, value }] })\n const taskAutomations: BaseAutomation[] = [\n {\n id: 'overdue',\n name: 'Overdue task',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'due', kind: 'date', op: 'isBefore', value: { mode: 'today' } }, notDone] },\n actions: [\n { type: 'highlight', fields: ['due'], background: '#fee2e2', color: '#b91c1c' },\n { type: 'notify', message: '{Task} is overdue: it was due {Due}', severity: 'error' },\n ],\n },\n {\n id: 'blocked',\n name: 'Task blocked',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: statusIs('Blocked'),\n actions: [{ type: 'notify', message: '{Task} is blocked ({Assignee})', severity: 'warning' }],\n },\n {\n id: 'done',\n name: 'Done closes the task',\n enabled: true,\n trigger: { type: 'recordUpdated', fields: ['status'] },\n condition: statusIs('Done'),\n actions: [\n { type: 'updateRecord', values: { done: 'true', progress: '100%' } },\n { type: 'notify', message: '{Task} is done', severity: 'success' },\n ],\n },\n {\n id: 'kickoff',\n name: 'Start a task',\n enabled: true,\n trigger: { type: 'button', field: 'kickoff' },\n actions: [{ type: 'updateRecord', values: { status: 'In progress', start: 'today' } }],\n },\n {\n id: 'request',\n name: 'Triage a request',\n enabled: true,\n trigger: { type: 'formSubmitted', view: 'form' },\n actions: [\n { type: 'updateRecord', values: { status: 'Todo', progress: '0%' } },\n { type: 'notify', message: 'New request: {Task}' },\n ],\n },\n ]\n\n // Saved views per table, as the view bar switches them. Each keeps its own\n // filter, sorts, groups, hidden fields, row height and colors from here on.\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'tasks',\n name: 'Tasks',\n primaryField: 'task',\n data: tasks,\n columns: taskColumns,\n automations: taskAutomations,\n views: [\n { id: 'all', name: 'All tasks', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'By status', type: 'kanban', stackBy: 'status' },\n { id: 'calendar', name: 'Due dates', type: 'calendar', dateField: 'due', color: { mode: 'field', field: 'status' } },\n { id: 'gallery', name: 'Gallery', type: 'gallery', coverField: 'files' },\n { id: 'form', name: 'Request a task', type: 'form', formFields: ['task', 'status', 'tags', 'assignee', 'due', 'priority', 'files'], formDescription: 'Adds a row to the tracker.' },\n ],\n props: {\n rowDragManaged: true,\n rowAdder: { label: 'Add task', defaults: () => ({ id: nextId++, task: '', status: 'Todo', tags: [], priority: 0, budget: 0, done: false }) },\n },\n },\n {\n id: 'milestones',\n name: 'Milestones',\n primaryField: 'name',\n data: milestones,\n columns: [\n { field: 'name', header: 'Milestone', width: 180, fieldType: 'text' },\n { field: 'date', header: 'Date', width: 180, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'tasks', header: 'Tasks', width: 320, fieldType: 'link', fieldProps: { table: 'tasks', multiple: true, inverse: 'milestone' } },\n { field: 'progress', header: 'Progress', width: 130, fieldType: 'rollup', fieldProps: { linkField: 'tasks', field: 'progress', aggregation: 'AVERAGE' } },\n { field: 'budget', header: 'Budget', width: 130, fieldType: 'rollup', fieldProps: { linkField: 'tasks', field: 'budget', aggregation: 'SUM' } },\n ],\n views: [{ id: 'milestones', name: 'All milestones', type: 'grid' }],\n props: { rowAdder: { label: 'Add milestone' } },\n },\n ])\n\n let added = $state(0)\n function onReady(api: SvGridApi<any, Task>) {\n installEnterprise(api)\n }\n</script>\n\n<section class=\"tracker\">\n <p class=\"tracker-note\">\n Columns are declared by field type. The URL in row 4 is a <code>javascript:</code> value: it stays text.\n {#if added}<span class=\"tracker-added\">{added} task{added === 1 ? '' : 's'} added</span>{/if}\n </p>\n <div class=\"tracker-grid\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={34}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n onRowAdd={() => (added += 1)}\n columnAdder\n fieldMenu\n onApiReady={onReady}\n />\n </div>\n <!-- The automations' notices. -->\n <SvToaster />\n</section>\n\n<style>\n .tracker { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: 8px; }\n .tracker-note { margin: 0; font-size: 13px; color: var(--sg-muted, #64748b); }\n .tracker-note code { font-size: 12px; }\n .tracker-added { margin-inline-start: 8px; color: var(--sg-fg, #0f172a); }\n .tracker-grid { flex: 1; min-height: 420px; display: flex; flex-direction: column; }\n</style>\n"
|
|
3073
|
+
},
|
|
3074
|
+
{
|
|
3075
|
+
"id": "505-gallery-catalog",
|
|
3076
|
+
"path": "examples/src/demos/505-gallery-catalog.svelte",
|
|
3077
|
+
"title": "Gallery Catalog",
|
|
3078
|
+
"category": "Gallery",
|
|
3079
|
+
"blurb": "505. Gallery: product catalog One <SvGrid> as a storefront: the `gallery` prop shows the rows as product cards, a `card` snippet draws each one (photo, brand, rating,",
|
|
3080
|
+
"source": "<script lang=\"ts\">\n // Gallery rendering is an Enterprise feature - register the renderer.\n import { enableGalleryView } from '@svgrid/enterprise'\n enableGalleryView()\n /**\n * 505. Gallery: product catalog\n * -----------------------------\n * One <SvGrid> as a storefront: the `gallery` prop shows the rows as\n * product cards, a `card` snippet draws each one (photo, brand, rating,\n * sale price, a badge), and Table shows the same rows as a grid. The\n * search box, the category filter and the sort are the grid's, so the two\n * views always agree. A click on a card opens the product in a panel\n * (`onCardClick`), where \"Add to cart\" counts into the header.\n */\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n import { productShot, type ProductKind } from '../shared/gallery-art'\n\n type Product = {\n id: number\n name: string\n brand: string\n kind: ProductKind\n category: string\n color: string\n colorName: string\n image: string\n price: number\n compareAt: number | null\n rating: number\n reviews: number\n stock: number\n isNew: boolean\n }\n\n const CATALOG: Array<[ProductKind, string, string, string, Array<[string, string]>]> = [\n ['chair', 'Furniture', 'Oslo lounge chair', 'Nordhem', [['#8a9a7b', 'Sage'], ['#c46f4e', 'Terracotta'], ['#d8cbb3', 'Oat']]],\n ['chair', 'Furniture', 'Fjord dining chair', 'Nordhem', [['#34495e', 'Navy'], ['#c9a227', 'Mustard']]],\n ['lamp', 'Lighting', 'Arc floor lamp', 'Lumen & Co', [['#efe6d8', 'Linen'], ['#2f2f33', 'Black']]],\n ['lamp', 'Lighting', 'Dune table lamp', 'Lumen & Co', [['#d9b38c', 'Sand'], ['#7d8c84', 'Moss']]],\n ['mug', 'Kitchen', 'Stoneware mug', 'Kiln House', [['#6d8fa3', 'Slate blue'], ['#e4ddd2', 'Bone'], ['#9e5a3c', 'Rust']]],\n ['mug', 'Kitchen', 'Espresso cup', 'Kiln House', [['#2e3d34', 'Forest'], ['#d7c6a9', 'Oat']]],\n ['clock', 'Decor', 'Station wall clock', 'Ticking', [['#2b2b2b', 'Graphite'], ['#b08d57', 'Brass']]],\n ['clock', 'Decor', 'Minimal desk clock', 'Ticking', [['#9fb4a8', 'Celadon']]],\n ['plant', 'Garden', 'Fiddle leaf fig', 'Greenhouse', [['#c67b5c', 'Terracotta'], ['#a8a39b', 'Concrete']]],\n ['plant', 'Garden', 'Snake plant', 'Greenhouse', [['#e9e4dc', 'White clay']]],\n ['speaker', 'Audio', 'Bookshelf speaker', 'Sonora', [['#3a3d42', 'Charcoal'], ['#c9c3b8', 'Stone']]],\n ['speaker', 'Audio', 'Portable speaker', 'Sonora', [['#6b7f6e', 'Olive'], ['#a5514a', 'Clay']]],\n ]\n const rows: Product[] = CATALOG.flatMap(([kind, category, name, brand, colors], i) =>\n colors.map(([color, colorName], j) => {\n const id = i * 10 + j + 1\n const price = Math.round(29 + ((i * 53 + j * 17) % 40) * 12.5) - 0.01\n const onSale = (i + j) % 4 === 1\n return {\n id,\n name: colors.length > 1 ? `${name}, ${colorName}` : name,\n brand,\n kind,\n category,\n color,\n colorName,\n image: productShot(kind, color),\n price: onSale ? Math.round(price * 0.8) - 0.01 : price,\n compareAt: onSale ? price : null,\n rating: Math.round((3.6 + ((i * 7 + j * 3) % 14) / 10) * 10) / 10,\n reviews: 12 + ((i * 97 + j * 31) % 480),\n stock: (i * 17 + j * 9) % 26,\n isNew: (i + j) % 5 === 0,\n }\n }),\n )\n const CATEGORIES = ['All', ...new Set(rows.map((r) => r.category))]\n\n const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })\n const columns: GridColumns<Product> = [\n { field: 'name', header: 'Product', width: 230 },\n { field: 'brand', header: 'Brand', width: 120 },\n { field: 'category', header: 'Category', width: 110 },\n { field: 'price', header: 'Price', width: 100, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n { field: 'rating', header: 'Rating', width: 80, editorType: 'number' },\n { field: 'reviews', header: 'Reviews', width: 90, editorType: 'number' },\n { field: 'stock', header: 'Stock', width: 80, editorType: 'number' },\n ]\n\n let view = $state<'gallery' | 'table'>('gallery')\n let category = $state('All')\n let sort = $state('')\n let api = $state.raw<SvGridApi<any, Product> | null>(null)\n let picked = $state<Product | null>(null)\n let cart = $state(0)\n $effect(() => {\n if (!api) return\n if (category === 'All') api.clearFilter('category')\n else api.setFilter('category', { operator: 'equals', value: category })\n })\n $effect(() => {\n if (!api) return\n if (sort === '') api.clearSort()\n else api.setSort(sort.replace(/-(asc|desc)$/, ''), sort.endsWith('desc') ? 'desc' : 'asc')\n })\n const badgeOf = (p: Product) => (p.stock === 0 ? 'Sold out' : p.compareAt ? 'Sale' : p.isNew ? 'New' : '')\n const STAR = 'M12 3.5l2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 17l-5.2 2.7 1-5.9-4.3-4.1 5.9-.8z'\n</script>\n\n<section class=\"shop flex flex-col gap-3 h-full min-h-0\">\n <header class=\"flex flex-wrap items-center gap-3 shrink-0\">\n <div class=\"shop-tabs\" role=\"radiogroup\" aria-label=\"Category\">\n {#each CATEGORIES as c (c)}\n <button type=\"button\" role=\"radio\" aria-checked={category === c} class:is-on={category === c} onclick={() => (category = c)}>{c}</button>\n {/each}\n </div>\n <span class=\"flex-1\"></span>\n <select class=\"shop-select\" aria-label=\"Sort\" bind:value={sort}>\n <option value=\"\">Featured</option>\n <option value=\"price-asc\">Price: low to high</option>\n <option value=\"price-desc\">Price: high to low</option>\n <option value=\"rating-desc\">Top rated</option>\n </select>\n <div class=\"shop-tabs\" role=\"radiogroup\" aria-label=\"View\">\n <button type=\"button\" role=\"radio\" aria-checked={view === 'gallery'} class:is-on={view === 'gallery'} onclick={() => (view = 'gallery')}>Gallery</button>\n <button type=\"button\" role=\"radio\" aria-checked={view === 'table'} class:is-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>\n </div>\n <span class=\"shop-cart\" aria-live=\"polite\" aria-label={`${cart} in cart`}>\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2M10 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM18 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" /></svg>\n {cart}\n </span>\n </header>\n\n <div class=\"flex-1 min-h-0 flex gap-4\">\n <div class=\"flex-1 min-w-0\">\n <SvGrid\n data={rows}\n {columns}\n getRowId={(r) => String(r.id)}\n sortable\n filterable\n containerHeight=\"100%\"\n onApiReady={(a) => (api = a)}\n gallery={view === 'gallery' ? { card: productCard, cardSize: 210, searchPlaceholder: 'Search products', onCardClick: (p) => (picked = p) } : undefined}\n />\n </div>\n {#if picked && view === 'gallery'}\n <aside class=\"shop-panel\" aria-label=\"Product details\">\n <button type=\"button\" class=\"shop-close\" aria-label=\"Close\" onclick={() => (picked = null)}>\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M6 6l12 12M18 6L6 18\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" /></svg>\n </button>\n <img class=\"shop-panel-img\" src={picked.image} alt={picked.name} />\n <span class=\"pc-brand\">{picked.brand}</span>\n <h3 class=\"shop-panel-name\">{picked.name}</h3>\n <span class=\"pc-rating\">\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" aria-hidden=\"true\"><path d={STAR} fill=\"currentColor\" /></svg>\n {picked.rating.toFixed(1)} <span class=\"pc-muted\">({picked.reviews} reviews)</span>\n </span>\n <div class=\"pc-price\">\n <strong>{money(picked.price)}</strong>\n {#if picked.compareAt}<s class=\"pc-muted\">{money(picked.compareAt)}</s>{/if}\n </div>\n <span class=\"shop-swatch\"><i style:background={picked.color}></i>{picked.colorName}</span>\n <span class=\"pc-muted\">{picked.stock === 0 ? 'Out of stock' : picked.stock < 5 ? `Only ${picked.stock} left` : 'In stock, ships in 2 days'}</span>\n <button type=\"button\" class=\"shop-add\" disabled={picked.stock === 0} onclick={() => (cart += 1)}>Add to cart</button>\n </aside>\n {/if}\n </div>\n</section>\n\n{#snippet productCard(p: Product)}\n {@const badge = badgeOf(p)}\n <article class=\"pc\">\n <div class=\"pc-media\">\n <img src={p.image} alt=\"\" loading=\"lazy\" decoding=\"async\" />\n {#if badge}<span class=\"pc-badge\" data-kind={badge}>{badge}</span>{/if}\n </div>\n <div class=\"pc-body\">\n <span class=\"pc-brand\">{p.brand}</span>\n <span class=\"pc-name\">{p.name}</span>\n <span class=\"pc-rating\">\n <svg viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" aria-hidden=\"true\"><path d={STAR} fill=\"currentColor\" /></svg>\n {p.rating.toFixed(1)} <span class=\"pc-muted\">({p.reviews})</span>\n </span>\n <div class=\"pc-price\">\n <strong>{money(p.price)}</strong>\n {#if p.compareAt}<s class=\"pc-muted\">{money(p.compareAt)}</s>{/if}\n </div>\n </div>\n </article>\n{/snippet}\n\n<style>\n .shop { color: var(--sg-fg, #0f172a); }\n .shop-tabs { display: inline-flex; padding: 3px; gap: 2px; border-radius: 999px; background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); }\n .shop-tabs button {\n border: 0; border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer;\n background: none; color: var(--sg-muted, #64748b);\n }\n .shop-tabs button.is-on { background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font-weight: 600; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12); }\n .shop-select {\n height: 32px; padding: 0 10px; border-radius: 999px; font: inherit; font-size: 13px;\n border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 12%, transparent); background: var(--sg-bg, #fff); color: inherit;\n }\n .shop-cart {\n display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: 999px; font-size: 13px; font-weight: 600;\n background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff);\n }\n .pc {\n display: flex; flex-direction: column; overflow: hidden; border-radius: 12px;\n background: var(--sg-bg, #fff); border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent);\n transition: transform 0.18s ease, box-shadow 0.18s ease;\n }\n :global(.sv-gal-custom-hit:hover) .pc { transform: translateY(-2px); box-shadow: 0 14px 30px -14px rgba(15, 23, 42, 0.35); }\n .pc-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: #efeae2; }\n .pc-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; }\n :global(.sv-gal-custom-hit:hover) .pc-media img { transform: scale(1.05); }\n .pc-badge {\n position: absolute; top: 10px; left: 10px; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600;\n background: #fff; color: #1f2937; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);\n }\n .pc-badge[data-kind='Sale'] { background: #b42318; color: #fff; }\n .pc-badge[data-kind='Sold out'] { background: #1f2937; color: #fff; }\n .pc-body { display: flex; flex-direction: column; gap: 3px; padding: 12px 14px 14px; min-width: 0; }\n .pc-brand { font-size: 11px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--sg-muted, #64748b); }\n .pc-name { font-size: 14px; font-weight: 600; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .pc-rating { display: inline-flex; align-items: center; gap: 4px; font-size: 12.5px; color: #b7791f; }\n .pc-rating .pc-muted { color: var(--sg-muted, #64748b); }\n .pc-price { display: flex; align-items: baseline; gap: 8px; margin-top: 4px; font-size: 15px; }\n .pc-muted { color: var(--sg-muted, #64748b); font-size: 12.5px; }\n .shop-panel {\n position: relative; width: 300px; flex: none; display: flex; flex-direction: column; gap: 6px; padding: 14px; overflow: auto;\n border-radius: 14px; background: var(--sg-bg, #fff); border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 9%, transparent);\n }\n .shop-close {\n position: absolute; top: 22px; right: 22px; display: inline-flex; padding: 6px; border: 0; border-radius: 999px; cursor: pointer;\n background: rgba(255, 255, 255, 0.9); color: #1f2937;\n }\n .shop-panel-img { width: 100%; border-radius: 10px; margin-bottom: 6px; }\n .shop-panel-name { margin: 0; font-size: 18px; line-height: 1.25; }\n .shop-swatch { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }\n .shop-swatch i { width: 16px; height: 16px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15); }\n .shop-add {\n margin-top: 8px; height: 40px; border: 0; border-radius: 10px; font: inherit; font-weight: 600; cursor: pointer;\n background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff);\n }\n .shop-add:disabled { opacity: 0.45; cursor: default; }\n @media (max-width: 720px) {\n .shop-panel { display: none; }\n }\n</style>\n"
|
|
3081
|
+
},
|
|
3082
|
+
{
|
|
3083
|
+
"id": "506-gallery-team-directory",
|
|
3084
|
+
"path": "examples/src/demos/506-gallery-team-directory.svelte",
|
|
3085
|
+
"title": "Gallery Team Directory",
|
|
3086
|
+
"category": "Gallery",
|
|
3087
|
+
"blurb": "506. Gallery: team directory A gallery whose cards you draw yourself: `gallery.card` is a snippet that gets each row, so a person shows as a profile card (a banner in their",
|
|
3088
|
+
"source": "<script lang=\"ts\">\n // Gallery rendering is an Enterprise feature - register the renderer.\n import { enableGalleryView } from '@svgrid/enterprise'\n enableGalleryView()\n /**\n * 506. Gallery: team directory\n * ----------------------------\n * A gallery whose cards you draw yourself: `gallery.card` is a snippet that\n * gets each row, so a person shows as a profile card (a banner in their\n * team's color, a portrait, role, location, status, email and message\n * links) rather than the default cover-and-fields layout. The grid still\n * owns the rows: the search box narrows them, the team buttons set a column\n * filter, and Table shows the same people, filtered the same way.\n */\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n import { portrait } from '../shared/gallery-art'\n\n type Person = {\n id: number\n name: string\n role: string\n team: string\n city: string\n email: string\n photo: string\n since: number\n status: 'Available' | 'In a meeting' | 'Away'\n }\n\n const TEAM_COLOR: Record<string, [string, string]> = {\n Platform: ['#3b5b8a', '#6b8cc0'],\n Design: ['#b5654a', '#e3a07f'],\n Data: ['#2f7f86', '#69b3b5'],\n Product: ['#7b5ea7', '#ad93d4'],\n Customers: ['#6a8f6b', '#a3c09c'],\n }\n const PEOPLE: Array<[string, string, string, string]> = [\n ['Ana Ruiz', 'Engineering lead', 'Platform', 'Lisbon, PT'],\n ['Ben Okafor', 'Frontend engineer', 'Platform', 'Lagos, NG'],\n ['Cleo Park', 'Product designer', 'Design', 'Seoul, KR'],\n ['Dev Shah', 'Data engineer', 'Data', 'Pune, IN'],\n ['Elena Novak', 'Product manager', 'Product', 'Prague, CZ'],\n ['Farah Haddad', 'Design researcher', 'Design', 'Amman, JO'],\n ['Gus Moreau', 'Backend engineer', 'Platform', 'Lyon, FR'],\n ['Hana Sato', 'Analytics lead', 'Data', 'Osaka, JP'],\n ['Ivan Petrov', 'Site reliability', 'Platform', 'Sofia, BG'],\n ['Jade Liu', 'Brand designer', 'Design', 'Taipei, TW'],\n ['Kofi Mensah', 'Support lead', 'Customers', 'Accra, GH'],\n ['Lea Fischer', 'Customer success', 'Customers', 'Berlin, DE'],\n ['Mateo Silva', 'Product analyst', 'Product', 'Bogota, CO'],\n ['Nora Lind', 'Data scientist', 'Data', 'Uppsala, SE'],\n ]\n const STATUS: Person['status'][] = ['Available', 'In a meeting', 'Away']\n const rows: Person[] = PEOPLE.map(([name, role, team, city], i) => ({\n id: i + 1,\n name,\n role,\n team,\n city,\n email: `${name.split(' ')[0]?.toLowerCase()}@example.com`,\n photo: portrait(i + 3),\n since: 2016 + (i % 9),\n status: STATUS[i % 5 === 2 ? 1 : i % 7 === 3 ? 2 : 0]!,\n }))\n const TEAMS = ['All', ...Object.keys(TEAM_COLOR)]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170 },\n { field: 'role', header: 'Role', width: 170 },\n { field: 'team', header: 'Team', width: 110 },\n { field: 'city', header: 'Location', width: 130 },\n { field: 'status', header: 'Status', width: 120 },\n { field: 'email', header: 'Email', width: 190 },\n { field: 'since', header: 'Joined', width: 90 },\n ]\n\n let view = $state<'gallery' | 'table'>('gallery')\n let team = $state('All')\n let api = $state.raw<SvGridApi<any, Person> | null>(null)\n $effect(() => {\n if (!api) return\n if (team === 'All') api.clearFilter('team')\n else api.setFilter('team', { operator: 'equals', value: team })\n })\n const banner = (p: Person) => {\n const [a, b] = TEAM_COLOR[p.team] ?? ['#64748b', '#94a3b8']\n return `linear-gradient(120deg, ${a}, ${b})`\n }\n</script>\n\n<section class=\"dir flex flex-col gap-3 h-full min-h-0\">\n <header class=\"flex flex-wrap items-center gap-3 shrink-0\">\n <div class=\"dir-tabs\" role=\"radiogroup\" aria-label=\"Team\">\n {#each TEAMS as t (t)}\n <button type=\"button\" role=\"radio\" aria-checked={team === t} class:is-on={team === t} onclick={() => (team = t)}>{t}</button>\n {/each}\n </div>\n <span class=\"flex-1\"></span>\n <div class=\"dir-tabs\" role=\"radiogroup\" aria-label=\"View\">\n <button type=\"button\" role=\"radio\" aria-checked={view === 'gallery'} class:is-on={view === 'gallery'} onclick={() => (view = 'gallery')}>Gallery</button>\n <button type=\"button\" role=\"radio\" aria-checked={view === 'table'} class:is-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>\n </div>\n </header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n {columns}\n getRowId={(r) => String(r.id)}\n sortable\n filterable\n containerHeight=\"100%\"\n onApiReady={(a) => (api = a)}\n gallery={view === 'gallery' ? { card: profile, cardSize: 230, searchPlaceholder: 'Find a person, role or city' } : undefined}\n />\n </div>\n</section>\n\n{#snippet profile(p: Person)}\n <article class=\"pf\">\n <div class=\"pf-banner\" style:background={banner(p)}></div>\n <div class=\"pf-photo-wrap\">\n <img class=\"pf-photo\" src={p.photo} alt=\"\" width=\"76\" height=\"76\" />\n <span class=\"pf-dot\" data-status={p.status} title={p.status}></span>\n </div>\n <strong class=\"pf-name\">{p.name}</strong>\n <span class=\"pf-role\">{p.role}</span>\n <div class=\"pf-meta\">\n <span class=\"pf-team\" style:--team={TEAM_COLOR[p.team]?.[0]}>{p.team}</span>\n <span class=\"pf-city\">\n <svg viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" aria-hidden=\"true\"><path d=\"M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21zM12 12a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" /></svg>\n {p.city}\n </span>\n </div>\n <div class=\"pf-actions\">\n <a class=\"pf-btn\" href={`mailto:${p.email}`}>\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" aria-hidden=\"true\"><path d=\"M4 6h16v12H4zM4 7l8 6 8-6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linejoin=\"round\" /></svg>\n Email\n </a>\n <span class=\"pf-btn pf-btn-quiet\" title={`Joined ${p.since}`}>Since {p.since}</span>\n </div>\n </article>\n{/snippet}\n\n<style>\n .dir { color: var(--sg-fg, #0f172a); }\n .dir-tabs { display: inline-flex; flex-wrap: wrap; padding: 3px; gap: 2px; border-radius: 999px; background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); }\n .dir-tabs button {\n border: 0; border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer;\n background: none; color: var(--sg-muted, #64748b);\n }\n .dir-tabs button.is-on { background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font-weight: 600; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12); }\n .pf {\n display: flex; flex-direction: column; align-items: center; text-align: center; overflow: hidden; padding-bottom: 14px;\n border-radius: 14px; background: var(--sg-bg, #fff); border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent);\n box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);\n }\n .pf-banner { align-self: stretch; height: 64px; }\n .pf-photo-wrap { position: relative; margin-top: -38px; margin-bottom: 8px; }\n .pf-photo { display: block; width: 76px; height: 76px; border-radius: 50%; border: 4px solid var(--sg-bg, #fff); }\n .pf-dot {\n position: absolute; right: 4px; bottom: 4px; width: 14px; height: 14px; border-radius: 50%;\n border: 2px solid var(--sg-bg, #fff); background: #22a06b;\n }\n .pf-dot[data-status='In a meeting'] { background: #e5a000; }\n .pf-dot[data-status='Away'] { background: #9aa4b2; }\n .pf-name { font-size: 15px; line-height: 1.25; padding: 0 12px; }\n .pf-role { font-size: 13px; color: var(--sg-muted, #64748b); margin-top: 2px; padding: 0 12px; }\n .pf-meta { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px; margin-top: 10px; padding: 0 12px; }\n .pf-team {\n padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600;\n color: var(--team); background: color-mix(in srgb, var(--team) 14%, transparent);\n }\n .pf-city { display: inline-flex; align-items: center; gap: 3px; font-size: 12px; color: var(--sg-muted, #64748b); }\n .pf-actions { display: flex; gap: 8px; margin-top: 14px; }\n .pf-btn {\n display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 8px; font-size: 12.5px; font-weight: 600;\n color: var(--sg-fg, #0f172a); text-decoration: none; border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 14%, transparent);\n }\n .pf-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, transparent); }\n .pf-btn-quiet { font-weight: 500; color: var(--sg-muted, #64748b); border-color: transparent; }\n</style>\n"
|
|
3089
|
+
},
|
|
3090
|
+
{
|
|
3091
|
+
"id": "507-gallery-listings",
|
|
3092
|
+
"path": "examples/src/demos/507-gallery-listings.svelte",
|
|
3093
|
+
"title": "Gallery Listings",
|
|
3094
|
+
"category": "Gallery",
|
|
3095
|
+
"blurb": "507. Gallery: real estate listings The cards are the grid's filtered and sorted rows, so everything the grid does to its rows shows on the wall. The status buttons set a column filter",
|
|
3096
|
+
"source": "<script lang=\"ts\">\n // Gallery rendering is an Enterprise feature - register the renderer.\n import { enableGalleryView } from '@svgrid/enterprise'\n enableGalleryView()\n /**\n * 507. Gallery: real estate listings\n * ----------------------------------\n * The cards are the grid's filtered and sorted rows, so everything the grid\n * does to its rows shows on the wall. The status buttons set a column filter\n * (`api.setFilter`), \"Beds\" a numeric one, and the sort menu `api.setSort`;\n * the search box is the grid's quick filter. Each listing is drawn by a\n * `card` snippet: the photo with its status, the price, beds, baths and\n * size on one line, the address and the agent. Table shows the same rows.\n */\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n import { housePhoto } from '../shared/gallery-art'\n\n type Listing = {\n id: number\n street: string\n area: string\n photo: string\n status: string\n price: number\n beds: number\n baths: number\n sqft: number\n daysListed: number\n agent: string\n }\n\n const STREETS = ['Elm Street', 'Oak Avenue', 'Birch Lane', 'Cedar Court', 'Maple Drive', 'Willow Way', 'Harbor Road', 'Hillcrest Place']\n const AREAS = ['Riverside', 'Old Town', 'Hillcrest', 'Harbor', 'Parkview']\n const AGENTS = ['Ana Ruiz', 'Ben Okafor', 'Cleo Park', 'Dev Shah']\n const STATUSES = ['For sale', 'Pending', 'Sold']\n const rows: Listing[] = Array.from({ length: 24 }, (_, i) => {\n const beds = 1 + ((i * 3) % 5)\n return {\n id: i + 1,\n street: `${120 + ((i * 37) % 880)} ${STREETS[i % STREETS.length]}`,\n area: AREAS[(i * 2) % AREAS.length]!,\n photo: housePhoto(i * 11 + 3, beds > 2 ? 2 : 1),\n status: STATUSES[i % 7 === 0 ? 2 : i % 4 === 0 ? 1 : 0]!,\n price: 285000 + beds * 92000 + ((i * 7919) % 90) * 1500,\n beds,\n baths: Math.max(1, beds - (i % 2)),\n sqft: 640 + beds * 360 + ((i * 131) % 220),\n daysListed: 1 + ((i * 13) % 40),\n agent: AGENTS[i % AGENTS.length]!,\n }\n })\n\n const columns: GridColumns<Listing> = [\n { field: 'street', header: 'Address', width: 200 },\n { field: 'area', header: 'Area', width: 110 },\n { field: 'price', header: 'Price', width: 120, editorType: 'number', format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },\n { field: 'status', header: 'Status', width: 100 },\n { field: 'beds', header: 'Beds', width: 70, editorType: 'number' },\n { field: 'baths', header: 'Baths', width: 70, editorType: 'number' },\n { field: 'sqft', header: 'Size', width: 100, editorType: 'number', formatter: ({ value }) => `${Number(value).toLocaleString('en-US')} sq ft` },\n { field: 'daysListed', header: 'Days listed', width: 100, editorType: 'number' },\n { field: 'agent', header: 'Agent', width: 120 },\n ]\n\n let view = $state<'gallery' | 'table'>('gallery')\n let status = $state('All')\n let minBeds = $state(0)\n let sort = $state('price-asc')\n let saved = $state<Record<number, boolean>>({})\n let api = $state.raw<SvGridApi<any, Listing> | null>(null)\n $effect(() => {\n if (!api) return\n if (status === 'All') api.clearFilter('status')\n else api.setFilter('status', { operator: 'equals', value: status })\n })\n $effect(() => {\n if (!api) return\n if (minBeds === 0) api.clearFilter('beds')\n else api.setFilter('beds', { operator: 'greaterThan', value: String(minBeds - 1) })\n })\n $effect(() => {\n if (!api) return\n const [field, dir] = sort.split('-') as [string, 'asc' | 'desc']\n api.setSort(field, dir)\n })\n const price = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })\n</script>\n\n<section class=\"ls flex flex-col gap-3 h-full min-h-0\">\n <header class=\"flex flex-wrap items-center gap-3 shrink-0\">\n <div class=\"ls-tabs\" role=\"radiogroup\" aria-label=\"Status\">\n {#each ['All', ...STATUSES] as s (s)}\n <button type=\"button\" role=\"radio\" aria-checked={status === s} class:is-on={status === s} onclick={() => (status = s)}>{s}</button>\n {/each}\n </div>\n <select class=\"ls-select\" aria-label=\"Bedrooms\" bind:value={minBeds}>\n <option value={0}>Any beds</option>\n <option value={2}>2+ beds</option>\n <option value={3}>3+ beds</option>\n <option value={4}>4+ beds</option>\n </select>\n <select class=\"ls-select\" aria-label=\"Sort\" bind:value={sort}>\n <option value=\"price-asc\">Price: low to high</option>\n <option value=\"price-desc\">Price: high to low</option>\n <option value=\"sqft-desc\">Largest first</option>\n <option value=\"daysListed-asc\">Newest listings</option>\n </select>\n <span class=\"flex-1\"></span>\n <div class=\"ls-tabs\" role=\"radiogroup\" aria-label=\"View\">\n <button type=\"button\" role=\"radio\" aria-checked={view === 'gallery'} class:is-on={view === 'gallery'} onclick={() => (view = 'gallery')}>Gallery</button>\n <button type=\"button\" role=\"radio\" aria-checked={view === 'table'} class:is-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>\n </div>\n </header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n {columns}\n getRowId={(r) => String(r.id)}\n sortable\n filterable\n containerHeight=\"100%\"\n onApiReady={(a) => (api = a)}\n gallery={view === 'gallery' ? { card: listing, cardSize: 280, searchPlaceholder: 'Search by street, area or agent', emptyText: 'No listings match' } : undefined}\n />\n </div>\n</section>\n\n{#snippet listing(l: Listing)}\n <article class=\"lc\">\n <div class=\"lc-media\">\n <img src={l.photo} alt=\"\" loading=\"lazy\" decoding=\"async\" />\n <span class=\"lc-status\" data-status={l.status}><i></i>{l.status}</span>\n <button\n type=\"button\"\n class=\"lc-save\"\n class:is-on={saved[l.id]}\n aria-pressed={!!saved[l.id]}\n aria-label={saved[l.id] ? 'Saved' : 'Save listing'}\n onclick={() => (saved = { ...saved, [l.id]: !saved[l.id] })}\n >\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z\" fill={saved[l.id] ? 'currentColor' : 'none'} stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linejoin=\"round\" /></svg>\n </button>\n {#if l.daysListed <= 3 && l.status === 'For sale'}<span class=\"lc-new\">New</span>{/if}\n </div>\n <div class=\"lc-body\">\n <strong class=\"lc-price\">{price(l.price)}</strong>\n <span class=\"lc-facts\"><b>{l.beds}</b> bd <i></i> <b>{l.baths}</b> ba <i></i> <b>{l.sqft.toLocaleString('en-US')}</b> sq ft</span>\n <span class=\"lc-addr\">{l.street}, {l.area}</span>\n <span class=\"lc-agent\">Listed by {l.agent} · {l.daysListed} {l.daysListed === 1 ? 'day' : 'days'} ago</span>\n </div>\n </article>\n{/snippet}\n\n<style>\n .ls { color: var(--sg-fg, #0f172a); }\n .ls-tabs { display: inline-flex; padding: 3px; gap: 2px; border-radius: 999px; background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); }\n .ls-tabs button {\n border: 0; border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer;\n background: none; color: var(--sg-muted, #64748b);\n }\n .ls-tabs button.is-on { background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font-weight: 600; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12); }\n .ls-select {\n height: 32px; padding: 0 10px; border-radius: 999px; font: inherit; font-size: 13px;\n border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 12%, transparent); background: var(--sg-bg, #fff); color: inherit;\n }\n .lc {\n overflow: hidden; border-radius: 14px; background: var(--sg-bg, #fff);\n border: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent);\n transition: box-shadow 0.18s ease, transform 0.18s ease;\n }\n .lc:hover { transform: translateY(-2px); box-shadow: 0 16px 32px -16px rgba(15, 23, 42, 0.4); }\n .lc-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; }\n .lc-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.45s ease; }\n .lc:hover .lc-media img { transform: scale(1.04); }\n .lc-status {\n position: absolute; top: 10px; left: 10px; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;\n border-radius: 999px; font-size: 11.5px; font-weight: 600; background: rgba(255, 255, 255, 0.94); color: #1f2937;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);\n }\n .lc-status i { width: 7px; height: 7px; border-radius: 50%; background: #16a34a; }\n .lc-status[data-status='Pending'] i { background: #d97706; }\n .lc-status[data-status='Sold'] i { background: #dc2626; }\n .lc-new {\n position: absolute; bottom: 10px; left: 10px; padding: 3px 9px; border-radius: 6px; font-size: 11px; font-weight: 700;\n letter-spacing: 0.04em; text-transform: uppercase; background: #1f2937; color: #fff;\n }\n .lc-save {\n position: absolute; top: 8px; right: 8px; display: inline-flex; padding: 7px; border: 0; border-radius: 999px; cursor: pointer;\n background: rgba(255, 255, 255, 0.92); color: #1f2937; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);\n }\n .lc-save.is-on { color: #dc2626; }\n .lc-body { display: flex; flex-direction: column; gap: 3px; padding: 12px 14px 14px; }\n .lc-price { font-size: 20px; letter-spacing: -0.01em; }\n .lc-facts { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--sg-muted, #64748b); }\n .lc-facts b { color: var(--sg-fg, #0f172a); font-weight: 600; }\n .lc-facts i { width: 1px; height: 12px; margin: 0 4px; background: color-mix(in srgb, var(--sg-fg, #0f172a) 18%, transparent); }\n .lc-addr { font-size: 13.5px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .lc-agent { font-size: 12px; color: var(--sg-muted, #64748b); }\n</style>\n"
|
|
3097
|
+
},
|
|
3098
|
+
{
|
|
3099
|
+
"id": "508-gallery-field-types",
|
|
3100
|
+
"path": "examples/src/demos/508-gallery-field-types.svelte",
|
|
3101
|
+
"title": "Gallery Field Types",
|
|
3102
|
+
"category": "Gallery",
|
|
3103
|
+
"blurb": "508. Gallery: cards from field types With the fields module on (`@svgrid/enterprise/fields`), a gallery card draws each field the way its cell does: a single select as a colored",
|
|
3104
|
+
"source": "<script lang=\"ts\">\n /**\n * 508. Gallery: cards from field types\n * ------------------------------------\n * With the fields module on (`@svgrid/enterprise/fields`), a gallery card\n * draws each field the way its cell does: a single select as a colored\n * pill, a multiple select as pills, a rating as stars, a person with an\n * avatar, a currency number with its symbol. The cover is the first image\n * of an attachment field. Each field's type icon sits beside its name.\n * Clicking a card picks it (`onCardClick`).\n */\n import { enableGalleryView } from '@svgrid/enterprise'\n import { enableFields, setFieldUsers, type Attachment } from '@svgrid/enterprise/fields'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import { editorialArt } from '../shared/gallery-art'\n\n enableGalleryView()\n enableFields()\n\n setFieldUsers([\n { id: 'u1', name: 'Ana Ruiz' },\n { id: 'u2', name: 'Ben Okafor' },\n { id: 'u3', name: 'Cleo Park' },\n ])\n\n // Campaign artwork drawn here (an editorial SVG per idea), so the demo needs no image host.\n const artwork = (i: number, name: string): Attachment => ({\n id: `art-${i}`,\n name: `${name.toLowerCase().replace(/\\s+/g, '-')}.svg`,\n type: 'image/svg+xml',\n url: editorialArt(i + 1),\n })\n\n const STAGE = [\n { value: 'Concept', color: '#8b46ff' },\n { value: 'In review', color: '#fcb400' },\n { value: 'Approved', color: '#20c933' },\n ]\n const CHANNELS = [\n { value: 'Web', color: '#2d7ff9' },\n { value: 'Social', color: '#ff08c2' },\n { value: 'Print', color: '#ff6f2c' },\n ]\n const IDEAS = ['Autumn launch', 'Studio tour', 'Field notes', 'Night market', 'Coastline', 'Paper goods', 'Open house', 'Winter sale', 'Makers week']\n const rows = IDEAS.map((name, i) => ({\n id: i + 1,\n name,\n images: [artwork(i, name)],\n stage: STAGE[i % 3]!.value,\n channels: [CHANNELS[i % 3]!.value, ...(i % 2 ? [CHANNELS[(i + 1) % 3]!.value] : [])],\n owner: ['u1', 'u2', 'u3'][i % 3],\n score: 2 + (i % 4),\n budget: 1200 + i * 450,\n }))\n\n type Idea = (typeof rows)[number]\n const columns: GridColumns<Idea> = [\n { field: 'name', header: 'Idea', width: 180, fieldType: 'text' },\n { field: 'images', header: 'Images', width: 140, fieldType: 'attachment' },\n { field: 'stage', header: 'Stage', width: 120, fieldType: 'singleSelect', fieldProps: { options: STAGE } },\n { field: 'channels', header: 'Channels', width: 170, fieldType: 'multiSelect', fieldProps: { options: CHANNELS } },\n { field: 'owner', header: 'Owner', width: 140, fieldType: 'user' },\n { field: 'score', header: 'Score', width: 120, fieldType: 'rating', fieldProps: { max: 5, color: '#fcb400' } },\n { field: 'budget', header: 'Budget', width: 110, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0 } },\n ]\n\n let view = $state<'gallery' | 'table'>('gallery')\n let picked = $state('')\n const getRowId = (r: { id: number }) => String(r.id)\n const pick = (r: { name: string }) => (picked = r.name)\n</script>\n\n<section class=\"flex flex-col gap-3 h-full min-h-0\">\n <div class=\"flex flex-wrap items-center gap-3 shrink-0\">\n <p class=\"ft-note text-sm m-0 flex-1 min-w-[240px]\">\n {picked ? `Picked: ${picked}` : 'Field types draw on the cards as in the cells. Click a card to pick it.'}\n </p>\n <div class=\"ft-switch inline-flex rounded-md overflow-hidden text-sm\" role=\"radiogroup\" aria-label=\"View\">\n <button type=\"button\" role=\"radio\" aria-checked={view === 'gallery'} class=\"px-3 py-1 {view === 'gallery' ? 'ft-on' : ''}\" onclick={() => (view = 'gallery')}>Gallery</button>\n <button type=\"button\" role=\"radio\" aria-checked={view === 'table'} class=\"px-3 py-1 {view === 'table' ? 'ft-on' : ''}\" onclick={() => (view = 'table')}>Table</button>\n </div>\n </div>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n {columns}\n {getRowId}\n sortable\n containerHeight=\"100%\"\n gallery={view === 'gallery'\n ? { coverField: 'images', titleField: 'name', onCardClick: pick, searchPlaceholder: 'Search ideas...' }\n : undefined}\n />\n </div>\n</section>\n\n<style>\n .ft-note { color: var(--sg-muted, #64748b); }\n .ft-switch { border: 1px solid var(--sg-border, #cbd5e1); background: none; color: var(--sg-fg, #0f172a); }\n .ft-on { background: var(--sg-header-bg, #f1f5f9); color: var(--sg-fg, #0f172a); font-weight: 600; }\n</style>\n"
|
|
3105
|
+
},
|
|
3106
|
+
{
|
|
3107
|
+
"id": "509-gallery-at-scale",
|
|
3108
|
+
"path": "examples/src/demos/509-gallery-at-scale.svelte",
|
|
3109
|
+
"title": "Gallery At Scale",
|
|
3110
|
+
"category": "Gallery",
|
|
3111
|
+
"blurb": "509. Gallery at scale 5,000 rows as cards. The wall draws them in batches as it scrolls (120 at a time, the next batch a little before the end comes into view), so the",
|
|
3112
|
+
"source": "<script lang=\"ts\">\n // Gallery rendering is an Enterprise feature - register the renderer.\n import { enableGalleryView } from '@svgrid/enterprise'\n enableGalleryView()\n /**\n * 509. Gallery at scale\n * ---------------------\n * 5,000 rows as cards. The wall draws them in batches as it scrolls (120 at\n * a time, the next batch a little before the end comes into view), so the\n * first paint costs the same as a small table. Search narrows all 5,000 and\n * starts the wall from the top again. The cover comes from a computed\n * column (`fieldFn`), so no image is stored in the rows: each piece is drawn\n * when its card is.\n */\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import { ceramic } from '../shared/gallery-art'\n\n // `cover` is not stored: the Cover column computes it (fieldFn).\n type Item = { id: number; sku: string; name: string; price: number; stock: number; cover?: string }\n\n // Named as drawn: the glaze cycles every piece, the shape every ten.\n const GLAZE = ['Sand', 'Celadon', 'Oat', 'Slate', 'Terracotta', 'Bone', 'Moss', 'Lilac', 'Ochre', 'Sky']\n const SHAPE = ['vase', 'bowl', 'jar', 'bottle']\n const rows: Item[] = Array.from({ length: 5000 }, (_, i) => ({\n id: i + 1,\n sku: `CER-${String(i + 1).padStart(5, '0')}`,\n name: `${GLAZE[i % 10]} ${SHAPE[Math.floor(i / 10) % 4]} no. ${Math.floor(i / 40) + 1}`,\n price: 18 + ((i * 37) % 160),\n stock: (i * 13) % 60,\n }))\n\n // Each piece photographed per card from its index: no image is stored in the rows.\n const shot = (r: Item) => ceramic(r.id - 1)\n\n const columns: GridColumns<Item> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'sku', header: 'SKU', width: 120 },\n { field: 'price', header: 'Price', width: 100, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n { field: 'stock', header: 'Stock', width: 90, editorType: 'number' },\n // The cover: computed per row, not stored, and not shown as a table column.\n { id: 'cover', field: 'cover', header: 'Cover', fieldFn: shot, visible: false },\n ]\n\n let view = $state<'gallery' | 'table'>('gallery')\n</script>\n\n<section class=\"flex flex-col gap-3 h-full min-h-0\">\n <div class=\"flex flex-wrap items-center gap-3 shrink-0\">\n <p class=\"sc-note text-sm m-0 flex-1 min-w-[240px]\">5,000 cards, drawn in batches as you scroll. Search narrows all of them.</p>\n <div class=\"sc-switch inline-flex rounded-md overflow-hidden text-sm\" role=\"radiogroup\" aria-label=\"View\">\n <button type=\"button\" role=\"radio\" aria-checked={view === 'gallery'} class=\"px-3 py-1 {view === 'gallery' ? 'sc-on' : ''}\" onclick={() => (view = 'gallery')}>Gallery</button>\n <button type=\"button\" role=\"radio\" aria-checked={view === 'table'} class=\"px-3 py-1 {view === 'table' ? 'sc-on' : ''}\" onclick={() => (view = 'table')}>Table</button>\n </div>\n </div>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n {columns}\n getRowId={(r) => String(r.id)}\n sortable\n containerHeight=\"100%\"\n gallery={view === 'gallery' ? { coverField: 'cover', titleField: 'name', cardFields: ['sku', 'price', 'stock'], cardSize: 'small' } : undefined}\n />\n </div>\n</section>\n\n<style>\n .sc-note { color: var(--sg-muted, #64748b); }\n .sc-switch { border: 1px solid var(--sg-border, #cbd5e1); background: none; color: var(--sg-fg, #0f172a); }\n .sc-on { background: var(--sg-header-bg, #f1f5f9); color: var(--sg-fg, #0f172a); font-weight: 600; }\n</style>\n"
|
|
3113
|
+
},
|
|
3066
3114
|
{
|
|
3067
3115
|
"id": "51-ai-assistant",
|
|
3068
3116
|
"path": "examples/src/demos/51-ai-assistant.svelte",
|
|
@@ -3071,6 +3119,86 @@ export const examples = [
|
|
|
3071
3119
|
"blurb": "51. AI assistant The AI helpers are FREE and ship in @svgrid/grid (MIT) - setAIProvider, aiFilter, aiSmartFill, aiSummarize, aiClassify, aiFindAnomalies and",
|
|
3072
3120
|
"source": "<script lang=\"ts\">\n /**\n * 51. AI assistant\n * ----------------\n * The AI helpers are FREE and ship in @svgrid/grid (MIT) - setAIProvider,\n * aiFilter, aiSmartFill, aiSummarize, aiClassify, aiFindAnomalies and\n * mockAIProvider. This demo imports nothing else. The grid stays\n * model-agnostic; the demo wires the bundled `mockAIProvider` so\n * everything works end-to-end without any keys. In production you\n * register your own adapter that calls OpenAI / Anthropic / a proxy:\n *\n * setAIProvider(async ({ prompt, responseFormat, signal }) => {\n * const r = await fetch('/api/ai', { method: 'POST', body: prompt, signal })\n * return r.text()\n * })\n *\n * Four panels, four AI features:\n *\n * 1. **Ask** - natural-language filter / sort plan. Preview the\n * filters the model proposes; one click applies them.\n * 2. **Smart fill** - column-completion from worked examples.\n * Type one or two values into \"tier\", hit \"Propose rest\".\n * 3. **Summarize** - one-paragraph summary of the current view,\n * a selection, or a single row.\n * 4. **Classify** - bucket free-text rows into predefined labels\n * (\"at-risk\", \"expanding\", \"steady\") with confidence.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n setAIProvider,\n mockAIProvider,\n aiFilter,\n aiSmartFill,\n aiSummarize,\n aiClassify,\n type ColumnDef,\n type SvGridApi,\n type AIFilterResult,\n type AISmartFillResult,\n type AISummary,\n type AIClassifyResult,\n } from '@svgrid/grid'\n\n // ---- Domain ---------------------------------------------------------\n\n type Account = {\n id: string\n company: string\n owner: string\n region: 'NA' | 'EMEA' | 'APAC' | 'LATAM'\n industry: string\n arr: number\n employees: number\n nps: number\n lastTouch: string // ISO date\n notes: string // free-text - what the classifier reads\n tier: string // committed value (Smart Fill commit or manual edit)\n proposedTier: string // AI proposal awaiting accept\n tierConfidence: number\n sentiment: string // committed value (Classify commit)\n proposedSentiment: string // AI proposal awaiting commit\n sentimentConfidence: number\n }\n\n // ---- Seed -----------------------------------------------------------\n\n const COMPANIES = [\n 'Northwind Logistics', 'Helios Holdings', 'Vertex Trust', 'Pacific Industries',\n 'Atlas Group', 'Stellar Networks', 'Quantum Bio', 'Apex Resources',\n 'Crescent Labs', 'Polaris Systems', 'Aurora Materials', 'Sigma Capital',\n 'Pioneer Mining', 'Granite Markets', 'Cobalt Engineering', 'Sentinel Health',\n 'Tessera Energy', 'Vanguard Foods', 'Cascade Bio', 'Meridian Retail',\n ]\n const OWNERS = ['Sasha Park', 'Jamie Chen', 'Robin Diaz', 'Casey Singh', 'Drew Olsen', 'Morgan Tran']\n const REGIONS: readonly Account['region'][] = ['NA', 'EMEA', 'APAC', 'LATAM']\n const INDUSTRIES = ['SaaS', 'Manufacturing', 'Logistics', 'Financial Services', 'Healthcare', 'Energy', 'Retail']\n\n const NOTE_TEMPLATES = [\n 'Renewal looking solid, expanding into 2 new business units next quarter.',\n 'Champion left the company, no clear backfill. ARR at risk if not stabilised.',\n 'Steady usage, no major changes. Quiet but reliable.',\n 'Procurement flagged budget cuts; spend review scheduled for next month.',\n 'Discussion about adding 200 seats and the analytics module.',\n 'Support escalation last week left a sour taste. Watching closely.',\n 'Pilot with the EMEA team converted. Looking at full org rollout in Q3.',\n 'Renewal pushed back two months while legal redlines the new MSA.',\n 'Open ticket about reporting performance. Engineering has eyes on it.',\n 'Looks healthy on the surface, but usage is trending down month over month.',\n ]\n\n let prng = 0xA1A2A3A4 >>> 0\n function rnd(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0\n return prng / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }\n\n function seedAccounts(): Account[] {\n return COMPANIES.map((name, i) => ({\n id: `ACC-${(1000 + i).toString()}`,\n company: name,\n owner: pick(OWNERS),\n region: pick(REGIONS),\n industry: pick(INDUSTRIES),\n arr: Math.round((40_000 + rnd() * 760_000) / 1000) * 1000,\n employees: 30 + Math.floor(rnd() * 1500),\n nps: -10 + Math.floor(rnd() * 70),\n lastTouch: new Date(Date.now() - Math.floor(rnd() * 90) * 86_400_000).toISOString().slice(0, 10),\n notes: pick(NOTE_TEMPLATES),\n tier: '',\n proposedTier: '',\n tierConfidence: 0,\n sentiment: '',\n proposedSentiment: '',\n sentimentConfidence: 0,\n }))\n }\n\n // ---- Reactive state -------------------------------------------------\n\n // Mock provider so the demo works without an API key. In a real app\n // this is the seam where you wire your model call.\n setAIProvider(mockAIProvider)\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n let accounts = $state<Account[]>(seedAccounts())\n let api = $state<SvGridApi<typeof features, Account> | null>(null)\n let activeIndex = $state<number>(0)\n\n // Tab state\n type Tab = 'ask' | 'fill' | 'summary' | 'classify'\n let tab = $state<Tab>('ask')\n\n // Shared \"loading\" state per panel.\n let busy = $state<Tab | null>(null)\n let errorMsg = $state<string | null>(null)\n\n // Ask (NL filter)\n let askQuery = $state<string>('show accounts with ARR over $200k in EMEA, highest first')\n let askPlan = $state<AIFilterResult | null>(null)\n\n // Smart fill\n let fillExamples = $state<Array<{ company: string; tier: string }>>([\n { company: COMPANIES[0]!, tier: 'enterprise' },\n { company: COMPANIES[1]!, tier: 'growth' },\n ])\n let fillResult = $state<AISmartFillResult | null>(null)\n\n // Summary\n let summaryScope = $state<'all' | 'row'>('all')\n let summaryQuestion = $state<string>('Which accounts should I worry about?')\n let summary = $state<AISummary | null>(null)\n\n // Classify\n const CLASSES = ['at-risk', 'expanding', 'steady'] as const\n let classifyResult = $state<AIClassifyResult | null>(null)\n\n // ---- Actions --------------------------------------------------------\n\n async function runAsk(): Promise<void> {\n if (!api) return\n busy = 'ask'; errorMsg = null; askPlan = null\n try {\n askPlan = await aiFilter(api, askQuery)\n } catch (e) { errorMsg = (e as Error).message }\n finally { busy = null }\n }\n\n function applyAskPlan(): void {\n if (!api || !askPlan) return\n api.clearAllFilters()\n api.clearSort()\n for (const f of askPlan.filters) {\n api.setFilter(f.field, { operator: f.operator, value: f.value ?? '' })\n }\n const last = askPlan.sort[askPlan.sort.length - 1]\n if (last) api.setSort(last.field, last.desc ? 'desc' : 'asc')\n }\n\n async function runSmartFill(): Promise<void> {\n if (!api) return\n busy = 'fill'; errorMsg = null; fillResult = null\n try {\n fillResult = await aiSmartFill(api, {\n field: 'tier',\n examples: fillExamples.map((ex) => ({\n input: accounts.find((a) => a.company === ex.company) ?? {},\n output: ex.tier,\n })),\n })\n // Write proposals onto the data so the grid cells pick them up.\n const byIdx = new Map(fillResult.predictions.map((p) => [p.rowIndex, p]))\n accounts = accounts.map((a, i) => {\n const p = byIdx.get(i)\n if (!p) return a\n return { ...a, proposedTier: String(p.value), tierConfidence: p.confidence }\n })\n } catch (e) { errorMsg = (e as Error).message }\n finally { busy = null }\n }\n\n function acceptFillPrediction(rowIndex: number, value: unknown): void {\n accounts = accounts.map((a, i) => (\n i === rowIndex ? { ...a, tier: String(value), proposedTier: '', tierConfidence: 0 } : a\n ))\n if (fillResult) {\n fillResult = {\n ...fillResult,\n predictions: fillResult.predictions.filter((p) => p.rowIndex !== rowIndex),\n }\n }\n }\n function acceptAllFill(): void {\n if (!fillResult) return\n const byIndex = new Map(fillResult.predictions.map((p) => [p.rowIndex, String(p.value)]))\n accounts = accounts.map((a, i) => (\n byIndex.has(i)\n ? { ...a, tier: byIndex.get(i)!, proposedTier: '', tierConfidence: 0 }\n : a\n ))\n fillResult = { ...fillResult, predictions: [] }\n }\n\n async function runSummarize(): Promise<void> {\n if (!api) return\n busy = 'summary'; errorMsg = null; summary = null\n try {\n summary = await aiSummarize(api, {\n target: summaryScope === 'row' ? { kind: 'row', rowIndex: activeIndex } : { kind: 'all' },\n question: summaryQuestion.trim() || undefined,\n })\n } catch (e) { errorMsg = (e as Error).message }\n finally { busy = null }\n }\n\n async function runClassify(): Promise<void> {\n if (!api) return\n busy = 'classify'; errorMsg = null; classifyResult = null\n try {\n classifyResult = await aiClassify(api, {\n inputField: 'notes',\n outputField: 'sentiment',\n classes: [...CLASSES],\n classDescriptions: {\n 'at-risk': 'churn signals, escalations, lost champion, declining usage',\n 'expanding': 'new modules, more seats, additional regions, deeper adoption',\n 'steady': 'no signals in either direction',\n },\n })\n // Write proposals onto the row data so the Sentiment cell picks them\n // up via standard Svelte reactivity. Without this the grid wouldn't\n // know to re-render cells when classifyResult changes.\n const byIdx = new Map(classifyResult.predictions.map((p) => [p.rowIndex, p]))\n accounts = accounts.map((a, i) => {\n const p = byIdx.get(i)\n if (!p) return a\n return { ...a, proposedSentiment: p.value, sentimentConfidence: p.confidence }\n })\n } catch (e) { errorMsg = (e as Error).message }\n finally { busy = null }\n }\n function applyClassify(): void {\n if (!classifyResult) return\n const byIndex = new Map(classifyResult.predictions.map((p) => [p.rowIndex, p.value]))\n accounts = accounts.map((a, i) => (\n byIndex.has(i)\n ? { ...a, sentiment: byIndex.get(i)!, proposedSentiment: '', sentimentConfidence: 0 }\n : a\n ))\n }\n\n // ---- Formatters -----------------------------------------------------\n\n function fmtMoneyShort(n: number): string {\n if (Math.abs(n) >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`\n if (Math.abs(n) >= 1_000) return `$${(n / 1_000).toFixed(0)}k`\n return `$${n}`\n }\n</script>\n\n<!-- ─────────────────── CELL SNIPPETS ─────────────────── -->\n\n{#snippet TierCell(props: { row: Account; rowIndex: number })}\n <span class=\"ai-tier-cell\">\n {#if props.row.tier}\n <span class={`ai-tier ai-tier-${props.row.tier}`}>{props.row.tier}</span>\n {:else if props.row.proposedTier}\n <span class={`ai-tier ai-tier-${props.row.proposedTier}`} title={`confidence ${Math.round(props.row.tierConfidence * 100)}%`}>\n {props.row.proposedTier}\n <button type=\"button\" class=\"ai-accept\" onclick={(e) => { e.stopPropagation(); acceptFillPrediction(props.rowIndex, props.row.proposedTier) }}>✓</button>\n </span>\n {:else}\n <span class=\"ai-muted\">-</span>\n {/if}\n </span>\n{/snippet}\n\n{#snippet SentimentCell(props: { row: Account; rowIndex: number })}\n {@const committed = props.row.sentiment}\n {@const proposed = props.row.proposedSentiment}\n {@const val = committed || proposed}\n {#if val}\n <span class={`ai-sent ai-sent-${val}`} title={!committed ? `proposed (${Math.round(props.row.sentimentConfidence * 100)}%)` : ''}>\n {val}{#if !committed && proposed}<span class=\"ai-pred-dot\" aria-hidden=\"true\"></span>{/if}\n </span>\n {:else}\n <span class=\"ai-muted\">-</span>\n {/if}\n{/snippet}\n\n{#snippet NotesCell(props: { row: Account })}\n <span class=\"ai-notes\" title={props.row.notes}>{props.row.notes}</span>\n{/snippet}\n\n{#snippet ArrCell(props: { row: Account })}\n <span class=\"ai-arr tabular-nums\">{fmtMoneyShort(props.row.arr)}</span>\n{/snippet}\n\n<!-- ─────────────────── LAYOUT ─────────────────── -->\n\n<section class=\"ai-shell flex flex-1 min-h-0 gap-3\">\n <!-- AI panel (left) -->\n <aside class=\"ai-panel\">\n <header class=\"ai-header\">\n <div class=\"ai-header-row\">\n <span class=\"ai-badge\">PRO · AI</span>\n <span class=\"ai-header-title\">Grid Assistant</span>\n </div>\n <p class=\"ai-header-sub\">\n Powered by your provider via <code>setAIProvider()</code>. This demo\n uses the bundled deterministic mock so no key is required.\n </p>\n </header>\n\n <nav class=\"ai-tabs\">\n {#each [\n { id: 'ask', label: 'Ask' },\n { id: 'fill', label: 'Smart fill' },\n { id: 'summary', label: 'Summarise' },\n { id: 'classify', label: 'Classify' },\n ] as t (t.id)}\n <button type=\"button\" class={`ai-tab ${tab === t.id ? 'ai-tab-active' : ''}`} onclick={() => (tab = t.id as Tab)}>\n {t.label}\n </button>\n {/each}\n </nav>\n\n <div class=\"ai-tab-body\">\n {#if tab === 'ask'}\n <label class=\"ai-field\">\n <span>Ask in plain English</span>\n <textarea rows=\"3\" bind:value={askQuery} placeholder=\"e.g. accounts losing momentum in EMEA, by NPS\"></textarea>\n </label>\n <button type=\"button\" class=\"ai-btn ai-btn-primary\" disabled={busy === 'ask'} onclick={runAsk}>\n {busy === 'ask' ? 'Planning...' : 'Plan filter'}\n </button>\n {#if askPlan}\n <div class=\"ai-result\">\n <div class=\"ai-rationale\">{askPlan.rationale}</div>\n {#if askPlan.filters.length || askPlan.sort.length}\n <ul class=\"ai-clauses\">\n {#each askPlan.filters as f, i (i)}\n <li><strong>{f.field}</strong> <span class=\"ai-muted\">{f.operator}</span> <code>{f.value ?? ''}</code></li>\n {/each}\n {#each askPlan.sort as s, i (`s${i}`)}\n <li>sort <strong>{s.field}</strong> <span class=\"ai-muted\">{s.desc ? 'desc' : 'asc'}</span></li>\n {/each}\n </ul>\n <button type=\"button\" class=\"ai-btn ai-btn-primary ai-btn-block\" onclick={applyAskPlan}>Apply to grid</button>\n {:else}\n <div class=\"ai-muted\">No filter clauses inferred.</div>\n {/if}\n </div>\n {/if}\n {:else if tab === 'fill'}\n <div class=\"ai-hint\">Provide one or two example values for the <strong>tier</strong> column, then let the assistant propose the rest.</div>\n <div class=\"ai-examples\">\n {#each fillExamples as ex, i (i)}\n <div class=\"ai-example-row\">\n <select bind:value={ex.company}>\n {#each COMPANIES as c (c)}<option>{c}</option>{/each}\n </select>\n <span class=\"ai-arrow\">-></span>\n <input type=\"text\" bind:value={ex.tier} placeholder=\"enterprise\" />\n </div>\n {/each}\n </div>\n <button type=\"button\" class=\"ai-btn ai-btn-primary\" disabled={busy === 'fill'} onclick={runSmartFill}>\n {busy === 'fill' ? 'Inferring...' : 'Propose rest'}\n </button>\n {#if fillResult}\n <div class=\"ai-result\">\n <div class=\"ai-rationale\">{fillResult.rationale}</div>\n <div class=\"ai-fill-summary\">\n {fillResult.predictions.length} proposal{fillResult.predictions.length === 1 ? '' : 's'} pending.\n {#if fillResult.predictions.length}\n <button type=\"button\" class=\"ai-btn ai-btn-sm\" onclick={acceptAllFill}>Accept all</button>\n {/if}\n </div>\n </div>\n {/if}\n {:else if tab === 'summary'}\n <div class=\"ai-scope\">\n <label><input type=\"radio\" bind:group={summaryScope} value=\"all\" /> Whole view</label>\n <label><input type=\"radio\" bind:group={summaryScope} value=\"row\" /> Active row</label>\n </div>\n <label class=\"ai-field\">\n <span>Focus question (optional)</span>\n <input type=\"text\" bind:value={summaryQuestion} placeholder=\"What should I worry about?\" />\n </label>\n <button type=\"button\" class=\"ai-btn ai-btn-primary\" disabled={busy === 'summary'} onclick={runSummarize}>\n {busy === 'summary' ? 'Reading rows...' : 'Summarise'}\n </button>\n {#if summary}\n <div class=\"ai-result ai-summary\">\n <p>{summary.text}</p>\n {#if summary.bullets.length}\n <ul class=\"ai-bullets\">\n {#each summary.bullets as b, i (i)}<li>{b}</li>{/each}\n </ul>\n {/if}\n {#if summary.highlightedFields.length}\n <div class=\"ai-highlight\">\n Key fields:\n {#each summary.highlightedFields as f, i (i)}<span class=\"ai-chip\">{f}</span>{/each}\n </div>\n {/if}\n </div>\n {/if}\n {:else if tab === 'classify'}\n <div class=\"ai-hint\">\n Bucket the <strong>notes</strong> column into one of:\n <span class=\"ai-chip ai-sent-at-risk\">at-risk</span>\n <span class=\"ai-chip ai-sent-expanding\">expanding</span>\n <span class=\"ai-chip ai-sent-steady\">steady</span>\n </div>\n <button type=\"button\" class=\"ai-btn ai-btn-primary\" disabled={busy === 'classify'} onclick={runClassify}>\n {busy === 'classify' ? 'Reading notes...' : 'Classify all rows'}\n </button>\n {#if classifyResult}\n <div class=\"ai-result\">\n <div class=\"ai-fill-summary\">\n {classifyResult.predictions.length} prediction{classifyResult.predictions.length === 1 ? '' : 's'} - visible in the <em>Sentiment</em> column.\n {#if classifyResult.predictions.length}\n <button type=\"button\" class=\"ai-btn ai-btn-sm\" onclick={applyClassify}>Commit all</button>\n {/if}\n </div>\n </div>\n {/if}\n {/if}\n\n {#if errorMsg}\n <div class=\"ai-error\">{errorMsg}</div>\n {/if}\n </div>\n\n <footer class=\"ai-footer\">\n Provider: <code>mockAIProvider</code>\n </footer>\n </aside>\n\n <!-- Grid (right) -->\n <div class=\"ai-grid-wrap flex-1 min-w-0\">\n <SvGrid responsive={true}\n columnResize\n data={accounts}\n columns={[\n { field: 'company', header: 'Company', width: 200 },\n { field: 'owner', header: 'Owner', width: 130 },\n { field: 'region', header: 'Region', width: 90 },\n { field: 'industry', header: 'Industry', width: 130 },\n { field: 'arr', header: 'ARR', editorType: 'number', width: 110, editable: false,\n cell: (ctx) => renderSnippet(ArrCell, { row: ctx.row.original }) },\n { field: 'employees', header: 'Employees', editorType: 'number', width: 100 },\n { field: 'nps', header: 'NPS', editorType: 'number', width: 80 },\n { field: 'lastTouch', header: 'Last touch', width: 110 },\n { field: 'tier', header: 'Tier', width: 140,\n cell: (ctx) => renderSnippet(TierCell, { row: ctx.row.original, rowIndex: ctx.row.index }) },\n { field: 'sentiment', header: 'Sentiment', width: 130, editable: false,\n cell: (ctx) => renderSnippet(SentimentCell, { row: ctx.row.original, rowIndex: ctx.row.index }) },\n { field: 'notes', header: 'Notes', width: 320, editable: false,\n cell: (ctx) => renderSnippet(NotesCell, { row: ctx.row.original }) },\n ] satisfies ColumnDef<typeof features, Account>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"row\"\n showRowSelection={false}\n enableInlineEditing={true}\n enableCellSelection={false}\n rowHeight={44}\n containerHeight=\"100%\"\n fitColumns={false}\n onApiReady={(a) => { api = a }}\n onActiveCellChange={(args) => { activeIndex = args.rowIndex }}\n />\n </div>\n</section>\n\n<style>\n .ai-shell { min-height: 0; }\n\n /* AI panel */\n .ai-panel {\n width: 360px;\n flex-shrink: 0;\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 10px;\n background: var(--sg-bg, #ffffff);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .ai-header {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--sg-border, #e2e8f0);\n background: color-mix(in srgb, var(--sg-accent, #2563eb) 6%, transparent 94%);\n }\n :global([data-theme='dark']) .ai-header {\n background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent 86%);\n }\n .ai-header-row { display: flex; align-items: center; gap: 8px; }\n .ai-badge {\n background: var(--sg-accent, #2563eb);\n color: var(--sg-on-accent, #fff);\n font-size: 10px;\n font-weight: 800;\n padding: 2px 7px;\n border-radius: 4px;\n letter-spacing: 0.06em;\n }\n .ai-header-title { font-size: 15px; font-weight: 700; }\n .ai-header-sub {\n margin: 6px 0 0;\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n line-height: 1.4;\n }\n .ai-header-sub code {\n font-size: 11px;\n background: var(--sg-header-bg, #f1f5f9);\n padding: 0 4px;\n border-radius: 3px;\n }\n\n .ai-tabs {\n display: flex;\n border-bottom: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-header-bg, #f1f5f9);\n }\n .ai-tab {\n flex: 1 1 0;\n border: 0;\n background: transparent;\n color: var(--sg-muted, #64748b);\n padding: 8px 6px;\n font-size: 12px;\n cursor: pointer;\n border-bottom: 2px solid transparent;\n }\n .ai-tab:hover { color: var(--sg-fg, #1e293b); }\n .ai-tab-active {\n color: var(--sg-accent, #2563eb);\n border-bottom-color: var(--sg-accent, #2563eb);\n font-weight: 600;\n background: var(--sg-bg, #ffffff);\n }\n\n .ai-tab-body {\n padding: 14px 16px;\n overflow: auto;\n flex: 1 1 auto;\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n .ai-field {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n .ai-field > span {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--sg-muted, #64748b);\n }\n .ai-field textarea,\n .ai-field input,\n .ai-scope label,\n .ai-example-row select,\n .ai-example-row input {\n border: 1px solid var(--sg-input-border, #cbd5e1);\n background: var(--sg-input-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 5px;\n padding: 6px 9px;\n font-size: 12.5px;\n font-family: inherit;\n }\n .ai-field textarea { resize: vertical; min-height: 56px; }\n\n .ai-hint {\n font-size: 12px;\n color: var(--sg-muted, #64748b);\n background: var(--sg-header-bg, #f1f5f9);\n padding: 8px 10px;\n border-radius: 6px;\n line-height: 1.4;\n }\n .ai-examples { display: flex; flex-direction: column; gap: 6px; }\n .ai-example-row { display: flex; align-items: center; gap: 6px; }\n .ai-example-row select { flex: 1 1 0; }\n .ai-example-row input { width: 110px; }\n .ai-arrow { font-family: ui-monospace, monospace; color: var(--sg-muted, #64748b); }\n\n .ai-scope { display: flex; gap: 14px; font-size: 12.5px; align-items: center; }\n .ai-scope label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; padding: 0; border: 0; background: transparent; }\n\n .ai-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 12.5px;\n cursor: pointer;\n }\n .ai-btn-primary {\n border-color: transparent;\n background: var(--sg-accent, #2563eb);\n color: var(--sg-on-accent, #fff);\n font-weight: 600;\n }\n .ai-btn-primary:disabled { opacity: 0.6; cursor: progress; }\n .ai-btn-block { width: 100%; }\n .ai-btn-sm { padding: 3px 9px; font-size: 11px; margin-left: 6px; }\n\n .ai-result {\n background: var(--sg-header-bg, #f1f5f9);\n border-radius: 8px;\n padding: 10px 12px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n font-size: 12.5px;\n }\n .ai-rationale {\n font-size: 12px;\n color: var(--sg-muted, #64748b);\n font-style: italic;\n }\n .ai-clauses {\n list-style: none;\n padding: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n font-size: 12.5px;\n }\n .ai-clauses code {\n background: var(--sg-bg, #ffffff);\n padding: 0 4px;\n border-radius: 3px;\n font-size: 11.5px;\n }\n .ai-fill-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n .ai-summary p { margin: 0; line-height: 1.45; }\n .ai-bullets { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 3px; font-size: 12px; }\n .ai-highlight { display: flex; flex-wrap: wrap; gap: 5px; font-size: 11.5px; color: var(--sg-muted, #64748b); align-items: center; }\n .ai-chip {\n display: inline-block;\n background: var(--sg-bg, #ffffff);\n border: 1px solid var(--sg-border, #cbd5e1);\n border-radius: 999px;\n padding: 1px 8px;\n font-size: 11px;\n font-weight: 600;\n }\n\n .ai-error {\n background: #fee2e2;\n color: #b91c1c;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n }\n :global([data-theme='dark']) .ai-error { background: rgba(239, 68, 68, 0.18); color: #f87171; }\n\n .ai-footer {\n padding: 8px 14px;\n border-top: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-header-bg, #f1f5f9);\n font-size: 10.5px;\n color: var(--sg-muted, #64748b);\n }\n .ai-footer code {\n background: var(--sg-bg, #ffffff);\n padding: 0 4px;\n border-radius: 3px;\n }\n\n /* Grid wrap */\n .ai-grid-wrap {\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 10px;\n background: var(--sg-bg, #ffffff);\n overflow: hidden;\n }\n\n /* Cell snippets - global because they live in cell scope */\n :global(.ai-tier-cell) { display: inline-flex; align-items: center; gap: 6px; }\n :global(.ai-tier) {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 1px 9px;\n border-radius: 4px;\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n :global(.ai-tier-enterprise) { background: #f3e8ff; color: #6b21a8; }\n :global(.ai-tier-growth) { background: #dbeafe; color: #1d4ed8; }\n :global(.ai-tier-starter) { background: #e2e8f0; color: #475569; }\n :global([data-theme='dark'] .ai-tier-enterprise) { background: rgba(168,85,247,.2); color: #d8b4fe; }\n :global([data-theme='dark'] .ai-tier-growth) { background: rgba(59,130,246,.2); color: #93c5fd; }\n :global([data-theme='dark'] .ai-tier-starter) { background: rgba(148,163,184,.2); color: #cbd5e1; }\n :global(.ai-accept) {\n border: 0;\n background: rgba(34, 197, 94, 0.18);\n color: #166534;\n width: 16px;\n height: 16px;\n border-radius: 50%;\n cursor: pointer;\n font-size: 11px;\n line-height: 1;\n font-weight: 800;\n }\n :global([data-theme='dark'] .ai-accept) { color: #4ade80; }\n\n :global(.ai-sent) {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 2px 9px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n :global(.ai-sent-at-risk) { background: #fee2e2; color: #b91c1c; }\n :global(.ai-sent-expanding) { background: #dcfce7; color: #166534; }\n :global(.ai-sent-steady) { background: #e2e8f0; color: #475569; }\n :global([data-theme='dark'] .ai-sent-at-risk) { background: rgba(239,68,68,.18); color: #f87171; }\n :global([data-theme='dark'] .ai-sent-expanding) { background: rgba(34,197,94,.18); color: #4ade80; }\n :global([data-theme='dark'] .ai-sent-steady) { background: rgba(148,163,184,.2); color: #cbd5e1; }\n :global(.ai-pred-dot) {\n width: 6px; height: 6px;\n border-radius: 50%;\n background: var(--sg-accent, #2563eb);\n }\n\n :global(.ai-notes) {\n display: inline-block;\n max-width: 100%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 12px;\n color: var(--sg-muted, #64748b);\n }\n :global(.ai-arr) { font-weight: 600; }\n :global(.ai-muted) { color: var(--sg-muted, #64748b); }\n</style>\n"
|
|
3073
3121
|
},
|
|
3122
|
+
{
|
|
3123
|
+
"id": "510-column-pin-splitter",
|
|
3124
|
+
"path": "examples/src/demos/510-column-pin-splitter.svelte",
|
|
3125
|
+
"title": "Column Pin Splitter",
|
|
3126
|
+
"category": "Columns",
|
|
3127
|
+
"blurb": "510. Drag to pin columns `pinSplitter` draws a divider at the right edge of the left-pinned columns (the row numbers when none are pinned). Drag it right and the",
|
|
3128
|
+
"source": "<script lang=\"ts\">\n /**\n * 510. Drag to pin columns\n * ------------------------\n * `pinSplitter` draws a divider at the right edge of the left-pinned\n * columns (the row numbers when none are pinned). Drag it right and the\n * columns it passes are pinned; drag it left and they scroll again. It\n * snaps to column edges, shows how many columns it will pin, keeps 120px\n * for the columns that scroll, and never pins them all. A column that\n * changes sides stays where it is on screen. Escape drops a drag; with the\n * keyboard, Tab to the divider and use the arrow keys (Home unpins all).\n *\n * Free in @svgrid/grid, and loaded only when the prop is on. Its words\n * come from `localization.text` like the rest of the grid's UI.\n */\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Order = {\n id: number\n order: string\n customer: string\n region: string\n rep: string\n product: string\n qty: number\n unitPrice: number\n discount: number\n total: number\n status: string\n ordered: string\n shipped: string\n carrier: string\n }\n\n const CUSTOMERS = ['Northwind Traders', 'Contoso Ltd', 'Fabrikam Inc', 'Adventure Works', 'Tailspin Toys', 'Wide World Importers', 'Litware', 'Proseware']\n const REGIONS = ['North America', 'Europe', 'Asia Pacific', 'Latin America']\n const REPS = ['Ana Ruiz', 'Ben Okafor', 'Cleo Park', 'Dev Shah', 'Elena Novak']\n const PRODUCTS = ['Standing desk', 'Ergonomic chair', 'Monitor arm', 'Desk lamp', 'Cable tray', 'Footrest']\n const STATUS = ['Processing', 'Shipped', 'Delivered', 'On hold']\n const CARRIERS = ['DHL', 'UPS', 'FedEx', 'Maersk']\n const day = (n: number) => new Date(Date.UTC(2026, 8, 1 + n)).toISOString().slice(0, 10)\n const rows: Order[] = Array.from({ length: 60 }, (_, i) => {\n const qty = 1 + ((i * 7) % 24)\n const unitPrice = [649, 389, 129, 89, 45, 59][i % 6]!\n const discount = [0, 0.05, 0.1, 0, 0.15][i % 5]!\n const status = STATUS[(i * 3) % 4]!\n return {\n id: i + 1,\n order: `SO-${String(24100 + i)}`,\n customer: CUSTOMERS[i % CUSTOMERS.length]!,\n region: REGIONS[(i * 5) % REGIONS.length]!,\n rep: REPS[i % REPS.length]!,\n product: PRODUCTS[i % PRODUCTS.length]!,\n qty,\n unitPrice,\n discount,\n total: Math.round(qty * unitPrice * (1 - discount) * 100) / 100,\n status,\n ordered: day(i % 28),\n shipped: status === 'Processing' || status === 'On hold' ? '' : day((i % 28) + 2),\n carrier: CARRIERS[i % CARRIERS.length]!,\n }\n })\n\n const columns: GridColumns<Order> = [\n { field: 'order', header: 'Order', width: 110 },\n { field: 'customer', header: 'Customer', width: 190 },\n { field: 'region', header: 'Region', width: 140 },\n { field: 'rep', header: 'Sales rep', width: 130 },\n { field: 'product', header: 'Product', width: 160 },\n { field: 'qty', header: 'Qty', width: 80, editorType: 'number' },\n { field: 'unitPrice', header: 'Unit price', width: 110, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n { field: 'discount', header: 'Discount', width: 100, editorType: 'number', format: { type: 'percent' } },\n { field: 'total', header: 'Total', width: 120, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n { field: 'status', header: 'Status', width: 120 },\n { field: 'ordered', header: 'Ordered', width: 120, cellDataType: 'dateString' },\n { field: 'shipped', header: 'Shipped', width: 120, cellDataType: 'dateString' },\n { field: 'carrier', header: 'Carrier', width: 110 },\n ]\n\n let api = $state.raw<SvGridApi<any, Order> | null>(null)\n // What is pinned, as the grid has it now (the splitter, the column menu or Reset).\n const pinned = $derived(api ? api.getColumnPinning().left : [])\n const headerOf = (id: string) => columns.find((c) => c.field === id)?.header ?? id\n</script>\n\n<section class=\"flex flex-col gap-3 h-full min-h-0\">\n <div class=\"flex flex-wrap items-center gap-3 shrink-0\">\n <p class=\"ps-note text-sm m-0 flex-1 min-w-[260px]\">\n Drag the line at the edge of the pinned columns to pin more or fewer. It snaps to column edges; Escape cancels; Tab to it and use the arrow keys.\n </p>\n <span class=\"ps-pinned text-sm\" aria-live=\"polite\">\n {pinned.length ? `Pinned: ${pinned.map(headerOf).join(', ')}` : 'Nothing pinned'}\n </span>\n <button type=\"button\" class=\"ps-btn text-sm\" onclick={() => api?.setColumnPinning({ left: ['order'], right: [] })}>Reset</button>\n </div>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n {columns}\n getRowId={(r) => String(r.id)}\n showRowNumbers\n showRowSelection={false}\n pinSplitter\n columnResize\n sortable\n initialColumnPinning={{ left: ['order'], right: [] }}\n containerHeight=\"100%\"\n onApiReady={(a) => (api = a)}\n />\n </div>\n</section>\n\n<style>\n .ps-note { color: var(--sg-muted, #64748b); }\n .ps-pinned {\n padding: 3px 10px; border-radius: 999px; color: var(--sg-fg, #0f172a);\n background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent);\n }\n .ps-btn {\n height: 30px; padding: 0 12px; border-radius: 8px; font: inherit; cursor: pointer;\n border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);\n }\n</style>\n"
|
|
3129
|
+
},
|
|
3130
|
+
{
|
|
3131
|
+
"id": "511-base-content-calendar",
|
|
3132
|
+
"path": "examples/src/demos/511-base-content-calendar.svelte",
|
|
3133
|
+
"title": "Base Content Calendar",
|
|
3134
|
+
"category": "Base",
|
|
3135
|
+
"blurb": "511. SvGrid Base: content calendar An editorial team's content calendar as a ready-to-use base with two tables. Posts have a stage, channels, an author, a publish date, a cover,",
|
|
3136
|
+
"source": "<script lang=\"ts\">\n /**\n * 511. SvGrid Base: content calendar\n * ----------------------------------\n * An editorial team's content calendar as a ready-to-use base with two\n * tables. Posts have a stage, channels, an author, a publish date, a cover,\n * a brief and the campaign they belong to; Campaigns list their posts and\n * add up the words written for them.\n *\n * The Posts views: the planning grid (colored by stage, in publishing\n * order), the pipeline as a Kanban, the publishing calendar, a wall of\n * covers, and the form a writer uses to pitch an idea.\n *\n * Everything is <SvBase>: drag a card to move a post along, pick a day on\n * the calendar to schedule one, Space on a row to open it with its comments.\n */\n import { SvBase, enableBase, type Attachment, type BaseTable } from '@svgrid/enterprise/base'\n import { editorialArt } from '../shared/gallery-art'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n type Post = {\n id: number\n title: string\n stage: string\n channels: string[]\n author: string\n publish: string\n cover: Attachment[]\n words: number\n brief: string\n link: string\n campaign: string[]\n }\n\n const STAGES = [\n { value: 'Idea', color: '#94a3b8' },\n { value: 'Drafting', color: '#2d7ff9' },\n { value: 'In review', color: '#fcb400' },\n { value: 'Scheduled', color: '#8b46ff' },\n { value: 'Published', color: '#20c933' },\n ]\n const CHANNELS = [\n { value: 'Blog', color: '#2d7ff9' },\n { value: 'Newsletter', color: '#ff6f2c' },\n { value: 'LinkedIn', color: '#18bfff' },\n { value: 'YouTube', color: '#f82b60' },\n { value: 'Podcast', color: '#8b46ff' },\n ]\n const TITLES = [\n 'Why we rebuilt our onboarding',\n 'Ten keyboard shortcuts power users love',\n 'Customer story: a logistics team at scale',\n 'The state of remote teams in 2026',\n 'Behind the scenes: our design review',\n 'A field guide to quarterly planning',\n 'Podcast: shipping weekly without burnout',\n 'How to write a product brief people read',\n 'Year in review: what our customers built',\n 'The case for fewer meetings',\n 'Five templates for running a launch',\n 'Interview: the people behind support',\n 'Our new pricing, explained',\n 'What we learned from 1,000 user calls',\n 'Newsletter: October roundup',\n 'Video: building a dashboard in ten minutes',\n 'Hiring for curiosity',\n 'A short history of the spreadsheet',\n ]\n const day = (n: number) => new Date(Date.UTC(2026, 9, 1 + n)).toISOString().slice(0, 10)\n let nextId = TITLES.length + 1\n const CAMPAIGNS = [\n ['Fall launch', 'Sign-ups from the new onboarding', 'u1', 0, 20],\n ['Customer stories', 'Three case studies in sales decks', 'u5', 5, 34],\n ['Hiring push', 'Twelve engineering applicants a week', 'u6', 10, 30],\n ] as const\n const posts: Post[] = TITLES.map((title, i) => ({\n id: i + 1,\n title,\n stage: STAGES[[4, 4, 3, 3, 2, 2, 1, 1, 1, 0, 0, 3, 2, 4, 3, 1, 0, 2][i]!]!.value,\n channels: [CHANNELS[i % 5]!.value, ...(i % 3 === 0 ? [CHANNELS[(i + 2) % 5]!.value] : [])],\n author: ['u1', 'u3', 'u5', 'u6', 'u2', 'u8'][i % 6]!,\n publish: day((i * 5) % 34),\n cover: [{ id: `cover-${i}`, name: `cover-${i + 1}.svg`, type: 'image/svg+xml', url: editorialArt(i + 1) }],\n words: 600 + ((i * 377) % 1900),\n brief: 'Audience: team leads. Angle: one concrete practice and the numbers behind it.',\n link: i % 4 === 0 ? `https://example.com/blog/post-${i + 1}` : '',\n campaign: [`k${(i % 3) + 1}`],\n }))\n const campaigns = CAMPAIGNS.map(([name, goal, owner, from, to], i) => ({\n id: `k${i + 1}`,\n name,\n goal,\n owner,\n starts: day(from),\n ends: day(to),\n posts: posts.filter((p) => p.campaign[0] === `k${i + 1}`).map((p) => String(p.id)),\n }))\n\n const postColumns: any[] = [\n { field: 'title', header: 'Title', width: 300, fieldType: 'text' },\n { field: 'stage', header: 'Stage', width: 130, fieldType: 'singleSelect', fieldProps: { options: STAGES } },\n { field: 'channels', header: 'Channels', width: 200, fieldType: 'multiSelect', fieldProps: { options: CHANNELS } },\n { field: 'author', header: 'Author', width: 160, fieldType: 'user' },\n { field: 'campaign', header: 'Campaign', width: 170, fieldType: 'link', fieldProps: { table: 'content-campaigns', multiple: false, inverse: 'posts' } },\n { field: 'publish', header: 'Publish date', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'cover', header: 'Cover', width: 120, fieldType: 'attachment' },\n { field: 'words', header: 'Words', width: 100, fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },\n { field: 'brief', header: 'Brief', width: 260, fieldType: 'longText' },\n { field: 'link', header: 'Link', width: 220, fieldType: 'url' },\n ]\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'content-posts',\n name: 'Posts',\n primaryField: 'title',\n data: posts,\n columns: postColumns,\n views: [\n { id: 'plan', name: 'Content plan', type: 'grid', color: { mode: 'field', field: 'stage' }, state: { sorting: [{ id: 'publish', desc: false }] } },\n { id: 'pipeline', name: 'Pipeline', type: 'kanban', stackBy: 'stage' },\n { id: 'calendar', name: 'Publishing calendar', type: 'calendar', dateField: 'publish', color: { mode: 'field', field: 'stage' } },\n { id: 'covers', name: 'Covers', type: 'gallery', coverField: 'cover' },\n { id: 'pitch', name: 'Pitch an idea', type: 'form', formFields: ['title', 'channels', 'author', 'campaign', 'brief'], formDescription: 'Tell the editors what you want to write. They pick it up in Monday planning.' },\n ],\n props: { rowAdder: { label: 'Add post', defaults: () => ({ id: nextId++, title: '', stage: 'Idea', channels: [], cover: [] }) } },\n },\n {\n id: 'content-campaigns',\n name: 'Campaigns',\n primaryField: 'name',\n data: campaigns,\n columns: [\n { field: 'name', header: 'Campaign', width: 200, fieldType: 'text' },\n { field: 'goal', header: 'Goal', width: 280, fieldType: 'text' },\n { field: 'owner', header: 'Owner', width: 160, fieldType: 'user' },\n { field: 'starts', header: 'Starts', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'ends', header: 'Ends', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'posts', header: 'Posts', width: 320, fieldType: 'link', fieldProps: { table: 'content-posts', multiple: true, inverse: 'campaign' } },\n { field: 'words', header: 'Words', width: 110, fieldType: 'rollup', fieldProps: { linkField: 'posts', field: 'words', aggregation: 'SUM' } },\n ],\n views: [\n { id: 'campaigns', name: 'All campaigns', type: 'grid' },\n { id: 'when', name: 'Campaign calendar', type: 'calendar', dateField: 'starts', endField: 'ends' },\n ],\n props: { rowAdder: { label: 'Add campaign' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={44}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3137
|
+
},
|
|
3138
|
+
{
|
|
3139
|
+
"id": "512-base-sales-crm",
|
|
3140
|
+
"path": "examples/src/demos/512-base-sales-crm.svelte",
|
|
3141
|
+
"title": "Base Sales Crm",
|
|
3142
|
+
"category": "Base",
|
|
3143
|
+
"blurb": "512. SvGrid Base: sales CRM A sales team's CRM as a ready-to-use base with three tables, one tab each. Deals link to an account and a contact; Accounts list their deals",
|
|
3144
|
+
"source": "<script lang=\"ts\">\n /**\n * 512. SvGrid Base: sales CRM\n * ---------------------------\n * A sales team's CRM as a ready-to-use base with three tables, one tab\n * each. Deals link to an account and a contact; Accounts list their deals\n * and contacts and roll up the pipeline value; Contacts link back to their\n * account and deals. Linking a deal to an account lists it on that\n * account's row, and the other way round.\n *\n * Add a link field (\"+\" at the end of the header, \"Link to records\") to\n * relate two tables: the other table gets a field back. Point it at\n * another table, or delete it, and the field back moves or goes with it.\n * The tabs add, rename, duplicate and delete tables.\n */\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n const STAGES = [\n { value: 'Lead', color: '#94a3b8' },\n { value: 'Qualified', color: '#18bfff' },\n { value: 'Proposal', color: '#2d7ff9' },\n { value: 'Negotiation', color: '#fcb400' },\n { value: 'Won', color: '#20c933' },\n { value: 'Lost', color: '#f82b60' },\n ]\n const ODDS: Record<string, number> = { Lead: 0.1, Qualified: 0.25, Proposal: 0.5, Negotiation: 0.75, Won: 1, Lost: 0 }\n const SOURCES = [\n { value: 'Inbound', color: '#20c933' },\n { value: 'Referral', color: '#8b46ff' },\n { value: 'Outbound', color: '#ff6f2c' },\n { value: 'Event', color: '#18bfff' },\n ]\n const INDUSTRIES = [\n { value: 'Wholesale', color: '#ff6f2c' },\n { value: 'Manufacturing', color: '#2d7ff9' },\n { value: 'Software', color: '#8b46ff' },\n { value: 'Retail', color: '#fcb400' },\n { value: 'Logistics', color: '#18bfff' },\n { value: 'Healthcare', color: '#20c933' },\n { value: 'Education', color: '#f82b60' },\n ]\n const NEXT = ['Send proposal', 'Book a demo', 'Security review', 'Pricing call', 'Legal redlines', 'Kickoff scheduled', 'Follow up next quarter']\n const PRODUCTS = ['Platform license', 'Team plan', 'Renewal', 'Expansion', 'Pilot', 'Enterprise plan']\n const day = (n: number) => new Date(Date.UTC(2026, 9, 6 + n)).toISOString().slice(0, 10)\n\n // ---- Sample records -------------------------------------------------------\n\n const ACCOUNTS = [\n ['Northwind Traders', 'Wholesale', 420, 'Seattle'],\n ['Contoso', 'Manufacturing', 2600, 'Chicago'],\n ['Fabrikam', 'Software', 880, 'Austin'],\n ['Adventure Works', 'Retail', 310, 'Denver'],\n ['Tailspin Toys', 'Retail', 140, 'Portland'],\n ['Wide World Importers', 'Logistics', 1200, 'Rotterdam'],\n ['Litware', 'Healthcare', 560, 'Boston'],\n ['Proseware', 'Education', 95, 'Dublin'],\n ] as const\n const PEOPLE = [\n ['Maya Lindqvist', 'Head of Operations'], ['Tom Becker', 'Procurement lead'],\n ['Priya Raman', 'CFO'], ['Luis Ortega', 'IT director'],\n ['Grace Kim', 'VP Engineering'], ['Owen Price', 'Platform lead'],\n ['Sofia Rossi', 'COO'], ['Ken Watanabe', 'Store systems'],\n ['Nora Ali', 'Founder'], ['Jack Moran', 'Head of Product'],\n ['Eva Novak', 'Supply chain director'], ['Ravi Menon', 'Finance manager'],\n ['Hannah Cole', 'CIO'], ['Marco Bianchi', 'Clinical systems'],\n ['Ines Duarte', 'Head of IT'], ['Leo Hart', 'Dean of Operations'],\n ] as const\n\n const contacts = PEOPLE.map(([name, title], i) => {\n const acct = Math.floor(i / 2)\n return {\n id: `c${i + 1}`,\n name,\n title,\n account: [`a${acct + 1}`],\n email: `${name.toLowerCase().replace(/[^a-z]+/g, '.')}@${ACCOUNTS[acct]![0].toLowerCase().replace(/[^a-z]+/g, '')}.example.com`,\n phone: `+1 555 01${String(10 + i).padStart(2, '0')}`,\n deals: [] as string[],\n }\n })\n const deals = Array.from({ length: 30 }, (_, i) => {\n const stage = STAGES[[0, 1, 2, 3, 4, 2, 1, 3, 0, 5, 2, 4, 1, 3, 2, 0, 4, 3, 1, 2, 5, 3, 2, 1, 4, 0, 2, 3, 1, 2][i]!]!.value\n const acct = i % ACCOUNTS.length\n return {\n id: `d${i + 1}`,\n // Each account sees each product at most once.\n deal: `${ACCOUNTS[acct]![0]}: ${PRODUCTS[(i - Math.floor(i / ACCOUNTS.length)) % PRODUCTS.length]}`,\n account: [`a${acct + 1}`],\n contact: [`c${acct * 2 + (i % 2) + 1}`],\n stage,\n owner: ['u1', 'u2', 'u4', 'u7'][i % 4]!,\n value: 8000 + ((i * 7919) % 90) * 1500,\n probability: ODDS[stage]!,\n close: day((i * 3) % 50),\n nextStep: stage === 'Won' || stage === 'Lost' ? '' : NEXT[i % NEXT.length]!,\n source: SOURCES[i % SOURCES.length]!.value,\n priority: 1 + ((i * 3) % 5),\n }\n })\n // The fields back list the records that link, as the base keeps them from now on.\n for (const d of deals) contacts.find((c) => c.id === d.contact[0])!.deals.push(d.id)\n const accounts = ACCOUNTS.map(([name, industry, employees, city], i) => ({\n id: `a${i + 1}`,\n name,\n industry,\n employees,\n city,\n deals: deals.filter((d) => d.account[0] === `a${i + 1}`).map((d) => d.id),\n contacts: contacts.filter((c) => c.account[0] === `a${i + 1}`).map((c) => c.id),\n }))\n\n // ---- The base ---------------------------------------------------------------\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'crm-deals',\n name: 'Deals',\n primaryField: 'deal',\n data: deals,\n columns: [\n { id: 'deal', field: 'deal', header: 'Deal', width: 280, fieldType: 'text' },\n { id: 'account', field: 'account', header: 'Account', width: 180, fieldType: 'link', fieldProps: { table: 'crm-accounts', multiple: false, inverse: 'deals' } },\n { id: 'contact', field: 'contact', header: 'Contact', width: 170, fieldType: 'link', fieldProps: { table: 'crm-contacts', multiple: false, inverse: 'deals' } },\n { id: 'stage', field: 'stage', header: 'Stage', width: 130, fieldType: 'singleSelect', fieldProps: { options: STAGES } },\n { id: 'owner', field: 'owner', header: 'Owner', width: 160, fieldType: 'user' },\n { id: 'value', field: 'value', header: 'Value', width: 130, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0, summary: 'sum' } },\n { id: 'probability', field: 'probability', header: 'Probability', width: 150, fieldType: 'number', fieldProps: { format: 'percent', precision: 0, showBar: true, summary: 'avg' } },\n { id: 'close', field: 'close', header: 'Close date', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { id: 'nextStep', field: 'nextStep', header: 'Next step', width: 200, fieldType: 'text' },\n { id: 'source', field: 'source', header: 'Source', width: 120, fieldType: 'singleSelect', fieldProps: { options: SOURCES } },\n { id: 'priority', field: 'priority', header: 'Priority', width: 120, fieldType: 'rating', fieldProps: { max: 5, icon: 'flag', color: '#f82b60' } },\n ],\n views: [\n { id: 'deals', name: 'All deals', type: 'grid', color: { mode: 'field', field: 'stage' }, state: { sorting: [{ id: 'value', desc: true }] } },\n { id: 'pipeline', name: 'Pipeline', type: 'kanban', stackBy: 'stage' },\n { id: 'closing', name: 'Closing calendar', type: 'calendar', dateField: 'close', color: { mode: 'field', field: 'stage' } },\n { id: 'by-stage', name: 'Pipeline value by stage', type: 'chart', chart: { kind: 'bar', x: 'stage', y: 'value' } },\n {\n id: 'open',\n name: 'Open deals by owner',\n type: 'grid',\n state: {\n grouping: ['owner'],\n sorting: [{ id: 'owner', desc: false }, { id: 'close', desc: false }],\n advancedFilter: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'stage', kind: 'select', op: 'isNoneOf', value: ['Won', 'Lost'] }] },\n },\n },\n { id: 'lead', name: 'New lead', type: 'form', formFields: ['deal', 'account', 'contact', 'owner', 'value', 'source', 'nextStep'], formDescription: 'A lead from a call, an event or a referral. It starts in the Lead stage.' },\n ],\n props: { rowAdder: { label: 'Add deal', defaults: () => ({ deal: '', stage: 'Lead', probability: 0.1 }) } },\n },\n {\n id: 'crm-accounts',\n name: 'Accounts',\n primaryField: 'name',\n data: accounts,\n columns: [\n { id: 'name', field: 'name', header: 'Account', width: 220, fieldType: 'text' },\n { id: 'industry', field: 'industry', header: 'Industry', width: 150, fieldType: 'singleSelect', fieldProps: { options: INDUSTRIES } },\n { id: 'deals', field: 'deals', header: 'Deals', width: 260, fieldType: 'link', fieldProps: { table: 'crm-deals', multiple: true, inverse: 'account' } },\n { id: 'pipeline', field: 'pipeline', header: 'Pipeline value', width: 170, fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'value', aggregation: 'SUM' } },\n { id: 'dealCount', field: 'dealCount', header: 'Deal count', width: 120, fieldType: 'count', fieldProps: { linkField: 'deals' } },\n { id: 'contacts', field: 'contacts', header: 'Contacts', width: 220, fieldType: 'link', fieldProps: { table: 'crm-contacts', multiple: true, inverse: 'account' } },\n { id: 'employees', field: 'employees', header: 'Employees', width: 120, fieldType: 'number', fieldProps: { format: 'integer' } },\n { id: 'city', field: 'city', header: 'City', width: 130, fieldType: 'text' },\n ],\n views: [\n { id: 'accounts', name: 'All accounts', type: 'grid', state: { sorting: [{ id: 'name', desc: false }] } },\n { id: 'by-industry', name: 'By industry', type: 'kanban', stackBy: 'industry' },\n ],\n props: { rowAdder: { label: 'Add account' } },\n },\n {\n id: 'crm-contacts',\n name: 'Contacts',\n primaryField: 'name',\n data: contacts,\n columns: [\n { id: 'name', field: 'name', header: 'Name', width: 190, fieldType: 'text' },\n { id: 'title', field: 'title', header: 'Title', width: 190, fieldType: 'text' },\n { id: 'account', field: 'account', header: 'Account', width: 180, fieldType: 'link', fieldProps: { table: 'crm-accounts', multiple: false, inverse: 'contacts' } },\n { id: 'deals', field: 'deals', header: 'Deals', width: 260, fieldType: 'link', fieldProps: { table: 'crm-deals', multiple: true, inverse: 'contact' } },\n { id: 'email', field: 'email', header: 'Email', width: 260, fieldType: 'email' },\n { id: 'phone', field: 'phone', header: 'Phone', width: 150, fieldType: 'phone' },\n ],\n views: [\n { id: 'contacts', name: 'All contacts', type: 'grid' },\n { id: 'add', name: 'Add a contact', type: 'form', formFields: ['name', 'title', 'account', 'email', 'phone'] },\n ],\n props: { rowAdder: { label: 'Add contact' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={40}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3145
|
+
},
|
|
3146
|
+
{
|
|
3147
|
+
"id": "513-base-product-roadmap",
|
|
3148
|
+
"path": "examples/src/demos/513-base-product-roadmap.svelte",
|
|
3149
|
+
"title": "Base Product Roadmap",
|
|
3150
|
+
"category": "Base",
|
|
3151
|
+
"blurb": "513. SvGrid Base: product roadmap A product team's roadmap as a ready-to-use base with two tables. Features have a quarter, status, teams, lead, impact, effort, dates and",
|
|
3152
|
+
"source": "<script lang=\"ts\">\n /**\n * 513. SvGrid Base: product roadmap\n * ---------------------------------\n * A product team's roadmap as a ready-to-use base with two tables.\n * Features have a quarter, status, teams, lead, impact, effort, dates and\n * customer votes. Feedback holds what customers asked for, each request\n * linked to the feature it is about; a feature lists its requests and\n * counts them.\n *\n * The Features views: the roadmap grouped by quarter (each group showing\n * its count, total effort in days and votes), the status board, a timeline\n * calendar from start to target date, the \"Now\" view filtered to work in\n * progress, and the form anyone can use to request a feature.\n */\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n type Feature = {\n id: number\n feature: string\n quarter: string\n status: string\n teams: string[]\n lead: string\n impact: number\n effort: number\n start: string\n target: string\n votes: number\n spec: string\n requests?: string[]\n }\n\n const QUARTERS = [\n { value: 'Q4 2026', color: '#2d7ff9' },\n { value: 'Q1 2027', color: '#18bfff' },\n { value: 'Q2 2027', color: '#20d9d2' },\n { value: 'Later', color: '#94a3b8' },\n ]\n const STATUS = [\n { value: 'Planned', color: '#94a3b8' },\n { value: 'In progress', color: '#2d7ff9' },\n { value: 'In beta', color: '#8b46ff' },\n { value: 'Shipped', color: '#20c933' },\n { value: 'Blocked', color: '#f82b60' },\n ]\n const TEAMS = [\n { value: 'Web', color: '#2d7ff9' },\n { value: 'Mobile', color: '#ff6f2c' },\n { value: 'Platform', color: '#8b46ff' },\n { value: 'Data', color: '#20c933' },\n { value: 'Design', color: '#ff08c2' },\n ]\n const FEATURES = [\n 'Single sign-on with SAML',\n 'Offline mode for the mobile app',\n 'Audit log export',\n 'Dark mode',\n 'Bulk edit from the selection bar',\n 'Two-factor recovery codes',\n 'Usage-based billing',\n 'Public API v2',\n 'Shared views with permissions',\n 'In-app onboarding checklist',\n 'Data retention policies',\n 'Webhooks with retries',\n 'Comments and mentions',\n 'Faster search index',\n 'Custom domains',\n 'Accessibility audit fixes',\n 'Templates gallery',\n 'Workspace analytics',\n 'Slack notifications',\n 'Calendar sync',\n ]\n const day = (n: number) => new Date(Date.UTC(2026, 9, 1 + n)).toISOString().slice(0, 10)\n let nextId = FEATURES.length + 1\n const features: Feature[] = FEATURES.map((feature, i) => {\n const q = Math.min(3, Math.floor(i / 5))\n const start = q * 30 + ((i * 7) % 20)\n const status = q === 0 ? STATUS[[1, 3, 2, 1, 4][i % 5]!]!.value : q === 1 ? STATUS[i % 3 === 0 ? 1 : 0]!.value : 'Planned'\n return {\n id: i + 1,\n feature,\n quarter: QUARTERS[q]!.value,\n status,\n teams: [TEAMS[i % 5]!.value, ...(i % 3 === 1 ? [TEAMS[(i + 2) % 5]!.value] : [])],\n lead: ['u2', 'u3', 'u4', 'u7', 'u8'][i % 5]!,\n impact: 1 + ((i * 3) % 5),\n effort: 2 + ((i * 5) % 12),\n start: day(start),\n target: day(start + 14 + ((i * 3) % 21)),\n votes: 4 + ((i * 37) % 180),\n spec: i % 3 === 0 ? `https://example.com/specs/${i + 1}` : '',\n }\n })\n\n // What customers asked for, each request about one feature.\n const PLANS = [\n { value: 'Free', color: '#94a3b8' },\n { value: 'Team', color: '#2d7ff9' },\n { value: 'Enterprise', color: '#8b46ff' },\n ]\n const ASKS: Array<[string, string, number, number]> = [\n ['We need SAML before security signs off', 'Contoso', 2, 0],\n ['Field techs lose signal in basements', 'Northwind Traders', 1, 1],\n ['Auditors want a CSV of every change', 'Litware', 2, 2],\n ['Please add dark mode, the night shift asks daily', 'Tailspin Toys', 0, 3],\n ['Editing 200 rows one by one takes an hour', 'Fabrikam', 1, 4],\n ['Bill us by seats used, not seats bought', 'Adventure Works', 2, 6],\n ['Our integration needs API v2 webhooks', 'Wide World Importers', 2, 7],\n ['Share a view with a client, read-only', 'Proseware', 1, 8],\n ['New hires do not know where to start', 'Contoso', 1, 9],\n ['Retries on failed webhooks, please', 'Litware', 2, 11],\n ['Mention a teammate in a comment', 'Fabrikam', 0, 12],\n ['Search is slow on large workspaces', 'Northwind Traders', 2, 13],\n ['Dark mode for presentations', 'Proseware', 0, 3],\n ['SSO with our Okta setup', 'Litware', 2, 0],\n ]\n const feedback = ASKS.map(([request, customer, plan, feature], i) => ({\n id: `f${i + 1}`,\n request,\n customer,\n plan: PLANS[plan]!.value,\n received: day(i * 2),\n feature: [String(feature + 1)],\n }))\n for (const f of features) f.requests = feedback.filter((r) => r.feature[0] === String(f.id)).map((r) => r.id)\n\n const featureColumns: any[] = [\n { field: 'feature', header: 'Feature', width: 280, fieldType: 'text' },\n { field: 'quarter', header: 'Quarter', width: 132, fieldType: 'singleSelect', fieldProps: { options: QUARTERS } },\n { field: 'status', header: 'Status', width: 130, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'teams', header: 'Teams', width: 180, fieldType: 'multiSelect', fieldProps: { options: TEAMS } },\n { field: 'lead', header: 'Lead', width: 160, fieldType: 'user' },\n { field: 'impact', header: 'Impact', width: 130, fieldType: 'rating', fieldProps: { max: 5, color: '#fcb400' } },\n { field: 'effort', header: 'Effort (days)', width: 120, fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },\n { field: 'start', header: 'Start', width: 140, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'target', header: 'Target', width: 140, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'votes', header: 'Votes', width: 100, fieldType: 'number', fieldProps: { format: 'integer', showBar: false, summary: 'sum' } },\n { field: 'requests', header: 'Requests', width: 260, fieldType: 'link', fieldProps: { table: 'roadmap-feedback', multiple: true, inverse: 'feature' } },\n { field: 'requestCount', header: 'Asked by', width: 110, fieldType: 'count', fieldProps: { linkField: 'requests' } },\n { field: 'spec', header: 'Spec', width: 220, fieldType: 'url' },\n ]\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'roadmap-features',\n name: 'Features',\n primaryField: 'feature',\n data: features,\n columns: featureColumns,\n views: [\n { id: 'roadmap', name: 'Roadmap by quarter', type: 'grid', color: { mode: 'field', field: 'status' }, state: { grouping: ['quarter'], sorting: [{ id: 'quarter', desc: false }, { id: 'impact', desc: true }] } },\n { id: 'board', name: 'Status board', type: 'kanban', stackBy: 'status' },\n { id: 'timeline', name: 'Timeline', type: 'calendar', dateField: 'start', endField: 'target', color: { mode: 'field', field: 'status' } },\n { id: 'effort', name: 'Effort by team', type: 'chart', chart: { kind: 'barH', x: 'teams', y: 'effort' } },\n {\n id: 'now',\n name: 'Now',\n type: 'grid',\n state: {\n sorting: [{ id: 'target', desc: false }],\n advancedFilter: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'status', kind: 'select', op: 'isAnyOf', value: ['In progress', 'In beta', 'Blocked'] }] },\n },\n },\n { id: 'request', name: 'Request a feature', type: 'form', formFields: ['feature', 'teams', 'impact', 'spec'], formDescription: 'What should we build, and who is it for? Product reviews requests every Friday.' },\n ],\n props: { rowAdder: { label: 'Add feature', defaults: () => ({ id: nextId++, feature: '', status: 'Planned', quarter: 'Later', teams: [], votes: 0 }) } },\n },\n {\n id: 'roadmap-feedback',\n name: 'Feedback',\n primaryField: 'request',\n data: feedback,\n columns: [\n { field: 'request', header: 'Request', width: 340, fieldType: 'text' },\n { field: 'customer', header: 'Customer', width: 180, fieldType: 'text' },\n { field: 'plan', header: 'Plan', width: 120, fieldType: 'singleSelect', fieldProps: { options: PLANS } },\n { field: 'feature', header: 'Feature', width: 240, fieldType: 'link', fieldProps: { table: 'roadmap-features', multiple: false, inverse: 'requests' } },\n { field: 'received', header: 'Received', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n ],\n views: [\n { id: 'all', name: 'All feedback', type: 'grid', state: { sorting: [{ id: 'received', desc: true }] } },\n { id: 'by-plan', name: 'By plan', type: 'kanban', stackBy: 'plan' },\n ],\n props: { rowAdder: { label: 'Add request' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={40}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3153
|
+
},
|
|
3154
|
+
{
|
|
3155
|
+
"id": "514-base-hiring-tracker",
|
|
3156
|
+
"path": "examples/src/demos/514-base-hiring-tracker.svelte",
|
|
3157
|
+
"title": "Base Hiring Tracker",
|
|
3158
|
+
"category": "Base",
|
|
3159
|
+
"blurb": "514. SvGrid Base: hiring tracker A recruiting team's pipeline as a ready-to-use base with two tables. Candidates have a photo, the role they applied for, the stage, the",
|
|
3160
|
+
"source": "<script lang=\"ts\">\n /**\n * 514. SvGrid Base: hiring tracker\n * --------------------------------\n * A recruiting team's pipeline as a ready-to-use base with two tables.\n * Candidates have a photo, the role they applied for, the stage, the\n * recruiter, a rating, the interview date, the salary ask, a resume and\n * notes. Roles are the open positions: the team, the hiring manager, the\n * headcount, and the candidates for each, linked both ways.\n *\n * The Candidates views: the hiring pipeline as a Kanban, every candidate in\n * a grid colored by stage, a gallery of faces, the interview calendar, and\n * the application form a candidate fills in. Open a candidate (Space, or a\n * card) to read the notes and leave a comment for the panel.\n */\n import { SvBase, enableBase, type Attachment, type BaseTable } from '@svgrid/enterprise/base'\n import { portrait } from '../shared/gallery-art'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n type Candidate = {\n id: number\n name: string\n photo: Attachment[]\n role: string[]\n stage: string\n recruiter: string\n rating: number\n interview: string\n salary: number\n email: string\n resume: Attachment[]\n source: string\n notes: string\n }\n\n const ROLES = [\n ['Frontend engineer', 'Engineering', 'u1', 2],\n ['Backend engineer', 'Engineering', 'u2', 2],\n ['Product designer', 'Design', 'u3', 1],\n ['Data analyst', 'Data', 'u4', 1],\n ['Support lead', 'Support', 'u7', 1],\n ] as const\n const TEAMS = [\n { value: 'Engineering', color: '#2d7ff9' },\n { value: 'Design', color: '#ff08c2' },\n { value: 'Data', color: '#20c933' },\n { value: 'Support', color: '#ff6f2c' },\n ]\n const STAGES = [\n { value: 'Applied', color: '#94a3b8' },\n { value: 'Phone screen', color: '#18bfff' },\n { value: 'Onsite', color: '#2d7ff9' },\n { value: 'Offer', color: '#fcb400' },\n { value: 'Hired', color: '#20c933' },\n { value: 'Rejected', color: '#f82b60' },\n ]\n const SOURCES = [\n { value: 'Referral', color: '#8b46ff' },\n { value: 'Careers page', color: '#2d7ff9' },\n { value: 'LinkedIn', color: '#18bfff' },\n { value: 'Agency', color: '#ff6f2c' },\n ]\n const NAMES = [\n 'Amara Nwosu', 'Lucas Ferreira', 'Mei Tanaka', 'Omar Haddad', 'Sofia Rossi', 'Jonas Berg', 'Priya Nair', 'Tom Walsh',\n 'Lena Vogel', 'Ravi Kumar', 'Chloe Martin', 'Diego Alvarez', 'Ines Costa', 'Kai Yamamoto', 'Nadia Petrova', 'Sam Okoro',\n 'Zoe Laurent', 'Hugo Silva',\n ]\n const day = (n: number) => new Date(Date.UTC(2026, 9, 12 + n)).toISOString().slice(0, 10)\n let nextId = NAMES.length + 1\n const candidates: Candidate[] = NAMES.map((name, i) => {\n const stage = STAGES[[0, 1, 2, 3, 4, 1, 2, 0, 5, 2, 1, 3, 0, 2, 1, 5, 0, 2][i]!]!.value\n const slug = name.toLowerCase().replace(/\\s+/g, '.')\n return {\n id: i + 1,\n name,\n photo: [{ id: `p${i}`, name: `${slug}.svg`, type: 'image/svg+xml', url: portrait(i + 11) }],\n role: [`r${(i % ROLES.length) + 1}`],\n stage,\n recruiter: ['u5', 'u6', 'u8'][i % 3]!,\n rating: stage === 'Applied' ? 0 : 2 + ((i * 3) % 4),\n interview: stage === 'Applied' || stage === 'Rejected' ? '' : day((i * 2) % 18),\n salary: 82000 + ((i * 7919) % 40) * 1500,\n email: `${slug}@example.com`,\n resume: [{ id: `cv${i}`, name: `${slug}-resume.pdf`, type: 'application/pdf', url: `https://example.com/cv/${slug}.pdf` }],\n source: SOURCES[i % SOURCES.length]!.value,\n notes: stage === 'Applied' ? '' : 'Strong portfolio. Ask about the migration project in the next round.',\n }\n })\n const roles = ROLES.map(([name, team, manager, headcount], i) => ({\n id: `r${i + 1}`,\n name,\n team,\n manager,\n headcount,\n status: i === 4 ? 'On hold' : 'Open',\n candidates: candidates.filter((c) => c.role[0] === `r${i + 1}`).map((c) => String(c.id)),\n }))\n\n const candidateColumns: any[] = [\n { field: 'name', header: 'Candidate', width: 200, fieldType: 'text' },\n { field: 'photo', header: 'Photo', width: 90, fieldType: 'attachment', fieldProps: { accept: 'image/*', maxFiles: 1 } },\n { field: 'role', header: 'Role', width: 180, fieldType: 'link', fieldProps: { table: 'hiring-roles', multiple: false, inverse: 'candidates' } },\n { field: 'stage', header: 'Stage', width: 140, fieldType: 'singleSelect', fieldProps: { options: STAGES } },\n { field: 'recruiter', header: 'Recruiter', width: 160, fieldType: 'user' },\n { field: 'rating', header: 'Rating', width: 130, fieldType: 'rating', fieldProps: { max: 5, color: '#fcb400' } },\n { field: 'interview', header: 'Interview', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'salary', header: 'Salary ask', width: 130, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0, summary: 'avg' } },\n { field: 'email', header: 'Email', width: 220, fieldType: 'email' },\n { field: 'resume', header: 'Resume', width: 200, fieldType: 'attachment' },\n { field: 'source', header: 'Source', width: 130, fieldType: 'singleSelect', fieldProps: { options: SOURCES } },\n { field: 'notes', header: 'Notes', width: 260, fieldType: 'longText' },\n ]\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'hiring-candidates',\n name: 'Candidates',\n primaryField: 'name',\n data: candidates,\n columns: candidateColumns,\n views: [\n { id: 'pipeline', name: 'Hiring pipeline', type: 'kanban', stackBy: 'stage' },\n { id: 'all', name: 'All candidates', type: 'grid', color: { mode: 'field', field: 'stage' }, state: { sorting: [{ id: 'rating', desc: true }] } },\n { id: 'faces', name: 'Faces', type: 'gallery', coverField: 'photo' },\n { id: 'interviews', name: 'Interviews', type: 'calendar', dateField: 'interview', color: { mode: 'field', field: 'stage' } },\n { id: 'funnel', name: 'Candidates by stage', type: 'chart', chart: { kind: 'donut', x: 'stage' } },\n { id: 'apply', name: 'Apply', type: 'form', formFields: ['name', 'email', 'role', 'resume', 'notes'], formTitle: 'Join the team', formDescription: 'Tell us a little about yourself. We reply to every application within a week.' },\n ],\n props: { rowAdder: { label: 'Add candidate', defaults: () => ({ id: nextId++, name: '', stage: 'Applied', photo: [], resume: [], rating: 0 }) } },\n },\n {\n id: 'hiring-roles',\n name: 'Roles',\n primaryField: 'name',\n data: roles,\n columns: [\n { field: 'name', header: 'Role', width: 200, fieldType: 'text' },\n { field: 'team', header: 'Team', width: 140, fieldType: 'singleSelect', fieldProps: { options: TEAMS } },\n { field: 'manager', header: 'Hiring manager', width: 190, fieldType: 'user' },\n { field: 'headcount', header: 'Headcount', width: 132, fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },\n { field: 'status', header: 'Status', width: 120, fieldType: 'singleSelect', fieldProps: { options: [{ value: 'Open', color: '#20c933' }, { value: 'On hold', color: '#fcb400' }, { value: 'Filled', color: '#94a3b8' }] } },\n { field: 'candidates', header: 'Candidates', width: 320, fieldType: 'link', fieldProps: { table: 'hiring-candidates', multiple: true, inverse: 'role' } },\n { field: 'candidateCount', header: 'In the pipeline', width: 140, fieldType: 'count', fieldProps: { linkField: 'candidates' } },\n ],\n views: [\n { id: 'roles', name: 'Open roles', type: 'grid', color: { mode: 'field', field: 'team' } },\n { id: 'by-team', name: 'By team', type: 'kanban', stackBy: 'team' },\n ],\n props: { rowAdder: { label: 'Add role' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={44}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3161
|
+
},
|
|
3162
|
+
{
|
|
3163
|
+
"id": "515-base-inventory",
|
|
3164
|
+
"path": "examples/src/demos/515-base-inventory.svelte",
|
|
3165
|
+
"title": "Base Inventory",
|
|
3166
|
+
"category": "Base",
|
|
3167
|
+
"blurb": "515. SvGrid Base: inventory A studio's equipment and stock as a ready-to-use base with two tables. Each item has a photo, category, location, supplier, quantity, unit cost,",
|
|
3168
|
+
"source": "<script lang=\"ts\">\n /**\n * 515. SvGrid Base: inventory\n * ---------------------------\n * A studio's equipment and stock as a ready-to-use base with two tables.\n * Each item has a photo, category, location, supplier, quantity, unit cost,\n * the stock value (a formula: Qty times Unit cost), its condition, who has\n * it checked out and when it was last counted. Suppliers list the items\n * they supply, how many, and the units in stock, linked both ways.\n *\n * The Items views: the stock list grouped by category with value totals, a\n * photo gallery, items by location as a Kanban (drag one to move it), the\n * \"Reorder\" view of what runs low, and the form for adding an item.\n */\n import { SvBase, enableBase, type Attachment, type BaseTable } from '@svgrid/enterprise/base'\n import { productShot, type ProductKind } from '../shared/gallery-art'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n type Item = {\n id: number\n item: string\n photo: Attachment[]\n category: string\n location: string\n supplier: string[]\n qty: number\n unitCost: number\n condition: number\n checkedOut: string\n audited: string\n sku: string\n }\n\n const CATEGORIES = [\n { value: 'Furniture', color: '#c46f4e' },\n { value: 'Lighting', color: '#fcb400' },\n { value: 'Kitchen', color: '#20c933' },\n { value: 'Audio', color: '#8b46ff' },\n { value: 'Decor', color: '#2d7ff9' },\n { value: 'Plants', color: '#20d9d2' },\n ]\n const LOCATIONS = [\n { value: 'Studio A', color: '#2d7ff9' },\n { value: 'Studio B', color: '#8b46ff' },\n { value: 'Warehouse', color: '#94a3b8' },\n { value: 'Showroom', color: '#ff6f2c' },\n ]\n const ITEMS: Array<[string, ProductKind, string, string, number]> = [\n ['Lounge chair, sage', 'chair', '#8a9a7b', 'Furniture', 640],\n ['Dining chair, navy', 'chair', '#34495e', 'Furniture', 290],\n ['Dining chair, oat', 'chair', '#d8cbb3', 'Furniture', 290],\n ['Arc floor lamp', 'lamp', '#efe6d8', 'Lighting', 410],\n ['Table lamp, black', 'lamp', '#2f2f33', 'Lighting', 160],\n ['Table lamp, sand', 'lamp', '#d9b38c', 'Lighting', 160],\n ['Stoneware mug, slate', 'mug', '#6d8fa3', 'Kitchen', 18],\n ['Stoneware mug, rust', 'mug', '#9e5a3c', 'Kitchen', 18],\n ['Espresso cup, forest', 'mug', '#2e3d34', 'Kitchen', 12],\n ['Bookshelf speaker', 'speaker', '#3a3d42', 'Audio', 380],\n ['Portable speaker, olive', 'speaker', '#6b7f6e', 'Audio', 140],\n ['Wall clock, brass', 'clock', '#b08d57', 'Decor', 95],\n ['Desk clock, celadon', 'clock', '#9fb4a8', 'Decor', 70],\n ['Fiddle leaf fig', 'plant', '#c67b5c', 'Plants', 85],\n ['Snake plant', 'plant', '#e9e4dc', 'Plants', 45],\n ]\n // Who supplies each category.\n const SUPPLIERS = [\n ['Nordic Home Supply', 'orders@nordichome.example.com', 14, ['Furniture', 'Decor']],\n ['Lumen & Co', 'trade@lumen.example.com', 10, ['Lighting']],\n ['Kiln Ceramics', 'hello@kiln.example.com', 21, ['Kitchen']],\n ['Signal Audio', 'b2b@signal.example.com', 7, ['Audio']],\n ['Greenhouse Co', 'shop@greenhouse.example.com', 5, ['Plants']],\n ] as const\n const supplierOf = (category: string) => `s${SUPPLIERS.findIndex((s) => (s[3] as readonly string[]).includes(category)) + 1}`\n const day = (n: number) => new Date(Date.UTC(2026, 8, 1 + n)).toISOString().slice(0, 10)\n let nextId = ITEMS.length + 1\n const items: Item[] = ITEMS.map(([item, kind, color, category, unitCost], i) => ({\n id: i + 1,\n item,\n photo: [{ id: `ph${i}`, name: `${item.toLowerCase().replace(/[^a-z]+/g, '-')}.svg`, type: 'image/svg+xml', url: productShot(kind, color) }],\n category,\n location: LOCATIONS[(i * 3) % LOCATIONS.length]!.value,\n supplier: [supplierOf(category)],\n qty: [12, 2, 30, 4, 1, 9, 48, 6, 3, 2, 7, 1, 5, 3, 0][i]!,\n unitCost,\n condition: 2 + ((i * 3) % 4),\n checkedOut: i % 4 === 1 ? ['u2', 'u3', 'u4'][i % 3]! : '',\n audited: day((i * 4) % 40),\n sku: `INV-${String(1000 + i * 7)}`,\n }))\n const suppliers = SUPPLIERS.map(([name, email, leadDays], i) => ({\n id: `s${i + 1}`,\n name,\n email,\n leadDays,\n items: items.filter((it) => it.supplier[0] === `s${i + 1}`).map((it) => String(it.id)),\n }))\n\n const itemColumns: any[] = [\n { field: 'item', header: 'Item', width: 230, fieldType: 'text' },\n { field: 'photo', header: 'Photo', width: 104, fieldType: 'attachment', fieldProps: { accept: 'image/*', maxFiles: 3 } },\n { field: 'category', header: 'Category', width: 150, fieldType: 'singleSelect', fieldProps: { options: CATEGORIES } },\n { field: 'location', header: 'Location', width: 130, fieldType: 'singleSelect', fieldProps: { options: LOCATIONS } },\n { field: 'supplier', header: 'Supplier', width: 180, fieldType: 'link', fieldProps: { table: 'inventory-suppliers', multiple: false, inverse: 'items' } },\n { field: 'qty', header: 'Qty', width: 90, fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },\n { field: 'unitCost', header: 'Unit cost', width: 132, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 2 } },\n { field: 'value', header: 'Stock value', width: 150, fieldType: 'formula', fieldProps: { formula: '{Qty} * {Unit cost}', result: 'currency', precision: 2, summary: 'sum' } },\n { field: 'condition', header: 'Condition', width: 130, fieldType: 'rating', fieldProps: { max: 5, color: '#20c933' } },\n { field: 'checkedOut', header: 'Checked out to', width: 170, fieldType: 'user' },\n { field: 'audited', header: 'Last counted', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'sku', header: 'SKU', width: 120, fieldType: 'barcode' },\n ]\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'inventory-items',\n name: 'Items',\n primaryField: 'item',\n data: items,\n columns: itemColumns,\n views: [\n { id: 'stock', name: 'Stock by category', type: 'grid', state: { grouping: ['category'], sorting: [{ id: 'category', desc: false }, { id: 'item', desc: false }] } },\n { id: 'photos', name: 'Photos', type: 'gallery', coverField: 'photo' },\n { id: 'where', name: 'By location', type: 'kanban', stackBy: 'location' },\n { id: 'units', name: 'Units by location', type: 'chart', chart: { kind: 'bar', x: 'location', y: 'qty' } },\n {\n id: 'reorder',\n name: 'Reorder',\n type: 'grid',\n color: { mode: 'conditions', rules: [{ color: '#f82b60', condition: { column: 'qty', kind: 'number', op: 'eq', value: 0 } }, { color: '#fcb400', condition: { column: 'qty', kind: 'number', op: 'lte', value: 3 } }] },\n state: {\n sorting: [{ id: 'qty', desc: false }],\n advancedFilter: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'qty', kind: 'number', op: 'lte', value: 3 }] },\n },\n },\n { id: 'add', name: 'Add an item', type: 'form', formFields: ['item', 'photo', 'category', 'location', 'supplier', 'qty', 'unitCost'], formDescription: 'New stock or equipment. It shows in the list as soon as you submit.' },\n ],\n props: { rowAdder: { label: 'Add item', defaults: () => ({ id: nextId++, item: '', photo: [], qty: 0, condition: 5 }) } },\n },\n {\n id: 'inventory-suppliers',\n name: 'Suppliers',\n primaryField: 'name',\n data: suppliers,\n columns: [\n { field: 'name', header: 'Supplier', width: 200, fieldType: 'text' },\n { field: 'email', header: 'Orders', width: 260, fieldType: 'email' },\n { field: 'leadDays', header: 'Lead time (days)', width: 150, fieldType: 'number', fieldProps: { format: 'integer' } },\n { field: 'items', header: 'Items', width: 320, fieldType: 'link', fieldProps: { table: 'inventory-items', multiple: true, inverse: 'supplier' } },\n { field: 'itemCount', header: 'Item count', width: 120, fieldType: 'count', fieldProps: { linkField: 'items' } },\n { field: 'units', header: 'Units in stock', width: 140, fieldType: 'rollup', fieldProps: { linkField: 'items', field: 'qty', aggregation: 'SUM' } },\n ],\n views: [{ id: 'suppliers', name: 'All suppliers', type: 'grid' }],\n props: { rowAdder: { label: 'Add supplier' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={44}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3169
|
+
},
|
|
3170
|
+
{
|
|
3171
|
+
"id": "516-base-postgres",
|
|
3172
|
+
"path": "examples/src/demos/516-base-postgres.svelte",
|
|
3173
|
+
"title": "Base Postgres",
|
|
3174
|
+
"category": "Base",
|
|
3175
|
+
"blurb": "516. Base on Postgres SvGrid Base with its tables in a real Postgres database: PGlite, Postgres compiled to WebAssembly, running in this page. No server, no keys.",
|
|
3176
|
+
"source": "<script lang=\"ts\">\n /**\n * 516. Base on Postgres\n * ---------------------\n * SvGrid Base with its tables in a real Postgres database: PGlite, Postgres\n * compiled to WebAssembly, running in this page. No server, no keys.\n *\n * Three tables, Tasks, Projects and Tags. Each `BaseTable` has a `source`:\n * `baseSource(createSqlDataSource(...))`. The base loads every record from\n * it, and each change you make becomes SQL: an UPDATE of the fields that\n * changed, an INSERT for a new record, a DELETE. The log under the base\n * lists each statement as it runs, with its parameters.\n *\n * Links are foreign keys and a junction table:\n * - Task -> Project is `tasks.project_id`, a foreign key. The Projects\n * table's Tasks field is worked out from it, so linking a task from the\n * project's side writes the task's `project_id`.\n * - Task <-> Tag goes through `task_tags`: adding a tag inserts a row\n * there, removing one deletes it.\n *\n * Automations (\"3 automations\" in the view bar):\n * - in the browser: Done ticks the box when a task's Status becomes Done.\n * Its change is written like an edit: the log shows a second UPDATE.\n * - on the server (`createAutomationServer`, here in the page standing in\n * for your server): a task that turns Blocked posts to the team chat\n * through a webhook whose URL names a secret, and a task set to Done\n * emails the lead. The server hears every write to the database, so they\n * also run for a change made with Run SQL. What they sent is listed under\n * \"Sent by the server\", and their runs show under Runs in the dialog.\n *\n * \"Run SQL\" changes the database behind the base's back, the way another\n * user would; the source's `realtime` tells the base, which reads the table\n * again and shows what moved. Try:\n * update tasks set status = 'Done' where priority >= 4\n *\n * Make a write fail: run\n * alter table tasks add constraint not_blocked check (status <> 'Blocked') not valid\n * and set a task's Status to Blocked. The database refuses, the base puts\n * the cell back, and the tab bar and a notice say why.\n */\n import { onMount } from 'svelte'\n import { PGlite } from '@electric-sql/pglite'\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseSource, createAutomationServer, enableBase, type AutomationServer, type BaseAutomation, type BaseTable } from '@svgrid/enterprise/base'\n import { createSqlDataSource, type EntitySchema } from '@svgrid/enterprise'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n // ---- The database -----------------------------------------------------------\n\n const SCHEMA = `\n create table projects (\n id text primary key,\n name text not null default '',\n owner text,\n due date\n );\n create table tags (\n id text primary key,\n name text not null default ''\n );\n create table tasks (\n id text primary key,\n name text not null default '',\n status text,\n priority int,\n assignee text,\n due date,\n done boolean not null default false,\n project_id text references projects(id) on delete set null\n );\n create table task_tags (\n id serial primary key,\n task_id text not null references tasks(id) on delete cascade,\n tag_id text not null references tags(id) on delete cascade,\n unique (task_id, tag_id)\n );\n\n insert into projects values\n ('p1', 'Launch', 'u1', current_date + 21),\n ('p2', 'Billing revamp', 'u2', current_date + 45),\n ('p3', 'Mobile app', 'u3', current_date + 70);\n insert into tags values ('g1', 'Design'), ('g2', 'Backend'), ('g3', 'Frontend'), ('g4', 'Risk');\n insert into tasks (id, name, status, priority, assignee, due, done, project_id) values\n ('t1', 'Landing page copy', 'Done', 3, 'u1', current_date - 4, true, 'p1'),\n ('t2', 'Pricing table', 'In progress', 4, 'u2', current_date + 3, false, 'p1'),\n ('t3', 'Launch email', 'Todo', 2, 'u3', current_date + 9, false, 'p1'),\n ('t4', 'Invoice PDFs', 'In progress', 5, 'u4', current_date + 6, false, 'p2'),\n ('t5', 'Tax rules by country', 'Blocked', 4, 'u2', current_date + 12, false, 'p2'),\n ('t6', 'Card retries', 'Todo', 3, 'u1', current_date + 20, false, 'p2'),\n ('t7', 'Offline mode', 'Todo', 2, 'u3', current_date + 34, false, 'p3'),\n ('t8', 'Push notifications', 'Todo', 1, 'u4', current_date + 40, false, 'p3'),\n ('t9', 'Bug bash', 'Todo', 3, null, current_date + 18, false, null);\n insert into task_tags (task_id, tag_id) values\n ('t1', 'g1'), ('t2', 'g1'), ('t2', 'g3'), ('t4', 'g2'), ('t5', 'g2'), ('t5', 'g4'), ('t7', 'g3'), ('t8', 'g3');\n `\n\n // What the SQL source needs to know of each table: its columns and key.\n const schema = (name: string, fields: EntitySchema['fields']): EntitySchema => ({ name, idField: 'id', fields: [{ field: 'id', type: 'text', primaryKey: true }, ...fields] })\n const SCHEMAS = {\n projects: schema('projects', [\n { field: 'name', type: 'text' },\n { field: 'owner', type: 'text' },\n { field: 'due', type: 'date' },\n ]),\n tags: schema('tags', [{ field: 'name', type: 'text' }]),\n tasks: schema('tasks', [\n { field: 'name', type: 'text' },\n { field: 'status', type: 'text' },\n { field: 'priority', type: 'number' },\n { field: 'assignee', type: 'text' },\n { field: 'due', type: 'date' },\n { field: 'done', type: 'boolean' },\n { field: 'project_id', type: 'text' },\n ]),\n task_tags: { name: 'task_tags', idField: 'id', fields: [{ field: 'id', type: 'number', primaryKey: true }, { field: 'task_id', type: 'text' }, { field: 'tag_id', type: 'text' }] } as EntitySchema,\n }\n\n let db: PGlite\n let ready = $state(false)\n let failed = $state('')\n\n // ---- Every statement, for the log ------------------------------------------------\n\n /** Who ran a statement: the base, the server (its automations), or you with Run SQL. */\n type Who = 'base' | 'server' | 'you'\n type Entry = { n: number; sql: string; params: unknown[]; ms: number; who: Who; error?: string }\n let log = $state<Entry[]>([])\n let n = 0\n async function run(sql: string, params: unknown[] = [], who: Who = 'base'): Promise<Record<string, unknown>[]> {\n const started = performance.now()\n const entry: Entry = { n: ++n, sql, params, ms: 0, who }\n try {\n const res = await db.query(sql, params)\n return res.rows as Record<string, unknown>[]\n } catch (e) {\n entry.error = e instanceof Error ? e.message : String(e)\n throw e\n } finally {\n entry.ms = Math.round(performance.now() - started)\n // The count query beside each read adds nothing to read here.\n if (!sql.startsWith('SELECT COUNT(')) log = [entry, ...log].slice(0, 40)\n // The server hears the writes it did not make itself.\n if (!entry.error && who !== 'server') heardWrite(sql)\n }\n }\n\n // \"Run SQL\" tells the base the tables changed, as a realtime feed would.\n const listeners = new Set<() => void>()\n const realtime = (onChange: () => void) => {\n listeners.add(onChange)\n return () => listeners.delete(onChange)\n }\n\n // ---- The server ---------------------------------------------------------------\n // In an app this is your server: a SvelteKit route or a worker with the same\n // tables and sources, told of each change by createKitHandlers' audit hook\n // or a database webhook. Here it hears every write that reaches Postgres.\n\n let server: AutomationServer | null = null\n type Sent = { n: number; kind: 'Chat' | 'Email'; to: string; text: string }\n let sent = $state<Sent[]>([])\n let sentN = 0\n // The team chat's webhook: it gets a JSON message.\n const chatFetch = (async (url: string, init?: RequestInit) => {\n const text = String(JSON.parse(String(init?.body ?? '{}')).text ?? '')\n sent = [{ n: ++sentN, kind: 'Chat' as const, to: new URL(url).host, text }, ...sent]\n return new Response('{}', { status: 200 })\n }) as unknown as typeof fetch\n\n const waiting = new Set<string>()\n /** A write reached the database: the server reads that table again. */\n function heardWrite(sql: string) {\n const m = /^\\s*(?:update|insert\\s+into|delete\\s+from)\\s+\"?(\\w+)\"?/i.exec(sql)\n const table = m?.[1]?.toLowerCase()\n if (!server || !table || !['tasks', 'projects', 'tags'].includes(table) || waiting.has(table)) return\n waiting.add(table)\n setTimeout(async () => {\n waiting.delete(table)\n const runs = (await server?.tableChanged(table)) ?? []\n // What it changed reaches the base the way another user's change would.\n if (runs.some((r) => r.steps.some((s) => s.action === 'Update record' || s.action === 'Create record'))) for (const on of listeners) on()\n }, 0)\n }\n\n const sql = (table: keyof typeof SCHEMAS, who: Who) =>\n createSqlDataSource({ schema: SCHEMAS[table], table, dialect: { placeholders: '$', ilike: true }, execute: (text, params) => run(text, params, who) })\n\n // ---- The base ------------------------------------------------------------------\n\n const STATUS = [\n { value: 'Todo', color: '#64748b' },\n { value: 'In progress', color: '#2563eb' },\n { value: 'Blocked', color: '#dc2626' },\n { value: 'Done', color: '#16a34a' },\n ]\n\n let tables = $state.raw<BaseTable[]>([])\n\n const statusIs = (value: string) => ({ kind: 'fieldFilter' as const, conjunction: 'and' as const, conditions: [{ column: 'status', kind: 'select' as const, op: 'is' as const, value }] })\n const AUTOMATIONS: BaseAutomation[] = [\n {\n id: 'done',\n name: 'Done ticks the box',\n enabled: true,\n trigger: { type: 'recordUpdated', fields: ['status'] },\n condition: statusIs('Done'),\n actions: [{ type: 'updateRecord', values: { done: 'true' } }],\n },\n {\n id: 'chat',\n name: 'Tell the team about a blocked task',\n enabled: true,\n runOn: 'server',\n trigger: { type: 'recordMatches' },\n condition: statusIs('Blocked'),\n actions: [{ type: 'webhook', url: 'https://chat.example.com/hooks/{secret:CHAT_HOOK}', body: '{\"text\": \"{Task} is blocked. Assignee: {Assignee}\"}' }],\n },\n {\n id: 'mail',\n name: 'Email the lead when a task is done',\n enabled: true,\n runOn: 'server',\n trigger: { type: 'recordUpdated', fields: ['status'] },\n condition: statusIs('Done'),\n actions: [{ type: 'email', to: 'lead@example.com', subject: '{Task} is done', body: '{Task} in {Project} was marked done.' }],\n },\n ]\n\n function build(who: Who): BaseTable[] {\n return [\n {\n id: 'tasks',\n name: 'Tasks',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Task', width: 220, fieldType: 'text' },\n { field: 'status', header: 'Status', width: 130, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'project', header: 'Project', width: 150, fieldType: 'link', fieldProps: { table: 'projects', multiple: false, inverse: 'tasks' } },\n { field: 'tags', header: 'Tags', width: 190, fieldType: 'link', fieldProps: { table: 'tags', multiple: true, inverse: 'tasks' } },\n { field: 'assignee', header: 'Assignee', width: 150, fieldType: 'user' },\n { field: 'due', header: 'Due', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'priority', header: 'Priority', width: 110, fieldType: 'rating', fieldProps: { max: 5, icon: 'flag', color: '#f82b60' } },\n { field: 'done', header: 'Done', width: 70, fieldType: 'checkbox' },\n ],\n views: [\n { id: 'all', name: 'All tasks', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'By status', type: 'kanban', stackBy: 'status' },\n ],\n source: baseSource(sql('tasks', who), {\n links: {\n project: { column: 'project_id' },\n tags: { through: sql('task_tags', who), from: 'task_id', to: 'tag_id' },\n },\n realtime,\n }),\n automations: AUTOMATIONS,\n props: { rowAdder: { label: 'Add task', defaults: () => ({ status: 'Todo', done: false }) } },\n },\n {\n id: 'projects',\n name: 'Projects',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Project', width: 180, fieldType: 'text' },\n { field: 'owner', header: 'Owner', width: 150, fieldType: 'user' },\n { field: 'due', header: 'Due', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'tasks', header: 'Tasks', width: 280, fieldType: 'link', fieldProps: { table: 'tasks', multiple: true, inverse: 'project' } },\n { field: 'open', header: 'Tasks', width: 90, fieldType: 'count', fieldProps: { linkField: 'tasks' } },\n ],\n source: baseSource(sql('projects', who), { realtime }),\n },\n {\n id: 'tags',\n name: 'Tags',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Tag', width: 160, fieldType: 'text' },\n { field: 'tasks', header: 'Tasks', width: 320, fieldType: 'link', fieldProps: { table: 'tasks', multiple: true, inverse: 'tags' } },\n ],\n source: baseSource(sql('tags', who), { realtime }),\n },\n ]\n }\n\n onMount(async () => {\n try {\n db = new PGlite()\n await db.exec(SCHEMA)\n tables = build('base')\n // The server reads the same tables through sources of its own.\n server = createAutomationServer({\n tables: build('server'),\n secrets: { CHAT_HOOK: 'T0DEMO-hook' },\n fetch: chatFetch,\n sendEmail: (m) => {\n sent = [{ n: ++sentN, kind: 'Email', to: m.to.join(', '), text: `${m.subject}: ${m.text}` }, ...sent]\n },\n })\n await server.prime()\n ready = true\n } catch (e) {\n failed = e instanceof Error ? e.message : String(e)\n }\n })\n\n // ---- Run SQL -------------------------------------------------------------------\n\n let query = $state(\"update tasks set status = 'Done' where priority >= 4\")\n let result = $state('')\n async function runQuery() {\n result = ''\n try {\n const rows = await run(query, [], 'you')\n result = rows.length ? `${rows.length} row${rows.length === 1 ? '' : 's'}` : 'Done'\n for (const on of listeners) on()\n } catch (e) {\n result = e instanceof Error ? e.message : String(e)\n }\n }\n\n const paramsText = (p: unknown[]) => p.map((v) => (typeof v === 'string' ? `'${v}'` : v === null ? 'null' : String(v))).join(', ')\n</script>\n\n<section class=\"pgbase\">\n {#if failed}\n <p class=\"pgbase-error\">The database did not start: {failed}</p>\n {:else if !ready}\n <p class=\"pgbase-wait\">Starting Postgres in the page…</p>\n {:else}\n <div class=\"pgbase-base\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n showRowSelection={false}\n expandRows\n rowHeight={34}\n containerHeight=\"100%\"\n columnAdder={false}\n fieldMenu\n serverRuns={async () => server?.runs ?? []}\n />\n </div>\n <div class=\"pgbase-bottom\">\n <div class=\"pgbase-db\">\n <form class=\"pgbase-run\" onsubmit={(e) => { e.preventDefault(); void runQuery() }}>\n <label for=\"pgbase-sql\">Run SQL</label>\n <input id=\"pgbase-sql\" bind:value={query} spellcheck=\"false\" autocomplete=\"off\" />\n <button type=\"submit\">Run</button>\n {#if result}<span class=\"pgbase-result\">{result}</span>{/if}\n </form>\n <ol class=\"pgbase-log\" aria-label=\"SQL the base ran\">\n {#each log as e (e.n)}\n <li class:is-error={!!e.error} data-who={e.who}>\n {#if e.who !== 'base'}<span class=\"pgbase-who\">{e.who === 'server' ? 'Server' : 'You'}</span>{/if}\n <code>{e.sql}</code>\n {#if e.params.length}<span class=\"pgbase-params\">[{paramsText(e.params)}]</span>{/if}\n <span class=\"pgbase-ms\">{e.error ? e.error : `${e.ms} ms`}</span>\n </li>\n {/each}\n </ol>\n </div>\n <aside class=\"pgbase-sent\" aria-label=\"Sent by the server\">\n <h3>Sent by the server</h3>\n {#each sent as s (s.n)}\n <p><span class=\"pgbase-kind\">{s.kind}</span> <span class=\"pgbase-to\">{s.to}</span><br />{s.text}</p>\n {:else}\n <p class=\"pgbase-none\">Set a task to Blocked or Done.</p>\n {/each}\n </aside>\n </div>\n {/if}\n <SvToaster />\n</section>\n\n<style>\n .pgbase { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: 10px; }\n .pgbase-base { flex: 1 1 auto; min-height: 380px; display: flex; flex-direction: column; }\n .pgbase-wait, .pgbase-error { margin: 0; padding: 24px 0; color: var(--sg-muted, #64748b); }\n .pgbase-error { color: var(--sg-danger, #dc2626); }\n .pgbase-bottom { flex: 0 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 10px; }\n @media (max-width: 900px) { .pgbase-bottom { grid-template-columns: 1fr; } }\n .pgbase-sent {\n height: 196px; overflow: auto; padding: 10px 12px; box-sizing: border-box; font-size: 12px;\n border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 10px);\n background: var(--sg-header-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a);\n }\n .pgbase-sent h3 { margin: 0 0 6px; font-size: 12px; color: var(--sg-muted, #64748b); font-weight: 600; }\n .pgbase-sent p { margin: 0 0 8px; line-height: 1.4; }\n .pgbase-kind { font-weight: 700; }\n .pgbase-to, .pgbase-none { color: var(--sg-muted, #64748b); }\n .pgbase-db {\n flex: 0 0 auto; display: flex; flex-direction: column; gap: 8px; padding: 10px 12px;\n border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 10px);\n background: var(--sg-header-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a);\n }\n .pgbase-run { display: flex; align-items: center; gap: 8px; font-size: 13px; }\n .pgbase-run label { color: var(--sg-muted, #64748b); white-space: nowrap; }\n .pgbase-run input {\n flex: 1; min-width: 0; height: 30px; padding: 0 8px; box-sizing: border-box;\n font: 12px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff);\n border: 1px solid var(--sg-border, #cbd5e1); border-radius: var(--sg-radius, 6px);\n }\n .pgbase-run input:focus-visible { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: -1px; }\n .pgbase-run button {\n height: 30px; padding: 0 14px; border: 0; border-radius: var(--sg-radius, 6px); cursor: pointer;\n background: var(--sg-accent, #2563eb); color: var(--sg-accent-fg, #fff); font: inherit; font-weight: 600;\n }\n .pgbase-result { color: var(--sg-muted, #64748b); font-size: 12px; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .pgbase-log {\n list-style: none; margin: 0; padding: 0; height: 150px; overflow: auto;\n font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n .pgbase-log li { display: flex; gap: 10px; align-items: baseline; padding: 3px 0; border-top: 1px solid var(--sg-border, #e2e8f0); }\n .pgbase-log li:first-child { border-top: 0; }\n .pgbase-log code { flex: 1; min-width: 0; overflow-wrap: anywhere; }\n .pgbase-params { color: var(--sg-muted, #64748b); overflow-wrap: anywhere; max-width: 35%; }\n .pgbase-ms { flex: none; color: var(--sg-muted, #64748b); }\n .pgbase-who { flex: none; padding: 0 5px; border-radius: 4px; font-size: 11px; border: 1px solid var(--sg-border, #cbd5e1); color: var(--sg-muted, #64748b); }\n .pgbase-log li[data-who='server'] code { color: var(--sg-muted, #64748b); }\n .pgbase-log li.is-error { flex-wrap: wrap; }\n /* A refused statement: the database's message on a line of its own. */\n .pgbase-log li.is-error .pgbase-ms { flex: 1 0 100%; color: var(--sg-danger, #dc2626); }\n</style>\n"
|
|
3177
|
+
},
|
|
3178
|
+
{
|
|
3179
|
+
"id": "517-base-supabase",
|
|
3180
|
+
"path": "examples/src/demos/517-base-supabase.svelte",
|
|
3181
|
+
"title": "Base Supabase",
|
|
3182
|
+
"category": "Base",
|
|
3183
|
+
"blurb": "517. Base on Supabase, live Two people on one Supabase project, side by side: each window is its own `<SvBase>` with its own client. Change a task on one side and the other",
|
|
3184
|
+
"source": "<script lang=\"ts\">\n /**\n * 517. Base on Supabase, live\n * ---------------------------\n * Two people on one Supabase project, side by side: each window is its own\n * `<SvBase>` with its own client. Change a task on one side and the other\n * shows it a moment later, through Supabase Realtime.\n *\n * Each table is `baseSource(createSupabaseDataSource(...))`, with\n * `realtime` from `createSupabaseRealtime`. A task's project is the\n * `project_id` foreign key; the Projects table's Tasks field is worked out\n * from it. A new record gets its id from the database (a uuid), and the\n * base keeps the one it gave the row in the page, so nothing jumps.\n *\n * Turn a window offline and edit there: the request fails, the base puts\n * the cell back and says why. Back online, it reads the tables again.\n *\n * The project here is in the page (`shared/mock-supabase.ts`), so the demo\n * needs no account. With a real one, the only change is the client:\n * const client = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY)\n */\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseSource, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import { createSupabaseDataSource, createSupabaseRealtime, type EntitySchema } from '@svgrid/enterprise'\n import { usePeople } from '../shared/base-people'\n import { createMockProject, createMockSupabase } from '../shared/mock-supabase'\n\n enableBase()\n usePeople()\n\n const day = (offset: number) => {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n }\n\n // The database both windows share.\n const project = createMockProject({\n projects: [\n { id: 'p1', name: 'Website relaunch', owner: 'u1' },\n { id: 'p2', name: 'Customer portal', owner: 'u2' },\n ],\n tasks: [\n { id: 't1', name: 'Wireframes', status: 'Done', assignee: 'u1', due: day(-6), project_id: 'p1' },\n { id: 't2', name: 'Visual design', status: 'In progress', assignee: 'u3', due: day(2), project_id: 'p1' },\n { id: 't3', name: 'CMS migration', status: 'Todo', assignee: 'u2', due: day(9), project_id: 'p1' },\n { id: 't4', name: 'Sign-in with SSO', status: 'In progress', assignee: 'u4', due: day(4), project_id: 'p2' },\n { id: 't5', name: 'Invoices page', status: 'Todo', assignee: 'u2', due: day(12), project_id: 'p2' },\n { id: 't6', name: 'Accessibility audit', status: 'Todo', assignee: null, due: day(15), project_id: null },\n ],\n })\n\n // What the Supabase source reads of each table: its columns and key.\n const SCHEMAS: Record<string, EntitySchema> = {\n tasks: {\n name: 'tasks',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'status', type: 'text' },\n { field: 'assignee', type: 'text' },\n { field: 'due', type: 'date' },\n { field: 'project_id', type: 'text' },\n ],\n },\n projects: {\n name: 'projects',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'owner', type: 'text' },\n ],\n },\n }\n\n const STATUS = [\n { value: 'Todo', color: '#64748b' },\n { value: 'In progress', color: '#2563eb' },\n { value: 'Blocked', color: '#dc2626' },\n { value: 'Done', color: '#16a34a' },\n ]\n\n class Win {\n tables = $state.raw<BaseTable[]>([])\n offline = $state(false)\n requests = $state<string[]>([])\n reconnect = new Set<() => void>()\n who: string\n constructor(who: string) {\n this.who = who\n }\n }\n\n /**\n * One person's window: a client of the shared project and the base's\n * tables on it. The table ids differ per window only because both bases\n * are on one page; in an app they are just 'tasks' and 'projects'.\n */\n function open(who: string, prefix: string): Win {\n const win = new Win(who)\n const client = createMockSupabase(project, {\n offline: () => win.offline,\n onRequest: (line) => {\n win.requests = [line, ...win.requests].slice(0, 3)\n },\n })\n const source = (table: string, links = {}) =>\n baseSource(createSupabaseDataSource({ client: client as never, table, schema: SCHEMAS[table]! }), {\n links,\n // Supabase makes the key: a new row is inserted without the page's id.\n serverIds: true,\n realtime: (onChange) => {\n win.reconnect.add(onChange)\n const live = createSupabaseRealtime({ client: client as never, table, onChange })\n return () => {\n win.reconnect.delete(onChange)\n live.unsubscribe()\n }\n },\n })\n win.tables = [\n {\n id: `${prefix}tasks`,\n name: 'Tasks',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Task', width: 170, fieldType: 'text' },\n { field: 'status', header: 'Status', width: 120, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'project', header: 'Project', width: 150, fieldType: 'link', fieldProps: { table: `${prefix}projects`, multiple: false, inverse: 'tasks' } },\n { field: 'assignee', header: 'Assignee', width: 140, fieldType: 'user' },\n { field: 'due', header: 'Due', width: 120, fieldType: 'date' },\n ],\n views: [\n { id: 'all', name: 'All tasks', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'Board', type: 'kanban', stackBy: 'status' },\n ],\n source: source('tasks', { project: { column: 'project_id' } }),\n props: { rowAdder: { label: 'Add task', defaults: () => ({ status: 'Todo' }) } },\n },\n {\n id: `${prefix}projects`,\n name: 'Projects',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Project', width: 170, fieldType: 'text' },\n { field: 'owner', header: 'Owner', width: 140, fieldType: 'user' },\n { field: 'tasks', header: 'Tasks', width: 260, fieldType: 'link', fieldProps: { table: `${prefix}tasks`, multiple: true, inverse: 'project' } },\n ],\n source: source('projects'),\n },\n ]\n return win\n }\n\n const windows = [open('Ana', 'a-'), open('Ben', 'b-')]\n\n function setOffline(win: Win, offline: boolean) {\n win.offline = offline\n // Back online, a realtime client reconnects and the base reads again.\n if (!offline) for (const on of win.reconnect) on()\n }\n</script>\n\n<section class=\"sbase\">\n {#each windows as win (win.who)}\n <div class=\"sbase-win\" class:is-offline={win.offline}>\n <header class=\"sbase-bar\">\n <span class=\"sbase-dots\" aria-hidden=\"true\"><i></i><i></i><i></i></span>\n <span class=\"sbase-who\">{win.who}'s browser</span>\n <label class=\"sbase-net\">\n <input type=\"checkbox\" checked={win.offline} onchange={(e) => setOffline(win, e.currentTarget.checked)} />\n Offline\n </label>\n </header>\n <div class=\"sbase-body\">\n <SvBase bind:tables={win.tables} editable sortable showRowNumbers showRowSelection={false} rowHeight={34} containerHeight=\"100%\" columnAdder={false} automations={false} />\n </div>\n <footer class=\"sbase-req\" aria-label={`${win.who}'s last requests`}>\n {#each win.requests as r, i (i)}<code>{r}</code>{/each}\n </footer>\n </div>\n {/each}\n <SvToaster />\n</section>\n\n<style>\n .sbase { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; flex: 1; min-height: 520px; }\n @media (max-width: 900px) { .sbase { grid-template-columns: 1fr; } }\n .sbase-win {\n display: flex; flex-direction: column; min-width: 0; min-height: 480px; overflow: hidden;\n border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 10px); background: var(--sg-bg, #fff);\n }\n .sbase-win.is-offline { border-color: var(--sg-danger, #dc2626); }\n .sbase-bar {\n display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 10px; font-size: 12px;\n color: var(--sg-muted, #64748b); background: var(--sg-header-bg, var(--sg-bg, #fff)); border-bottom: 1px solid var(--sg-border, #e2e8f0);\n }\n .sbase-dots { display: inline-flex; gap: 5px; }\n .sbase-dots i { width: 9px; height: 9px; border-radius: 50%; background: color-mix(in srgb, var(--sg-fg, #0f172a) 18%, transparent); }\n .sbase-who { flex: 1; font-weight: 600; color: var(--sg-fg, #0f172a); }\n .sbase-net { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }\n .sbase-net input { accent-color: var(--sg-danger, #dc2626); }\n .sbase-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }\n .sbase-req {\n display: flex; flex-direction: column; gap: 2px; min-height: 52px; padding: 6px 10px; box-sizing: border-box;\n border-top: 1px solid var(--sg-border, #e2e8f0); font-size: 11px; color: var(--sg-muted, #64748b);\n }\n .sbase-req code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n</style>\n"
|
|
3185
|
+
},
|
|
3186
|
+
{
|
|
3187
|
+
"id": "518-base-rest-api",
|
|
3188
|
+
"path": "examples/src/demos/518-base-rest-api.svelte",
|
|
3189
|
+
"title": "Base Rest Api",
|
|
3190
|
+
"category": "Base",
|
|
3191
|
+
"blurb": "518. Base on your API A CRM base over a JSON API, built from the API's own description of its tables. `baseTablesFromSchemas` turns each table's columns into fields: a",
|
|
3192
|
+
"source": "<script lang=\"ts\">\n /**\n * 518. Base on your API\n * ---------------------\n * A CRM base over a JSON API, built from the API's own description of its\n * tables. `baseTablesFromSchemas` turns each table's columns into fields: a\n * number column into a number field, an enum into a single select with its\n * values, a foreign key (`company_id`) into a link to Companies, with a\n * field in Companies that links back. Each table is on\n * `createRestDataSource`, which reads `GET /api/deals?offset=0&limit=...`\n * and writes with POST, PATCH and DELETE.\n *\n * The schemas here are written out; `introspectSupabaseTable`,\n * `introspectDatabase` or `introspectOpenApi` read them from a live\n * database or an OpenAPI document. After that, the fields are yours to\n * adjust like any other: Amount shows as currency below.\n *\n * The API checks what it is sent: give a deal a negative amount and it\n * answers 422 with its reason. The base puts the cell back and shows the\n * reason. A new record gets its id from the API (`de1000`); the base keeps\n * the one it gave the row in the page.\n *\n * The API is in the page (`shared/mock-rest.ts`), handed to the source as\n * its `fetch`. With a real one, drop `fetch` and point `url` at it.\n */\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseTablesFromSchemas, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import { createRestDataSource, type EntitySchema } from '@svgrid/enterprise'\n import { createMockRestApi, type MockApiCall } from '../shared/mock-rest'\n\n enableBase()\n\n const day = (offset: number) => {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n }\n\n // ---- The API ---------------------------------------------------------------------\n\n let calls = $state<Array<MockApiCall & { n: number }>>([])\n let n = 0\n const api = createMockRestApi(\n {\n companies: [\n { id: 'c1', name: 'Northwind Traders', industry: 'Retail', website: 'https://northwind.example' },\n { id: 'c2', name: 'Contoso', industry: 'Manufacturing', website: 'https://contoso.example' },\n { id: 'c3', name: 'Fabrikam', industry: 'Software', website: 'https://fabrikam.example' },\n ],\n deals: [\n { id: 'd1', title: 'Annual renewal', stage: 'Won', amount: 48000, close_date: day(-12), company_id: 'c1' },\n { id: 'd2', title: 'Second warehouse', stage: 'Proposal', amount: 125000, close_date: day(18), company_id: 'c1' },\n { id: 'd3', title: 'Pilot for 40 seats', stage: 'Qualified', amount: 22000, close_date: day(30), company_id: 'c2' },\n { id: 'd4', title: 'Platform upgrade', stage: 'Negotiation', amount: 87000, close_date: day(9), company_id: 'c3' },\n { id: 'd5', title: 'Support plan', stage: 'Lost', amount: 9000, close_date: day(-3), company_id: 'c2' },\n ],\n contacts: [\n { id: 'p1', name: 'Maria Anders', email: 'maria@northwind.example', company_id: 'c1' },\n { id: 'p2', name: 'Ken Sato', email: 'ken@contoso.example', company_id: 'c2' },\n { id: 'p3', name: 'Rita Gomez', email: 'rita@fabrikam.example', company_id: 'c3' },\n ],\n },\n {\n latency: 200,\n validate: (table, row) => (table === 'deals' && typeof row.amount === 'number' && row.amount < 0 ? 'Amount must be 0 or more' : null),\n onRequest: (call) => {\n calls = [{ ...call, n: ++n }, ...calls].slice(0, 30)\n },\n },\n )\n\n // ---- What the API says its tables hold ---------------------------------------------\n\n const STAGES = [\n { value: 'Qualified', label: 'Qualified', color: '#64748b' },\n { value: 'Proposal', label: 'Proposal', color: '#2563eb' },\n { value: 'Negotiation', label: 'Negotiation', color: '#d97706' },\n { value: 'Won', label: 'Won', color: '#16a34a' },\n { value: 'Lost', label: 'Lost', color: '#dc2626' },\n ]\n const SCHEMAS: EntitySchema[] = [\n {\n name: 'deals',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'title', type: 'text' },\n { field: 'stage', type: 'enum', options: STAGES },\n { field: 'amount', type: 'number' },\n { field: 'close_date', type: 'date', label: 'Close date' },\n { field: 'company_id', type: 'relation', relation: { entity: 'companies', labelField: 'name' } },\n ],\n },\n {\n name: 'companies',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'industry', type: 'enum', options: ['Retail', 'Manufacturing', 'Software'].map((v) => ({ value: v, label: v })) },\n { field: 'website', type: 'text', format: 'url' },\n ],\n },\n {\n name: 'contacts',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'email', type: 'text', format: 'email' },\n { field: 'company_id', type: 'relation', relation: { entity: 'companies', labelField: 'name' } },\n ],\n },\n ]\n\n // ---- The base ------------------------------------------------------------------\n\n function build(): BaseTable[] {\n const tables = baseTablesFromSchemas(\n SCHEMAS,\n (schema) => createRestDataSource({ url: `/api/${schema.name}`, schema, fetch: api }),\n // The API makes the key of a new record.\n () => ({ serverIds: true }),\n )\n // The fields are ordinary Base fields: adjust them as you would any other.\n return tables.map((t) =>\n t.id !== 'deals'\n ? t\n : {\n ...t,\n columns: t.columns.map((c) => (c.field === 'amount' ? { ...c, fieldProps: { format: 'currency', symbol: '$', precision: 0, summary: 'sum' } } : c)),\n views: [\n { id: 'grid', name: 'All deals', type: 'grid', color: { mode: 'field', field: 'stage' } },\n { id: 'pipeline', name: 'Pipeline', type: 'kanban', stackBy: 'stage' },\n ],\n },\n )\n }\n let tables = $state.raw<BaseTable[]>(build())\n\n const statusTone = (s: number) => (s >= 400 ? 'is-bad' : s >= 300 ? '' : 'is-ok')\n</script>\n\n<section class=\"rbase\">\n <div class=\"rbase-base\">\n <SvBase bind:tables editable sortable showRowNumbers showRowSelection={false} summary rowHeight={34} containerHeight=\"100%\" columnAdder={false} />\n </div>\n <ol class=\"rbase-log\" aria-label=\"Requests to the API\">\n {#each calls as c (c.n)}\n <li>\n <span class=\"rbase-status {statusTone(c.status)}\">{c.status}</span>\n <code>{c.method} {c.path}</code>\n {#if c.body}<code class=\"rbase-body\">{JSON.stringify(c.body)}</code>{/if}\n <span class=\"rbase-ms\">{c.ms} ms</span>\n </li>\n {/each}\n </ol>\n <SvToaster />\n</section>\n\n<style>\n .rbase { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: 10px; }\n .rbase-base { flex: 1 1 auto; min-height: 380px; display: flex; flex-direction: column; }\n .rbase-log {\n flex: 0 0 auto; list-style: none; margin: 0; padding: 8px 12px; height: 140px; overflow: auto;\n border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 10px);\n background: var(--sg-header-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a);\n font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n .rbase-log li { display: flex; gap: 10px; align-items: baseline; padding: 2px 0; }\n .rbase-status { flex: none; width: 30px; font-weight: 700; color: var(--sg-muted, #64748b); }\n .rbase-status.is-ok { color: var(--sg-success, #16a34a); }\n .rbase-status.is-bad { color: var(--sg-danger, #dc2626); }\n .rbase-body { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--sg-muted, #64748b); }\n .rbase-ms { flex: none; margin-left: auto; color: var(--sg-muted, #64748b); }\n</style>\n"
|
|
3193
|
+
},
|
|
3194
|
+
{
|
|
3195
|
+
"id": "519-base-from-database",
|
|
3196
|
+
"path": "examples/src/demos/519-base-from-database.svelte",
|
|
3197
|
+
"title": "Base From Database",
|
|
3198
|
+
"category": "Base",
|
|
3199
|
+
"blurb": "519. Database to app Plain SQL tables in, a working app out. Pick a database (or edit its SQL) and press \"Make the app\": the tables are created in a real Postgres in",
|
|
3200
|
+
"source": "<script lang=\"ts\">\n /**\n * 519. Database to app\n * --------------------\n * Plain SQL tables in, a working app out. Pick a database (or edit its SQL)\n * and press \"Make the app\": the tables are created in a real Postgres in\n * the page (PGlite), then one call reads them:\n *\n * const tables = await baseFromDatabase({ execute })\n *\n * and `<SvBase {tables}>` shows them. Nothing about the app is written by\n * hand. From the database alone it gets:\n * - a tab per table, with fields typed from the columns and their names\n * (an email column is an email field, `price` is currency, `notes` long\n * text, `due_on` a date);\n * - a single select, with colors, for a text column that repeats a few\n * values (a status, a priority, a category);\n * - a link for each foreign key, with a field in the other table that\n * links back, and a link to many records for a table that only pairs\n * two others (`event_speakers`);\n * - a Kanban board by the first select and a calendar by the first date.\n * Every edit goes back to the database as SQL.\n *\n * In an app, `execute` runs on your server: `createBaseEndpoint({ execute })`\n * in a SvelteKit route and `baseFromEndpoint('/api/base')` in the page.\n */\n import { PGlite } from '@electric-sql/pglite'\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseFromDatabase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import eventsSql from '../shared/base-sql/events.sql?raw'\n import shopSql from '../shared/base-sql/shop.sql?raw'\n import helpdeskSql from '../shared/base-sql/helpdesk.sql?raw'\n import trackerSql from '../shared/base-sql/tracker.sql?raw'\n import crmSql from '../shared/base-sql/crm.sql?raw'\n\n enableBase()\n\n // The sample databases `npm create @svgrid` offers for a base app: the same files.\n const SAMPLES: Record<string, { label: string; sql: string }> = {\n events: { label: 'Events', sql: eventsSql },\n shop: { label: 'Shop', sql: shopSql },\n helpdesk: { label: 'Help desk', sql: helpdeskSql },\n tracker: { label: 'Project tracker', sql: trackerSql },\n crm: { label: 'Sales CRM', sql: crmSql },\n }\n\n let sample = $state('events')\n let sql = $state(SAMPLES.events!.sql)\n let tables = $state.raw<BaseTable[] | null>(null)\n let busy = $state(false)\n let error = $state('')\n let tookMs = $state<number | null>(null)\n let run = 0\n\n function pick(key: string) {\n sample = key\n sql = SAMPLES[key]!.sql\n tables = null\n tookMs = null\n error = ''\n }\n\n async function makeApp() {\n busy = true\n error = ''\n tables = null\n const mine = ++run\n try {\n // A fresh database each time, with your SQL in it.\n const db = new PGlite()\n await db.exec(sql)\n const started = performance.now()\n // The one call: read the database, make the base.\n const made = await baseFromDatabase({ execute: async (text, params) => (await db.query(text, params)).rows as Record<string, unknown>[] })\n if (mine !== run) return\n tookMs = Math.round(performance.now() - started)\n tables = made\n } catch (e) {\n error = e instanceof Error ? e.message : String(e)\n } finally {\n busy = false\n }\n }\n</script>\n\n<section class=\"dbapp\">\n <div class=\"dbapp-setup\">\n <div class=\"dbapp-pick\" role=\"tablist\" aria-label=\"Sample database\">\n {#each Object.entries(SAMPLES) as [key, s] (key)}\n <button type=\"button\" role=\"tab\" aria-selected={sample === key} class:is-on={sample === key} onclick={() => pick(key)}>{s.label}</button>\n {/each}\n </div>\n <label class=\"dbapp-sql\">\n <span>Your database (Postgres SQL)</span>\n <textarea bind:value={sql} spellcheck=\"false\" rows=\"9\"></textarea>\n </label>\n <div class=\"dbapp-go\">\n <button type=\"button\" class=\"dbapp-make\" disabled={busy} onclick={makeApp}>{busy ? 'Reading the database…' : 'Make the app'}</button>\n <code class=\"dbapp-code\">const tables = await baseFromDatabase({'{'} execute {'}'})</code>\n {#if tookMs != null}<span class=\"dbapp-took\">Read {tables?.length ?? 0} tables in {tookMs} ms</span>{/if}\n </div>\n {#if error}<p class=\"dbapp-error\">{error}</p>{/if}\n </div>\n <div class=\"dbapp-app\">\n {#if tables}\n {#key tables}\n <SvBase {tables} editable sortable showRowNumbers showRowSelection={false} expandRows rowHeight={34} containerHeight=\"100%\" columnAdder fieldMenu />\n {/key}\n {:else}\n <p class=\"dbapp-empty\">Press \"Make the app\": the tables above become a base, with no other setup.</p>\n {/if}\n </div>\n <SvToaster />\n</section>\n\n<style>\n .dbapp { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(420px, 1fr); gap: 12px; flex: 1; min-height: 0; color: var(--sg-fg, #0f172a); }\n .dbapp-setup {\n display: grid; gap: 8px; padding: 12px; border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 10px);\n background: var(--sg-header-bg, var(--sg-bg, #fff));\n }\n .dbapp-pick { display: flex; gap: 6px; }\n .dbapp-pick button {\n height: 28px; padding: 0 12px; border: 1px solid var(--sg-border, #cbd5e1); border-radius: 999px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer;\n }\n .dbapp-pick button.is-on { border-color: var(--sg-accent, #2563eb); color: var(--sg-accent, #2563eb); }\n .dbapp-sql { display: grid; gap: 4px; font-size: 12px; color: var(--sg-muted, #64748b); }\n .dbapp-sql textarea {\n width: 100%; box-sizing: border-box; padding: 8px; resize: vertical; min-height: 120px;\n font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--sg-fg, #0f172a);\n background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #cbd5e1); border-radius: var(--sg-radius, 6px);\n }\n .dbapp-go { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }\n .dbapp-make {\n height: 34px; padding: 0 16px; border: 0; border-radius: var(--sg-radius, 6px); cursor: pointer; font: inherit; font-weight: 600;\n background: var(--sg-accent, #2563eb); color: var(--sg-accent-fg, #fff);\n }\n .dbapp-make:disabled { opacity: 0.6; cursor: default; }\n .dbapp-code { font-size: 12px; color: var(--sg-muted, #64748b); }\n .dbapp-took { font-size: 12px; font-weight: 600; color: var(--sg-success, #16a34a); }\n .dbapp-error { margin: 0; color: var(--sg-danger, #dc2626); font-size: 13px; }\n .dbapp-app { display: flex; flex-direction: column; min-width: 0; min-height: 420px; }\n .dbapp-empty {\n margin: 0; flex: 1; display: grid; place-items: center; color: var(--sg-muted, #64748b);\n border: 1px dashed var(--sg-border, #cbd5e1); border-radius: var(--sg-radius-lg, 10px);\n }\n</style>\n"
|
|
3201
|
+
},
|
|
3074
3202
|
{
|
|
3075
3203
|
"id": "52-pivot-table",
|
|
3076
3204
|
"path": "examples/src/demos/52-pivot-table.svelte",
|
|
@@ -3079,6 +3207,46 @@ export const examples = [
|
|
|
3079
3207
|
"blurb": "52. Pivot table - built on <SvPivotDesigner> A self-contained sales pivot built on the shipped `<SvPivotDesigner>` (which the previous 1,232-line version of this demo hand-rolled).",
|
|
3080
3208
|
"source": "<script lang=\"ts\">\n /**\n * 52. Pivot table - built on <SvPivotDesigner>\n * ---------------------------------------------\n * A self-contained sales pivot built on the shipped `<SvPivotDesigner>`\n * (which the previous 1,232-line version of this demo hand-rolled).\n * The component handles the field rail, the four wells, drag-and-drop,\n * chip menus, presets, totals toggles, and the inline pivot grid.\n *\n * This demo adds two thin layers around it:\n *\n * - A grand-total KPI strip ABOVE the pivot, recomputed from the\n * designer's `onPivot` callback whenever the layout changes.\n * - Currency / number formatting on value cells via `decorateColumns`\n * so revenue + cost render as money and units as plain numbers.\n *\n * The pivot data is ~2,400 synthetic sales facts: 4 regions × 13\n * countries × 4 channels × 6 categories × 3 years × 4 quarters.\n */\n import {\n SvPivotDesigner,\n type PivotField,\n type PivotLayout,\n type PivotPreset,\n type PivotRow,\n } from '@svgrid/enterprise'\n import type { ColumnDef } from '@svgrid/grid'\n\n // ---- Domain ---------------------------------------------------------\n type Region = 'NA' | 'EMEA' | 'APAC' | 'LATAM'\n type Channel = 'online' | 'retail' | 'partner' | 'direct'\n type Category = 'electronics' | 'home' | 'fashion' | 'kitchen' | 'sports' | 'books'\n type Fact = {\n year: number\n quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4'\n region: Region\n country: string\n channel: Channel\n category: Category\n revenue: number\n units: number\n cost: number\n }\n\n const REGIONS_COUNTRIES: Record<Region, string[]> = {\n NA: ['USA', 'Canada', 'Mexico'],\n EMEA: ['Germany', 'UK', 'France', 'Italy'],\n APAC: ['Japan', 'Australia', 'India', 'Singapore'],\n LATAM: ['Brazil', 'Chile', 'Argentina'],\n }\n const CHANNELS: readonly Channel[] = ['online', 'retail', 'partner', 'direct']\n const CATEGORIES: readonly Category[] = ['electronics', 'home', 'fashion', 'kitchen', 'sports', 'books']\n const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const\n const YEARS = [2024, 2025, 2026] as const\n\n let prng = 0xFA170DA7 >>> 0\n function rnd(): number { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }\n\n function seedFacts(): Fact[] {\n const out: Fact[] = []\n for (let i = 0; i < 2400; i += 1) {\n const region = pick(['NA', 'EMEA', 'APAC', 'LATAM'] as const)\n const country = pick(REGIONS_COUNTRIES[region])\n const baseUnits = 8 + Math.floor(rnd() * 240)\n const unitPrice = 12 + Math.floor(rnd() * 240)\n const revenue = baseUnits * unitPrice\n out.push({\n year: pick(YEARS),\n quarter: pick(QUARTERS),\n region, country,\n channel: pick(CHANNELS),\n category: pick(CATEGORIES),\n revenue,\n units: baseUnits,\n cost: Math.round(revenue * (0.35 + rnd() * 0.25)),\n })\n }\n return out\n }\n const facts: Fact[] = seedFacts()\n\n // ---- Field metadata for the designer's rail ------------------------\n const moneyFormat = { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } as const\n const numberFormat = { type: 'number', options: { maximumFractionDigits: 0 } } as const\n\n const fields: PivotField<Fact>[] = [\n { field: 'year', label: 'Year', kind: 'dimension', group: 'Time' },\n { field: 'quarter', label: 'Quarter', kind: 'dimension', group: 'Time' },\n { field: 'region', label: 'Region', kind: 'dimension', group: 'Geography' },\n { field: 'country', label: 'Country', kind: 'dimension', group: 'Geography' },\n { field: 'channel', label: 'Channel', kind: 'dimension', group: 'Sales' },\n { field: 'category', label: 'Category', kind: 'dimension', group: 'Sales' },\n { field: 'revenue', label: 'Revenue', kind: 'measure', defaultAgg: 'sum', format: moneyFormat },\n { field: 'cost', label: 'Cost', kind: 'measure', defaultAgg: 'sum', format: moneyFormat },\n { field: 'units', label: 'Units', kind: 'measure', defaultAgg: 'sum', format: numberFormat },\n ]\n\n // Saved layouts shown in the designer's Presets menu.\n const presets: PivotPreset[] = [\n {\n name: 'Revenue by Region × Quarter',\n layout: {\n rows: ['region', 'country'], cols: ['year', 'quarter'],\n values: [{ field: 'revenue', agg: 'sum', label: 'Revenue', format: moneyFormat }],\n filters: [],\n },\n },\n {\n name: 'Margin scorecard (Region × Category)',\n layout: {\n rows: ['region'], cols: ['category'],\n values: [\n { field: 'revenue', agg: 'sum', label: 'Revenue', format: moneyFormat },\n { field: 'cost', agg: 'sum', label: 'Cost', format: moneyFormat },\n ],\n filters: [],\n },\n },\n {\n name: 'Channel mix',\n layout: {\n rows: ['channel'], cols: [],\n values: [\n { field: 'revenue', agg: 'sum', label: 'Revenue', format: moneyFormat },\n { field: 'units', agg: 'sum', label: 'Units', format: numberFormat },\n { field: 'revenue', agg: 'avg', label: 'Avg deal', format: moneyFormat },\n ],\n filters: [],\n },\n },\n ]\n\n let layout = $state<PivotLayout>(presets[0]!.layout)\n\n // ---- KPI strip: read totals off the grand-total row ----------------\n // The designer's `onPivot` hands us the rebuilt rows + column tree\n // after every layout change. The grand-total row carries the same\n // values that appear in the rightmost grand-total column - we just\n // bucket them by which measure the column key encodes.\n let pivotRows = $state<PivotRow[]>([])\n let pivotColumns = $state<ColumnDef<{ rowSortingFeature: { key: string } }, PivotRow>[]>([])\n\n /** Decode the engine's column id \"pv__<axis>__m<i>\" to a measure field. */\n function measureFieldFromColId(colId: string): string | null {\n const m = colId.match(/__m(\\d+)$/)\n if (!m) return null\n const chip = layout.values[Number(m[1])]\n return chip?.field ?? null\n }\n /** Flatten the column tree to every leaf id. */\n function leafColIds(cols: typeof pivotColumns): string[] {\n const out: string[] = []\n function walk(cs: typeof pivotColumns) {\n for (const c of cs) {\n if (c.columns?.length) walk(c.columns as typeof pivotColumns)\n else if (c.id) out.push(c.id)\n }\n }\n walk(cols)\n return out\n }\n const kpis = $derived.by(() => {\n const grand = pivotRows.find((r) => r.__pivotKind === 'grandTotal') ?? pivotRows[0]\n if (!grand) return { revenue: 0, cost: 0, units: 0, margin: 0, dataCount: 0 }\n let revenue = 0, cost = 0, units = 0\n for (const id of leafColIds(pivotColumns)) {\n const field = measureFieldFromColId(id)\n if (!field) continue\n const v = Number(grand[id] ?? 0)\n if (field === 'revenue') revenue += v\n else if (field === 'cost') cost += v\n else if (field === 'units')units += v\n }\n return {\n revenue, cost, units,\n margin: revenue > 0 ? (revenue - cost) / revenue : 0,\n dataCount: pivotRows.filter((r) => r.__pivotKind === 'leaf').length,\n }\n })\n\n const fmtMoney = (v: number) =>\n Math.abs(v) >= 1e6 ? '$' + (v / 1e6).toFixed(2) + 'M'\n : Math.abs(v) >= 1e3 ? '$' + Math.round(v / 1e3) + 'k'\n : '$' + Math.round(v)\n const fmtNum = (v: number) => v.toLocaleString('en-US')\n const fmtPct = (v: number) => (v * 100).toFixed(1) + '%'\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <header class=\"ph-header\">\n <h2>Pivot table</h2>\n <p>\n ~2,400 sales facts across 4 regions × 4 channels × 6 categories × 3 years. Drag fields\n between the wells, pick presets, change aggregators on the Value chips. The KPI strip\n below recomputes from the pivot's grand total on every layout change.\n </p>\n </header>\n\n <!-- KPI strip - lives ABOVE the designer because it summarises the\n designer's output. Recomputed whenever `onPivot` fires. -->\n <div class=\"ph-kpis\">\n <div class=\"ph-kpi\">\n <div class=\"ph-kpi-label\">Revenue</div>\n <div class=\"ph-kpi-value\">{fmtMoney(kpis.revenue)}</div>\n </div>\n <div class=\"ph-kpi\">\n <div class=\"ph-kpi-label\">Cost</div>\n <div class=\"ph-kpi-value\">{fmtMoney(kpis.cost)}</div>\n </div>\n <div class=\"ph-kpi\">\n <div class=\"ph-kpi-label\">Units</div>\n <div class=\"ph-kpi-value\">{fmtNum(kpis.units)}</div>\n </div>\n <div class=\"ph-kpi\">\n <div class=\"ph-kpi-label\">Gross margin</div>\n <div class=\"ph-kpi-value\">{fmtPct(kpis.margin)}</div>\n </div>\n <div class=\"ph-kpi\">\n <div class=\"ph-kpi-label\">Leaf rows</div>\n <div class=\"ph-kpi-value\">{fmtNum(kpis.dataCount)}</div>\n </div>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvPivotDesigner\n data={facts}\n {fields}\n bind:layout\n {presets}\n expandable\n onPivot={(rows, cols) => {\n pivotRows = rows\n pivotColumns = cols as typeof pivotColumns\n }}\n />\n </div>\n</section>\n\n<style>\n .ph-header h2 { font-size: 16px; font-weight: 700; margin: 0; }\n .ph-header p { margin: 4px 0 0; font-size: 12.5px; color: var(--sg-muted, #64748b); max-width: 84ch; }\n\n .ph-kpis {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n gap: 8px;\n flex-shrink: 0;\n }\n .ph-kpi {\n background: var(--sg-header-bg, #f1f5f9);\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 8px;\n padding: 10px 14px;\n }\n .ph-kpi-label {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.07em;\n color: var(--sg-muted, #64748b);\n font-weight: 700;\n }\n .ph-kpi-value {\n margin-top: 4px;\n font-size: 18px;\n font-weight: 800;\n font-variant-numeric: tabular-nums;\n color: var(--sg-fg, #0f172a);\n }\n\n @media (max-width: 900px) {\n .ph-kpis { grid-template-columns: repeat(2, 1fr); }\n }\n</style>\n"
|
|
3081
3209
|
},
|
|
3210
|
+
{
|
|
3211
|
+
"id": "520-base-bug-tracker",
|
|
3212
|
+
"path": "examples/src/demos/520-base-bug-tracker.svelte",
|
|
3213
|
+
"title": "Base Bug Tracker",
|
|
3214
|
+
"category": "Base",
|
|
3215
|
+
"blurb": "520. SvGrid Base: bug tracker A team's bugs and releases as a ready-to-use base. Each bug has a severity, a status, the areas it touches, who reported it and who is on",
|
|
3216
|
+
"source": "<script lang=\"ts\">\n /**\n * 520. SvGrid Base: bug tracker\n * -----------------------------\n * A team's bugs and releases as a ready-to-use base. Each bug has a\n * severity, a status, the areas it touches, who reported it and who is on\n * it, the release it ships in, the steps to reproduce, and when it was\n * reported and fixed. Each release lists its bugs and how many are open.\n *\n * Views: the triage list grouped by status, the board by status (drag a\n * card to move it on), what is critical now, bugs by area as a chart, and\n * the \"Report a bug\" form.\n *\n * Automations (\"4 automations\" in the view bar):\n * - a critical bug that is not fixed turns its row red and raises a notice;\n * - setting Status to Fixed stamps \"Fixed on\" with today;\n * - a bug sent with the form starts as New and announces itself;\n * - \"Start\" in the Fix column moves a bug to In progress.\n */\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase, type BaseAutomation, type BaseTable } from '@svgrid/enterprise/base'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n /** A 'YYYY-MM-DD' this many days from today, so the base stays current. */\n const day = (offset: number) => {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n }\n\n const SEVERITY = [\n { value: 'Critical', color: '#f82b60' },\n { value: 'High', color: '#ff6f2c' },\n { value: 'Medium', color: '#fcb400' },\n { value: 'Low', color: '#94a3b8' },\n ]\n const STATUS = [\n { value: 'New', color: '#8b46ff' },\n { value: 'Triaged', color: '#18bfff' },\n { value: 'In progress', color: '#2d7ff9' },\n { value: 'Fixed', color: '#20c933' },\n { value: \"Won't fix\", color: '#666666' },\n ]\n const AREAS = [\n { value: 'UI', color: '#2d7ff9' },\n { value: 'API', color: '#8b46ff' },\n { value: 'Data', color: '#20d9d2' },\n { value: 'Auth', color: '#ff6f2c' },\n { value: 'Billing', color: '#20c933' },\n ]\n\n // [bug, severity, status, areas, reporter, assignee, release, reported (days ago), fixed (days ago or null), steps]\n const BUGS: Array<[string, string, string, string[], string, string, string, number, number | null, string]> = [\n ['Checkout button does nothing on Safari 17', 'Critical', 'In progress', ['UI', 'Billing'], 'u5', 'u2', 'r2', 3, null, '1. Add an item\\n2. Open the cart in Safari 17\\n3. Press Checkout'],\n ['Invoices show last month\\'s tax rate', 'High', 'Triaged', ['Billing', 'Data'], 'u6', 'u4', 'r2', 5, null, 'Create an invoice dated the 1st of a month.'],\n ['Sign-in link expires after 1 minute', 'High', 'Fixed', ['Auth'], 'u7', 'u3', 'r1', 14, 9, 'Request a sign-in link and wait 70 seconds.'],\n ['Dark mode: table header unreadable', 'Medium', 'Fixed', ['UI'], 'u8', 'u1', 'r1', 20, 16, 'Turn on dark mode, open Reports.'],\n ['Export to CSV drops the last row', 'Critical', 'New', ['Data'], 'u5', '', 'r2', 1, null, 'Export any report with more than 1,000 rows.'],\n ['API returns 500 on empty filter', 'Medium', 'In progress', ['API'], 'u6', 'u2', 'r2', 4, null, 'GET /api/orders?status='],\n ['Avatar upload hangs at 99%', 'Low', 'Triaged', ['UI'], 'u7', 'u1', 'r3', 8, null, 'Upload a PNG over 5 MB.'],\n ['Webhook retries flood the log', 'Medium', 'New', ['API', 'Data'], 'u8', '', 'r3', 2, null, 'Point a webhook at a URL that answers 503.'],\n ['Password rules not shown on sign-up', 'Low', \"Won't fix\", ['Auth', 'UI'], 'u5', 'u3', 'r1', 30, null, 'Open the sign-up page.'],\n ['Refund marks the order paid twice', 'High', 'Fixed', ['Billing'], 'u6', 'u4', 'r1', 18, 12, 'Refund part of an order.'],\n ]\n const RELEASES = [\n { id: 'r1', version: '2.4', ships: day(-7), notes: 'Billing fixes and dark mode.' },\n { id: 'r2', version: '2.5', ships: day(10), notes: 'Checkout and exports.' },\n { id: 'r3', version: '2.6', ships: day(31), notes: 'API polish.' },\n ]\n\n let nextId = BUGS.length + 1\n const bugs = BUGS.map(([bug, severity, status, areas, reporter, assignee, release, reported, fixed, steps], i) => ({\n id: i + 1,\n bug,\n severity,\n status,\n areas,\n reporter,\n assignee,\n release: [release],\n reported: day(-reported),\n fixedOn: fixed == null ? null : day(-fixed),\n steps,\n }))\n const releases = RELEASES.map((r) => ({ ...r, bugs: bugs.filter((b) => b.release[0] === r.id).map((b) => String(b.id)) }))\n\n const notFixed = { column: 'status', kind: 'select', op: 'isNoneOf', value: ['Fixed', \"Won't fix\"] } as const\n const automations: BaseAutomation[] = [\n {\n id: 'critical',\n name: 'Critical bug',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'severity', kind: 'select', op: 'is', value: 'Critical' }, notFixed] },\n actions: [\n { type: 'highlight', background: '#fee2e2', color: '#991b1b' },\n { type: 'notify', message: 'Critical: {Bug} ({Assignee})', severity: 'error' },\n ],\n },\n {\n id: 'fixed',\n name: 'Stamp the fix date',\n enabled: true,\n trigger: { type: 'recordUpdated', fields: ['status'] },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'status', kind: 'select', op: 'is', value: 'Fixed' }] },\n actions: [{ type: 'updateRecord', values: { fixedOn: 'today' } }],\n },\n {\n id: 'reported',\n name: 'New report',\n enabled: true,\n trigger: { type: 'formSubmitted', view: 'report' },\n actions: [\n { type: 'updateRecord', values: { status: 'New', reported: 'today' } },\n { type: 'notify', message: 'New bug: {Bug}', severity: 'warning' },\n ],\n },\n {\n id: 'start',\n name: 'Start fixing',\n enabled: true,\n trigger: { type: 'button', field: 'fix' },\n actions: [{ type: 'updateRecord', values: { status: 'In progress' } }],\n },\n ]\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'bugs-bugs',\n name: 'Bugs',\n primaryField: 'bug',\n data: bugs,\n columns: [\n { field: 'bug', header: 'Bug', width: 280, fieldType: 'text' },\n { field: 'severity', header: 'Severity', width: 120, fieldType: 'singleSelect', fieldProps: { options: SEVERITY } },\n { field: 'status', header: 'Status', width: 130, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'fix', header: 'Fix', width: 96, fieldType: 'button', fieldProps: { label: 'Start', color: '#2d7ff9' } },\n { field: 'areas', header: 'Areas', width: 170, fieldType: 'multiSelect', fieldProps: { options: AREAS } },\n { field: 'assignee', header: 'Assignee', width: 160, fieldType: 'user' },\n { field: 'reporter', header: 'Reported by', width: 160, fieldType: 'user' },\n { field: 'release', header: 'Release', width: 110, fieldType: 'link', fieldProps: { table: 'bugs-releases', multiple: false, inverse: 'bugs' } },\n { field: 'reported', header: 'Reported', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'fixedOn', header: 'Fixed on', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'steps', header: 'Steps to reproduce', width: 260, fieldType: 'longText' },\n ],\n views: [\n { id: 'triage', name: 'Triage', type: 'grid', color: { mode: 'field', field: 'severity' }, state: { grouping: ['status'], sorting: [{ id: 'severity', desc: false }] } },\n { id: 'board', name: 'Board', type: 'kanban', stackBy: 'status', color: { mode: 'field', field: 'severity' } },\n {\n id: 'critical',\n name: 'Critical now',\n type: 'grid',\n state: { advancedFilter: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'severity', kind: 'select', op: 'is', value: 'Critical' }, notFixed] } },\n },\n { id: 'areas', name: 'Bugs by area', type: 'chart', chart: { kind: 'barH', x: 'areas' } },\n { id: 'report', name: 'Report a bug', type: 'form', formFields: ['bug', 'severity', 'areas', 'reporter', 'steps'], formTitle: 'Report a bug', formDescription: 'What went wrong, and how to see it happen.' },\n ],\n automations,\n props: { rowAdder: { label: 'Add bug', defaults: () => ({ id: nextId++, status: 'New', areas: [], reported: day(0) }) } },\n },\n {\n id: 'bugs-releases',\n name: 'Releases',\n primaryField: 'version',\n data: releases,\n columns: [\n { field: 'version', header: 'Version', width: 110, fieldType: 'text' },\n { field: 'ships', header: 'Ships', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'bugs', header: 'Bugs', width: 360, fieldType: 'link', fieldProps: { table: 'bugs-bugs', multiple: true, inverse: 'release' } },\n { field: 'count', header: 'Bug count', width: 110, fieldType: 'count', fieldProps: { linkField: 'bugs' } },\n { field: 'notes', header: 'Notes', width: 240, fieldType: 'longText' },\n ],\n views: [\n { id: 'all', name: 'All releases', type: 'grid', state: { sorting: [{ id: 'ships', desc: false }] } },\n { id: 'calendar', name: 'Release calendar', type: 'calendar', dateField: 'ships' },\n ],\n props: { rowAdder: { label: 'Add release' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={40}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n <SvToaster />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3217
|
+
},
|
|
3218
|
+
{
|
|
3219
|
+
"id": "521-base-event-planning",
|
|
3220
|
+
"path": "examples/src/demos/521-base-event-planning.svelte",
|
|
3221
|
+
"title": "Base Event Planning",
|
|
3222
|
+
"category": "Base",
|
|
3223
|
+
"blurb": "521. SvGrid Base: event planning A year of events as a ready-to-use base with three tables: Events, Attendees and the Checklist that gets each event done. An event has a",
|
|
3224
|
+
"source": "<script lang=\"ts\">\n /**\n * 521. SvGrid Base: event planning\n * --------------------------------\n * A year of events as a ready-to-use base with three tables: Events,\n * Attendees and the Checklist that gets each event done. An event has a\n * date, a venue, a status, a budget and a capacity; it counts who\n * registered and works out the seats left (a formula). Attendees have a\n * ticket type and check in on the day. Checklist items belong to an event\n * and have an owner and a due date.\n *\n * Views: the events calendar, the planning board by status, the guest\n * list grouped by event, tickets by type as a donut, and the RSVP form.\n *\n * Automations (\"3 automations\" in the view bar of Events and Attendees):\n * - an event that turns Confirmed gets its checklist: three items\n * created in the Checklist table and linked back to it;\n * - an event with no seats left turns red and raises a notice;\n * - someone who RSVPs with the form is told they are in.\n */\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n const day = (offset: number) => {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n }\n\n const STATUS = [\n { value: 'Idea', color: '#94a3b8' },\n { value: 'Planning', color: '#fcb400' },\n { value: 'Confirmed', color: '#2d7ff9' },\n { value: 'Done', color: '#20c933' },\n ]\n const TICKETS = [\n { value: 'General', color: '#2d7ff9' },\n { value: 'VIP', color: '#8b46ff' },\n { value: 'Speaker', color: '#ff6f2c' },\n { value: 'Press', color: '#20d9d2' },\n ]\n const TEAMS = [\n { value: 'Venue', color: '#2d7ff9' },\n { value: 'Catering', color: '#20c933' },\n { value: 'Marketing', color: '#ff6f2c' },\n ]\n\n const events = [\n { id: 'e1', event: 'Customer summit', date: day(12), venue: 'Harbor Hall, Lisbon', status: 'Confirmed', budget: 42000, capacity: 6, owner: 'u1' },\n { id: 'e2', event: 'Partner dinner', date: day(5), venue: 'Casa Azul, Porto', status: 'Confirmed', budget: 6500, capacity: 4, owner: 'u2' },\n { id: 'e3', event: 'Product launch webinar', date: day(26), venue: 'Online', status: 'Planning', budget: 3000, capacity: 500, owner: 'u3' },\n { id: 'e4', event: 'Design meetup', date: day(40), venue: 'The Foundry, Berlin', status: 'Idea', budget: 1200, capacity: 60, owner: 'u4' },\n { id: 'e5', event: 'Team offsite', date: day(-9), venue: 'Lake house, Sintra', status: 'Done', budget: 15800, capacity: 25, owner: 'u1' },\n ]\n const PEOPLE: Array<[string, string, string, string, string, boolean]> = [\n ['Maria Anders', 'maria@northwind.example', 'Northwind', 'e1', 'VIP', false],\n ['Ken Sato', 'ken@contoso.example', 'Contoso', 'e1', 'General', false],\n ['Rita Gomez', 'rita@fabrikam.example', 'Fabrikam', 'e1', 'Speaker', false],\n ['Tom Wood', 'tom@tailspin.example', 'Tailspin', 'e1', 'General', false],\n ['Lee Wong', 'lee@adatum.example', 'Adatum', 'e1', 'Press', false],\n ['Omar Haddad', 'omar@litware.example', 'Litware', 'e2', 'VIP', false],\n ['Eva Novak', 'eva@proseware.example', 'Proseware', 'e2', 'VIP', false],\n ['Sam Reyes', 'sam@woodgrove.example', 'Woodgrove', 'e2', 'General', false],\n ['Ines Costa', 'ines@lucerne.example', 'Lucerne', 'e3', 'General', false],\n ['Jon Berg', 'jon@fourthcoffee.example', 'Fourth Coffee', 'e3', 'Speaker', false],\n ['Ana Lima', 'ana@relecloud.example', 'Relecloud', 'e5', 'General', true],\n ['Ravi Iyer', 'ravi@wingtip.example', 'Wingtip', 'e5', 'General', true],\n ]\n let nextAttendee = PEOPLE.length + 1\n const attendees = PEOPLE.map(([name, email, company, event, ticket, checkedIn], i) => ({ id: `a${i + 1}`, name, email, company, event: [event], ticket, checkedIn }))\n const checklist = [\n { id: 'c1', item: 'Sign the venue contract', event: ['e1'], team: 'Venue', due: day(-2), done: true },\n { id: 'c2', item: 'Order catering for 120', event: ['e1'], team: 'Catering', due: day(6), done: false },\n { id: 'c3', item: 'Send the agenda to attendees', event: ['e1'], team: 'Marketing', due: day(8), done: false },\n { id: 'c4', item: 'Book a private room', event: ['e2'], team: 'Venue', due: day(1), done: true },\n { id: 'c5', item: 'Set up the webinar page', event: ['e3'], team: 'Marketing', due: day(14), done: false },\n ]\n const withLinks = events.map((e) => ({\n ...e,\n attendees: attendees.filter((a) => a.event[0] === e.id).map((a) => a.id),\n checklist: checklist.filter((c) => c.event[0] === e.id).map((c) => c.id),\n }))\n\n const statusIs = (value: string) => ({ kind: 'fieldFilter' as const, conjunction: 'and' as const, conditions: [{ column: 'status', kind: 'select' as const, op: 'is' as const, value }] })\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'events-events',\n name: 'Events',\n primaryField: 'event',\n data: withLinks,\n columns: [\n { field: 'event', header: 'Event', width: 220, fieldType: 'text' },\n { field: 'date', header: 'Date', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'status', header: 'Status', width: 130, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'venue', header: 'Venue', width: 200, fieldType: 'text' },\n { field: 'owner', header: 'Owner', width: 150, fieldType: 'user' },\n { field: 'budget', header: 'Budget', width: 120, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0, summary: 'sum' } },\n { field: 'capacity', header: 'Capacity', width: 110, fieldType: 'number', fieldProps: { format: 'integer' } },\n { field: 'attendees', header: 'Attendees', width: 240, fieldType: 'link', fieldProps: { table: 'events-attendees', multiple: true, inverse: 'event' } },\n { field: 'registered', header: 'Registered', width: 120, fieldType: 'count', fieldProps: { linkField: 'attendees' } },\n { field: 'left', header: 'Seats left', width: 120, fieldType: 'formula', fieldProps: { formula: '{Capacity} - {Registered}', result: 'number', precision: 0 } },\n { field: 'checklist', header: 'Checklist', width: 240, fieldType: 'link', fieldProps: { table: 'events-checklist', multiple: true, inverse: 'event' } },\n ],\n views: [\n { id: 'calendar', name: 'Calendar', type: 'calendar', dateField: 'date', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'Planning board', type: 'kanban', stackBy: 'status' },\n { id: 'all', name: 'All events', type: 'grid', color: { mode: 'field', field: 'status' }, state: { sorting: [{ id: 'date', desc: false }] } },\n ],\n automations: [\n {\n id: 'confirmed',\n name: 'Confirmed event gets its checklist',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: statusIs('Confirmed'),\n actions: [\n { type: 'createRecord', table: 'events-checklist', values: { item: 'Sign the venue contract', team: 'Venue', due: 'today+3' }, linkField: 'event' },\n { type: 'createRecord', table: 'events-checklist', values: { item: 'Book catering', team: 'Catering', due: 'today+7' }, linkField: 'event' },\n { type: 'createRecord', table: 'events-checklist', values: { item: 'Announce {Event}', team: 'Marketing', due: 'today+2' }, linkField: 'event' },\n { type: 'notify', message: '{Event} is confirmed: its checklist is ready', severity: 'success' },\n ],\n },\n {\n id: 'full',\n name: 'Sold out',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'left', kind: 'number', op: 'lte', value: 0 }] },\n actions: [\n { type: 'highlight', fields: ['left', 'registered'], background: '#fee2e2', color: '#991b1b' },\n { type: 'notify', message: '{Event} is full ({Registered} of {Capacity})', severity: 'warning' },\n ],\n },\n ],\n props: { rowAdder: { label: 'Add event', defaults: () => ({ status: 'Idea', attendees: [], checklist: [] }) } },\n },\n {\n id: 'events-attendees',\n name: 'Attendees',\n primaryField: 'name',\n data: attendees,\n columns: [\n { field: 'name', header: 'Name', width: 190, fieldType: 'text' },\n { field: 'event', header: 'Event', width: 200, fieldType: 'link', fieldProps: { table: 'events-events', multiple: false, inverse: 'attendees' } },\n { field: 'ticket', header: 'Ticket', width: 120, fieldType: 'singleSelect', fieldProps: { options: TICKETS } },\n { field: 'email', header: 'Email', width: 230, fieldType: 'email' },\n { field: 'company', header: 'Company', width: 160, fieldType: 'text' },\n { field: 'checkedIn', header: 'Checked in', width: 110, fieldType: 'checkbox', fieldProps: { color: '#20c933' } },\n ],\n views: [\n { id: 'guests', name: 'Guest list', type: 'grid', state: { grouping: ['event'] } },\n { id: 'tickets', name: 'Tickets by type', type: 'chart', chart: { kind: 'donut', x: 'ticket' } },\n { id: 'rsvp', name: 'RSVP', type: 'form', formFields: ['name', 'email', 'company', 'event', 'ticket'], formTitle: 'Save your seat', formDescription: 'Pick the event and the ticket. We send the details by email.' },\n ],\n automations: [\n {\n id: 'rsvp',\n name: 'Welcome a guest',\n enabled: true,\n trigger: { type: 'formSubmitted', view: 'rsvp' },\n actions: [{ type: 'notify', message: '{Name} is in for {Event}', severity: 'success' }],\n },\n ],\n props: { rowAdder: { label: 'Add attendee', defaults: () => ({ id: `a${nextAttendee++}`, ticket: 'General', checkedIn: false }) } },\n },\n {\n id: 'events-checklist',\n name: 'Checklist',\n primaryField: 'item',\n data: checklist,\n columns: [\n { field: 'item', header: 'Item', width: 260, fieldType: 'text' },\n { field: 'event', header: 'Event', width: 200, fieldType: 'link', fieldProps: { table: 'events-events', multiple: false, inverse: 'checklist' } },\n { field: 'team', header: 'Team', width: 130, fieldType: 'singleSelect', fieldProps: { options: TEAMS } },\n { field: 'due', header: 'Due', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'done', header: 'Done', width: 80, fieldType: 'checkbox', fieldProps: { color: '#20c933' } },\n ],\n views: [\n { id: 'open', name: 'To do', type: 'grid', state: { grouping: ['event'], sorting: [{ id: 'due', desc: false }] } },\n { id: 'teams', name: 'By team', type: 'kanban', stackBy: 'team' },\n ],\n props: { rowAdder: { label: 'Add item', defaults: () => ({ done: false }) } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={40}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n <SvToaster />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3225
|
+
},
|
|
3226
|
+
{
|
|
3227
|
+
"id": "522-base-okrs",
|
|
3228
|
+
"path": "examples/src/demos/522-base-okrs.svelte",
|
|
3229
|
+
"title": "Base Okrs",
|
|
3230
|
+
"category": "Base",
|
|
3231
|
+
"blurb": "522. SvGrid Base: OKRs Objectives and key results as a ready-to-use base. An objective has a quarter, an owner and its key results; its progress is the average of",
|
|
3232
|
+
"source": "<script lang=\"ts\">\n /**\n * 522. SvGrid Base: OKRs\n * ----------------------\n * Objectives and key results as a ready-to-use base. An objective has a\n * quarter, an owner and its key results; its progress is the average of\n * theirs (a rollup). A key result measures something from a start value\n * to a target; its progress is a formula over Start, Current and Target,\n * and its confidence says whether it will get there.\n *\n * Views: key results grouped by objective, the confidence board, progress\n * by objective as a chart, and the weekly check-in grid.\n *\n * Automations (\"2 automations\" in the view bar of Key results):\n * - a key result that falls Off track turns red and raises a notice;\n * - one that reaches its target is marked Achieved and celebrated.\n */\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n const day = (offset: number) => {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n }\n\n const QUARTERS = [\n { value: 'Q3', color: '#94a3b8' },\n { value: 'Q4', color: '#2d7ff9' },\n { value: 'Q1 next year', color: '#8b46ff' },\n ]\n const CONFIDENCE = [\n { value: 'On track', color: '#20c933' },\n { value: 'At risk', color: '#fcb400' },\n { value: 'Off track', color: '#f82b60' },\n { value: 'Achieved', color: '#2d7ff9' },\n ]\n\n const objectives = [\n { id: 'o1', objective: 'Make onboarding effortless', quarter: 'Q4', owner: 'u1' },\n { id: 'o2', objective: 'Grow revenue from existing customers', quarter: 'Q4', owner: 'u2' },\n { id: 'o3', objective: 'Ship a faster product', quarter: 'Q4', owner: 'u3' },\n { id: 'o4', objective: 'Build a team people want to join', quarter: 'Q3', owner: 'u4' },\n ]\n // [key result, objective, start, current, target, unit, confidence, updated (days ago)]\n const KRS: Array<[string, string, number, number, number, string, string, number]> = [\n ['Time to first value under 10 minutes', 'o1', 34, 18, 10, 'minutes', 'On track', 2],\n ['Activation rate to 60%', 'o1', 41, 47, 60, '%', 'At risk', 4],\n ['Support tickets per new account to 0.5', 'o1', 1.8, 1.6, 0.5, 'tickets', 'Off track', 6],\n ['Net revenue retention to 115%', 'o2', 104, 109, 115, '%', 'On track', 1],\n ['Upgrade 40 accounts to the annual plan', 'o2', 0, 26, 40, 'accounts', 'On track', 3],\n ['Page load (p75) under 1.2 s', 'o3', 2.4, 1.5, 1.2, 'seconds', 'At risk', 2],\n ['Crash-free sessions to 99.8%', 'o3', 99.1, 99.8, 99.8, '%', 'Achieved', 5],\n ['Hire 6 engineers', 'o4', 0, 5, 6, 'people', 'On track', 9],\n ['Engagement score to 80', 'o4', 71, 74, 80, 'points', 'At risk', 12],\n ]\n const keyResults = KRS.map(([kr, objective, start, current, target, unit, confidence, updated], i) => ({\n id: `k${i + 1}`,\n kr,\n objective: [objective],\n start,\n current,\n target,\n unit,\n confidence,\n updated: day(-updated),\n }))\n const withLinks = objectives.map((o) => ({ ...o, krs: keyResults.filter((k) => k.objective[0] === o.id).map((k) => k.id) }))\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'okr-krs',\n name: 'Key results',\n primaryField: 'kr',\n data: keyResults,\n columns: [\n { field: 'kr', header: 'Key result', width: 300, fieldType: 'text' },\n { field: 'objective', header: 'Objective', width: 240, fieldType: 'link', fieldProps: { table: 'okr-objectives', multiple: false, inverse: 'krs' } },\n { field: 'progress', header: 'Progress', width: 160, fieldType: 'formula', fieldProps: { formula: 'MAX(0, MIN(1, ({Current} - {Start}) / ({Target} - {Start})))', result: 'percent', precision: 0 } },\n { field: 'confidence', header: 'Confidence', width: 130, fieldType: 'singleSelect', fieldProps: { options: CONFIDENCE } },\n { field: 'start', header: 'Start', width: 90, fieldType: 'number', fieldProps: { format: 'decimal', precision: 1 } },\n { field: 'current', header: 'Current', width: 100, fieldType: 'number', fieldProps: { format: 'decimal', precision: 1 } },\n { field: 'target', header: 'Target', width: 90, fieldType: 'number', fieldProps: { format: 'decimal', precision: 1 } },\n { field: 'unit', header: 'Unit', width: 100, fieldType: 'text' },\n { field: 'updated', header: 'Checked in', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n ],\n views: [\n { id: 'by-objective', name: 'By objective', type: 'grid', color: { mode: 'field', field: 'confidence' }, state: { grouping: ['objective'] } },\n { id: 'board', name: 'Confidence', type: 'kanban', stackBy: 'confidence' },\n { id: 'checkin', name: 'Weekly check-in', type: 'grid', state: { hiddenColumns: ['objective', 'start', 'unit'], sorting: [{ id: 'updated', desc: false }] } },\n ],\n automations: [\n {\n id: 'off-track',\n name: 'Off track',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'confidence', kind: 'select', op: 'is', value: 'Off track' }] },\n actions: [\n { type: 'highlight', background: '#fee2e2', color: '#991b1b' },\n { type: 'notify', message: '{Key result} is off track ({Objective})', severity: 'error' },\n ],\n },\n {\n id: 'achieved',\n name: 'Target reached',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'progress', kind: 'number', op: 'gte', value: 1 }] },\n actions: [\n { type: 'updateRecord', values: { confidence: 'Achieved', updated: 'today' } },\n { type: 'notify', message: 'Achieved: {Key result}', severity: 'success' },\n ],\n },\n ],\n props: { rowAdder: { label: 'Add key result', defaults: () => ({ start: 0, current: 0, target: 1, confidence: 'On track', updated: day(0) }) } },\n },\n {\n id: 'okr-objectives',\n name: 'Objectives',\n primaryField: 'objective',\n data: withLinks,\n columns: [\n { field: 'objective', header: 'Objective', width: 300, fieldType: 'text' },\n { field: 'quarter', header: 'Quarter', width: 130, fieldType: 'singleSelect', fieldProps: { options: QUARTERS } },\n { field: 'owner', header: 'Owner', width: 160, fieldType: 'user' },\n { field: 'krs', header: 'Key results', width: 320, fieldType: 'link', fieldProps: { table: 'okr-krs', multiple: true, inverse: 'objective' } },\n { field: 'progress', header: 'Progress', width: 140, fieldType: 'rollup', fieldProps: { linkField: 'krs', field: 'progress', aggregation: 'AVERAGE' } },\n ],\n views: [\n { id: 'all', name: 'All objectives', type: 'grid', state: { grouping: ['quarter'] } },\n { id: 'chart', name: 'Progress by objective', type: 'chart', chart: { kind: 'barH', x: 'objective', y: 'progress', aggregation: 'avg' } },\n ],\n props: { rowAdder: { label: 'Add objective', defaults: () => ({ quarter: 'Q4', krs: [] }) } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={40}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n <SvToaster />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3233
|
+
},
|
|
3234
|
+
{
|
|
3235
|
+
"id": "523-base-expenses",
|
|
3236
|
+
"path": "examples/src/demos/523-base-expenses.svelte",
|
|
3237
|
+
"title": "Base Expenses",
|
|
3238
|
+
"category": "Base",
|
|
3239
|
+
"blurb": "523. SvGrid Base: expense approvals A team's expenses and budgets as a ready-to-use base. An expense has an amount, a category, the date, who spent it, a receipt, a status through",
|
|
3240
|
+
"source": "<script lang=\"ts\">\n /**\n * 523. SvGrid Base: expense approvals\n * -----------------------------------\n * A team's expenses and budgets as a ready-to-use base. An expense has an\n * amount, a category, the date, who spent it, a receipt, a status through\n * approval to payment, and who approved it. Each budget category lists its\n * expenses, what was spent (a rollup) and what is left (a formula).\n *\n * Views: this month's expenses with totals, the approval board, spend by\n * category as a pie, receipts as a gallery, and the \"Submit an expense\"\n * form.\n *\n * Automations (\"4 automations\" in the view bar of Expenses):\n * - an expense over $1,000 goes to Needs approval and says so;\n * - \"Approve\" in the Review column approves it and records who and when;\n * - a rejected expense turns red;\n * - one sent with the form starts as Submitted.\n * Budgets has one more: a category over its budget raises a notice.\n */\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase, type Attachment, type BaseTable } from '@svgrid/enterprise/base'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n const day = (offset: number) => {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n }\n\n /** A receipt drawn here, so the demo needs no image host. */\n function receipt(name: string, total: number): Attachment {\n const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"160\" height=\"200\" viewBox=\"0 0 160 200\"><rect width=\"160\" height=\"200\" fill=\"#fdfcf7\"/><path d=\"M0 196 l10 -6 l10 6 l10 -6 l10 6 l10 -6 l10 6 l10 -6 l10 6 l10 -6 l10 6 l10 -6 l10 6 l10 -6 l10 6 l10 -6 l10 6 V200 H0z\" fill=\"#e7e2d4\"/><text x=\"80\" y=\"34\" text-anchor=\"middle\" font-family=\"monospace\" font-size=\"13\" fill=\"#333\">${name.slice(0, 18)}</text><g fill=\"#bbb\"><rect x=\"20\" y=\"56\" width=\"90\" height=\"6\"/><rect x=\"20\" y=\"72\" width=\"70\" height=\"6\"/><rect x=\"20\" y=\"88\" width=\"100\" height=\"6\"/><rect x=\"20\" y=\"104\" width=\"60\" height=\"6\"/></g><text x=\"20\" y=\"150\" font-family=\"monospace\" font-size=\"13\" fill=\"#333\">TOTAL</text><text x=\"140\" y=\"150\" text-anchor=\"end\" font-family=\"monospace\" font-size=\"13\" fill=\"#333\">$${total}</text></svg>`\n return { id: `r-${name}`, name: `${name.toLowerCase().replace(/[^a-z]+/g, '-')}.svg`, type: 'image/svg+xml', url: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` }\n }\n\n const STATUS = [\n { value: 'Draft', color: '#94a3b8' },\n { value: 'Submitted', color: '#18bfff' },\n { value: 'Needs approval', color: '#fcb400' },\n { value: 'Approved', color: '#20c933' },\n { value: 'Rejected', color: '#f82b60' },\n { value: 'Paid', color: '#2d7ff9' },\n ]\n\n const budgets = [\n { id: 'b1', category: 'Travel', monthly: 6000 },\n { id: 'b2', category: 'Software', monthly: 2500 },\n { id: 'b3', category: 'Meals', monthly: 1200 },\n { id: 'b4', category: 'Equipment', monthly: 4000 },\n ]\n // [what, amount, category, days ago, spent by, status, approver]\n const ROWS: Array<[string, number, string, number, string, string, string]> = [\n ['Flight to the Lisbon summit', 642, 'b1', 3, 'u1', 'Approved', 'u4'],\n ['Hotel, 3 nights', 1180, 'b1', 3, 'u1', 'Needs approval', ''],\n ['Design tool, annual seat', 588, 'b2', 9, 'u3', 'Paid', 'u4'],\n ['Team lunch after launch', 236, 'b3', 1, 'u2', 'Submitted', ''],\n ['4K monitor', 899, 'b4', 6, 'u3', 'Approved', 'u4'],\n ['Laptop for new hire', 2340, 'b4', 2, 'u2', 'Needs approval', ''],\n ['Taxi to the airport', 54, 'b1', 4, 'u1', 'Paid', 'u4'],\n ['Error tracking, monthly', 120, 'b2', 12, 'u3', 'Paid', 'u4'],\n ['Client dinner', 312, 'b3', 5, 'u2', 'Rejected', 'u4'],\n ['Conference tickets x3', 1497, 'b1', 8, 'u1', 'Approved', 'u4'],\n ]\n let nextId = ROWS.length + 1\n const expenses = ROWS.map(([what, amount, budget, ago, spentBy, status, approver], i) => ({\n id: i + 1,\n what,\n amount,\n budget: [budget],\n date: day(-ago),\n spentBy,\n status,\n approver,\n approvedOn: approver ? day(-ago + 1) : null,\n receipt: [receipt(what, amount)],\n }))\n const withLinks = budgets.map((b) => ({ ...b, expenses: expenses.filter((e) => e.budget[0] === b.id).map((e) => String(e.id)) }))\n\n const statusIs = (value: string) => ({ kind: 'fieldFilter' as const, conjunction: 'and' as const, conditions: [{ column: 'status', kind: 'select' as const, op: 'is' as const, value }] })\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'exp-expenses',\n name: 'Expenses',\n primaryField: 'what',\n data: expenses,\n columns: [\n { field: 'what', header: 'Expense', width: 240, fieldType: 'text' },\n { field: 'amount', header: 'Amount', width: 120, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 2, summary: 'sum' } },\n { field: 'budget', header: 'Category', width: 140, fieldType: 'link', fieldProps: { table: 'exp-budgets', multiple: false, inverse: 'expenses' } },\n { field: 'status', header: 'Status', width: 150, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'review', header: 'Review', width: 110, fieldType: 'button', fieldProps: { label: 'Approve', color: '#20c933' } },\n { field: 'date', header: 'Date', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'spentBy', header: 'Spent by', width: 160, fieldType: 'user' },\n { field: 'receipt', header: 'Receipt', width: 100, fieldType: 'attachment', fieldProps: { accept: 'image/*,application/pdf', maxFiles: 3 } },\n { field: 'approver', header: 'Approved by', width: 160, fieldType: 'user' },\n { field: 'approvedOn', header: 'Approved on', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n ],\n views: [\n { id: 'month', name: 'This month', type: 'grid', color: { mode: 'field', field: 'status' }, state: { sorting: [{ id: 'date', desc: true }] } },\n { id: 'board', name: 'Approvals', type: 'kanban', stackBy: 'status' },\n { id: 'spend', name: 'Spend by category', type: 'chart', chart: { kind: 'pie', x: 'budget', y: 'amount' } },\n { id: 'receipts', name: 'Receipts', type: 'gallery', coverField: 'receipt' },\n { id: 'submit', name: 'Submit an expense', type: 'form', formFields: ['what', 'amount', 'budget', 'date', 'spentBy', 'receipt'], formTitle: 'Submit an expense', formDescription: 'Over $1,000 goes to a manager for approval.' },\n ],\n automations: [\n {\n id: 'big',\n name: 'Big expenses need approval',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'amount', kind: 'number', op: 'gt', value: 1000 }, { column: 'status', kind: 'select', op: 'isAnyOf', value: ['Draft', 'Submitted'] }] },\n actions: [\n { type: 'updateRecord', values: { status: 'Needs approval' } },\n { type: 'notify', message: '{Expense} ({Amount}) needs approval', severity: 'warning' },\n ],\n },\n {\n id: 'approve',\n name: 'Approve',\n enabled: true,\n trigger: { type: 'button', field: 'review' },\n actions: [{ type: 'updateRecord', values: { status: 'Approved', approver: 'Dev Shah', approvedOn: 'today' } }, { type: 'notify', message: 'Approved: {Expense}', severity: 'success' }],\n },\n {\n id: 'rejected',\n name: 'Rejected stands out',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: statusIs('Rejected'),\n actions: [{ type: 'highlight', fields: ['status', 'amount'], background: '#fee2e2', color: '#991b1b' }],\n },\n {\n id: 'submitted',\n name: 'Submitted with the form',\n enabled: true,\n trigger: { type: 'formSubmitted', view: 'submit' },\n actions: [{ type: 'updateRecord', values: { status: 'Submitted' } }],\n },\n ],\n props: { rowAdder: { label: 'Add expense', defaults: () => ({ id: nextId++, status: 'Draft', date: day(0), receipt: [] }) } },\n },\n {\n id: 'exp-budgets',\n name: 'Budgets',\n primaryField: 'category',\n data: withLinks,\n columns: [\n { field: 'category', header: 'Category', width: 160, fieldType: 'text' },\n { field: 'monthly', header: 'Monthly budget', width: 150, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0 } },\n { field: 'expenses', header: 'Expenses', width: 320, fieldType: 'link', fieldProps: { table: 'exp-expenses', multiple: true, inverse: 'budget' } },\n { field: 'spent', header: 'Spent', width: 130, fieldType: 'rollup', fieldProps: { linkField: 'expenses', field: 'amount', aggregation: 'SUM' } },\n { field: 'left', header: 'Left', width: 130, fieldType: 'formula', fieldProps: { formula: '{Monthly budget} - {Spent}', result: 'currency', precision: 0 } },\n ],\n views: [{ id: 'all', name: 'All categories', type: 'grid' }],\n automations: [\n {\n id: 'over',\n name: 'Over budget',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'left', kind: 'number', op: 'lt', value: 0 }] },\n actions: [\n { type: 'highlight', fields: ['left'], background: '#fee2e2', color: '#991b1b' },\n { type: 'notify', message: '{Category} is over budget: {Left}', severity: 'error' },\n ],\n },\n ],\n props: { rowAdder: { label: 'Add category' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n summary\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={40}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n <SvToaster />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3241
|
+
},
|
|
3242
|
+
{
|
|
3243
|
+
"id": "524-base-onboarding",
|
|
3244
|
+
"path": "examples/src/demos/524-base-onboarding.svelte",
|
|
3245
|
+
"title": "Base Onboarding",
|
|
3246
|
+
"category": "Base",
|
|
3247
|
+
"blurb": "524. SvGrid Base: employee onboarding New hires, the teams they join and the checklist that gets them ready, as a ready-to-use base with three tables. A person has a photo, a role,",
|
|
3248
|
+
"source": "<script lang=\"ts\">\n /**\n * 524. SvGrid Base: employee onboarding\n * -------------------------------------\n * New hires, the teams they join and the checklist that gets them ready,\n * as a ready-to-use base with three tables. A person has a photo, a role,\n * a department, a manager, a start date and a status; their checklist\n * items each have an owner (IT, HR or the manager) and a due date.\n * Departments list their people and count them.\n *\n * Views: the people board by status, a photo directory, start dates on a\n * calendar, the directory grouped by department, and the \"Add a new hire\"\n * form.\n *\n * Automations (\"3 automations\" in the view bar of People):\n * - a new hire, from \"+ Add row\" or the form, gets a checklist: three\n * items created in the Checklist table and linked back to them;\n * - someone starting within a week who is not active yet has their start\n * date colored;\n * - \"Activate\" in the Ready column marks them Active.\n */\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase, type Attachment, type BaseTable } from '@svgrid/enterprise/base'\n import { portrait } from '../shared/gallery-art'\n import { usePeople } from '../shared/base-people'\n\n enableBase()\n usePeople()\n\n const day = (offset: number) => {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n }\n\n const STATUS = [\n { value: 'Offer signed', color: '#8b46ff' },\n { value: 'Onboarding', color: '#fcb400' },\n { value: 'Active', color: '#20c933' },\n ]\n const OWNERS = [\n { value: 'IT', color: '#2d7ff9' },\n { value: 'HR', color: '#ff08c2' },\n { value: 'Manager', color: '#ff6f2c' },\n ]\n const departments = [\n { id: 'd1', department: 'Engineering', head: 'u2' },\n { id: 'd2', department: 'Design', head: 'u1' },\n { id: 'd3', department: 'Sales', head: 'u4' },\n ]\n // [name, role, department, start (days from today), manager, status]\n const HIRES: Array<[string, string, string, number, string, string]> = [\n ['Priya Nair', 'Frontend engineer', 'd1', 4, 'u2', 'Onboarding'],\n ['Lucas Moreau', 'Product designer', 'd2', 11, 'u1', 'Offer signed'],\n ['Aiko Tanaka', 'Account executive', 'd3', -20, 'u4', 'Active'],\n ['Diego Alvarez', 'Backend engineer', 'd1', 2, 'u2', 'Onboarding'],\n ['Nora Lindqvist', 'Sales engineer', 'd3', 25, 'u4', 'Offer signed'],\n ['Samuel Okoye', 'Data engineer', 'd1', -45, 'u3', 'Active'],\n ]\n const photo = (i: number): Attachment[] => [{ id: `p${i}`, name: `photo-${i}.svg`, type: 'image/svg+xml', url: portrait(i + 11) }]\n let nextId = HIRES.length + 1\n const people = HIRES.map(([name, role, department, start, manager, status], i) => ({\n id: `h${i + 1}`,\n name,\n photo: photo(i),\n role,\n department: [department],\n start: day(start),\n manager,\n status,\n email: `${name.split(' ')[0]!.toLowerCase()}@example.com`,\n }))\n const ITEMS: Array<[string, string, string, number, boolean]> = [\n ['Laptop and accounts for Priya', 'h1', 'IT', 2, true],\n ['Contract and payroll for Priya', 'h1', 'HR', 1, true],\n ['First-week plan for Priya', 'h1', 'Manager', 3, false],\n ['Laptop and accounts for Diego', 'h4', 'IT', 1, false],\n ['Contract and payroll for Diego', 'h4', 'HR', -1, true],\n ['Laptop and accounts for Lucas', 'h2', 'IT', 9, false],\n ]\n const checklist = ITEMS.map(([item, person, owner, due, done], i) => ({ id: `c${i + 1}`, item, person: [person], owner, due: day(due), done }))\n const withLinks = people.map((p) => ({ ...p, checklist: checklist.filter((c) => c.person[0] === p.id).map((c) => c.id) }))\n const depts = departments.map((d) => ({ ...d, people: people.filter((p) => p.department[0] === d.id).map((p) => p.id) }))\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'onb-people',\n name: 'People',\n primaryField: 'name',\n data: withLinks,\n columns: [\n { field: 'name', header: 'Name', width: 190, fieldType: 'text' },\n { field: 'photo', header: 'Photo', width: 90, fieldType: 'attachment', fieldProps: { accept: 'image/*', maxFiles: 1 } },\n { field: 'status', header: 'Status', width: 140, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'ready', header: 'Ready', width: 104, fieldType: 'button', fieldProps: { label: 'Activate', color: '#20c933' } },\n { field: 'role', header: 'Role', width: 180, fieldType: 'text' },\n { field: 'department', header: 'Department', width: 150, fieldType: 'link', fieldProps: { table: 'onb-departments', multiple: false, inverse: 'people' } },\n { field: 'start', header: 'Start date', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'manager', header: 'Manager', width: 160, fieldType: 'user' },\n { field: 'email', header: 'Email', width: 200, fieldType: 'email' },\n { field: 'checklist', header: 'Checklist', width: 280, fieldType: 'link', fieldProps: { table: 'onb-checklist', multiple: true, inverse: 'person' } },\n { field: 'items', header: 'Items', width: 90, fieldType: 'count', fieldProps: { linkField: 'checklist' } },\n ],\n views: [\n { id: 'board', name: 'Pipeline', type: 'kanban', stackBy: 'status' },\n { id: 'directory', name: 'Photo directory', type: 'gallery', coverField: 'photo' },\n { id: 'starts', name: 'Start dates', type: 'calendar', dateField: 'start', color: { mode: 'field', field: 'status' } },\n { id: 'by-team', name: 'By department', type: 'grid', state: { grouping: ['department'] } },\n { id: 'hire', name: 'Add a new hire', type: 'form', formFields: ['name', 'role', 'department', 'start', 'manager', 'email', 'photo'], formTitle: 'Add a new hire', formDescription: 'Their checklist is made for IT, HR and their manager as soon as you submit.' },\n ],\n automations: [\n {\n id: 'checklist',\n name: 'New hire gets a checklist',\n enabled: true,\n trigger: { type: 'recordCreated' },\n actions: [\n { type: 'updateRecord', values: { status: 'Offer signed' } },\n { type: 'createRecord', table: 'onb-checklist', values: { item: 'Laptop and accounts for {Name}', owner: 'IT', due: 'today+5' }, linkField: 'person' },\n { type: 'createRecord', table: 'onb-checklist', values: { item: 'Contract and payroll for {Name}', owner: 'HR', due: 'today+3' }, linkField: 'person' },\n { type: 'createRecord', table: 'onb-checklist', values: { item: 'First-week plan for {Name}', owner: 'Manager', due: 'today+7' }, linkField: 'person' },\n { type: 'notify', message: 'Checklist ready for {Name}', severity: 'success' },\n ],\n },\n {\n id: 'soon',\n name: 'Starting this week',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: {\n kind: 'fieldFilter',\n conjunction: 'and',\n conditions: [\n { column: 'start', kind: 'date', op: 'isWithin', value: { span: 'nextWeek' } },\n { column: 'status', kind: 'select', op: 'isNot', value: 'Active' },\n ],\n },\n actions: [{ type: 'highlight', fields: ['start'], background: '#fef3c7', color: '#92400e' }],\n },\n {\n id: 'activate',\n name: 'Activate',\n enabled: true,\n trigger: { type: 'button', field: 'ready' },\n actions: [{ type: 'updateRecord', values: { status: 'Active' } }, { type: 'notify', message: 'Welcome aboard, {Name}', severity: 'success' }],\n },\n ],\n props: { rowAdder: { label: 'Add person', defaults: () => ({ id: `h${nextId++}`, photo: [], checklist: [] }) } },\n },\n {\n id: 'onb-checklist',\n name: 'Checklist',\n primaryField: 'item',\n data: checklist,\n columns: [\n { field: 'item', header: 'Item', width: 280, fieldType: 'text' },\n { field: 'person', header: 'Person', width: 180, fieldType: 'link', fieldProps: { table: 'onb-people', multiple: false, inverse: 'checklist' } },\n { field: 'owner', header: 'Owner', width: 120, fieldType: 'singleSelect', fieldProps: { options: OWNERS } },\n { field: 'due', header: 'Due', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'done', header: 'Done', width: 80, fieldType: 'checkbox', fieldProps: { color: '#20c933' } },\n ],\n views: [\n { id: 'open', name: 'By person', type: 'grid', state: { grouping: ['person'], sorting: [{ id: 'due', desc: false }] } },\n { id: 'owners', name: 'By owner', type: 'kanban', stackBy: 'owner' },\n ],\n props: { rowAdder: { label: 'Add item', defaults: () => ({ done: false }) } },\n },\n {\n id: 'onb-departments',\n name: 'Departments',\n primaryField: 'department',\n data: depts,\n columns: [\n { field: 'department', header: 'Department', width: 180, fieldType: 'text' },\n { field: 'head', header: 'Head', width: 160, fieldType: 'user' },\n { field: 'people', header: 'People', width: 340, fieldType: 'link', fieldProps: { table: 'onb-people', multiple: true, inverse: 'department' } },\n { field: 'headcount', header: 'Headcount', width: 120, fieldType: 'count', fieldProps: { linkField: 'people' } },\n ],\n views: [{ id: 'all', name: 'All departments', type: 'grid' }],\n props: { rowAdder: { label: 'Add department' } },\n },\n ])\n</script>\n\n<section class=\"base-demo\">\n <SvBase\n bind:tables\n editable\n sortable\n showRowNumbers\n expandRows\n pinSplitter\n rowNumberWidth={64}\n showRowSelection={false}\n rowHeight={44}\n containerHeight=\"100%\"\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n columnAdder\n fieldMenu\n />\n <SvToaster />\n</section>\n\n<style>\n .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }\n</style>\n"
|
|
3249
|
+
},
|
|
3082
3250
|
{
|
|
3083
3251
|
"id": "53-excel-import",
|
|
3084
3252
|
"path": "examples/src/demos/53-excel-import.svelte",
|
|
@@ -3573,14 +3741,14 @@ export const docs = [
|
|
|
3573
3741
|
"path": "docs/enterprise/README.md",
|
|
3574
3742
|
"title": "Enterprise feature pack",
|
|
3575
3743
|
"section": "Enterprise tier",
|
|
3576
|
-
"markdown": "# Enterprise feature pack\n\n`@svgrid/enterprise` is a paid add-on for `@svgrid/grid`. It bolts onto\nthe same `<SvGrid>` you already have and adds five feature areas: the\nserver-side row model, data export, data import, pivot tables, and the\nKanban, Scheduler and Spreadsheet views of the same grid. (The AI helpers\nare built in and **free** in `@svgrid/grid` - see\n[AI assistant](../help/ai.md).)\n\n\n\n```bash\npnpm add @svgrid/enterprise\n```\n\n```ts\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey('YOUR-LICENSE-KEY') // once, at app startup\nconst pro = installEnterprise(api) // wraps a SvGridApi with Enterprise methods\n```\n\nThat's the whole integration. Every Enterprise helper hangs off the api object\nyou already have; the grid component stays Community.\n\n## What ships in Enterprise\n\n### [Data export](../help/export.md)\n\n`pro.exportData(opts)` writes the current view to Excel (xlsx), PDF,\nCSV, TSV, or HTML from a single call.\n\n**CSV, TSV and JSON export do not need Enterprise** - they are free in\n`@svgrid/grid`, along with copy-to-clipboard. What Enterprise adds is\nxlsx, PDF, styled HTML and XML, plus the unified entry point with\ncolumn resolution and group handling. See\n[the export page](../help/export.md) for the exact split.\n\nThe xlsx writer honours:\n\n- **Cell + row styles** via `opts.styles` - read the same `--sg-*`\n tokens the grid renders with so the file matches the theme.\n- **Page header + footer** lines with text or embedded images\n (`opts.header`, `opts.footer`).\n- **Embedded images** from cell values when columns are listed in\n `opts.imageFields`.\n- **Multiple sheets** in one workbook via `opts.sheets`.\n- **Printable view** via `pro.print(opts)` - opens a new window with\n repeat-on-page headers, cover page, page-size + orientation.\n\nDemos: [56 theme-matched](../../examples/src/demos/56-export-theme-matched.svelte),\n[57 header + footer + logo](../../examples/src/demos/57-export-header-footer-logo.svelte),\n[58 images](../../examples/src/demos/58-export-with-images.svelte),\n[59 multi-sheet](../../examples/src/demos/59-export-multi-sheet.svelte).\n\n### [Data import](../help/import.md)\n\n`pro.importData(opts)` reads an Excel / CSV / TSV / JSON file (or\ninline text), maps columns to your row shape, validates each row, and\nreturns a typed `ImportResult` with `rows`, `errors`, and a `summary`.\nYou decide whether to commit the rows into the grid or preview first.\n\nDemo: [53 Excel import](../../examples/src/demos/53-excel-import.svelte).\n\n### AI assistant - now built-in + free\n\nThe natural-language filter, smart-fill, summarise, classify, chart-this,\nfind-anomalies and natural-language-export helpers moved into the free\n`@svgrid/grid` package. Import them from `@svgrid/grid` and register a provider\nwith `setAIProvider(yourAdapter)` (no model client is bundled - you bring\nOpenAI / Anthropic / Ollama / local). See [AI assistant](../help/ai.md). The\nonly enterprise touch-point is `aiExport`'s write step, which uses this pack's\nexport engine for xlsx / pdf.\n\nDemo: [51 AI assistant](../../examples/src/demos/51-ai-assistant.svelte).\n\n### [Pivot tables](../help/pivot.md)\n\n`createPivotModel(data, config)` (or `pro.pivot.build(config)` against\nthe live api) returns `{ rows, columns }` you feed to a separate\n`<SvGrid>` instance. Supports row + column axes, eight built-in\naggregators (sum/avg/min/max/count/countDistinct/first/last) or\ncustom, grand-total row + column, subtotals, custom axis sort.\n\nDemo: [52 pivot table + designer](../../examples/src/demos/52-pivot-table.svelte).\n\n### [Server-Side Row Model](../help/server/server-grouping.md)\n\n`createServerRowModel(source, options)` runs over the free datasource\ncontract (`ServerDataSource.getRows`) and hands the grid one `rowModel`\nprop: lazy grouping and tree data one level at a time with a block cache\nper level, aggregates, child counts and grand totals, server-side pivot\nwith `SvPivotDesigner` in server mode, transactions on loaded rows,\npaging over the tree, and a selection kept as a rule so select-all\nreaches rows the grid never loaded, with a bulk edit by rule. Flat paging\nand infinite scroll stay free in `@svgrid/grid`.\n\nDemos: [467 one million rows](../../examples/src/demos/467-server-row-model-1m.svelte),\n[468 server pivot](../../examples/src/demos/468-server-pivot.svelte),\n[469 tree data](../../examples/src/demos/469-server-tree-data.svelte),\n[470 transactions](../../examples/src/demos/470-server-transactions.svelte),\n[471 selection by rule](../../examples/src/demos/471-server-selection.svelte),\n[472 request to SQL](../../examples/src/demos/472-server-sql-planner.svelte),\n[473 grouping rules](../../examples/src/demos/473-server-grouping-rules.svelte),\n[482 CRUD with a version check](../../examples/src/demos/482-server-crud.svelte),\n[483 master-detail](../../examples/src/demos/483-server-master-detail.svelte),\n[500 queries in a Web Worker](../../examples/src/demos/500-worker-data-source.svelte).\n\n### Views of the same grid\n\nThe `board` and `scheduler` props and their config types are free in\n`@svgrid/grid`; what Enterprise ships is the renderer behind each,\nregistered once with `enableBoardView()` or `enableSchedulerView()`\n(`installEnterprise(api)` calls both). The same rows, columns, sort and\nfilter feed every one of them. A [Gantt view](../help/rows/gantt.md) of the\nsame grid is in progress and due on 1 November 2026.\n\n- [Kanban board](../help/rows/kanban-board.md) - cards in lanes, drag-and-drop,\n swimlanes, WIP limits, a card drawer.\n- [Scheduler / calendar](../help/rows/scheduler.md) - month, week, day, agenda\n and timeline views with resources, recurrence and booking rules.\n- [The spreadsheet shell](../help/cells/spreadsheet-shell.md) - `<SvSheet />`\n wraps the grid in a ribbon, formula bar, gutter and sheet tabs over the\n [formula engine](../help/spreadsheet-formulas.md).\n\n## Licensing\n\nYou can evaluate the pack without asking for a key. Until a valid\nlicense key is set, everything still functions, and loading the\nEnterprise module displays a watermark on the grid and a console message\nabout the Enterprise package. Set a key once at app startup and both\ndisappear:\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey('YOUR-LICENSE-KEY') // the key from your order\n```\n\nThe pack comes in two editions, both covering unlimited production apps.\n**Grid** is everything on this page except the spreadsheet and Studio;\n**Suite** is all of it. The Indie license is the Grid edition at a lower\nprice for freelancers and companies under $1M revenue, without support.\nA key names its edition, and a key that predates\neditions reads as Suite. A Grid key that opens `<SvSheet>` is treated the\nsame way as an unlicensed build: the feature runs, with the watermark and a\nconsole notice naming the edition that covers it.\n\nPricing and the OEM tier for redistribution: <https://svgrid.com/pricing/>.\nSee also [licensing](./licensing.md).\n\n## How Enterprise integrates\n\n```ts\nimport { SvGrid, type SvGridApi } from '@svgrid/grid'\nimport { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\n\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n\nlet api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\n```\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\n api = installEnterprise(next)\n }}\n/>\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\n Export to Excel\n</button>\n```\n\n`installEnterprise` mutates and returns the same api object - existing\nreferences keep working. The `EnterpriseGridApi` type extends `SvGridApi` so\nthe grid's existing methods are still there alongside the new ones.\n\n## Tree-shaking\n\nEach Enterprise module is a separate subpath export:\n\n```ts\nimport { exportGrid } from '@svgrid/enterprise/export' // export only\nimport { importData } from '@svgrid/enterprise/import' // import only\nimport { createPivotModel } from '@svgrid/enterprise/pivot' // pivot only\n// AI helpers are free + built in: import { aiFilter } from '@svgrid/grid'\n```\n\nIf you only need export, the AI provider plumbing, pivot engine, and\nimport parser don't ship. The `@svgrid/enterprise` barrel import is\nconvenient; the subpaths are smaller.\n\n## Peer dependencies\n\n| Feature | Peer dep | Optional? |\n| ------- | -------------------- | ------------------------------------------ |\n| xlsx | `jszip` | Yes - only loaded when xlsx is exported. |\n| pdf | `pdfmake` | Yes - only loaded when pdf is exported. |\n| import | `jszip` (xlsx only) | Yes - only for xlsx import. |\n| AI | - | -. You bring your own provider client. |\n| pivot | - | -. Pure TypeScript, no runtime deps. |\n\nInstall only what you need:\n\n```bash\npnpm add @svgrid/enterprise jszip # community + Enterprise + xlsx\npnpm add @svgrid/enterprise jszip pdfmake # also pdf\n```\n\n## See also\n\n- [Getting started](../getting-started.md) - if you haven't seen the\n Community walkthrough yet.\n- [Help index](../help/index.md) - all topic pages including the\n Enterprise pages.\n- [Missing features](../help/missing-features.md) - the honest gap list.\n"
|
|
3744
|
+
"markdown": "# Enterprise feature pack\n\n`@svgrid/enterprise` is a paid add-on for `@svgrid/grid`. It bolts onto\nthe same `<SvGrid>` you already have and adds five feature areas: the\nserver-side row model, data export, data import, pivot tables, and the\nKanban, Scheduler and Spreadsheet views of the same grid. (The AI helpers\nare built in and **free** in `@svgrid/grid` - see\n[AI assistant](../help/ai.md).)\n\n\n\n```bash\npnpm add @svgrid/enterprise\n```\n\n```ts\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey('YOUR-LICENSE-KEY') // once, at app startup\nconst pro = installEnterprise(api) // wraps a SvGridApi with Enterprise methods\n```\n\nThat's the whole integration. Every Enterprise helper hangs off the api object\nyou already have; the grid component stays Community.\n\n## What ships in Enterprise\n\n### [Data export](../help/export.md)\n\n`pro.exportData(opts)` writes the current view to Excel (xlsx), PDF,\nCSV, TSV, or HTML from a single call.\n\n**CSV, TSV and JSON export do not need Enterprise** - they are free in\n`@svgrid/grid`, along with copy-to-clipboard. What Enterprise adds is\nxlsx, PDF, styled HTML and XML, plus the unified entry point with\ncolumn resolution and group handling. See\n[the export page](../help/export.md) for the exact split.\n\nThe xlsx writer honours:\n\n- **Cell + row styles** via `opts.styles` - read the same `--sg-*`\n tokens the grid renders with so the file matches the theme.\n- **Page header + footer** lines with text or embedded images\n (`opts.header`, `opts.footer`).\n- **Embedded images** from cell values when columns are listed in\n `opts.imageFields`.\n- **Multiple sheets** in one workbook via `opts.sheets`.\n- **Printable view** via `pro.print(opts)` - opens a new window with\n repeat-on-page headers, cover page, page-size + orientation.\n\nDemos: [56 theme-matched](../../examples/src/demos/56-export-theme-matched.svelte),\n[57 header + footer + logo](../../examples/src/demos/57-export-header-footer-logo.svelte),\n[58 images](../../examples/src/demos/58-export-with-images.svelte),\n[59 multi-sheet](../../examples/src/demos/59-export-multi-sheet.svelte).\n\n### [Data import](../help/import.md)\n\n`pro.importData(opts)` reads an Excel / CSV / TSV / JSON file (or\ninline text), maps columns to your row shape, validates each row, and\nreturns a typed `ImportResult` with `rows`, `errors`, and a `summary`.\nYou decide whether to commit the rows into the grid or preview first.\n\nDemo: [53 Excel import](../../examples/src/demos/53-excel-import.svelte).\n\n### AI assistant - now built-in + free\n\nThe natural-language filter, smart-fill, summarise, classify, chart-this,\nfind-anomalies and natural-language-export helpers moved into the free\n`@svgrid/grid` package. Import them from `@svgrid/grid` and register a provider\nwith `setAIProvider(yourAdapter)` (no model client is bundled - you bring\nOpenAI / Anthropic / Ollama / local). See [AI assistant](../help/ai.md). The\nonly enterprise touch-point is `aiExport`'s write step, which uses this pack's\nexport engine for xlsx / pdf.\n\nDemo: [51 AI assistant](../../examples/src/demos/51-ai-assistant.svelte).\n\n### [Pivot tables](../help/pivot.md)\n\n`createPivotModel(data, config)` (or `pro.pivot.build(config)` against\nthe live api) returns `{ rows, columns }` you feed to a separate\n`<SvGrid>` instance. Supports row + column axes, eight built-in\naggregators (sum/avg/min/max/count/countDistinct/first/last) or\ncustom, grand-total row + column, subtotals, custom axis sort.\n\nDemo: [52 pivot table + designer](../../examples/src/demos/52-pivot-table.svelte).\n\n### [Server-Side Row Model](../help/server/server-grouping.md)\n\n`createServerRowModel(source, options)` runs over the free datasource\ncontract (`ServerDataSource.getRows`) and hands the grid one `rowModel`\nprop: lazy grouping and tree data one level at a time with a block cache\nper level, aggregates, child counts and grand totals, server-side pivot\nwith `SvPivotDesigner` in server mode, transactions on loaded rows,\npaging over the tree, and a selection kept as a rule so select-all\nreaches rows the grid never loaded, with a bulk edit by rule. Flat paging\nand infinite scroll stay free in `@svgrid/grid`.\n\nDemos: [467 one million rows](../../examples/src/demos/467-server-row-model-1m.svelte),\n[468 server pivot](../../examples/src/demos/468-server-pivot.svelte),\n[469 tree data](../../examples/src/demos/469-server-tree-data.svelte),\n[470 transactions](../../examples/src/demos/470-server-transactions.svelte),\n[471 selection by rule](../../examples/src/demos/471-server-selection.svelte),\n[472 request to SQL](../../examples/src/demos/472-server-sql-planner.svelte),\n[473 grouping rules](../../examples/src/demos/473-server-grouping-rules.svelte),\n[482 CRUD with a version check](../../examples/src/demos/482-server-crud.svelte),\n[483 master-detail](../../examples/src/demos/483-server-master-detail.svelte),\n[500 queries in a Web Worker](../../examples/src/demos/500-worker-data-source.svelte).\n\n### Views of the same grid\n\nThe `board` and `scheduler` props and their config types are free in\n`@svgrid/grid`; what Enterprise ships is the renderer behind each,\nregistered once with `enableBoardView()` or `enableSchedulerView()`\n(`installEnterprise(api)` calls both). The same rows, columns, sort and\nfilter feed every one of them. A [Gantt view](../help/rows/gantt.md) of the\nsame grid is in progress and due on 1 November 2026.\n\n- [Kanban board](../help/rows/kanban-board.md) - cards in lanes, drag-and-drop,\n swimlanes, WIP limits, a card drawer.\n- [Scheduler / calendar](../help/rows/scheduler.md) - month, week, day, agenda\n and timeline views with resources, recurrence and booking rules.\n- [The spreadsheet shell](../help/cells/spreadsheet-shell.md) - `<SvSheet />`\n wraps the grid in a ribbon, formula bar, gutter and sheet tabs over the\n [formula engine](../help/spreadsheet-formulas.md).\n\n## Licensing\n\nYou can evaluate the pack without asking for a key. Until a valid\nlicense key is set, everything still functions, and loading the\nEnterprise module displays a watermark on the grid and a console message\nabout the Enterprise package. Set a key once at app startup and both\ndisappear:\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey('YOUR-LICENSE-KEY') // the key from your order\n```\n\nThe pack comes in two editions, both covering unlimited production apps.\n**Grid** is everything on this page except the spreadsheet, SvGrid Base and Studio;\n**Suite** is all of it. The Indie license is the Grid edition at a lower\nprice for freelancers and companies under $1M revenue, without support.\nA key names its edition, and a key that predates\neditions reads as Suite. A Grid key that opens `<SvSheet>` or `<SvBase>` is treated the\nsame way as an unlicensed build: the feature runs, with the watermark and a\nconsole notice naming the edition that covers it.\n\nPricing and the OEM tier for redistribution: <https://svgrid.com/pricing/>.\nSee also [licensing](./licensing.md).\n\n## How Enterprise integrates\n\n```ts\nimport { SvGrid, type SvGridApi } from '@svgrid/grid'\nimport { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\n\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n\nlet api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\n```\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\n api = installEnterprise(next)\n }}\n/>\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\n Export to Excel\n</button>\n```\n\n`installEnterprise` mutates and returns the same api object - existing\nreferences keep working. The `EnterpriseGridApi` type extends `SvGridApi` so\nthe grid's existing methods are still there alongside the new ones.\n\n## Tree-shaking\n\nEach Enterprise module is a separate subpath export:\n\n```ts\nimport { exportGrid } from '@svgrid/enterprise/export' // export only\nimport { importData } from '@svgrid/enterprise/import' // import only\nimport { createPivotModel } from '@svgrid/enterprise/pivot' // pivot only\n// AI helpers are free + built in: import { aiFilter } from '@svgrid/grid'\n```\n\nIf you only need export, the AI provider plumbing, pivot engine, and\nimport parser don't ship. The `@svgrid/enterprise` barrel import is\nconvenient; the subpaths are smaller.\n\n## Peer dependencies\n\n| Feature | Peer dep | Optional? |\n| ------- | -------------------- | ------------------------------------------ |\n| xlsx | `jszip` | Yes - only loaded when xlsx is exported. |\n| pdf | `pdfmake` | Yes - only loaded when pdf is exported. |\n| import | `jszip` (xlsx only) | Yes - only for xlsx import. |\n| AI | - | -. You bring your own provider client. |\n| pivot | - | -. Pure TypeScript, no runtime deps. |\n\nInstall only what you need:\n\n```bash\npnpm add @svgrid/enterprise jszip # community + Enterprise + xlsx\npnpm add @svgrid/enterprise jszip pdfmake # also pdf\n```\n\n## See also\n\n- [Getting started](../getting-started.md) - if you haven't seen the\n Community walkthrough yet.\n- [Help index](../help/index.md) - all topic pages including the\n Enterprise pages.\n- [Missing features](../help/missing-features.md) - the honest gap list.\n"
|
|
3577
3745
|
},
|
|
3578
3746
|
{
|
|
3579
3747
|
"slug": "enterprise/evaluation",
|
|
3580
3748
|
"path": "docs/enterprise/evaluation.md",
|
|
3581
3749
|
"title": "Enterprise evaluation",
|
|
3582
3750
|
"section": "Enterprise tier",
|
|
3583
|
-
"markdown": "# Enterprise evaluation\n\nYou can try every `@svgrid/enterprise` feature without a key. Install it\nand use it. Nothing is locked; the page shows a small watermark and logs\na console message, and that is the only difference.\n\n\n\n## 1. Install\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\nnpm install jszip pdfmake # only needed for Excel and PDF export\n```\n\n## 2. Add Enterprise to a grid\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n import { installEnterprise } from '@svgrid/enterprise'\n\n let api = $state(null)\n\n const rows = [\n { name: 'Ada Lovelace', team: 'Engineering', salary: 145000 },\n { name: 'Alan Turing', team: 'Research', salary: 160000 },\n ]\n const columns = [\n { field: 'name', header: 'Name' },\n { field: 'team', header: 'Team' },\n { field: 'salary', header: 'Salary' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} onApiReady={(next) => (api = installEnterprise(next))} />\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'team' })}>\n Export to Excel\n</button>\n```\n\nThe `<SvGrid>` component itself stays the same. `installEnterprise()` adds\nthe Enterprise methods to the grid API: `exportData`, `importData`,\n`print`, pivot and AI.\n\nFor a complete page with themes, PDF, CSV and print, see\n[Enterprise getting started](./getting-started.md).\n\n## 3. Try the features\n\nThe [demo gallery](https://svgrid.com/demos/) has a working demo for every\nEnterprise feature, with the source next to it. These pages cover each\none in detail:\n\n- [Export](../help/export.md) - Excel, PDF, CSV\n- [Import](../help/import.md) - column mapping and per-row validation\n\n## Without a key\n\nYou can skip `setLicenseKey()` entirely. Everything still runs, and you\nsee:\n\n- a small \"www.svgrid.com\" watermark on each grid, which fades after 5\n seconds\n- one console message per page load\n- a one-time upgrade card the first time you call an Enterprise feature\n\nNone of these make network calls or write cookies or storage (see\n[security](../help/security.md)).\n\nThis works the same on localhost, staging, or a demo deployment. You don't\nneed to ask anyone for a trial key. When you buy a license, call\n`setLicenseKey()` with your key and the watermark goes away.\n\n## Buying\n\n- **Indie Developer License**, $299 per developer: the enterprise grid for\n freelancers and companies under $1M revenue, without support.\n- **Grid Developer License**, $599 per developer: the enterprise grid.\n- **Suite Developer License**, $999 per developer: adds the spreadsheet\n (`<SvSheet>`) and Studio.\n\nAll three cover unlimited apps and are perpetual, with a year of updates\n(and support, except Indie). For 5+ developers, a PO, or redistribution as an SDK, contact\nsales. Details on the [pricing page](https://svgrid.com/pricing/).\n\n## See also\n\n- [Enterprise getting started](./getting-started.md) - a complete working page\n- [Enterprise licensing](./licensing.md) - key formats, seats, renewals\n- [Migration guides](../help/migrating-from-ag-grid.md) - moving from another grid\n"
|
|
3751
|
+
"markdown": "# Enterprise evaluation\n\nYou can try every `@svgrid/enterprise` feature without a key. Install it\nand use it. Nothing is locked; the page shows a small watermark and logs\na console message, and that is the only difference.\n\n\n\n## Start a 30-day trial\n\nOne download has both packages, the demo browser to run offline with every\nexample's source, and a README with the steps. It also starts 30 days of\nemail support: write to support@jqwidgets.com and an engineer who works on\nSvGrid answers. The features are the same as installing from npm below.\n\n<div data-docs-trial=\"docs-evaluation\"></div>\n\n## 1. Install\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\nnpm install jszip pdfmake # only needed for Excel and PDF export\n```\n\n## 2. Add Enterprise to a grid\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n import { installEnterprise } from '@svgrid/enterprise'\n\n let api = $state(null)\n\n const rows = [\n { name: 'Ada Lovelace', team: 'Engineering', salary: 145000 },\n { name: 'Alan Turing', team: 'Research', salary: 160000 },\n ]\n const columns = [\n { field: 'name', header: 'Name' },\n { field: 'team', header: 'Team' },\n { field: 'salary', header: 'Salary' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} onApiReady={(next) => (api = installEnterprise(next))} />\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'team' })}>\n Export to Excel\n</button>\n```\n\nThe `<SvGrid>` component itself stays the same. `installEnterprise()` adds\nthe Enterprise methods to the grid API: `exportData`, `importData`,\n`print`, pivot and AI.\n\nFor a complete page with themes, PDF, CSV and print, see\n[Enterprise getting started](./getting-started.md).\n\n## 3. Try the features\n\nThe [demo gallery](https://svgrid.com/demos/) has a working demo for every\nEnterprise feature, with the source next to it. These pages cover each\none in detail:\n\n- [Export](../help/export.md) - Excel, PDF, CSV\n- [Import](../help/import.md) - column mapping and per-row validation\n\n## Without a key\n\nYou can skip `setLicenseKey()` entirely. Everything still runs, and you\nsee:\n\n- a small \"www.svgrid.com\" watermark on each grid, which fades after 5\n seconds\n- one console message per page load\n- a one-time upgrade card the first time you call an Enterprise feature\n\nNone of these make network calls or write cookies or storage (see\n[security](../help/security.md)).\n\nThis works the same on localhost, staging, or a demo deployment. You don't\nneed to ask anyone for a trial key. When you buy a license, call\n`setLicenseKey()` with your key and the watermark goes away.\n\n## Buying\n\n- **Indie Developer License**, $299 per developer: the enterprise grid for\n freelancers and companies under $1M revenue, without support.\n- **Grid Developer License**, $599 per developer: the enterprise grid.\n- **Suite Developer License**, $999 per developer: adds the spreadsheet\n (`<SvSheet>`), SvGrid Base (`<SvBase>`) and Studio.\n\nAll three cover unlimited apps and are perpetual, with a year of updates\n(and support, except Indie). For 5+ developers, a PO, or redistribution as an SDK, contact\nsales. Details on the [pricing page](https://svgrid.com/pricing/).\n\n## See also\n\n- [Enterprise getting started](./getting-started.md) - a complete working page\n- [Enterprise licensing](./licensing.md) - key formats, seats, renewals\n- [Migration guides](../help/migrating-from-ag-grid.md) - moving from another grid\n"
|
|
3584
3752
|
},
|
|
3585
3753
|
{
|
|
3586
3754
|
"slug": "enterprise/getting-started",
|
|
@@ -3594,7 +3762,7 @@ export const docs = [
|
|
|
3594
3762
|
"path": "docs/enterprise/licensing.md",
|
|
3595
3763
|
"title": "Enterprise licensing",
|
|
3596
3764
|
"section": "Enterprise tier",
|
|
3597
|
-
"markdown": "# Enterprise licensing\n\nYou can evaluate `@svgrid/enterprise` without asking for a key. Loaded\nwithout a license key, the Enterprise module displays a watermark in the\nbottom-right corner of the grid and a console message about the\nEnterprise package; every feature still works. Set a key once at app\nstartup and both disappear.\n\n\n\n## Setting the key\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\nCall this before the first `<SvGrid>` mounts. In Vite/SvelteKit,\nexpose the key as `VITE_SVPRO_KEY=YOUR-LICENSE-KEY` in your `.env` (NOT\nchecked in) - or read it from your config service.\n\n## License tiers\n\nEvery Enterprise license is a **perpetual license** that includes **1 year of\nupdates and support**. The price shown **renews automatically each year**\nto keep updates and support active; **cancel anytime** and you keep every\nversion released during your paid term.\n\n| License | What it covers | Price (per developer) | Support |\n| ---------------------------------- | ------------------------------------------------------------------ | --------------------- | ----------------------------------------- |\n| **Indie Developer License** | Grid features, for freelancers and companies under $1M revenue | **$299** | None (GitHub issues only), updates only |\n| **Grid Developer License** | Enterprise grid features. No spreadsheet, no Studio | **$599** | Email (next business day) + private Slack |\n| **Suite Developer License** | Everything in Grid, plus the spreadsheet and Studio | **$999** | Email (next business day) + private Slack |\n| **Enterprise / volume** | Suite, site or organisation-wide | Custom quote | Priority + named contact, NDA / PO |\n| **OEM / redistribution** | Shipping SvGrid to third parties as a component or app builder | Custom quote | Priority + named contact, NDA / PO |\n\nAll three developer licenses cover an **unlimited number of production apps** in your\norganisation. The line between them is the feature set, not the app count.\n\n**Grid ($599)** is the enterprise grid: server-side row model, grouping and tree\ndata, pivot, export and import, print, the advanced filter, alerts, the selection\nbar, and the Kanban, scheduler and Gantt renderers.\n\n**Indie ($299)** is the Grid feature set for freelancers and for companies with\nless than $1M in yearly revenue, self-declared at purchase. It comes without\nsupport (no email, no Slack; public GitHub issues only, as with the Community\ntier), and its yearly renewal pays for updates only. Indie orders are\nfulfilled with a standard Grid key. Grow past the threshold and you move the\nseats to Grid at your next renewal.\n\n**Suite ($999)** adds the two things that are their own products:\n\n- the **spreadsheet**: `<SvSheet>`, the `<sv-sheet>` element, the formula engine,\n and the `.xlsx` / `.xls` / `.ods` / `.csv` readers and writers;\n- **Studio**: the designer, the code generator, and the SQL data sources.\n\n> Priced **per developer** - engineers who write or modify code that imports\n> `@svgrid/enterprise`. Production seats / end users are unlimited. One license key\n> activates every grid in scope - no per-page or per-component accounting.\n> Teams of 5+ and multi-year terms get volume discounts; email\n> `sales@jqwidgets.com`.\n\n### When you need an OEM license\n\nA developer seat covers your own application, including a SaaS product your\ncustomers pay for. It does not cover handing SvGrid to third parties as\nsomething they build with: shipping it as a component or SDK, embedding it in an\napp builder or low-code platform where your customers author their own screens,\nor reselling it white-labelled. Those need an OEM license, priced separately from\nseats. Section 4A of the [EULA](/docs/legal/EULA) has the exact test. If you are\nnot sure which side of the line you are on, email `sales@jqwidgets.com` and\ndescribe the product; the answer is usually one message.\n\n## The license key\n\nEvery customer receives the same license key, a GUID, in the order email. Set\nit once at app startup with `setLicenseKey()`; it covers every edition and\ncarries no expiry date. Your edition, seat count and updates term are recorded\non your order and governed by the [EULA](/docs/legal/EULA), not encoded in the\nkey.\n\nThe check is client-side and **no network call** is ever made to validate, so\nair-gapped deployments work out of the box. The package keeps only a SHA-256 of\nthe key and compares the key you set against it\n(`packages/enterprise/src/license-core.ts`), so the key is not readable in\n`node_modules`.\n\nA key that is not the license key (a typo, or an `SVENTERPRISE-...` string from\nbefore October 2026) never throws: the features still run, with the watermark\nand a one-time console warning, exactly as if no key were set.\n\nIt is not DRM. Anyone with devtools can read a key out of a deployed bundle,\nwhich is why the key file must never be committed to a public repository. The\nlicense is a legal agreement, not a technical lock, and we would rather say so\nthan imply a scheme that isn't there.\n\n## Per-environment keys\n\nDifferent keys for different deployment stages so revoking is surgical:\n\n```ts\nconst KEY = {\n development: import.meta.env.VITE_SVPRO_DEV_KEY,\n staging: import.meta.env.VITE_SVPRO_STAGING_KEY,\n production: import.meta.env.VITE_SVPRO_PROD_KEY,\n}[import.meta.env.MODE] ?? ''\n\nsetLicenseKey(KEY)\n```\n\n## What happens without a key\n\n| Surface | Behaviour |\n| --------------------------- | ---------------------------------------------------------- |\n| Grid render | Works. |\n| Editing, sort, filter | Works. |\n| `pro.exportData(...)` | Works. |\n| `pro.importData(...)` | Works. |\n| `pro.ai.*` | Works. |\n| `createPivotModel(...)` | Works. |\n| Studio: designer / panels | Works. |\n| Studio: `createSqlDataSource` | Works. |\n| Studio: MCP generator | Works; output carries a one-line notice. |\n| Watermark | Visible on every grid instance. |\n| Console message | About the Enterprise package, logged once per page load. |\n\nThat is the evaluation: every feature, with the watermark and the\nconsole message until a key is set. Once you're sold, drop in a key.\n\n## A Grid key on a Suite feature\n\nThe same rule, for the same reason. A Grid Developer License that opens\n`<SvSheet>`, calls the formula engine, or reaches a Studio data source gets the\nwatermark and a one-time console notice naming the feature. Nothing stops\nworking and nothing is hidden, so a Grid-tier team can try the spreadsheet in\ntheir own app before deciding to upgrade, exactly as an unlicensed team can.\n\n```ts\nimport { licenseCovers } from '@svgrid/enterprise'\n\nlicenseCovers('spreadsheet') // false on a GRID key, true on SUITE\n```\n\nUse `licenseCovers` if you want to hide a Suite-only entry point in your own UI\nrather than let it nudge.\n\n## Studio (data-app generator)\n\nThe Studio - the schema designer, edit panel, master-detail, the SQL,\nREST and Supabase data sources, and the AI generator - is covered by the\n**Suite Developer License**. There is no Studio-only key and no\nper-feature entitlement inside it: a Suite key covers the whole Studio\ntogether with the spreadsheet and the grid. Loaded without a Suite key,\nevery Studio surface still works and displays the watermark and the\nconsole message about the Enterprise package, as the rest of the package\ndoes.\n\nIt has two places you set the key, because it runs in two places:\n\n**1. In your app (the browser)** - the same `setLicenseKey()` you\nalready call. The designer, edit panel, master-detail, and data sources\nall read it. Nothing new to do.\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\n**2. In the AI generator (the MCP server)** - the generator runs in a\nNode process (Claude Code / Desktop), so it reads the key from the\n`SVGRID_LICENSE_KEY` environment variable in your MCP config. Set it\nonce:\n\n```jsonc\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"YOUR-LICENSE-KEY\" }\n }\n }\n}\n```\n\nWithout it, the generator still produces code - it just prepends a\none-line commercial notice, and the generated app carries the usual\nwatermark until you call `setLicenseKey()` in it. Same honor-system,\nsame key format, no network calls.\n\n## License renewal\n\nThe license itself is perpetual; what renews each year is your\n**updates-and-support term**. It renews automatically until you cancel\n(cancel anytime). The key keeps validating every version released during\na paid term, and if you cancel or let the term lapse, your installed\nversion keeps working - you just stop receiving new releases and support.\n\n- 60 days before the term ends: yellow nudge on the watermark\n- 30 days: orange\n- 7 days: red\n- After the term ends (if you cancelled): features keep working; only\n versions released after your paid term need a renewed term\n\nWe never disable working features on a paying customer.\n\n## Audit trail\n\nEach license key has a stable ID embedded. The same key activates as\nmany builds as you want; the ID is what we cross-reference against\nyour subscription in support tickets. **No telemetry is sent** -\nsupport uses the ID you give us, not anything we phoned home for.\n\n## See also\n\n- [Enterprise evaluation](./evaluation.md) - trying Enterprise without a key\n- [Enterprise support](./support.md) - SLAs, escalation, contact channels\n- [Pricing](https://svgrid.com/pricing/) - canonical pricing source\n"
|
|
3765
|
+
"markdown": "# Enterprise licensing\n\nYou can evaluate `@svgrid/enterprise` without asking for a key. Loaded\nwithout a license key, the Enterprise module displays a watermark in the\nbottom-right corner of the grid and a console message about the\nEnterprise package; every feature still works. Set a key once at app\nstartup and both disappear.\n\n\n\n## Setting the key\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\nCall this before the first `<SvGrid>` mounts. In Vite/SvelteKit,\nexpose the key as `VITE_SVPRO_KEY=YOUR-LICENSE-KEY` in your `.env` (NOT\nchecked in) - or read it from your config service.\n\n## License tiers\n\nEvery Enterprise license is a **perpetual license** that includes **1 year of\nupdates and support**. The price shown **renews automatically each year**\nto keep updates and support active; **cancel anytime** and you keep every\nversion released during your paid term.\n\n| License | What it covers | Price (per developer) | Support |\n| ---------------------------------- | ------------------------------------------------------------------ | --------------------- | ----------------------------------------- |\n| **Indie Developer License** | Grid features, for freelancers and companies under $1M revenue | **$299** | None (GitHub issues only), updates only |\n| **Grid Developer License** | Enterprise grid features. No spreadsheet, no Base, no Studio | **$599** | Email (next business day) + private Slack |\n| **Suite Developer License** | Everything in Grid, plus the spreadsheet, Base and Studio | **$999** | Email (next business day) + private Slack |\n| **Enterprise / volume** | Suite, site or organisation-wide | Custom quote | Priority + named contact, NDA / PO |\n| **OEM / redistribution** | Shipping SvGrid to third parties as a component or app builder | Custom quote | Priority + named contact, NDA / PO |\n\nAll three developer licenses cover an **unlimited number of production apps** in your\norganisation. The line between them is the feature set, not the app count.\n\n**Grid ($599)** is the enterprise grid: server-side row model, grouping and tree\ndata, pivot, export and import, print, the advanced filter, alerts, the selection\nbar, and the Kanban, scheduler and Gantt renderers.\n\n**Indie ($299)** is the Grid feature set for freelancers and for companies with\nless than $1M in yearly revenue, self-declared at purchase. It comes without\nsupport (no email, no Slack; public GitHub issues only, as with the Community\ntier), and its yearly renewal pays for updates only. Indie orders are\nfulfilled with a standard Grid key. Grow past the threshold and you move the\nseats to Grid at your next renewal.\n\n**Suite ($999)** adds the two things that are their own products:\n\n- the **spreadsheet**: `<SvSheet>`, the `<sv-sheet>` element, the formula engine,\n and the `.xlsx` / `.xls` / `.ods` / `.csv` readers and writers;\n- **Studio**: the designer, the code generator, and the SQL data sources.\n\n> Priced **per developer** - engineers who write or modify code that imports\n> `@svgrid/enterprise`. Production seats / end users are unlimited. One license key\n> activates every grid in scope - no per-page or per-component accounting.\n> Teams of 5+ and multi-year terms get volume discounts; email\n> `sales@jqwidgets.com`.\n\n### When you need an OEM license\n\nA developer seat covers your own application, including a SaaS product your\ncustomers pay for. It does not cover handing SvGrid to third parties as\nsomething they build with: shipping it as a component or SDK, embedding it in an\napp builder or low-code platform where your customers author their own screens,\nor reselling it white-labelled. Those need an OEM license, priced separately from\nseats. Section 4A of the [EULA](/docs/legal/EULA) has the exact test. If you are\nnot sure which side of the line you are on, email `sales@jqwidgets.com` and\ndescribe the product; the answer is usually one message.\n\n## The license key\n\nEvery customer receives the same license key, a GUID, in the order email. Set\nit once at app startup with `setLicenseKey()`; it covers every edition and\ncarries no expiry date. Your edition, seat count and updates term are recorded\non your order and governed by the [EULA](/docs/legal/EULA), not encoded in the\nkey.\n\nThe check is client-side and **no network call** is ever made to validate, so\nair-gapped deployments work out of the box. The package keeps only a SHA-256 of\nthe key and compares the key you set against it\n(`packages/enterprise/src/license-core.ts`), so the key is not readable in\n`node_modules`.\n\nA key that is not the license key (a typo, or an `SVENTERPRISE-...` string from\nbefore October 2026) never throws: the features still run, with the watermark\nand a one-time console warning, exactly as if no key were set.\n\nIt is not DRM. Anyone with devtools can read a key out of a deployed bundle,\nwhich is why the key file must never be committed to a public repository. The\nlicense is a legal agreement, not a technical lock, and we would rather say so\nthan imply a scheme that isn't there.\n\n## Per-environment keys\n\nDifferent keys for different deployment stages so revoking is surgical:\n\n```ts\nconst KEY = {\n development: import.meta.env.VITE_SVPRO_DEV_KEY,\n staging: import.meta.env.VITE_SVPRO_STAGING_KEY,\n production: import.meta.env.VITE_SVPRO_PROD_KEY,\n}[import.meta.env.MODE] ?? ''\n\nsetLicenseKey(KEY)\n```\n\n## What happens without a key\n\n| Surface | Behaviour |\n| --------------------------- | ---------------------------------------------------------- |\n| Grid render | Works. |\n| Editing, sort, filter | Works. |\n| `pro.exportData(...)` | Works. |\n| `pro.importData(...)` | Works. |\n| `pro.ai.*` | Works. |\n| `createPivotModel(...)` | Works. |\n| Studio: designer / panels | Works. |\n| Studio: `createSqlDataSource` | Works. |\n| Studio: MCP generator | Works; output carries a one-line notice. |\n| Watermark | Visible on every grid instance. |\n| Console message | About the Enterprise package, logged once per page load. |\n\nThat is the evaluation: every feature, with the watermark and the\nconsole message until a key is set. Once you're sold, drop in a key.\n\n## A Grid key on a Suite feature\n\nThe same rule, for the same reason. A Grid Developer License that opens\n`<SvSheet>` or `<SvBase>`, calls the formula engine, or reaches a Studio data source gets the\nwatermark and a one-time console notice naming the feature. Nothing stops\nworking and nothing is hidden, so a Grid-tier team can try the spreadsheet in\ntheir own app before deciding to upgrade, exactly as an unlicensed team can.\n\n```ts\nimport { licenseCovers } from '@svgrid/enterprise'\n\nlicenseCovers('spreadsheet') // false on a GRID key, true on SUITE\nlicenseCovers('base') // SvGrid Base: the same\n```\n\nUse `licenseCovers` if you want to hide a Suite-only entry point in your own UI\nrather than let it nudge.\n\n## Studio (data-app generator)\n\nThe Studio - the schema designer, edit panel, master-detail, the SQL,\nREST and Supabase data sources, and the AI generator - is covered by the\n**Suite Developer License**. There is no Studio-only key and no\nper-feature entitlement inside it: a Suite key covers the whole Studio\ntogether with the spreadsheet and the grid. Loaded without a Suite key,\nevery Studio surface still works and displays the watermark and the\nconsole message about the Enterprise package, as the rest of the package\ndoes.\n\nIt has two places you set the key, because it runs in two places:\n\n**1. In your app (the browser)** - the same `setLicenseKey()` you\nalready call. The designer, edit panel, master-detail, and data sources\nall read it. Nothing new to do.\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\n**2. In the AI generator (the MCP server)** - the generator runs in a\nNode process (Claude Code / Desktop), so it reads the key from the\n`SVGRID_LICENSE_KEY` environment variable in your MCP config. Set it\nonce:\n\n```jsonc\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"YOUR-LICENSE-KEY\" }\n }\n }\n}\n```\n\nWithout it, the generator still produces code - it just prepends a\none-line commercial notice, and the generated app carries the usual\nwatermark until you call `setLicenseKey()` in it. Same honor-system,\nsame key format, no network calls.\n\n## License renewal\n\nThe license itself is perpetual; what renews each year is your\n**updates-and-support term**. It renews automatically until you cancel\n(cancel anytime). The key keeps validating every version released during\na paid term, and if you cancel or let the term lapse, your installed\nversion keeps working - you just stop receiving new releases and support.\n\n- 60 days before the term ends: yellow nudge on the watermark\n- 30 days: orange\n- 7 days: red\n- After the term ends (if you cancelled): features keep working; only\n versions released after your paid term need a renewed term\n\nWe never disable working features on a paying customer.\n\n## Audit trail\n\nEach license key has a stable ID embedded. The same key activates as\nmany builds as you want; the ID is what we cross-reference against\nyour subscription in support tickets. **No telemetry is sent** -\nsupport uses the ID you give us, not anything we phoned home for.\n\n## See also\n\n- [Enterprise evaluation](./evaluation.md) - trying Enterprise without a key\n- [Enterprise support](./support.md) - SLAs, escalation, contact channels\n- [Pricing](https://svgrid.com/pricing/) - canonical pricing source\n"
|
|
3598
3766
|
},
|
|
3599
3767
|
{
|
|
3600
3768
|
"slug": "enterprise/studio",
|
|
@@ -4042,7 +4210,7 @@ export const docs = [
|
|
|
4042
4210
|
"path": "docs/help/alerts.md",
|
|
4043
4211
|
"title": "Alerts - Enterprise",
|
|
4044
4212
|
"section": "Alerts & scheduling",
|
|
4045
|
-
"markdown": "# Alerts - Enterprise\n\nAlert rules let your users say \"tell me when the data does X\" - and then act on\nit automatically: raise a toast, tint the row, flash the cell, block the edit,\nor just log it. Rules are authored at runtime in a no-code builder, persisted,\nand shareable as JSON. No redeploy to add an alert.\n\nFor the rules built up on a grid that moves, with a live example under most steps, see [Alerts: rules over live data](./alerts/start.md); this page is the reference.\n\nAlerts ship in the paid `@svgrid/enterprise` package. They build on the grid's\nown engines: predicates reuse the same operators as the filter row\n(`applyExcelFilter`), styling paints through the conditional-format pipeline,\nand notifications go through the grid's toast store.\n\n<div data-docs-demo=\"399-alert-rules-engine\" data-height=\"440\"></div>\n\n## Setup\n\nMount `<SvGridAlerts>` next to your grid and spread its `formats` output into the\ngrid's `conditionalFormats`. The overlay watches your data reactively, runs the\nrule engine on every change, and paints matches back through the grid. It diffs\nsnapshots of the data rather than listening to a single edit event, so it reacts\nto streaming feeds and programmatic updates as well as in-grid edits (the grid's\nown `onCellValueChange` callback covers only the latter).\n\nEvaluation never runs inside the grid's render frame: a data change schedules one\npass on the next animation frame (post-paint), so the grid always paints first.\nOn very large or fast-moving datasets, drive it in [push mode](#performance-and-large--live-datasets)\nso each pass costs only the rows that actually changed.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import type { ConditionalFormat } from '@svgrid/grid/format'\n import { SvGridAlerts, enableAlerts, setLicenseKey, type ExprColumn } from '@svgrid/enterprise'\n\n setLicenseKey('YOUR-KEY')\n enableAlerts()\n\n let rows = $state.raw(data)\n let alertFormats = $state<ConditionalFormat<Row>[]>([])\n\n // Columns the rule/expression editors offer.\n const exprColumns: ExprColumn[] = [\n { id: 'price', name: 'Price', type: 'number' },\n { id: 'region', name: 'Region', type: 'text' },\n ]\n</script>\n\n<SvGridAlerts\n data={rows}\n columns={exprColumns}\n getRowId={(r) => r.id}\n storageKey=\"app:alerts\"\n bind:formats={alertFormats} />\n\n<SvGrid data={rows} {columns} getRowId={(r) => r.id}\n conditionalFormats={alertFormats} />\n```\n\n`<SvGridAlerts>` renders a small control group: a bell (opens the fired-alert\nlog) and an **Alerts** button (opens the rule manager). Set `controls={false}`\nto hide them and drive the panels yourself with `bind:panelOpen` /\n`bind:managerOpen`.\n\n## The rule model\n\nAn `AlertRule` has four moving parts:\n\n| Part | What it is |\n| --- | --- |\n| **predicate** | A boolean [expression](./expressions-query.md) - when it holds, the rule matches. |\n| **trigger** | *When* the rule is checked (see below). |\n| **scope** | `row`, `cell`, or `aggregate`. |\n| **actions** | What happens when it fires. |\n\n### Triggers\n\n- **`dataChange`** - fires on the true edge: when a row *newly* satisfies the\n predicate after an edit or feed update. It will not re-fire while the row keeps\n matching; it re-arms once the row stops matching.\n- **`relativeChange`** - fires when a value *moves*: an absolute delta, a percent\n change, or crossing a threshold. Uses the previous snapshot of the row.\n- **`validation`** - evaluated on edit; with a `preventEdit` action the engine's\n `validateEdit` answers `vetoed`, which a column with `validate` and\n `rejectInvalid: true` turns into a refused write (`validate` alone only\n flags the cell).\n- **`scheduled`** - re-checked on a cron schedule (reuses the enterprise\n scheduler), surfacing rows that currently match.\n\n### Actions\n\n| Kind | Effect |\n| --- | --- |\n| `toast` | A toast in the rule's severity colour. |\n| `highlight` | Tints matching rows/cells (persists while they match). |\n| `badge` | Colours the targeted cells and draws its `icon` before the value. |\n| `cellFlash` | A brief flash on the cell that fired. |\n| `preventEdit` | Marks a validation rule as a veto; a column with `rejectInvalid` refuses the write. |\n| `log` | Records the firing in the alert log only. |\n\nEvery firing is recorded in the log regardless of action, so the bell badge and\nthe [alerts panel](#the-fired-alert-log) always reflect activity.\n\nMessages are templates: `{value}`, `{column}`, `{rule}`, `{severity}`, and\n`{field}` / `{row.field}` for any row field.\n\n```ts\nconst rule: AlertRule = {\n id: 'price-spike',\n name: 'Price over 700',\n enabled: true,\n severity: 'warning',\n scope: 'row',\n predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 700 },\n trigger: { type: 'dataChange' },\n actions: [\n { kind: 'toast', message: '{name} crossed 700 -> {value}' },\n { kind: 'highlight', style: { background: '#fef3c7', color: '#92400e' } },\n ],\n createdAt: Date.now(),\n}\n```\n\nSeed rules with the `rules` prop (used only when storage is empty), or let users\nbuild them in the manager.\n\n## Persistence and sharing\n\nPass `storageKey` to persist rules in `localStorage`; omit it for in-memory\nrules. The manager's **Export** / **Import** buttons round-trip the whole rule\nset as JSON, so a team lead can hand a set of alerts to colleagues. Under the\nhood this is the same pluggable-storage shape as\n[saved views](../recipes/saved-views.md):\n\n```ts\nimport { createAlertRules, localStorageAlertRules } from '@svgrid/enterprise'\n\nconst rules = createAlertRules(localStorageAlertRules('app:alerts'))\nrules.save(rule)\nconst json = rules.export() // share\nrules.import(json) // load\n```\n\n## Headless usage\n\nNot on Svelte, or want to run the engine yourself? `attachAlertEngine` observes\na data source and routes fired events to their side effects, returning the\nconditional formats + flash targets to apply:\n\n```ts\nimport { attachAlertEngine, localStorageAlertRules } from '@svgrid/enterprise'\n\n// Pass your row type: without it TData defaults to Record<string, unknown>\n// and `r.id` comes back as `unknown`.\nconst attach = attachAlertEngine<Row>({\n rules,\n getRowId: (r) => r.id,\n getData: () => currentRows,\n applyFormats: (formats) => setGridFormats(formats),\n})\n// later\nattach.detach()\n```\n\nThe pure `createAlertEngine` (no DOM, no timers) is exported too if you want full\ncontrol over evaluation.\n\n## Performance and large / live datasets\n\nAlerts are built to stay out of the grid's way. Two things make that true:\n\n- **Off-frame evaluation.** Every pass is deferred to a post-paint animation\n frame and coalesced, so a burst of updates costs one pass and the grid never\n waits on alerts to render.\n- **Change-scoped work.** Prev-value snapshots are only kept when a rule actually\n needs them (a `relativeChange` trigger). A rule set of pure `dataChange` /\n `aggregate` rules keeps none.\n\n### Watch mode (default)\n\nBy default the overlay reactively scans `data` when it changes, diffing the new\narray against the previous one to find the changed rows. Because Svelte replaces\nchanged rows immutably (unchanged rows keep their reference), the diff evaluates\nonly the rows that moved. This is the zero-config path and is fine for most grids.\n\n### Push mode (streaming / 100k+ rows)\n\nWhen your app already knows which rows changed - a streaming feed, a transaction,\na tick loop - hand that set straight to the overlay and skip the scan entirely.\nSet `watch={false}`, capture the handle with `onReady` (or `bind:this`), and call\n`pushChanged` with just the changed rows. Cost is then O(rows that changed),\nindependent of total row count.\n\n```svelte\n<script lang=\"ts\">\n let rows = $state.raw(data)\n let alerts: { pushChanged: (rows: readonly Row[]) => void } | null = null\n\n function onTick(changed: Row[]) {\n rows = applyChanges(rows, changed) // your immutable update\n alerts?.pushChanged(changed) // evaluate only these, next frame\n }\n</script>\n\n<SvGridAlerts\n data={rows}\n columns={exprColumns}\n getRowId={(r) => r.id}\n watch={false}\n onReady={(h) => (alerts = h)}\n bind:formats={alertFormats} />\n```\n\nThe handle also exposes `reseed(allRows)` (silently re-arm edges after a full data\nreset) and `flush()` (run any pending pass immediately). For big live feeds, prefer\n`dataChange` rules (fire once when a row crosses the line) over `relativeChange`\n(fires on every move), and keep actions toast-only to avoid conditional-format\nchurn.\n\nOn a busy feed many rows can cross a threshold every second. Evaluation stays cheap,\nbut a nonstop stream of toasts is disruptive and repaints constantly. Set\n`toastCooldownMs` to rate-limit toasts to at most one per rule per interval - every\nevent is still logged (the bell badge stays accurate), only the visible toast is\nthrottled:\n\n```svelte\n<SvGridAlerts ... watch={false} onReady={(h) => (alerts = h)} toastCooldownMs={6000} />\n```\n\n## The fired-alert log\n\nThe bell badge shows the unacknowledged count. Clicking it opens\n`SvAlertsPanel` - a drawer listing fired alerts newest-first, filterable by\nseverity, with **Acknowledge**, **Clear**, and **Go to row** (wire `onJump` to\nscroll/select the row).\n\n## More examples\n\n### Styling rules\n\nAlert rules are not just notifications: a highlight or badge action becomes live conditional formatting, painted through the grid's own format pipeline. A server fleet lights up by rule - hot CPU turns amber, near-full disks turn red via a cross-column rule (used / total > 0.9). Randomise the load and the colours follow. Add your own styling rule in the visual builder.\n\n<div data-docs-demo=\"400-alert-styling-rules\" data-height=\"520\"></div>\n\n### KPI & aggregate alerts\n\nAlerts that watch a whole-table total, not just a row. An aggregate-scope rule fires once when SUM(revenue) crosses the company target; a row rule flags any region trailing its own target. Close a few deals and watch the aggregate alert fire the moment the total clears the line. Aggregate rules use the expression language's SUM / AVG / COUNT reducers.\n\n<div data-docs-demo=\"401-alert-aggregate-kpi\" data-height=\"520\"></div>\n\n### Validation guardrails\n\nA validation-trigger alert is evaluated on edit; through the column's `validate` hook and `rejectInvalid` it refuses the change. A budget sheet is editable, and two guardrail rules block bad edits: a negative amount, or an amount over that line's budget. The pure engine is wired into the grid's per-column validate hook - the blessed integration point for prevent-edit. Toggle a guardrail off to allow the edit through.\n\n<div data-docs-demo=\"402-alert-validation-guardrails\" data-height=\"520\"></div>\n\n### Alerts operations console\n\nA full monitoring app: an Outlook-style navigation pane (SvNavPane) on the left - a module rail (Monitor / Rules / Reports) plus badged folders (Critical / Warnings / Healthy and per-region) - filtering a live fleet grid on the right. Alert rules watch every host regardless of the folder you are viewing, paint the rows, and feed the bell and the fired-alert panel. Module buttons open the rule manager and the report panel.\n\n<div data-docs-demo=\"403-alert-console-navpane\" data-height=\"520\"></div>\n\n## See also\n\n- [Expression query language](./expressions-query.md) - the predicate language rules are built on.\n- [Highlighting changes](./cells/highlighting-changes.md) - the `cellFlash` primitive alerts build on.\n- [Conditional formatting](./cells/conditional-formatting.md) - the styling pipeline alerts paint through.\n"
|
|
4213
|
+
"markdown": "# Alerts - Enterprise\n\nAlert rules let your users say \"tell me when the data does X\" - and then act on\nit automatically: raise a toast, tint the row, flash the cell, block the edit,\nor just log it. Rules are authored at runtime in a no-code builder, persisted,\nand shareable as JSON. No redeploy to add an alert.\n\nFor the rules built up on a grid that moves, with a live example under most steps, see [Alerts: rules over live data](./alerts/start.md); this page is the reference.\n\nAlerts ship in the paid `@svgrid/enterprise` package. They build on the grid's\nown engines: predicates reuse the same operators as the filter row\n(`applyExcelFilter`), styling paints through the conditional-format pipeline,\nand notifications go through the grid's toast store.\n\n<div data-docs-demo=\"399-alert-rules-engine\" data-height=\"440\"></div>\n\n## Setup\n\nMount `<SvGridAlerts>` next to your grid and spread its `formats` output into the\ngrid's `conditionalFormats`. The overlay watches your data reactively, runs the\nrule engine on every change, and paints matches back through the grid. It diffs\nsnapshots of the data rather than listening to a single edit event, so it reacts\nto streaming feeds and programmatic updates as well as in-grid edits (the grid's\nown `onCellValueChange` callback covers only the latter).\n\nEvaluation never runs inside the grid's render frame: a data change schedules one\npass on the next animation frame (post-paint), so the grid always paints first.\nOn very large or fast-moving datasets, drive it in [push mode](#performance-and-large--live-datasets)\nso each pass costs only the rows that actually changed.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import type { ConditionalFormat } from '@svgrid/grid/format'\n import { SvGridAlerts, enableAlerts, setLicenseKey, type ExprColumn } from '@svgrid/enterprise'\n\n setLicenseKey('YOUR-KEY')\n enableAlerts()\n\n let rows = $state.raw(data)\n let alertFormats = $state<ConditionalFormat<Row>[]>([])\n\n // Columns the rule/expression editors offer.\n const exprColumns: ExprColumn[] = [\n { id: 'price', name: 'Price', type: 'number' },\n { id: 'region', name: 'Region', type: 'text' },\n ]\n</script>\n\n<SvGridAlerts\n data={rows}\n columns={exprColumns}\n getRowId={(r) => r.id}\n storageKey=\"app:alerts\"\n bind:formats={alertFormats} />\n\n<SvGrid data={rows} {columns} getRowId={(r) => r.id}\n conditionalFormats={alertFormats} />\n```\n\n`<SvGridAlerts>` renders a small control group: a bell (opens the fired-alert\nlog) and an **Alerts** button (opens the rule manager). Set `controls={false}`\nto hide them and drive the panels yourself with `bind:panelOpen` /\n`bind:managerOpen`.\n\n## The rule model\n\nAn `AlertRule` has four moving parts:\n\n| Part | What it is |\n| --- | --- |\n| **predicate** | A boolean [expression](./expressions-query.md) - when it holds, the rule matches. |\n| **trigger** | *When* the rule is checked (see below). |\n| **scope** | `row`, `cell`, or `aggregate`. |\n| **actions** | What happens when it fires. |\n\n### Triggers\n\n- **`dataChange`** - fires on the true edge: when a row *newly* satisfies the\n predicate after an edit or feed update. It will not re-fire while the row keeps\n matching; it re-arms once the row stops matching.\n- **`relativeChange`** - fires when a value *moves*: an absolute delta, a percent\n change, or crossing a threshold. Uses the previous snapshot of the row.\n- **`validation`** - evaluated on edit; with a `preventEdit` action the engine's\n `validateEdit` answers `vetoed`, which a column with `validate` and\n `rejectInvalid: true` turns into a refused write (`validate` alone only\n flags the cell).\n- **`scheduled`** - re-checked on a cron schedule (reuses the enterprise\n scheduler), surfacing rows that currently match.\n\n### Actions\n\n| Kind | Effect |\n| --- | --- |\n| `toast` | A toast in the rule's severity colour. |\n| `highlight` | Tints matching rows/cells (persists while they match). |\n| `badge` | Colours the targeted cells and draws its `icon` before the value. |\n| `cellFlash` | A brief flash on the cell that fired. |\n| `preventEdit` | Marks a validation rule as a veto; a column with `rejectInvalid` refuses the write. |\n| `log` | Records the firing in the alert log only. |\n\nEvery firing is recorded in the log regardless of action, so the bell badge and\nthe [alerts panel](#the-fired-alert-log) always reflect activity.\n\nMessages are templates: `{value}`, `{column}`, `{rule}`, `{severity}`, and\n`{field}` / `{row.field}` for any row field.\n\n```ts\nconst rule: AlertRule = {\n id: 'price-spike',\n name: 'Price over 700',\n enabled: true,\n severity: 'warning',\n scope: 'row',\n predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 700 },\n trigger: { type: 'dataChange' },\n actions: [\n { kind: 'toast', message: '{name} crossed 700 -> {value}' },\n { kind: 'highlight', style: { background: '#fef3c7', color: '#92400e' } },\n ],\n createdAt: Date.now(),\n}\n```\n\nSeed rules with the `rules` prop (used only when storage is empty), or let users\nbuild them in the manager.\n\n## Persistence and sharing\n\nPass `storageKey` to persist rules in `localStorage`; omit it for in-memory\nrules. The manager's **Export** / **Import** buttons round-trip the whole rule\nset as JSON, so a team lead can hand a set of alerts to colleagues. Under the\nhood this is the same pluggable-storage shape as\n[saved views](../recipes/saved-views.md):\n\n```ts\nimport { createAlertRules, localStorageAlertRules } from '@svgrid/enterprise'\n\nconst rules = createAlertRules(localStorageAlertRules('app:alerts'))\nrules.save(rule)\nconst json = rules.export() // share\nrules.import(json) // load\n```\n\n## Headless usage\n\nNot on Svelte, or want to run the engine yourself? `attachAlertEngine` observes\na data source and routes fired events to their side effects, returning the\nconditional formats + flash targets to apply:\n\n```ts\nimport { attachAlertEngine, localStorageAlertRules } from '@svgrid/enterprise'\n\n// Pass your row type: without it TData defaults to Record<string, unknown>\n// and `r.id` comes back as `unknown`.\nconst attach = attachAlertEngine<Row>({\n rules,\n getRowId: (r) => r.id,\n getData: () => currentRows,\n applyFormats: (formats) => setGridFormats(formats),\n})\n// later\nattach.detach()\n```\n\nThe pure `createAlertEngine` (no DOM, no timers) is exported too if you want full\ncontrol over evaluation.\n\n## Performance and large / live datasets\n\nAlerts are built to stay out of the grid's way. Two things make that true:\n\n- **Off-frame evaluation.** Every pass is deferred to a post-paint animation\n frame and coalesced, so a burst of updates costs one pass and the grid never\n waits on alerts to render.\n- **Change-scoped work.** Prev-value snapshots are only kept when a rule actually\n needs them (a `relativeChange` trigger). A rule set of pure `dataChange` /\n `aggregate` rules keeps none.\n\n### Watch mode (default)\n\nBy default the overlay reactively scans `data` when it changes, diffing the new\narray against the previous one to find the changed rows. Because Svelte replaces\nchanged rows immutably (unchanged rows keep their reference), the diff evaluates\nonly the rows that moved. This is the zero-config path and is fine for most grids.\n\n### Push mode (streaming / 100k+ rows)\n\nWhen your app already knows which rows changed - a streaming feed, a transaction,\na tick loop - hand that set straight to the overlay and skip the scan entirely.\nSet `watch={false}`, capture the handle with `onReady` (or `bind:this`), and call\n`pushChanged` with just the changed rows. Cost is then O(rows that changed),\nindependent of total row count.\n\n```svelte\n<script lang=\"ts\">\n let rows = $state.raw(data)\n let alerts: { pushChanged: (rows: readonly Row[]) => void } | null = null\n\n function onTick(changed: Row[]) {\n rows = applyChanges(rows, changed) // your immutable update\n alerts?.pushChanged(changed) // evaluate only these, next frame\n }\n</script>\n\n<SvGridAlerts\n data={rows}\n columns={exprColumns}\n getRowId={(r) => r.id}\n watch={false}\n onReady={(h) => (alerts = h)}\n bind:formats={alertFormats} />\n```\n\nThe handle also exposes `reseed(allRows)` (silently re-arm edges after a full data\nreset) and `flush()` (run any pending pass immediately). For big live feeds, prefer\n`dataChange` rules (fire once when a row crosses the line) over `relativeChange`\n(fires on every move), and keep actions toast-only to avoid conditional-format\nchurn.\n\nOn a busy feed many rows can cross a threshold every second. Evaluation stays cheap,\nbut a nonstop stream of toasts is disruptive and repaints constantly. Set\n`toastCooldownMs` to rate-limit toasts to at most one per rule per interval - every\nevent is still logged (the bell badge stays accurate), only the visible toast is\nthrottled:\n\n```svelte\n<SvGridAlerts ... watch={false} onReady={(h) => (alerts = h)} toastCooldownMs={6000} />\n```\n\n## The fired-alert log\n\nThe bell badge shows the unacknowledged count. Clicking it opens\n`SvAlertsPanel` - a drawer listing fired alerts newest-first, filterable by\nseverity, with **Acknowledge**, **Clear**, and **Go to row** (wire `onJump` to\nscroll/select the row).\n\n## Conditions from another module\n\nA predicate can hold a condition another module evaluates: an `ext` node,\n`{ kind: 'ext', type, payload }`. SvGrid Base registers one, `baseCondition(filter)`,\nso a rule over a base grid can use the view bar's filter as its condition.\nRegister a kind of your own with `registerPredicateKind`:\n\n```ts\nimport { registerPredicateKind } from '@svgrid/enterprise'\n\nregisterPredicateKind('hasTag', {\n // The payload as a row test; null for an empty condition (matches every row).\n compile: (payload, { getValue }) => {\n const { column, tag } = payload as { column: string; tag: string }\n return (row) => ((getValue(row, column) as string[]) ?? []).includes(tag)\n },\n // How the rule list shows it, with the column's name.\n describe: (payload, name) => `${name((payload as { column: string }).column)} has ${(payload as { tag: string }).tag}`,\n // The columns it reads: `{value}` and `{column}` come from the first.\n columns: (payload) => [(payload as { column: string }).column],\n})\n```\n\nA type no module registered matches nothing. To write such a condition in\nthe rule editor, set `setAlertConditionEditor` in a component around\n`<SvGridAlerts>`: its `component` edits the predicate in place of the\nexpression editor for the predicates its `accepts` takes.\n\n`<SvGridAlerts token>` resolves message tokens the built-in ones do not\ncover, such as a field named with spaces (`{Due date}`). The overlay's\n`onRulesChange` hands over the list after each edit in the manager.\n\nFor a base, its own [Automations](./base/automations.md) do what alerts do\nand can also change records.\n\n## More examples\n\n### Styling rules\n\nAlert rules are not just notifications: a highlight or badge action becomes live conditional formatting, painted through the grid's own format pipeline. A server fleet lights up by rule - hot CPU turns amber, near-full disks turn red via a cross-column rule (used / total > 0.9). Randomise the load and the colours follow. Add your own styling rule in the visual builder.\n\n<div data-docs-demo=\"400-alert-styling-rules\" data-height=\"520\"></div>\n\n### KPI & aggregate alerts\n\nAlerts that watch a whole-table total, not just a row. An aggregate-scope rule fires once when SUM(revenue) crosses the company target; a row rule flags any region trailing its own target. Close a few deals and watch the aggregate alert fire the moment the total clears the line. Aggregate rules use the expression language's SUM / AVG / COUNT reducers.\n\n<div data-docs-demo=\"401-alert-aggregate-kpi\" data-height=\"520\"></div>\n\n### Validation guardrails\n\nA validation-trigger alert is evaluated on edit; through the column's `validate` hook and `rejectInvalid` it refuses the change. A budget sheet is editable, and two guardrail rules block bad edits: a negative amount, or an amount over that line's budget. The pure engine is wired into the grid's per-column validate hook - the blessed integration point for prevent-edit. Toggle a guardrail off to allow the edit through.\n\n<div data-docs-demo=\"402-alert-validation-guardrails\" data-height=\"520\"></div>\n\n### Alerts operations console\n\nA full monitoring app: an Outlook-style navigation pane (SvNavPane) on the left - a module rail (Monitor / Rules / Reports) plus badged folders (Critical / Warnings / Healthy and per-region) - filtering a live fleet grid on the right. Alert rules watch every host regardless of the folder you are viewing, paint the rows, and feed the bell and the fired-alert panel. Module buttons open the rule manager and the report panel.\n\n<div data-docs-demo=\"403-alert-console-navpane\" data-height=\"520\"></div>\n\n## See also\n\n- [Expression query language](./expressions-query.md) - the predicate language rules are built on.\n- [Highlighting changes](./cells/highlighting-changes.md) - the `cellFlash` primitive alerts build on.\n- [Conditional formatting](./cells/conditional-formatting.md) - the styling pipeline alerts paint through.\n"
|
|
4046
4214
|
},
|
|
4047
4215
|
{
|
|
4048
4216
|
"slug": "help/alerts/start",
|
|
@@ -4065,6 +4233,104 @@ export const docs = [
|
|
|
4065
4233
|
"section": "Help",
|
|
4066
4234
|
"markdown": "# Architecture overview\n\nA one-page mental model that should let you reason about every other\ntopic in the docs. SvGrid is a strict three-layer system - if you know\nwhich layer a piece of code lives in, you know what it can and cannot\ndo.\n\n\n\n## The three layers\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Layer 3 │ <SvGrid> render component (Svelte 5) │\n│ │ - DOM, scroll, virtualization, editor popovers │\n│ │ - keyboard handlers, pointer events │\n│ │ - sticks the headless engine to a viewport │\n├───────────┼─────────────────────────────────────────────────┤\n│ Layer 2 │ Headless engine (createSvGrid) │\n│ │ - column model + row model pipeline │\n│ │ - sort, filter, group, paginate, expand │\n│ │ - aggregators, accessors, comparators │\n│ │ - 100% pure functions, no DOM │\n├───────────┼─────────────────────────────────────────────────┤\n│ Layer 1 │ Your data + your column definitions │\n│ │ - the only thing YOU author │\n│ │ - plain TypeScript: arrays, objects, types │\n└───────────┴─────────────────────────────────────────────────┘\n```\n\nLayer 1 is yours. Layer 2 is `@svgrid/grid` minus the renderer.\nLayer 3 is the `<SvGrid>` component everyone uses by default.\n\n**You can use Layer 2 without Layer 3.** That's the headless promise:\nif you want to render the grid yourself - in Tailwind cards, a print\nPDF template, a custom virtualization layer - import `createSvGrid`\nand read the state directly.\n\n## Data flow on every render\n\n```\nraw data ──► engine pipeline ──► visible rows ──► renderer\n (you) (Layer 2) (Layer 2 out) (Layer 3)\n │\n ▼\n ┌────────────────────────────┐\n │ 1. coreRowModel │ shape data into Row objects\n │ 2. filteredRowModel │ apply column + global filters\n │ 3. sortedRowModel │ apply sort spec\n │ 4. groupedRowModel │ apply groupBy + aggregators\n │ 5. expandedRowModel │ flatten expanded groups\n │ 6. paginatedRowModel │ slice the visible page\n └────────────────────────────┘\n```\n\nEvery \"feature\" you register in `tableFeatures({ ... })` plugs one or\nmore row models into this pipeline. Disable a feature and that stage\nno-ops. The pipeline runs once per state change, **not per scroll\nframe** - virtualization is purely a presentational concern.\n\n## The two APIs you'll use\n\n### Declarative (the `<SvGrid>` props)\n\n99% of consumers stop here. You author `data`, `columns`, and `features`,\nthen handle events from props:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onCellValueChange={handleChange}\n onActiveCellChange={handleFocus}\n/>\n```\n\n### Imperative (`SvGridApi`)\n\nFor toolbars, ribbons, keyboard shortcuts that need to drive the grid,\nask for the API via `onApiReady`:\n\n```svelte\n<SvGrid\n ...\n onApiReady={(api) => {\n api.setSort('name', 'asc')\n api.setFilter('region', { operator: 'equals', value: 'EMEA' })\n }}\n/>\n```\n\nSee the [API reference](https://svgrid.com/api/) for the full surface.\n\n## State ownership\n\nTwo questions decide where each piece of state lives:\n\n| Question | Lives in |\n| ------------------------------------------ | ------------------------- |\n| Does it change row content or cell values? | **Your component** (`$state`) - hand the new array down to `data`. |\n| Is it a column/grid setting (sort, filter, group, page)? | **The engine** owns it. Use `api.setSort(...)` etc., or pass an initial state. |\n| Is it a visual concern (column widths, hover)? | **The renderer** owns it. The grid manages this internally. |\n\nThis split is deliberate: the engine is dataless, so it can't \"lose\"\nyour rows. Your component is rendererless, so it can't accidentally\nmutate DOM nodes during a sort.\n\n## Where each topic page sits\n\n| Topic | Layer | Why |\n| ------------------------------------ | ------ | ------------------------------------------------------------------------- |\n| [Column definitions](./columns/column-definitions.md) | 1 | Pure types you author. |\n| [Row data](./rows/row-data.md) | 1 | Your input. |\n| [Row sorting](./rows/row-sorting.md) | 2 | Engine row-model. |\n| [Filtering overview](./filtering/overview.md) | 2 + 3 | Engine for the pipeline; renderer for the popovers + filter row. |\n| [Row pagination](./rows/row-pagination.md) | 2 | Engine slice. |\n| [Editing](./editing/overview.md) | 3 | DOM editors live in the renderer. |\n| [Tree rows](./rows/tree-rows.md) | 1 + 3 | You derive `visibleRows`; the renderer indents + draws chevrons. |\n| [Pivot tables](./pivot.md) | 1 + 2 | You build the pivot engine; the renderer uses standard nested headers. |\n| [AI assistant](./ai.md) | 2 | Pure helpers; the renderer never sees them. |\n| [Export / import](./export.md), [import](./import.md) | 2 + 3 | Helpers + browser-side file IO. |\n\n## Why this matters for shipping\n\n- **You can test Layer 2 without a DOM.** Every engine helper is a\n pure function. Vitest in node, no jsdom required. See\n [Testing your grid](./testing.md).\n- **You can swap Layer 3.** If your design system has its own table\n primitive, drop `<SvGrid>` and read from `createSvGrid()` directly.\n- **Layer 2 is the public API surface.** Imports, exports, and types\n are versioned per the [API stability](./api-stability.md) policy.\n The `--sg-*` tokens are versioned too (the core set is not renamed in a minor).\n The renderer's CSS classes and DOM structure are NOT - override them at your peril.\n\n## Where the layers physically live\n\n| Layer | Source path | Build output |\n| ----- | ------------------------------------------------- | ------------------------------------ |\n| 1 | Your app | n/a |\n| 2 | `packages/grid/src/core.ts` + row-models | `dist/headless.js`, the `@svgrid/grid/core` entry (size in the [bundle size reference](./bundle-size.md)) |\n| 3 | `packages/grid/src/SvGrid.svelte` | `dist/index.js`, bundled with the engine (size in the same reference) |\n| | `packages/enterprise/src/{export,print,import,ai}.ts` | `@svgrid/enterprise/dist/*` (lazy-loaded peers) |\n\n## Frequently asked questions\n\n### How is SvGrid architected?\n\nAs three strict layers: a headless core engine (state + row-model pipeline), a\nSvelte render component (`<SvGrid>`) that draws the DOM, and your application\ncode. Knowing which layer a piece of code lives in tells you what it can and\ncannot do.\n\n### What does \"headless\" mean for SvGrid?\n\nThe core engine computes sorting, filtering, grouping, and selection state\nwithout rendering anything. You can drive your own markup with it, or drop in the\nbatteries-included `<SvGrid>` component that renders on top of the same engine.\n\n### Can I use the engine without the SvGrid component?\n\nYes. Use `createSvGrid` and the row-model factories directly to build a custom\nrendering layer. The render component is optional sugar over the same public\nengine API.\n\n## Layer 2 in practice\n\nThe headless engine with no `<SvGrid>` anywhere: it owns sorting and filtering\nand hands back a row model, and the markup below is an ordinary table you wrote.\nThis is the same engine layer 3 renders.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th onclick={h.column.getToggleSortingHandler()}>\n {h.column.columnDef.header}\n {h.column.getIsSorted() === 'desc' ? ' v' : h.column.getIsSorted() ? ' ^' : ''}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\nClick a header. The sort lives in your `$state`, the engine derives the row\nmodel from it, and nothing about the markup came from us.\n\n## See also\n\n- [Why headless?](../why-headless.md) - the design rationale for the\n Layer 2 / Layer 3 split.\n- [API reference](https://svgrid.com/api/) - every export with its layer\n noted.\n- [Performance benchmarks](./benchmarks.md) - numbers from each layer\n in isolation.\n"
|
|
4067
4235
|
},
|
|
4236
|
+
{
|
|
4237
|
+
"slug": "help/base/automations",
|
|
4238
|
+
"path": "docs/help/base/automations.md",
|
|
4239
|
+
"title": "Automations",
|
|
4240
|
+
"section": "SvGrid Base",
|
|
4241
|
+
"markdown": "# Automations\n\nAn automation does something on its own when something happens to a\nrecord: a task set to Done ticks its Done box, a new request goes to Todo, a\nblocked task tells its owner, a button starts the work. Each table of a base\nhas its own, under \"Automations\" in the view bar.\n\n<div data-docs-demo=\"504-project-tracker\" data-height=\"680\"></div>\n\nIn the demo, set a task's Status to Done, press Start in the Kick off\ncolumn, or send the Request a task form, then open \"5 automations\" and look\nat Runs.\n\n\n\n## Add one\n\nOpen \"Automations\" in the view bar and press \"+ New automation\". The dialog\nhas three parts:\n\n- **When**: what sets it off.\n- **Only for records where**: a condition written with the view bar's\n filter, so it compares a select's option, a person, a linked record's name\n or \"within the past week\" the way the filter does. Leave it empty for\n every record.\n- **Do**: the actions, in order.\n\n\n\nEvery change is kept as you make it. \"Run now\" tries the automation on the\nrecord you pick, as if its trigger had happened.\n\n## When\n\n| Trigger | Runs |\n|---|---|\n| A record is created | When a record is added: \"+ Add row\", a form, a board lane, a calendar day, another automation |\n| A record changes | When a record is edited; pick fields to run only when one of them changes. A link made from the other table counts: adding a guest to an event from Guests changes the event's Guests field |\n| A record matches a condition | When a record starts to match the condition. It does not run again while the record keeps matching |\n| A form is sent | When a form view sends a record; pick the form, or any form of the table |\n| A button is pressed | When a Button field is pressed on a record |\n| At a scheduled time | Every day at 9, every Monday, every hour, or a cron expression: once for each record that matches the condition then, or once with no record |\n\nA paste or a fill that changes several cells of one record is one change to\nit.\n\n## Do\n\n| Action | What it does |\n|---|---|\n| Update the record | Sets fields of the record that set it off |\n| Create a record | Adds a record to any table of the base; \"Link it back\" fills a link field of the new record with the record that set it off |\n| Show a notice | A toast; `{Field name}` is that field of the record |\n| Color the record while it matches | Colors the whole row, or some fields, for as long as the record matches the condition |\n| Call a webhook | POSTs the record (or a body you write) to a URL: a team chat, your API. Secrets as `{secret:NAME}`, read on the server only |\n| Send an email | To, subject and text with field names; sent by the [server](#on-the-server) |\n| Your own actions | Anything the app adds with `registerAutomationAction` |\n\nA webhook's URL, headers and body name fields in braces, and secrets as\n`{secret:NAME}`; leave the body empty to send the record as JSON:\n\n\n\nValues are written as the field shows them:\n\n| Field | Write |\n|---|---|\n| Single or multiple select | The option's name: `Done`, `Bug, Risk` |\n| Person | The person's name: `Ana Ruiz` |\n| Checkbox | `true` or `false` |\n| Number | `42`; in a percent field, `100%` |\n| Date | `2026-11-01`, `today`, `today+7`, `today-1`, `now` |\n| Link | A record's name, several with commas, or `{record}` for the record that set it off |\n| Any text | Text with fields in braces: `{Task} follow-up` |\n| Any field | Nothing, to clear it |\n\n## Runs\n\nEvery run is listed under Runs, newest first, with what each action did and\nthe record it ran on. A failed action stops the run there and says why.\n\n\n\n## In code\n\nA table's automations are its `automations`, plain data you can store with\nthe rest of the table:\n\n```ts\nimport type { BaseAutomation } from '@svgrid/enterprise/base'\n\nconst statusIs = (value: string) => ({\n kind: 'fieldFilter' as const,\n conjunction: 'and' as const,\n conditions: [{ column: 'status', kind: 'select' as const, op: 'is' as const, value }],\n})\n\nexport const taskAutomations: BaseAutomation[] = [\n {\n id: 'done',\n name: 'Done closes the task',\n enabled: true,\n trigger: { type: 'recordUpdated', fields: ['status'] },\n condition: statusIs('Done'),\n actions: [\n { type: 'updateRecord', values: { done: 'true', progress: '100%' } },\n { type: 'notify', message: '{Task} is done', severity: 'success' },\n ],\n },\n {\n id: 'blocked',\n name: 'Open an issue for a blocked task',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: statusIs('Blocked'),\n actions: [{ type: 'createRecord', table: 'issues', values: { title: 'Unblock {Task}', owner: '{Assignee}' }, linkField: 'task' }],\n },\n]\n```\n\n```svelte\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n import { taskAutomations } from '$lib/automations'\n\n enableBase()\n let { tasks, issues }: { tasks: BaseTable; issues: BaseTable } = $props()\n let tables = $state.raw([{ ...tasks, automations: taskAutomations }, issues])\n</script>\n\n<SvBase bind:tables editable onTablesChange={(t) => console.log('save', t)} />\n<SvToaster />\n```\n\nAutomations edited in the dialog reach `onTablesChange` with the rest of the\ntable. Automations need `<SvBase tables>`; one table is a list of one.\nNotices need one `<SvToaster />` in the app.\n\n## Your own actions\n\n`registerAutomationAction` adds an action to the dialog's list. It runs in\nthe page, so call your own server for anything that needs a secret:\n\n```ts\nimport { registerAutomationAction } from '@svgrid/enterprise/base'\n\nregisterAutomationAction('post-to-chat', {\n label: 'Post to the team chat',\n run: async ({ record, text }) => {\n if (!record) return 'No record'\n const res = await fetch('/api/chat', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ message: `${text('Task')} needs a hand (${text('Assignee')})` }),\n })\n if (!res.ok) throw new Error(`Chat answered ${res.status}`)\n return 'Posted'\n },\n})\n```\n\nAn automation runs it with `{ type: 'run', action: 'post-to-chat' }`. What it\nreturns is the run's detail; what it throws is the run's error.\n\n## Where they run\n\nEach automation says where it runs, under \"Runs in\":\n\n- **This browser** (the default): in the page of the person who made the\n change, right away and once. A change that arrives from someone else\n through [realtime](./data.md#changes-made-elsewhere) does not run it again\n in your page. Scheduled ones run while the base is open.\n- **The server**: in your server, for every change to the database, whoever\n or whatever made it, with secrets and email. Set it up as below.\n\nEither way, a change an automation makes is an ordinary change: with a\n[source](./data.md), it is written to the database like an edit. What an\naction changes can set off other automations, up to four deep; past that the\nrun stops and says so, so two automations that answer each other cannot loop.\n\n## On the server\n\n\n\n`createAutomationServer` runs the automations set to \"The server\". It reads\nthe same tables, with the same automations, through sources of its own.\nKeep the tables in one module both sides import, each side handing in its\nown way to reach the data:\n\n```ts\n// src/lib/tables.ts: the tables, for the browser and for the server.\nimport { baseSource, type BaseTable } from '@svgrid/enterprise/base'\nimport type { ServerDataSource } from '@svgrid/grid'\n\nexport function trackerTables(source: (table: string) => ServerDataSource<Record<string, unknown>>): BaseTable[] {\n return [\n {\n id: 'tasks',\n name: 'Tasks',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Task', fieldType: 'text' },\n { field: 'status', header: 'Status', fieldType: 'singleSelect', fieldProps: { options: [{ value: 'Todo' }, { value: 'Blocked' }, { value: 'Done' }] } },\n { field: 'owner_email', header: 'Owner email', fieldType: 'email' },\n ],\n automations: [\n {\n id: 'blocked',\n name: 'Tell the team about a blocked task',\n enabled: true,\n runOn: 'server',\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'status', kind: 'select', op: 'is', value: 'Blocked' }] },\n actions: [\n { type: 'webhook', url: 'https://hooks.slack.com/services/{secret:SLACK_HOOK}', body: '{\"text\": \"{Task} is blocked\"}' },\n { type: 'email', to: '{Owner email}', subject: '{Task} is blocked', body: 'Someone marked {Task} as blocked.' },\n ],\n },\n ],\n source: baseSource(source('tasks')),\n },\n ]\n}\n```\n\nThe server side, in a SvelteKit app:\n\n```ts\n// src/lib/server/automations.ts\nimport pg from 'pg'\nimport { createAutomationServer } from '@svgrid/enterprise/base'\nimport { createSqlDataSource, type EntitySchema } from '@svgrid/enterprise'\nimport { env } from '$env/dynamic/private'\nimport { trackerTables } from '$lib/tables'\n\nconst pool = new pg.Pool({ connectionString: env.DATABASE_URL })\nconst schemas: Record<string, EntitySchema> = {\n tasks: {\n name: 'tasks',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'status', type: 'text' },\n { field: 'owner_email', type: 'text' },\n ],\n },\n}\n\n// Your mail provider's client.\ndeclare const mailer: { send: (m: { from: string; to: string[]; subject: string; text: string }) => Promise<unknown> }\n\nexport const automations = createAutomationServer({\n tables: trackerTables((table) =>\n createSqlDataSource({\n schema: schemas[table]!,\n table,\n dialect: { placeholders: '$', ilike: true },\n execute: async (sql, params) => (await pool.query(sql, params)).rows,\n }),\n ),\n // {secret:SLACK_HOOK} in a webhook reads SLACK_HOOK from the environment.\n secrets: (name) => env[name],\n sendEmail: (m) => mailer.send({ from: 'tracker@example.com', ...m }),\n onRun: (run) => {\n if (run.status !== 'ok') console.error('automation failed', run)\n },\n})\n```\n\nThen tell it about changes, in whichever way fits your app:\n\n- **Through your endpoint.** Pass its `audit` hook to `createKitHandlers`:\n every create, update and delete through the endpoint reaches it, with the\n record as stored. It compares with what it read last, so it needs a server\n that stays up between requests.\n\n ```ts\n // src/routes/api/[table]/+server.ts\n import { createKitHandlers, createSqlDataSource, type EntitySchema } from '@svgrid/enterprise'\n import { automations } from '$lib/server/automations'\n\n declare const schema: EntitySchema\n declare const source: ReturnType<typeof createSqlDataSource>\n\n export const { POST } = createKitHandlers({ schema, source, audit: automations.audit('tasks') })\n ```\n\n- **From the database.** A Supabase database webhook, or a Postgres trigger\n that posts the old and new row, hands over both sides of the change.\n Nothing has to stay in memory, so it suits serverless hosts too:\n\n ```ts\n // src/routes/api/automations/changes/+server.ts\n import { error } from '@sveltejs/kit'\n import { env } from '$env/dynamic/private'\n import { automations } from '$lib/server/automations'\n\n export async function POST({ request }: { request: Request }) {\n if (request.headers.get('x-webhook-secret') !== env.WEBHOOK_SECRET) error(401, 'No')\n // Supabase sends { type, table, record, old_record }.\n const change = await request.json()\n await automations.recordChanged(change.table, { before: change.old_record, after: change.record })\n return new Response('ok')\n }\n ```\n\n- **By reading the table again**, when all you know is that it changed:\n `automations.tableChanged('tasks')`.\n\nScheduled automations run on `tick()`. Call it once a minute: from a cron\njob your host runs against an endpoint, or a timer in a long-running server.\nTwo ticks in one minute run them once.\n\n```ts\n// src/routes/api/automations/tick/+server.ts: called by your host's cron every minute.\nimport { automations } from '$lib/server/automations'\n\nexport async function POST() {\n const runs = await automations.tick()\n return new Response(JSON.stringify({ ran: runs.length }))\n}\n```\n\nThe server's runs show in the dialog next to the page's, tagged Server,\nwhen the base can fetch them:\n\n```ts\n// src/routes/api/automations/runs/+server.ts\nimport { json } from '@sveltejs/kit'\nimport { automations } from '$lib/server/automations'\n\nexport function GET() {\n return json(automations.runs)\n}\n```\n\n```svelte\n<SvBase bind:tables editable serverRuns={() => fetch('/api/automations/runs').then((r) => r.json())} />\n```\n\n\n\nThe server ignores the echo of its own writes (the webhook the database\nsends for a change it made), so one change runs each automation once.\n\nThe [Postgres demo](./postgres.md) runs a server in the page, standing in\nfor yours: set a task to Blocked and its chat message shows under \"Sent by\nthe server\"; run `update tasks set status = 'Blocked' where id = 't9'` and it\nruns for that change too.\n\n## See also\n\n- [Keep a base in a database](./data.md)\n- [Field types](../fields.md): the view bar's filter, which conditions use\n"
|
|
4242
|
+
},
|
|
4243
|
+
{
|
|
4244
|
+
"slug": "help/base/collaboration",
|
|
4245
|
+
"path": "docs/help/base/collaboration.md",
|
|
4246
|
+
"title": "People, comments and files",
|
|
4247
|
+
"section": "SvGrid Base",
|
|
4248
|
+
"markdown": "# People, comments and files\n\nA tracker one person fills in needs a status column and little else. Once a\nteam uses it, people want to see who a task is assigned to, who changed the\nstatus and when, a thread of comments on the task, and the mockups attached\nto it, kept somewhere that survives a reload. This page adds those to the\ntask tracker from [Build a project tracker](./start.md).\n\n<div data-docs-demo=\"514-base-hiring-tracker\" data-height=\"680\"></div>\n\n> SvGrid Base is part of the **Suite edition** of `@svgrid/enterprise`. Without a key it runs with a watermark.\n\nIn the demo, double-click a candidate's card on the pipeline (or switch to\n\"All candidates\" and press Space on a row), change the Stage, leave a\ncomment, and watch the Activity column on the right of the record.\n\nThe base cannot know who your users are or where your files and comments\nlive. You tell it with four calls in the browser; the change log needs\nnothing:\n\n\n\nEach step below works on its own.\n\n## 1. List the people\n\nUser fields pick from one list of people that you hand to the base. A person\nis a `FieldUser`:\n\n```ts\nimport { setFieldUsers, type FieldUser } from '@svgrid/enterprise/base'\n\nconst team: FieldUser[] = [\n { id: 'u1', name: 'Ana Ruiz', email: 'ana@example.com', avatarUrl: 'https://cdn.example.com/ana.jpg' },\n { id: 'u2', name: 'Ben Okafor', email: 'ben@example.com' },\n { id: 'u3', name: 'Cleo Park', email: 'cleo@example.com' },\n]\n\n// In the browser only; step 2 shows where in SvelteKit.\nsetFieldUsers(team)\n```\n\nGive the tasks a `user` field for the owner and one with `multiple` for\nreviewers. The cell stores ids, never names, so renaming a person renames\nthem everywhere:\n\n```ts\nconst columns = [\n { field: 'task', header: 'Task', fieldType: 'text' },\n { field: 'status', header: 'Status', fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'assignee', header: 'Assignee', fieldType: 'user' },\n { field: 'reviewers', header: 'Reviewers', fieldType: 'user', fieldProps: { multiple: true } },\n]\n\nconst data = [\n { id: 't1', task: 'Write the brief', status: 'Todo', assignee: 'u1', reviewers: ['u2', 'u3'] },\n { id: 't2', task: 'Pick the fonts', status: 'In progress', assignee: 'u2', reviewers: [] },\n]\n```\n\nA cell draws an avatar and the name. With several people it overlaps up to\nfour avatars and writes the first name with a count: \"Ben Okafor +1\".\n`avatarUrl` is used when it is an `http(s):`, `blob:` or `data:image` URL;\nanything else, or no URL, draws the initials on a color worked out from the\nname, so a person keeps the same color on every page. An id that is not in\nthe list shows as the id itself.\n\nEditing a user cell opens a panel with a \"Find a person\" box and the people\nfrom the list, each with their avatar; typing filters them. If the list is\nempty the panel says \"No people (setFieldUsers)\". Text pasted or filled into\na user cell is matched against each person's id, name and email, ignoring\ncase; a multiple field takes them comma-separated and drops repeats. A name\nthat matches nobody stays as typed, so check pasted data. Copying a user cell\nwrites the names, so a copy and paste between records keeps the people.\n`fieldProps.default` takes an id for new rows. The field reference has the\n[user field's properties](../fields.md#declare-columns-by-type).\n\nA Kanban view can stack by a single user field (`stackBy: 'assignee'`): one\nlane per person in the list, plus \"Uncategorized\" while some task has nobody.\nDragging a card to a lane reassigns the task through the grid's edit path,\nso it counts as an edit.\n\n## 2. Say who is signed in\n\n`setCurrentUser` names the person using the page. Several things read it:\n\n- `createdBy` fills new rows with their id, and `lastModifiedBy` writes it\n on every edit it watches.\n- Each comment is saved with their id as `by`.\n- Each change in a record's activity is noted with their id.\n- A filter condition with the value `'@me'` (`ME`) matches them.\n- Your own code can read it with `getCurrentUser()`.\n\nIn SvelteKit the session is on the server, so load the person and the team\nin a layout and hand both to the base in the browser:\n\n```ts\n// src/routes/(app)/+layout.server.ts\nimport { redirect } from '@sveltejs/kit'\nimport type { LayoutServerLoad } from './$types'\nimport { db } from '$lib/server/db'\n\nexport const load: LayoutServerLoad = async ({ locals }) => {\n if (!locals.user) redirect(303, '/login')\n const team = await db.query(\n 'select id, name, email, avatar_url as \"avatarUrl\" from users where team_id = $1 order by name',\n [locals.user.teamId],\n )\n return { me: { id: locals.user.id, name: locals.user.name }, team: team.rows }\n}\n```\n\n```svelte\n<!-- src/routes/(app)/+layout.svelte -->\n<script lang=\"ts\">\n import type { Snippet } from 'svelte'\n import { setCurrentUser, setFieldUsers, type FieldUser } from '@svgrid/enterprise/base'\n\n let { data, children }: { data: { me: FieldUser; team: FieldUser[] }; children: Snippet } = $props()\n\n // Again when the data changes: a sign-in as someone else.\n $effect.pre(() => {\n setFieldUsers(data.team)\n setCurrentUser(data.me)\n })\n</script>\n\n{@render children()}\n```\n\nThe four setters on this page write state shared by the whole module, so\ncall them in the browser only: an `$effect` as here (it does not run during\nserver rendering), `onMount`, or behind `browser` from `$app/environment`.\nCalled at the top of a component's `<script>`, they would also run on the\nserver and hand one request's team and user to the next. Cells and pickers\nread both values reactively, so the base can draw before they arrive. The signed-in person does not have to be in the team list for\ntheir name to show, but they only appear in the picker if they are.\n\nNow add who made each task and when, and give a new task to whoever adds it:\n\n```ts\nimport { getCurrentUser, type BaseTable } from '@svgrid/enterprise/base'\n\nconst tasks: BaseTable = {\n id: 'tasks',\n name: 'Tasks',\n primaryField: 'task',\n data,\n columns: [\n ...columns,\n { field: 'createdBy', header: 'Created by', fieldType: 'createdBy' },\n { field: 'createdAt', header: 'Created', fieldType: 'createdTime', fieldProps: { dateFormat: 'friendly' } },\n ],\n props: {\n rowAdder: { label: 'Add task', defaults: () => ({ status: 'Todo', assignee: getCurrentUser()?.id ?? null }) },\n },\n}\n```\n\nBoth fields are read-only. Every record the base makes gets the current user\nand the time: \"+ Add row\", a form, the record menu, \"+ Add a card\" on a\nKanban, a calendar day. Last modified fields start out the same way. Rows\nyou load keep whatever your database sends.\n\nOn sign-out, call `closeRecord()`, then `setCurrentUser(null)` and\n`setFieldUsers([])`.\n\n## 3. Add a \"My tasks\" view\n\nA filter condition on a user field compares it with a person's id. The value\n`'@me'` (exported as `ME`) stands for whoever is signed in when the filter\nruns, so one view works for everyone:\n\n```ts\nimport { ME, type FieldView } from '@svgrid/enterprise/base'\n\nconst views: FieldView[] = [\n {\n id: 'mine',\n name: 'My tasks',\n type: 'grid',\n state: {\n advancedFilter: {\n kind: 'fieldFilter',\n conjunction: 'or',\n conditions: [\n { column: 'assignee', kind: 'select', op: 'is', value: ME },\n { column: 'reviewers', kind: 'multi', op: 'hasAnyOf', value: [ME] },\n ],\n },\n },\n },\n { id: 'all', name: 'All tasks', type: 'grid' },\n]\n```\n\nIn the Filter panel the person picker lists \"Me\" first, so users can build\nthe same view themselves. The condition reads \"Assignee is me\".\n\nA single user field filters with the select operators (`is`, `isNot`,\n`isAnyOf`, `isNoneOf`), a multiple one with `hasAnyOf`, `hasAllOf`,\n`isExactly` and `hasNoneOf`; both also have `isEmpty` and `isNotEmpty`.\nCreated by and last modified by filter the same way, so \"Created by is me\"\nworks too. The [filter conditions](../fields.md#filter-conditions) table\nlists them all.\n\nThe view stores `'@me'`, not an id, so it can be saved with the other views\n(`onViewsChange`, or the `views` inside `onTablesChange`) and Ben sees his own\ntasks when he opens it.\n\n## 4. Open a record\n\n`expandRows` with `showRowNumbers` turns the row number into a checkbox and an\nexpand button:\n\n```svelte\n<SvBase bind:tables editable showRowNumbers expandRows onTablesChange={save} />\n```\n\nA record opens from the expand button, from Space on a selected cell (not a\ncheckbox cell, and only with `expandRows`), from \"Expand record\" in the\nright-click menu, from a gallery card, from a double click (or F2) on a\nKanban card, from a calendar event and from a Gantt task.\n\n\n\nThe record shows:\n\n- The primary field as the title.\n- Every field with its type icon and name, drawn as its cell draws it. A click\n opens the cell's own editor; the value is written through the grid's edit\n path, so undo, validation, `onCellValueChange` and last-modified fields work\n the same as in the cell. Read-only fields say \"read-only\".\n- Fields hidden in the view, folded under \"Show N hidden fields\" and marked\n \"hidden in the grid\". They are read-only here.\n- Activity on the right: changes and comments, oldest first, with \"just now\",\n \"5 minutes ago\", \"3 hours ago\" or the date. The box under them takes a\n comment; Ctrl+Enter (Cmd+Enter) or Comment sends it. A base without\n `editable` still takes comments.\n- Previous and next buttons that step through the rows in the view's order,\n and \"3 of 12\".\n\nA record with comments shows how many by its row number (the pointer over the\nrow swaps the count for the checkbox and expand button) and as a badge on its\nKanban card. The card's badge opens the record with the comment box focused,\nas does \"Comment\" in the right-click menu of a row or a card.\n\nOn a narrow screen the activity moves under the fields. If an edit makes the\nrecord leave the view's filter, the record says \"This row is not in the\ngrid's view any more.\" and the footer \"Not in view (12 records)\". Previous\nand next still work: they move to the records that were before and after it.\n\nApp code opens a record with the grid api that `onApiReady` hands it:\n\n```ts\nimport { openRecord, closeRecord } from '@svgrid/enterprise/base'\n\nlet api: unknown\n// <SvBase ... onApiReady={(a) => (api = a)} />\n\nopenRecord({ api, rowId: 't1' })\nopenRecord({ api, rowId: 't1', focus: 'comment' }) // with the comment box focused\ncloseRecord(api)\n```\n\n`closeRecord()` closes whatever record is open, from anywhere: call it on\nsign-out or when the route changes. `closeRecord(api)` closes it only when it\nis open on that grid. A custom field type's `cell` component gets the grid\nas `ctrl` (with `row.id`), and `openRecord({ ctrl, rowId })` takes that too.\nA button field's `fieldProps.onClick` receives `{ row, rowId, columnId, api }`,\nso a button can open its own record.\n\n## 5. Keep comments in your backend\n\nWithout a store, comments live in memory and are gone on reload. A comment is\na `RecordComment`, and a store is two functions:\n\n```ts {nocheck}\nexport type RecordComment = { id: string; rowId: string; tableId?: string; text: string; at: number; by?: string }\n\nexport type RecordCommentContext = { tableId?: string; api: unknown }\n\nexport type RecordCommentStore = {\n list: (rowId: string, api: unknown, context: RecordCommentContext) => ReadonlyArray<RecordComment>\n add: (rowId: string, comment: RecordComment, api: unknown, context: RecordCommentContext) => void\n}\n```\n\n`rowId` is the record's id (its `id`, or the table's `getRowId`). In\n`<SvBase tables>`, `context.tableId` (and the comment's `tableId`) names the\ntable, so Tasks `1` and Milestones `1` are two records; with one table it is\nundefined. `at` is milliseconds since 1970, `by` the id of the person who was\nsigned in. The record view makes `id`, `at` and `by` itself and calls `add`.\nA store written for `list(rowId, api)` and `add(rowId, comment, api)` still\nworks.\n\nBoth functions are synchronous, and `list` runs often: for the open record,\nand for every row the grid draws (with `expandRows`) and every Kanban card,\nto show the comment count. So fetch a whole table's comments once into reactive state and answer\nfrom it. Because `list` runs inside the base's reactive code, the counts and\nthe record draw again when the fetch lands:\n\n```ts\n// src/lib/comments.svelte.ts\nimport { browser } from '$app/environment'\nimport { toast } from '@svgrid/grid'\nimport { setRecordComments, type RecordComment, type RecordCommentStore } from '@svgrid/enterprise/base'\n\ntype ByRecord = Record<string, ReadonlyArray<RecordComment>>\n\n// Each table's comments by record id, fetched once per table.\nlet byTable = $state.raw<Record<string, ByRecord>>({})\nconst asked = new Set<string>()\n\nfunction put(table: string, rowId: string, list: ReadonlyArray<RecordComment>) {\n byTable = { ...byTable, [table]: { ...byTable[table], [rowId]: list } }\n}\n\nasync function load(table: string) {\n asked.add(table)\n const res = await fetch(`/api/comments?table=${encodeURIComponent(table)}`)\n if (!res.ok) return void asked.delete(table)\n const grouped: Record<string, RecordComment[]> = {}\n for (const c of (await res.json()) as RecordComment[]) (grouped[c.rowId] ??= []).push(c)\n byTable = { ...byTable, [table]: grouped }\n}\n\nconst store: RecordCommentStore = {\n list(rowId, _api, { tableId = '' }) {\n if (!asked.has(tableId)) void load(tableId)\n return byTable[tableId]?.[rowId] ?? []\n },\n add(rowId, comment, _api, { tableId = '' }) {\n const mine = () => byTable[tableId]?.[rowId] ?? []\n // Shown at once; the server's copy replaces it, or it goes away again.\n put(tableId, rowId, [...mine(), comment])\n fetch('/api/comments', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ id: comment.id, tableId, rowId, text: comment.text }),\n })\n .then(async (res) => {\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const saved = (await res.json()) as RecordComment\n put(tableId, rowId, mine().map((c) => (c.id === comment.id ? saved : c)))\n })\n .catch(() => {\n put(tableId, rowId, mine().filter((c) => c.id !== comment.id))\n toast.error('Your comment was not saved')\n })\n },\n}\n\n// On the server this cache would be shared by every request.\nif (browser) setRecordComments(store)\n```\n\nImport the file once, in the page that shows the base. The failure notice\nneeds one `<SvToaster />` from `@svgrid/grid` in the app.\n`setRecordComments(null)` goes back to memory. For a table with a very large\ndiscussion, have the endpoint return counts per record and fetch a record's\ncomments when it opens.\n\nThe endpoint takes the author and the time from the server, never from the\nrequest body:\n\n```ts\n// src/routes/api/comments/+server.ts\nimport { error, json } from '@sveltejs/kit'\nimport type { RequestHandler } from './$types'\nimport { db } from '$lib/server/db'\n\nexport const GET: RequestHandler = async ({ url, locals }) => {\n if (!locals.user) error(401, 'Sign in first')\n const tableId = url.searchParams.get('table') ?? ''\n // `at` is a bigint column; the cast hands it back as a number, not a string.\n const { rows } = await db.query(\n 'select id, table_id as \"tableId\", row_id as \"rowId\", text, at::float8 as at, author as \"by\" from comments where team_id = $1 and table_id = $2 order by at',\n [locals.user.teamId, tableId],\n )\n return json(rows)\n}\n\nexport const POST: RequestHandler = async ({ request, locals }) => {\n if (!locals.user) error(401, 'Sign in first')\n const body = (await request.json()) as { id?: unknown; tableId?: unknown; rowId?: unknown; text?: unknown }\n const text = String(body.text ?? '').trim().slice(0, 4000)\n if (typeof body.id !== 'string' || typeof body.rowId !== 'string' || !text) error(400, 'Bad comment')\n const tableId = typeof body.tableId === 'string' ? body.tableId : ''\n const comment = { id: body.id, tableId, rowId: body.rowId, text, at: Date.now(), by: locals.user.id }\n await db.query(\n 'insert into comments (id, team_id, table_id, row_id, text, at, author) values ($1, $2, $3, $4, $5, $6, $7)',\n [comment.id, locals.user.teamId, comment.tableId, comment.rowId, comment.text, comment.at, comment.by],\n )\n return json(comment, { status: 201 })\n}\n```\n\nThe activity names the author from the people list: a `by` that is not in\n`setFieldUsers` (and is not the current user) shows as \"Someone\", and a\ncomment with no `by` shows as \"You\". Keep people who left the team in the\nlist, or return their name from your own data.\n\nComments other people add show once the cache changes. To see them while the\nbase is open, call `put` from your realtime channel or a poll.\n\n## 6. Upload files\n\nAn attachment cell holds an array of `Attachment`: `id`, `url`, `name`,\n`type` (the MIME type), and optional `size`, `width`, `height` and\n`thumbnailUrl` (a smaller image the cell uses instead of `url`). The\n[attachments section](../fields.md#attachments) of the field reference has\nan example value.\n\nWithout an uploader, added files stay in the page as `blob:` URLs and are\ngone on reload. `setAttachmentUploader` sets the function every attachment\ncolumn uses. It gets the `File` and an `AttachmentUploadContext`: `row` (the\nrecord), `columnId`, a `signal` that aborts when the user removes the file\nor closes the dialog, and `onProgress(fraction)` with a value from 0 to 1.\nIt returns the attachment.\n\nThis one asks your server for a presigned URL and sends the bytes straight\nto object storage. It uses `XMLHttpRequest` because `fetch` reports no upload\nprogress:\n\n```ts\n// src/lib/uploads.ts\nimport { browser } from '$app/environment'\nimport { setAttachmentUploader, type AttachmentUploader } from '@svgrid/enterprise/base'\n\nconst upload: AttachmentUploader = async (file, { columnId, signal, onProgress }) => {\n const res = await fetch('/api/uploads', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ name: file.name, type: file.type, size: file.size, columnId }),\n signal,\n })\n if (!res.ok) throw new Error(res.status === 413 ? 'File is too large' : `Upload refused (${res.status})`)\n const { id, uploadUrl, fileUrl } = (await res.json()) as { id: string; uploadUrl: string; fileUrl: string }\n\n await new Promise<void>((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.open('PUT', uploadUrl)\n xhr.setRequestHeader('content-type', file.type || 'application/octet-stream')\n xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total)\n xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`Storage said ${xhr.status}`)))\n xhr.onerror = () => reject(new Error('Network error'))\n xhr.onabort = () => reject(new Error('Cancelled'))\n signal.addEventListener('abort', () => xhr.abort(), { once: true })\n xhr.send(file)\n })\n\n return { id, url: fileUrl, name: file.name, type: file.type, size: file.size }\n}\n\nif (browser) setAttachmentUploader(upload)\n```\n\nThe message of the error you throw is what the user reads on the failed tile,\nnext to Retry and Remove, so write it for them.\n\nThe server checks the user and the size, signs a URL for one key, and keeps\na row per file:\n\n```ts\n// src/routes/api/uploads/+server.ts\nimport { error, json } from '@sveltejs/kit'\nimport type { RequestHandler } from './$types'\nimport { db } from '$lib/server/db'\nimport { presignPut } from '$lib/server/storage'\n\nconst MAX = 25 * 1024 * 1024\n\nexport const POST: RequestHandler = async ({ request, locals, url }) => {\n if (!locals.user) error(401, 'Sign in first')\n const { name, type, size } = (await request.json()) as { name: string; type: string; size: number }\n if (!(size > 0 && size <= MAX)) error(413, 'File is too large')\n const id = crypto.randomUUID()\n const safe = String(name).replace(/[^\\w.-]+/g, '_')\n const key = `${locals.user.teamId}/${id}/${safe}`\n await db.query('insert into files (id, team_id, key, type) values ($1, $2, $3, $4)', [id, locals.user.teamId, key, type])\n return json({ id, uploadUrl: await presignPut(key, type), fileUrl: `${url.origin}/api/files/${id}/${safe}` })\n}\n```\n\n`fileUrl` points at your app, not at the bucket. A presigned read URL\nexpires, and a cell keeps its URL for as long as the record exists. Serve\n`/api/files/[id]/[name]` with a `GET` that checks the user and the team,\nlooks up the key by `id` and answers `redirect(303, signedReadUrl)`:\nthumbnails, the preview and Download all follow the redirect, and old records\nkeep working.\n\nThe URL is absolute and ends in the file name for the sake of copy and\npaste. Copying an attachment cell writes the files' URLs, and a paste\nattaches each `http(s)` URL it finds, taking the name from the path and the\ntype from its extension. A relative URL would show fine in the cell but be\ndropped on paste, and a URL without the name would come back as a file named\nby its id, an image drawn as a chip.\n\nThe column limits what the dialog takes with\n[`AttachmentFieldProps`](../fields.md#attachments): `accept` (as for\n`<input accept>`, such as `'image/*,.pdf'`), `maxFiles` (default 20) and\n`maxSize` in bytes (default 25 MB). A file over a limit is listed under the\ntiles with the reason, such as \"At most 1 file\", \"Larger than 2 MB\" or \"Not\nan accepted type\". A URL added in the dialog, and text pasted into a cell,\nare checked the same way, their type read from the extension: what fails is\ndropped, the rest is kept. The field editor offers `accept` and `maxFiles`;\n`maxSize` is set in code:\n\n```ts\nconst photo = {\n field: 'photo',\n header: 'Photo',\n fieldType: 'attachment',\n fieldProps: { accept: 'image/*', maxFiles: 1, maxSize: 2 * 1024 * 1024 },\n}\n```\n\n`fieldProps.upload` sets an uploader for one column instead of the app's.\n\n### What the user sees\n\nThe cell shows images as thumbnails and other files as chips with the\nextension and the name, as many as the column width holds, then \"+N\". Enter\nor a double click opens the dialog: add files with \"Add files\", by dropping,\nby pasting files, or by pasting a URL into \"or paste a URL\". Each upload is a\ntile with its percent and a bar; Done reads \"Uploading 2...\" and stays\ndisabled until they finish. Tiles can be dragged to reorder, or moved with\nAlt+Left and Alt+Right; Delete removes one. Cancel or Escape leaves the cell\nas it was and aborts the uploads still running.\n\nFiles can also be dropped straight on a cell. Each one uploads on its own,\nwith a thin progress bar along the bottom of the cell, and the ones that\nupload are written to the cell in one edit. A file over a limit shows in a\n\"Not added\" toast and an upload that throws in an \"Upload failed\" toast, each\nwith the file name and the reason; the others still land. The toasts need\nthe same `<SvToaster />` as the comment store. Uploads still running when the\ncell goes away (scrolled out, or the table switched) are aborted through\n`signal`.\n\nEnter on a tile, or a click on its picture, opens the preview: images inline,\nPDFs in an iframe, anything else as its name with a Download link. Left and\nRight step through the files. For the PDF frame your storage has to send\n`application/pdf` and allow the page to frame it.\n\n## 7. Track who changed what\n\n`lastModifiedTime` and `lastModifiedBy` change on every edit to the record by\ndefault. With `watch: 'specific'` they change only when one of `watchFields`\ndoes, which turns them into \"status changed at\" and \"status changed by\":\n\n```ts\nconst stamps = [\n { field: 'updatedAt', header: 'Last modified', fieldType: 'lastModifiedTime', fieldProps: { dateFormat: 'friendly' } },\n { field: 'statusAt', header: 'Status changed', fieldType: 'lastModifiedTime', fieldProps: { watch: 'specific', watchFields: ['status'] } },\n { field: 'statusBy', header: 'Status changed by', fieldType: 'lastModifiedBy', fieldProps: { watch: 'specific', watchFields: ['status'] } },\n]\n```\n\n`watchFields` holds column ids: the column's `id`, or its `field` when it has\nnone. The time is an ISO string; the person is the current user's id. With\nnobody signed in, the time still moves and the person stays as it was. A\ncommit that leaves the value as it was (an editor opened and closed with\nEnter) is not an edit: nothing is stamped and `onCellValueChange` does not\nfire. An automation's `updateRecord` moves the time but not the person.\n\nThe stamp is written into the row before `onCellValueChange` fires, so the\nevent's `row` already holds it. If you save cell by cell, the event names\nonly the edited column: send the stamps along, or have the server write\n`updated_at` and `updated_by` from its own clock and session, which a\nbrowser cannot fake. With `onTablesChange` or a table `source` ([Keep a base\nin a database](./data.md)) the stamps travel with the rest of the row.\n\n## The activity log\n\nEvery edit through the grid is noted for the record: a typed value, a paste,\na fill, a clear, a checkbox click, a card dragged to another lane, an edit in\nthe record view, files added in the dialog or dropped on a cell. Each note\nholds the time, the current user's id, the column and the new value, and the\nrecord view writes it as \"Ana Ruiz changed Status\" over the value as the cell\nshows it, or \"(cleared)\". A field an automation's `updateRecord` sets is\nnoted under the automation's name: \"Automation: Done ticks the box changed\nDone\".\n\nThe log lives in memory and keeps the latest 50 changes of each record. In\n`<SvBase tables>` it is kept by base, table and record id, so it survives\nswitching tabs; with one table it is kept per grid. It is not exported, and\nit is gone after a reload. Changes made by your code replacing `tables` or\n`data` are not noted.\n\nIf the team needs a history that lasts, record it yourself from\n`onCellValueChange` (`columnId`, `oldValue`, `newValue`, `row`) on the\nserver, where you know who is signed in.\n\n## The record menu\n\nRight-click a record in a base's grid:\n\n| Item | What it does |\n| --- | --- |\n| Expand record | Opens the record, with or without `expandRows` |\n| Comment | Opens the record with the comment box focused |\n| Insert record above, Insert record below | A new record beside this one with the values of the fields the view groups by, so it stays in the group; editing starts on its first field |\n| Duplicate record | A copy below, with lists copied; autonumber, created and last modified fields get fresh values |\n| Copy, Paste | The grid's own cell copy and paste |\n| Delete record | Asks \"Delete record?\" (\"Write the brief is deleted, and its links from other records go with it.\"), then removes it and fires `onRowsDelete` |\n\nWithout `editable`, only Expand record, Comment and Copy show. A new record\nfrom the menu is made the way \"+ Add row\" makes one: the `rowAdder` defaults,\na new id, the next autonumber, the current user in created by, and `onRowAdd`\nfired, so a table with a database writes it. The grid scrolls to it. A Kanban\ncard's right-click menu has Expand record and Comment, and with `editable`\n\"Move to\" a lane, Duplicate record and Delete record. Passing your own\n`contextMenu` to `SvBase` replaces the grid's menu.\n\n## Pitfalls\n\n- **No current user, no author.** Without `setCurrentUser`, new rows get\n `null` in created by, and an edit leaves last modified by as it was, so\n the field names whoever edited last while signed in.\n- **People are page-wide.** `setFieldUsers` and `setCurrentUser` set one list\n and one person for every base on the page. Two bases for two teams on one\n page share them.\n- **Records need real ids.** Comments and the activity are keyed by row id:\n each record's `id`, or `getRowId`. With one table a record without an `id`\n falls back to its index, so its comments move to another record when a row\n above it is deleted. In `<SvBase tables>` every record without an `id`\n shares the id `''`, and with it one thread. Give every record an id.\n- **Your store tells tables apart by id only.** In memory, each `<SvBase>`\n keeps its own comments and activity, even when two bases on one page use\n the same table ids. Your comment store gets the table id and nothing about\n the base: if two bases share one store and their table ids, key the store\n by something of your own as well. With `serverIds: true` a new record\n keeps the page's id until the table loads again; re-key its comments when\n the insert returns.\n- **`'@me'` is whoever runs the filter.** Before `setCurrentUser` it\n matches no one; the grid filters again once a user is set. In an\n automation's condition it is the person whose edit set it off, and on the\n server runner, where nobody is signed in, it matches no one.\n- **A URL's type is its extension.** Under `accept: 'image/*'` a URL with no\n extension is refused, and a URL's size is checked only when it is known.\n The browser checks are for the user; check types and sizes on the server\n too.\n- **Storage keeps what the cell drops.** Removing a file, Cancel after an\n upload finished, or deleting the record leaves the object in the bucket.\n Clean up on the server, for example files no record has pointed at for a\n day.\n- **`fieldProps.upload` does not survive JSON.** A function in a column is\n lost when you store columns in a database through `onTablesChange`. Use\n `setAttachmentUploader` and branch on `columnId` instead.\n\n## Next\n\n- [Linked records](./linked-records.md): tasks that point at milestones,\n people that point at teams, and the records that link back.\n- [Formulas](./formulas.md): fields worked out from other fields.\n- [Views](./views.md): grids, boards, calendars and galleries over the same\n records.\n- [Forms](./forms.md): let people outside the team add records.\n- [Your own field type](./custom-field-type.md): a cell of your own, with the\n grid's `ctrl` to open records from it.\n- [Automations](./automations.md): tell the assignee when a task is blocked.\n"
|
|
4249
|
+
},
|
|
4250
|
+
{
|
|
4251
|
+
"slug": "help/base/custom-field-type",
|
|
4252
|
+
"path": "docs/help/base/custom-field-type.md",
|
|
4253
|
+
"title": "Build your own field type",
|
|
4254
|
+
"section": "SvGrid Base",
|
|
4255
|
+
"markdown": "# Build your own field type\n\nA field type decides what a column holds and how it behaves: what the cell\nshows, which editor opens, how pasted text is read, what the footer can sum.\nThe built-in types are registered with `registerFieldType`, the same call\nyou will use, so your type gets the field editor, the views and the\nexpanded record the way they do.\n\n> SvGrid Base is part of the **Suite edition** of `@svgrid/enterprise`. Without a key it runs with a watermark.\n\nThis page builds two types. **Phone (international)** stores numbers as\nE.164 text (`+14155550123`), shows them the local way, dials on click and\nreads whatever people paste. **Traffic light** stores a score from 0 to 100\nand draws a green, amber or red light with its own cell and its own editor.\nThe [field types page](../fields.md#your-own-field-type) has the short\nversion.\n\n\n\n## 1. Decide what the field stores\n\nSettle the stored value before you write the type. Filters, sorting, copy,\nthe database and your API all see the stored value; only the cell and the\nformatter see the text on screen. A phone number stored as `+14155550123`\ndials from any app, sorts by country and compares exactly.\n\n```ts\n// phone-field.ts\nexport type PhoneProps = { country: string; display: 'national' | 'international' }\n\nconst E164 = /^\\+[1-9]\\d{6,14}$/\n\n/** Typed or pasted text as E.164 (\"+14155550123\"), or null when it is no phone number. */\nexport function toE164(text: string, country: string): string | null {\n let s = text.trim().replace(/^tel:/i, '').replace(/[\\s().\\-\\/]/g, '')\n if (s.startsWith('00')) s = '+' + s.slice(2)\n if (!s.startsWith('+')) {\n // A national number: drop the trunk prefix (1 in North America, 0 elsewhere).\n s = country === '1' ? s.replace(/^1(?=\\d{10}$)/, '') : s.replace(/^0/, '')\n // North American numbers need all ten digits: no guessing an area code.\n if (country === '1' && !/^\\d{10}$/.test(s)) return null\n s = `+${country}${s}`\n }\n return E164.test(s) ? s : null\n}\n\n/** A stored value as the field shows it. A number from another country shows as stored. */\nexport function showPhone(value: unknown, p: PhoneProps): string {\n const s = value == null ? '' : String(value)\n const home = `+${p.country}`\n if (!E164.test(s) || !s.startsWith(home)) return s\n const rest = s.slice(home.length)\n if (p.country === '1' && rest.length === 10) {\n const local = `(${rest.slice(0, 3)}) ${rest.slice(3, 6)}-${rest.slice(6)}`\n return p.display === 'national' ? local : `+1 ${local}`\n }\n const grouped = rest.replace(/\\B(?=(\\d{4})+$)/g, ' ')\n return p.display === 'national' ? `0${grouped}` : `${home} ${grouped}`\n}\n```\n\n| Typed, home country | Stored | Shown (national) |\n|---|---|---|\n| `(415) 555-0123`, +1 | `+14155550123` | `(415) 555-0123` |\n| `1 415 555 0123`, +1 | `+14155550123` | `(415) 555-0123` |\n| `020 7946 0958`, +44 | `+442079460958` | `020 7946 0958` |\n| `0044 20 7946 0958`, +1 | `+442079460958` | `+442079460958` |\n| `555-0123`, +1 | refused | |\n| `n/a` | refused | |\n\nBoth are plain functions, so you can unit-test them without a grid.\n\n## 2. Register the type\n\n```ts\n// phone-field.ts, continued\nimport { registerFieldType, type FieldTypeDef } from '@svgrid/enterprise/base'\n\nexport const phoneType: FieldTypeDef<PhoneProps> = {\n id: 'intlPhone',\n label: 'Phone (international)',\n description: 'Stored as +14155550123, shown in the local format, dialed with a click.',\n icon: 'M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2',\n group: 'basic',\n width: 170,\n defaults: { country: '1', display: 'national' },\n fill: (def, props) => ({\n ...def,\n editorType: def.editorType ?? 'text',\n formatter: def.formatter ?? ((ctx: { value: unknown }) => showPhone(ctx.value, props)),\n // Typed and pasted text is stored as E.164; text that is no number keeps the old value.\n valueParser: def.valueParser ?? ((x: { newValue: unknown; oldValue: unknown; rawInput?: unknown }) => {\n const text = String((typeof x.rawInput === 'string' ? x.rawInput : x.newValue) ?? '').trim()\n if (!text) return null\n return toE164(text, props.country) ?? x.oldValue\n }),\n // Values that never went through the parser (loaded data, an API write) are flagged.\n validate: def.validate ?? ((ctx: { value: unknown }) =>\n ctx.value == null || ctx.value === '' || E164.test(String(ctx.value)) ? null : 'Not a +country phone number'),\n }),\n}\n\nregisterFieldType(phoneType)\n```\n\nImport the file once at app start, next to `enableBase()`, and name the type\nin a column:\n\n```ts\nimport { enableBase } from '@svgrid/enterprise/base'\nimport './phone-field'\n\nenableBase()\n\nconst columns = [\n { field: 'name', header: 'Name', fieldType: 'text' },\n { field: 'phone', header: 'Phone', fieldType: 'intlPhone', fieldProps: { country: '44' } },\n]\n```\n\n`fill` is where a type turns into grid behavior. It receives the column as\nthe app declared it, the type's `defaults` with the column's `fieldProps` on\ntop (`props`), and a context with every column. It returns the column the\ngrid sees: `editorType`, `formatter`, `valueParser`, `validate`, `align`,\n`cellDataType`, `sortValue`, `editable`. Write each key as\n`def.key ?? yours`, so a column that sets `formatter` itself keeps it. The\nbuilt-ins in `builtin-types.ts` follow that rule.\n\nWith this registered, the header shows the phone icon before \"Phone\", cells\nread `020 7946 0958`, and \"Phone (international)\" appears in the field\neditor's type picker under \"Fields\", with its description under the picker\nonce it is chosen. `width` is the width a new field of this type starts\nwith when someone adds it there.\n\n\n\nThe picker draws a section per `group`, in a fixed order, and skips a\nsection no type uses. The built-ins fill only Fields, Numbers and time,\nComputed and automatic, and Other tables (Date and User sit under Fields),\nso \"Dates\" and \"People\" appear only once a type of yours names them. A\n`group` the list does not know puts the type under Fields. Once more than\neight types are on offer, a \"Find a type\" box sits above the list.\n\n## 3. Read what people type and paste\n\n`valueParser` gets `newValue` (after the grid's own coercion for the\neditor type), `oldValue`, `rawInput` (the text as typed or pasted), the\nrow as `data` and the `columnId`. It runs on a cell edit, a paste (one\nvalue pasted over a range included), a full-row edit, an edit in the\nexpanded record and a form field. Returning `oldValue` refuses the input\nquietly: paste `n/a` over a number and the number stays. The text editor\nopens on the stored `+442079460958`, and whatever the user leaves in it\ngoes back through the parser.\n\n`validate` runs for every rendered cell, so a bad value gets the red mark\nand the message as its tooltip even when it was loaded that way. While the\ncell's editor is open, it checks the draft before `valueParser` has read\nit, so `(415) 555-0123` shows red until Enter stores it as E.164. Add\n`rejectInvalid: def.rejectInvalid ?? true` to refuse a flagged edit\noutright; a paste is not checked against it, which is why the parser does\nthe refusing here.\n\nCheck the filled column without mounting anything:\n\n```ts\nimport { fillFieldColumn } from '@svgrid/enterprise/base'\n\nconst col = fillFieldColumn({ field: 'phone', fieldType: 'intlPhone', fieldProps: { country: '44' } })\nconst show = col.formatter as (ctx: { value: unknown }) => string\nconst parse = col.valueParser as (x: { newValue: unknown; oldValue: unknown; rawInput: string }) => unknown\n\nshow({ value: '+442079460958' }) // '020 7946 0958'\nparse({ newValue: '020 7946 0958', oldValue: null, rawInput: '020 7946 0958' }) // '+442079460958'\nparse({ newValue: 'n/a', oldValue: '+442079460958', rawInput: 'n/a' }) // '+442079460958'\n```\n\n## 4. Draw the cell\n\nWithout `cell`, the grid draws the formatter's text. A cell component can\ndraw anything; this one is a `tel:` link, so a click dials.\n\n```svelte\n<!-- PhoneCell.svelte -->\n<script lang=\"ts\">\n import { linkHref, type FieldCellProps } from '@svgrid/enterprise/base'\n import { showPhone, type PhoneProps } from './phone-field'\n\n let { value, props }: FieldCellProps<PhoneProps> = $props()\n // 'tel:+14155550123'; null unless the value is 3 to 20 digits with an optional +.\n const href = $derived(linkHref('phone', value))\n const text = $derived(showPhone(value, props))\n</script>\n\n{#if href}\n <!-- Stop pointerdown: a click on the number dials, a click beside it selects the cell. -->\n <a class=\"phone\" {href} onpointerdown={(e) => e.stopPropagation()}>{text}</a>\n{:else}\n <span class=\"phone is-bad\">{text}</span>\n{/if}\n\n<style>\n .phone { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; }\n a.phone { text-decoration: underline; text-decoration-color: var(--sg-muted, #94a3b8); text-underline-offset: 2px; }\n .is-bad { color: var(--sg-muted, #64748b); }\n</style>\n```\n\n```ts\nimport { registerFieldType } from '@svgrid/enterprise/base'\nimport { phoneType } from './phone-field'\nimport PhoneCell from './PhoneCell.svelte'\n\nregisterFieldType({ ...phoneType, cell: PhoneCell })\n```\n\nRegistering an id again replaces the type and keeps its place in the\npicker; in your own file, add `cell: PhoneCell` to `phoneType` instead.\nThe cell receives `FieldCellProps`: `value`, `props` (defaults merged with\n`fieldProps`), `row` (`{ id, original }`), `column` (`{ id, columnDef }`)\nand `ctrl`, the grid. `linkHref` is the helper the built-in URL, email and\nphone cells use; it returns an href only for the scheme the kind allows,\nso a value like `javascript:alert(1)` never becomes a link.\n\n## 5. Add properties to the field editor\n\n`properties` is what \"Edit field\" shows for the type. Each property is\nkept under its `key` in the column's `fieldProps`.\n\n```ts\nimport { registerFieldType, type FieldPropertyDef } from '@svgrid/enterprise/base'\nimport { phoneType } from './phone-field'\nimport PhoneCell from './PhoneCell.svelte'\n\nexport const PHONE_PROPERTIES: FieldPropertyDef[] = [\n {\n key: 'country', label: 'Home country', kind: 'choice',\n choices: [\n { value: '1', label: 'United States, Canada (+1)' },\n { value: '44', label: 'United Kingdom (+44)' },\n { value: '49', label: 'Germany (+49)' },\n { value: '359', label: 'Bulgaria (+359)' },\n ],\n hint: 'Numbers typed without a + are read as this country\\'s.',\n },\n {\n key: 'display', label: 'Show numbers of that country', kind: 'segmented',\n choices: [{ value: 'national', label: '(415) 555-0123' }, { value: 'international', label: '+1 (415) 555-0123' }],\n },\n]\n\nregisterFieldType({ ...phoneType, cell: PhoneCell, properties: PHONE_PROPERTIES })\n```\n\nThe dialog draws a dropdown with the hint under it, then a row of two\nbuttons. A property the column has not set shows the type's default.\nPicking your type in the dialog starts the draft from your `defaults` and\nkeeps only the values of properties your type also lists, so a field made\nthere saves your defaults into its `fieldProps`; changing `defaults` later\ndoes not move it. On save, empty strings, empty lists (other than\n`options`) and `undefined` are dropped. A `choice` hands back the option's\n`value` string; a `number` hands back a number, or `undefined` when\ncleared.\n\n## 6. A type with its own cell: Traffic light\n\nThe second type stores a number and draws a light. Its properties set the\nthresholds, the direction and the green color.\n\n```ts\n// traffic-light.ts\nimport { registerFieldType, formatNumber, BRIGHT, type FieldTypeDef } from '@svgrid/enterprise/base'\nimport TrafficLightCell from './TrafficLightCell.svelte'\n\nexport type LightProps = {\n green: number\n amber: number\n higherIsBetter: boolean\n goodColor: string\n show: 'light' | 'number'\n showNumber: boolean\n default?: number | string\n}\n\nexport const LIGHT_DEFAULTS: LightProps = {\n green: 80, amber: 50, higherIsBetter: true, goodColor: '#20c933', show: 'light', showNumber: true,\n}\n\nexport type Light = { band: 'green' | 'amber' | 'red'; color: string; label: string }\n\n/** The light a score gets under the field's thresholds. */\nexport function lightOf(n: number, p: LightProps): Light {\n const reaches = (limit: number) => (p.higherIsBetter ? n >= limit : n <= limit)\n if (reaches(Number(p.green))) return { band: 'green', color: p.goodColor, label: 'On track' }\n if (reaches(Number(p.amber))) return { band: 'amber', color: '#fcb400', label: 'At risk' }\n return { band: 'red', color: '#f82b60', label: 'Off track' }\n}\n\n/** A stored value as a score, or null. Old data may hold \"72\" or \"72%\" as text. */\nexport function scoreOf(value: unknown): number | null {\n if (value == null || value === '') return null\n const n = typeof value === 'number' ? value : Number(String(value).replace(/[%\\s]/g, ''))\n return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : null\n}\n\nexport const trafficLightType: FieldTypeDef<LightProps> = {\n id: 'trafficLight',\n label: 'Traffic light',\n description: 'A score from 0 to 100, shown as a green, amber or red light.',\n icon: 'M9 2h6v20H9zM12 6h.01M12 12h.01M12 18h.01',\n group: 'number',\n width: 120,\n defaults: LIGHT_DEFAULTS,\n properties: [\n { key: 'green', label: 'Green at', kind: 'number', min: 0, max: 100, hint: 'With \"Higher is better\" off, green is at or below this value.' },\n { key: 'amber', label: 'Amber at', kind: 'number', min: 0, max: 100, hint: 'Past amber the light is red.' },\n {\n key: 'higherIsBetter', label: 'Higher is better', kind: 'checkbox',\n // Flipping the direction swaps the thresholds. `p` is the draft with\n // the type's defaults filled in, as `visible` sees it.\n alsoSets: (_v, p) => ({ green: p.amber, amber: p.green }),\n },\n { key: 'goodColor', label: 'Green color', kind: 'color', palette: BRIGHT },\n { key: 'show', label: 'Show as', kind: 'segmented', choices: [{ value: 'light', label: 'Light' }, { value: 'number', label: 'Number' }] },\n { key: 'showNumber', label: 'Number next to the light', kind: 'checkbox', visible: (p) => p.show !== 'number' },\n { key: 'default', label: 'Default value', kind: 'default', hint: 'New rows start with this score.' },\n ],\n fill: (def) => ({\n ...def,\n cellDataType: def.cellDataType ?? 'number',\n editorType: def.editorType ?? 'trafficLight',\n align: def.align ?? 'left',\n formatter: def.formatter ?? ((ctx: { value: unknown }) => {\n const n = scoreOf(ctx.value)\n return n == null ? '' : formatNumber(n, { precision: 0 })\n }),\n valueParser: def.valueParser ?? ((x: { newValue: unknown; oldValue: unknown; rawInput?: unknown }) => {\n if (typeof x.rawInput !== 'string') return scoreOf(x.newValue)\n if (!x.rawInput.trim()) return null\n return scoreOf(x.rawInput) ?? x.oldValue\n }),\n }),\n cell: TrafficLightCell,\n // \"Show as: Number\" hands the cell back to the grid, which draws the formatter's text.\n hasCell: (p) => p.show !== 'number',\n newRowValue: ({ props }) => (props.default == null ? undefined : scoreOf(props.default)),\n}\n\nregisterFieldType(trafficLightType)\n```\n\n```svelte\n<!-- TrafficLightCell.svelte -->\n<script lang=\"ts\">\n import type { FieldCellProps } from '@svgrid/enterprise/base'\n import { lightOf, scoreOf, type LightProps } from './traffic-light'\n\n let { value, props }: FieldCellProps<LightProps> = $props()\n const n = $derived(scoreOf(value))\n const light = $derived(n == null ? null : lightOf(n, props))\n const number = $derived(n == null ? '' : String(Math.round(n)))\n</script>\n\n{#if light}\n <span class=\"tl\" title={`${light.label}: ${number}`}>\n <span class=\"tl-dot\" style:background={light.color} role=\"img\" aria-label={light.label}></span>\n {#if props.showNumber}<span class=\"tl-num\">{number}</span>{/if}\n </span>\n{/if}\n\n<style>\n .tl { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }\n .tl-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; }\n .tl-num { font-variant-numeric: tabular-nums; color: var(--sg-fg, #0f172a); }\n</style>\n```\n\n`cellDataType: 'number'` makes it a number column to the rest of the base:\nthe footer offers Sum, Average, Median, Min, Max and Range, each formatted\nby your `formatter`; the column menu labels its sorts \"1 → 9\" and\n\"9 → 1\"; the view bar filters it with =, <, > and the other number\noperators. The light has an `aria-label`, so a screen reader user gets the\nband as a word, not only a color.\n\nIn \"Edit field\" the type shows two number boxes, a checkbox, ten color\nswatches, a Light / Number switch, a second checkbox and the default box.\n\"Number next to the light\" disappears while the switch is on Number,\nbecause `visible` reads the draft. Group by this field and each score is a\ngroup of its own: the grid groups by the stored value, and the group\nheader draws its light.\n\nUntil step 7 registers an editor named `trafficLight`, the cell edits in a\nplain text box and the parser reads the text: type `72%` and 72 is stored.\n\n## 7. Give it its own editor\n\nAn editor is a Svelte component registered with `registerCellEditor` from\n`@svgrid/grid` under the name your `fill` puts in `editorType`.\n\n```svelte\n<!-- TrafficLightEditor.svelte -->\n<script lang=\"ts\">\n import { fieldPropsOf } from '@svgrid/enterprise/base'\n import { lightOf, scoreOf, type LightProps } from './traffic-light'\n\n let {\n value,\n columnDef,\n onChange,\n onCommit,\n onCommitAndMove,\n onCancel,\n }: {\n value: unknown\n columnDef?: Record<string, unknown>\n onChange: (v: unknown) => void\n onCommit: (v?: unknown) => void\n onCommitAndMove: (v: unknown, direction: 1 | -1) => void\n onCancel: () => void\n } = $props()\n\n // The column's properties: the type's defaults under its fieldProps.\n const props = $derived(fieldPropsOf(columnDef ?? {}) as LightProps)\n // svelte-ignore state_referenced_locally\n let n = $state(scoreOf(value) ?? 0)\n const light = $derived(lightOf(n, props))\n\n function focusOnMount(el: HTMLElement) {\n queueMicrotask(() => el.focus())\n }\n function slide(e: Event & { currentTarget: HTMLInputElement }) {\n n = Number(e.currentTarget.value)\n // Keep the grid's draft in step, so a click elsewhere saves what is shown.\n onChange(n)\n }\n function onKey(e: KeyboardEvent) {\n if (e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End') {\n // The slider moves; the grid's active cell stays put.\n e.stopPropagation()\n return\n }\n if (e.key === 'Enter') onCommit(n)\n else if (e.key === 'Tab') onCommitAndMove(n, e.shiftKey ? -1 : 1)\n else if (e.key === 'Escape') onCancel()\n else return\n e.preventDefault()\n e.stopPropagation()\n }\n</script>\n\n<span class=\"tle\">\n <input type=\"range\" min=\"0\" max=\"100\" step=\"1\" value={n} aria-label=\"Score\"\n style:accent-color={light.color} use:focusOnMount oninput={slide} onkeydown={onKey} />\n <output>{n}</output>\n</span>\n\n<style>\n .tle { display: flex; align-items: center; gap: 8px; height: 100%; padding: 0 8px; background: var(--sg-bg, #fff); }\n .tle input { flex: 1; min-width: 0; }\n .tle output { width: 3ch; text-align: end; font-variant-numeric: tabular-nums; }\n</style>\n```\n\n```ts\nimport { registerCellEditor } from '@svgrid/grid'\nimport TrafficLightEditor from './TrafficLightEditor.svelte'\n\nregisterCellEditor('trafficLight', {\n component: TrafficLightEditor,\n props: (ctx) => ({\n value: ctx.value,\n columnDef: ctx.columnDef,\n onChange: ctx.onChange,\n onCommit: ctx.onCommit,\n onCommitAndMove: ctx.onCommitAndMove,\n onCancel: ctx.onCancel,\n }),\n typeToEdit: false,\n})\n```\n\nDouble-click or F2 opens the slider in the cell. Arrows move it, Enter\nsaves, Tab saves and moves to the next cell, Escape leaves the cell as it\nwas. `typeToEdit: false` keeps typed keys from opening it; a function such\nas `(c) => /^[0-9]$/.test(c)` would open it on a digit, with `typed: true`\nand the digit as `value` on the context. Without `props`, the grid passes\n`value`, `onChange`, `onCommit`, `onCancel`, `onCommitAndMove`,\n`onRequestClose` and `inCell`. The context also carries `rowId`,\n`columnId`, `columnDef`, `row`, `grid`, `typed` and `onCommitAndMoveDown`.\n\nThe expanded record and a form open the same registered editor, with\n`inCell: false`. A value from a registered editor reaches `valueParser` as\nit is, with `rawInput` set to that same value rather than to text.\n\n## 8. Values the type writes itself\n\n`newRowValue` gives each new row a value: from the row adder, a group's\n\"+\", a form, a board lane. Return `undefined` to fall back to the field's\n`default` property. `onCreate` fills every existing row once, when someone\ncreates the field in the field editor. Both get a `FieldRowContext`: the\nfield's `props`, its `field` key and the table's `rows`.\n\n```ts\nimport { registerFieldType } from '@svgrid/enterprise/base'\n\nconst ticket = (prefix: string, n: number) => `${prefix}${String(n).padStart(4, '0')}`\n/** The number in \"T-0042\", or 0. */\nconst numberIn = (v: unknown) => Number(/(\\d+)$/.exec(String(v ?? ''))?.[1] ?? 0)\n\nregisterFieldType({\n id: 'ticketId',\n label: 'Ticket ID',\n description: 'T-0001, T-0002 and on. Set automatically.',\n icon: 'M4 7h16v10H4zM9 7v10',\n group: 'computed',\n width: 100,\n defaults: { prefix: 'T-' },\n properties: [{ key: 'prefix', label: 'Prefix', kind: 'text', placeholder: 'T-' }],\n fill: (def) => ({ ...def, editable: def.editable ?? false }),\n onCreate: ({ rows, props }) => rows.map((_, i) => ticket(String(props.prefix), i + 1)),\n newRowValue: ({ rows, field, props }) =>\n ticket(String(props.prefix), rows.reduce((max, r) => Math.max(max, numberIn(r?.[field])), 0) + 1),\n copy: false,\n})\n```\n\nWhen `onCreate` returns values, `onColumnAdd` hands your app the new rows\nas `data` next to the columns, so store both. `editable: false` also keeps\nthe field out of a form. \"Duplicate record\" copies every stored field into\nthe copy. `copy: false` opts a type out: the copy gets a new value from\n`newRowValue` (the next ticket number here), or the field's `default` when\nthe type has no `newRowValue`. Leave it out and two records share a ticket\nnumber. The built-in autonumber, created and last-modified fields always\nget fresh values. The traffic light in step\n6 reads its `default` property in `newRowValue` because a `default`\nproperty on a custom type is drawn as a text box and saved as a string.\n\n## Replace a built-in type\n\nRegister a type with a built-in id before calling `enableBase()`:\n`enableBase()` registers each built-in only when no type has its id yet.\n\n```ts\nimport { enableBase, registerFieldType } from '@svgrid/enterprise/base'\nimport { phoneType, PHONE_PROPERTIES } from './phone-field'\nimport PhoneCell from './PhoneCell.svelte'\n\nregisterFieldType({ ...phoneType, id: 'phone', label: 'Phone number', cell: PhoneCell, properties: PHONE_PROPERTIES })\nenableBase()\n```\n\nEvery column with `fieldType: 'phone'` now uses yours, and it is listed\nfirst in its picker section, since the picker keeps registration order.\nSome behavior stays keyed to the id rather than to your definition: changing a\nfield to `phone` converts its values as text without your parser, the\nfilter treats it as text, and a form checks it as a phone number.\n`baseToSql` follows your `fill`'s `cellDataType`, as for any registered\ntype. Override a number type such as `rating` and the type change, the\nfilter and `baseToSql` keep treating it as a number.\n\n## Keep an old type id\n\nRename a type without breaking saved columns: register the old id with\n`editAs`, pointing at the new one.\n\n```ts\nimport { registerFieldType } from '@svgrid/enterprise/base'\nimport { phoneType } from './phone-field'\nimport PhoneCell from './PhoneCell.svelte'\n\nregisterFieldType({ ...phoneType, id: 'mobile', label: 'Mobile', cell: PhoneCell, editAs: { type: 'intlPhone', props: { display: 'international' } } })\n```\n\nColumns that say `fieldType: 'mobile'` still draw, and the type picker\nleaves `mobile` out. \"Edit field\" opens such a column as Phone\n(international) with `display: 'international'` on top of its properties.\nSaving writes `fieldType: 'intlPhone'` and converts the stored values as\nany type change does, through the `intlPhone` parser, which keeps every\nvalid E.164 number. The conversion starts from the stored type, `mobile`,\nso the editor warns before you save when it would clear cells: \"Mobile to\nPhone (international) converts the values. 2 cells will be cleared or cut\nshort.\" When\nevery value converts it says nothing, since you did not pick a new type.\nThe built-in Currency and Percent types work this way: both open as Number.\n\n## Where your type shows up\n\n| Place | What it uses |\n|---|---|\n| Grid cell | `cell` when `hasCell` allows it, else the filled column's `formatter` and `align` |\n| Header, type picker, record view and form labels, view bar panels, gallery labels | `icon`, drawn as a 24x24 stroke path |\n| Group header | `cell`, given the group's first record as `row`; else `formatter` |\n| Gallery and Kanban cards | `cell`, with `ctrl` the grid at the card's width and editing off; else `formatter` |\n| Expanded record | `cell` for a non-empty value; a click opens the editor `editorType` names (a registered one, the date picker or a text box) and saves through the grid, so `valueParser` and `rejectInvalid` apply |\n| Form view | The same cells and editors; the editor's value goes through the grid's coercion and `valueParser`, as in a cell edit. On submit the form runs `validate` and refuses the record while it fails, with or without `rejectInvalid` |\n| Footer summary | Number summaries for `cellDataType: 'number'`, date ones for `'date'` and `'dateString'`, checked, unchecked and percent checked for `'boolean'`, as a checkbox gets; counts for the rest; see [Summaries at the bottom](../fields.md#summaries-at-the-bottom) |\n| View bar filter | By the filled column's `cellDataType`: the number operators for `'number'`, the date ones for `'date'` and `'dateString'`, \"is\" checked / unchecked for `'boolean'`; otherwise the text operators (contains, does not contain, is, is not, is empty, is not empty) on the stored value |\n| Sort | The stored value; a `sortValue` in `fill` sorts by a number of your own, as a single select sorts by option order |\n| Copy | `String(value)`: an array copies as `a,b`, an object as `[object Object]`, so keep stored values to strings, numbers and arrays of them. In a base, people, links and attachments copy as names and URLs; a custom type does not get that |\n| Paste | `valueParser` with the pasted text as `rawInput`; `rejectInvalid` does not refuse it |\n| Changing a field to your type | Each value as text through your `valueParser`, as a paste with `oldValue: null`; what it refuses or throws on is cleared and counted in the editor's warning. Without a parser, a list becomes its text and a plain value stays |\n| Changing your type to a built-in | The built-in's conversion, reading your value as text (`convertValue`) |\n| `baseToSql` | By the filled column's `cellDataType`, as the built-ins map: `double precision` for `'number'`, `boolean` for `'boolean'`, `timestamptz` for `'date'`, `date` for `'dateString'`; otherwise `text` |\n\n`convertValue(value, from, to, toProps)` and\n`countCleared(values, from, to, toProps)` are exported, so a migration\nscript can preview a type change the way the field editor's warning does.\n`toProps` is the new field's `fieldProps`, which your `fill` sees when the\nparser is made. `filterKindOf(def)` returns `'number'`, `'date'` or\n`'checkbox'` from the filled column's `cellDataType` for an id it does not\nknow, and `'text'` when `cellDataType` says nothing; formula, lookup and\nrollup fields follow their `cellDataType` the same way. `operatorsFor(kind)`\nlists the operators a kind offers.\n\n## FieldTypeDef\n\n| Member | Type | What it does |\n|---|---|---|\n| `id` | `string` | The name columns use in `fieldType` |\n| `label` | `string` | Name in the type picker; a new unnamed field is named after it |\n| `description` | `string` | One line under the type list once the type is chosen |\n| `icon` | `string` | SVG path data, 24x24 viewBox, drawn as a stroke |\n| `group` | `'basic' \\| 'number' \\| 'date' \\| 'people' \\| 'computed' \\| 'relation'` | Picker section, in this order: Fields, Numbers and time, Dates, People, Computed and automatic, Other tables; default `'basic'` |\n| `defaults` | `P` | Property values a column gets when its `fieldProps` leave them out |\n| `fill` | `(def, props: P, ctx: FieldFillContext) => Record<string, unknown>` | Turns the declared column into the column the grid sees. Optional: without it the column stays as declared and still gets its footer summary |\n| `cell` | `Component<FieldCellProps<P>>` | Draws the cell |\n| `hasCell` | `(props: P) => boolean` | Whether `cell` draws; default always |\n| `properties` | `ReadonlyArray<FieldPropertyDef>` | What the field editor lets a user set |\n| `width` | `number` | Width of a new field made in the field editor (else 150) |\n| `newRowValue` | `(ctx: FieldRowContext) => unknown` | The value a new row gets |\n| `onCreate` | `(ctx: FieldRowContext) => unknown[] \\| null` | Values for every existing row when the field is created |\n| `copy` | `false` | A duplicated record gets a new value from `newRowValue` (else the field's `default`) instead of a copy |\n| `editAs` | `{ type: string; props: Record<string, unknown> }` | Keeps an old id drawing; edited as `type` and left out of the picker |\n\n## Property kinds\n\nEvery `FieldPropertyDef` has `key`, `label` and `kind`, and may have\n`visible(props)` (shown only when it returns true), `hint` (a line under the\ncontrol, for every kind) and `alsoSets(value, props)` (other keys to change\nwith this one). `visible` and `alsoSets` get the same `props`: the draft\nwith the type's defaults filled in, before this change.\n\n| `kind` | Extra keys | Draws | Hands back |\n|---|---|---|---|\n| `number` | `min`, `max` | Number box | `number`, or `undefined` when cleared |\n| `text` | `placeholder` | Text box | `string` |\n| `checkbox` | | Checkbox with the label beside it | `boolean` |\n| `choice` | `choices`: a list, or `(ctx: FieldEditorContext) => list` | Dropdown, with \"Choose...\" while the value is not in the list | The option's `value` |\n| `segmented` | `choices` | Row of buttons | The option's `value` |\n| `color` | `palette` | Swatches; `BRIGHT` is the ten-color set the built-ins use | Hex string |\n| `icon` | `icons` (`value`, `label`, `path`) | Icon buttons, the picked one in the `color` property's color | The icon's `value` |\n| `options` | | The select options editor | Option list |\n| `default` | | A checkbox, select, person or number input for those built-in types; a text box for every other type, yours included | Depends on the type; a `string` for yours |\n| `formula` | | Formula box with field buttons, a functions list and error checking; a formula that does not compile blocks Save | `string` |\n| `columns` | | Chips for the other fields | Column ids |\n\nA `choice` whose options depend on the table passes a function, as the\nbuilt-in Lookup and Rollup do to list the table's link fields:\n\n```ts\nimport type { FieldEditorContext, FieldPropertyDef } from '@svgrid/enterprise/base'\n\nconst numberFields = (ctx: FieldEditorContext) =>\n ctx.columns.filter((c) => c.fieldType === 'number').map((c) => ({ value: String(c.id ?? c.field), label: String(c.header ?? c.field) }))\n\nexport const TARGET: FieldPropertyDef = { key: 'target', label: 'Compare with', kind: 'choice', choices: numberFields }\n```\n\n## The contexts\n\n| Type | Members | Given to |\n|---|---|---|\n| `FieldCellProps<P>` | `ctrl`, `row: { id, original }`, `column: { id, columnDef }`, `value`, `props: P` | `cell` |\n| `FieldEditorContext` | `ctrl`, `props` (draft with defaults), `columns` (every other field, as declared), `self` (the field's own key, id, saved header and draft name, which its formula may not read) | `choices` functions |\n| `FieldFillContext` | `columns` (every column, as declared, this one included) | `fill` |\n| `FieldRowContext` | `props`, `field`, `rows` | `newRowValue`, `onCreate` |\n\n## Look up registered types\n\n`getFieldType(id)` returns a registered type or `undefined`;\n`listFieldTypes()` returns them all in registration order. To offer only\nsome types in the \"+\" column, pass their ids; the picker lists them in\nthat order within each section:\n\n```svelte\n<SvBase data={rows} {columns} columnAdder={{ types: ['text', 'intlPhone', 'trafficLight', 'date'] }} fieldMenu editable />\n```\n\n## Pitfalls\n\n- **Always spread `def` in `fill`**, or the column loses its `field` and\n `fieldProps`.\n- **A formatter should read only `value`.** A grid cell passes\n `{ value, row, column, table }` with `row` the grid's row, and a pinned\n row passes `row: null`. The group header (its first record), the\n expanded record, a form, and gallery and Kanban cards pass\n `{ value, row, column }` with `row` shaped as the grid's,\n `{ id, original }`; in a form `id` is `''`. A linked record's preview,\n automation messages and alert text pass the plain record as `row`. The\n footer summary and the\n chart view's axis and tooltips pass `{ value }` alone.\n- **On a card, editing is off.** In a base's gallery and Kanban views,\n `ctrl` is the grid with the card's width as the column's (220 px on a\n Kanban card) and `editingEnabled` false: a cell that edits in place\n should check it, since the card opens its record for editing. A plain\n `<SvGrid>` gallery or board without a base passes a stand-in with only\n `getColumnWidth`. In a form, `row.original` is the record being filled in.\n- **Converting into your type is only as good as your parser.** Changing a\n text field to Phone stores `(415) 555-0123` as `+14155550123`; a multiple\n select changed to Traffic light reaches the parser as `\"Design, Backend\"`,\n which is no score, so the cell is cleared. Data loaded from elsewhere\n never goes through the parser: read stored values defensively, as\n `scoreOf` does.\n- **`onCreate` runs only when the field is created**, not when an existing\n field changes to your type.\n- **A numeric type needs `cellDataType`.** The filter, the footer\n summaries, the sort labels and `baseToSql` read it from the filled\n column. Without it, Traffic light filters as text, \"contains 7\" is what\n a user can write, and its Postgres column is `text`.\n\n## Next\n\n- [Field types](../fields.md): every built-in type and its properties.\n- [Linked records](./linked-records.md): fields that point at records of another table.\n- [Formulas](./formulas.md): computed fields written as formulas.\n- [Views](./views.md): grid, Kanban, calendar, gallery and chart views of the same records.\n- [Forms](./forms.md): a form view that adds records.\n- [Keep a base in a database](./data.md): load the tables and write every change back.\n"
|
|
4256
|
+
},
|
|
4257
|
+
{
|
|
4258
|
+
"slug": "help/base/data",
|
|
4259
|
+
"path": "docs/help/base/data.md",
|
|
4260
|
+
"title": "Keep a base in a database",
|
|
4261
|
+
"section": "SvGrid Base",
|
|
4262
|
+
"markdown": "# Keep a base in a database\n\nA base can keep its records in your database instead of the page. Give a\ntable a `source` and `<SvBase>` loads the records from it when it opens, then\nwrites every change back: a cell edit, a pasted range, a new record, a\ndeleted one, a link made from either side. Postgres, MySQL, SQLite, Supabase\nand any REST API work the same way, because each is reached through the\ndata source contract the grid already uses.\n\n<div data-docs-demo=\"516-base-postgres\" data-height=\"760\"></div>\n\nThe demo above is SvGrid Base on a real Postgres database running in the\npage (PGlite). Change a status and the log shows the `UPDATE` it ran.\n\n## How it fits together\n\n\n\n- **The page holds every record of a table.** Views, filters, groups,\n summaries, colors and automations all work on those records, so they stay\n instant. A table loads up to 10,000 records by default (`limit`).\n- **The writer sends what changed.** After each change it compares the\n tables with what the database holds and queues an `INSERT` for a new\n record, an `UPDATE` with only the fields that changed, or a `DELETE`. The\n writes go one at a time, in order, so an update never overtakes the insert\n of its own record.\n- **A refused write goes back.** When the database says no (a constraint, a\n row level security policy, a network error), the cell returns to what the\n database holds, the end of the tab bar says why, and a notice shows the\n message.\n- **Changes made elsewhere come in.** With `realtime`, the base reads a table\n again when someone else changed it and takes what moved, without writing\n anything back.\n\n## Give a table a source\n\n`baseSource` wraps any data source:\n\n```ts\nimport { baseSource, type BaseTable } from '@svgrid/enterprise/base'\nimport { createRestDataSource } from '@svgrid/enterprise'\n\nconst tasks: BaseTable = {\n id: 'tasks',\n name: 'Tasks',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Task', fieldType: 'text' },\n { field: 'status', header: 'Status', fieldType: 'singleSelect', fieldProps: { options: [{ value: 'Todo' }, { value: 'Done' }] } },\n ],\n source: baseSource(createRestDataSource({ url: '/api/tasks' })),\n}\n```\n\n```svelte\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n\n enableBase()\n let { tables }: { tables: BaseTable[] } = $props()\n</script>\n\n<SvBase bind:tables editable />\n<SvToaster />\n```\n\n`data` can start empty: the base fills it. `<SvToaster />` shows the notice\nwhen a write is refused; leave it out and the tab bar still says so.\n\nThe data sources that ship with `@svgrid/enterprise`:\n\n| Source | Talks to | Tutorial |\n|---|---|---|\n| `createSupabaseDataSource` | Supabase (PostgREST), from the browser | [Build a base on Supabase](./supabase.md) |\n| `createSqlDataSource` | Any SQL database, through an `execute(sql, params)` you write | [Build a base on Postgres](./postgres.md) |\n| `createKitDataSource` | Your SvelteKit endpoint, which runs `createSqlDataSource` on the server | [Build a base on Postgres](./postgres.md) |\n| `createRestDataSource` | A JSON API: `GET`, `POST`, `PATCH`, `DELETE` | [Build a base on your API](./rest-api.md) |\n| `createInMemoryDataSource` | An array, for tests and prototypes | |\n\nYour own source works too: an object with `getRows`, `createRow`,\n`updateRow` and `deleteRow` (the grid's `ServerDataSource`).\n\n## Link fields\n\n\n\nIn the page a link field holds record ids. `links` says how each one is kept:\n\n```ts\nimport { baseSource } from '@svgrid/enterprise/base'\nimport { createRestDataSource } from '@svgrid/enterprise'\n\nconst taskTags = createRestDataSource({ url: '/api/task_tags' })\n\nconst source = baseSource(createRestDataSource({ url: '/api/tasks' }), {\n links: {\n // A link to one record: the column with the linked record's id.\n project: { column: 'project_id' },\n // A link to many records: a junction table, one row per pair.\n tags: { through: taskTags, from: 'task_id', to: 'tag_id' },\n },\n})\n```\n\nThe field that links back (a project's Tasks, when tasks have a Project\nfield) is worked out in the page from the other side and never stored. Link\na task from the project's side and the base writes that task's\n`project_id`. A link field that is neither in `links` nor the other side of\none is written as it is, a list of ids, which suits a Postgres `text[]` or a\n`jsonb` column.\n\n## Record ids\n\nA new record gets an id in the page at once, so it can be edited and linked\nbefore the database answers. By default that id is its key in the database\ntoo: give the key column a text type.\n\nWhen the database makes the key (a `serial` column, a `uuid` with a default),\nset `serverIds: true`. The record is inserted without the page's id, and the\nwriter keeps the page id for the grid and translates to the database id for\nevery later write and every link that points at the record.\n\n## What is not written\n\nFields the page works out are never sent: formula, lookup, rollup, count and\nbutton fields, and the field that links back. Add any other field to `skip`\nto keep it in the page only.\n\n## Changes made elsewhere\n\n\n\n`realtime` takes a function that calls `onChange` when the table changed and\nreturns a function that stops listening. With Supabase:\n\n```ts\nimport { baseSource } from '@svgrid/enterprise/base'\nimport {\n createSupabaseDataSource,\n createSupabaseRealtime,\n type EntitySchema,\n type SupabaseClientLike,\n type SupabaseRealtimeClientLike,\n} from '@svgrid/enterprise'\n\n// Your supabase-js client: createClient(url, anonKey).\ndeclare const client: SupabaseClientLike & SupabaseRealtimeClientLike\ndeclare const schema: EntitySchema\n\nconst source = baseSource(createSupabaseDataSource({ client, table: 'tasks', schema }), {\n realtime: (onChange) => createSupabaseRealtime({ client, table: 'tasks', onChange }),\n})\n```\n\nAny other feed works the same way: a WebSocket message, a server-sent event,\na poll.\n\n## Status and errors\n\nThe end of the tab bar says what the base is doing:\n\n| It reads | When |\n|---|---|\n| Loading records... | The tables are loading |\n| Saving... | Writes are on their way |\n| All changes saved | The last write went through |\n| Not saved: *message* | A write was refused; the cell went back |\n| Tasks: the first 10,000 records | A table has more records than `limit` |\n\n\n\n## Options\n\n`baseSource(dataSource, options)`:\n\n| Option | Default | What it does |\n|---|---|---|\n| `links` | none | How link fields are stored: `{ column }` or `{ through, from, to }` |\n| `columns` | none | Column names that differ from field names: `{ dueDate: 'due_date' }` |\n| `idField` | `'id'` | The key column |\n| `serverIds` | `false` | The database makes the key of a new record |\n| `realtime` | none | Hear of changes made elsewhere |\n| `skip` | none | Fields kept in the page only |\n| `limit` | `10000` | The most records a table loads |\n\n## Build a base from your database\n\n`baseTablesFromSchemas` makes the tables from your database's own\ndescription of them: a number column becomes a number field, an enum a\nsingle select with its values, a foreign key a link with a field in the\nother table that links back. Read the descriptions with\n`introspectSupabaseTable`, `introspectDatabase` or `introspectOpenApi`, or\nwrite them out:\n\n```ts\nimport { baseTablesFromSchemas } from '@svgrid/enterprise/base'\nimport { createRestDataSource, type EntitySchema } from '@svgrid/enterprise'\n\nconst schemas: EntitySchema[] = [\n {\n name: 'deals',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'title', type: 'text' },\n { field: 'stage', type: 'enum', options: [{ value: 'Open', label: 'Open' }, { value: 'Won', label: 'Won' }] },\n { field: 'company_id', type: 'relation', relation: { entity: 'companies', labelField: 'name' } },\n ],\n },\n { name: 'companies', fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'name', type: 'text' }] },\n]\n\nconst tables = baseTablesFromSchemas(schemas, (schema) => createRestDataSource({ url: `/api/${schema.name}`, schema }))\n```\n\nThe result is ordinary `BaseTable` objects: change a field's type, add views,\nrename a table. [Build a base on your API](./rest-api.md) does this end to end.\n\nThe other way round, `baseToSql(tables)` writes a base as Postgres SQL:\ntables, foreign keys, the tables behind links to many, and the records, with\n`svgrid:` comments for the field settings, views and automations that SQL\nhas no type for. The tables it makes have text keys with a\n`gen_random_uuid()` default, so a record added later gets its key from the\ndatabase. See [Take a base to a database](./quick-start.md#take-a-base-to-a-database).\n\n## When to use the grid instead\n\nA base works on every record of a table in the page, which is right for the\nthousands of records a team keeps in a tracker, a CRM or an inventory. For a\ntable of millions of rows, use `<SvGrid>` with the\n[server row model](../server/server-row-model.md), which sorts, filters and\ngroups in the database and loads only what is on screen.\n"
|
|
4263
|
+
},
|
|
4264
|
+
{
|
|
4265
|
+
"slug": "help/base/forms",
|
|
4266
|
+
"path": "docs/help/base/forms.md",
|
|
4267
|
+
"title": "Collect records with forms",
|
|
4268
|
+
"section": "SvGrid Base",
|
|
4269
|
+
"markdown": "# Collect records with forms\n\nA form view asks for some of a table's fields and adds a record when it is\nsent. Use it for records other people start: a bug report, an expense, a\njob application. This page builds a hiring tracker's application flow.\nCandidates apply through a form, the record lands in the Candidates table,\nand recruiters move it across a Kanban by stage.\n\n<div data-docs-demo=\"514-base-hiring-tracker\" data-height=\"680\"></div>\n\nIn the demo, pick the \"Apply\" view, fill in the form and press Submit, then\nswitch to \"Hiring pipeline\": the new candidate is a card in Applied.\n\n> SvGrid Base is part of the **Suite edition** of `@svgrid/enterprise`. Without a key it runs with a watermark.\n\n## 1. Declare the Candidates table\n\nKeep the fields in one module. The base imports them, and so do the public\npage and the server you build later on this page, so all three agree on\nfield ids and types.\n\n```ts\n// src/lib/hiring.ts\nimport type { BaseTable } from '@svgrid/enterprise/base'\n\nexport const STAGES = [\n { value: 'Applied', color: '#94a3b8' },\n { value: 'Phone screen', color: '#18bfff' },\n { value: 'Onsite', color: '#2d7ff9' },\n { value: 'Offer', color: '#fcb400' },\n { value: 'Hired', color: '#20c933' },\n { value: 'Rejected', color: '#f82b60' },\n]\nexport const SOURCES = [{ value: 'Careers page' }, { value: 'Referral' }, { value: 'Job board' }]\n\nexport const candidateColumns: BaseTable['columns'] = [\n { field: 'name', header: 'Candidate', fieldType: 'text' },\n { field: 'email', header: 'Email', fieldType: 'email' },\n { field: 'phone', header: 'Phone', fieldType: 'phone', description: 'With the country code.' },\n { field: 'role', header: 'Role', fieldType: 'link', fieldProps: { table: 'roles', multiple: false, inverse: 'candidates' } },\n {\n field: 'resume', header: 'Resume', fieldType: 'attachment',\n fieldProps: { accept: '.pdf,.doc,.docx', maxFiles: 1, maxSize: 10 * 1024 * 1024 },\n description: 'One PDF or Word file, up to 10 MB.',\n },\n { field: 'portfolio', header: 'Portfolio', fieldType: 'url', description: 'A site, a repository or a design portfolio.' },\n { field: 'years', header: 'Years of experience', fieldType: 'number', fieldProps: { format: 'integer', allowNegative: false } },\n { field: 'start', header: 'Earliest start', fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'notes', header: 'Notes', fieldType: 'longText' },\n // The team's fields. The form leaves them out.\n { field: 'stage', header: 'Stage', fieldType: 'singleSelect', fieldProps: { options: STAGES, default: 'Applied' } },\n { field: 'source', header: 'Source', fieldType: 'singleSelect', fieldProps: { options: SOURCES } },\n { field: 'recruiter', header: 'Recruiter', fieldType: 'user' },\n { field: 'applied', header: 'Applied', fieldType: 'createdTime' },\n]\n\nexport const roleColumns: BaseTable['columns'] = [\n { field: 'name', header: 'Role', fieldType: 'text' },\n { field: 'candidates', header: 'Candidates', fieldType: 'link', fieldProps: { table: 'candidates', multiple: true, inverse: 'role' } },\n]\n\n/** What a candidate fills in, in order. */\nexport const APPLY_FIELDS: string[] = ['name', 'email', 'phone', 'role', 'resume', 'portfolio', 'years', 'start', 'notes']\n```\n\nThe form labels each field with the column's `header`, the type's icon in\nfront of it. A column's `description` is the help text: the form shows it in\nsmall grey type under the field, and the grid shows it on the info mark in\nthe header.\n\n## 2. Add the form view\n\nA form is a view, next to the pipeline and the grid:\n\n```svelte\n<script lang=\"ts\">\n import { SvBase, enableBase, setFieldUsers, type BaseTable } from '@svgrid/enterprise/base'\n import { APPLY_FIELDS, candidateColumns, roleColumns } from '$lib/hiring'\n\n enableBase()\n setFieldUsers([{ id: 'u1', name: 'Ana Ruiz' }, { id: 'u2', name: 'Ben Okafor' }])\n\n let { candidates, roles }: { candidates: Record<string, unknown>[]; roles: Record<string, unknown>[] } = $props()\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'candidates',\n name: 'Candidates',\n primaryField: 'name',\n data: candidates,\n columns: candidateColumns,\n views: [\n { id: 'pipeline', name: 'Pipeline', type: 'kanban', stackBy: 'stage' },\n { id: 'all', name: 'All candidates', type: 'grid', color: { mode: 'field', field: 'stage' } },\n {\n id: 'apply',\n name: 'Apply',\n type: 'form',\n formFields: APPLY_FIELDS,\n formTitle: 'Join the team',\n formDescription: 'Tell us about yourself. We reply to every application within a week.',\n formRequired: ['name', 'email', 'role', 'resume'],\n formSubmitLabel: 'Send application',\n },\n ],\n },\n { id: 'roles', name: 'Roles', primaryField: 'name', data: roles, columns: roleColumns },\n ])\n</script>\n\n<SvBase bind:tables editable expandRows containerHeight={640} onTablesChange={(t) => console.log('save', t)} />\n```\n\nPick \"Apply\" in the view menu. The grid stays mounted underneath and the\nform covers it: a card with \"Join the team\" as the heading, the description\nunder it, and one box per field. A click on a box opens the editor the cell\nuses: the select panel for a select, the record picker for Role, the date\npicker for Earliest start, the attachment dialog for Resume, the number box\nfor Years of experience. Tab and Shift+Tab move to the next or previous\nfield from inside an editor.\n\n\n\nFor a form view, the view bar keeps the view menu and, when the base runs\nautomations, the Automations button. Hide fields, Filter, Sort, the record\ncount and search are gone.\n\nWithout `formFields`, the form asks for every field a person can fill in:\nthe primary field first, then the rest in the grid's column order, hidden\ncolumns included. Computed types (formula, lookup, rollup, count,\nautonumber, created and modified time and by, button), columns with\n`editable: false` and columns with a `fieldFn` are always left out, even\nwhen `formFields` names them. Without `formTitle`, the heading is the\nview's `name`.\n\n## 3. See what a sent form adds\n\n\n\nPress Submit and the form runs its checks first\n([What the form checks](#what-the-form-checks)). When they pass, it builds\nthe record the way the row adder builds a new row, from three layers, each\nwinning over the one before:\n\n1. Each field's own new-row value: a `fieldProps.default`, today for a date\n with `defaultToday`, the next autonumber, the created time, the current\n user for created by.\n2. The grid's `rowAdder.defaults()`. In a base, set it in the table's\n `props`.\n3. What the form's fields show.\n\nInside `<SvBase tables>` every new record also gets an `id`, even in a table\nwhose `props` set `rowAdder: false`.\n\nThe first two layers also fill the form when it opens: a field the form\nasks for starts with the value the new record would get, and the person\ncan change it. A field the form does not ask for still gets its value, so a\ncandidate who never sees Stage lands in Applied because of\n`default: 'Applied'`.\n\nThe record is appended to the grid, then the grid's `onRowAdd` fires with\nit, and with `tables` the base hands the whole list to `onTablesChange`:\n\n```svelte\n<SvBase bind:tables editable onRowAdd={(e: { row: Record<string, unknown> }) => console.log('new candidate', e.row)} />\n```\n\nThe form then shows \"Thank you\" and \"The record was added.\" with a \"Submit\nanother\" button, which starts a new record with the defaults again. A form\nview starts a new record each time it opens, so switching to another view\ndrops what was typed.\n\nA sent record holds values in the shapes the cells use:\n\n| Field type | Value |\n|---|---|\n| `text`, `longText`, `email`, `url`, `phone` | A string, as typed |\n| `number` | A number, rounded to the field's precision |\n| `date` without time | `'2026-11-02'` |\n| `singleSelect` | The option's `value` |\n| `link` | An array of record ids, also with `multiple: false`: `['r2']` |\n| `attachment` | An array of [attachments](../fields.md#attachments) |\n| `user` | The person's id |\n\nEach value goes through the same coercion and column `valueParser` as a\ncell edit, so a custom field type parses the same in a form. A field left\nblank is not in the record. A field that showed a default and was cleared\nis sent empty (`''`, `null` or `[]`, as its editor leaves it): the person\nremoved the default, so it does not come back.\n\n## 4. Put the form on a public page\n\nCandidates should not open your base. Give them a page that draws only the\nform and posts what they send to your server, while the team keeps working\nin the base.\n\n`SvBaseForm` is the form the view draws, as a component. It reads its fields\nfrom a grid through that grid's api, so the page mounts a grid with the\nCandidates columns and no rows, out of sight:\n\n```svelte\n<!-- src/routes/apply/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { SvBaseForm, enableBase, registerLinkedTable } from '@svgrid/enterprise/base'\n import { APPLY_FIELDS, candidateColumns } from '$lib/hiring'\n\n enableBase()\n\n let { data }: { data: { roles: Array<{ id: string; name: string }> } } = $props()\n\n // The record picker for Role lists the open roles, and only those. In an\n // effect: it runs in the browser only (the list is page-wide, so a server\n // render must not set it for every visitor), and it unregisters on leave.\n $effect(() => registerLinkedTable({ id: 'roles', name: 'Roles', primaryField: 'name', rows: () => data.roles }))\n\n // Raw: the form finds the grid by the api object itself.\n let api = $state.raw<any>(null)\n\n // The form waits on the Promise: thanks when it resolves, the message when it rejects.\n async function send(row: Record<string, unknown>) {\n const res = await fetch('/apply', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(row),\n })\n if (!res.ok) throw new Error('Your application did not reach us. Press Send application again.')\n }\n</script>\n\n<div class=\"store\" aria-hidden=\"true\">\n <SvGrid data={[]} columns={candidateColumns} onApiReady={(a) => (api = a)} />\n</div>\n\n{#if api}\n <SvBaseForm\n {api}\n fields={APPLY_FIELDS}\n required={['name', 'email', 'role', 'resume']}\n title=\"Apply to Acme\"\n description=\"Tell us about yourself. We reply to every application within a week.\"\n submitLabel=\"Send application\"\n onSubmit={send}\n />\n{/if}\n\n<style>\n .store { position: absolute; width: 1px; height: 1px; overflow: hidden; visibility: hidden; }\n</style>\n```\n\n```ts\n// src/routes/apply/+page.server.ts\ndeclare const db: { openRoles: () => Promise<Array<{ id: string; name: string }>> }\n\nexport async function load() {\n return { roles: await db.openRoles() }\n}\n```\n\nA required field shows a red asterisk after its label. Press \"Send\napplication\" with Email empty and its box turns red, with \"Email is\nrequired\" under it; nothing is sent. Type `ana@` and the line reads \"Not a\nvalid email address\". The message goes away as soon as the field gets a new\nvalue.\n\n`send` returns a Promise, so the form waits for the server, with the button\ndisabled. When the Promise resolves, the form shows \"Thank you\". When it\nrejects, the form takes the row back out of the hidden grid, keeps every\nfield as it was, and shows the error's message above the button; pressing\nthe button again sends the same record. An error with no message shows \"The\nrecord could not be sent. Try again.\" An `onSubmit` that returns nothing\nshows \"Thank you\" at once.\n\nThe form draws with the `--sg-*` tokens (`--sg-bg`, `--sg-fg`,\n`--sg-border`, `--sg-muted`, `--sg-accent`, `--sg-accent-fg`,\n`--sg-danger`, `--sg-font`), so the page's theme styles it.\n\n## 5. Prefill hidden values from the URL\n\nA job post links to `/apply?role=r2&source=Referral`. The candidate should\nnot pick the role again, and should never see Source. Read both in `load`,\ncheck them, and hand them to the hidden grid as its `rowAdder.defaults`:\nthe form starts every record from them, and what is typed wins over them.\nThis `load` replaces the one in step 4:\n\n```ts\n// src/routes/apply/+page.server.ts\nimport { SOURCES } from '$lib/hiring'\n\ndeclare const db: { openRoles: () => Promise<Array<{ id: string; name: string }>> }\n\nexport async function load({ url }: { url: URL }) {\n const roles = await db.openRoles()\n const role = roles.find((r) => r.id === url.searchParams.get('role'))?.id ?? null\n const asked = url.searchParams.get('source')\n const source = SOURCES.some((s) => s.value === asked) ? asked : 'Careers page'\n return { roles, role, source }\n}\n```\n\nIn the page, leave the role out of the fields when the link named one, and\ngive the hidden grid the defaults. Source is not among the fields, so it\nstays hidden and still lands in the record. The rest of the page is as in\nstep 4:\n\n```svelte\n<script lang=\"ts\">\n import { APPLY_FIELDS } from '$lib/hiring'\n // ...the other imports, registerLinkedTable, api and send from step 4\n\n let { data }: { data: { roles: Array<{ id: string; name: string }>; role: string | null; source: string } } = $props()\n\n const fields = $derived(data.role ? APPLY_FIELDS.filter((f) => f !== 'role') : APPLY_FIELDS)\n const rowAdder = $derived({\n defaults: () => ({ source: data.source, ...(data.role ? { role: [data.role] } : {}) }),\n })\n</script>\n\n<div class=\"store\" aria-hidden=\"true\">\n <SvGrid data={[]} columns={candidateColumns} {rowAdder} onApiReady={(a) => (api = a)} />\n</div>\n\n{#if api}\n <SvBaseForm {api} {fields} required={['name', 'email', 'resume']} submitLabel=\"Send application\" onSubmit={send} />\n{/if}\n```\n\nA link value is an array of ids, so the default is `[data.role]`. Drop\n`'role'` from `required` too: a required id that is not among the fields is\nignored, but leaving it there reads as if the role were still asked for.\n\n## 6. Upload the resume\n\nWith no uploader, a file added in the form stays in the browser as a `blob:`\nURL, and the server receives a link it cannot open. Set an uploader on the\npublic page:\n\n```ts\nimport { setAttachmentUploader } from '@svgrid/enterprise/base'\n\nsetAttachmentUploader(async (file, { signal, onProgress }) => {\n const res = await fetch(`/apply/upload?name=${encodeURIComponent(file.name)}`, {\n method: 'PUT',\n body: file,\n signal,\n headers: { 'content-type': file.type || 'application/octet-stream' },\n })\n if (!res.ok) throw new Error(res.status === 413 ? 'Larger than 10 MB' : `Upload failed (${res.status})`)\n onProgress(1)\n const { url } = (await res.json()) as { url: string }\n return { id: url, url, name: file.name, type: file.type, size: file.size }\n})\n```\n\n```ts\n// src/routes/apply/upload/+server.ts\nimport { error, json } from '@sveltejs/kit'\n\n// Returns the stored file's URL, under https://files.example.com/resumes/.\ndeclare const storage: { put: (key: string, body: ArrayBuffer, type: string) => Promise<string> }\n\nconst TYPES = new Set([\n 'application/pdf',\n 'application/msword',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n])\n\nexport async function PUT({ request, url }: { request: Request; url: URL }) {\n const type = request.headers.get('content-type') ?? ''\n if (!TYPES.has(type)) error(415, 'Send a PDF or a Word file')\n const body = await request.arrayBuffer()\n if (body.byteLength > 10 * 1024 * 1024) error(413, 'Larger than 10 MB')\n const name = (url.searchParams.get('name') ?? 'resume').replace(/[^\\w.-]+/g, '_').slice(0, 100)\n return json({ url: await storage.put(`resumes/${crypto.randomUUID()}-${name}`, body, type) })\n}\n```\n\nClick the Resume box (\"Add files\") and the attachment dialog opens: pick,\ndrop or paste a file, or paste a URL. Before uploading, the dialog checks\nthe field's `maxFiles` (default 20), `maxSize` (default 25 MB) and\n`accept`, and lists each refused file with its name and the reason:\n\"At most 1 file\", \"Larger than 10 MB\", \"Not an accepted type\". A file added\nby URL gets the `maxFiles` and `accept` checks; its type comes from the\nname's extension, so with `accept: '.pdf,.doc,.docx'` a link to `cv.pdf` is\ntaken and a link with no extension is refused. A file that fails to upload\nstays in the dialog with Retry. The upload runs when the file is added, not\nwhen the form is sent, and Done waits for uploads still running.\n\nAn uploader for one column only goes in that column's `fieldProps.upload`;\nit wins over the app-wide one. Both get the file, an abort signal (aborted\nwhen the candidate removes the file or cancels the dialog), `onProgress`,\nthe column id and `row`, which in a form is what the fields show so far,\nwith no record id yet.\n\n## 7. Check it again on the server\n\nThe form's checks help a person filling it in. Anyone can post to `/apply`\nwithout it, so the endpoint checks every field again with the same\n`linkHref` the form uses for email, URL and phone, keeps only the fields a\ncandidate may set, and sets the team's fields itself:\n\n```ts\n// src/routes/apply/+server.ts\nimport { error, json } from '@sveltejs/kit'\nimport { linkHref } from '@svgrid/enterprise/base'\nimport { SOURCES } from '$lib/hiring'\nimport { automations } from '$lib/server/automations'\n\ndeclare const db: {\n roleIsOpen: (id: string) => Promise<boolean>\n insertCandidate: (row: Record<string, unknown>) => Promise<string>\n}\n\n// Where the upload endpoint in step 6 stores resumes.\nconst FILES = 'https://files.example.com/resumes/'\n\nexport async function POST({ request }: { request: Request }) {\n const body = (await request.json()) as Record<string, unknown>\n const text = (k: string, max = 200) => {\n const v = body[k]\n return typeof v === 'string' ? v.trim().slice(0, max) : ''\n }\n\n const name = text('name')\n if (!name) error(400, 'Candidate is required')\n const email = text('email')\n if (!linkHref('email', email)) error(400, 'Not a valid email address')\n const phone = text('phone', 40)\n if (phone && !linkHref('phone', phone)) error(400, 'Not a valid phone number')\n const portfolio = text('portfolio', 500)\n if (portfolio && !linkHref('url', portfolio)) error(400, 'Not a valid web address')\n\n const role = Array.isArray(body.role) ? String(body.role[0] ?? '') : ''\n if (!(await db.roleIsOpen(role))) error(400, 'Pick an open role')\n\n const resume = Array.isArray(body.resume) ? body.resume.slice(0, 1) : []\n if (!resume.length || !resume.every((a) => typeof a?.url === 'string' && a.url.startsWith(FILES))) error(400, 'Upload a resume')\n\n const years = body.years == null ? null : Number(body.years)\n if (years != null && !(Number.isInteger(years) && years >= 0 && years <= 60)) error(400, 'Years is not a whole number')\n\n const start = text('start', 10)\n if (start && !/^\\d{4}-\\d{2}-\\d{2}$/.test(start)) error(400, 'Earliest start is not a date')\n\n const source = SOURCES.some((s) => s.value === body.source) ? body.source : 'Careers page'\n\n const id = await db.insertCandidate({\n name, email, phone, role: [role], resume, portfolio, years, start, source,\n notes: text('notes', 5000),\n // Never from the request: these belong to the team.\n stage: 'Applied', recruiter: null, applied: new Date().toISOString(),\n })\n // Step 8: the \"A form is sent\" automations that run on the server.\n await automations.formSubmitted('candidates', id, 'apply')\n return json({ id })\n}\n```\n\nThe form shows the `error()` message only if `send` passes it on; the one\nin step 4 shows its own line for any failure.\n\nTo see new applications in the base without a reload, give the Candidates\ntable a `source` with `realtime`:\n[Keep a base in a database](./data.md#changes-made-elsewhere).\n\n## 8. Run an automation when a form is sent\n\nIn the Automations dialog the trigger is \"A form is sent\", with a Form\npicker: \"Any form of this table\" or one form view. In code it is\n`formSubmitted`, and `view` names the form:\n\n```ts\nimport type { BaseAutomation } from '@svgrid/enterprise/base'\n\nexport const candidateAutomations: BaseAutomation[] = [\n {\n id: 'new-application',\n name: 'Announce a new application',\n enabled: true,\n trigger: { type: 'formSubmitted', view: 'apply' },\n actions: [{ type: 'notify', message: 'New application from {Candidate}', severity: 'info' }],\n },\n {\n id: 'thank-you-email',\n name: 'Thank the candidate',\n enabled: true,\n runOn: 'server',\n trigger: { type: 'formSubmitted', view: 'apply' },\n actions: [\n {\n type: 'email',\n to: '{Email}',\n subject: 'We got your application',\n body: 'Hi {Candidate}, thank you for applying. We reply within a week.',\n },\n ],\n },\n]\n```\n\nPut them on the table (`automations: candidateAutomations`) and add one\n`<SvToaster />` from `@svgrid/grid` for the notice. In the base, a form view\nsends its record to the table's automations with the view's `id`. They need\n`<SvBase tables>`: with `data` and `columns` a form still adds records, but\nno automation runs.\n\nThe public page has no base and no browser runner, so the endpoint in step 7\ntells the [automation server](./automations.md#on-the-server) after the\ninsert. The view id there is whatever string your automations name. Only\nautomations with `runOn: 'server'` run on the server. If it has not seen\nthe record yet, it reads the table again first, and that reading also runs\nthe table's \"A record is created\" server automations for the new record.\n\n## SvBaseForm props\n\n| Prop | Type | Default | What it does |\n|---|---|---|---|\n| `api` | grid api | required | The api of the grid the record is added to, from its `onApiReady`. Nothing draws until that grid has drawn once. |\n| `fields` | `ReadonlyArray<string>` | every field a person can fill in | Column ids, in order. An id that is not such a field is skipped. |\n| `required` | `ReadonlyArray<string>` | `[]` | Column ids that must have a value before the record is added. |\n| `title` | `string` | `'New record'` | The heading. |\n| `description` | `string` | `''` | The line under the heading. |\n| `submitLabel` | `string` | `'Submit'` | The button's text. |\n| `defaults` | `() => Record<string, unknown>` | | Values each record from this form starts with. The grid's `rowAdder.defaults()` wins over them. Called once per record. `<SvBase tables>` passes one that gives an `id`. |\n| `onSubmit` | `(row: Record<string, unknown>) => void \\| Promise<unknown>` | | Called after the row was appended to the grid and the grid's `onRowAdd` ran. A Promise keeps the button disabled until it settles: resolved shows \"Thank you\", rejected takes the row back out of the grid, keeps the fields and shows the error's message. |\n\n## Form view keys\n\n| Key | Read as |\n|---|---|\n| `type` | `'form'` |\n| `name` | The view's name in the menu; the heading when there is no `formTitle` |\n| `formFields` | The fields it asks for, in order |\n| `formTitle` | The heading |\n| `formDescription` | The line under it |\n| `formRequired` | Column ids that must have a value, as `required` on `SvBaseForm` |\n| `formSubmitLabel` | The button's text (default `'Submit'`) |\n\nA record the view sends reaches `onRowAdd`, `onTablesChange` and the\n`formSubmitted` automations, which get the view's `id`.\n\n## What the form checks\n\n- **Required**: empty means no value, an empty string or an empty list. A\n checkbox that is off and a number that is 0 count as filled. The message\n is \"{header} is required\".\n- **Number and duration**: the editor refuses text that is not a number\n (\"Not a number\") and a negative where `allowNegative: false` (\"Negative\n numbers are not allowed\"), and keeps the box open. A click outside the\n box while it shows the error drops what was typed. A value is rounded to\n the field's precision.\n- **Attachment**: `maxFiles`, `maxSize` and `accept`, file by file, before\n uploading. A file added by URL is checked against `maxFiles` and `accept`\n (by the name's extension); its size is not known, so `maxSize` does not\n apply.\n- **Email, URL, phone**: on submit, a value must be one the cell can link:\n an address for `mailto:`, an http or https address (a bare host such as\n `svgrid.com/docs` counts), or 3 to 20 digits with an optional leading `+`\n and spaces, dots, dashes or parentheses. The messages are \"Not a valid\n email address\", \"Not a valid web address\" and \"Not a valid phone number\".\n An empty field is not checked.\n- **The column's `validate`**: on submit, the form calls it as the grid\n does, with `value`, `row` (what the fields show), `rowIndex: -1` and\n `column`. `false` shows \"{header} is not valid\"; a string shows that\n string. It runs on empty fields too, and it blocks the submit even\n without `rejectInvalid`.\n- **Select**: only the field's options. Text that matches none shows \"No\n option matches\"; a form never offers \"Add option\".\n- **Link**: only records of the linked table, plus `Create \"...\"` for the\n typed name when that table has `create`.\n\nA failed check shows its message under the field and nothing is sent. The\nmessage goes away when that field gets a new value.\n\n## Pitfalls\n\n- **\"Thank you\" waits only for a Promise.** If `onSubmit` posts but does\n not return the Promise (or throw), the form thanks the person before the\n server answers. Return it, as `send` in step 4 does.\n- **A rejected `onSubmit` comes after `onRowAdd`.** On a rejection the form\n takes the row back out of the grid, but anything your `onRowAdd` did with\n it stays done. Save in one of the two, not both.\n- **Automatic values come from the browser.** On the public page the hidden\n grid has no rows, so an autonumber is always 1, and a created time is the\n visitor's clock. Set them on the server.\n- **One form sets off two triggers.** In `<SvBase tables>`, a sent form runs\n the \"A record is created\" automations, then the \"A form is sent\" ones. An\n automation on each that does the same thing does it twice.\n- **Uploads outlive abandoned forms.** A file is uploaded when it is added,\n so a candidate who adds a resume and leaves leaves a file behind. Remove\n stored files no record points to.\n\n## Next\n\n- [Automations](./automations.md): the other triggers, and the server\n runner the public page reports to.\n- [Keep a base in a database](./data.md): give Candidates a source so\n applications show up for the team.\n- [Saved views](./views.md): the Kanban the recruiters work in, its stacks\n and colors.\n- [Linked records](./linked-records.md): the Role link and the Candidates\n list on each role.\n- [Field types](../fields.md#declare-columns-by-type): every type's\n `fieldProps`, including `default` and `defaultToday`.\n"
|
|
4270
|
+
},
|
|
4271
|
+
{
|
|
4272
|
+
"slug": "help/base/formulas",
|
|
4273
|
+
"path": "docs/help/base/formulas.md",
|
|
4274
|
+
"title": "Computed fields with formulas",
|
|
4275
|
+
"section": "SvGrid Base",
|
|
4276
|
+
"markdown": "# Computed fields with formulas\n\nA formula field works out its value from the other fields of the same\nrecord: a stock value from quantity and unit cost, a status from a reorder\nlevel, a date 90 days after the last count. Nobody types into it, and views,\nfooters, charts and automations read it like any other field.\n\n<div data-docs-demo=\"515-base-inventory\" data-height=\"680\"></div>\n\n> SvGrid Base is part of the **Suite edition** of `@svgrid/enterprise`. Without a key it runs with a watermark.\n\nThis page builds a small inventory: Items with a quantity, a unit cost and a\nreorder level, then a Suppliers table they link to. In the demo above,\nchange an item's Qty or Unit cost and Stock value and the footer total\nfollow.\n\n## 1. Start with the stored fields\n\nFormulas need something to read. Declare the Items table with the fields\npeople type in:\n\n```svelte\n<script lang=\"ts\">\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n\n enableBase()\n\n const items = [\n { id: 1, item: 'Arc floor lamp', qty: 4, unitCost: 410, reorder: 5, audited: '2026-09-05' },\n { id: 2, item: 'Table lamp, black', qty: 9, unitCost: 160, reorder: 4, audited: '2026-09-21' },\n { id: 3, item: 'Stoneware mug, slate', qty: 48, unitCost: 18, reorder: 24, audited: '2026-10-02' },\n { id: 4, item: 'Espresso cup, forest', qty: 0, unitCost: 12, reorder: 12, audited: '2026-08-14' },\n { id: 5, item: 'Bookshelf speaker', qty: 2, unitCost: 380, reorder: 2, audited: '2026-09-30' },\n ]\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'items',\n name: 'Items',\n primaryField: 'item',\n data: items,\n columns: [\n { field: 'item', header: 'Item', fieldType: 'text' },\n { field: 'qty', header: 'Qty', fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },\n { field: 'unitCost', header: 'Unit cost', fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 2 } },\n { field: 'reorder', header: 'Reorder level', fieldType: 'number', fieldProps: { format: 'integer' } },\n { field: 'audited', header: 'Last counted', fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n ],\n views: [{ id: 'stock', name: 'Stock', type: 'grid' }],\n },\n ])\n</script>\n\n<SvBase bind:tables editable sortable summary columnAdder fieldMenu containerHeight={560} />\n```\n\n`summary` turns on the footer row; `columnAdder` and `fieldMenu` let people\nadd and edit fields, which step 8 uses.\n\n## 2. Add a formula field\n\nAdd one column to the list:\n\n```ts\nconst stockValue = {\n field: 'value',\n header: 'Stock value',\n fieldType: 'formula',\n fieldProps: { formula: '{Qty} * {Unit cost}', result: 'currency', precision: 2, summary: 'sum' },\n}\n```\n\nA field reference is a name in braces. It matches a field's header or its\n`field` key, ignoring case and the spaces around it, so `{Qty}`, `{qty}` and\n`{ QTY }` all read `qty`, and `{unitCost}` reads the same field as\n`{Unit cost}`.\n\nThe formula is compiled once into a function. Each time a cell, a sort or a\nfooter needs the value, that function runs on the record, and `result` and\n`precision` decide how the number is shown:\n\n\n\nWhat you see:\n\n- Arc floor lamp shows `$1,640.00`, the espresso cups `$0.00`. The column is\n right-aligned because the result is a currency.\n- The footer shows the sum of the column, `$4,704.00`, formatted like the\n cells.\n- The cells are read-only. Edit Qty and the stock value changes as you\n commit the edit.\n- A form view leaves the field out: there is nothing to type.\n- The value is never stored in the record. A base kept in a database does\n not write it either (see [What is not written](./data.md#what-is-not-written)).\n\n[Format the result](#format-the-result) has the details of `result`,\n`precision` and `summary`.\n\n## 3. Branch with IF\n\nA status that says whether to reorder, and how many to order:\n\n```ts\nconst status = {\n field: 'status',\n header: 'Status',\n fieldType: 'formula',\n fieldProps: { formula: 'IF({Qty} = 0, \"Out\", IF({Qty} <= {Reorder level}, \"Low\", \"OK\"))' },\n}\nconst toOrder = {\n field: 'toOrder',\n header: 'To order',\n fieldType: 'formula',\n fieldProps: { formula: 'IF({Qty} <= {Reorder level}, {Reorder level} * 2 - {Qty}, 0)', result: 'number', precision: 0 },\n}\n```\n\nStatus reads Low for the lamp (4 is at or under 5), OK for the table lamp and\nthe mugs, Out for the espresso cups and Low for the speaker (2 is at its level\nof 2). To order shows 6, 0, 0, 24 and 2.\n\n`IF(condition, then, else)` works out only the branch it takes, so\n`IF({Qty} = 0, 0, 1000 / {Qty})` never divides by zero. Without the third\nargument the field is empty when the condition is false. `SWITCH` is lazy\nthe same way: only the result it returns is worked out.\n\nA comparison on its own is a value too: `{Qty} <= {Reorder level}` shows\n`true` or `false` in the cell.\n\n## 4. Join text\n\n`&` joins values into text:\n\n```text\n{Item} & \" (\" & {Qty} & \" in stock)\"\n```\n\ngives `Arc floor lamp (4 in stock)`. `CONCATENATE({Item}, \" x\", {Qty})` does\nthe same with a function: `Arc floor lamp x4`.\n\nNumbers join as plain numbers, without the format of the field they came\nfrom. `\"$\" & {Unit cost} * 1.15` gives `$471.49999999999994`; round first,\nand it gives `$471.5`:\n\n```text\n\"$\" & ROUND({Unit cost} * 1.15, 2)\n```\n\n`*` comes before `&`, so the multiplication needs no parentheses. An empty\nfield joins as nothing: an Arc floor lamp with no Qty shows\n`Arc floor lamp ( in stock)`.\n\n## 5. Work with dates\n\nWhen each item is due for its next count, and how long since the last one:\n\n```ts\nconst nextCount = {\n field: 'nextCount',\n header: 'Next count',\n fieldType: 'formula',\n fieldProps: { formula: 'DATEADD({Last counted}, 90, \"days\")', result: 'date', dateFormat: 'friendly' },\n}\nconst sinceCount = {\n field: 'sinceCount',\n header: 'Days since count',\n fieldType: 'formula',\n fieldProps: { formula: 'DATETIME_DIFF(TODAY(), {Last counted}, \"days\")', result: 'number', precision: 0 },\n}\n```\n\nNext count shows `December 4, 2026` for the lamp: `DATEADD` returns a date,\nand a date shows in the field's `dateFormat`. Days since count is 39 on\nOctober 14, 2026.\n\nA date field stores a day as text, `2026-09-05`. A formula reads it as that\nday at local midnight, the same way the cell shows it and the way `TODAY()`\nis, so `DAY`, `WEEKDAY` and `DATETIME_DIFF` give the same answer in every\ntime zone.\n\nOther text is a date only when it has a four-digit year and a separator:\n`\"October 5, 2026\"` and `\"10/05/2026\"` are dates, `\"1\"` and `\"12\"` are not,\nso `YEAR(\"1\")` is empty.\n\nTo put a date into text, turn it into `YYYY-MM-DD` with `DATESTR` first.\nJoined as it is, a date becomes a UTC timestamp such as\n`2026-12-03T22:00:00.000Z`:\n\n```text\n\"Count by \" & DATESTR(DATEADD({Last counted}, 90, \"days\"))\n```\n\ngives `Count by 2026-12-04`.\n\n`DATETIME_DIFF(a, b, unit)` is `a` minus `b`, cut to whole units, in\nseconds when there is no unit. `DATEADD(d, n, unit)` adds `n` units, days\nwhen there is no unit. Both take the same units:\n\n| Unit | Also written |\n|---|---|\n| `milliseconds` | `millisecond`, `ms` |\n| `seconds` | `second`, `s` |\n| `minutes` | `minute`, `m` |\n| `hours` | `hour`, `h` |\n| `days` | `day`, `d` |\n| `weeks` | `week`, `w` |\n| `months` | `month` |\n| `years` | `year`, `y` |\n\nCase does not matter, so `\"M\"` is minutes too. Days, weeks, months and years\ncount calendar days, so a daylight-saving change in between does not cut a\nday short. Adding months keeps the day inside the month: January 31 plus one\nmonth is February 28. A unit written in the formula that is not in the table\nis an error the editor shows; one read from a field gives an empty cell.\n\nTo test a date, compare it with another date:\n\n```text\nIF(DATEADD({Last counted}, 90, \"days\") < TODAY(), \"Count now\", \"\")\n```\n\nWhen one side is a date value (from `TODAY()`, `DATEADD` and the like), the\nother side is read as a date too, so `{Last counted} < TODAY()` works.\n`IS_BEFORE(a, b)` and `IS_AFTER(a, b)` read both sides as dates whatever\nthey are, and are false when either side is empty.\n\n## 6. Read another table\n\nAdd a Suppliers table and link each item to one. A formula cannot follow a\nlink by itself; a lookup brings the linked field into the record, and the\nformula reads the lookup.\n\n```ts\nconst suppliers = [\n { id: 's1', name: 'Lumen & Co', leadDays: 10, items: ['1', '2'] },\n { id: 's2', name: 'Kiln Ceramics', leadDays: 21, items: ['3', '4'] },\n { id: 's3', name: 'Signal Audio', leadDays: 7, items: ['5'] },\n]\n\n// Items: each row gets supplier: ['s1'] and so on, plus these columns.\nconst itemLinks = [\n { field: 'supplier', header: 'Supplier', fieldType: 'link', fieldProps: { table: 'suppliers', multiple: false, inverse: 'items' } },\n { field: 'supplierName', header: 'Supplier name', fieldType: 'lookup', fieldProps: { linkField: 'supplier', field: 'name' } },\n { field: 'leadTime', header: 'Lead time', fieldType: 'lookup', fieldProps: { linkField: 'supplier', field: 'leadDays' } },\n {\n field: 'reorderNote',\n header: 'Reorder note',\n fieldType: 'formula',\n fieldProps: { formula: 'IF({Qty} <= {Reorder level}, \"Order \" & ({Reorder level} * 2 - {Qty}) & \" from \" & {Supplier name}, \"\")' },\n },\n {\n field: 'arrives',\n header: 'Arrives by',\n fieldType: 'formula',\n fieldProps: { formula: 'DATEADD(TODAY(), {Lead time}, \"days\")', result: 'date', dateFormat: 'friendly' },\n },\n]\n\nconst supplierTable = {\n id: 'suppliers',\n name: 'Suppliers',\n primaryField: 'name',\n data: suppliers,\n columns: [\n { field: 'name', header: 'Supplier', fieldType: 'text' },\n { field: 'leadDays', header: 'Lead time (days)', fieldType: 'number', fieldProps: { format: 'integer' } },\n { field: 'items', header: 'Items', fieldType: 'link', fieldProps: { table: 'items', multiple: true, inverse: 'supplier' } },\n { field: 'itemCount', header: 'Item count', fieldType: 'count', fieldProps: { linkField: 'items' } },\n { field: 'stockValue', header: 'Stock value', fieldType: 'rollup', fieldProps: { linkField: 'items', field: 'value', aggregation: 'SUM' } },\n { field: 'avgValue', header: 'Average value', fieldType: 'formula', fieldProps: { formula: '{Stock value} / {Item count}', result: 'currency', precision: 2 } },\n ],\n}\n```\n\nReorder note reads `Order 6 from Lumen & Co` for the lamp. A lookup is a\nlist, one value per linked record: joined into text it reads as the values\nseparated by commas, and in arithmetic it adds them up, so `{Lead time}` is\n10 for the lamp. With no supplier the list is empty, which joins as nothing\nand counts as 0.\n\nIt works the other way too. The Suppliers rollup sums Stock value, a\nformula in Items, and shows `$3,080.00` for Lumen & Co in the formula's\nformat. Average value is a formula over that rollup and the count:\n`$1,540.00`. A supplier with no items divides by zero and shows an empty\ncell.\n\nRead the link field itself and you get record ids: `{Supplier}` is `s1`, not\nthe name. Use a lookup of the linked table's primary field for the name.\n[Tables and links](../fields.md#tables-and-links) has the rest of the link,\nlookup and rollup setup.\n\n## 7. Use the computed values\n\nA formula field sorts, filters, groups and colors rows by what it computes,\nand its footer summary reads the same values:\n\n```ts\nimport type { FieldView } from '@svgrid/enterprise/base'\n\nconst views: FieldView[] = [\n { id: 'stock', name: 'Stock', type: 'grid', state: { grouping: ['status'], sorting: [{ id: 'value', desc: true }] } },\n {\n id: 'reorder',\n name: 'Reorder',\n type: 'grid',\n color: {\n mode: 'conditions',\n rules: [\n { color: '#f82b60', condition: { column: 'status', kind: 'text', op: 'is', value: 'Out' } },\n { color: '#fcb400', condition: { column: 'status', kind: 'text', op: 'is', value: 'Low' } },\n ],\n },\n state: {\n advancedFilter: {\n kind: 'fieldFilter',\n conjunction: 'or',\n conditions: [\n { column: 'status', kind: 'text', op: 'is', value: 'Low' },\n { column: 'status', kind: 'text', op: 'is', value: 'Out' },\n ],\n },\n },\n },\n { id: 'value', name: 'Value by supplier', type: 'chart', chart: { kind: 'bar', x: 'supplier', y: 'value' } },\n]\n```\n\nStock groups the items by Status, highest value first in each group.\nReorder shows the three items that need ordering and colors the espresso\ncups red, the other two yellow. The chart adds up Stock value per supplier.\n\nIn the charts, a formula can be the value added up only when its `result`\nis `number`, `currency` or `percent`. Any formula can be the field a chart\ngroups by, and it groups by the computed value: a chart of Status has the\nbars Low, OK and Out. A date result groups by month, as a date field does.\nA lookup can group a chart too, one group per linked value.\n\nIn the view bar's filter, a formula field follows its `result`. `number`,\n`currency` and `percent` get `=`, `<`, `>=` and the other number operators;\n`date` gets is before, is within and the other date operators. With\n`result: 'auto'` the column's `cellDataType` decides (`'number'` or\n`'date'`), and without one the field gets the text operators (contains,\ndoes not contain, is, is not, is empty, is not empty). A condition written\nin code names its kind itself:\n`{ column: 'value', kind: 'number', op: 'gte', value: 1000 }`.\n\nAutomations see computed values as well. A condition on a formula fires\nwhen the formula's result starts to match, and `{Field name}` in a message\nis the formatted value. A token matches a header or a `field` key the way a\nformula does, ignoring case and outer spaces, so `{reorder note}` works too:\n\n```ts\nimport type { BaseAutomation } from '@svgrid/enterprise/base'\n\nconst lowStock: BaseAutomation = {\n id: 'low-stock',\n name: 'Low stock',\n enabled: true,\n trigger: { type: 'recordMatches' },\n condition: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'status', kind: 'text', op: 'is', value: 'Low' }] },\n actions: [{ type: 'notify', message: '{Item}: {Reorder note}', severity: 'warning' }],\n}\n```\n\nThe [OKR template](./templates.md#okrs) does this with a number: its\n\"Target reached\" automation runs when Progress, a formula, reaches 1.\n\n## 8. Write a formula in the field editor\n\nPeople who use the base can add formulas without code. Press \"+\" after the\nlast column (or Edit field in a column's menu), name the field and pick\nFormula under \"Computed and automatic\". The editor shows:\n\n- **Formula**: a text box. As you type, a message under it says what is\n wrong (`No field named \"Unit price\"`, `Unknown function SUMM`) and the box\n is marked invalid.\n- A button for each other field. A click puts `{Field name}` where the cursor\n is.\n- **Functions**: shows the name of every function the formula can call\n (Hide functions closes the list).\n- **Format the result as**: As computed, Number, Currency, Percent or Date,\n then Precision for a number, currency or percent, Currency symbol for a\n currency, and Date format for a date.\n\n\n\nWhile the message shows, Save (Create field for a new field) is disabled,\nand its tooltip repeats the message. A formula left empty can be saved: its\ncells stay empty until someone writes it.\n\nA formula cannot read its own field. The editor will not save\n`{Stock value} + 1` as the formula of Stock value, and the same formula\ndeclared in code shows `#ERROR` in every cell.\n\nThe formula is kept as `fieldProps.formula`; `onColumnAdd` and\n`onColumnChange` hand back the column list with it, ready to store (see\n[Create and edit fields](../fields.md#create-and-edit-fields)).\n\n## The formula language\n\n| Part | Written as | Notes |\n|---|---|---|\n| Field | `{Unit cost}` | A header or `field` key, any case |\n| Number | `12`, `0.5`, `.5` | No thousands separators |\n| Text | `\"Low\"` or `'Low'` | `\\` before a character keeps it: `\"say \\\"hi\\\"\"` |\n| True and false | `TRUE`, `FALSE` | Any case |\n| Function | `ROUND({Qty} * 1.1, 0)` | Any case: `round(...)` works |\n| Grouping | `( ... )` | |\n\nOperators, from the first applied to the last:\n\n| Operators | Does |\n|---|---|\n| `-` before a value | Negates it |\n| `*` `/` | Multiply, divide. Dividing by zero gives an empty value |\n| `+` `-` | Add, subtract |\n| `&` | Joins as text |\n| `=` `!=` `<>` `<` `>` `<=` `>=` | Compare; `<>` is the same as `!=` |\n\nThere is no `^` (use `POWER`), no `%` (use `MOD`) and no `==`.\n\nHow values turn into each other:\n\n- **In arithmetic**, an empty field is 0, `TRUE` is 1, text that is a number\n (`\"12\"`) is that number, and other text gives no number, so the cell is\n empty. A lookup's list adds up.\n- **In a comparison**, when either side is a date value both sides compare\n as dates: a stored `\"2026-09-05\"` is read as that day, and an empty or\n unreadable side matches nothing (only `!=` is true). When either side is a\n number, both compare as numbers: `{Qty} = \"4\"` is true. Otherwise they\n compare as text, exactly: `\"a\" = \"A\"` is false, and `\"10\" < \"9\"` is true.\n- **In IF, AND, OR and NOT**, empty, `FALSE` and 0 are false. Everything\n else is true, including the text `\"0\"` and `\"false\"`.\n\n## Functions\n\nThe editor's Functions list has the same names as `FORMULA_FUNCTIONS`.\nExamples use the lamp: Item `Arc floor lamp`, Qty 4, Unit cost 410, Reorder\nlevel 5, Last counted `2026-09-05`, Status `Low`.\n\n**Logic**\n\n| Function | Does | Example |\n|---|---|---|\n| `IF(cond, then, else)` | `then` when `cond` is true, else `else` (or empty) | `IF({Qty} > 3, \"many\", \"few\")` is `many` |\n| `AND(a, b, ...)` | True when every argument is true | `AND({Qty} > 0, {Qty} <= 5)` is true |\n| `OR(a, b, ...)` | True when any argument is true | `OR({Qty} = 0, {Qty} > 3)` is true |\n| `NOT(a)` | The opposite | `NOT({Qty} > 3)` is false |\n| `XOR(a, b, ...)` | True when an odd number of arguments are true | `XOR(TRUE, FALSE, TRUE)` is false |\n| `BLANK()` | An empty value | `IF({Qty} > 0, {Qty}, BLANK())` |\n| `SWITCH(v, m1, r1, ..., default)` | The result after the first match of `v`, compared as text; else the default or empty. Only that result is worked out | `SWITCH({Status}, \"Out\", \"Order now\", \"Low\", \"Order soon\", \"\")` is `Order soon` |\n\n**Text**\n\n| Function | Does | Example |\n|---|---|---|\n| `CONCATENATE(a, b, ...)` | Joins the arguments | `CONCATENATE({Item}, \" x\", {Qty})` is `Arc floor lamp x4` |\n| `LEN(t)` | Number of characters | `LEN({Item})` is 14 |\n| `UPPER(t)`, `LOWER(t)` | Changes case | `UPPER(LEFT({Item}, 3))` is `ARC` |\n| `TRIM(t)` | Drops spaces at both ends | `TRIM(\" bolt \")` is `bolt` |\n| `LEFT(t, n)` | First `n` characters (default 1) | `LEFT({Item}, 3)` is `Arc` |\n| `RIGHT(t, n)` | Last `n` characters (default 1) | `RIGHT({Item}, 4)` is `lamp` |\n| `MID(t, start, n)` | `n` characters from position `start` (1 is the first); without `n`, the rest | `MID({Item}, 5, 5)` is `floor`, `MID({Item}, 5)` is `floor lamp` |\n| `FIND(part, t, start)` | Position of `part` in `t`, 0 when missing; case matters | `FIND(\"lamp\", {Item})` is 11 |\n| `SUBSTITUTE(t, old, new)` | Replaces every `old` | `SUBSTITUTE({Item}, \" \", \"-\")` is `Arc-floor-lamp` |\n| `REPT(t, n)` | `t` repeated `n` times, at most 1000 | `REPT(\"*\", {Qty})` is `****` |\n| `T(v)` | `v` when it is text, else empty text | `T({Qty})` is empty |\n| `VALUE(t)` | The number in a text, ignoring other characters; empty when there is no digit | `VALUE(\"$1,200.50\")` is 1200.5, `VALUE(\"n/a\")` is empty |\n| `ARRAYJOIN(list, sep)` | A lookup's values joined by `sep` (default `, `), empty ones left out | `ARRAYJOIN({Supplier name}, \" / \")` |\n\n**Numbers**\n\n| Function | Does | Example |\n|---|---|---|\n| `SUM(a, b, ...)` | Adds; lists add up too | `SUM({Qty}, 2, 3)` is 9 |\n| `AVERAGE(a, b, ...)` | Mean of the values that are not empty | `AVERAGE(2, 4, \"\")` is 3 |\n| `MAX(...)`, `MIN(...)` | Largest, smallest; empty values ignored | `MAX(0, MIN(1, 1.4))` is 1 |\n| `COUNT(...)` | How many arguments are numbers | `COUNT(1, \"2\", 3)` is 2 |\n| `COUNTA(...)` | How many are not empty | `COUNTA(1, \"\", \"x\")` is 2 |\n| `ROUND(x, digits)` | Rounds; negative digits round to tens, hundreds | `ROUND(1234.5, -2)` is 1200 |\n| `ROUNDUP(x, digits)` | Rounds up | `ROUNDUP(2.41, 1)` is 2.5 |\n| `ROUNDDOWN(x, digits)` | Rounds down | `ROUNDDOWN(2.49, 1)` is 2.4 |\n| `INT(x)` | Rounds down to a whole number | `INT(-2.5)` is -3 |\n| `ABS(x)` | Drops the sign | `ABS(-3)` is 3 |\n| `MOD(x, d)` | Remainder, with the sign of `x` | `MOD(7, 3)` is 1, `MOD(-7, 3)` is -1 |\n| `POWER(x, p)` | `x` to the power `p` | `POWER(2, 10)` is 1024 |\n| `SQRT(x)` | Square root; empty for a negative | `SQRT(16)` is 4 |\n| `CEILING(x, s)`, `FLOOR(x, s)` | Up or down to a multiple of `s` (default 1) | `CEILING(2.1)` is 3, `CEILING(7, 5)` is 10, `FLOOR(2.37, 0.05)` is 2.35 |\n\n**Dates**\n\n| Function | Does | Example |\n|---|---|---|\n| `TODAY()` | Today at midnight, local time | `DATEADD(TODAY(), 7, \"days\")` |\n| `NOW()` | This moment | `NOW() > TODAY()` is true |\n| `YEAR(d)`, `MONTH(d)`, `DAY(d)` | Parts of a date; months count from 1 | `MONTH({Last counted})` is 9 |\n| `WEEKDAY(d)` | Day of the week, 0 for Sunday | `WEEKDAY({Last counted})` is 6 |\n| `DATEADD(d, n, unit)` | `d` plus `n` units (default days); units in [step 5](#5-work-with-dates) | `DATEADD({Last counted}, 2, \"weeks\")` is September 19, `DATEADD({Last counted}, 1, \"months\")` is October 5 |\n| `DATETIME_DIFF(a, b, unit)` | `a` minus `b` in whole units (default seconds) | `DATETIME_DIFF(\"2026-10-14\", {Last counted}, \"days\")` is 39, with `\"months\"` it is 1 |\n| `IS_BEFORE(a, b)`, `IS_AFTER(a, b)` | Compare two dates; false when either is empty | `IS_BEFORE({Last counted}, \"2026-10-01\")` is true |\n| `DATESTR(d)` | The date as `YYYY-MM-DD` text | `DATESTR(DATEADD({Last counted}, 90, \"days\"))` is `2026-12-04` |\n\n## Format the result\n\nThe formula computes a number, text, `true`/`false`, a date or a list.\n`fieldProps.result` says how the cell shows a number, and a date written as\ntext:\n\n| `result` | Shows | Other properties |\n|---|---|---|\n| `auto` (default, \"As computed\") | A number as is, rounded to 10 decimals: `1640` | |\n| `number` | 1640 as `1,640.0` | `precision` |\n| `currency` | 1640 as `$1,640.00` | `precision`, `symbol` (default `$`) |\n| `percent` | 0.4 as `40%` | `precision` |\n| `date` | Text written as a stored date (`2026-12-04` or `2026-12-04T10:30:00Z`) as a date; other text as it is | `dateFormat`: `local`, `friendly`, `us`, `european`, `iso` |\n\nWhatever `result` says, a date value (from `TODAY()`, `DATEADD`) shows as a\ndate in `dateFormat` (`local` without one), `true` and `false` show as those\nwords, and a list shows its values joined by commas. Text shows as it is.\nUnder `date`, only text in the stored-date form turns into a date, so\n`IF({Qty} = 0, \"Count now\", DATEADD({Last counted}, 90, \"days\"))` shows\n`Count now` or the date, and `\"1\"` stays `1`. A number with\n`result: 'date'` stays a number.\n\nSet `precision` (0 to 8) for a number, currency or percent. Without it the\ncell uses the number field's defaults: 1 decimal place for a number, 2 for a\ncurrency and 0 for a percent.\n\n`result` also decides the footer. With `number`, `currency` or `percent` the\nfooter offers Sum, Average, Median, Min, Max and Range; with `date` it\noffers Earliest date, Latest date and Date range (days); with `auto` only\nEmpty, Filled, Unique, Percent empty and Percent filled. Set `result` on any\nformula you want to add up, and keep the choice in `fieldProps.summary`, as\nin step 2 ([Summaries at the bottom](../fields.md#summaries-at-the-bottom)).\n\n## When a formula is wrong\n\nThere are two kinds of problem, and they show differently:\n\n- **The formula does not compile.** The field editor shows the message and\n will not save: `No field named \"Nope\"`, `Unknown function FOO`,\n `Unknown unit \"fortnights\" in DATEADD: ...`, `Expected \")\" at the end`,\n `The formula ends too early`, `Unclosed \"{\" at 1`, `Unclosed text at 1`,\n `Unexpected \"^\" at 7`, `A formula cannot read its own field {Stock value}`.\n Positions count characters from 1. A formula declared in code with a\n mistake, or one that stops compiling because a field it reads was deleted,\n shows `#ERROR` in every cell. Delete field in a column's menu asks first\n when formulas read the field, and names them: \"These formulas read it and\n will show #ERROR: Stock value, Status.\"\n- **It compiles, but a row gives no number.** Division by zero, `\"abc\" * 2`,\n `SQRT(-1)`: that cell is empty, and the other rows still compute.\n\n## Rename a field a formula reads\n\nRename \"Unit cost\" to \"Cost\" in the field editor and every other formula in\nthe table that reads `{Unit cost}` now reads `{Cost}`. Stock value keeps\nworking, and `onColumnChange` hands back the column list with the rewritten\nformulas, ready to store. Text inside quotes is left alone: `\"{Unit cost}\"`\nstays as it is.\n\nA reference to the `field` key needs no rewrite, because renaming changes\nthe header only: `{qty} * {unitCost}` keeps working whatever people call\nthe columns. When a header is also the key, as with \"Qty\" and `qty`, both\nspellings follow the new name, and both still read the field.\n\nOnly a rename made in the field editor rewrites formulas. Change a header\nin the columns you pass the grid and `{Unit cost}` names no field any more:\nthe formula shows `#ERROR` until you change it too.\n\n## Turn a formula into a stored field\n\nChange a formula field's type in the field editor, to Number or Single line\ntext for example, and each record stores the value the formula computed,\nconverted to the new type. Stock value to Number stores `1640` for the\nlamp; to Single line text it stores `\"1640\"`, not the formatted\n`$1,640.00`; a date result becomes `YYYY-MM-DD`. Before you save, the\neditor says \"Every value converts.\" or how many cells will be cleared or\ncut short. The\nvalues arrive in `onColumnChange` as `data`. A formula that did not\ncompile stores nothing. Lookups, rollups and counts convert the same way.\n\n## Formulas from code\n\nThe same parser is exported, for checking a formula in your own UI or\ncomputing a value outside the grid.\n\n| Export | What it is |\n|---|---|\n| `compileFormula(src, resolve)` | Returns `{ fn, refs }`, or throws `FormulaError` (an empty formula throws too) |\n| `formulaError(src, resolve)` | The message, or `null` when the formula compiles |\n| `FormulaError` | The error class, with the message the editor shows |\n| `FORMULA_FUNCTIONS` | Every function name, sorted |\n\n`resolve` turns a name from the braces into the row key it reads, or `null`\nfor no such field. `fn(row)` never throws: a problem gives `null`. `refs`\nlists the keys the formula reads.\n\n```ts\nimport { compileFormula, formulaError } from '@svgrid/enterprise/base'\n\nconst columns = [\n { field: 'qty', header: 'Qty' },\n { field: 'unitCost', header: 'Unit cost' },\n]\nconst resolve = (name: string) => {\n const n = name.trim().toLowerCase()\n const hit = columns.find((c) => c.header.toLowerCase() === n || c.field.toLowerCase() === n)\n return hit ? hit.field : null\n}\n\nformulaError('{Qty} * {Unit cost}', resolve) // null\nformulaError('{Qty} * {Unit price}', resolve) // 'No field named \"Unit price\"'\n\nconst { fn, refs } = compileFormula('{Qty} * {Unit cost}', resolve)\nfn({ qty: 4, unitCost: 410 }) // 1640\nrefs // ['qty', 'unitCost']\n```\n\nA formula box of your own, with the same message under it as the field\neditor:\n\n```svelte\n<script lang=\"ts\">\n import { formulaError, FORMULA_FUNCTIONS } from '@svgrid/enterprise/base'\n\n let { columns }: { columns: Array<{ field: string; header: string }> } = $props()\n let formula = $state('')\n\n const resolve = (name: string) => {\n const n = name.trim().toLowerCase()\n const hit = columns.find((c) => c.header.trim().toLowerCase() === n || c.field.toLowerCase() === n)\n return hit ? hit.field : null\n }\n const message = $derived(formula.trim() ? formulaError(formula, resolve) : null)\n</script>\n\n<textarea bind:value={formula} rows=\"3\" aria-invalid={!!message}></textarea>\n{#if message}<p class=\"formula-error\">{message}</p>{/if}\n<p class=\"formula-help\">{FORMULA_FUNCTIONS.join(', ')}</p>\n```\n\nOn the server, where formula values are not stored, compute them with the\nsame formula. A SvelteKit endpoint that totals the stock:\n\n```ts\n// src/routes/api/stock-value/+server.ts\nimport { json } from '@sveltejs/kit'\nimport { compileFormula, FormulaError } from '@svgrid/enterprise/base'\nimport { loadItems } from '$lib/server/items'\n\nconst keys: Record<string, string> = { qty: 'qty', 'unit cost': 'unitCost' }\nconst resolve = (name: string) => keys[name.trim().toLowerCase()] ?? null\n\nexport async function GET() {\n try {\n const { fn } = compileFormula('{Qty} * {Unit cost}', resolve)\n const items: Array<Record<string, unknown>> = await loadItems()\n const total = items.reduce((sum, row) => sum + Number(fn(row) ?? 0), 0)\n return json({ total })\n } catch (e) {\n if (e instanceof FormulaError) return json({ error: e.message }, { status: 400 })\n throw e\n }\n}\n```\n\nFor the text the cell shows, format included, fill the column the way the\ngrid does. `fillFieldColumn` gives back the column with its `fieldFn` and\n`formatter`:\n\n```ts\nimport { enableBase, fillFieldColumn } from '@svgrid/enterprise/base'\n\nenableBase()\n\nconst columns = [\n { field: 'qty', header: 'Qty', fieldType: 'number' },\n { field: 'unitCost', header: 'Unit cost', fieldType: 'number' },\n]\nconst value = { field: 'value', header: 'Stock value', fieldType: 'formula', fieldProps: { formula: '{Qty} * {Unit cost}', result: 'currency', precision: 2 } }\n\nconst filled = fillFieldColumn(value, columns)\nconst compute = filled.fieldFn as (row: Record<string, unknown>) => unknown\nconst show = filled.formatter as (c: { value: unknown }) => string\nshow({ value: compute({ qty: 4, unitCost: 410 }) }) // \"$1,640.00\" in an English locale\n```\n\n## How it runs\n\nA formula is parsed once, when its column is filled (on load and after any\nchange to the fields), into plain functions. Nothing is passed to `eval` or\n`Function`, so a formula a user types cannot run code.\n\nEach read runs those functions against the record, and nothing is cached\nbetween reads. A formula that reads another formula, a lookup, a rollup or\na count works that field out at the same moment, over the linked records\nwhere it has to. A chain that loops back on itself stops after a few steps\nand reads the stored record (empty) instead of hanging.\n\n## Pitfalls\n\n- **A date field holds a day, not a moment.** It reads as local midnight, so\n `{Last counted} = TODAY()` can be true but `{Last counted} = NOW()` never\n is.\n- **Two dates as text compare as text.** A stored date against another\n stored date, or against a date written in quotes\n (`{Last counted} < \"2026-10-01\"`), compares as text. That orders\n `YYYY-MM-DD` correctly, but an empty date is \"before\" every date. Use\n `IS_BEFORE` and `IS_AFTER`, or compare with a date value such as\n `TODAY()`, when empty dates should match nothing.\n- **A date joined with `&` is a UTC timestamp.** Wrap it in `DATESTR`.\n- **An empty number is 0.** `{Qty} = 0` is true for an item with no Qty, so\n step 3 calls it Out.\n- **Text comparisons are exact.** `=` and `SWITCH` care about case and\n spaces: `\"low\"` does not match `\"Low\"`.\n- **`\"m\"` is minutes.** So is `\"M\"`. Write `\"months\"` for months.\n\n## Next\n\n- [Linked records](./linked-records.md): links, lookups and rollups between\n tables, which formulas read.\n- [Views](./views.md): filters, sorts, groups and charts over computed\n fields.\n- [Automations](./automations.md): conditions and messages that use formula\n results.\n- [Your own field type](./custom-field-type.md): a computed type of your own\n when a formula is not enough.\n- [Field types](../fields.md#declare-columns-by-type): every type and its\n `fieldProps`.\n"
|
|
4277
|
+
},
|
|
4278
|
+
{
|
|
4279
|
+
"slug": "help/base/linked-records",
|
|
4280
|
+
"path": "docs/help/base/linked-records.md",
|
|
4281
|
+
"title": "Link tables: lookups, rollups and counts",
|
|
4282
|
+
"section": "SvGrid Base",
|
|
4283
|
+
"markdown": "# Link tables: lookups, rollups and counts\n\nA base holds several tables, and a link field relates their records: a deal\nnames its account, the account lists its deals. Lookups, rollups and counts\nthen read through the link, so an account can show the value of its pipeline\nwithout anyone typing it. This page builds a small sales CRM with Accounts,\nContacts and Deals, one step at a time.\n\n\n\n<div data-docs-demo=\"512-base-sales-crm\" data-height=\"680\"></div>\n\n> SvGrid Base is part of the **Suite edition** of `@svgrid/enterprise`. Without a key it runs with a watermark.\n\nThe demo above is the finished CRM. Step 1 is a complete page; the later\nsteps add a table and fields to it.\n\n## 1. Two tables and a link\n\nGive `SvBase` a list of tables instead of `data` and `columns`. Each table\nhas an `id`, its records, its fields and its views. A link field names the\ntable it points at in `fieldProps.table`, and the field there that links back\nin `fieldProps.inverse`.\n\n```svelte\n<script lang=\"ts\">\n import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'\n\n enableBase()\n\n const STAGES = [\n { value: 'Lead', color: '#94a3b8' },\n { value: 'Qualified', color: '#18bfff' },\n { value: 'Proposal', color: '#2d7ff9' },\n { value: 'Won', color: '#20c933' },\n { value: 'Lost', color: '#f82b60' },\n ]\n const INDUSTRIES = [\n { value: 'Wholesale', color: '#ff6f2c' },\n { value: 'Manufacturing', color: '#2d7ff9' },\n { value: 'Healthcare', color: '#20c933' },\n ]\n\n const deals = [\n { id: 'd1', deal: 'Northwind: Platform license', account: ['a1'], stage: 'Proposal', value: 48000 },\n { id: 'd2', deal: 'Contoso: Pilot', account: ['a2'], stage: 'Qualified', value: 12000 },\n { id: 'd3', deal: 'Northwind: Renewal', account: ['a1'], stage: 'Won', value: 30000 },\n ]\n const accounts = [\n { id: 'a1', name: 'Northwind Traders', industry: 'Wholesale', deals: ['d1', 'd3'] },\n { id: 'a2', name: 'Contoso', industry: 'Manufacturing', deals: ['d2'] },\n { id: 'a3', name: 'Litware', industry: 'Healthcare', deals: [] },\n ]\n\n let tables = $state.raw<BaseTable[]>([\n {\n id: 'deals',\n name: 'Deals',\n primaryField: 'deal',\n data: deals,\n columns: [\n { field: 'deal', header: 'Deal', width: 260, fieldType: 'text' },\n { field: 'account', header: 'Account', width: 180, fieldType: 'link', fieldProps: { table: 'accounts', multiple: false, inverse: 'deals' } },\n { field: 'stage', header: 'Stage', fieldType: 'singleSelect', fieldProps: { options: STAGES } },\n { field: 'value', header: 'Value', fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0 } },\n ],\n views: [{ id: 'all', name: 'All deals', type: 'grid' }],\n },\n {\n id: 'accounts',\n name: 'Accounts',\n primaryField: 'name',\n data: accounts,\n columns: [\n { field: 'name', header: 'Account', width: 220, fieldType: 'text' },\n { field: 'industry', header: 'Industry', fieldType: 'singleSelect', fieldProps: { options: INDUSTRIES } },\n { field: 'deals', header: 'Deals', width: 260, fieldType: 'link', fieldProps: { table: 'deals', multiple: true, inverse: 'account' } },\n ],\n views: [{ id: 'all', name: 'All accounts', type: 'grid' }],\n },\n ])\n\n function save(next: BaseTable[]) {\n // Every change to records, fields, views or tables lands here.\n console.log(next)\n }\n</script>\n\n<SvBase bind:tables editable sortable expandRows columnAdder fieldMenu\n containerHeight={600} onTablesChange={save} />\n```\n\nOn screen there are two tabs, Deals and Accounts. The Account column shows\none chip per linked record, with the account's name (its primary field). On\nthe Accounts tab, Northwind Traders shows two chips in Deals.\n\nWhat the link needs:\n\n- **Record ids.** A link stores ids, so every record needs an `id` (or the\n table needs a `getRowId`).\n- **Both sides declared.** `account.inverse` names the `deals` field of\n Accounts, and `deals.inverse` names `account`. The `inverse` value is the\n other field's `id`, or its `field` when it has no `id`.\n- **Both sides agree in the data you load.** The base keeps the two in step\n from the first change on, but it does not rewrite loaded data, so\n `accounts[0].deals` has to list `d1` and `d3` because those deals name\n `a1`. A mismatch is reported in the console when SvBase mounts (see\n [Pitfalls](#pitfalls)).\n\n`multiple: false` lets a deal pick one account. Leave it out and the field\nlinks many records; that is the type's default.\n\n## 2. Pick linked records\n\nSelect an Account cell and press Enter, or double-click it. A dialog titled\n\"Link to Accounts\" lists the accounts by name under a \"Search Accounts\" box.\nClick a row to tick it; in a single link a click on another row moves the\ntick, and a click on the ticked row clears it. The footer counts what is\nticked (\"1 linked\"). Done writes the ids, Cancel or Escape leaves the cell\nas it was. Typing on a selected link cell does not open the dialog.\n\nSearch matches the primary field and the linked table's text fields (text,\nlong text, email, URL, phone and the selects), anywhere in the value. A\nrecord found by another field shows where, for example \"Email:\nmaya@northwind.example.com\". The list shows the first 200 matches; when more\nmatch, a line under it says how many (\"Showing 200 of 1,240. Type to\nnarrow.\"). When no account is named exactly what you typed, the list ends\nwith + Create \"Initech\": it adds an account called Initech to the Accounts\ntable, with a new id, and ticks it. The account is added at once; Cancel\nleaves the link as it was but does not take the account back.\n\nCopy and paste work on names. Ctrl+C on a link cell copies \"Northwind\nTraders\", not `a1`. Pasting text into a link cell splits it at commas and\nlinks each part that matches a record's name or id, ignoring case; parts that\nmatch nothing are dropped, and a single link keeps the first match.\n\nA name with a comma or a double quote is copied in quotes, CSV style:\n`\"Acme, Inc.\", Contoso`, with an inner quote doubled (`\"The \"\"Big\"\" Shop\"`).\nThe paste reads a quoted part as one name, so the copy pastes back to the\nsame records. A user field with several people copies and pastes its names\nthe same way.\n\nPick Litware for \"Contoso: Pilot\", press Done, and switch to Accounts:\nContoso no longer lists the pilot and Litware does. The base rewrites the\nother side after every link edit, from either side. Link a deal from an\naccount's Deals cell and the deal's Account cell changes; because a deal has\none account, the account that listed it before lets go of it. Records\ndeleted on one side drop out of the links on the other. A record added with\n\"+ Add row\", a form or a group's \"+\" is linked on both sides as soon as it\nexists.\n\nAutomations hear these changes too: linking a deal from Accounts counts as a\nchange to the deal's Account field. See [Automations](./automations.md#when).\n\n## 3. Open and preview a linked record\n\nRest the pointer on a chip for a moment and a card opens under it: the\nrecord's name, its table, and the first four of its other fields that have a\nvalue, written as their cells write them. Empty fields are skipped and do not\ncount toward the four. Move onto the card to\nread it; it closes when the pointer leaves. Touch input skips the preview.\n\nClick a chip to open the record in a dialog. It shows every field of the\nlinked record with the field's icon and name, drawn the way its cells draw\nthem: a select as its pill, a link as chips. The record is read here, not\nedited. A chip inside it opens that record over this one, so you can walk\nfrom a deal to its account to the account's other deals.\n\nFor a table of the same base the dialog has \"Open in Accounts\" next to\nClose. It closes every open linked record, switches to the Accounts tab and\nexpands the record there, where it can be edited. If the record was deleted\nin the meantime, the dialog says \"This record is not in its table any\nmore.\"\n\n## 4. Add a third table\n\nContacts link to an account and to their deals. Add the table and one link\nfield on each side of each pair.\n\n```ts\nconst contacts = [\n { id: 'c1', name: 'Maya Lindqvist', title: 'Head of Operations', email: 'maya@northwind.example.com', account: ['a1'], deals: ['d1'] },\n { id: 'c2', name: 'Priya Raman', title: 'CFO', email: 'priya@contoso.example.com', account: ['a2'], deals: ['d2'] },\n]\n\nconst contactsTable: BaseTable = {\n id: 'contacts',\n name: 'Contacts',\n primaryField: 'name',\n data: contacts,\n columns: [\n { field: 'name', header: 'Name', width: 190, fieldType: 'text' },\n { field: 'title', header: 'Title', fieldType: 'text' },\n { field: 'email', header: 'Email', width: 240, fieldType: 'email' },\n { field: 'account', header: 'Account', fieldType: 'link', fieldProps: { table: 'accounts', multiple: false, inverse: 'contacts' } },\n { field: 'deals', header: 'Deals', fieldType: 'link', fieldProps: { table: 'deals', multiple: true, inverse: 'contact' } },\n ],\n}\n```\n\nThen the new fields in the other two tables, each naming its partner:\n\n```ts\n// In Deals' columns, with contact: ['c1'] on d1 and ['c2'] on d2\nconst contact = { field: 'contact', header: 'Contact', fieldType: 'link', fieldProps: { table: 'contacts', multiple: false, inverse: 'deals' } }\n// In Accounts' columns, with contacts: ['c1'] on a1 and ['c2'] on a2\nconst accountContacts = { field: 'contacts', header: 'Contacts', fieldType: 'link', fieldProps: { table: 'contacts', multiple: true, inverse: 'account' } }\n```\n\nTwo fields in different tables can share a name (Deals and Contacts both\nhave `account`); `inverse` names a field of the table the link points at.\n\nYou can build the same links without code. On the Contacts tab press \"+\" at\nthe end of the header, pick \"Link to records\" and choose Accounts under\nTable. Accounts gets a field back at once, a link to many named after the\ntable it comes from (\"Contacts\", or \"Contacts 2\" when that name is taken),\nand both fields name each other in `inverse`.\n\n## 5. Show a field of the linked record: lookups\n\nA lookup shows a field of the records a link field points at. On Deals,\nshow the account's industry and the contact's email:\n\n```ts\nconst accountIndustry = { field: 'accountIndustry', header: 'Industry', fieldType: 'lookup', fieldProps: { linkField: 'account', field: 'industry' } }\nconst contactEmail = { field: 'contactEmail', header: 'Contact email', width: 240, fieldType: 'lookup', fieldProps: { linkField: 'contact', field: 'email' } }\n```\n\n`linkField` is the link field of this table; `field` is a field of the linked\ntable. The lookup's value is a list, one entry per linked record, and the\ncell writes it as text joined with commas, each value the way its own field\nwrites it: an amount with its symbol, a date in its format. \"Manufacturing\"\nshows as text, not as the colored pill, and the email is not a mailto link.\n\nA lookup is read-only and is never stored in the records. When the linked\nfield is itself computed (a formula, a rollup, another lookup), the lookup\nworks it out. In the field editor, pick the link under \"Link field\" and the\nfield under \"Field in the linked table\".\n\n## 6. Sum and count the linked records: rollups and counts\n\nA rollup reduces one field over the linked records to a single value. A count\nsays how many records a link points at. On Accounts:\n\n```ts\nconst pipeline = { field: 'pipeline', header: 'Pipeline value', width: 160, fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'value', aggregation: 'SUM' } }\nconst largest = { field: 'largest', header: 'Largest deal', fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'value', aggregation: 'MAX' } }\nconst stages = { field: 'stages', header: 'Stages', width: 200, fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'stage', aggregation: 'ARRAYUNIQUE' } }\nconst dealCount = { field: 'dealCount', header: 'Deal count', fieldType: 'count', fieldProps: { linkField: 'deals' } }\n```\n\nNorthwind Traders shows \"$78,000\", \"$48,000\", \"Proposal, Won\" and 2. Edit a\ndeal's value on the Deals tab and the account's numbers follow.\n\n| `aggregation` | Result |\n|---|---|\n| `SUM` | The total; values that are not numbers count as 0. The default |\n| `AVERAGE`, `MIN`, `MAX` | Over the values that read as numbers; empty when there are none |\n| `COUNT` | How many values are numbers |\n| `COUNTA` | How many values are not empty |\n| `COUNTALL` | How many linked records, empty values included |\n| `ARRAYJOIN` | The non-empty values as text, joined with \", \" |\n| `ARRAYUNIQUE` | The same, each value once |\n| `CONCATENATE` | Every value as text, joined with nothing between |\n| `AND`, `OR` | Whether every value, or any value, is truthy |\n\nA `SUM`, `AVERAGE`, `MIN` or `MAX` writes its result the way the linked field\nwrites its values, so a sum of dollar amounts shows \"$78,000\". Set\n`precision` (0 to 8) on the rollup and it writes a plain number with that many\ndecimals instead. A rollup with one of these four or with `COUNT`, `COUNTA`\nor `COUNTALL` is a number column: it right-aligns, and its footer offers Sum,\nAverage and the other number summaries (step 7).\n\nA count field takes only `linkField` and is the number of linked records\nthat exist. That differs from a `COUNT` rollup, which counts the linked\nrecords whose field holds a number.\n\nRollups, lookups and counts are read-only. The [field types\ntable](../fields.md#declare-columns-by-type) lists their `fieldProps` with\nevery other type's.\n\n## 7. Formulas and footers over linked values\n\nA formula can name a rollup or a count like any other field, by its header\nin braces. The formula works the computed field out when it reads it. On\nAccounts, the average deal size:\n\n```ts\nconst avgDeal = {\n field: 'avgDeal',\n header: 'Average deal',\n fieldType: 'formula',\n fieldProps: { formula: 'IF({Deal count} > 0, {Pipeline value} / {Deal count}, 0)', result: 'currency', symbol: '$', precision: 0 },\n}\n```\n\nThe other direction works too: a rollup can add up a formula of the linked\ntable. Give Deals a probability and a weighted value, then sum the weighted\nvalue per account:\n\n```ts\n// In Deals' columns, with probability: 0.5 on d1, 0.25 on d2, 1 on d3\nconst probability = { field: 'probability', header: 'Probability', fieldType: 'number', fieldProps: { format: 'percent', precision: 0 } }\nconst weighted = { field: 'weighted', header: 'Weighted', fieldType: 'formula', fieldProps: { formula: '{Value} * {Probability}', result: 'currency', symbol: '$', precision: 0 } }\n\n// In Accounts' columns\nconst forecast = { field: 'forecast', header: 'Forecast', fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'weighted', aggregation: 'SUM' } }\n```\n\nNorthwind's forecast is $54,000: $24,000 from the proposal and $30,000 from\nthe renewal. The OKR base does the same with an `AVERAGE` of each key\nresult's progress formula:\n\n<div data-docs-demo=\"522-base-okrs\" data-height=\"620\"></div>\n\nWith the `summary` prop on `SvBase`, every field gets a footer cell. Click\nthe Pipeline value footer and pick Sum, or set the choice in\n`fieldProps.summary`: the footer adds up the rollup's values over the shown\nrecords and writes the total the way the rollup writes it (\"Sum $90,000\").\nA count's footer offers the same choices.\n\n```ts\nconst pipelineWithTotal = { field: 'pipeline', header: 'Pipeline value', fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'value', aggregation: 'SUM', summary: 'sum' } }\n```\n\n\n\n[Summaries at the bottom](../fields.md#summaries-at-the-bottom) lists every\nsummary.\n\n## 8. Filter and group by a link\n\nThe view bar's Filter compares a link by the linked records' names: contains,\ndoes not contain, is, is not, is empty and is not empty. \"Account is\nContoso\" keeps the deals linked to Contoso. Counts filter as numbers, and so\ndo rollups that are numbers (`SUM`, `AVERAGE`, `MIN`, `MAX`, `COUNT`,\n`COUNTA`, `COUNTALL`). An `ARRAYJOIN`, `ARRAYUNIQUE` or `CONCATENATE` rollup\nfilters as text, an `AND` or `OR` rollup as a checkbox (is checked or\nunchecked). Lookups filter as text.\n\nGrouping by a link field puts the records linked to the same records in one\ngroup. The group's header shows the link as chips, the record count and the\nsummaries the fields picked. Its \"+\" adds a deal already linked to that\naccount, and the account lists the new deal at once.\n\nBoth can be saved in a view:\n\n```ts\nimport type { FieldView } from '@svgrid/enterprise/base'\n\nconst dealViews: FieldView[] = [\n { id: 'all', name: 'All deals', type: 'grid' },\n { id: 'by-account', name: 'By account', type: 'grid', state: { grouping: ['account'], sorting: [{ id: 'value', desc: true }] } },\n {\n id: 'contoso',\n name: 'Contoso deals',\n type: 'grid',\n state: {\n advancedFilter: {\n kind: 'fieldFilter',\n conjunction: 'and',\n conditions: [{ column: 'account', kind: 'link', op: 'is', value: 'Contoso', table: 'accounts' }],\n },\n },\n },\n]\n```\n\nA link condition needs `table`, the linked table's id: that is how it turns\nids into names. The Filter panel fills it in; in code, write it yourself.\nWithout it the condition compares the ids as text.\n\n## 9. Link to a table outside the base\n\nA link field can point at a table your app owns and the base does not show:\na product catalog from your API, a list of regions. Register it with\n`registerLinkedTable`:\n\n```ts\nimport { registerLinkedTable } from '@svgrid/enterprise/base'\n\nlet products = [\n { sku: 'PLT-1', name: 'Platform license', price: 24000 },\n { sku: 'TEAM-1', name: 'Team plan', price: 6000 },\n { sku: 'SUP-1', name: 'Premium support', price: 4800 },\n]\n\nregisterLinkedTable({\n id: 'products',\n name: 'Products',\n primaryField: 'name',\n getRowId: (row) => String(row.sku),\n // A function is read every time, so the list can change.\n rows: () => products,\n fields: [\n { field: 'name', header: 'Product' },\n { field: 'price', header: 'List price', fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0 } },\n ],\n})\n```\n\nDeals then link to it and roll it up like any table of the base:\n\n```ts\n// In Deals' columns, with products: ['PLT-1', 'SUP-1'] on d1\nconst dealProducts = { field: 'products', header: 'Products', width: 220, fieldType: 'link', fieldProps: { table: 'products', multiple: true } }\nconst listPrice = { field: 'listPrice', header: 'List price', fieldType: 'rollup', fieldProps: { linkField: 'products', field: 'price', aggregation: 'SUM' } }\n```\n\nWhat differs from a table of the base:\n\n- There is no field back and no `inverse`: the catalog is yours, and the\n base never writes to it.\n- The picker offers \"+ Create\" only when the table has a `create(name)` that\n adds the record and returns it with its id.\n- The linked record dialog has \"Open in Products\" only when the table has an\n `open(rowId)` that shows the record somewhere in your app.\n- `fields` is what the field editor offers a lookup or rollup under \"Field\n in the linked table\", and its types decide how a lookup, a rollup, the\n preview and the record dialog write each value (\"$24,000\", not 24000).\n Without `fields` the editor offers only the primary field and the dialog\n shows every key of the record. A lookup or rollup declared in code can\n read any key either way. The picker's search reads the text fields in\n `fields`, or every text value of the record without them.\n\nThe table appears under Table in the field editor of every link field on the\npage. `unregisterLinkedTable('products')` removes it, and so does calling the\nfunction `registerLinkedTable` returns.\n\n## How links are stored\n\nA link field's value is an array of record ids, in the order they were\npicked. A single link is an array of one. Here is \"Northwind: Platform\nlicense\" and its account, as `onTablesChange` hands them over after steps 1\nto 9:\n\n```json\n{\n \"id\": \"d1\",\n \"deal\": \"Northwind: Platform license\",\n \"account\": [\"a1\"],\n \"contact\": [\"c1\"],\n \"products\": [\"PLT-1\", \"SUP-1\"],\n \"stage\": \"Proposal\",\n \"value\": 48000,\n \"probability\": 0.5\n}\n```\n\n```json\n{\n \"id\": \"a1\",\n \"name\": \"Northwind Traders\",\n \"industry\": \"Wholesale\",\n \"deals\": [\"d1\", \"d3\"],\n \"contacts\": [\"c1\"]\n}\n```\n\nLookups, rollups, counts and formulas are not in the records. They are\nworked out from the links each time they are drawn, so there is nothing to\nkeep in step. An id that names no record is skipped when the cell draws.\n\nIn a database, a link to one record is usually a foreign key column and a\nlink to many a junction table, and the field back is not stored at all. See\n[Keep a base in a database](./data.md#link-fields).\n\n## Change or delete a link field\n\n- **Point it at another table.** Change Table in the field editor. The field\n back is removed from the old table, values included; the link's own\n values are cleared (they were ids of the old table), and the new table\n gets a field back.\n- **Turn it into another type.** The field back goes, and the values become\n the linked records' names. A link turned into text holds \"Northwind\n Traders, Contoso\". A multiple select gets every name, a single select the\n first, and the select's options are made from those names. A cell that\n held several records keeps only one name in a single select, so the field\n editor counts it in its warning (\"2 cells will be cleared or cut\n short.\").\n- **Turn text, a select or a lookup into a link.** Each name becomes the\n record of the new table with that name or id, ignoring case, as a paste\n does. Text splits at commas; a quoted part is one name (`\"Acme, Inc.\"`).\n Names that match no record are dropped. The field editor counts the cells\n that end up empty or lose a name before it applies (\"3 cells will be\n cleared or cut short.\").\n- **Make it single.** Untick \"Allow linking to multiple records\" in the\n field editor (`multiple: false`) on a link that holds several ids: each\n record keeps the first one, and the field back drops the rest.\n- **Delete it.** With `fieldMenu`, Delete field on a link that has a field\n back, or that lookups, rollups or counts read, asks first. The dialog\n names the field back (\"Account links to Accounts. The field there that\n links back, Deals, is deleted with it. The links between the records go\n too.\") and lists the lookups, rollups and counts on either side that read\n it, the other table's in brackets (\"These fields read it and are deleted\n too: Industry, Pipeline value (Accounts), ...\"). They are deleted with the\n link.\n- **Duplicate it.** The copy gets its own field back in the other table.\n- **Delete a table.** Delete table in the tab's menu asks first: the\n table's records and views go, and so do the link fields of other tables\n that point at it (\"Deals: Account\") and the lookups, rollups and counts\n that read them (\"Deals: Industry (reads Account)\"), values included. A\n base keeps at least one table.\n- **Duplicate a table.** The copy's link fields point at the same records,\n each with a new field back in the other table.\n\n## Record ids\n\nRecords added through the base get an id from `newRecordId()`: \"+ Add row\",\na form, a group's \"+\", the picker's \"+ Create\". The id is text starting with\n`rec`. Use it for records you add in code, so they can be linked like the\nothers:\n\n```ts\nimport { blankTable, newRecordId, type BaseTable } from '@svgrid/enterprise/base'\n\nfunction addAccount(list: BaseTable[], name: string): BaseTable[] {\n return list.map((t) => (t.id === 'accounts' ? { ...t, data: [...t.data, { id: newRecordId(), name, deals: [] }] } : t))\n}\n\n// A new table as \"Add table\" makes one: Name, Notes and Status, three empty\n// records with ids, a grid view, and an id made from the name (\"meetings\").\nfunction addMeetings(list: BaseTable[]): BaseTable[] {\n return [...list, blankTable(list, 'Meetings')]\n}\n```\n\nIf your records already carry a key under another name, set `getRowId` on\nthe table, for example `getRowId: (row) => String(row.uuid)`. Links store\nwhatever it returns.\n\n## Pitfalls\n\n- **Loaded data is not repaired.** The base does not reconcile the two\n sides of a link when it mounts. It warns once in the console instead:\n \"The two sides of the link ... disagree in the loaded data\". The mismatch\n stays until the next change that syncs that link: a link edit, or a\n record added to or deleted from one of its tables. The base then rebuilds\n the other side from the table where the change was made. It does not fix\n the data on load because that would be a write nobody made:\n `onTablesChange` would fire, a table with a `source` would save to the\n database, and automations would run on records the app just loaded. It\n also cannot tell which side is right. Fix the data at its source, or sync\n it yourself before you pass it in.\n- **A missing field back means no syncing.** `inverse` must name a link field\n that exists in the other table. If it names nothing, links on one side\n never reach the other. SvBase warns once in the console, naming the link\n field and the missing one. Fields made in the field editor get both sides\n automatically; fields declared in code do not.\n- **Table ids are global.** Every table of a base is registered under its\n `id`, the same registry `registerLinkedTable` writes to. When two\n registrations share an id (a base and your app, or two bases on one page),\n link fields on the whole page see only the newest one. The older one is\n not lost: when the newer base unmounts or deletes that table, the older\n one shows again. Give every table on a page its own id.\n- **A record without an id cannot be linked.** Records you load need an `id`\n or a `getRowId` that returns one. The base gives an id only to the records\n it adds itself (see [Record ids](#record-ids)).\n\n## Next\n\n- [Formulas](./formulas.md): the formula language, and formulas over\n rollups and counts.\n- [Views](./views.md): saved filters, groups, Kanban and calendar views of\n the same records.\n- [Forms](./forms.md): a form view whose link fields open the same picker.\n- [Keep a base in a database](./data.md): links as foreign keys and junction\n tables.\n- [Automations](./automations.md): create a record in another table and\n link it back.\n- [Field types](../fields.md#tables-and-links): the reference for tables and\n links.\n"
|
|
4284
|
+
},
|
|
4285
|
+
{
|
|
4286
|
+
"slug": "help/base/postgres",
|
|
4287
|
+
"path": "docs/help/base/postgres.md",
|
|
4288
|
+
"title": "Build a base on Postgres",
|
|
4289
|
+
"section": "SvGrid Base",
|
|
4290
|
+
"markdown": "# Build a base on Postgres\n\nThis tutorial keeps a base in your own Postgres database, behind your own\nSvelteKit server. The browser never sees a database password: it calls one\nendpoint per table, and the endpoint runs parameterized SQL. Three tables,\nTasks, Projects and Tags, with a foreign key and a junction table between\nthem.\n\nThe demo below is the finished base on a real Postgres running in the page\n(PGlite), with the SQL each change runs listed under it:\n\n<div data-docs-demo=\"516-base-postgres\" data-height=\"760\"></div>\n\n\n\n> SvGrid Base and the SQL source are part of the **Suite edition** of\n> `@svgrid/enterprise`. Without a key they run with a watermark.\n\n## 1. Create the tables\n\n```sql\ncreate table projects (\n id text primary key,\n name text not null default '',\n due date\n);\ncreate table tags (\n id text primary key,\n name text not null default ''\n);\ncreate table tasks (\n id text primary key,\n name text not null default '',\n status text,\n priority int,\n due date,\n done boolean not null default false,\n project_id text references projects(id) on delete set null\n);\ncreate table task_tags (\n id serial primary key,\n task_id text not null references tasks(id) on delete cascade,\n tag_id text not null references tags(id) on delete cascade,\n unique (task_id, tag_id)\n);\n```\n\nThe record keys are text: a new record gets its id in the page, and that id\nis its key here. (With `serial` or `uuid` keys the database makes, set\n`serverIds: true` on the source; see [record ids](./data.md#record-ids).)\n\n## 2. Install\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise pg\n```\n\n```bash\n# .env\nDATABASE_URL=postgres://user:password@localhost:5432/tracker\n```\n\n## 3. One endpoint per table\n\n`createSqlDataSource` turns the base's reads and writes into SQL for one\ntable; `createKitHandlers` serves it as a SvelteKit endpoint. A helper makes\nboth from a table name and its columns:\n\n```ts\n// src/lib/server/tables.ts\nimport pg from 'pg'\nimport { createKitHandlers, createSqlDataSource, type EntitySchema } from '@svgrid/enterprise'\nimport { DATABASE_URL } from '$env/static/private'\n\nconst pool = new pg.Pool({ connectionString: DATABASE_URL })\n\nconst text = (field: string) => ({ field, type: 'text' as const })\nconst SCHEMAS: Record<string, EntitySchema> = {\n projects: { name: 'projects', fields: [{ ...text('id'), primaryKey: true }, text('name'), { field: 'due', type: 'date' }] },\n tags: { name: 'tags', fields: [{ ...text('id'), primaryKey: true }, text('name')] },\n tasks: {\n name: 'tasks',\n fields: [\n { ...text('id'), primaryKey: true },\n text('name'),\n text('status'),\n { field: 'priority', type: 'number' },\n { field: 'due', type: 'date' },\n { field: 'done', type: 'boolean' },\n text('project_id'),\n ],\n },\n task_tags: { name: 'task_tags', fields: [{ field: 'id', type: 'number', primaryKey: true }, text('task_id'), text('tag_id')] },\n}\n\n/** The POST handler for one table: reads, inserts, updates and deletes as SQL. */\nexport function tableEndpoint(table: string) {\n const schema = SCHEMAS[table]!\n const source = createSqlDataSource({\n schema,\n table,\n dialect: { placeholders: '$', ilike: true },\n execute: async (sql, params) => (await pool.query(sql, params)).rows,\n })\n return createKitHandlers({ schema, source })\n}\n```\n\nThen a route that serves any of the four tables:\n\n```ts\n// src/routes/api/[table]/+server.ts\nimport { error } from '@sveltejs/kit'\nimport { tableEndpoint } from '$lib/server/tables'\n\nconst TABLES = new Set(['projects', 'tags', 'tasks', 'task_tags'])\n\nexport async function POST(event: { params: { table: string }; request: Request; locals: Record<string, unknown> }) {\n if (!TABLES.has(event.params.table)) error(404, 'No such table')\n return tableEndpoint(event.params.table).POST(event)\n}\n```\n\nEvery statement uses parameters (`$1`, `$2`), so no value the base sends is\never part of the SQL text. Using Drizzle? Pass `execute` the same `pg.Pool`\nyou gave `drizzle()`.\n\nWho may do what belongs here too: `createKitHandlers` takes `authorize`\n(return `false` to refuse an operation), `scope` (each person sees only\ntheir rows) and `audit` (log every write). See\n[access control](../../enterprise/studio/access-control.md).\n\n## 4. The tables in the browser\n\nOn the client each table's source is `createKitDataSource`, which talks to\nthe endpoint:\n\n```ts\n// src/lib/tracker.ts\nimport { baseSource, type BaseTable } from '@svgrid/enterprise/base'\nimport { createKitDataSource } from '@svgrid/enterprise'\n\nconst api = (table: string) => createKitDataSource({ endpoint: `/api/${table}` })\n\nconst STATUS = [\n { value: 'Todo', color: '#64748b' },\n { value: 'In progress', color: '#2563eb' },\n { value: 'Blocked', color: '#dc2626' },\n { value: 'Done', color: '#16a34a' },\n]\n\nexport function trackerTables(): BaseTable[] {\n return [\n {\n id: 'tasks',\n name: 'Tasks',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Task', width: 220, fieldType: 'text' },\n { field: 'status', header: 'Status', width: 130, fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'project', header: 'Project', width: 160, fieldType: 'link', fieldProps: { table: 'projects', multiple: false, inverse: 'tasks' } },\n { field: 'tags', header: 'Tags', width: 190, fieldType: 'link', fieldProps: { table: 'tags', multiple: true, inverse: 'tasks' } },\n { field: 'due', header: 'Due', width: 150, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'priority', header: 'Priority', width: 110, fieldType: 'rating', fieldProps: { max: 5 } },\n { field: 'done', header: 'Done', width: 70, fieldType: 'checkbox' },\n ],\n source: baseSource(api('tasks'), {\n links: {\n project: { column: 'project_id' },\n tags: { through: api('task_tags'), from: 'task_id', to: 'tag_id' },\n },\n }),\n },\n {\n id: 'projects',\n name: 'Projects',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Project', width: 200, fieldType: 'text' },\n { field: 'due', header: 'Due', width: 150, fieldType: 'date' },\n { field: 'tasks', header: 'Tasks', width: 300, fieldType: 'link', fieldProps: { table: 'tasks', multiple: true, inverse: 'project' } },\n { field: 'count', header: 'Open', width: 90, fieldType: 'count', fieldProps: { linkField: 'tasks' } },\n ],\n source: baseSource(api('projects')),\n },\n {\n id: 'tags',\n name: 'Tags',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Tag', width: 180, fieldType: 'text' },\n { field: 'tasks', header: 'Tasks', width: 300, fieldType: 'link', fieldProps: { table: 'tasks', multiple: true, inverse: 'tags' } },\n ],\n source: baseSource(api('tags')),\n },\n ]\n}\n```\n\nWhat each change runs:\n\n| In the base | In the database |\n|---|---|\n| Set a task's Status | `UPDATE \"tasks\" SET \"status\" = $1 WHERE \"id\" = $2 RETURNING *` |\n| Pick a Project for a task | `UPDATE \"tasks\" SET \"project_id\" = $1 WHERE \"id\" = $2 RETURNING *` |\n| Add a task to a project from the Projects tab | the same `UPDATE` on the task |\n| Add a tag to a task | `INSERT INTO \"task_tags\" (\"task_id\", \"tag_id\") VALUES ($1, $2) RETURNING *` |\n| Remove a tag | `DELETE FROM \"task_tags\" WHERE \"id\" = $1` |\n| \"+ Add task\" | `INSERT INTO \"tasks\" (...) VALUES (...) RETURNING *` |\n| Delete a task | `DELETE FROM \"tasks\" WHERE \"id\" = $1` |\n\nThe Count field and the Tasks fields of Projects and Tags are worked out in\nthe page and never written.\n\n## 5. The page\n\n```svelte\n<!-- src/routes/tracker/+page.svelte -->\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase } from '@svgrid/enterprise/base'\n import { trackerTables } from '$lib/tracker'\n\n enableBase()\n let tables = $state.raw(trackerTables())\n</script>\n\n<div style=\"height: 680px\">\n <SvBase bind:tables editable sortable showRowNumbers expandRows containerHeight=\"100%\" />\n</div>\n<SvToaster />\n```\n\n## 6. When the database says no\n\nConstraints keep working: the database has the last word. Add one:\n\n```sql\nalter table tasks add constraint not_blocked check (status <> 'Blocked') not valid;\n```\n\nand set a task to Blocked. The `UPDATE` fails, the cell goes back to what\nthe database holds, and the base says why:\n\n\n\n## 7. Changes made by others\n\nThe base reads a table again when its source's `realtime` says it changed.\nPostgres can tell you with `LISTEN`/`NOTIFY`: a trigger notifies on every\nchange, your server forwards it to the browser over server-sent events or a\nWebSocket, and the client calls `onChange`:\n\n```ts\nimport { baseSource } from '@svgrid/enterprise/base'\nimport { createKitDataSource } from '@svgrid/enterprise'\n\nconst tasksSource = baseSource(createKitDataSource({ endpoint: '/api/tasks' }), {\n links: { project: { column: 'project_id' } },\n realtime: (onChange) => {\n // Your endpoint that streams a line whenever the tasks table changes.\n const events = new EventSource('/api/changes?table=tasks')\n events.onmessage = () => onChange()\n return () => events.close()\n },\n})\n```\n\nWithout `realtime`, people see each other's changes when they open the base\nagain.\n\n## Next\n\n- [Keep a base in a database](./data.md): how the writer works and every\n option.\n- [Build a base on Supabase](./supabase.md): the same, with Supabase doing\n the server part.\n"
|
|
4291
|
+
},
|
|
4292
|
+
{
|
|
4293
|
+
"slug": "help/base/quick-start",
|
|
4294
|
+
"path": "docs/help/base/quick-start.md",
|
|
4295
|
+
"title": "From a database to an app",
|
|
4296
|
+
"section": "SvGrid Base",
|
|
4297
|
+
"markdown": "# From a database to an app\n\nYou have a database. One call reads its tables and `<SvBase>` turns them\ninto an app your team can use: a tab per table, fields typed from the\ncolumns, links from the foreign keys, colored selects, a Kanban board and a\ncalendar. Every edit goes back to the database.\n\n<!-- tutorial:mk-meet-svgrid-base -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-mk-meet-svgrid-base\" data-docs-tutorial=\"mk-meet-svgrid-base\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-meet-svgrid-base.mp4\" poster=\"/tutorials/mk-meet-svgrid-base.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"Meet SvGrid Base: tables inside your Svelte app, on your own database, 108 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-meet-svgrid-base.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-meet-svgrid-base.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Meet SvGrid Base: tables inside your Svelte app, on your own database</strong> (1:48, with narration). <a href=\"https://www.youtube.com/watch?v=rB-K65XMm7M\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-meet-svgrid-base -->\n\n<small>Airtable is a registered trademark of Formagrid Inc dba Airtable. SvGrid is not affiliated with Airtable.</small>\n\n\n\nTry it: pick a sample database, change its SQL if you like, and press\n\"Make the app\". The tables are created in a real Postgres running in the\npage.\n\n<div data-docs-demo=\"519-base-from-database\" data-height=\"900\"></div>\n\n## Start a new app\n\n```bash\nnpm create @svgrid@latest my-base -- --template base\n```\n\nThe wizard asks two things:\n\n```text\n Where is your data?\n 1. Postgres in the browser - nothing to set up, try it now\n 2. Your Postgres - DATABASE_URL, behind a SvelteKit endpoint\n 3. Supabase - your project, live through Realtime\n Start with which template?\n 1. Project tracker - tasks and milestones\n 2. Sales CRM - deals, accounts and contacts\n 3. Content calendar - posts and campaigns\n 4. Product roadmap - features and feedback\n 5. Hiring tracker - candidates and roles\n 6. Inventory - items and suppliers\n 7. Bug tracker - bugs and releases\n 8. Event planning - events, attendees, checklist\n 9. OKRs - objectives and key results\n 10. Expense approvals - expenses and budgets\n 11. Employee onboarding - people, checklist, departments\n 12. My own tables - an empty db/schema.sql to fill in\n```\n\nThe templates are the [ready-to-use bases](./templates.md), as SQL: their\ntables, records, views and automations. Or answer on the command line:\n\n```bash\nnpm create @svgrid@latest my-base -- -t base --data postgres --sample sales-crm\n```\n\nThe app is a SvelteKit project. The tables it starts with are in\n`db/schema.sql`, and the other templates in `db/templates/`. With\n**Postgres in the browser** it runs straight away, saved in the browser.\nWith **your Postgres**, set `DATABASE_URL` in `.env` and run\n`npm run db:setup`. With **Supabase**, run `db/schema.sql` and\n`db/policies.sql` in the SQL editor and put the project URL and anon key in\n`.env`. Skip the schema to show tables you already have.\n\n## Take a base to a database\n\nA base you built in the page goes to a database the same way: \"All tables\"\nin the tab bar, then **Download as SQL**. The file creates the tables, the\nforeign keys and the tables behind links to many, and inserts the records.\nRun it with `psql`, in the Supabase SQL editor, or on PGlite, and the\nbase opens from that database as it was. In code:\n\n```ts\nimport { baseToSql } from '@svgrid/enterprise/base'\n\nconst sql = baseToSql(tables) // tables: the BaseTable[] you pass to <SvBase>\n```\n\nSQL has no column type for a select's colors, a formula, a view or an\nautomation. Those go into comments on the tables and columns\n(`comment on column ... is 'svgrid:{...}'`), and `baseFromDatabase`,\n`createBaseEndpoint` and `baseFromSupabase` read them back. Remove them and\nthe base still opens, read from the columns alone. Dates near today are\nwritten as `current_date + 3`, so sample data stays current whenever it\nruns; `baseToSql(tables, { relativeDays: 0 })` writes every date as it is,\nand `{ data: false }` leaves the records out.\n\n## Add it to an app you have\n\n**Postgres (or MySQL, SQLite) behind your server.** Two files. The endpoint\nreads the database and serves its tables; the page shows them:\n\n```ts\n// src/routes/api/base/+server.ts\nimport pg from 'pg'\nimport { createBaseEndpoint } from '@svgrid/enterprise/base'\nimport { env } from '$env/dynamic/private'\n\nconst pool = new pg.Pool({ connectionString: env.DATABASE_URL })\n\nexport const { GET, POST } = createBaseEndpoint({\n execute: async (sql, params) => (await pool.query(sql, params)).rows,\n})\n```\n\n```svelte\n<!-- src/routes/base/+page.svelte (with `export const ssr = false` in +page.ts) -->\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseFromEndpoint, enableBase } from '@svgrid/enterprise/base'\n\n enableBase()\n const tables = baseFromEndpoint('/api/base')\n</script>\n\n{#await tables then tables}\n <SvBase {tables} editable sortable />\n{/await}\n<SvToaster />\n```\n\nThe page never sees the connection string. Every read and write is\nparameterized SQL, and only the tables the endpoint described can be\nreached. `createBaseEndpoint` takes `tables` to show only some, `dialect`\n(`'mysql'`, `'sqlite'`, `'mssql'`), `authorize` to decide who may do what,\nand `audit` to run [server automations](./automations.md#on-the-server).\n\n**Supabase.** One call in the page; other people's changes arrive through\nRealtime:\n\n```ts\nimport { createClient } from '@supabase/supabase-js'\nimport { baseFromSupabase } from '@svgrid/enterprise/base'\n\nconst supabase = createClient('https://your-project.supabase.co', 'your-anon-key')\nconst tables = await baseFromSupabase({ client: supabase, url: 'https://your-project.supabase.co', key: 'your-anon-key' })\n```\n\n**Postgres in the browser** (PGlite), for prototypes and offline tools:\n\n```ts\nimport { PGlite } from '@electric-sql/pglite'\nimport { baseFromDatabase } from '@svgrid/enterprise/base'\n\nconst db = new PGlite('idb://my-base')\nconst tables = await baseFromDatabase({ execute: async (sql, params) => (await db.query(sql, params)).rows as Record<string, unknown>[] })\n```\n\n## What it reads from your database\n\n| In the database | In the base |\n|---|---|\n| A table | A tab; the table that links to the most others comes first |\n| A text column | A text field; `email`, `url`, `phone` in the name give those fields, `notes` or `description` a long text |\n| A text column that repeats a few values (`status`, `priority`) | A single select, one color per value |\n| A number column | A number; `price`, `amount`, `budget` show as currency, `rating` as stars |\n| A `date` or `timestamp` column | A date field, with the time for a timestamp |\n| A `boolean` column | A checkbox |\n| A foreign key (`project_id`) | A link to that table, and a field there that links back |\n| A table that only pairs two others (`task_tags`) | A link to many records, stored in that table |\n| A key the database makes (`serial`, a default `uuid`) | New records get theirs from the database |\n| A column named `name` or `title` | The primary field, first and pinned |\n\nEach table also gets views: a grid colored by its first select, a Kanban\nboard by that select, and a calendar by its first date.\n\n\n\n## Make it yours\n\nThe base is ordinary `BaseTable` objects: change a field, add a view, set\n[automations](./automations.md). What you change in the app (field types,\nviews, colors) lives in the page; to keep it, store the tables\n`onTablesChange` hands you and start from them next time. The records\nalways stay in your database.\n\n## Next\n\n- [Keep a base in a database](./data.md): how the writes work, links,\n record ids, realtime.\n- [Ready-to-use bases](./templates.md): templates to start from.\n- [Automations](./automations.md).\n"
|
|
4298
|
+
},
|
|
4299
|
+
{
|
|
4300
|
+
"slug": "help/base/rest-api",
|
|
4301
|
+
"path": "docs/help/base/rest-api.md",
|
|
4302
|
+
"title": "Build a base on your API",
|
|
4303
|
+
"section": "SvGrid Base",
|
|
4304
|
+
"markdown": "# Build a base on your API\n\nWhen your data sits behind an API you already have, a base can use it as it\nis: it reads a table with one `GET`, and writes each change with `POST`,\n`PATCH` or `DELETE`. This tutorial builds a small CRM, Deals, Companies and\nContacts, from the API's own description of its tables.\n\n<div data-docs-demo=\"518-base-rest-api\" data-height=\"720\"></div>\n\n\n\n## 1. What the API answers\n\nFor each table, four routes. The shapes below are the defaults; the next\nsection shows how to read others.\n\n| Request | Answer |\n|---|---|\n| `GET /api/deals?offset=0&limit=10000` | `200` with `{ \"rows\": [...], \"rowCount\": 5 }`, or a plain array |\n| `POST /api/deals` with the new record | `201` with the record as stored, its `id` included |\n| `PATCH /api/deals/d3` with the changed fields only | `200` with the record as stored |\n| `DELETE /api/deals/d3` | `204` |\n\nA foreign key is a plain field: a deal has `company_id`. To refuse a write,\nanswer with a `4xx` status and `{ \"error\": \"Amount must be 0 or more\" }`;\nthe base puts the cell back and shows that sentence.\n\n## 2. Read another shape\n\n`createRestDataSource` takes `buildQuery` (the query string for a read) and\n`parse` (the rows and count from the answer). For an API that pages with\n`page` and `per_page` and answers `{ \"data\": [...], \"meta\": { \"total\": 5 } }`:\n\n```ts\nimport { createRestDataSource } from '@svgrid/enterprise'\n\nconst deals = createRestDataSource({\n url: 'https://api.example.com/v2/deals',\n headers: () => ({ authorization: `Bearer ${localStorage.getItem('token') ?? ''}` }),\n buildQuery: (request) => ({ page: '1', per_page: String(request.endRow - request.startRow) }),\n parse: (body) => {\n const b = body as { data: Record<string, unknown>[]; meta: { total: number } }\n return { rows: b.data, rowCount: b.meta.total }\n },\n})\n```\n\n`offsetLimitAdapter`, `jsonServerAdapter` and `dummyJsonAdapter` are\nready-made pairs for common shapes.\n\n## 3. Describe the tables\n\n`baseTablesFromSchemas` makes base tables from a description of each table:\nits columns and their types. Write the description out, or read it from an\nOpenAPI document with `introspectOpenApi(spec).entities`:\n\n```ts\n// src/lib/crm.ts\nimport { baseTablesFromSchemas, type BaseTable } from '@svgrid/enterprise/base'\nimport { createRestDataSource, type EntitySchema } from '@svgrid/enterprise'\n\nconst STAGES = ['Qualified', 'Proposal', 'Negotiation', 'Won', 'Lost'].map((value) => ({ value, label: value }))\n\nconst SCHEMAS: EntitySchema[] = [\n {\n name: 'deals',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'title', type: 'text' },\n { field: 'stage', type: 'enum', options: STAGES },\n { field: 'amount', type: 'number' },\n { field: 'close_date', type: 'date', label: 'Close date' },\n { field: 'company_id', type: 'relation', relation: { entity: 'companies', labelField: 'name' } },\n ],\n },\n {\n name: 'companies',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'website', type: 'text', format: 'url' },\n ],\n },\n {\n name: 'contacts',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'email', type: 'text', format: 'email' },\n { field: 'company_id', type: 'relation', relation: { entity: 'companies', labelField: 'name' } },\n ],\n },\n]\n\nexport function crmTables(): BaseTable[] {\n return baseTablesFromSchemas(\n SCHEMAS,\n (schema) => createRestDataSource({ url: `/api/${schema.name}`, schema }),\n // The API picks the id of a new record.\n () => ({ serverIds: true }),\n )\n}\n```\n\nWhat comes out:\n\n| In the description | In the base |\n|---|---|\n| `text`, with `format: 'url'` or `'email'` | Text, URL or Email field |\n| `number` | Number field |\n| `date`, `datetime` | Date field, with the time for `datetime` |\n| `enum` with options | Single select with those options |\n| `boolean` | Checkbox |\n| `relation` (`company_id`) | A Company link, stored as `company_id`, and a field in Companies that links back |\n| The primary key | The record's id, not a field |\n\nThe first text field is each table's primary field. Companies gets a Deals\nfield and a Contacts field that link back; they are worked out in the page\nand never sent to the API.\n\n## 4. Adjust the fields\n\nThe tables are plain objects. Show Amount as currency with a total, and give\nDeals a Kanban view by stage:\n\n```ts\nimport type { BaseTable } from '@svgrid/enterprise/base'\nimport { crmTables } from '$lib/crm'\n\nconst tables: BaseTable[] = crmTables().map((t) =>\n t.id !== 'deals'\n ? t\n : {\n ...t,\n columns: t.columns.map((c) =>\n c.field === 'amount' ? { ...c, fieldProps: { format: 'currency', symbol: '$', precision: 0, summary: 'sum' } } : c,\n ),\n views: [\n { id: 'grid', name: 'All deals', type: 'grid', color: { mode: 'field', field: 'stage' } },\n { id: 'pipeline', name: 'Pipeline', type: 'kanban', stackBy: 'stage' },\n ],\n },\n)\n```\n\n## 5. The page\n\n```svelte\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase } from '@svgrid/enterprise/base'\n import { crmTables } from '$lib/crm'\n\n enableBase()\n let tables = $state.raw(crmTables())\n</script>\n\n<div style=\"height: 640px\">\n <SvBase bind:tables editable sortable summary containerHeight=\"100%\" />\n</div>\n<SvToaster />\n```\n\nOpen it and the base makes one `GET` per table. Change a stage and it sends\n`PATCH /api/deals/d3` with `{ \"stage\": \"Won\" }`. Link a contact to a company\nfrom the Companies tab and it sends `PATCH /api/contacts/p2` with\n`{ \"company_id\": \"c1\" }`.\n\n## Next\n\n- [Keep a base in a database](./data.md): links, record ids, changes made\n elsewhere and every option.\n- [Build a base on Supabase](./supabase.md) and\n [on Postgres](./postgres.md).\n"
|
|
4305
|
+
},
|
|
4306
|
+
{
|
|
4307
|
+
"slug": "help/base/start",
|
|
4308
|
+
"path": "docs/help/base/start.md",
|
|
4309
|
+
"title": "Build a project tracker",
|
|
4310
|
+
"section": "SvGrid Base",
|
|
4311
|
+
"markdown": "# Build a project tracker\n\nSvGrid Base is a table database you put inside your own Svelte app: typed\nfields, saved views (a grid, a Kanban board, a calendar, a gallery, a form),\nfilters, groups, row colors, linked records, and an expanded record with\ncomments. Your users get a tool they already know how to use; you keep the\ndata, the backend and the rest of the app.\n\n<div data-docs-demo=\"504-project-tracker\" data-height=\"660\"></div>\n\n> **SvGrid Base is part of the Suite edition** of `@svgrid/enterprise`. It runs\n> without a key during an evaluation (with a watermark), and with a Grid\n> edition key it runs too, showing the watermark and an upgrade notice.\n\nThis page builds a small tracker step by step. Each step works on its own.\n\n## 1. Turn it on\n\n```ts\nimport { SvBase, enableBase } from '@svgrid/enterprise/base'\n\nenableBase()\n```\n\n`enableBase()` registers the field types and the renderers they draw with.\nCall it once, at app start or in the component that uses it.\n\n## 2. Declare the fields\n\nA field is a column with a `fieldType`. The type brings its editor, its format,\nits alignment and, for the types that need one, its own cell.\n\n```ts\nconst STATUS = [\n { value: 'Todo', color: '#94a3b8' },\n { value: 'In progress', color: '#2d7ff9' },\n { value: 'Done', color: '#20c933' },\n]\n\nlet columns = $state.raw([\n { field: 'task', header: 'Task', fieldType: 'text' },\n { field: 'status', header: 'Status', fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'owner', header: 'Owner', fieldType: 'user' },\n { field: 'due', header: 'Due', fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'budget', header: 'Budget', fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', summary: 'sum' } },\n { field: 'files', header: 'Files', fieldType: 'attachment' },\n])\n```\n\nThe [field types page](../fields.md) lists every type and its properties.\n\n## 3. Render the base\n\n```svelte\n<SvBase data={rows} {columns} primaryField=\"task\"\n editable sortable showRowNumbers expandRows summary containerHeight={600} />\n```\n\n`primaryField` is the record's name: always the first column, pinned, and the\ntitle of a record, a card or a calendar event. `expandRows` turns the row\nnumber into a checkbox and an expand button that opens the record.\n`summary` adds the row of the fields' summaries under the records, with\n\"+ Add row\" below it, both on the column headers' ground. The primary\nfield's cell counts the records the view shows until you pick a summary for\nit.\n\nRight-click a record for its menu: expand it, comment on it, insert a\nrecord above or below it (in its group, when the view is grouped),\nduplicate it, copy and paste cells, or delete it, which asks first. A card\non a board has the same menu, with \"Move to\" a lane. A record made there\ngets its own id and the next autonumber and is saved like any other. Pass\nyour own `contextMenu` to the grid to replace the menu, or\n`contextMenu={false}` to turn it off.\n\nA record with comments shows how many next to its row number and on its\ncard; a click opens the record at its discussion. See\n[Collaboration](./collaboration.md) for where comments are kept.\n\n## 4. Give it views\n\n```ts\nimport type { FieldView } from '@svgrid/enterprise/base'\n\nlet views = $state<FieldView[]>([\n { id: 'all', name: 'All tasks', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'Board', type: 'kanban', stackBy: 'status' },\n { id: 'due', name: 'Due dates', type: 'calendar', dateField: 'due' },\n { id: 'files', name: 'Files', type: 'gallery', coverField: 'files' },\n { id: 'chart', name: 'By status', type: 'chart', chart: { kind: 'bar', x: 'status', y: 'budget' } },\n { id: 'request', name: 'Request a task', type: 'form' },\n])\n```\n\n```svelte\n<SvBase data={rows} {columns} bind:views primaryField=\"task\" editable />\n```\n\nEach view keeps its own filter, sorts, groups, hidden fields, row height and\ncolors. A view can start with them set, as `state` (the grid's view state) and\n`color`.\n\n## 5. Let people change the fields\n\n`columnAdder` puts a \"+\" after the last column, `fieldMenu` adds Edit, Insert,\nDuplicate, Hide and Delete to each column's menu. The grid keeps no schema of\nits own: every change hands back the whole column list.\n\n```svelte\n<SvBase data={rows} {columns} columnAdder fieldMenu\n onColumnAdd={store} onColumnChange={store} onColumnRemove={store} />\n```\n\n```ts\nfunction store(e: { columns: any[]; data?: Row[] }) {\n columns = e.columns\n if (e.data) rows = e.data\n}\n```\n\n## 6. Keep it\n\n`onViewsChange` fires whenever a view is created, renamed, deleted or changed.\n`onCellValueChange`, `onRowAdd` and `onRowsDelete` report the data. Store them\nwherever your app stores things; the base reads them back as props.\n\n## 7. Add tables\n\nA base can hold several tables, as tabs. Give `SvBase` a list of tables\ninstead of `data` and `columns`, and link them with a link field: the other\ntable gets a field that links back, kept in step from then on.\n\n```svelte\n<SvBase bind:tables editable columnAdder fieldMenu onTablesChange={save} />\n```\n\n[Tables and links](../fields.md#tables-and-links) has the shape of a table and\nwhat the tabs do.\n\n## Next\n\n- [Ready-to-use bases](./templates.md): a content calendar, a CRM, a roadmap,\n a hiring tracker and an inventory, each a working starting point.\n- [Link tables](./linked-records.md): a CRM with accounts, contacts and\n deals, and the lookups, rollups and counts across them.\n- [Formulas](./formulas.md): fields computed from the other fields of a\n record.\n- [Views](./views.md): a board, a calendar, a chart and filtered grids over\n the same records, saved per team.\n- [Forms](./forms.md): new records from a form, inside the base or on a\n public page.\n- [People, comments and files](./collaboration.md): real users, comments in\n your backend and uploads to your storage.\n- [Your own field type](./custom-field-type.md): a type with its own cell,\n editor, filter and summary.\n- [Automations](./automations.md): records that update themselves, new\n records made for you, notices and buttons that start work.\n- [Keep a base in a database](./data.md): load the tables from Postgres,\n Supabase or your API and write every change back.\n- [Field types](../fields.md): every type, the view bar, filters, records and\n linked tables.\n"
|
|
4312
|
+
},
|
|
4313
|
+
{
|
|
4314
|
+
"slug": "help/base/supabase",
|
|
4315
|
+
"path": "docs/help/base/supabase.md",
|
|
4316
|
+
"title": "Build a base on Supabase",
|
|
4317
|
+
"section": "SvGrid Base",
|
|
4318
|
+
"markdown": "# Build a base on Supabase\n\nThis tutorial puts a project tracker in a Svelte app with its records in\nSupabase: two tables, Projects and Tasks, a link between them stored as a\nforeign key, and edits from other people arriving through Supabase Realtime.\nIt takes about fifteen minutes and a free Supabase project.\n\n\n\nTry it first in the page, with a stand-in for the Supabase project. Change a\ntask on one side and watch the other:\n\n<div data-docs-demo=\"517-base-supabase\" data-height=\"640\"></div>\n\n> SvGrid Base and the Supabase source are part of the **Suite edition** of\n> `@svgrid/enterprise`. Without a key they run with a watermark, so you can\n> follow this tutorial before you buy.\n\n## 1. Create the tables\n\nIn the Supabase dashboard, open the SQL editor and run:\n\n```sql\ncreate table projects (\n id uuid primary key default gen_random_uuid(),\n name text not null default '',\n owner text\n);\n\ncreate table tasks (\n id uuid primary key default gen_random_uuid(),\n name text not null default '',\n status text default 'Todo',\n due date,\n project_id uuid references projects(id) on delete set null\n);\n\ninsert into projects (name) values ('Website relaunch'), ('Customer portal');\ninsert into tasks (name, status, project_id)\n select 'Wireframes', 'Done', id from projects where name = 'Website relaunch';\n```\n\nThe keys are uuids the database makes. A task's project is the\n`project_id` foreign key; the Projects table has no column for its tasks.\nThe base works that list out from `project_id`.\n\n## 2. Let signed-in people read and write\n\nRow level security decides what each person can do. For a team tracker where\neveryone who is signed in can edit everything:\n\n```sql\nalter table projects enable row level security;\nalter table tasks enable row level security;\n\ncreate policy \"team reads and writes projects\" on projects\n for all to authenticated using (true) with check (true);\ncreate policy \"team reads and writes tasks\" on tasks\n for all to authenticated using (true) with check (true);\n```\n\nNarrow the policies as your app needs; the base talks to Supabase as the\nsigned-in person, so a write a policy refuses comes back to them as an error\nand the cell goes back.\n\n## 3. Turn on Realtime for the tables\n\n```sql\nalter publication supabase_realtime add table projects, tasks;\n```\n\nWithout this the base still works; it just does not see other people's\nchanges until it is opened again.\n\n## 4. Install\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise @supabase/supabase-js\n```\n\nPut the project URL and the anon key in `.env`:\n\n```bash\nPUBLIC_SUPABASE_URL=https://your-project.supabase.co\nPUBLIC_SUPABASE_ANON_KEY=your-anon-key\n```\n\n## 5. Describe the tables to the base\n\nA base table names its fields by type; the Supabase source needs the\ncolumns it reads. Both go in one file:\n\n```ts\n// src/lib/tracker.ts\nimport { createClient } from '@supabase/supabase-js'\nimport { baseSource, type BaseTable } from '@svgrid/enterprise/base'\nimport { createSupabaseDataSource, createSupabaseRealtime, type EntitySchema } from '@svgrid/enterprise'\nimport { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'\n\nexport const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY)\n\n// The columns of each table, as the Supabase source reads them.\nconst schemas: Record<string, EntitySchema> = {\n projects: {\n name: 'projects',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'owner', type: 'text' },\n ],\n },\n tasks: {\n name: 'tasks',\n fields: [\n { field: 'id', type: 'text', primaryKey: true },\n { field: 'name', type: 'text' },\n { field: 'status', type: 'text' },\n { field: 'due', type: 'date' },\n { field: 'project_id', type: 'text' },\n ],\n },\n}\n\n/** A table on Supabase: loads it, writes back, and listens for other people's changes. */\nconst onSupabase = (table: string, links = {}) =>\n baseSource(createSupabaseDataSource({ client: supabase, table, schema: schemas[table]! }), {\n links,\n // The database makes the uuid of a new record.\n serverIds: true,\n realtime: (onChange) => createSupabaseRealtime({ client: supabase, table, onChange }),\n })\n\nexport function trackerTables(): BaseTable[] {\n return [\n {\n id: 'tasks',\n name: 'Tasks',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Task', width: 220, fieldType: 'text' },\n {\n field: 'status', header: 'Status', width: 130, fieldType: 'singleSelect',\n fieldProps: { options: [{ value: 'Todo', color: '#64748b' }, { value: 'In progress', color: '#2563eb' }, { value: 'Blocked', color: '#dc2626' }, { value: 'Done', color: '#16a34a' }] },\n },\n { field: 'project', header: 'Project', width: 170, fieldType: 'link', fieldProps: { table: 'projects', multiple: false, inverse: 'tasks' } },\n { field: 'due', header: 'Due', width: 140, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n ],\n views: [\n { id: 'all', name: 'All tasks', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'Board', type: 'kanban', stackBy: 'status' },\n ],\n // The Project field is the project_id column.\n source: onSupabase('tasks', { project: { column: 'project_id' } }),\n },\n {\n id: 'projects',\n name: 'Projects',\n primaryField: 'name',\n data: [],\n columns: [\n { field: 'name', header: 'Project', width: 200, fieldType: 'text' },\n { field: 'owner', header: 'Owner', width: 160, fieldType: 'text' },\n // Worked out from tasks.project_id: never stored.\n { field: 'tasks', header: 'Tasks', width: 300, fieldType: 'link', fieldProps: { table: 'tasks', multiple: true, inverse: 'project' } },\n ],\n source: onSupabase('projects'),\n },\n ]\n}\n```\n\nThe Project field and the Tasks field name each other (`inverse`), so they\nstay in step: link a task to a project from either table and the task's\n`project_id` is written.\n\n## 6. Put the base on a page\n\n```svelte\n<!-- src/routes/tracker/+page.svelte -->\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, enableBase } from '@svgrid/enterprise/base'\n import { trackerTables } from '$lib/tracker'\n\n enableBase()\n let tables = $state.raw(trackerTables())\n</script>\n\n<div style=\"height: 640px\">\n <SvBase bind:tables editable sortable containerHeight=\"100%\" />\n</div>\n<SvToaster />\n```\n\nRun `npm run dev` and open `/tracker`. The tab bar says \"Loading records...\"\nand then shows the tasks. Change a status, and in the Supabase table editor\nthe row has the new value. Open the page in a second browser, change\nsomething there, and the first one shows it.\n\nThe base runs in the browser, so turn off server rendering for the route\n(`export const ssr = false` in `+page.ts`) or sign the person in before it\nloads, so the requests carry their session.\n\n## 7. Let Supabase describe the tables\n\nStep 5 writes the fields out, which gives you control over each one. To\nstart from the database instead, read each table's columns with\n`introspectSupabaseTable` and let `baseTablesFromSchemas` make the fields and\nthe links:\n\n```ts\nimport { baseTablesFromSchemas, type BaseTable } from '@svgrid/enterprise/base'\nimport { createSupabaseDataSource, introspectSupabaseTable, type SupabaseClientLike } from '@svgrid/enterprise'\n\nexport async function tablesFromSupabase(client: SupabaseClientLike, url: string, key: string): Promise<BaseTable[]> {\n const schemas = await Promise.all(['projects', 'tasks'].map((table) => introspectSupabaseTable({ url, key, table })))\n return baseTablesFromSchemas(\n schemas,\n (schema) => createSupabaseDataSource({ client, table: schema.name, schema }),\n () => ({ serverIds: true }),\n )\n}\n```\n\n`project_id` becomes a Project link with a Tasks field in Projects that links\nback. Adjust the result like any other tables: give Status its colors, add a\nKanban view.\n\n## When something goes wrong\n\n| What you see | Why |\n|---|---|\n| Not saved: new row violates row-level security policy | A policy refused the write. Check the policies in step 2 and that the person is signed in. |\n| Other people's changes do not show | The table is not in the `supabase_realtime` publication (step 3). |\n| Not saved: Failed to fetch | The network dropped. The cell went back; make the change again when the connection returns. |\n| Tasks: the first 10,000 records | The table has more records than `limit`. Raise it, or show that table with the grid and a [server row model](../server/server-row-model.md). |\n\n## Next\n\n- [Keep a base in a database](./data.md): how the writer works, links,\n record ids and every option.\n- [Build a base on Postgres](./postgres.md): the same base with your own\n server and database.\n"
|
|
4319
|
+
},
|
|
4320
|
+
{
|
|
4321
|
+
"slug": "help/base/templates",
|
|
4322
|
+
"path": "docs/help/base/templates.md",
|
|
4323
|
+
"title": "Ready-to-use bases",
|
|
4324
|
+
"section": "SvGrid Base",
|
|
4325
|
+
"markdown": "# Ready-to-use bases\n\nEach base below is one `<SvBase>` with sample data: the fields, the saved\nviews and the forms a team would set up on day one. Open the source of any of\nthem, replace the sample rows with your own, and it is your app's tool.\n\nAll of them share the same parts, so what works in one works in all: the table\ntabs (where a base has more than one table), the view bar (Hide fields, Filter, Group, Sort, Color, Row height, search), the\nexpanded record with its changes and comments, Space on a row to open it,\n\"+ Add row\", the field editor, the selection bar for editing or deleting many\nrows at once, and dragging the pinned columns' edge.\n\n## Project tracker\n\nTwo tables, Tasks and Milestones. Tasks have a status, tags, an assignee,\ndates, an estimate, progress, a budget, files and a milestone; each milestone\nlists its tasks with their average progress and their budget. The\n[tutorial](./start.md) builds the task table step by step.\n\n## Content calendar\n\n<div data-docs-demo=\"511-base-content-calendar\" data-height=\"640\"></div>\n\nTwo tables, Posts and Campaigns. A post has a stage, channels, an author, a\npublish date, a cover image, a word count, a brief and its campaign; a\ncampaign lists its posts and adds up their words.\n\n- **Content plan:** the posts in publishing order, colored by stage, word\n counts summed in the footer.\n- **Pipeline:** a Kanban by stage; dragging a card moves the post along.\n- **Publishing calendar:** posts on their publish date; drag one to another\n day, or pick an empty day to schedule a new one.\n- **Covers:** a gallery of the cover images.\n- **Pitch an idea:** a form with the title, channels, author and brief.\n\n## Sales CRM\n\n<div data-docs-demo=\"512-base-sales-crm\" data-height=\"640\"></div>\n\nThree tables: Deals, Accounts and Contacts. A deal links to an account and a\ncontact and has a stage, an owner, a value, a probability, a close date, the\nnext step, a source and a priority. An account lists its deals and contacts\nand rolls up its pipeline value; a contact lists its deals.\n\n- **All deals:** biggest first, colored by stage; the footer sums the value\n and averages the probability.\n- **Pipeline:** a Kanban by stage.\n- **Closing calendar:** deals on their close date.\n- **Open deals by owner:** grouped by owner, Won and Lost filtered out; each\n group shows its own totals.\n- **New lead:** a form that starts a deal in the Lead stage.\n\nLinking a deal to an account lists it on the account, and the other way\nround. Hover a chip to preview the account, click it to open it (\"Open in\nAccounts\" goes there), and create a new account from the link picker.\n\n## Product roadmap\n\n<div data-docs-demo=\"513-base-product-roadmap\" data-height=\"640\"></div>\n\nTwo tables, Features and Feedback. A feature has a quarter, a status, teams,\na lead, impact, effort in days, start and target dates, customer votes and a\nspec link; each customer request links to the feature it is about, and the\nfeature counts them.\n\n- **Roadmap by quarter:** grouped by quarter, highest impact first, each\n group with its effort and votes; \"+\" in a group adds a feature to that\n quarter.\n- **Status board:** a Kanban by status.\n- **Timeline:** each feature from its start to its target date.\n- **Now:** in progress, in beta or blocked, by target date.\n- **Request a feature:** a form for anyone in the company.\n\n## Hiring tracker\n\n<div data-docs-demo=\"514-base-hiring-tracker\" data-height=\"640\"></div>\n\nTwo tables, Candidates and Roles. A candidate has a photo, a role, a stage, a\nrecruiter, a rating, the interview date, the salary ask, an email, a resume,\nthe source and notes; a role has its team, hiring manager and headcount, and\nlists its candidates.\n\n- **Hiring pipeline:** a Kanban by stage.\n- **All candidates:** best rated first, colored by stage.\n- **Faces:** a gallery of the photos.\n- **Interviews:** a calendar of interview dates.\n- **Apply:** the form a candidate fills in, with their resume.\n\n## Inventory\n\n<div data-docs-demo=\"515-base-inventory\" data-height=\"640\"></div>\n\nTwo tables, Items and Suppliers. An item has photos, a category, a location,\na supplier, the quantity, the unit cost, the stock value (a formula,\n`{Qty} * {Unit cost}`), the condition, who has it checked out, when it was\nlast counted and a barcode; a supplier lists its items and the units in\nstock.\n\n- **Stock by category:** grouped by category, the footer and each group\n summing quantity and value.\n- **Photos:** a gallery.\n- **By location:** a Kanban by location; dragging an item moves it.\n- **Reorder:** three or fewer in stock, red at zero and amber below four.\n- **Add an item:** a form.\n\n## Bug tracker\n\n<div data-docs-demo=\"520-base-bug-tracker\" data-height=\"640\"></div>\n\nTwo tables, Bugs and Releases. A bug has a severity, a status, the areas it\ntouches, who reported it and who is on it, the release it ships in, the\nsteps to reproduce, and when it was reported and fixed. A release lists its\nbugs and counts them.\n\n\n\n- **Triage:** grouped by status, colored by severity.\n- **Board:** a Kanban by status.\n- **Critical now:** critical bugs that are not fixed.\n- **Bugs by area:** a bar chart.\n- **Report a bug:** the form anyone can fill in.\n\nAutomations: a critical bug that is not fixed turns red and raises a notice;\nsetting Status to Fixed stamps \"Fixed on\"; a bug from the form starts as\nNew; **Start** in the Fix column moves a bug to In progress.\n\n## Event planning\n\n<div data-docs-demo=\"521-base-event-planning\" data-height=\"640\"></div>\n\nThree tables: Events, Attendees and Checklist. An event has a date, a venue,\na status, an owner, a budget and a capacity; it counts who registered and\nworks out the seats left (`{Capacity} - {Registered}`). Attendees have a\nticket type and check in on the day; checklist items have a team and a due\ndate.\n\n\n\n- **Calendar:** events on their date, colored by status.\n- **Planning board:** a Kanban by status.\n- **Guest list:** attendees grouped by event.\n- **Tickets by type:** a donut.\n- **RSVP:** the form a guest fills in.\n\nAutomations: an event that turns Confirmed gets its checklist (three items\ncreated in Checklist and linked back to it); an event with no seats left\nturns red and raises a notice, also when the last seat goes from the\nAttendees table; a guest who RSVPs is told they are in.\n\n## OKRs\n\n<div data-docs-demo=\"522-base-okrs\" data-height=\"640\"></div>\n\nTwo tables, Key results and Objectives. A key result measures something from\na start value to a target; its progress is a formula,\n`MAX(0, MIN(1, ({Current} - {Start}) / ({Target} - {Start})))`, and its\nconfidence says whether it will get there. An objective has a quarter and an\nowner, and averages the progress of its key results with a rollup.\n\n\n\n- **By objective:** key results grouped by objective, colored by confidence.\n- **Confidence:** a Kanban.\n- **Weekly check-in:** the numbers to update, least recently checked first.\n- **Progress by objective:** a bar chart of the rollup.\n\nAutomations: a key result that falls Off track turns red and raises a\nnotice; one that reaches its target is marked Achieved.\n\n## Expense approvals\n\n<div data-docs-demo=\"523-base-expenses\" data-height=\"640\"></div>\n\nTwo tables, Expenses and Budgets. An expense has an amount, a category, a\ndate, who spent it, a receipt, a status from Draft to Paid, and who approved\nit and when. A budget category lists its expenses, adds up what was spent\n(a rollup) and shows what is left (a formula over the rollup).\n\n\n\n- **This month:** newest first, colored by status, totals in the footer.\n- **Approvals:** a Kanban by status.\n- **Spend by category:** a pie of the amounts.\n- **Receipts:** a gallery.\n- **Submit an expense:** the form.\n\nAutomations: an expense over $1,000 goes to Needs approval; **Approve** in\nthe Review column approves it and records who and when; a rejected expense\nturns red; one sent with the form starts as Submitted; a category over its\nbudget raises a notice.\n\n## Employee onboarding\n\n<div data-docs-demo=\"524-base-onboarding\" data-height=\"640\"></div>\n\nThree tables: People, Checklist and Departments. A person has a photo, a\nrole, a department, a manager, a start date and a status; checklist items\nhave an owner (IT, HR or the manager) and a due date; departments list their\npeople and count them.\n\n\n\n- **Pipeline:** a Kanban by status.\n- **Photo directory:** a gallery.\n- **Start dates:** a calendar.\n- **By department:** grouped by department.\n- **Add a new hire:** the form.\n\nAutomations: a new hire, from \"+ Add row\" or the form, gets a checklist:\nthree items for IT, HR and their manager, created in Checklist and linked\nback. Someone starting within a week has their start date colored, and\n**Activate** in the Ready column marks them Active.\n\n## Start from your own database\n\nEvery template here is also a database. `npm create @svgrid@latest my-base\n-- -t base` offers each one by name and writes it as SQL to `db/schema.sql`,\nwith its records, views and automations, on Postgres in the browser, your\nown Postgres or Supabase ([From a database to an app](./quick-start.md)):\n\n```bash\nnpm create @svgrid@latest my-base -- -t base --data supabase --sample bug-tracker\n```\n\nThe names: `project-tracker`, `sales-crm`, `content-calendar`,\n`product-roadmap`, `hiring-tracker`, `inventory`, `bug-tracker`,\n`event-planning`, `okrs`, `expenses`, `onboarding`. A base you changed\nhere goes the same way: \"All tables\", then **Download as SQL**.\n"
|
|
4326
|
+
},
|
|
4327
|
+
{
|
|
4328
|
+
"slug": "help/base/views",
|
|
4329
|
+
"path": "docs/help/base/views.md",
|
|
4330
|
+
"title": "Views for every team",
|
|
4331
|
+
"section": "SvGrid Base",
|
|
4332
|
+
"markdown": "# Views for every team\n\nOne table of records, many ways to look at it. Product reads the roadmap\ngrouped by quarter, engineering works off a status board, the leads check a\ntimeline, and anyone in the company files a request through a form. Each of\nthose is a saved view: a name, a type, and the filter, sorts, groups, hidden\nfields, row height and colors it had when someone last left it.\n\n\n\n<div data-docs-demo=\"513-base-product-roadmap\" data-height=\"680\"></div>\n\n> SvGrid Base is part of the **Suite edition** of `@svgrid/enterprise`. Without a key it runs with a watermark.\n\nThis page builds the Features table of that roadmap one view at a time. Each\nstep adds to the `views` list from the step before and works on its own.\n\n## 1. The table\n\n```svelte\n<script lang=\"ts\">\n import { SvBase, enableBase, setFieldUsers, type FieldView } from '@svgrid/enterprise/base'\n\n enableBase()\n setFieldUsers([\n { id: 'u1', name: 'Ana Ruiz' },\n { id: 'u2', name: 'Ben Okafor' },\n { id: 'u3', name: 'Cleo Park' },\n ])\n\n type Feature = {\n id: number; feature: string; quarter: string; status: string; teams: string[]\n lead: string; impact: number; effort: number; start: string; target: string; votes: number\n }\n\n const QUARTERS = [\n { value: 'Q4 2026', color: '#2d7ff9' },\n { value: 'Q1 2027', color: '#18bfff' },\n { value: 'Later', color: '#94a3b8' },\n ]\n const STATUS = [\n { value: 'Planned', color: '#94a3b8' },\n { value: 'In progress', color: '#2d7ff9' },\n { value: 'In beta', color: '#8b46ff' },\n { value: 'Shipped', color: '#20c933' },\n { value: 'Blocked', color: '#f82b60' },\n ]\n const TEAMS = [\n { value: 'Web', color: '#2d7ff9' },\n { value: 'Mobile', color: '#ff6f2c' },\n { value: 'Platform', color: '#8b46ff' },\n ]\n\n let rows = $state.raw<Feature[]>([\n { id: 1, feature: 'Single sign-on with SAML', quarter: 'Q4 2026', status: 'In progress', teams: ['Platform'], lead: 'u2', impact: 5, effort: 12, start: '2026-10-05', target: '2026-11-13', votes: 182 },\n { id: 2, feature: 'Offline mode for the mobile app', quarter: 'Q4 2026', status: 'Blocked', teams: ['Mobile'], lead: 'u3', impact: 4, effort: 13, start: '2026-10-12', target: '2026-12-04', votes: 96 },\n { id: 3, feature: 'Dark mode', quarter: 'Q4 2026', status: 'Shipped', teams: ['Web'], lead: 'u1', impact: 2, effort: 4, start: '2026-10-01', target: '2026-10-16', votes: 154 },\n { id: 4, feature: 'Audit log export', quarter: 'Q1 2027', status: 'Planned', teams: ['Platform', 'Web'], lead: 'u1', impact: 3, effort: 5, start: '2027-01-11', target: '2027-01-29', votes: 41 },\n ])\n\n const columns = [\n { field: 'feature', header: 'Feature', width: 260, fieldType: 'text' },\n { field: 'quarter', header: 'Quarter', fieldType: 'singleSelect', fieldProps: { options: QUARTERS } },\n { field: 'status', header: 'Status', fieldType: 'singleSelect', fieldProps: { options: STATUS } },\n { field: 'teams', header: 'Teams', fieldType: 'multiSelect', fieldProps: { options: TEAMS } },\n { field: 'lead', header: 'Lead', fieldType: 'user' },\n { field: 'impact', header: 'Impact', fieldType: 'rating', fieldProps: { max: 5 } },\n { field: 'effort', header: 'Effort (days)', fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },\n { field: 'start', header: 'Start', fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'target', header: 'Target', fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'votes', header: 'Votes', fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },\n ]\n\n let views = $state<FieldView[]>([{ id: 'all', name: 'All features', type: 'grid' }])\n</script>\n\n<SvBase data={rows} {columns} bind:views primaryField=\"feature\"\n editable sortable showRowNumbers expandRows summary containerHeight={640}\n rowAdder={{ label: 'Add feature', defaults: () => ({ id: Date.now(), status: 'Planned', quarter: 'Later', teams: [] }) }} />\n```\n\nThe view bar over the grid has \"All features\" on the left, then Hide\nfields, Filter, Group, Sort, Color and a row height button, and \"4 records\"\nwith a search button on the right. The footer row shows \"Sum 34\" under Effort\nand \"Sum 473\" under Votes, because those fields set `fieldProps.summary`.\n\nEvery view key that names a field (`stackBy`, `dateField`, a filter\ncondition's `column`, a chart's `x`) takes the column id. A column without an\n`id` uses its `field`, so in this table the two are the same.\n\n## 2. A roadmap grouped by quarter\n\nA view can start with settings. `state` is the grid's own state, the same\nobject `api.getState()` returns; `color` says how rows are colored.\n\n```ts\nconst roadmap: FieldView = {\n id: 'roadmap',\n name: 'Roadmap by quarter',\n type: 'grid',\n color: { mode: 'field', field: 'status' },\n rowHeight: 56,\n state: {\n grouping: ['quarter'],\n sorting: [{ id: 'quarter', desc: false }, { id: 'impact', desc: true }],\n hiddenColumns: ['lead'],\n },\n}\n```\n\nGroups and sorts share the grid's `sorting` list: a grouped field's\ndirection is an entry ahead of the sorts. The view bar writes them that way,\nso write them that way too. Here the bar reads \"1 hidden field\", \"Grouped by\n1 field\" and \"Sorted by 1 field\": the `quarter` entry belongs to the group,\nnot to Sort.\n\nEach group header shows \"Quarter\" over the option's pill, the number of\nrecords, the summaries the fields picked for that group (\"Effort (days) 29\",\n\"Votes 432\"), and \"+\" to add a feature that already has that quarter. Groups\ncome in the select's option order, not alphabetically. Group rows are at\nleast 52px tall so the field's name fits over the value; with\n`rowHeight: 56` they match the records. Each row has a status color stripe\non its row header. 56 is the row height menu's \"Medium\": from 48px, text and\nselect pills wrap.\n\n## 3. A view that starts filtered\n\nThe filter is the grid's advanced filter with its own kind, `fieldFilter`.\nPut it in `state.advancedFilter`:\n\n```ts\nimport { describeFieldFilter, type FieldFilter } from '@svgrid/enterprise/base'\n\nconst activeWork: FieldFilter = {\n kind: 'fieldFilter',\n conjunction: 'and',\n conditions: [\n { column: 'status', kind: 'select', op: 'isAnyOf', value: ['In progress', 'In beta', 'Blocked'] },\n {\n conjunction: 'or',\n conditions: [\n { column: 'target', kind: 'date', op: 'isWithin', value: { span: 'nextDays', days: 30 } },\n { column: 'impact', kind: 'number', op: 'gte', value: 4 },\n ],\n },\n ],\n}\n\nconst now: FieldView = {\n id: 'now',\n name: 'Now',\n type: 'grid',\n state: { sorting: [{ id: 'target', desc: false }], advancedFilter: activeWork },\n}\n\nconst header = (id: string) => columns.find((c) => c.field === id)?.header ?? id\ndescribeFieldFilter(activeWork, header)\n// \"Status is any of In progress, In beta, Blocked and (Target is within the next 30 days or Impact ≥ 4)\"\n```\n\nThe Filter button reads \"Filtered by 3 fields\" (one field reads \"Filtered by\nStatus\") and the record count drops to what the filter keeps.\n`describeFieldFilter` writes a filter in the words the panel uses, for a page\nsubtitle or an audit line. It returns `\"every record\"` for a filter with no\ncomplete conditions.\n\nA condition is a field, an operator and a value; a group is one level deep.\n[Filters in code](#filters-in-code) below lists the operators per kind.\n\nOne view can serve every lead. `ME` (the string `'@me'`) in a person\ncondition stands for whoever `setCurrentUser` names when the filter runs; the\nFilter panel lists it as \"Me\", first among the people.\n\n```ts\nimport { ME, setCurrentUser } from '@svgrid/enterprise/base'\n\nsetCurrentUser({ id: 'u2', name: 'Ben Okafor' })\n\nconst mine: FieldView = {\n id: 'mine',\n name: 'My features',\n type: 'grid',\n state: {\n advancedFilter: {\n kind: 'fieldFilter',\n conjunction: 'and',\n conditions: [{ column: 'lead', kind: 'select', op: 'is', value: ME }],\n },\n },\n}\n```\n\nBen sees \"Single sign-on with SAML\"; Ana, signed in, sees her two. With\nnobody signed in the condition matches no one. `describeFieldFilter` writes\nit as \"Lead is me\".\n\n## 4. A status board\n\n```ts\nconst board: FieldView = { id: 'board', name: 'Status board', type: 'kanban', stackBy: 'status' }\n```\n\n\n\n`stackBy` takes a single select or a user field that holds one person. You\nget one lane per option, in option order and color, plus \"Uncategorized\",\nwhich shows only while a record has no status. Stack by `lead` instead and\nthe lanes are the people from `setFieldUsers`.\n\nDrop a card in another lane and the status changes through the grid's edit\npath, so undo, validation and `onCellValueChange` all see it. \"+ Add a card\"\nadds a record already in that lane, named by the primary field.\n\nA card shows the record's name, then the next three fields the view shows,\nin column order (the stacked field left out), each drawn as its cell draws\nit. Hide a field in Hide fields and it leaves the cards; drag it up there to\nput it on them. Cards are read, not edited: a rating shows its stars\nbut does not take a click. Double-click a card or press F2 to open the\nrecord. Right-click it for its menu: Expand record, Comment, and, when the\nbase is `editable`, Move to (a submenu of the lanes), Duplicate record and\nDelete record, which asks \"Delete record?\" first. A card with comments shows\ntheir count; click it to open the record at its comment box.\n\n## 5. A timeline\n\n```ts\nconst timeline: FieldView = {\n id: 'timeline',\n name: 'Timeline',\n type: 'calendar',\n dateField: 'start',\n endField: 'target',\n color: { mode: 'field', field: 'status' },\n}\n```\n\nEach feature spans its start to its target date. A date field without a time\nshows as all-day events. Drag an event to move both dates; drag its edge to\nchange the target. The calendar's end is exclusive, so the record keeps the\nlast day: a feature dragged to cover the 19th to the 21st gets\n`target: '2026-10-21'`.\n\nWith only `dateField`, events move but cannot be resized. Pick a day (or a\nrange) on the calendar and a new record lands on it, opened with its name\nready to type. Colored by a single select, the events take the option colors\nand the options appear above the calendar as a legend that hides and shows\nthem.\n\nMoving and resizing need `editable` on the base. Without it the calendar is\nread-only.\n\n## 6. Effort by team\n\n```ts\nconst effort: FieldView = { id: 'effort', name: 'Effort by team', type: 'chart', chart: { kind: 'barH', x: 'teams', y: 'effort' } }\nconst mix: FieldView = { id: 'mix', name: 'Features per quarter', type: 'chart', chart: { kind: 'donut', x: 'quarter' } }\n```\n\nA chart groups the records the view shows by `x` and adds up `y` (or counts\nrecords when there is no `y`). `aggregation: 'avg'` averages instead of\nsumming. Kinds are `'bar'`, `'barH'`, `'line'`, `'pie'` and `'donut'`.\n\nHow `x` groups:\n\n- A single or multiple select keeps its options' order and colors. A\n multiple select counts a record in each of its options, so \"Audit log\n export\" adds 5 days to both Platform and Web.\n- A person or a linked record shows by name.\n- A date groups by month (\"Oct 2026\").\n- Records with no value go in an \"Empty\" bar, last.\n\nValues read as the summed field writes them, and a count as a whole number.\nThe series is labeled \"Sum of Effort (days)\", \"Average of ...\" or \"Records\".\nThe bar shows Filter, which narrows what is charted, and \"Customize chart\",\nwhose Type, Group by, Value and Combine (sum or average) are kept in `chart`.\nA chart view declared without `chart` draws \"Choose a field to chart in\nCustomize chart.\" until a field is picked there.\n\n## 7. A gallery of mockups\n\nAdd an attachment field, then a gallery view with it as the cover:\n\n```ts\nconst mockupField = { field: 'mockups', header: 'Mockups', fieldType: 'attachment' }\nconst gallery: FieldView = { id: 'mockups', name: 'Mockups', type: 'gallery', coverField: 'mockups' }\n```\n\nEach card shows the first image of `mockups`, the primary field as its title,\nthen the fields the grid shows, each drawn as its cell draws it. Hide a field\nin this view and it leaves the cards; drag fields in Hide fields to reorder\nthem. A click on a card opens the record. Without `coverField` the cards have\nno cover.\n\n## 8. A request form\n\n```ts\nconst request: FieldView = {\n id: 'request',\n name: 'Request a feature',\n type: 'form',\n formFields: ['feature', 'teams', 'impact'],\n formRequired: ['feature'],\n formTitle: 'Request a feature',\n formDescription: 'What should we build, and who is it for? Product reviews requests every Friday.',\n formSubmitLabel: 'Send request',\n}\n```\n\nThe form asks for the fields in `formFields` (column ids, in order), each with\nthe editor its cell uses. Without it, the form asks for every field a person\ncan fill in, the primary field first; computed fields are left out. A field\nin `formRequired` must be filled before the form sends. `formTitle` defaults\nto the view's name, `formSubmitLabel` to \"Submit\". A sent form adds a record\nthe way \"+ Add row\" does: `rowAdder.defaults` first, so a request starts as\nPlanned in Later, then what was typed, then `onRowAdd`. The bar shows only\nthe view switcher on a form.\n\nAll the views so far, in one list:\n\n```ts\nlet views = $state<FieldView[]>([roadmap, board, timeline, effort, now, mine, gallery, request])\n```\n\n## 9. Keep the views\n\n`onViewsChange` fires with the whole list when a view is created, renamed,\nduplicated or deleted, and about 400ms after the shown view's settings stop\nchanging. Switching views stores the one being left first. Store the list\nwherever the app stores things.\n\nIn the browser:\n\n```svelte\n<script lang=\"ts\">\n import { SvBase, enableBase, type FieldView } from '@svgrid/enterprise/base'\n\n enableBase()\n const KEY = 'roadmap:views'\n const DEFAULT_VIEWS: FieldView[] = [{ id: 'all', name: 'All features', type: 'grid' }]\n\n function read<T>(key: string, fallback: T): T {\n try {\n const raw = localStorage.getItem(key)\n return raw ? (JSON.parse(raw) as T) : fallback\n } catch {\n return fallback\n }\n }\n function write(key: string, value: unknown) {\n try {\n localStorage.setItem(key, JSON.stringify(value))\n } catch {\n // Private mode or a full quota: the views still work, unsaved.\n }\n }\n\n let views = $state<FieldView[]>(read(KEY, DEFAULT_VIEWS))\n let activeView = $state<string>(read(`${KEY}:active`, ''))\n\n $effect(() => write(`${KEY}:active`, activeView))\n</script>\n\n<SvBase data={rows} {columns} primaryField=\"feature\" editable\n bind:views bind:activeView onViewsChange={(v: FieldView[]) => write(KEY, v)} />\n```\n\nOn a server, decide who owns a list.\n\n- **Per user.** Each person keeps their own list, so one lead's filter does\n not change another's. Seed it from the team's defaults the first time.\n- **Shared.** One list per table that everyone reads. Write it only for the\n people you allow; for everyone else, keep their changes in memory or in\n their own list. A shared \"My features\" view built on `ME` still shows each\n person their own records.\n\n```ts\ntype Scope = 'me' | 'team'\n\nasync function loadViews(scope: Scope): Promise<FieldView[]> {\n const res = await fetch(`/api/tables/features/views?scope=${scope}`)\n return res.ok ? ((await res.json()) as FieldView[]) : DEFAULT_VIEWS\n}\n\nfunction saveViews(scope: Scope, next: FieldView[]) {\n void fetch(`/api/tables/features/views?scope=${scope}`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(next),\n })\n}\n```\n\n```svelte\n{#await loadViews('me') then loaded}\n <SvBase data={rows} {columns} primaryField=\"feature\" editable\n views={loaded} onViewsChange={(v: FieldView[]) => saveViews('me', v)} />\n{/await}\n```\n\nOr mount at once and swap the list in when it arrives. When `views` or\n`activeView` changes from outside and the shown view is not the one the grid\nhas, the bar applies it. It never stores the grid's settings into a view it\nhas not applied yet.\n\n```svelte\n<script lang=\"ts\">\n let views = $state<FieldView[]>(DEFAULT_VIEWS)\n let loaded = false\n loadViews('me').then((v) => {\n views = v\n loaded = true\n })\n</script>\n\n<SvBase data={rows} {columns} primaryField=\"feature\" editable\n bind:views onViewsChange={(v: FieldView[]) => loaded && saveViews('me', v)} />\n```\n\nThe `loaded` check matters: about 400ms after mount the bar stores the\ndefault view's settings, and that save must not replace the list on the\nserver before the real one is in.\n\nWith several tables (`<SvBase bind:tables>`), each table keeps its own\n`views`, and `onTablesChange` hands back the whole list of tables, views\nincluded.\n\n## View types and the keys they read\n\n| Type | Keys | View bar buttons |\n|---|---|---|\n| `'grid'` | `state`, `rowHeight`, `color` | Hide fields, Filter, Group, Sort, Color, Row height |\n| `'kanban'` | `stackBy`: a single select, or a user field with one person | Hide fields, Filter, Sort |\n| `'calendar'` | `dateField`, `endField` (optional), `color` | Hide fields, Filter, Sort, Color |\n| `'gantt'` | `dateField`, `endField`, `progressField`, `dependencyField`, `zoom`, `color` | Hide fields, Filter, Sort, Color, Customize timeline |\n| `'gallery'` | `coverField`: an attachment field | Hide fields, Filter, Sort |\n| `'chart'` | `chart`: `kind`, `x`, `y`, `aggregation` | Filter, Customize chart |\n| `'form'` | `formFields`, `formRequired`, `formTitle`, `formDescription`, `formSubmitLabel` | none |\n\nEvery type also keeps `state` (filter, sorts, groups, hidden fields, column\norder, widths, pinning) and switches with the grid's own state, so a filter\nset on the board is still there when you come back to it. The search text is\nnot part of it. The views menu's \"Create\" offers each type `SvBase` can show\nand greys out one the table cannot: a Kanban needs a single select, a\ncalendar a date field, a chart a field to group by. A new view starts with no\nfilter, sorts or groups and every field shown.\n`viewTypes={['grid', 'kanban', 'calendar']}` on `SvBase` limits what\n\"Create\" offers.\n\nA Gantt is a calendar with a task table beside the bars. It is offered once\nthe app has called `enableGanttView()` from `@svgrid/enterprise`. Its bars\ntake the view's colors, `progressField` is a percent field drawn as each\nbar's fill, and `dependencyField` is a link field to the same table naming\nwhat each record waits for, drawn as arrows.\n\n## The view bar, panel by panel\n\n- **Views menu** (the view's name): switch, rename, duplicate (\"Roadmap by\n quarter copy\") and delete. The last view cannot be deleted.\n- **Hide fields**: a switch per field, a search, hide all and show all, and\n drag to reorder. The primary field is always shown.\n- **Filter**: \"Where [field] [operator] [value]\" rows joined by \"and\" or\n \"or\", and condition groups. Each change filters at once. A condition still\n missing its value filters nothing.\n- **Group**: up to three fields, each with a direction.\n- **Sort**: fields in order, each with a direction labeled for its type (\"1\n → 9\", \"A → Z\", \"first → last\" for a single select). Attachment and button\n fields are not offered.\n- **Color**: None, Select field (a single select's option colors) or\n Conditions in order.\n- **Row height**: Short 34, Medium 56, Tall 88, Extra tall 128.\n `rowHeights` on `SvBase` or `SvBaseViewBar` sets the list.\n- **Search**: in a grid view, the grid's find bar (Ctrl+F) that marks every\n match. In a board, calendar or gallery, a \"Find a record\" box that narrows\n the records. Switching views clears it.\n\nA button with something set says so and is tinted. With `fieldMenu` on, each\ncolumn's menu adds \"Filter by this field\" and \"Group by this field\", which\nopen the bar's panel with the new condition or group in it.\n\n## Filters in code\n\n`filterKindOf(columnDef)` gives the kind a field filters as, and\n`operatorsFor(kind)` the operators the panel offers for it, in menu order.\n[Filter conditions](../fields.md#filter-conditions) maps each field type to\nits kind.\n\n| `kind` | `op` values | `value` |\n|---|---|---|\n| `'text'`, `'link'` | `contains`, `notContains`, `is`, `isNot` | a string; a link compares the linked records' names |\n| `'number'` | `eq`, `neq`, `lt`, `gt`, `lte`, `gte` | a number |\n| `'select'` | `is`, `isNot` | one option value, or a person's id (or `ME`) for a user field |\n| `'select'` | `isAnyOf`, `isNoneOf` | an array of them |\n| `'multi'` | `hasAnyOf`, `hasAllOf`, `isExactly`, `hasNoneOf` | an array of option values or ids |\n| `'checkbox'` | `is` | `true` or `false` |\n| `'date'` | `is`, `isBefore`, `isAfter`, `isOnOrBefore`, `isOnOrAfter` | `{ mode: 'today' }`, with `mode` also `'tomorrow'`, `'yesterday'`, `'oneWeekAgo'`, `'oneWeekFromNow'`, `'oneMonthAgo'`, `'oneMonthFromNow'`; or `{ mode: 'exactDate', date: '2026-11-01' }` |\n| `'date'` | `isWithin` | `{ span: 'pastWeek' }`, with `span` also `'pastMonth'`, `'pastYear'`, `'nextWeek'`, `'nextMonth'`, `'nextYear'`; or `{ span: 'pastDays', days: 14 }`, `{ span: 'nextDays', days: 14 }` |\n| `'files'` | `contains` (file name contains) | a string |\n\nEvery kind except checkbox also takes `isEmpty` and `isNotEmpty`, with no\nvalue. A few rules from the code:\n\n- An empty number cell is no number: it matches only `neq`.\n- Dates compare whole days. A bare `'2026-11-01'` is that day where the user\n is, and \"within the past week\" runs from 7 days ago to today.\n- A user condition holds the person's id (`'u2'`), not the name, or `ME`\n for whoever is signed in.\n- A link condition sets `table` to the linked table's id, so it can match\n records by name.\n\nSet a filter on the shown view with `api.setAdvancedFilter(filter)`; the\nview keeps it like one set in the panel.\n\n## Row colors: by field or by conditions\n\n```ts\nimport type { ColorSetting } from '@svgrid/enterprise/base'\n\nconst byStatus: ColorSetting = { mode: 'field', field: 'status' }\nconst byRisk: ColorSetting = {\n mode: 'conditions',\n rules: [\n { color: '#f82b60', condition: { column: 'status', kind: 'select', op: 'is', value: 'Blocked' } },\n { color: '#fcb400', condition: { column: 'target', kind: 'date', op: 'isBefore', value: { mode: 'today' } } },\n ],\n}\n```\n\nBy field, each row takes its option's color, and the row header's title says\nthe option, so the color is never the only signal. A multiple select colors\nby its first value. By conditions, the first rule that matches wins: a\nblocked feature is red even when it is also late. A condition is tested as\nthe filter tests it: on the value the cell shows, so a formula, lookup,\nrollup or count field works, and a condition still missing its value colors\nnothing. A calendar colors its events by a single select only; a Gantt's bars\ntake either kind.\n\nThe color is a 3px stripe on the row header, unless the `rowColor` prop on\n`SvBase` sets a `target` (`'bar'` or `'row'`): a view's colors use it too. A\nview without `color` shows the `rowColor` prop's colors until someone picks\ncolors for it in Color. `{ mode: 'none' }` turns the colors off in that view.\n\n## Summaries in the footer and in group headers\n\nWith `summary` on, a click on a field's footer cell picks its summary: sum,\naverage, median, min, max or range for numbers; checked, unchecked or percent\nchecked for a checkbox; earliest, latest or the date range in days for dates;\nempty, filled, unique, percent empty or percent filled for any field. The\nresult is written as the field writes values. The choice is stored in the\nfield's `fieldProps.summary` and reported through `onColumnChange`; see\n[Summaries at the bottom](../fields.md#summaries-at-the-bottom).\n\nGroup headers show the same summaries over that group's records. Both leave\nout records the filter hides, even in a collapsed group.\n\n## The view bar over your own grid\n\n`SvBaseViewBar` is the bar alone, for a page that lays out its own\n`<SvGrid>`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { SvBaseViewBar, enableBase, type FieldView } from '@svgrid/enterprise/base'\n\n enableBase()\n // Raw: the bar finds its grid by the api object itself, and a proxy is another object.\n let api = $state.raw<any>(null)\n let views = $state<FieldView[]>([\n { id: 'all', name: 'All features', type: 'grid' },\n { id: 'board', name: 'Status board', type: 'kanban', stackBy: 'status' },\n ])\n let activeView = $state('all')\n</script>\n\n{#if api}\n <SvBaseViewBar {api} bind:views bind:activeView\n rowHeights={[{ label: 'Compact', height: 32 }, { label: 'Roomy', height: 64 }]} />\n{/if}\n<SvGrid data={rows} {columns} primaryField=\"feature\" editable containerHeight={600}\n onApiReady={(a) => (api = a)} />\n```\n\nIts \"Create\" offers grid, Kanban and calendar (and Gantt once enabled). It\nloads the board, calendar and gallery renderers the first time a view of\nthat type is shown, and opens the record and the \"Delete record?\" question\nfor a card's menu. Chart and form views are drawn by `SvBase`, not by the\nbar, so leave them out of `viewTypes` here, or draw `SvBaseForm` yourself\nwhen a form view is active.\n\n## Pitfalls\n\n- **A new view from \"Create\" starts with `color: { mode: 'none' }`**, so it\n hides the `rowColor` prop's colors. A new calendar or Gantt is the\n exception: it starts colored by the first single select. Pick colors in\n Color, or declare the view in code without `color`.\n- **`color.field` is the data field; the other keys are column ids.** They\n differ only when a column sets an `id`.\n- **Summaries belong to the field, not the view.** Picking \"Average\" in one\n view changes every view's footer and group headers.\n- **Save views only after they load.** See the `loaded` check in step 9.\n\n## Next\n\n- [Linked records](./linked-records.md): link Features to Feedback and chart\n or filter by the linked names.\n- [Formulas](./formulas.md): computed fields a view can sort, group and chart\n by.\n- [Forms](./forms.md): required fields, the thank-you screen and forms\n outside the base.\n- [Collaboration](./collaboration.md): comments, people and who changed what.\n- [Automations](./automations.md): conditions written with this same filter.\n- [Keep a base in a database](./data.md): load the tables from Postgres,\n Supabase or your API and write every change back.\n"
|
|
4333
|
+
},
|
|
4068
4334
|
{
|
|
4069
4335
|
"slug": "help/benchmarks",
|
|
4070
4336
|
"path": "docs/help/benchmarks.md",
|
|
@@ -4098,7 +4364,7 @@ export const docs = [
|
|
|
4098
4364
|
"path": "docs/help/cells/cell-data-types.md",
|
|
4099
4365
|
"title": "Cell data types",
|
|
4100
4366
|
"section": "Cells",
|
|
4101
|
-
"markdown": "# Cell data types\n\
|
|
4367
|
+
"markdown": "# Cell data types\n\nA column has a data type and an editor, set separately. `cellDataType` says\nwhat the values are; `editorType` says what edits them.\n<div data-docs-demo=\"01-quick-start\" data-height=\"540\"></div>\n\n| Effect | Driven by |\n| ------ | --------- |\n| Which sort comparator is used | `cellDataType`: `number` and `date` / `dateString` pick non-default `sortFns` |\n| Which filter operators the column menu offers | `cellDataType`: text / number / date / boolean sets differ |\n| Default alignment | `cellDataType`: numbers and dates right, booleans centered |\n| Which inline editor opens on `F2` / double-click | `editorType` |\n\nSo a number column can be edited in a plain text box, a number input, or\nany editor registered with `registerCellEditor`, and still sort, filter and\nalign as numbers:\n\n```ts\nconst columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First', cellDataType: 'text' },\n { field: 'age', header: 'Age', cellDataType: 'number' }, // number input\n { field: 'salary', header: 'Salary', cellDataType: 'number', editorType: 'text' }, // typed as text\n { field: 'rating', header: 'Rating', cellDataType: 'number', editorType: 'stars' }, // a registered editor\n { field: 'joined', header: 'Joined', cellDataType: 'date' },\n { field: 'due', header: 'Due', cellDataType: 'dateString' }, // ISO date strings\n { field: 'active', header: 'Active', cellDataType: 'boolean' },\n]\n```\n\nA column with no `editorType` gets the usual editor for its data type:\n\n| `cellDataType` | default `editorType` | + format |\n| --- | --- | --- |\n| `text` | `text` | - |\n| `number` | `number` | - |\n| `boolean` | `checkbox` | - |\n| `date` | `date` | `{ type: 'date' }` |\n| `dateString` | `date` | - |\n\nAnything you set explicitly (`editorType`, `align`, `format`) always wins.\n\nA column that sets only a built-in `editorType` (`number`, `date`,\n`datetime`, `checkbox`) and no `cellDataType` takes its data type from it, as\nit always has: `{ editorType: 'number' }` still sorts and filters as numbers.\n\nSet a data type even if you do not enable inline editing. Without one,\nsorting falls back to the `auto` comparator: a column whose values are all\nnumbers still sorts numerically, but numbers stored as strings (`'10'`,\n`'9'`) sort as text, and a column mixing numbers and text puts the numbers\nfirst.\n\nEvery built-in comparator sorts blanks (`null`, `undefined`, `''`) first in\nascending order and last in descending. The `number` and `date` comparators\ntreat values they cannot parse (`'n/a'`, an invalid date) the same way.\n\n## Inferring types from the data\n\nSet `inferColumnTypes` on the grid and any column without a data type (no\n`cellDataType`, and no `number` / `date` / `checkbox` editor) has it inferred\nfrom the first data row (number / boolean / `Date` / ISO date-string / text),\nwhatever edits it:\n\n```svelte\n<SvGrid {data} {columns} inferColumnTypes />\n```\n\n## Built-in types\n\n| `editorType` | accepted value space |\n| ------------ | -------------------- |\n| `'text'` | strings |\n| `'number'` | numbers (or numeric strings) |\n| `'date'` | ISO date strings (`YYYY-MM-DD`) or `Date` |\n| `'datetime'` | ISO datetime strings or `Date` |\n| `'checkbox'` | booleans |\n\n## Custom types\n\nThere is no plug-in \"register a new cell data type\" API. To use a custom\ntype:\n\n- Render with a custom `cell` (see [Cell components](./cell-components.md))\n- Sort with a custom value through `fieldFn` that normalises to a\n comparable primitive\n- Filter with a custom operator UI in your own header component\n\n## Editor value parsing\n\nThe editor receives a string from the DOM and converts to the canonical\nvalue before commit. The implementation lives in\n[`parseEditorValue`](../../../packages/grid/src/editors/cell-editors.ts).\n\n```ts\nimport { parseEditorValue } from '@svgrid/grid'\n\nparseEditorValue('number', '42') // 42\nparseEditorValue('number', 'abc') // NaN - caller should reject\nparseEditorValue('checkbox', 'true') // true\nparseEditorValue('date', '2026-05-27') // '2026-05-27'\n```\n\n## Telling the grid what a string holds\n\nA date that arrives as a string sorts alphabetically unless you say otherwise.\n`cellDataType: 'dateString'` is that declaration, and it changes sorting and the\nfilter operators together.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n // Without cellDataType this sorts as text, which puts 2019 after 2021-03.\n { field: 'joined', header: 'Joined', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={people} {columns} sortable filterable filterMode=\"menu\" />\n```\n\n\n## Booleans\n\n`cellDataType: 'boolean'` gives the column the true/false filter rather than a\ntext box, and pairs with the checkbox editor so reading and writing agree.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'active', header: 'Active', width: 120,\n cellDataType: 'boolean', editorType: 'checkbox' },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" editable />\n```\n\n## See also\n\n- [Provided cell editors](../editing/provided-editors.md)\n- [Filter conditions](../filtering/filter-conditions.md)\n"
|
|
4102
4368
|
},
|
|
4103
4369
|
{
|
|
4104
4370
|
"slug": "help/cells/cell-text-selection",
|
|
@@ -4350,7 +4616,7 @@ export const docs = [
|
|
|
4350
4616
|
"path": "docs/help/columns/column-pinning.md",
|
|
4351
4617
|
"title": "Column pinning",
|
|
4352
4618
|
"section": "Columns",
|
|
4353
|
-
"markdown": "# Column pinning\n\nPinning sticks a column to the **left** or **right** edge of the viewport so\nit does not scroll horizontally with the rest.\n\n\n\nLive demo - pin Company left, Price right, scroll the middle:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"480\"></div>\n\n## Through the column menu\n\nEvery column header has a menu (the `⋮` button). The menu has \"Pin left\" /\n\"Pin right\" / \"Unpin\" items.\n\n## Programmatically\n\n`SvGridApi` exposes `setColumnPinning` and `getColumnPinning` for\nread / write from outside the grid:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.setColumnPinning({ left: ['company'], right: ['actions'] })}>\n Pin company left, actions right\n</button>\n<button onclick={() => api?.setColumnPinning({ left: [], right: [] })}>\n Unpin all\n</button>\n```\n\nFor initial pinning at mount, use the `initialColumnPinning` prop on\n`<SvGrid>`:\n\n```svelte\n<SvGrid {data} {columns} features={features}\n initialColumnPinning={{ left: ['company'], right: ['actions'] }} />\n```\n\n`getColumnPinning()` returns `{ left: string[]; right: string[] }` -\nthe array order is the visible order along the pinned edge.\n\n## Rendering\n\n- Pinned-left columns sit at the start of the visible row, with `position:\n sticky; left: <offset>px; z-index: 3`.\n- Pinned-right columns sit at the end of the visible row, with `position:\n sticky; right: <offset>px; z-index: 3`.\n- Offsets cascade - second-left column sits at the cumulative width of\n the first.\n\n## Styling\n\nPinned columns are visually differentiated from the scrollable middle\nthrough three layered cues:\n\n1. **A distinct background tint** so the pinned strip reads as \"frozen\"\n even before you scroll.\n2. **A 1-pixel divider** on the inside edge (the side facing the\n scrollable region) and a soft drop shadow that fades into the\n scroll area.\n3. **A bolder header** font weight so the frozen header reads as part\n of the grid chrome.\n\nAll three cues are driven by CSS custom properties. Override them to\nmatch your design system:\n\n| Token | Default | Used for |\n|-----------------------------|---------------------------------------------------------------------------|--------------------------------|\n| `--sg-pinned-bg` | `color-mix(in oklab, var(--sg-header-bg) 92%, var(--sg-accent) 8%)` | Body cells in pinned columns |\n| `--sg-pinned-header-bg` | `color-mix(in oklab, var(--sg-header-bg) 86%, var(--sg-accent) 14%)` | Header cells in pinned columns |\n| `--sg-pinned-divider` | `var(--sg-border)` | The 1-pixel inside-edge line |\n| `--sg-pinned-shadow-color` | `rgba(15, 23, 42, 0.22)` | The drop shadow into the scroll area |\n\n> **Keep the pinned background opaque.** When you override `--sg-pinned-bg`\n> with `color-mix`, the two percentages **must sum to 100%**. If they add up\n> to less (e.g. `60% + 20% = 80%`), CSS scales the result's alpha down to\n> `0.8` - the pinned column turns semi-transparent and the scrolling middle\n> columns bleed through it. Always pair the percentages as `N%` / `(100 - N)%`.\n\nThe fallbacks compute a subtle accent-tinted background from your\nexisting header background, so a pinned column never looks identical\nto the rest of the grid even if you don't set anything. That works\nwell for a low-chroma accent. If yours is saturated, the derived tint\ncan get loud enough to compete with your selection colour - name a\nneutral fill instead and let the divider and shadow carry the\nboundary:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-row-alt-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n --sg-pinned-divider: var(--sg-border);\n --sg-pinned-shadow-color: rgba(94, 72, 52, 0.18);\n}\n```\n\nTo opt out of the tint and match the body exactly:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n}\n```\n\nOr to make pinned columns *very* obvious - useful for high-density\nfinancial grids where the user must instantly know which side is\nfrozen:\n\n```css\n.themed-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg) 80%, var(--sg-accent) 20%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-bg) 70%, var(--sg-accent) 30%);\n --sg-pinned-divider: var(--sg-accent);\n}\n```\n\n### Hover, selection, and zebra rows\n\nThe pinned tint sits *under* the hover, selection, and zebra row\nbackgrounds via stacked `linear-gradient` paints. That means:\n\n- Hovering a row in a pinned cell shows the pinned tint *with* the\n hover overlay on top - the user still sees the row highlight.\n- Selecting a row layers the selection colour over the pinned tint.\n- Zebra rows re-paint the pinned cell so the alternating row\n background doesn't leak through the pin.\n\nYou don't need to override anything for these states to work; they\nfollow `--sg-row-hover-bg`, `--sg-selection-bg`, and the pinned tokens\nautomatically.\n\n## Multiple pinned columns\n\nMultiple pins are stacked in the order they were pinned. The first-pinned\ncolumn is the outermost.\n\n## Gotchas\n\n- Pinning **plus** column virtualization is supported. The pinned columns\n are always rendered, as their own runs either side of the virtual window;\n the columns between them stay virtualized, so a wide grid with pinned\n edges renders only the columns in view plus the pinned ones.\n- If you have so many pinned columns that they exceed the viewport width\n there is no horizontal scrollbar within the pinned regions - the user\n loses access to the non-pinned middle. Pin only \"anchor\" columns\n (identifier, action, status) and keep the count single-digit.\n\n## Try it\n\nPinning is a grid-level prop naming column ids, not a flag on the column.\nScroll the grid sideways: Name stays at the left edge, Salary at the right.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n/>\n```\n\n## Pinning both edges\n\nPinned columns stay put while the middle scrolls. Narrow the browser and the\ntwo pinned columns hold their edges while Department and City pan between them.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n sortable\n/>\n```\n\n## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
|
|
4619
|
+
"markdown": "# Column pinning\n\nPinning sticks a column to the **left** or **right** edge of the viewport so\nit does not scroll horizontally with the rest.\n\n\n\nLive demo - pin Company left, Price right, scroll the middle:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"480\"></div>\n\n## Through the column menu\n\nEvery column header has a menu (the `⋮` button). The menu has \"Pin left\" /\n\"Pin right\" / \"Unpin\" items.\n\n## Drag to pin\n\n`pinSplitter` draws a divider at the right edge of the left-pinned columns\n(or of the row numbers when none are pinned). Drag it right to pin the\ncolumns it passes, left to unpin them; it snaps to column edges and shows how\nmany columns it will pin.\n\n<div data-docs-demo=\"510-column-pin-splitter\" data-height=\"520\"></div>\n\n```svelte\n<SvGrid {data} {columns} showRowNumbers pinSplitter\n initialColumnPinning={{ left: ['order'], right: [] }} />\n```\n\n- The columns stay where they are on screen: a column you unpin does not\n jump back to where it was declared, and `onColumnOrderChange` reports the\n order when it changes.\n- It leaves 120px for the columns that scroll and never pins every column.\n- Escape drops a drag. From the keyboard, Tab to the divider: the arrow keys\n pin or unpin one column, Home unpins them all. It is a `separator` that says\n how many columns are pinned.\n- Its words come from `localization.text`: `pinSplitterLabel`,\n `pinSplitterHint`, `pinSplitterNone`, `pinSplitterOne` and\n `pinSplitterMany` (`{count}` is the number of pinned columns).\n- The grid loads it only when the prop is on.\n\n## Programmatically\n\n`SvGridApi` exposes `setColumnPinning` and `getColumnPinning` for\nread / write from outside the grid:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.setColumnPinning({ left: ['company'], right: ['actions'] })}>\n Pin company left, actions right\n</button>\n<button onclick={() => api?.setColumnPinning({ left: [], right: [] })}>\n Unpin all\n</button>\n```\n\nFor initial pinning at mount, use the `initialColumnPinning` prop on\n`<SvGrid>`:\n\n```svelte\n<SvGrid {data} {columns} features={features}\n initialColumnPinning={{ left: ['company'], right: ['actions'] }} />\n```\n\n`getColumnPinning()` returns `{ left: string[]; right: string[] }` -\nthe array order is the visible order along the pinned edge.\n\n## Rendering\n\n- Pinned-left columns sit at the start of the visible row, with `position:\n sticky; left: <offset>px; z-index: 3`.\n- Pinned-right columns sit at the end of the visible row, with `position:\n sticky; right: <offset>px; z-index: 3`.\n- Offsets cascade - second-left column sits at the cumulative width of\n the first.\n\n## Styling\n\nPinned columns are visually differentiated from the scrollable middle\nthrough three layered cues:\n\n1. **A distinct background tint** so the pinned strip reads as \"frozen\"\n even before you scroll.\n2. **A 1-pixel divider** on the inside edge (the side facing the\n scrollable region) and a soft drop shadow that fades into the\n scroll area.\n3. **A bolder header** font weight so the frozen header reads as part\n of the grid chrome.\n\nAll three cues are driven by CSS custom properties. Override them to\nmatch your design system:\n\n| Token | Default | Used for |\n|-----------------------------|---------------------------------------------------------------------------|--------------------------------|\n| `--sg-pinned-bg` | `color-mix(in oklab, var(--sg-header-bg) 92%, var(--sg-accent) 8%)` | Body cells in pinned columns |\n| `--sg-pinned-header-bg` | `color-mix(in oklab, var(--sg-header-bg) 86%, var(--sg-accent) 14%)` | Header cells in pinned columns |\n| `--sg-pinned-divider` | `var(--sg-border)` | The 1-pixel inside-edge line |\n| `--sg-pinned-shadow-color` | `rgba(15, 23, 42, 0.22)` | The drop shadow into the scroll area |\n\n> **Keep the pinned background opaque.** When you override `--sg-pinned-bg`\n> with `color-mix`, the two percentages **must sum to 100%**. If they add up\n> to less (e.g. `60% + 20% = 80%`), CSS scales the result's alpha down to\n> `0.8` - the pinned column turns semi-transparent and the scrolling middle\n> columns bleed through it. Always pair the percentages as `N%` / `(100 - N)%`.\n\nThe fallbacks compute a subtle accent-tinted background from your\nexisting header background, so a pinned column never looks identical\nto the rest of the grid even if you don't set anything. That works\nwell for a low-chroma accent. If yours is saturated, the derived tint\ncan get loud enough to compete with your selection colour - name a\nneutral fill instead and let the divider and shadow carry the\nboundary:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-row-alt-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n --sg-pinned-divider: var(--sg-border);\n --sg-pinned-shadow-color: rgba(94, 72, 52, 0.18);\n}\n```\n\nTo opt out of the tint and match the body exactly:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n}\n```\n\nOr to make pinned columns *very* obvious - useful for high-density\nfinancial grids where the user must instantly know which side is\nfrozen:\n\n```css\n.themed-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg) 80%, var(--sg-accent) 20%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-bg) 70%, var(--sg-accent) 30%);\n --sg-pinned-divider: var(--sg-accent);\n}\n```\n\n### Hover, selection, and zebra rows\n\nThe pinned tint sits *under* the hover, selection, and zebra row\nbackgrounds via stacked `linear-gradient` paints. That means:\n\n- Hovering a row in a pinned cell shows the pinned tint *with* the\n hover overlay on top - the user still sees the row highlight.\n- Selecting a row layers the selection colour over the pinned tint.\n- Zebra rows re-paint the pinned cell so the alternating row\n background doesn't leak through the pin.\n\nYou don't need to override anything for these states to work; they\nfollow `--sg-row-hover-bg`, `--sg-selection-bg`, and the pinned tokens\nautomatically.\n\n## Multiple pinned columns\n\nMultiple pins are stacked in the order they were pinned. The first-pinned\ncolumn is the outermost.\n\n## Gotchas\n\n- Pinning **plus** column virtualization is supported. The pinned columns\n are always rendered, as their own runs either side of the virtual window;\n the columns between them stay virtualized, so a wide grid with pinned\n edges renders only the columns in view plus the pinned ones.\n- If you have so many pinned columns that they exceed the viewport width\n there is no horizontal scrollbar within the pinned regions - the user\n loses access to the non-pinned middle. Pin only \"anchor\" columns\n (identifier, action, status) and keep the count single-digit.\n\n## Try it\n\nPinning is a grid-level prop naming column ids, not a flag on the column.\nScroll the grid sideways: Name stays at the left edge, Salary at the right.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n/>\n```\n\n## Pinning both edges\n\nPinned columns stay put while the middle scrolls. Narrow the browser and the\ntwo pinned columns hold their edges while Department and City pan between them.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n sortable\n/>\n```\n\n## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
|
|
4354
4620
|
},
|
|
4355
4621
|
{
|
|
4356
4622
|
"slug": "help/columns/column-sizing",
|
|
@@ -4513,6 +4779,13 @@ export const docs = [
|
|
|
4513
4779
|
"section": "Help",
|
|
4514
4780
|
"markdown": "# Expression query language - Enterprise\n\nThe expression language is a small, dependency-free query language your users\nauthor through the UI. It is the connective tissue behind [alerts](./alerts.md)\n(and, in time, styled and calculated columns): one model for predicates, scalar\nmaths, and change detection, evaluated the same way everywhere.\n\nIt ships in `@svgrid/enterprise`. Leaf comparisons delegate to the grid's own\n`applyExcelFilter`, so an expression's operators mean exactly what the column\nfilter menu means.\n\n## The editor\n\n`<SvExpressionEditor>` offers two modes over the same value:\n\n- **Builder** (default) - a list of conditions (column + operator + value)\n combined with *all* (AND) or *any* (OR), just like the filter row. Set\n operators show a token input; `between` shows two fields; blank operators show\n none.\n- **Text** - a free-text box for power users, with live validation and column\n maths.\n\nBoth show a live \"matches N of M\" count against a sample `rows` array.\n\n```svelte\n<script lang=\"ts\">\n import { SvExpressionEditor, type ExprColumn, type PredicateExpr } from '@svgrid/enterprise'\n\n const columns: ExprColumn[] = [\n { id: 'price', name: 'Price', type: 'number' },\n { id: 'region', name: 'Region', type: 'text' },\n ]\n let expr = $state<PredicateExpr>({ kind: 'const', value: true })\n</script>\n\n<SvExpressionEditor {columns} bind:value={expr} rows={sample} />\n```\n\n## Text syntax\n\nColumn references are a bare id (`price`) or a bracketed label (`[Unit Price]`)\nwhen the name has spaces.\n\n```\nprice > 100 AND region IN (\"EU\", \"US\")\nname CONTAINS \"widget\" OR name STARTSWITH \"gadget\"\nprice BETWEEN 10 AND 20\nname ISBLANK\nprice / qty >= 40 -- cross-column maths\nSUM(amount) > 10000 -- aggregate over the rows in scope\n```\n\n- Logic: `AND`, `OR`, `NOT`, parentheses.\n- Text/set/range operators: `CONTAINS`, `STARTSWITH`, `ENDSWITH`, `MATCHES`\n (regex), `IN (…)`, `BETWEEN … AND …`, `ISBLANK`, `ISNOTBLANK`.\n- Symbolic comparators: `=`, `!=`, `>`, `<`, `>=`, `<=` (these also drive\n cross-column maths).\n- Arithmetic: `+`, `-`, `*`, `/`, `%`, unary `-`.\n- Aggregates: `SUM(col)`, `AVG(col)`, `COUNT(col)`.\n- Functions: `ABS`, `ROUND`, `FLOOR`, `CEIL`, `MIN`, `MAX`, `IF`, `COALESCE`,\n `CONCAT`, `LOWER`, `UPPER`, `LEN`.\n\nA `column = literal` comparison parses to a grid-filter `cmp` node (so it folds\naccents/case like the filter row); general comparisons and maths parse to a\n`scalarCmp`.\n\n## The model\n\nThe canonical form is a JSON AST - no parser is needed at runtime. Three\nfamilies:\n\n- **`PredicateExpr`** - boolean: `and` / `or` / `not` / `cmp` (a filter-operator\n test) / `scalarCmp` (compare two scalar expressions) / `const`.\n- **`ScalarExpr`** - a single value: `col` / `lit` / `bin` (arithmetic) / `agg` /\n `func`.\n- **`ChangeExpr`** - `changed` / `delta` / `percentChange` / `crossed`, for the\n \"relative change\" alert trigger.\n\n## Evaluating expressions\n\n```ts\nimport { evaluatePredicate, parsePredicate, validateExpression } from '@svgrid/enterprise'\n\nconst expr = parsePredicate('price > 100 AND region = \"EU\"', columns)\nvalidateExpression(expr, columns) // [] when sound\nevaluatePredicate(expr, { row: { price: 120, region: 'EU' } }) // true\n```\n\n`evaluateScalar` and `evaluateChange` cover the other two families. All three are\npure functions - safe to unit-test and to run in a Web Worker.\n\nAdvanced scalar functions can be injected per evaluation via the context's\n`functions` map (for example, to delegate a formula to HyperFormula) without\nmaking it a hard dependency.\n\n## Try it\n\nBuild a predicate in the visual editor and watch the AST underneath. The same\nvalue round-trips through the text mode, which is what makes it storable.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvExpressionEditor } from '@svgrid/enterprise'\n import type { ExprColumn, PredicateExpr } from '@svgrid/enterprise'\n\n const columns: ExprColumn[] = [\n { id: 'name', name: 'Name', type: 'text' },\n { id: 'department', name: 'Department', type: 'text' },\n { id: 'age', name: 'Age', type: 'number' },\n { id: 'salary', name: 'Salary', type: 'number' },\n ]\n\n const rows = [\n { name: 'Ada Lovelace', department: 'Engineering', age: 36, salary: 142000 },\n { name: 'Grace Hopper', department: 'Engineering', age: 45, salary: 168000 },\n { name: 'Linus Torvalds', department: 'Platform', age: 54, salary: 155000 },\n ]\n\n let expr = $state<PredicateExpr>({ kind: 'const', value: true })\n</script>\n\n<SvExpressionEditor {columns} {rows} bind:value={expr} />\n\n<pre>{JSON.stringify(expr, null, 2)}</pre>\n```\n\nThe AST is plain JSON, so a rule authored here is something you can put in a\ndatabase and evaluate later - which is exactly what [alerts](./alerts.md) do\nwith it.\n\n## See also\n\n- [Alerts](./alerts.md) - the flagship consumer of this language.\n- [Filtering](./filtering/overview.md) - the column filter operators the `cmp` nodes reuse.\n"
|
|
4515
4781
|
},
|
|
4782
|
+
{
|
|
4783
|
+
"slug": "help/fields",
|
|
4784
|
+
"path": "docs/help/fields.md",
|
|
4785
|
+
"title": "Field types",
|
|
4786
|
+
"section": "SvGrid Base",
|
|
4787
|
+
"markdown": "# Field types\n\nA column can say what it is - a rating, a URL, a single select with colored\noptions, a list of files - with `fieldType`, and set that type's properties\nwith `fieldProps`. The type fills in the editor, the format and the\nalignment, and draws the cell when the type needs a cell of its own (stars,\nlinks, file thumbnails). The same module adds a \"+\" after the last column to\ncreate fields, a \"+ Add row\" line under the last row, status colors on the\nrow header, field actions in the column menu, and a view bar with saved grid,\nKanban, calendar, gallery and form views.\n\n<div data-docs-demo=\"504-project-tracker\" data-height=\"620\"></div>\n\n> **SvGrid Base, part of the Suite edition.** The `fieldType` / `fieldProps`\n> column properties and the `rowAdder`, `rowColor`, `columnAdder`,\n> `fieldMenu`, `expandRows` and `primaryField` props are part of the free\n> `@svgrid/grid` types; what draws them is SvGrid Base\n> (`@svgrid/enterprise/base`, or `@svgrid/enterprise/fields` for the field\n> types alone). Without it a `fieldType` column draws as an ordinary column and\n> the props do nothing. Without a Suite key the grid shows the Enterprise\n> watermark and everything still runs. New here? [Build a project\n> tracker](./base/start.md) first.\n\n## Turn it on\n\n`enableFields()` registers the built-in types for every grid on the page. It is\na separate module: `installEnterprise()` does not load it, so an app that never\ncalls `enableFields()` does not ship it.\n\n```ts\nimport { enableFields } from '@svgrid/enterprise/fields'\n\nenableFields()\n```\n\n## A table with views\n\n`SvBase` is a grid with a view bar over it. Every prop it does not name\ngoes to the grid; `views` are the saved views and `activeView` the one shown.\n\n```svelte\n<script lang=\"ts\">\n import { SvBase, type FieldView } from '@svgrid/enterprise/fields'\n\n let views = $state<FieldView[]>([\n { id: 'all', name: 'All tasks', type: 'grid' },\n { id: 'board', name: 'By status', type: 'kanban', stackBy: 'status' },\n { id: 'due', name: 'Due dates', type: 'calendar', dateField: 'due' },\n { id: 'cards', name: 'Gallery', type: 'gallery', coverField: 'files' },\n { id: 'form', name: 'Request a task', type: 'form' },\n ])\n</script>\n\n<SvBase {data} {columns} bind:views primaryField=\"task\" editable\n showRowNumbers expandRows containerHeight={600} />\n```\n\nA grid, Kanban, calendar, Gantt or gallery view is the grid itself in another\nmode (`board`, `scheduler`, `gantt`, `gallery`), so the filter and the sort\ncarry over; a form view is drawn beside the grid, which stays mounted under\nit. In a board, calendar or gallery the bar's search narrows the records; the\ntext is not saved with the view. The Gantt type is\noffered once the app has enabled the Gantt view; its task table holds the\nprimary field, the two dates and the working days.\n\n`SvBaseViewBar` is the bar on its own, for a page that lays out its own\ngrid:\n\n```svelte\n<SvBaseViewBar {api} bind:views bind:activeView />\n<SvGrid {data} {columns} onApiReady={(a) => (api = a)} />\n```\n\n## The view bar\n\nFrom the left: the view switcher, then Hide fields, Filter, Group, Sort,\nColor and Row height, and at the right the record count and search (the\ngrid's find bar, Ctrl+F). A button with something set says so (\"2 hidden\nfields\", \"Filtered by Status\", \"Sorted by 1 field\") and is tinted. Each\nsetting goes through the grid's own state, so `api.getState()` holds it.\n\n- **Hide fields**: a switch per field, a search, hide all / show all, and\n drag to reorder. The primary field is always shown.\n- **Filter**: conditions on fields (below), joined by \"and\" or \"or\", with\n condition groups one level deep. Each change filters at once.\n- **Group**: up to three fields, each with a direction. Group headers show\n the value as its cell draws it (see [Grouped records](#grouped-records)).\n- **Sort**: fields in order, each with a direction labeled for its type:\n \"1 → 9\" for a number or a date, \"A → Z\" for text, \"☐ → ☑\" for a checkbox.\n- **Color**: none, a single select's option colors, or conditions in order\n with a color each (the first match colors the row, where the `rowColor`\n prop's `target` says; a condition still missing its value colors nothing).\n A view with no color setting shows the `rowColor` prop's colors.\n- **Row height**: short, medium, tall and extra tall (`rowHeights` on\n `SvBase` or `SvBaseViewBar` sets the list).\n\n## Filter conditions\n\nA condition is a field, an operator that fits the field's type and a value in\nthe control the field calls for. A condition without a value yet filters\nnothing. The filter is the grid's advanced filter with its own expression\nkind, `fieldFilter`, so `api.setAdvancedFilter` sets it and a saved view\nkeeps it.\n\n| Field type | Operators |\n|---|---|\n| Text, long text, URL, email, phone, barcode, formula, lookup | contains, does not contain, is, is not, is empty, is not empty |\n| Number, currency, percent, rating, duration, autonumber, count, rollup | =, ≠, <, >, ≤, ≥, is empty, is not empty |\n| Single select, user | is, is not, is any of, is none of, is empty, is not empty |\n| Multiple select, multiple users | has any of, has all of, is exactly, has none of, is empty, is not empty |\n| Date, created time, last modified time | is, is before, is after, is on or before, is on or after, is within, is empty, is not empty |\n| Checkbox | is checked / unchecked |\n| Attachment | file name contains, is empty, is not empty |\n| Link to another table | contains, does not contain, is, is not (by the records' names), is empty, is not empty |\n\nA date compares days: \"today\", \"tomorrow\", \"one week ago\", \"one month from\nnow\" or an exact date, and \"is within\" takes the past or next week, month or\nyear, or a number of days. A bare `'2026-10-14'` is that day where the user\nis.\n\n```ts\napi.setAdvancedFilter({\n kind: 'fieldFilter',\n conjunction: 'and',\n conditions: [\n { column: 'status', kind: 'select', op: 'isAnyOf', value: ['Todo', 'Blocked'] },\n { column: 'due', kind: 'date', op: 'isWithin', value: { span: 'nextWeek' } },\n ],\n})\n```\n\n## Saved views\n\nA view is a name, a type and the settings it had when it was last left:\n\n| Key | What it holds |\n|---|---|\n| `id`, `name`, `type` | `'grid'`, `'kanban'`, `'calendar'`, `'gantt'`, `'gallery'`, `'chart'` or `'form'` |\n| `state` | the grid's state: sorts, groups, the filter, hidden fields, order, widths, pinned columns |\n| `rowHeight`, `color` | the row height and how rows are colored |\n| `stackBy` | Kanban: the single select or user field the cards stack by |\n| `dateField`, `endField` | calendar and Gantt: the dates a record starts and ends on |\n| `progressField`, `dependencyField`, `zoom` | Gantt: a percent field the bars fill with, a link field to the same table naming what each record depends on, and the zoom it was left at |\n| `coverField` | gallery: the attachment field whose first image is the cover |\n| `chart` | chart: `kind` (`'bar'`, `'barH'`, `'line'`, `'pie'`, `'donut'`), `x` the field to group by, `y` a number field (else a count), `aggregation` (`'sum'` or `'avg'`) |\n| `formFields`, `formTitle`, `formDescription` | form: the fields it asks for and its heading |\n\nThe view switcher creates a view of any type (a Kanban asks what to stack by,\na calendar which date field), renames, duplicates and deletes. A new view\nstarts plain: no filter, sorts or groups, every field shown. Changes to the\nshown view are kept a moment after they are made, and `onViewsChange` reports\nthe list whenever it changes, so the app can store it. A Kanban card moved to\nanother lane, or a calendar record moved to another day, writes the field\nthrough the grid's edit path (undo, validation, `onCellValueChange`); a date\nfield without a time shows as all-day events. A Kanban card draws its fields as their cells do (pills,\navatars, file thumbnails), and \"+ Add a card\" in a lane adds a record already\nin that lane, named by the primary field. Double-click a card (or F2) to open its record. On\nthe calendar, picking a day adds a record on it and opens it with its name\nready to type.\n\nA Kanban stacked by a field draws an \"Uncategorized\" lane only while a record\nhas no value in that field. A calendar view whose `color` is a single select\n(`{ mode: 'field', field: 'stage' }`) colors each event as the record's\noption, with the options above the calendar as a legend that hides and shows\ntheir events.\n\n## Gallery, chart and form views\n\nA gallery draws each record as a card: the cover image, the primary field as\nthe title, and the fields the grid shows, each drawn as its cell draws it. The\ncards follow the filter, the sort and the hidden fields; a click opens the\nrecord. It is the grid's [gallery view](./rows/gallery.md) (`gallery`), set up\nfrom the view: the cover field, the primary field as the title, the shown\nfields as the card's fields.\n\nA chart groups the records the view shows (its filter applies) by one field\nand counts them, or adds up or averages a number field, as a bar, horizontal\nbar, line, pie or donut chart. A single select keeps its options' order and\ncolors, a person and a linked record show by name, a date groups by month, and\nthe values read as the summed field writes them (\"$480,000\"). \"Customize\nchart\" in the view bar changes the kind, the field and the value; the view\nkeeps them as `chart`:\n\n```ts\n{ id: 'pipeline', name: 'Pipeline value by stage', type: 'chart', chart: { kind: 'bar', x: 'stage', y: 'value' } }\n{ id: 'funnel', name: 'Candidates by stage', type: 'chart', chart: { kind: 'donut', x: 'stage' } }\n```\n\nA form asks for the fields a person can fill in (computed fields are left\nout), each with the editor its cell uses, and adds a row the way \"+ Add row\"\ndoes: field defaults, the next autonumber, then what was typed, and\n`onRowAdd`. `SvBaseForm` takes `fields`, `required`, `title`,\n`description` and `onSubmit`; a field's `description` shows under it.\n\n## The primary field\n\n`primaryField` names the field that is the record's name. It is always the\nfirst column and pinned, it cannot be hidden or deleted (the column menu does\nnot offer it, the pin splitter stops at it), and it titles the expanded\nrecord, a gallery card and a calendar event.\n\n```svelte\n<SvGrid {data} {columns} primaryField=\"task\" />\n```\n\n## Declare columns by type\n\n```ts\nimport type { GridColumns } from '@svgrid/grid'\n\ntype Task = { task: string; status: string; tags: string[]; due: string; score: number; budget: number; spec: string }\n\nconst columns: GridColumns<Task> = [\n { field: 'task', header: 'Task', fieldType: 'text' },\n {\n field: 'status',\n header: 'Status',\n fieldType: 'singleSelect',\n fieldProps: { options: [{ value: 'Todo', color: '#64748b' }, { value: 'Done', color: '#16a34a' }] },\n },\n { field: 'tags', header: 'Tags', fieldType: 'multiSelect', fieldProps: { options: [{ value: 'Design' }, { value: 'Docs' }] } },\n { field: 'due', header: 'Due', fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },\n { field: 'score', header: 'Score', fieldType: 'rating' },\n { field: 'budget', header: 'Budget', fieldType: 'number', fieldProps: { format: 'currency', symbol: '€', symbolPosition: 'after', precision: 0 } },\n { field: 'spec', header: 'Spec', fieldType: 'url' },\n]\n```\n\nA property the column sets itself wins over its type:\n`{ fieldType: 'number', align: 'left' }` stays left-aligned.\n\n| Type | `fieldProps` |\n| --- | --- |\n| `text` | `default` |\n| `longText` | `richText` (**bold**, *italic*, `code`, [links](https://svgrid.com), \"- \" lists; drawn as elements, never as HTML) |\n| `attachment` | `accept`, `maxFiles`, `maxSize`, `upload` |\n| `checkbox` | `icon` (`check`, `x`, `star`, `heart`, `thumbsUp`, `flag`, `dot`), `color`, `default` |\n| `singleSelect`, `multiSelect` | `options` (`{ value, label?, color? }[]`, 40 colors in the editor, sort A to Z), `default` |\n| `user` | `multiple`, `default`; the people come from `setFieldUsers` |\n| `date` | `dateFormat` (`local`, `friendly`, `us`, `european`, `iso`), `time`, `timeFormat` (`12hour`, `24hour`), `gmt` (one time zone for everyone), `showTimeZone`, `defaultToday` |\n| `phone`, `email`, `url` | none |\n| `barcode` | none |\n| `button` | `label`, `color`, `url` (`{Field name}` filled from the row), or `onClick` set in code |\n| `number` | `format` (`integer`, `decimal`, `currency`, `percent`), `precision` 0 to 8, `step` (the editor's spinner, default 1), `separators` (`local`, `comma-period`, `period-comma`, `space-comma`, `space-period`), `showThousands`, `abbreviation` (`none`, `thousand`, `million`, `billion`; not for percent), `allowNegative`, `default`; currency: `symbol`, `symbolPosition` (`before`, `after`); percent: `showBar`, `points` (values 0 to 100 instead of fractions) |\n| `duration` | `durationFormat` (`h:mm`, `h:mm:ss`, `h:mm:ss.S`, `h:mm:ss.SS`, `h:mm:ss.SSS`); values are seconds, typed as `1:30` or `1h 30m` |\n| `rating` | `max` 1 to 10, `icon` (`star`, `heart`, `thumbsUp`, `flag`, `dot`), `color` |\n| `formula` | `formula`, `result` (`auto`, `number`, `currency`, `percent`, `date`) and that format's options |\n| `createdTime`, `lastModifiedTime` | the date options; last modified also `watch` (`all`, `specific`) and `watchFields` |\n| `createdBy`, `lastModifiedBy` | last modified by: `watch`, `watchFields`; the user comes from `setCurrentUser` |\n| `autoNumber` | none |\n| `link` | `table` (from `registerLinkedTable`), `multiple` |\n| `lookup` | `linkField`, `field` |\n| `rollup` | `linkField`, `field`, `aggregation` (`SUM`, `AVERAGE`, `MIN`, `MAX`, `COUNT`, `COUNTA`, `COUNTALL`, `ARRAYJOIN`, `ARRAYUNIQUE`, `CONCATENATE`, `AND`, `OR`), `precision` |\n| `count` | `linkField` |\n\nA number field has one editor for every format. The box shows the plain\nnumber with the field's decimal character, the currency symbol (before or\nafter) or the percent sign right beside it, and a spinner at the cell's\nedge: its arrows, or Up and Down, add or take away `step` (Shift: ten\ntimes). It reads what people type the way the field writes it: its own\nseparators, a pasted symbol or percent sign, `2.5k`, `3M`, `(120)` for -120.\nEnter commits and moves a row down, Tab a column across. On commit the\nvalue is rounded to the field's precision; text that is not a number, or a\nnegative where the field allows none, keeps the box open and says why under\nthe cell. Pasting `$1,200.50` into the cell is read the same way. Columns\nwith the older `currency` and `percent` field types keep working and open in\nthe field editor as a number in that format.\n\nLong text opens a panel over the cell with room for paragraphs: Enter\nstarts a new line, Ctrl+Enter (Cmd+Enter), Tab or a click outside saves,\nEscape leaves the cell as it was. With rich text on, the panel names the\nmarkup the cell draws.\n\nA duration uses the same editor in its own format: `2:00` in the box,\n`1:30` or `1h 30m` typed, a minute a step (a second when the format shows\nseconds). A date opens with the date written as its cells write it\n(`October 24, 2026` for friendly), and a date without time is stored as the\ncalendar date (`'2026-10-24'`), never moved across a day by a time zone.\n\nA select cell opens a panel over itself: the chosen options as pills (each\nwith a remove button), a search box and the options. Typing filters them; a\nvalue that is not an option yet is offered as \"Add option\", which adds it to\nthe field (with a color, through `onColumnChange`) and picks it. A multiple\nselect stays open to pick more; Backspace in the empty search box removes the\nlast choice. A user field opens the same panel with the people from\n`setFieldUsers`, each with the avatar and name its cells show.\n\nA `default` (or a type's own new-row value: today, the next autonumber, the\ncreated time, the current user) fills the rows the row adder adds. Computed\nand automatic fields are read-only; autonumber and created time give every\nexisting row a value when the field is created.\n\nURL, email and phone cells become links only for the schemes they mean\n(`http(s):`, `mailto:`, `tel:`). A value such as `javascript:alert(1)` stays\ntext.\n\n## Attachments\n\nAn attachment cell holds an array of files:\n\n```ts\nimport type { Attachment } from '@svgrid/enterprise/fields'\n\nconst files: Attachment[] = [\n { id: 'a1', url: 'https://cdn.example.com/plan.png', name: 'plan.png', type: 'image/png', size: 48213 },\n { id: 'a2', url: 'https://cdn.example.com/spec.pdf', name: 'spec.pdf', type: 'application/pdf' },\n]\n```\n\nImages show as thumbnails, other files as chips, as many as the column width\nholds and then \"+N\". Enter or a double click opens the dialog: add files with\nthe picker, by dropping them, by pasting them or by URL; remove, reorder (drag,\nor Alt+Left / Alt+Right) and preview (images and PDFs inline). Files dropped on\nthe cell itself are added without the dialog. Export writes the file names. A\ncopy in `<SvBase>` writes their URLs, and pasting text adds one attachment per\nhttp(s) URL in it, so a copied cell pastes as the same files. In a base, people\nand links copy as names too, and a pasted name finds its person or record.\n\nUploading is yours to decide. Set one uploader for the app, or one per column\nin `fieldProps.upload`; it gets the file, the row and an abort signal, reports\nprogress, and returns the attachment. A failed upload stays in the dialog with\nRetry.\n\n```ts\nimport { setAttachmentUploader } from '@svgrid/enterprise/fields'\n\nsetAttachmentUploader(async (file, { signal, onProgress }) => {\n const res = await fetch(`/api/upload?name=${encodeURIComponent(file.name)}`, { method: 'PUT', body: file, signal })\n if (!res.ok) throw new Error(`Upload failed (${res.status})`)\n onProgress(1)\n const { url } = (await res.json()) as { url: string }\n return { id: url, url, name: file.name, type: file.type, size: file.size }\n})\n```\n\nWith no uploader, added files stay in the page as `blob:` URLs and are gone\non reload. Thumbnails use `http(s):`, `blob:` and raster `data:image` URLs\nonly.\n\n## People, linked tables and formulas\n\nUser fields choose from the people you list; created by and last modified by\nwrite whoever you say is signed in. Link fields point at rows of a table you\nregister; lookup, rollup and count read through a link field.\n\n```ts\nimport { registerLinkedTable, setCurrentUser, setFieldUsers } from '@svgrid/enterprise/fields'\n\nsetFieldUsers([\n { id: 'u1', name: 'Ana Ruiz' },\n { id: 'u2', name: 'Ben Okafor', avatarUrl: 'https://cdn.example.com/ben.png' },\n])\nsetCurrentUser({ id: 'u1', name: 'Ana Ruiz' })\n\nregisterLinkedTable({\n id: 'milestones',\n name: 'Milestones',\n primaryField: 'name',\n rows: () => [{ id: 'm1', name: 'Beta', date: '2026-10-31' }],\n // A field's type, when it has one, is how a lookup shows it.\n fields: [{ field: 'name', header: 'Name' }, { field: 'date', header: 'Date', fieldType: 'date', fieldProps: { dateFormat: 'friendly' } }],\n})\n\nconst columns = [\n { field: 'milestone', header: 'Milestone', fieldType: 'link', fieldProps: { table: 'milestones', multiple: false } },\n { field: 'milestoneDate', header: 'Milestone date', fieldType: 'lookup', fieldProps: { linkField: 'milestone', field: 'date' } },\n { field: 'total', header: 'Total', fieldType: 'formula', fieldProps: { formula: '{Qty} * {Unit price}', result: 'currency', precision: 2 } },\n]\n```\n\nA link cell shows one chip per linked record. A click on a chip opens that\nrecord (its fields, read-only: the table is the app's), and resting the\npointer on one previews it. A table with a `create` function lets the link\npicker add a record: a search that names no record offers \"+ Create\" for it,\npicked at once.\n\n```ts\nlet milestones = [{ id: 'm1', name: 'Beta', date: '2026-10-31' }]\nregisterLinkedTable({\n id: 'milestones',\n name: 'Milestones',\n primaryField: 'name',\n rows: () => milestones,\n create: (name) => {\n const row = { id: `m${milestones.length + 1}`, name, date: '' }\n milestones = [...milestones, row]\n return row\n },\n})\n```\n\nA formula refers to fields by name in braces and supports `+ - * /`, `&` to\njoin text, comparisons, and functions such as `IF`, `SWITCH`, `AND`, `OR`,\n`CONCATENATE`, `LEFT`, `ROUND`, `SUM`, `DATEADD` and `DATETIME_DIFF`\n(the editor lists them all). It is parsed into functions once, never\nevaluated as code; a formula that does not compile shows `#ERROR` in its\ncells and its message in the field editor.\n\n## Tables and links\n\n`<SvBase tables>` holds several tables, one tab each. A table is its records,\nits fields and its views; `id` is what link fields name.\n\n```ts\nimport { SvBase, type BaseTable } from '@svgrid/enterprise/base'\n\nlet tables: BaseTable[] = [\n {\n id: 'deals', name: 'Deals', primaryField: 'deal', data: deals,\n columns: [\n { field: 'deal', header: 'Deal', fieldType: 'text' },\n { field: 'account', header: 'Account', fieldType: 'link', fieldProps: { table: 'accounts', multiple: false, inverse: 'deals' } },\n ],\n views: [{ id: 'all', name: 'All deals', type: 'grid' }],\n },\n {\n id: 'accounts', name: 'Accounts', primaryField: 'name', data: accounts,\n columns: [\n { field: 'name', header: 'Name', fieldType: 'text' },\n { field: 'deals', header: 'Deals', fieldType: 'link', fieldProps: { table: 'deals', inverse: 'account' } },\n { field: 'pipeline', header: 'Pipeline', fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'value', aggregation: 'SUM' } },\n ],\n },\n]\n```\n\n```svelte\n<SvBase bind:tables editable columnAdder fieldMenu onTablesChange={save} />\n```\n\nThe tabs take the theme's colors: the tables sit on its muted surface and the\nshown one is marked with its accent. They switch tables; double-click one, or use its menu, to rename it. The\nmenu also duplicates and deletes the table (deleting asks first and lists the\nlink fields that go with it), and \"Add table\" starts a new one with a Name,\nNotes and Status field. A record of a base has an `id`, and the row adder\ngives a new one its own.\n\nEvery table of the base is a table a link field can point at. A link field\nfrom one table to another gets a field in the other table that links back\n(`fieldProps.inverse` on each names the other), and the two stay in step:\n\n- Linking a deal to an account lists the deal on the account, and the other\n way round. Deleting records takes them out of the links on the other side.\n- Pointing the field at another table moves the field back to that table and\n clears the old links. Turning the field into another type, or deleting it,\n takes the field back with it; the delete asks first and names the lookups,\n rollups and counts that read it, which go with it.\n- Deleting a table deletes the link fields that point at it, and the\n lookups, rollups and counts that read those links.\n\nA rollup of amounts shows as the amounts do (\"$482,000\"). A linked record\nopened from a chip has \"Open in {table}\", which switches to that tab with\nthe record expanded. `onTablesChange` hands over every table after each\nchange to records, fields, views or tables, ready to store.\n\nTo keep the records in a database instead, give each table a `source`: the\nbase loads from it and writes every change back, a link to one record as a\nforeign key and a link to many through a junction table. See\n[Keep a base in a database](./base/data.md).\n\n## Automations\n\nA table's `automations` do things on their own when a record is created,\nchanges, starts matching a condition, is sent with a form or has its button\npressed, or at a scheduled time: update the record, create a record in any\ntable and link it back, show a notice, color the record while it matches, or\nrun an action your app adds. \"Automations\" in the view bar edits them and\nlists every run. See [Automations](./base/automations.md).\n\n## Add a row\n\n`rowAdder` draws \"+ Add row\" under the last row. A click appends a row, scrolls\nto it and starts editing its first editable cell, then fires `onRowAdd` with\nthe row so you can store it.\n\n```ts\nimport type { RowAdderConfig } from '@svgrid/grid'\n\nconst rowAdder: RowAdderConfig<{ status: string }> = {\n label: 'Add task',\n defaults: () => ({ status: 'Todo' }),\n}\n```\n\nWith the grid's `rowDragManaged`, rows drag into a new order by their row\nnumber, but only while nothing sorts or groups them: in a sorted table the\norder is the sort's, so the fields module turns dragging off until the sort is\ncleared.\n\n## Expand a row\n\n`expandRows` (with `showRowNumbers`) makes the row number the row's handle:\non hover it turns into a checkbox that selects the row and an expand button.\nExpand opens the row as a record: every field with its name and type icon,\nits value drawn as the cell draws it, and the same editor the cell uses one\nclick away. A value changed there is written the way a cell edit is, so undo,\nvalidation, `onCellValueChange` and last-modified fields behave the same.\nThe arrows step through the rows in the grid's order. Space on a selected\ncell (not a checkbox) opens its row too.\n\nFields the grid hides are listed under the others, folded, and read-only. Beside\nthe fields is the record's activity: the changes made to it in this session\n(who changed which field to what) and its comments. Comments live in memory\nunless the app keeps them:\n\n```svelte\n<SvGrid {data} {columns} showRowNumbers expandRows editable />\n```\n\n```ts\nimport { setRecordComments } from '@svgrid/enterprise/fields'\n\nsetRecordComments({\n list: (rowId) => comments.filter((c) => c.rowId === rowId),\n add: (rowId, comment) => saveComment(comment),\n})\n```\n\n## Drag to pin columns\n\nThe grid's [`pinSplitter`](./columns/column-pinning.md#drag-to-pin) works over\nfield columns too: drag the divider at the edge of the pinned columns to pin\nmore or fewer. With a `primaryField` it stops at the primary field, which\nstays pinned.\n\n```svelte\n<SvGrid {data} {columns} showRowNumbers pinSplitter primaryField=\"task\" />\n```\n\n## Taller rows\n\nRow height is the grid's `rowHeight`; a view can offer it as short, medium,\ntall and extra tall (34, 56, 88, 128 in the demo above). From 48px the field\ncells use the height: content starts at the top, text wraps to as many lines\nas fit (2 to 6), select pills wrap, long text keeps its line breaks and\nattachment thumbnails grow with the row.\n\n## Summaries at the bottom\n\nWith the grid's `summary` prop on, each field's cell in the footer row picks\nits own summary: a click offers what fits the field (sum, average, median,\nmin, max, range for numbers; checked for a checkbox; earliest, latest and the\nspan for dates; empty, filled, unique and their percentages for every field).\nThe result is written as the field writes its values (`Sum $17,700`,\n`Average 42%`). The choice is kept in `fieldProps.summary` and reported\nthrough `onColumnChange`, so it can be set in the column too:\n\n```ts\n{ field: 'budget', header: 'Budget', fieldType: 'number', fieldProps: { format: 'currency', summary: 'sum' } }\n```\n\n## Grouped records\n\nGrouped by a field (the view bar's Group, the column menu, or `groupBy`), each\ngroup's header shows the field's name over its value, drawn as the cell draws\nit (a select's pill, a person's avatar), the number of records, the summaries\nthe fields picked for that group (\"Budget $4,300\"), and \"+\" to add a record\nthat is already in the group: it gets the group's value, and its parent\ngroups' values, before editing starts.\n\nA single select sorts and groups in the order of its options (\"Q4 2026\"\nbefore \"Q1 2027\" before \"Later\"), not alphabetically. With a filter on, the\ngroup's count and summaries, the record count in the view bar and the footer\ntotals leave out the records the filter hides, whether the group is open or\nclosed.\n\n## Act on selected rows\n\nTick rows with the row header's checkbox (`expandRows`) or the selection\ncolumn, and the grid's [selection bar](./rows/selection-bar.md) acts on all of\nthem: `'editFields'` sets fields on every selected row with each field's own\ncontrol, `'deleteRows'` deletes them after an inline confirm.\n\n```svelte\n<SvGrid {data} {columns} showRowNumbers expandRows\n selectionBar={['selectAll', 'editFields', 'deleteRows']}\n onRowsDelete={(e) => (data = e.data)} />\n```\n\n## Color rows by status\n\nWith `fieldMenu`, a single select's column menu has \"Color rows by this\nfield\" (and \"Stop coloring rows\" on the one in use): the rows take that\nfield's option colors at once, and `onRowColorChange` reports the choice so\nthe app can keep it as `rowColor`.\n\n`rowColor` gives each row a color on its row header: a 3px stripe on the\nleading edge, with the row number left as it is. Color by a select field and\neach row takes its option's color; or give rules, first match wins.\n`target: 'bar'` puts the stripe on the first cell (for grids without row\nnumbers), `target: 'row'` tints the whole row. The row header also gets the status as its title, so the color is\nnever the only signal. Only rendered rows are colored, so it costs nothing at\na million rows.\n\n```ts\nimport type { RowColorConfig } from '@svgrid/grid'\n\ntype Task = { status: string; budget: number }\n\nconst byStatus: RowColorConfig<Task> = { field: 'status' }\nconst byRule: RowColorConfig<Task> = {\n target: 'row',\n rules: [{ when: (row) => row.budget > 2500, color: '#f59e0b', label: 'Over budget' }],\n}\n```\n\n## Create and edit fields\n\n`columnAdder` draws a \"+\" after the last column. It opens the field editor: a\nname, a type (`{ types: [...] }` limits the list) and that type's properties.\nA field left unnamed is named after its type (`Number`, then `Number 2`).\nEvery field column shows its type's icon before the header label.\n`fieldMenu` adds Edit field, Insert left, Insert right, Duplicate field, Hide\nfield and Delete field to every column menu; with a view bar over the grid,\nalso \"Filter by this field\" and \"Group by this field\", which add the condition\nor group and open the bar's panel. The menu's sort items are labeled for the\nfield (\"Sort 1 → 9\", \"Sort A → Z\"). A field's description shows as an info\nmark after its name, read on hover.\n\nChanging a field's type converts its values where they can be (a number typed\nas text stays a number, a comma list becomes multiple-select values, URLs in\ntext become attachments); before applying, the editor says how many cells\nwould be cleared. A select made from other values starts with those values as\nits options.\n\nThe grid keeps no schema. Every change fires an event with the whole column\nlist after the change, as you declared it, and the converted rows when values\nchanged. Store them and the grid stays in step:\n\n```ts\ntype Row = Record<string, unknown>\nlet columns: Record<string, unknown>[] = []\nlet rows: Row[] = []\n\nfunction onFieldChange(e: { columns: Record<string, unknown>[]; data?: Row[] }) {\n columns = e.columns\n if (e.data) rows = e.data\n}\n// <SvGrid {columns} data={rows} columnAdder fieldMenu\n// onColumnAdd={onFieldChange} onColumnChange={onFieldChange} onColumnRemove={onFieldChange} />\n```\n\n## Your own field type\n\n`registerFieldType` adds a type, or replaces a built-in one with the same id.\n`fill` sets up the column; `properties` is what the field editor shows for it;\n`cell` (optional) is a Svelte component that draws the cell and receives\n`{ value, props, row, column }`.\n\n```ts\nimport { registerFieldType } from '@svgrid/enterprise/fields'\n\nregisterFieldType({\n id: 'sku',\n label: 'SKU',\n defaults: { prefix: 'SKU-' },\n properties: [{ key: 'prefix', label: 'Prefix', kind: 'text' }],\n fill: (def, props) => ({\n ...def,\n editorType: def.editorType ?? 'text',\n formatter: def.formatter ?? ((ctx: { value: unknown }) => `${String(props.prefix)}${String(ctx.value ?? '')}`),\n }),\n})\n```\n\n## See also\n\n- [Cell data types](./cells/cell-data-types.md)\n- [Cell components](./cells/cell-components.md)\n- [Conditional formatting](./cells/conditional-formatting.md)\n- [Column menu](./columns/column-menu.md)\n"
|
|
4788
|
+
},
|
|
4516
4789
|
{
|
|
4517
4790
|
"slug": "help/figma",
|
|
4518
4791
|
"path": "docs/help/figma.md",
|
|
@@ -4693,7 +4966,7 @@ export const docs = [
|
|
|
4693
4966
|
"path": "docs/help/index.md",
|
|
4694
4967
|
"title": "SvGrid Help",
|
|
4695
4968
|
"section": "Help",
|
|
4696
|
-
"markdown": "# SvGrid Help\n\nTopic-oriented documentation for SvGrid. Each page is a focused\nexplanation of one feature with copy-paste code that runs against the\nshipping library - written for SvGrid, not translated from another grid.\n\nStart with [Getting Started](../getting-started.md) if you have not\nalready.\n\n> **Tier badges.** Pages whose title ends with `- Enterprise` describe a\n> feature that ships in the paid `@svgrid/enterprise` add-on. Everything else\n> is part of the open-source `@svgrid/grid` package. The same\n> visual convention is used throughout this documentation.\n\nLive reference - the trading-desk demo runs the full feature set at\nreal-world scale:\n\n<div data-docs-demo=\"00-trading-desk\" data-height=\"560\"></div>\n\n## Background\n\n- [Why headless?](../why-headless.md) - what the headless core gives you and when to reach for it\n- [Headless](./headless/overview.md) - drive `@svgrid/grid/core` with your own markup: [build a table](./headless/build-a-table.md), [styling](./headless/styling.md), [virtualization](./headless/virtualization.md), [controlled state](./headless/controlled-state.md), [row models](./headless/row-models.md), [server-side](./headless/server-side.md)\n- [Architecture overview](./architecture.md) - the three-layer model: your data, the engine, the renderer\n- [Bundle size](./bundle-size.md) - what each entry costs gzipped, measured on every release, and how to reproduce the number\n- [Glossary](./glossary.md) - terminology used across the docs (accessor, snippet, row model, ...)\n- [Tailwind integration](./tailwind.md) - re-theming the grid via `--sg-*` tokens, dark-mode wiring, what *not* to do\n- [**Enterprise feature pack**](../enterprise/README.md) - landing page for the paid add-on; what's in it + how to license it\n- **Enterprise tutorials** - one build per module, a live example under each step:\n [Server-Side Row Model: a walkthrough](./server/row-model-walkthrough.md) ·\n [Export: a report to Excel and PDF](./export/report.md) ·\n [Import: a spreadsheet into the grid](./export/import-spreadsheet.md) ·\n [Pivot: from rows to a pivot table](./pivot/start.md) ·\n [Build a sprint board](./kanban/sprint-board.md) ·\n [Build a booking calendar](./scheduler/booking-calendar.md) ·\n [Alerts: rules over live data](./alerts/start.md)\n- [Data export and printing - Enterprise](./export.md) - Excel, PDF, CSV, TSV, HTML, and Print\n- [Data import - Enterprise](./import.md) - Excel, CSV, TSV, and JSON import with column mapping + validation\n- [**AI Toolkit**](./ai-toolkit.md) - the hub for every AI feature: in-grid helpers, agents, MCP server, and LLM grounding, all model-agnostic\n- [AI assistant](./ai.md) - natural-language filter, smart fill, summarise, classify; free in @svgrid/grid, bring-your-own model adapter\n- [Pivot tables - Enterprise](./pivot.md) - `createPivotModel` + nested column headers; designer UI is a separate demo\n- [Alerts - Enterprise](./alerts.md) - no-code alert rules: toast / highlight / flash / prevent-edit when the data crosses a line; persisted + shareable\n- [Expression query language - Enterprise](./expressions-query.md) - the predicate / scalar / change language behind alerts (and styled + calculated columns)\n- [Upgrading a Svelte 4 table to Svelte 5](./svelte-5-upgrade-data-tables.md) - a svelte-headless-table app upgraded step by step, every diagnostic captured, and the three exits\n- **Migrating to SvGrid** - column / API translation guides from other grids:\n [AG Grid](./migrating-from-ag-grid.md) ·\n [TanStack Table](./migrating-from-tanstack-table.md) ·\n [MUI X DataGrid](./migrating-from-mui-x.md) ·\n [Handsontable](./migrating-from-handsontable.md) ·\n [Glide Data Grid](./migrating-from-glide.md) ·\n [svelte-headless-table](./migrating-from-svelte-headless-table.md) ·\n [SVAR Svelte DataGrid](./migrating-from-svar-svelte-datagrid.md) ·\n [@vincjo/datatables](./migrating-from-vincjo-datatables.md) ·\n [shadcn-svelte data table](./migrating-from-shadcn-data-table.md) ·\n [Flowbite / Skeleton / shadcn tables](./migrating-from-ui-kit-tables.md) ·\n [Tabulator](./migrating-from-tabulator.md) ·\n [Grid.js](./migrating-from-gridjs.md) ·\n [React Data Grid](./migrating-from-react-data-grid.md) ·\n [PrimeVue / PrimeNG / PrimeReact](./migrating-from-primevue-datatable.md) ·\n [Kendo UI Grid](./migrating-from-kendo-grid.md) ·\n [DevExtreme](./migrating-from-devextreme.md) ·\n [Syncfusion](./migrating-from-syncfusion.md) ·\n [jqxGrid](./migrating-from-jqxgrid.md) ·\n [Smart.Grid](./migrating-from-smart-grid.md)\n\n## Patterns & playbooks\n\n- [Recipes / Cookbook](./recipes.md) - 20+ copy-paste patterns from sort/filter/paginate to inline edit + cascading totals to streaming, each paired with a live interactive demo\n- [AI Smart Paste](./ai-smart-paste.md) - vCard / Markdown / signature-block / CSV parsing with email typo correction, phone normalization, and multi-language headers\n- [Spreadsheet formulas](./spreadsheet-formulas.md) - in-cell `=SUM` / `=IF` / `=COUNTIF` with cell refs, ranges, cycle detection\n- [Mobile / responsive card view](./mobile-card-view.md) - the same `$state` array driving a desktop grid and a touch-friendly card list\n- [Conditional form schema](./conditional-form-schema.md) - declarative `when` rules for per-cell visibility and editability\n- [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort + filter, sparse infinite scroll (with velocity-aware chunk loading + abort guards)\n- [Real-time / streaming updates](./real-time.md) - poll vs WebSocket, delta merge, pause-while-editing, backpressure\n- [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators, custom group cells, group-vs-leaf sort, group footers, grouped paging\n- [Tree data](./rows/tree-data.md) - hierarchical rows from flat parent-id or nested children, treegrid keyboard + ARIA\n- [Columns hierarchy + manager](./columns-hierarchy.md) - side-panel column tree with drag-to-reorder, collapsible groups, summary columns\n- [State maintenance](./state-maintenance.md) - capture / apply, undo / redo, bookmarks, JSON IO, debounced auto-save\n- [Saved views & persistence](./saved-views.md) - localStorage / server-side persistence, view migration, URL sharing\n- [Internationalisation & RTL](./i18n-rtl.md) - locale-aware formatting, RTL layout flip, CJK column widths, mixed-direction safety\n\n## Enterprise readiness\n\n- [Security & supply chain](./security.md) - peer-dep table, CSP guidance, SBOM generation, vulnerability handling, data residency\n- [Browser & runtime support](./browser-support.md) - tested browsers + versions, SSR runtimes, build tools, DOM-API requirements, mobile\n- [Accessibility](./accessibility.md) - WAI-ARIA 1.2 grid pattern, WCAG 2.1 AA mapping, keyboard map, forced-colors + reduced-motion\n- [Performance benchmarks](./benchmarks.md) - first paint, sustained scroll FPS, sort / filter / group, memory, bundle size, on a documented machine\n- [Testing your grid](./testing.md) - unit tests against the engine, jsdom component tests, Playwright e2e, axe-core for a11y regressions\n- [API stability & semver policy](./api-stability.md) - the promise we make to you about breaking changes + deprecation lifecycle\n- [**API reference**](../reference/index.md) - exhaustive prop/method/type tables for `<SvGrid>`, `SvGridApi`, `ColumnDef`, features, and the Enterprise surface\n- [API stability badges](https://svgrid.com/api/) - flat index of every Stable export with its tier badge\n- [Changelog](../changelog.md) - reverse-chronological log of every shipped change\n- [Error reference](./errors.md) - every typed error this surface throws, with the trigger and the fix\n\n## Production checklist\n\nA focused walkthrough for the questions enterprise teams ask before\nshipping. Each link drops you straight into the relevant topic page.\n\n| Concern | Reach for |\n| -------------------------------- | -------------------------------------------------------------------------------------------------- |\n| Performance with >10k rows | [Performance benchmarks](./benchmarks.md) + [Row pagination](./rows/row-pagination.md) |\n| Server-side data | [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort, sparse infinite scroll |\n| Real-time / streaming updates | [Real-time / streaming](./real-time.md) - delta merge, pause-while-editing, backpressure |\n| Tree / hierarchical data | [Tree rows](./rows/tree-rows.md) - flat-array + expanded-map pattern, lazy load, keyboard nav |\n| Pivot / multi-level headers | [Pivot tables](./pivot.md) + [Column groups](./columns/column-groups.md) |\n| Grouping + aggregation | [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators + custom group cells |\n| Inline editing with validation | [Editing overview](./editing/overview.md) + [Validation while editing](./editing/validation.md) |\n| Theming + dark mode | [Tailwind integration](./tailwind.md) - the `--sg-*` token list |\n| Accessibility / WAI-ARIA | [Accessibility](./accessibility.md) - WCAG 2.1 AA mapping + keyboard map + forced-colors |\n| Internationalisation / RTL | [Internationalisation & RTL](./i18n-rtl.md) - locales, formatting, mixed-direction safety |\n| Saved views / layout persistence | [Saved views](./saved-views.md) - localStorage / server-side persistence + migration |\n| Export to Excel / PDF / CSV | [Data export and printing](./export.md) |\n| Excel / CSV / JSON import | [Data import](./import.md) - file picker -> column map -> preview -> commit |\n| Add AI to the grid | [AI assistant](./ai.md) - one provider adapter, four helpers |\n| Multi-app deployment licensing | [Pricing](https://svgrid.com/pricing/) - Multiple App License covers an org |\n| Testing the grid | [Testing your grid](./testing.md) + [Testing and quality](./testing-and-quality.md) |\n\nIf a question is missing from this table, **press `Ctrl/Cmd + K`** in\nthe docs sidebar - the search box indexes every page's title, headings,\nand body and ranks matches by where they hit.\n\n## Core features\n\n### Columns\n\n- [Column definitions](./columns/column-definitions.md)\n- [Updating definitions](./columns/updating-definitions.md)\n- [Column state](./columns/column-state.md)\n- [Column headers - styling & height](./columns/column-headers.md)\n- [Column groups](./columns/column-groups.md)\n- [Column sizing](./columns/column-sizing.md)\n- [Column moving](./columns/column-moving.md)\n- [Column pinning](./columns/column-pinning.md)\n- [Column spanning](./columns/column-spanning.md)\n- [Aligned grids](./columns/aligned-grids.md)\n- [Column menu](./columns/column-menu.md)\n- [Custom header components](./columns/custom-header-components.md)\n\n### Rows\n\n- [Row data](./rows/row-data.md)\n- [Row sorting](./rows/row-sorting.md)\n- [Row spanning](./rows/row-spanning.md)\n- [Row pinning](./rows/row-pinning.md)\n- [Row height](./rows/row-height.md)\n- [Styling rows](./rows/styling-rows.md)\n- [Row pagination](./rows/row-pagination.md)\n- [Accessing rows](./rows/accessing-rows.md)\n- [Row dragging](./rows/row-dragging.md)\n- [Full-width rows](./rows/full-width-rows.md)\n- [Master / detail (nested grids)](./rows/master-detail.md)\n- [Tree rows (expand / collapse)](./rows/tree-rows.md)\n- [Bulk-action bar (selection)](./rows/selection-bar.md)\n\n### Cells\n\n- [Getting values](./cells/getting-values.md)\n- [Text formatting](./cells/text-formatting.md)\n- [Cell components](./cells/cell-components.md)\n- [Cell data types](./cells/cell-data-types.md)\n- [Styling cells](./cells/styling-cells.md)\n- [Highlighting changes](./cells/highlighting-changes.md)\n- [Tooltips](./cells/tooltips.md)\n- [Expressions](./cells/expressions.md)\n- [View refresh](./cells/view-refresh.md)\n- [Cell text selection](./cells/cell-text-selection.md)\n\n### Spreadsheet\n\nTutorials, in reading order:\n\n- [Getting started](./sheet/start.md) - `<SvSheet />`, seed data, a formula across sheets, the document underneath\n- [Formulas](./sheet/formulas.md) - references, spills, names, LET and LAMBDA, your own functions, auditing\n- [Formatting](./sheet/formatting.md) - number formats, styles, sizes, merged headers, conditional formatting rules\n- [Data validation](./sheet/validation.md) - dropdown lists, bounds, a formula rule, input messages\n- [Files](./sheet/files.md) - `getState`, autosave, `.xlsx` / `.ods` / `.xls` / `.csv`, paste from Excel\n- [Data tools](./sheet/data-tools.md) - AutoFilter, sort, Text to Columns, Remove Duplicates, Goal Seek, PivotTable\n- [Charts, sparklines and pictures](./sheet/charts-and-objects.md) - objects over the sheet and `=IMAGE` in a cell\n- [Review and share](./sheet/review.md) - comments, protection, two people on one sheet\n\nReference:\n\n- [The spreadsheet shell](./cells/spreadsheet-shell.md) - `<SvSheet />`: every prop, every dialog, the file formats, the limits\n- [Formulas](./spreadsheet-formulas.md) - the engine, the function library and the dependency graph\n- [Excel keyboard shortcuts](./cells/keyboard-shortcuts.md)\n- [Number formats](./cells/number-formats.md)\n- [Paste Special, Find and Replace, insert and delete](./cells/paste-find-structure.md)\n- [Workbooks](./cells/workbooks.md) - several sheets behind one engine\n- [Tables and structured references](./cells/tables.md)\n\n### Filtering\n\n- [Overview](./filtering/overview.md)\n- [Text filter](./filtering/text-filter.md)\n- [Number filter](./filtering/number-filter.md)\n- [Date filter](./filtering/date-filter.md)\n- [Set filter](./filtering/set-filter.md)\n- [Filter conditions](./filtering/filter-conditions.md)\n- [Applying filters](./filtering/applying-filters.md)\n- [Filter API](./filtering/filter-api.md)\n- [Custom column filters](./filtering/custom-column-filters.md)\n- [Floating filters](./filtering/floating-filters.md)\n\n### Editing\n\n- [Overview](./editing/overview.md)\n- [Start / stop editing](./editing/start-stop-editing.md)\n- [Parsing values](./editing/parsing-values.md)\n- [Saving values](./editing/saving-values.md)\n- [Edit components](./editing/edit-components.md)\n- [Provided cell editors](./editing/provided-editors.md)\n- [Undo / redo](./editing/undo-redo.md)\n- [Full-row editing](./editing/full-row.md)\n- [Drag a range to move or copy it](./editing/move-cells.md)\n- [Validation](./editing/validation.md)\n\n## Conventions\n\nEach topic page is structured as:\n\n1. **What it is** - a one-sentence definition.\n2. **When to use it** - the situation that calls for this feature.\n3. **Minimal example** - copy-pasteable code that runs.\n4. **Reference** - the relevant exports and prop names.\n5. **Gotchas** - known limits, gaps, or things that surprise people.\n\nPages explicitly note when a feature is **not yet implemented** in the\ncommunity build so you know what you can rely on. The current\ngap list is at [missing-features.md](./missing-features.md).\n"
|
|
4969
|
+
"markdown": "# SvGrid Help\n\nTopic-oriented documentation for SvGrid. Each page is a focused\nexplanation of one feature with copy-paste code that runs against the\nshipping library - written for SvGrid, not translated from another grid.\n\nStart with [Getting Started](../getting-started.md) if you have not\nalready.\n\n> **Tier badges.** Pages whose title ends with `- Enterprise` describe a\n> feature that ships in the paid `@svgrid/enterprise` add-on. Everything else\n> is part of the open-source `@svgrid/grid` package. The same\n> visual convention is used throughout this documentation.\n\nLive reference - the trading-desk demo runs the full feature set at\nreal-world scale:\n\n<div data-docs-demo=\"00-trading-desk\" data-height=\"560\"></div>\n\n## Background\n\n- [Why headless?](../why-headless.md) - what the headless core gives you and when to reach for it\n- [Headless](./headless/overview.md) - drive `@svgrid/grid/core` with your own markup: [build a table](./headless/build-a-table.md), [styling](./headless/styling.md), [virtualization](./headless/virtualization.md), [controlled state](./headless/controlled-state.md), [row models](./headless/row-models.md), [server-side](./headless/server-side.md)\n- [Architecture overview](./architecture.md) - the three-layer model: your data, the engine, the renderer\n- [Bundle size](./bundle-size.md) - what each entry costs gzipped, measured on every release, and how to reproduce the number\n- [Glossary](./glossary.md) - terminology used across the docs (accessor, snippet, row model, ...)\n- [Tailwind integration](./tailwind.md) - re-theming the grid via `--sg-*` tokens, dark-mode wiring, what *not* to do\n- [**Enterprise feature pack**](../enterprise/README.md) - landing page for the paid add-on; what's in it + how to license it\n- **Enterprise tutorials** - one build per module, a live example under each step:\n [Server-Side Row Model: a walkthrough](./server/row-model-walkthrough.md) ·\n [Export: a report to Excel and PDF](./export/report.md) ·\n [Import: a spreadsheet into the grid](./export/import-spreadsheet.md) ·\n [Pivot: from rows to a pivot table](./pivot/start.md) ·\n [Build a sprint board](./kanban/sprint-board.md) ·\n [Build a booking calendar](./scheduler/booking-calendar.md) ·\n [Alerts: rules over live data](./alerts/start.md)\n- [Data export and printing - Enterprise](./export.md) - Excel, PDF, CSV, TSV, HTML, and Print\n- [Data import - Enterprise](./import.md) - Excel, CSV, TSV, and JSON import with column mapping + validation\n- [**AI Toolkit**](./ai-toolkit.md) - the hub for every AI feature: in-grid helpers, agents, MCP server, and LLM grounding, all model-agnostic\n- [AI assistant](./ai.md) - natural-language filter, smart fill, summarise, classify; free in @svgrid/grid, bring-your-own model adapter\n- [Pivot tables - Enterprise](./pivot.md) - `createPivotModel` + nested column headers; designer UI is a separate demo\n- [Alerts - Enterprise](./alerts.md) - no-code alert rules: toast / highlight / flash / prevent-edit when the data crosses a line; persisted + shareable\n- [Expression query language - Enterprise](./expressions-query.md) - the predicate / scalar / change language behind alerts (and styled + calculated columns)\n- [Upgrading a Svelte 4 table to Svelte 5](./svelte-5-upgrade-data-tables.md) - a svelte-headless-table app upgraded step by step, every diagnostic captured, and the three exits\n- **Migrating to SvGrid** - column / API translation guides from other grids:\n [AG Grid](./migrating-from-ag-grid.md) ·\n [TanStack Table](./migrating-from-tanstack-table.md) ·\n [MUI X DataGrid](./migrating-from-mui-x.md) ·\n [Handsontable](./migrating-from-handsontable.md) ·\n [Glide Data Grid](./migrating-from-glide.md) ·\n [svelte-headless-table](./migrating-from-svelte-headless-table.md) ·\n [SVAR Svelte DataGrid](./migrating-from-svar-svelte-datagrid.md) ·\n [@vincjo/datatables](./migrating-from-vincjo-datatables.md) ·\n [shadcn-svelte data table](./migrating-from-shadcn-data-table.md) ·\n [Flowbite / Skeleton / shadcn tables](./migrating-from-ui-kit-tables.md) ·\n [Tabulator](./migrating-from-tabulator.md) ·\n [Grid.js](./migrating-from-gridjs.md) ·\n [React Data Grid](./migrating-from-react-data-grid.md) ·\n [PrimeVue / PrimeNG / PrimeReact](./migrating-from-primevue-datatable.md) ·\n [Kendo UI Grid](./migrating-from-kendo-grid.md) ·\n [DevExtreme](./migrating-from-devextreme.md) ·\n [Syncfusion](./migrating-from-syncfusion.md) ·\n [jqxGrid](./migrating-from-jqxgrid.md) ·\n [Smart.Grid](./migrating-from-smart-grid.md)\n- **Moving a table database into your app** - from a hosted or self-hosted table-database app to SvGrid Base:\n [Airtable](./migrating-from-airtable.md) ·\n [Baserow](./migrating-from-baserow.md) ·\n [NocoDB](./migrating-from-nocodb.md)\n\n## Patterns & playbooks\n\n- [Recipes / Cookbook](./recipes.md) - 20+ copy-paste patterns from sort/filter/paginate to inline edit + cascading totals to streaming, each paired with a live interactive demo\n- [AI Smart Paste](./ai-smart-paste.md) - vCard / Markdown / signature-block / CSV parsing with email typo correction, phone normalization, and multi-language headers\n- [Spreadsheet formulas](./spreadsheet-formulas.md) - in-cell `=SUM` / `=IF` / `=COUNTIF` with cell refs, ranges, cycle detection\n- [Mobile / responsive card view](./mobile-card-view.md) - the same `$state` array driving a desktop grid and a touch-friendly card list\n- [Conditional form schema](./conditional-form-schema.md) - declarative `when` rules for per-cell visibility and editability\n- [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort + filter, sparse infinite scroll (with velocity-aware chunk loading + abort guards)\n- [Real-time / streaming updates](./real-time.md) - poll vs WebSocket, delta merge, pause-while-editing, backpressure\n- [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators, custom group cells, group-vs-leaf sort, group footers, grouped paging\n- [Tree data](./rows/tree-data.md) - hierarchical rows from flat parent-id or nested children, treegrid keyboard + ARIA\n- [Columns hierarchy + manager](./columns-hierarchy.md) - side-panel column tree with drag-to-reorder, collapsible groups, summary columns\n- [State maintenance](./state-maintenance.md) - capture / apply, undo / redo, bookmarks, JSON IO, debounced auto-save\n- [Saved views & persistence](./saved-views.md) - localStorage / server-side persistence, view migration, URL sharing\n- [Internationalisation & RTL](./i18n-rtl.md) - locale-aware formatting, RTL layout flip, CJK column widths, mixed-direction safety\n\n## Enterprise readiness\n\n- [Security & supply chain](./security.md) - peer-dep table, CSP guidance, SBOM generation, vulnerability handling, data residency\n- [Browser & runtime support](./browser-support.md) - tested browsers + versions, SSR runtimes, build tools, DOM-API requirements, mobile\n- [Accessibility](./accessibility.md) - WAI-ARIA 1.2 grid pattern, WCAG 2.1 AA mapping, keyboard map, forced-colors + reduced-motion\n- [Performance benchmarks](./benchmarks.md) - first paint, sustained scroll FPS, sort / filter / group, memory, bundle size, on a documented machine\n- [Testing your grid](./testing.md) - unit tests against the engine, jsdom component tests, Playwright e2e, axe-core for a11y regressions\n- [API stability & semver policy](./api-stability.md) - the promise we make to you about breaking changes + deprecation lifecycle\n- [**API reference**](../reference/index.md) - exhaustive prop/method/type tables for `<SvGrid>`, `SvGridApi`, `ColumnDef`, features, and the Enterprise surface\n- [API stability badges](https://svgrid.com/api/) - flat index of every Stable export with its tier badge\n- [Changelog](../changelog.md) - reverse-chronological log of every shipped change\n- [Error reference](./errors.md) - every typed error this surface throws, with the trigger and the fix\n\n## Production checklist\n\nA focused walkthrough for the questions enterprise teams ask before\nshipping. Each link drops you straight into the relevant topic page.\n\n| Concern | Reach for |\n| -------------------------------- | -------------------------------------------------------------------------------------------------- |\n| Performance with >10k rows | [Performance benchmarks](./benchmarks.md) + [Row pagination](./rows/row-pagination.md) |\n| Server-side data | [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort, sparse infinite scroll |\n| Real-time / streaming updates | [Real-time / streaming](./real-time.md) - delta merge, pause-while-editing, backpressure |\n| Tree / hierarchical data | [Tree rows](./rows/tree-rows.md) - flat-array + expanded-map pattern, lazy load, keyboard nav |\n| Pivot / multi-level headers | [Pivot tables](./pivot.md) + [Column groups](./columns/column-groups.md) |\n| Grouping + aggregation | [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators + custom group cells |\n| Inline editing with validation | [Editing overview](./editing/overview.md) + [Validation while editing](./editing/validation.md) |\n| Theming + dark mode | [Tailwind integration](./tailwind.md) - the `--sg-*` token list |\n| Accessibility / WAI-ARIA | [Accessibility](./accessibility.md) - WCAG 2.1 AA mapping + keyboard map + forced-colors |\n| Internationalisation / RTL | [Internationalisation & RTL](./i18n-rtl.md) - locales, formatting, mixed-direction safety |\n| Saved views / layout persistence | [Saved views](./saved-views.md) - localStorage / server-side persistence + migration |\n| Export to Excel / PDF / CSV | [Data export and printing](./export.md) |\n| Excel / CSV / JSON import | [Data import](./import.md) - file picker -> column map -> preview -> commit |\n| Add AI to the grid | [AI assistant](./ai.md) - one provider adapter, four helpers |\n| Multi-app deployment licensing | [Pricing](https://svgrid.com/pricing/) - Multiple App License covers an org |\n| Testing the grid | [Testing your grid](./testing.md) + [Testing and quality](./testing-and-quality.md) |\n\nIf a question is missing from this table, **press `Ctrl/Cmd + K`** in\nthe docs sidebar - the search box indexes every page's title, headings,\nand body and ranks matches by where they hit.\n\n## Core features\n\n### Columns\n\n- [Column definitions](./columns/column-definitions.md)\n- [Updating definitions](./columns/updating-definitions.md)\n- [Column state](./columns/column-state.md)\n- [Column headers - styling & height](./columns/column-headers.md)\n- [Column groups](./columns/column-groups.md)\n- [Column sizing](./columns/column-sizing.md)\n- [Column moving](./columns/column-moving.md)\n- [Column pinning](./columns/column-pinning.md)\n- [Column spanning](./columns/column-spanning.md)\n- [Aligned grids](./columns/aligned-grids.md)\n- [Column menu](./columns/column-menu.md)\n- [Custom header components](./columns/custom-header-components.md)\n\n### Rows\n\n- [Row data](./rows/row-data.md)\n- [Row sorting](./rows/row-sorting.md)\n- [Row spanning](./rows/row-spanning.md)\n- [Row pinning](./rows/row-pinning.md)\n- [Row height](./rows/row-height.md)\n- [Styling rows](./rows/styling-rows.md)\n- [Row pagination](./rows/row-pagination.md)\n- [Accessing rows](./rows/accessing-rows.md)\n- [Row dragging](./rows/row-dragging.md)\n- [Full-width rows](./rows/full-width-rows.md)\n- [Master / detail (nested grids)](./rows/master-detail.md)\n- [Tree rows (expand / collapse)](./rows/tree-rows.md)\n- [Bulk-action bar (selection)](./rows/selection-bar.md)\n\n### Cells\n\n- [Getting values](./cells/getting-values.md)\n- [Text formatting](./cells/text-formatting.md)\n- [Cell components](./cells/cell-components.md)\n- [Cell data types](./cells/cell-data-types.md)\n- [Styling cells](./cells/styling-cells.md)\n- [Highlighting changes](./cells/highlighting-changes.md)\n- [Tooltips](./cells/tooltips.md)\n- [Expressions](./cells/expressions.md)\n- [View refresh](./cells/view-refresh.md)\n- [Cell text selection](./cells/cell-text-selection.md)\n\n### Spreadsheet\n\nTutorials, in reading order:\n\n- [Getting started](./sheet/start.md) - `<SvSheet />`, seed data, a formula across sheets, the document underneath\n- [Formulas](./sheet/formulas.md) - references, spills, names, LET and LAMBDA, your own functions, auditing\n- [Formatting](./sheet/formatting.md) - number formats, styles, sizes, merged headers, conditional formatting rules\n- [Data validation](./sheet/validation.md) - dropdown lists, bounds, a formula rule, input messages\n- [Files](./sheet/files.md) - `getState`, autosave, `.xlsx` / `.ods` / `.xls` / `.csv`, paste from Excel\n- [Data tools](./sheet/data-tools.md) - AutoFilter, sort, Text to Columns, Remove Duplicates, Goal Seek, PivotTable\n- [Charts, sparklines and pictures](./sheet/charts-and-objects.md) - objects over the sheet and `=IMAGE` in a cell\n- [Review and share](./sheet/review.md) - comments, protection, two people on one sheet\n\nReference:\n\n- [The spreadsheet shell](./cells/spreadsheet-shell.md) - `<SvSheet />`: every prop, every dialog, the file formats, the limits\n- [Formulas](./spreadsheet-formulas.md) - the engine, the function library and the dependency graph\n- [Excel keyboard shortcuts](./cells/keyboard-shortcuts.md)\n- [Number formats](./cells/number-formats.md)\n- [Paste Special, Find and Replace, insert and delete](./cells/paste-find-structure.md)\n- [Workbooks](./cells/workbooks.md) - several sheets behind one engine\n- [Tables and structured references](./cells/tables.md)\n\n### Filtering\n\n- [Overview](./filtering/overview.md)\n- [Text filter](./filtering/text-filter.md)\n- [Number filter](./filtering/number-filter.md)\n- [Date filter](./filtering/date-filter.md)\n- [Set filter](./filtering/set-filter.md)\n- [Filter conditions](./filtering/filter-conditions.md)\n- [Applying filters](./filtering/applying-filters.md)\n- [Filter API](./filtering/filter-api.md)\n- [Custom column filters](./filtering/custom-column-filters.md)\n- [Floating filters](./filtering/floating-filters.md)\n\n### Editing\n\n- [Overview](./editing/overview.md)\n- [Start / stop editing](./editing/start-stop-editing.md)\n- [Parsing values](./editing/parsing-values.md)\n- [Saving values](./editing/saving-values.md)\n- [Edit components](./editing/edit-components.md)\n- [Provided cell editors](./editing/provided-editors.md)\n- [Undo / redo](./editing/undo-redo.md)\n- [Full-row editing](./editing/full-row.md)\n- [Drag a range to move or copy it](./editing/move-cells.md)\n- [Validation](./editing/validation.md)\n\n## Conventions\n\nEach topic page is structured as:\n\n1. **What it is** - a one-sentence definition.\n2. **When to use it** - the situation that calls for this feature.\n3. **Minimal example** - copy-pasteable code that runs.\n4. **Reference** - the relevant exports and prop names.\n5. **Gotchas** - known limits, gaps, or things that surprise people.\n\nPages explicitly note when a feature is **not yet implemented** in the\ncommunity build so you know what you can rely on. The current\ngap list is at [missing-features.md](./missing-features.md).\n"
|
|
4697
4970
|
},
|
|
4698
4971
|
{
|
|
4699
4972
|
"slug": "help/kanban/sprint-board",
|
|
@@ -4723,6 +4996,20 @@ export const docs = [
|
|
|
4723
4996
|
"section": "Help",
|
|
4724
4997
|
"markdown": "# Migrating from AG Grid to SvGrid\n\nIf you tried AG Grid on a Svelte 5 project - via `ag-grid-svelte`, the\nold `ag-grid-community/svelte`, or a hand-rolled wrapper - you probably\nhit the same friction everyone hits: the bridge between AG Grid's\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\nheavy, and the Enterprise pricing only makes sense at scale.\n\n<!-- facts:start ag-grid -->\n> **Facts, checked 12 Sep 2026.** `ag-grid-community` 36.1.0, MIT, last published 5 Aug 2026, 12,400,000 npm downloads in the 30 days to 10 Sep 2026. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026); `ag-grid-community` 36.1.0 317.5 KB JS, no separate stylesheet (measured 12 Sep 2026). AG Grid pricing, as its site states it: AG Grid Community is free under MIT. AG Grid Enterprise is listed at $999 USD per developer with one year of updates and Zendesk support; the Enterprise Bundle with AG Charts Enterprise is $1,498 USD per developer (https://www.ag-grid.com/license-pricing/, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise. Side by side, with sources: [SvGrid vs AG Grid (community + enterprise)](https://svgrid.com/compare/ag-grid/).\n<!-- facts:end -->\n\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\ncovers what maps 1:1, what's different by design, and what you'll lose.\nWe tell you when **not** to switch at the bottom.\n\n## TL;DR\n\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | @svgrid/enterprise |\n| --- | --- | --- | --- | --- |\n| **Licence** | MIT | Commercial, per developer (the price is in the facts box above) | **MIT** | $599/dev/yr (grid) or $999/dev/yr (grid + spreadsheet + Studio) |\n| **Svelte 5 native** | No (community-built integration) | No (community-built integration) | Yes | Yes |\n| **Bundle (gzipped)** | measured, in the facts box above | not measured (licence) | measured, in the facts box above | lazy-loaded subpaths |\n| **Sorting / filtering** | Yes | Yes | Yes | (in Community) |\n| **Row grouping + aggregation** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Master/detail, tree, range select** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Set filter / Excel-style filter menu** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Server-side data, flat (infinite row model)** | Yes | Yes | Yes (free) | (in Community) |\n| **Server-side row model (grouping, tree, pivot, transactions, selection)** | No (Enterprise) | Yes | No | Yes |\n| **Integrated charts** | No (Enterprise) | Yes, AG Charts | Yes (free) | (in Community) |\n| **CSV export** | Yes (API) | Yes | Yes, with TSV and JSON | (in Community) |\n| **Excel export** | No | Yes | No | Yes |\n| **PDF / styled-HTML export, print** | No | No | No | Yes |\n| **Pivot tables** | No | Yes | No | Yes |\n| **MCP server for AI assistants** | Yes, `ag-mcp` | Yes | Yes, `@svgrid/mcp` | (in Community) |\n| **In-grid AI helpers** | No | No | Yes (bring your own model) | (in Community) |\n\n**SvGrid Community gives you most of what AG Grid sells as Enterprise for\nfree**, and `@svgrid/enterprise` adds Excel, PDF and print output, import,\npivot tables and the server-side row model per developer per year. Compare what each paid tier adds\nbefore comparing prices; both are in the facts box. The trade-offs are\nSvelte-only and a much smaller ecosystem. Every AG Grid cell above follows\nag-grid.com's own pages; the dated list is on the\n[SvGrid vs AG Grid](https://svgrid.com/compare/ag-grid/) page.\n\n## Mental model - what changes\n\nAG Grid is one big object you configure declaratively. SvGrid is a\n**headless engine** (`createSvGrid`) with an optional **render\ncomponent** (`<SvGrid>`) on top - the same split TanStack Table made\npopular. You can use either layer; most projects use the render\ncomponent.\n\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, type ColumnDef } from '@svgrid/grid'\n\n type Row = { id: number; make: string; model: string; price: number }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let rows = $state<Row[]>([\n { id: 1, make: 'Toyota', model: 'Celica', price: 35000 },\n { id: 2, make: 'Ford', model: 'Mondeo', price: 32000 },\n { id: 3, make: 'Porsche', model: 'Boxster', price: 72000 },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'make', header: 'Make' },\n { field: 'model', header: 'Model' },\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<!-- AG Grid (via a Svelte wrapper) -->\n<AgGridSvelte\n gridOptions={{\n rowData: rows,\n columnDefs: columns,\n onGridReady: (params) => (gridApi = params.api),\n }}\n/>\n\n<!-- SvGrid -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\nThree things to note:\n\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\n2. **Features are opt-in.** You pass a `features` object built with\n `tableFeatures({...})` - only the features you list ship JS.\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\n AG Grid `gridApi` equivalent (see the API-mapping table below).\n\n## Column definitions - direct translation\n\nThe shapes are similar enough that you can usually translate by hand\nwithout thinking too hard.\n\n```ts\n// AG Grid\nconst columnDefs: ColDef[] = [\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\n { field: 'price', headerName: 'Price', type: 'numericColumn',\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\n { field: 'date', headerName: 'Date',\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\n { field: 'status', headerName: 'Status',\n cellRenderer: StatusCellRenderer,\n cellRendererParams: { onChange: handleStatusChange } },\n]\n```\n\n```ts\n// SvGrid\nimport { renderComponent, type ColumnDef } from '@svgrid/grid'\nimport StatusCell from './StatusCell.svelte'\n\nconst columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\n { field: 'price', header: 'Price',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'date', header: 'Date',\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'status', header: 'Status',\n cell: renderComponent(StatusCell, (ctx) => ({\n value: ctx.getValue(),\n onChange: handleStatusChange,\n })),\n },\n]\n```\n\n### Property mapping\n\n| AG Grid | SvGrid | Notes |\n| --- | --- | --- |\n| `field` | `field` | Same. |\n| `headerName` | `header` | Accepts a string or a snippet/component. |\n| `width` | `width` | Same. |\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\n| `valueGetter` | `fieldFn` | Returns the value for sorting/filtering. |\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\n| `aggFunc: 'sum'` | `aggregate: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\n\n## Feature registration - the one new thing\n\nAG Grid auto-enables most features; you turn them off. SvGrid is the\nopposite - features are opt-in. The result is a smaller bundle.\n\n```ts\nimport {\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n rowPaginationFeature,\n rowSelectionFeature,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n // omit any you don't need - their code won't ship\n})\n```\n\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\non for the registered features.\n\n## API mapping (`gridApi` → `SvGridApi`)\n\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\n`onGridReady`).\n\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\n| --- | --- |\n| `setRowData(rows)` | (declarative - just update `data` prop) |\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\n| `getModel()` (raw rows) | `api.getData()` |\n\n## Common patterns\n\n### Sorting + filtering + pagination (the 80% case)\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination\n showColumnFilters\n/>\n```\n\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\n\n### Cell editing with persistence\n\n```svelte\n<script lang=\"ts\">\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\n // Persist however you like (fetch to backend, optimistic local update, etc.)\n console.log('cell changed', e)\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing\n onCellValueChange={onCellValueChange}\n/>\n```\n\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\nto mutate (or not) on the `rows` array you passed in.\n\n### Grouping + aggregation\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n columnGroupingFeature,\n rowSortingFeature,\n rowExpandingFeature,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns = [\n { field: 'department', header: 'Department' },\n { field: 'team', header: 'Team' },\n { field: 'salary', header: 'Salary', aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n\n function setGroup(api) {\n api.setGroupBy(['department', 'team'])\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showGroupingControls\n onApiReady={setGroup}\n/>\n```\n\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\n\n### Master / detail\n\nAG Grid Enterprise feature; **free in SvGrid Community**. See\n[demo 08](https://svgrid.com/demos/08-tree-and-master-detail/) for the exact pattern.\n\n### Server-side data\n\nAG Grid's Infinite Row Model (Community) and Server-Side Row Model\n(Enterprise) both take an `IServerSideDatasource` with a `getRows(params)`\nthat answers through `params.success({ rowData, rowCount })`. SvGrid has one\n`ServerDataSource` whose `getRows(request)` returns `{ rows, rowCount }`,\nand two controllers over it: `createServerDataSource` (free: paging or\ninfinite block scroll, sort, filter, CRUD) and `createServerRowModel`\n(Enterprise: lazy grouping, tree, pivot, transactions, selection). Both\nmount through one prop, `<SvGrid rowModel={ctl} />`.\n\nAn existing datasource written for AG Grid keeps working behind one call:\n\n```ts\nimport { adaptCallbackDatasource, createServerRowModel } from '@svgrid/enterprise'\n\nconst ctl = createServerRowModel(adaptCallbackDatasource(myServerSideDatasource), {\n groupBy: ['country'],\n aggregations: [{ col: 'amount', fn: 'sum' }],\n})\n```\n\n`adaptCallbackDatasource` maps the request both ways (`rowGroupCols` ->\n`groupBy`, `valueCols` -> `aggregations`, `pivotCols` -> `pivotBy`, the\nsort and filter models, `context`, `parentNode`) and turns `success` /\n`fail` into a resolved or rejected promise.\n\nMore often the datasource is a thin `fetch` and the real investment is the\nendpoint behind it - a Java or Node service that parses AG Grid's\n`IServerSideGetRowsRequest` JSON. That endpoint stays as it is: post\n`toCallbackRequest(request)` and read the answer back.\n\n```ts\nimport { toCallbackRequest } from '@svgrid/enterprise'\n\nconst source = {\n async getRows(request) {\n const res = await fetch('/api/sales', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(toCallbackRequest(request)),\n })\n const { rowData, rowCount, pivotResultFields } = await res.json()\n return { rows: rowData, rowCount: rowCount ?? -1, pivotResultFields }\n },\n}\n```\n\n`fromCallbackRequest` is the other direction, for a SvelteKit endpoint that\nhas to keep serving an AG Grid client during the switch. A saved selection\nor a bulk endpoint written against `getServerSideSelectionState()` keeps\nits shape the same way: `toCallbackSelectionState(ctl.getSelectionState())`\nproduces `{ selectAll, toggledNodes }` (or the `nodeId` /\n`selectAllChildren` / `toggledNodes` tree), `ctl.setSelectionState` takes\nthat shape as it is when your group node ids are the group keys, and\n`fromCallbackSelectionState(state, { groupKey, isGroup })` maps them when\na `getRowId` gave group rows ids of their own. Option by option:\n\n| AG Grid | SvGrid |\n| --- | --- |\n| `rowModelType: 'infinite'` + `datasource` | `createServerDataSource(source, { mode: 'infinite' })` + `rowModel={ctl}` |\n| `rowModelType: 'serverSide'` + `serverSideDatasource` | `createServerRowModel(source, options)` + `rowModel={ctl}` |\n| `getRows(params)` + `params.success({ rowData, rowCount })` / `params.fail()` | `getRows(request)` returning `{ rows, rowCount }` / throwing |\n| `cacheBlockSize`, `maxBlocksInCache`, `maxConcurrentDatasourceRequests`, `blockLoadDebounceMillis` | `blockSize`, `maxBlocksInCache`, `maxConcurrentRequests`, `blockLoadDebounceMs` |\n| `infiniteInitialRowCount` / `serverSideInitialRowCount` | `initialRowCount` |\n| `rowCount: -1` (unknown) | `rowCount: -1` |\n| `refreshInfiniteCache()` / `purgeInfiniteCache()` | `ctl.refresh()` / `ctl.purge()` |\n| `request.rowGroupCols`, `groupKeys`, `valueCols`, `pivotCols`, `pivotMode` | `request.groupBy`, `groupKeys`, `aggregations`, `pivotBy`, `pivotMode` |\n| `getChildCount(data)` | `childCount: (row) => row.childCount` |\n| `isServerSideGroupOpenByDefault` | `isGroupOpenByDefault(route, row)` |\n| `getServerSideGroupLevelParams` | `levelParams(level, route)` |\n| `isServerSideGroup` / `getServerSideGroupKey` (tree) | `treeData: true` + `hasChildren` / `getRowId` |\n| `grandTotalRow` / `groupTotalRow` | `grandTotalRow` / `groupFooters` |\n| `refreshServerSide({ route, purge })` / `retryServerSideLoads()` | `ctl.refresh({ route, purge })` / `ctl.retryLoads()` |\n| `applyServerSideTransaction` / `applyServerSideTransactionAsync` / `flushServerSideAsyncTransactions` | `ctl.applyTransaction` / `applyTransactionAsync` / `flushAsyncTransactions` |\n| `applyServerSideRowData` | `ctl.applyRowData` |\n| `serverSideSortAllLevels`, `serverSideEnableClientSideSort`, `serverSideOnlyRefreshFilteredGroups` | `sortAllLevels`, `clientSideSort`, `onlyRefreshFilteredGroups` |\n| `getServerSideSelectionState` / `setServerSideSelectionState` | `ctl.getSelectionState()` / `setSelectionState()` (the `toggledNodes` shape is accepted; `toCallbackSelectionState` / `fromCallbackSelectionState` convert) |\n| `ServerSideTransactionResult.status`: `Applied`, `StoreNotFound`, `StoreLoading`, `StoreWaitingToLoad`, `StoreLoadingFailed`, `Cancelled` | `ServerTransactionResult.status`: the same six, lower camel case (`applied`, `storeNotFound`, ...) - a `switch` on the old spelling falls through silently |\n| `groupSelects: 'descendants'` | `selection: { groupSelects: 'descendants' }` |\n| `pivotResultFields` in `success`, `setPivotResultColumns`, `processPivotResultColDef` | `pivotResultFields` / `pivotResultColumns` in the result, `pivotResultColumn(field, def)` |\n| `pagination` + `paginateChildRows` | `pagination: { pageSize, paginateChildRows }` |\n| `getCacheBlockState()` / `getServerSideGroupLevelState()` | `ctl.getCacheState()` / `ctl.levelStates()` |\n| `serverSideDatasource.destroy()` | `source.destroy()` |\n\nTwo differences worth knowing before you start: SvGrid sends the global\nsearch box to the server as `filterModel.global` (AG Grid's quick filter is\nclient-side only and unsupported under its infinite and server-side models),\nand SvGrid has no viewport row model. See\n[Server grouping](./server/server-grouping.md) and\n[demo 467](https://svgrid.com/demos/467-server-row-model-1m/).\n\n### Excel / PDF export\n\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\nSvGrid: install `@svgrid/enterprise`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\n\n```ts\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey('YOUR-LICENSE-KEY') // your Enterprise key\n\n// inside onApiReady:\nconst pro = installEnterprise(api)\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\n```\n\n## Gotchas - things that don't translate directly\n\n### 1. Per-column `editable: true`\nEditing is switched on at the grid level (`enableInlineEditing`) and\nnarrowed per column with `editable: boolean | (ctx) => boolean` on the\ncolumn definition, so AG Grid's per-column flag and its callback form both\nmap directly. What does not map is AG Grid's `editable` on a column\ngroup: set it on each child column.\n\n### 2. Column drag-to-reorder\nHeader drag is `enableColumnReorder` on `<SvGrid>`; `setColumnOrder`\non the API does the same from code. See\n[column moving](./columns/column-moving.md) for the events.\n\n### 3. AG Grid `valueGetter` chains\nAG Grid's `valueGetter` can read other column values via the API. In\nSvGrid, `fieldFn` only receives the row; if you need cross-column\ncomputed values, do it in the cell renderer with `ctx.row.original` or\ncompute the derived value upstream and store it in the row.\n\n### 4. `cellClass` / `rowClass` callbacks\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\nwith the conditional class.\n\n### 5. The Status Bar / Side Bar / Tool Panels\nAG Grid's chrome (status bar with row count, side bar with filters and\ncolumns panels) doesn't exist in SvGrid - build it as plain Svelte\nmarkup around the grid. Most teams build their own anyway because\nAG Grid's defaults rarely match a polished design system.\n\n### 6. Set filter (the Excel-style funnel popup)\nSvGrid ships an Excel-style filter menu (free in Community). API surface\nis similar but not identical - see [Set filter](./filtering/set-filter.md).\n\n## When NOT to migrate\n\nBe honest. Stay on AG Grid if you:\n\n- **Use multiple frameworks** - AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\n- **Need a push-based viewport row model** - SvGrid's server-side row model covers grouping, tree, pivot, transactions and select-all across unloaded rows, and its real-time page merges deltas into loaded rows, but the server cannot push the visible window over a socket the way the viewport model does.\n- **Need pluggable custom filter components or custom tool panels** - SvGrid's tool panel is a fixed Columns + Filters pair.\n- **Are mid-project and shipping in <2 weeks** - the migration is a few hours per grid, but only do it when you have buffer.\n- **Have a Svelte 4 codebase you can't upgrade** - SvGrid requires Svelte 5 runes. (Consider [htmlelements.com](https://www.htmlelements.com) for vanilla / multi-framework.)\n\nIf none of those apply: switching drops a per-developer Enterprise licence\nfor features that are in SvGrid's MIT core, cuts the bundle by the\ndifference in the facts box above, and gives you a Svelte-native API that\nplays well with runes.\n\n## Step-by-step migration\n\nA typical migration of a single grid takes 1-3 hours:\n\n1. **Install** - `pnpm add @svgrid/grid` (and `@svgrid/enterprise` if you need export).\n2. **Translate columnDefs** - use the mapping table above. Most columns are 1:1.\n3. **Wrap features** - figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\n4. **Swap the component** - `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\n5. **Move event handlers** - AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\n6. **Move API calls** - AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\n7. **Test interactions** - sort, filter, edit, select. Most \"just works.\"\n8. **Remove `ag-grid-*` packages** - `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the drop; the facts box above has both measured sizes.\n\n## Need help migrating?\n\nEnterprise customers get **migration help included** with the support plan\n(architecture review, port one grid for you as a reference). Email\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\npre-sales questions.\n\n## Frequently asked questions\n\n### Is SvGrid a drop-in replacement for AG Grid in Svelte?\n\nNot a literal drop-in - there is no `ag-grid-svelte` shim to swap. But the\nconcepts map closely: column definitions, row models, sorting, filtering,\ngrouping, and an imperative API all have direct SvGrid equivalents, so most\nteams port a grid in 30 minutes to a day. It is a configuration translation,\nnot a rewrite.\n\n### What is the SvGrid equivalent of AG Grid Enterprise?\n\n`@svgrid/enterprise`. It adds Excel/PDF/styled-HTML export (CSV, TSV and JSON export are already free in the grid), a printable view, pivot\ntables and data import. The in-grid AI helpers are free in `@svgrid/grid`.\nIt is licensed per developer ($599 for the grid, $999 with the spreadsheet\nand Studio, both covering unlimited apps), and the\nCommunity package is MIT-licensed and free for commercial use.\n\n### Does SvGrid use Svelte 5 runes, or is it a wrapper?\n\nIt is Svelte-5-native. State is `$state` / `$derived` / `$effect` and cells\nrender through Svelte snippets - there is no React or Angular core underneath\nand no framework bridge to keep in sync.\n\n### Will my AG Grid bundle size shrink?\n\nAlmost always. The facts box at the top of this page has both packages\nmeasured the same way on the same date: the full SvGrid render component\nwith its stylesheet against `createGrid` with every AG Grid Community\nmodule registered. You add `@svgrid/enterprise` features only where you\nimport them, and the headless core on its own is a small fraction of the\nrender component.\n\n## More examples\n\n### AG Grid ↔ sv-grid side-by-side\n\nTwo real grids over the same dataset: the installed AG Grid Community on the left, sv-grid on the right. Same global filter drives both. Source code panels for either side.\n\n<div data-docs-demo=\"139-migration-from-ag-grid\" data-height=\"460\"></div>\n\n## See also\n\n- [Getting started](../getting-started.md) - full SvGrid walkthrough\n- [Why headless?](../why-headless.md) - the headless / render-component split\n- [Data export and printing](./export.md) - the `@svgrid/enterprise` feature pack\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](./comparison.md) - the\n measured benchmark and the feature matrix\n- [SvGrid vs AG Grid comparison page](https://svgrid.com/compare/ag-grid/) -\n every claim with its source and date\n"
|
|
4725
4998
|
},
|
|
4999
|
+
{
|
|
5000
|
+
"slug": "help/migrating-from-airtable",
|
|
5001
|
+
"path": "docs/help/migrating-from-airtable.md",
|
|
5002
|
+
"title": "Migrating from Airtable",
|
|
5003
|
+
"section": "Help",
|
|
5004
|
+
"markdown": "# Migrating from Airtable\n\nAirtable is a cloud service: the base, its views and its records live on\nAirtable, and the most you can put in your own app is a read-only iframe.\n[SvGrid Base](./base/start.md) is the same kind of table database shipped\nas a Svelte 5 component. The records move into your own database, `<SvBase>`\nrenders the views inside your app, and every edit goes back to your tables.\n\n> Estimated effort: **half a day** for a typical base, plus the time to\n> rebuild its automations.\n\n<!-- facts:start airtable -->\n> **Facts, checked 14 Oct 2026.** `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the 30 days to 10 Sep 2026. Airtable pricing, as its site states it: Airtable Free has no cost. airtable.com/pricing lists Team at $20 per seat per month billed annually ($24 monthly) and Business at $45 per seat per month billed annually ($54 monthly); Enterprise Scale pricing is custom. Read-only collaborators are not charged on Team or Business (https://airtable.com/pricing, read 14 Oct 2026). SvGrid: SvGrid Base ships in Enterprise Suite of @svgrid/enterprise, $999 per developer per year, unlimited apps and end users; the grid it runs on is MIT. Side by side, with sources: [SvGrid vs Airtable](https://svgrid.com/compare/airtable/).\n<!-- facts:end -->\n\n## What maps to what\n\n| Airtable | SvGrid Base |\n| --- | --- |\n| A base | A `<SvBase tables>` with one tab per table |\n| A table | A table in your database, opened as a `BaseTable` |\n| A linked record field | A foreign key (link to one) or a junction table (link to many) |\n| A view | A `FieldView`: `grid`, `kanban`, `calendar`, `gallery`, `gantt`, `chart` or `form` |\n| An automation | An automation in the view bar, or `createAutomationServer` on your server |\n| Collaborators and permissions | Your app's login, and `authorize` on the endpoint |\n| Comments | `setRecordComments`, stored where your app stores them |\n| An interface | Your own Svelte page around the base |\n\nField types carry over almost one to one:\n\n| Airtable field | `fieldType` |\n| --- | --- |\n| Single line text | `text` |\n| Long text | `longText` (with `richText` for bold, italic, links and lists) |\n| Attachment | `attachment` |\n| Checkbox | `checkbox` |\n| Single select, multiple select | `singleSelect`, `multiSelect` |\n| User | `user` |\n| Date | `date` |\n| Phone number, email, URL | `phone`, `email`, `url` |\n| Number, currency, percent | `number` with `format: 'integer'`, `'currency'` or `'percent'` |\n| Duration | `duration` |\n| Rating | `rating` |\n| Formula | `formula` |\n| Lookup, rollup, count | `lookup`, `rollup`, `count` |\n| Created time, last modified time | `createdTime`, `lastModifiedTime` |\n| Created by, last modified by | `createdBy`, `lastModifiedBy` |\n| Autonumber | `autoNumber` |\n| Barcode, button | `barcode`, `button` |\n| Linked record | `link` |\n\nFormulas use the same shape: fields by name in braces, `&` to join text,\nand functions such as `IF`, `SWITCH`, `ROUND`, `DATEADD` and\n`DATETIME_DIFF`. Paste each formula into the field editor; one that uses a\nfunction the base does not have shows `#ERROR` and names it, and you\nrewrite that part.\n\n## 1. Export each table\n\nIn Airtable, open a grid view that shows every field and every record (no\nfilter, no hidden fields) and choose **Download CSV**. Do this for each\ntable. A linked record column comes out as text naming the linked records\nrather than as record ids; open one file to see the exact form before the\nnext step turns those names back into links.\n\nAttachment columns come out as URLs that Airtable expires after a few\nhours. Download the files straight away and put them in your own storage,\nor skip the column and move the files later.\n\n## 2. Load the tables into your database\n\nCreate a table per Airtable table, with a key the database makes, and load\nthe CSV with a text column for each link. Postgres:\n\n```sql\ncreate table projects (\n id uuid primary key default gen_random_uuid(),\n name text not null,\n status text,\n due date\n);\n\ncreate table tasks (\n id uuid primary key default gen_random_uuid(),\n task text not null,\n status text,\n budget numeric,\n project_name text, -- the CSV's \"Project\" column\n project_id uuid references projects(id)\n);\n\n\\copy projects (name, status, due) from 'Projects-Grid view.csv' csv header\n\\copy tasks (task, status, budget, project_name) from 'Tasks-Grid view.csv' csv header\n```\n\nThen match the names to keys and drop the text column:\n\n```sql\nupdate tasks t set project_id = p.id\nfrom projects p where p.name = t.project_name;\n\nalter table tasks drop column project_name;\n```\n\nA link to many records becomes a table that pairs the two. Load the CSV's\nlist of names into a text column first (`tag_names` below; adjust the\nseparator to what the file holds), then:\n\n```sql\ncreate table task_tags (\n task_id uuid references tasks(id) on delete cascade,\n tag_id uuid references tags(id) on delete cascade,\n primary key (task_id, tag_id)\n);\n\ninsert into task_tags (task_id, tag_id)\nselect t.id, g.id\nfrom tasks t\ncross join lateral unnest(string_to_array(t.tag_names, ', ')) as n(name)\njoin tags g on g.name = n.name;\n```\n\nNames must be unique for the match to be right. If two Airtable records\nshared a primary value, give them distinct names before you export, or\nmatch on another column.\n\n## 3. Open the database as a base\n\nOne endpoint on your server reads the tables and serves them; the page\nopens them. A foreign key becomes a link with a field in the other table\nthat links back, a pairing table a link to many records, and a text column\nthat repeats a few values a single select with a color per value\n([what it reads](./base/quick-start.md#what-it-reads-from-your-database)).\n\n```ts\n// src/routes/api/base/+server.ts\nimport { createBaseEndpoint } from '@svgrid/enterprise/base'\n\n// Your database driver: run parameterized SQL, return the rows.\ndeclare function query(sql: string, params: unknown[]): Promise<Record<string, unknown>[]>\n\nexport const { GET, POST } = createBaseEndpoint({ execute: query })\n```\n\n```svelte\n<!-- src/routes/base/+page.svelte (with `export const ssr = false` in +page.ts) -->\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseFromEndpoint, enableBase } from '@svgrid/enterprise/base'\n\n enableBase()\n const tables = baseFromEndpoint('/api/base')\n</script>\n\n{#await tables then tables}\n <SvBase {tables} editable sortable columnAdder fieldMenu />\n{/await}\n<SvToaster />\n```\n\n[Build a base on Postgres](./base/postgres.md) is the same thing end to\nend, and [From a database to an app](./base/quick-start.md) covers MySQL,\nSQLite and Supabase.\n\n## 4. Set the field types and views\n\nThe base guesses the types from the columns. Fix the ones it cannot know\n(a percent, a rating, a user, a formula) and recreate the views you used:\n\n```ts\nimport type { FieldView } from '@svgrid/enterprise/base'\n\nconst views: FieldView[] = [\n { id: 'all', name: 'All tasks', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'Board', type: 'kanban', stackBy: 'status' },\n { id: 'due', name: 'Due dates', type: 'calendar', dateField: 'due' },\n { id: 'request', name: 'Request a task', type: 'form' },\n]\n```\n\nWhat you change in the app (field types, views, colors) comes back through\n`onTablesChange`; store it and start from it next time. The records stay in\nyour database either way.\n\n## 5. Rebuild the automations\n\nOpen **Automations** in the view bar. The triggers you know are there: a\nrecord is created, a record changes, a record matches a condition, a form\nis sent, a button is pressed, a scheduled time. The actions update the\nrecord, create a record in any table, show a notice, color the record,\ncall a webhook or send an email. An Airtable script that calls an API\nbecomes a webhook action, or an action you register in code with\n`registerAutomationAction`.\n\nAutomations that must run whoever made the change, or that hold secrets,\nrun on your server with `createAutomationServer`; see\n[Automations on the server](./base/automations.md#on-the-server).\n\n## 6. Users, permissions and comments\n\nYour app's login replaces Airtable collaborators. List the people for user\nfields with `setFieldUsers`, say who is signed in with `setCurrentUser`\n(created by and last modified by read it), and decide who may read or write\nwhat in the endpoint's `authorize`, or with row level security on Supabase.\nComments go through `setRecordComments` to wherever you keep them.\n\n## What you give up\n\n- The hosted service: someone adds `<SvBase>` to a Svelte app and runs the\n database it reads.\n- Interfaces, Omni and the AI credits. The pages around the base are your\n own Svelte pages.\n- Native mobile apps, and the list and inbox views.\n- The permissions screen, sharing links and the long revision history.\n Access rules live in your server or database; the record shows the\n changes made in the session, and the endpoint's `audit` hook can log\n every write.\n\nSvGrid Base is part of the Suite edition of `@svgrid/enterprise` ($999 per\ndeveloper). It runs without a key during an evaluation, with a watermark.\n\n## See also\n\n- [SvGrid vs Airtable](https://svgrid.com/compare/airtable/): the side-by-side comparison with sources.\n- [Build a project tracker](./base/start.md): SvGrid Base step by step.\n- [Ready-to-use bases](./base/templates.md): a CRM, a content calendar, a roadmap and more.\n- [Field types](./fields.md): every type and its properties.\n"
|
|
5005
|
+
},
|
|
5006
|
+
{
|
|
5007
|
+
"slug": "help/migrating-from-baserow",
|
|
5008
|
+
"path": "docs/help/migrating-from-baserow.md",
|
|
5009
|
+
"title": "Migrating from Baserow",
|
|
5010
|
+
"section": "Help",
|
|
5011
|
+
"markdown": "# Migrating from Baserow\n\nBaserow is an app your team signs into; outside it, a view shows up only as\na read-only iframe. [SvGrid Base](./base/start.md) is the same kind of table\ndatabase shipped as a Svelte 5 component. The records go into tables in your\nown database, `<SvBase>` renders editable views inside your app, and every\nchange is written back to those tables.\n\n> Estimated effort: **1-3 hours** per database, plus the time to rebuild\n> its automations.\n\n<!-- facts:start baserow -->\n> **Facts, checked 14 Oct 2026.** `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the 30 days to 10 Sep 2026. Baserow pricing, as its site states it: Baserow's self-hosted open-source edition is free with unlimited rows. baserow.io/pricing lists Premium at $10 per user per month billed yearly ($12 monthly), Advanced at $18 per user per month billed yearly ($22 monthly) and Enterprise on request, at the same prices on cloud and self-hosted; cloud Free is $0 with 3,000 rows per workspace (https://baserow.io/pricing, read 14 Oct 2026). SvGrid: SvGrid Base ships in Enterprise Suite of @svgrid/enterprise, $999 per developer per year, unlimited apps and end users; the grid it runs on is MIT. Side by side, with sources: [SvGrid vs Baserow](https://svgrid.com/compare/baserow/).\n<!-- facts:end -->\n\n## Two starting points\n\n- **Your data was synced in from your own PostgreSQL.** Baserow's\n PostgreSQL data sync copies a table you own into Baserow. Skip the export:\n point the base at the source tables (step 3) and they open with their\n foreign keys as links.\n- **Your data lives in Baserow.** Baserow's own tables belong to Baserow.\n Export each one and load it into tables of yours, as below.\n\n## What maps to what\n\n| Baserow | SvGrid Base |\n| --- | --- |\n| A database | A `<SvBase tables>` with one tab per table |\n| A table | A table in your database, opened as a `BaseTable` |\n| A view | A `FieldView`: `grid`, `kanban`, `calendar`, `gallery`, `gantt`, `chart` or `form` |\n| A timeline view | A `gantt` view with `dateField` and `endField` |\n| An automation | An automation in the view bar, or `createAutomationServer` on your server |\n| Workspace roles | Your app's login, and `authorize` on the endpoint |\n| Row comments | `setRecordComments`, stored where your app stores them |\n| An application builder page | Your own Svelte page around the base |\n\n| Baserow field | `fieldType` |\n| --- | --- |\n| Single line text, long text | `text`, `longText` |\n| Link to table | `link` |\n| Lookup, rollup, count | `lookup`, `rollup`, `count` |\n| Formula | `formula` |\n| Number, rating, duration | `number`, `rating`, `duration` |\n| Boolean | `checkbox` |\n| Date | `date` |\n| Created on, last modified | `createdTime`, `lastModifiedTime` |\n| Created by, last modified by | `createdBy`, `lastModifiedBy` |\n| Collaborator | `user` |\n| File | `attachment` |\n| Single select, multiple select | `singleSelect`, `multiSelect` |\n| Autonumber | `autoNumber` |\n| URL, email, phone number | `url`, `email`, `phone` |\n| Button | `button` |\n| UUID | `text`, or the table's key |\n| Password | none: passwords belong to your app's login |\n| AI prompt | none: an automation action you register with `registerAutomationAction` |\n\nBaserow formulas name fields in square brackets (`[Quantity] * [Unit Price]`);\nSvGrid Base names them in braces (`{Quantity} * {Unit Price}`). Change the\nbrackets and paste the formula into the field editor. A function the base\ndoes not have shows `#ERROR` and is named in the editor, so you rewrite\nthat part.\n\n## 1. Export each table\n\nIn each table, export the grid view as CSV with every field shown and no\nfilter. Open one file and look at how the link columns came out: the next\nstep matches the linked rows they name back to keys.\n\n## 2. Load the tables into your database\n\nCreate a table per Baserow table, with a key the database makes, load the\nCSV, and turn the link text into foreign keys and pairing tables. The SQL is\nthe same as for any other CSV export:\n[Load the tables into your database](./migrating-from-airtable.md#2-load-the-tables-into-your-database)\nwalks through it, a link to one record and a link to many.\n\n## 3. Open the database as a base\n\n```ts\n// src/routes/api/base/+server.ts\nimport { createBaseEndpoint } from '@svgrid/enterprise/base'\n\n// Your database driver: run parameterized SQL, return the rows.\ndeclare function query(sql: string, params: unknown[]): Promise<Record<string, unknown>[]>\n\nexport const { GET, POST } = createBaseEndpoint({ execute: query })\n```\n\n```svelte\n<!-- src/routes/base/+page.svelte (with `export const ssr = false` in +page.ts) -->\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseFromEndpoint, enableBase } from '@svgrid/enterprise/base'\n\n enableBase()\n const tables = baseFromEndpoint('/api/base')\n</script>\n\n{#await tables then tables}\n <SvBase {tables} editable sortable columnAdder fieldMenu />\n{/await}\n<SvToaster />\n```\n\nForeign keys become links with a field on the other side that links back,\npairing tables become links to many records, and a text column that repeats\na few values becomes a single select with a color per value. See\n[what it reads from your database](./base/quick-start.md#what-it-reads-from-your-database).\n\n## 4. Views, automations and access\n\nRecreate the views you used, including the Kanban, calendar and timeline\nviews that were paid on Baserow:\n\n```ts\nimport type { FieldView } from '@svgrid/enterprise/base'\n\nconst views: FieldView[] = [\n { id: 'all', name: 'All rows', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'Board', type: 'kanban', stackBy: 'status' },\n { id: 'due', name: 'Due dates', type: 'calendar', dateField: 'due' },\n]\n```\n\nAutomations take the triggers you know (a record created or changed, a\ncondition, a form, a button, a schedule) and update records, create\nrecords, call webhooks or send email; see [Automations](./base/automations.md).\nRoles move into the endpoint's `authorize`, or into row level security on\nSupabase.\n\n## What you give up\n\n- Workspaces, roles and the sharing screen. Access rules live in your\n server or database.\n- The application builder. The screens around the base are your own\n Svelte pages.\n- Long row change history and audit logs kept for you. The record shows the\n changes made in the session, and the endpoint's `audit` hook can log every\n write.\n- File import, AI fields and the AI assistant inside the tool.\n\nSvGrid Base is part of the Suite edition of `@svgrid/enterprise` ($999 per\ndeveloper). It runs without a key during an evaluation, with a watermark.\n\n## See also\n\n- [SvGrid vs Baserow](https://svgrid.com/compare/baserow/): the side-by-side comparison with sources.\n- [Keep a base in a database](./base/data.md): how the writes, links and realtime work.\n- [Ready-to-use bases](./base/templates.md): templates to start from.\n- [Field types](./fields.md): every type and its properties.\n"
|
|
5012
|
+
},
|
|
4726
5013
|
{
|
|
4727
5014
|
"slug": "help/migrating-from-devextreme",
|
|
4728
5015
|
"path": "docs/help/migrating-from-devextreme.md",
|
|
@@ -4772,6 +5059,13 @@ export const docs = [
|
|
|
4772
5059
|
"section": "Help",
|
|
4773
5060
|
"markdown": "# Migrating from MUI X DataGrid\n\nMUI X DataGrid is the most common starting point for teams already\non Material UI. Its MIT Community edition plus paid Pro and Premium\ntiers is a split similar to sv-grid's, except that column pinning,\nmaster/detail, tree data, row grouping, aggregation and cell selection\nare paid in MUI X and free here. The port is mostly mechanical.\n\n> Estimated effort: **1-3 hours** per grid, depending on how heavily\n> you've leant on `apiRef.current.*` calls.\n\n<!-- facts:start mui-x-datagrid -->\n> **Facts, checked 12 Sep 2026.** `@mui/x-data-grid` 9.13.0, MIT, last published 4 Sep 2026, 11,800,000 npm downloads in the 30 days to 10 Sep 2026. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026); `@mui/x-data-grid` 9.13.0 155.0 KB JS, no separate stylesheet, @emotion/react + @emotion/styled + @mui/material + @mui/system + react + react-dom external (measured 12 Sep 2026). MUI X DataGrid pricing, as its site states it: MUI X Community is free under MIT. mui.com lists Pro at $299 per year per developer, Premium at $599 per year per developer and Enterprise at $1,399 per year per developer, with perpetual and annual licence models (https://mui.com/pricing/, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise. Side by side, with sources: [SvGrid vs MUI X DataGrid](https://svgrid.com/compare/mui-x-datagrid/).\n<!-- facts:end -->\n\n## Package map\n\n| MUI X | sv-grid |\n| ------------------------------------ | ---------------------------------------- |\n| `@mui/x-data-grid` | `@svgrid/grid` |\n| `@mui/x-data-grid-pro` | `@svgrid/enterprise` (export, import, pivot, AI) |\n| `@mui/x-data-grid-premium` | All of `@svgrid/enterprise` ships in one tier |\n\n## Imports\n\nThe example at the end of this page runs against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200, editorType: 'text' },\n { field: 'department', header: 'Department', width: 150, editorType: 'text' },\n { field: 'city', header: 'City', width: 140, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n { field: 'salary', header: 'Salary', width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```diff\n- import { DataGrid, GridColDef, GridRowsProp } from '@mui/x-data-grid'\n\n+ import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\n+ type ColumnDef } from '@svgrid/grid'\n```\n\n## Column defs\n\n```diff\n- const columns: GridColDef[] = [\n- { field: 'id', headerName: 'ID', width: 90 },\n- { field: 'lastName', headerName: 'Last', width: 150, editable: true },\n- { field: 'age', headerName: 'Age', type: 'number', width: 110, editable: true },\n- ]\n\n+ const columns: ColumnDef<typeof features, Row>[] = [\n+ { field: 'id', header: 'ID', width: 90 },\n+ { field: 'lastName', header: 'Last', width: 150 },\n+ { field: 'age', header: 'Age', width: 110, editorType: 'number' },\n+ ]\n```\n\n- `headerName` → `header`\n- `type: 'number'` → `editorType: 'number'`\n- `editable: true` → omit (every column is editable by default; set\n `editable: false` to OPT OUT)\n\n## Mounting\n\n```diff\n- <DataGrid rows={rows} columns={columns} pageSize={25} checkboxSelection />\n\n+ <SvGrid\n+ data={rows} columns={columns} features={features}\n+ showPagination={true} pageSize={25}\n+ selectionMode=\"row\" />\n```\n\n`tableFeatures({ rowSortingFeature, columnFilteringFeature,\nrowSelectionFeature })` registers what you'd implicitly get from\nMUI X.\n\n## `apiRef` translation\n\n| MUI X (`apiRef.current.*`) | sv-grid (`api.*`) |\n| -------------------------------------------- | ------------------------------------------ |\n| `setRows(rows)` | mutate the `$state` array you bound to `data` |\n| `updateRows([{id, ...patch}])` | `api.setCellValue(rowIndex, field, value)` |\n| `setSortModel([{field, sort}])` | `api.setSort(field, sort)` |\n| `setFilterModel({items: [...]})` | `api.setFilter(field, {operator, value})` |\n| `setPage(0)` / `setPageSize(50)` | Use the built-in pager; for headless control register `pageSize` prop |\n| `selectRow(id)` | Toggle the row checkbox via the wrapper's UI; programmatic select is `api.selectRows([id])` (`api.toggleRowSelected(id)` to flip one) |\n| `getSelectedRows()` | `api.getSelectedRows()` (ids via `api.getSelectedRowIds()`) |\n| `exportDataAsExcel()` | `api.exportData({ format: 'xlsx' })` (Enterprise) |\n| `setColumnVisibilityModel({field: false})` | `api.setColumnVisible('field', false)` |\n\n## Custom cells\n\n```diff\n- {\n- field: 'status',\n- renderCell: ({ row }) => <Chip label={row.status} color={row.status === 'active' ? 'success' : 'default'} />,\n- }\n\n+ {\n+ field: 'status',\n+ cell: (ctx) => renderSnippet(StatusChip, { status: ctx.row.original.status }),\n+ }\n```\n\n## Selection\n\n```diff\n- <DataGrid checkboxSelection\n- onRowSelectionModelChange={(ids) => setSelected(ids)} />\n\n+ <SvGrid\n+ {data} {columns} features={features}\n+ selectionMode=\"row\"\n+ onRowSelectionChange={(selection, rows) => setSelected(rows)} />\n```\n\n`selectionMode` values: `'row'` (the MUI X equivalent), `'cell'`\n(spreadsheet-style range), `'both'`, `'none'`.\n\n## Inline editing\n\nMUI X had `processRowUpdate(newRow, oldRow)` returning the new row.\nSv-grid's equivalent is `onCellValueChange`:\n\n```diff\n- <DataGrid processRowUpdate={async (newRow) => {\n- await api.savePatch(newRow)\n- return newRow\n- }} />\n\n+ <SvGrid\n+ onCellValueChange={async (e) => {\n+ await api.savePatch({ id: e.row.id, [e.columnId]: e.newValue })\n+ }}\n+ />\n```\n\nFor full-row editing (one Save button per row), see\n[Full-row editing](./editing/full-row.md).\n\n## Server-side data\n\nMUI X's `pagination + serverSideMode + filterMode='server'` maps to\nsv-grid's `externalSort + externalFilter`, or to `createServerDataSource` (free:\npaging or infinite scroll, sort, filter) when the server pages, and to the\nEnterprise `createServerRowModel` when it also groups; the demo below runs\nthe free controller against a fake API with sort, filter and infinite scroll:\n\n<div data-docs-demo=\"148-server-row-model\" data-height=\"520\"></div>\n\nMUI X's server-side row grouping and aggregation (Premium) map to the\nEnterprise row model, which loads each group level on expand and pages,\nedits and selects across rows the grid never fetched:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"600\"></div>\n\n```svelte\n<SvGrid\n data={rows} columns={columns} features={features}\n externalSort={true}\n externalFilter={true}\n onSortingChange={async (s) => { rows = await fetchPage({ sort: s }) }}\n onFiltersChange={async (f) => { rows = await fetchPage({ filters: f.columns }) }}\n/>\n```\n\n## Slots / customisation\n\nMUI X exposes a `slots` object. Sv-grid doesn't - instead, every\nvisual piece is a CSS custom property (`--sg-*`); see\n[design tokens](./tokens.md). Override at any DOM level:\n\n```css\n.grid-host { --sg-accent: #db2777; --sg-radius: 10px; }\n```\n\nRow height is the exception: it is the `rowHeight` prop rather than a\ntoken, because the virtualizer needs it as a number.\n\nFor full theme presets (Ant, MUI, Fluent, Base Web, shadcn) see\n[demo 74](https://svgrid.com/demos/74-theme-integrations/).\n\n## What you get for free vs MUI X\n\n- **No Emotion / no Material theme dependency.** The facts box at the\n top of the page has both packages measured the same way; the MUI X\n figure excludes React, Material UI, Emotion and the MUI system, which\n it needs and sv-grid does not.\n- **Pro and Premium features in the MIT core.** Column pinning, master/detail,\n tree data, row grouping, aggregation and cell selection carry Pro or\n Premium badges on mui.com; they are free here. Excel export and pivot\n are paid on both sides.\n- **In-grid AI helpers against your own model.** MUI X's AI assistant is a\n Premium feature that routes prompts through MUI's hosted service unless\n you build your own; sv-grid's natural-language filter, smart fill and\n summarise helpers are free and run against a provider you register.\n- **CSP-clean.** No `eval`.\n\n## What you give up\n\n- **The Material Design look out of the box.** Sv-grid ships\n unstyled-by-token; the [MUI preset in demo 74](https://svgrid.com/demos/74-theme-integrations/)\n is one drop-in.\n- **MUI form-field integration.** Bind directly to your own MUI\n inputs in custom cell components if you want them.\n- **The rest of MUI X in one design system.** Date pickers, charts and\n the tree view share MUI X's theme; sv-grid ships its own date and time\n editors and charts, themed through the same `--sg-*` tokens.\n- **A hosted AI assistant with starter credits.** Premium licence holders\n get MUI's service; sv-grid's helpers need a model provider you host or\n pay for yourself.\n\n## Frequently asked questions\n\n### How hard is it to migrate from MUI X DataGrid to SvGrid?\n\nMostly mechanical - typically 1-3 hours per grid, depending on how heavily you\nrelied on `apiRef.current.*`. The Community/Enterprise split mirrors MUI X's, so the\nlicensing mental model carries over directly.\n\n### Can SvGrid keep the Material Design look?\n\nYes. SvGrid ships unstyled-by-token and re-themes through `--sg-*` CSS\nvariables; the MUI preset in demo 74 is a drop-in starting point. You can also\nbind your own MUI inputs inside custom cell components.\n\n### Is SvGrid cheaper than MUI X Pro/Premium?\n\nSvGrid's Community tier is MIT and free for commercial use, and `@svgrid/enterprise`\nis priced per developer ($599 for the grid, $999 with the spreadsheet and\nStudio, both covering unlimited apps). MUI X Pro and\nPremium are also per developer per year; the prices mui.com listed on the\ndate we read it are in the facts box at the top of this page. The difference\nis what the free tier holds: most of what MUI X sells in Pro and Premium is\nin SvGrid's MIT core, so compare feature needs before prices on the\n[pricing page](https://svgrid.com/pricing/).\n\n## What you end up with\n\nSorting, filtering, pagination and inline editing - the MUI X DataGrid feature set.\n\n```svelte {runnable}\n<SvGrid data={rows} {columns} sortable filterable editable pageable pageSize={3} />\n```\n\n## See also\n\n- [SvGrid vs MUI X DataGrid](https://svgrid.com/compare/mui-x-datagrid/) - the side-by-side comparison, with a source and date for every claim\n- [Server-side data](./server-side-data.md) - the row model the demo above uses\n- [AI assistant](./ai.md) - the free in-grid helpers\n- [Migrating from AG Grid](./migrating-from-ag-grid.md)\n- [Migrating from TanStack Table](./migrating-from-tanstack-table.md)\n- [Design tokens](./tokens.md)\n"
|
|
4774
5061
|
},
|
|
5062
|
+
{
|
|
5063
|
+
"slug": "help/migrating-from-nocodb",
|
|
5064
|
+
"path": "docs/help/migrating-from-nocodb.md",
|
|
5065
|
+
"title": "Migrating from NocoDB",
|
|
5066
|
+
"section": "Help",
|
|
5067
|
+
"markdown": "# Migrating from NocoDB\n\nNocoDB is an app your team signs into, and outside it a shared view shows up\nonly as an iframe. [SvGrid Base](./base/start.md) is the same kind of table\ndatabase shipped as a Svelte 5 component: `<SvBase>` renders the views\ninside your own app, on your own database, and writes every change back.\n\n> Estimated effort: **1-2 hours** per base on PostgreSQL or MySQL; more to\n> rebuild workflows.\n\n<!-- facts:start nocodb -->\n> **Facts, checked 14 Oct 2026.** `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the 30 days to 10 Sep 2026. NocoDB pricing, as its site states it: NocoDB self-hosted Community is $0 under the Sustainable Use License. nocodb.com/pricing lists cloud Plus at $12 per seat per month billed annually ($15 monthly), Business at $24 per seat per month billed annually ($30 monthly), Scale at $45 per seat per month billed annually with 3 minimum seats, and Enterprise on request; self-hosted Business and Scale are priced the same per seat (https://nocodb.com/pricing, read 14 Oct 2026). SvGrid: SvGrid Base ships in Enterprise Suite of @svgrid/enterprise, $999 per developer per year, unlimited apps and end users; the grid it runs on is MIT. Side by side, with sources: [SvGrid vs NocoDB](https://svgrid.com/compare/nocodb/).\n<!-- facts:end -->\n\n## Two starting points\n\n- **NocoDB was connected to your own PostgreSQL or MySQL.** The tables are\n already yours. Point the base at the same database (step 2) and you are\n most of the way there.\n- **The tables live in NocoDB's own database.** Download each table as CSV\n (NocoDB also offers JSON and Excel), load it into tables of yours, and\n continue from step 2.\n [Load the tables into your database](./migrating-from-airtable.md#2-load-the-tables-into-your-database)\n has the SQL for the keys, a link to one record and a link to many.\n\n## What maps to what\n\n| NocoDB | SvGrid Base |\n| --- | --- |\n| A base | A `<SvBase tables>` with one tab per table |\n| A table | A table in your database, opened as a `BaseTable` |\n| A view | A `FieldView`: `grid`, `kanban`, `calendar`, `gallery`, `gantt`, `chart` or `form` |\n| A timeline or Gantt view | A `gantt` view with `dateField`, `endField` and `dependencyField` |\n| A workflow or webhook | An automation in the view bar, or `createAutomationServer` on your server |\n| Roles | Your app's login, and `authorize` on the endpoint |\n| Record comments | `setRecordComments`, stored where your app stores them |\n\n| NocoDB field | `fieldType` |\n| --- | --- |\n| Single line text, long text | `text`, `longText` |\n| Link to another record | `link` |\n| Lookup, rollup | `lookup`, `rollup`; a rollup with the count function can be `count` |\n| Formula | `formula` |\n| Number, currency, percent | `number` with the matching `format` |\n| Rating, duration | `rating`, `duration` |\n| Checkbox | `checkbox` |\n| Date | `date` (with `time: true` for a date and time) |\n| Created time, last modified time | `createdTime`, `lastModifiedTime` |\n| Created by, last modified by | `createdBy`, `lastModifiedBy` |\n| User | `user` |\n| Attachment | `attachment` |\n| Single select, multi select | `singleSelect`, `multiSelect` |\n| Barcode, button | `barcode`, `button` |\n| Auto number | `autoNumber` |\n| Email, URL, phone number | `email`, `url`, `phone` |\n\nPaste each formula into the field editor. Fields go by name in braces\n(`{Qty} * {Unit price}`); a function the base does not have shows `#ERROR`\nand is named in the editor, so you rewrite that part.\n\n## 1. Check the links\n\nThe base turns a foreign key into a link with a field on the other side\nthat links back, and a table that only pairs two others into a link to many\nrecords. Look at the links you made in NocoDB: if one has no foreign key or\npairing table behind it in your database, add one before you open the base,\nor that link will not appear.\n\n## 2. Open the database as a base\n\n```ts\n// src/routes/api/base/+server.ts\nimport { createBaseEndpoint } from '@svgrid/enterprise/base'\n\n// Your database driver: run parameterized SQL, return the rows.\ndeclare function query(sql: string, params: unknown[]): Promise<Record<string, unknown>[]>\n\nexport const { GET, POST } = createBaseEndpoint({ execute: query })\n```\n\nFor MySQL, pass `dialect: 'mysql'`; `tables` limits the base to the tables\nyou name.\n\n```svelte\n<!-- src/routes/base/+page.svelte (with `export const ssr = false` in +page.ts) -->\n<script lang=\"ts\">\n import { SvToaster } from '@svgrid/grid'\n import { SvBase, baseFromEndpoint, enableBase } from '@svgrid/enterprise/base'\n\n enableBase()\n const tables = baseFromEndpoint('/api/base')\n</script>\n\n{#await tables then tables}\n <SvBase {tables} editable sortable columnAdder fieldMenu />\n{/await}\n<SvToaster />\n```\n\n## 3. Views, automations and access\n\nRecreate the views, including a Gantt for the timeline and Gantt views that\nwere on NocoDB's Business plan:\n\n```ts\nimport type { FieldView } from '@svgrid/enterprise/base'\n\nconst views: FieldView[] = [\n { id: 'all', name: 'All tasks', type: 'grid', color: { mode: 'field', field: 'status' } },\n { id: 'board', name: 'Board', type: 'kanban', stackBy: 'status' },\n { id: 'plan', name: 'Plan', type: 'gantt', dateField: 'start', endField: 'due' },\n]\n```\n\nWorkflows become automations: a record created or changed, a condition, a\nform, a button or a schedule sets them off, and they update or create\nrecords, call webhooks and send email. Integration nodes (Slack, Gmail and\nso on) become webhook calls or actions you register with\n`registerAutomationAction`. See [Automations](./base/automations.md).\n\nNocoDB's roles move into the endpoint's `authorize`, or into row level\nsecurity on Supabase.\n\n## What you give up\n\n- The roles screen and sharing links. Access rules live in your server or\n database.\n- The long record revision history. The record shows the changes made in\n the session, and the endpoint's `audit` hook can log every write.\n- Workflow integration nodes; they become webhooks and actions you write.\n- A no-code setup path: someone adds `<SvBase>` to a Svelte app.\n\nSvGrid Base is part of the Suite edition of `@svgrid/enterprise` ($999 per\ndeveloper). It runs without a key during an evaluation, with a watermark.\n\n## See also\n\n- [SvGrid vs NocoDB](https://svgrid.com/compare/nocodb/): the side-by-side comparison with sources.\n- [From a database to an app](./base/quick-start.md): what the base reads from your database.\n- [Keep a base in a database](./base/data.md): how the writes, links and realtime work.\n- [Field types](./fields.md): every type and its properties.\n"
|
|
5068
|
+
},
|
|
4775
5069
|
{
|
|
4776
5070
|
"slug": "help/migrating-from-primevue-datatable",
|
|
4777
5071
|
"path": "docs/help/migrating-from-primevue-datatable.md",
|
|
@@ -4854,7 +5148,7 @@ export const docs = [
|
|
|
4854
5148
|
"path": "docs/help/missing-features.md",
|
|
4855
5149
|
"title": "Missing features",
|
|
4856
5150
|
"section": "Help",
|
|
4857
|
-
"markdown": "# Missing features\n\nAn honest accounting of what is **not yet built**, audited against the shipped\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\n\nShipped items are struck through with the demo or API that covers them, so you\ncan see both the trajectory and the (short) list of real gaps.\n\n## Columns\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`getRowId` prop~~ | **shipped** | ✓ |\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | ✓ |\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | ✓ |\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | ✓ |\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | ✓ |\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | ✓ |\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | ✓ |\n\n## Rows\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | ✓ |\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | ✓ |\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | ✓ |\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | ✓ |\n| ~~Auto row height (measure content)~~ | **shipped** - `autoRowHeight` wraps cell text and measures each row, virtualization included | ✓ |\n| ~~`api.getDisplayedRows()`~~ | **shipped** | ✓ |\n| ~~Client-side tree data (hierarchical rows)~~ | **shipped** - `treeData` nests by parent id, `flattenTreeData` converts nested children; treegrid role + arrow-key expand; demo `426-tree-data` | ✓ |\n| ~~Built-in row dragging~~ | **shipped** - `rowDragManaged` reorders in-grid and moves rows **grid-to-grid** via a shared `rowDragGroup`; `onRowDragEnd` on the receiver; demos `105-row-reorder` (custom) + `180-row-dragging` (managed) | ✓ |\n\n## Cells\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | ✓ |\n| ~~Formula language~~ | **shipped** - a real module in `@svgrid/enterprise/sheet`: parser, evaluator, the common functions plus financial, math, statistics, text and date packs (the [formulas page](./spreadsheet-formulas.md) lists them), cross-sheet and whole-column refs, short-circuiting `IF` / `IFERROR`, a dependency graph for incremental recalc, and `translateFormula` / `fixupReferences`. Absolute refs are now absolute (the demo copies stripped `$`, so anything filled or copied was quietly wrong) | ✓ |\n| ~~Formula editor (bar, autocomplete, Name Box)~~ | **shipped** - `<SvFormulaBar>` shows the raw text behind the active cell, autocompletes function names closest-match-first with signature hints, and its Name Box jumps to an address or a defined name; `createNames` defines them. Demo `453` | ✓ |\n| ~~Per-cell number formats and styles~~ | **shipped** - an Excel format-string compiler (`compileNumberFormat`) plus a store keyed on row id so formatting survives a sort, wired to Ctrl+1, Ctrl+Shift+1..6 and Ctrl+B/I/U. Demo `453` | ✓ |\n| ~~AutoSum~~ | **shipped** - Alt+= over the run Excel would guess | ✓ |\n| ~~Reference highlighting~~ | **shipped** - the sheet shell outlines every cell a formula in progress refers to, each in its own colour, whether the formula is typed in the cell or in the bar | ✓ |\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F; demo `87-find-in-grid` | ✓ |\n| ~~Excel keyboard shortcuts~~ | **shipped** - `enableSheet()` binds Ctrl+Arrow (a run-boundary search, so it hops gaps), Ctrl+Shift+Arrow, Ctrl+A region-then-sheet, Ctrl+Space / Shift+Space, Ctrl+D / Ctrl+R, Ctrl+; and Ctrl+'; demo `452-excel-shortcuts`. Bind your own through the free `registerGridShortcuts` seam | ✓ |\n| ~~Reference-aware fill~~ | **shipped** - `enableSheet()` wires `translateFormula` into Ctrl+D / Ctrl+R, so a filled formula shifts its relative refs and keeps its pinned ones | ✓ |\n| ~~Paste Special~~ | **shipped** - values / formulas / formats / transpose / add-subtract-multiply-divide / skip-blanks, plus a `text/html` clipboard flavour so formats and formulas survive a round trip through Excel. `Ctrl+Shift+V` | ✓ |\n| ~~Find and Replace~~ | **shipped** - match case, whole cell, look in values or formulas, scope to the selection, Replace All as one undo. `Ctrl+H` | ✓ |\n| ~~Text to Columns, Remove Duplicates~~ | **shipped** - `textToColumns` / `splitText` / `guessDelimiter` split a column, quoted fields and doubled quotes included; `findDuplicates` / `removeDuplicates` report before they remove, and match case-insensitively as Excel does | done |\n| ~~Open and save spreadsheet files~~ | **shipped** - the shell's File tab opens an .xlsx, an .ods, an .xls or a .csv into the document (decided by the bytes, not the file name) and saves the document back as any of the first three, formulas, formats, sizes, hidden lines and sheets, panes, merges, validation, conditional formatting, protection, comments and names included as far as each format carries them; `documentToXlsx` / `documentFromXlsx`, `documentToOds` / `documentFromOds`, `documentToXls` / `documentFromXls` and `documentFromFile` in `@svgrid/enterprise/sheet` | done |\n| ~~Multi-sheet workbook~~ | **shipped** - `createWorkbook` holds named sheets that read each other, with the dependency graph and structural edits spanning them; `<SvSheetTabs>` plus Ctrl+PageUp/PageDown and Shift+F11 | done |\n| ~~Goal Seek~~ | **shipped** - `goalSeekCell` solves for the input that makes a formula hit a target, secant with a bisection fallback, restoring the input cell so a dialog can ask before applying | done |\n| ~~Tables with structured references~~ | **shipped** - `Orders[Amount]`, `[@Amount]`, `[#Headers]` / `[#Totals]` / `[#All]`, column spans and auto-expansion. Resolved at evaluation, so a total keeps covering rows added after it was typed | done |\n| ~~Rewriting formulas on a sheet RENAME~~ | **shipped** - `renameSheet` rewrites every qualified reference and defined name through the parser, so a string literal that happens to hold the old name is untouched and a new name with a space comes out quoted | ✓ |\n| ~~Insert / delete with reference fixup~~ | **shipped** - `Ctrl+Shift+Plus` / `Ctrl+Minus` rewrite every formula and named range through `fixupReferences` | ✓ |\n| ~~Freeze panes: the ROW half~~ | **shipped** - `frozenRows={N}` on the grid keeps the first N rows under the header, the grid's own rows still, with the virtual window skipping them; `applyFreeze` / `freezeAtActiveCell` set both halves through the api and the shell keeps the panes per sheet | ✓ |\n| ~~`Alt+Enter`, `F4`~~ | **shipped** - Alt+Enter is a line break in the grid's multiline text editor and turns on Wrap Text in the sheet, whose rows grow to fit; F4 turns the reference at the caret through its anchorings, in the cell and in the formula bar | ✓ |\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | ✓ |\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | ✓ |\n| ~~Drag a selected range to move / copy it~~ | **shipped** - `moveCells`, on by default with cell selection; demo `429-move-cells` | ✓ |\n| ~~Render inside a shadow root~~ | **shipped** - `<sv-grid-shadow>` in `@svgrid/grid-wc`. Open roots only; closed is unsupported, because a null `shadowRoot` leaves you unable to style, query or test your own grid | ✓ |\n| Eight draggable resize handles on a selection | **declined** - shift-click, shift-arrow, pointer drag and Ctrl+drag already resize a range four ways. Eight more hit targets per range in the hottest render path buys an interaction users already have. | - |\n\n## Export / Print\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Excel / xlsx, PDF, styled-HTML export, Print~~ | **shipped** in `@svgrid/enterprise` (CSV / TSV / JSON export is free in `@svgrid/grid`) - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | ✓ |\n\n## Filtering\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | ✓ |\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | ✓ |\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | ✓ |\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | ✓ |\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | ✓ |\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | ✓ |\n\n## Editing\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | ✓ |\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | ✓ |\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | ✓ |\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | ✓ |\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | ✓ |\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | ✓ |\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | ✓ |\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | ✓ |\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | ✓ |\n| ~~Async / server-loaded editor option lists~~ | **shipped** - `editorOptions` may return a Promise (per column or per row), with a loading state, caching and `api.refreshEditorOptions()`; demo `428-async-editor-options` | ✓ |\n\n## The real remaining gaps (short list)\n\nThe previous round shipped declarative col/row spanning, cell flash,\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\nparity items, mostly UX affordances on top of engines that already exist:\n\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\nsparklines, collaboration). The genuine remaining gaps:\n\n| Gap | What exists today | Effort |\n| --- | ----------------- | ------ |\n| ~~**Multiple range selection** (Ctrl-drag additional cell ranges)~~ | **shipped** - Ctrl/Cmd+drag adds ranges; all highlight + copy together; `api.selectCells([...])` takes many; demo `118` | ✓ |\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | ✓ |\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | ✓ |\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | ✓ |\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | ✓ |\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | ✓ |\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | ✓ |\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | ✓ |\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | ✓ |\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | ✓ |\n\n### Still open (medium / large)\n\n| Gap | Note | Effort |\n| --- | ---- | ------ |\n| ~~**Multi Filter** (two conditions on one column)~~ | **shipped** - a column filter takes a second condition joined by AND / OR, in the menu and via `api.setFilter` | ✓ |\n| **Custom filter / floating-filter component** slot | first-class pluggable filter. Needs a MODEL seam as well as a render one: menu filters compile through `compileExcelFilter` from the closed `FilterOperator` union, so there is nowhere for a consumer predicate to enter today | L |\n| **Custom tool panels** | panel is fixed Columns + Filters (`toolPanelDefaultTab` is a `\"columns\" \\| \"filters\"` union, with no registry) | M |\n| ~~**UI-string localisation**~~ | **shipped** - the `localization` prop takes `{ locale, text }`, where `text` is a partial `GridMessages`; every menu/panel/chrome string is overridable. Note there is no `localeText` prop, and no translation catalogues ship in the box - you supply the strings. See [i18n and RTL](./i18n-rtl.md) | ✓ |\n| ~~**Row-grouping display modes** + group-level footers~~ | **shipped** - `groupDisplayMode: 'groupRows' \\| 'singleColumn' \\| 'multipleColumns'` plus `groupFooters` and `grandTotalRow`; demo `427-group-footers` | ✓ |\n| ~~**In-grid pivot mode** (toggle on the main grid)~~ | **shipped** - `enablePivot()` registers the engine and the main grid pivots in place | ✓ |\n| ~~**Integrated-chart depth** (chart toolbar, cross-filtering)~~ | **shipped** - `crossFilter` config plus `applyChartCrossFilter` / `clearChartCrossFilter`, wired from chart selection in `SvGridChartPanel`, with a Clear filter button; the panel toolbar has chart-type switching, export, AI, add-chart, tabs, maximize, dock and pop out. The picker now reaches all 15 types (it offered 4), gated on whether the current columns can feed each one, and `--sg-chart-*` tokens let a theme recolour the series | ✓ |\n| ~~**Chart depth program** (axis model, 13 more series types, interaction, financial toolkit, builder)~~ | **shipped** 12 Sep 2026, all free in `@svgrid/grid` - per-axis config with a numeric x axis and reference bands; histogram, range, lollipop, dumbbell, pareto, stream, sunburst, radial, nightingale, chord and bullet; wheel / pinch / pan zoom, range presets, `syncGroup`, a context menu, animation, drilldown, point selection, LTTB decimation; eight indicators with `SvChartPanes`, `rowsToOhlcSpec` + resampling, a last-price line, flags, drawing tools; PDF and print; the in-panel builder with a type gallery, Format tab and link / unlink; `<sv-chart>` with React / Vue / Angular wrappers. Demos 434 to 437 | ✓ |\n| ~~**Server-side pivot**~~ | **shipped** (Enterprise) - `createServerRowModel` sends `pivotBy` + `pivotMode`, builds the columns from `pivotResultFields`, and `SvPivotDesigner` drives it in server mode; see [Server pivot](./server/server-pivot.md) | ✓ |\n| **Viewport row model** (the server pushes the visible window over a socket) | not built; [real-time](./real-time.md) merges deltas into loaded rows instead, and a socket can push a whole level through `applyRowData` | M |\n\n## What's already there\n\nThe stable, built-in feature surface is large. Highlights: sorting (single +\nmulti), per-column filtering (menu + row + global) with a `between` range\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\ndata, master/detail + full-width detail rows, row + column virtualization\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\nhandle + drag-the-border move/copy, inline editing with 14 editor types plus a custom `cellEditor` slot,\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\nrow pinning, a formula engine (+ HyperFormula adapter), a server-side row model (flat paging and infinite scroll free; grouping, tree, pivot, transactions and selection across unloaded rows in Enterprise),\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\nCSP-clean runtime.\n\n## How to contribute\n\n1. Pick a gap from **The real remaining gaps** above.\n2. Open an issue describing the API you'd want - names, types, the minimal change.\n3. If you can write the patch, do so, and keep tests with the change.\n"
|
|
5151
|
+
"markdown": "# Missing features\n\nAn honest accounting of what is **not yet built**, audited against the shipped\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\n\nShipped items are struck through with the demo or API that covers them, so you\ncan see both the trajectory and the (short) list of real gaps.\n\n## Columns\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`getRowId` prop~~ | **shipped** | ✓ |\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | ✓ |\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | ✓ |\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | ✓ |\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | ✓ |\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | ✓ |\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | ✓ |\n\n## Rows\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | ✓ |\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | ✓ |\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | ✓ |\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | ✓ |\n| ~~Auto row height (measure content)~~ | **shipped** - `autoRowHeight` wraps cell text and measures each row, virtualization included | ✓ |\n| ~~`api.getDisplayedRows()`~~ | **shipped** | ✓ |\n| ~~Client-side tree data (hierarchical rows)~~ | **shipped** - `treeData` nests by parent id, `flattenTreeData` converts nested children; treegrid role + arrow-key expand; demo `426-tree-data` | ✓ |\n| ~~Built-in row dragging~~ | **shipped** - `rowDragManaged` reorders in-grid and moves rows **grid-to-grid** via a shared `rowDragGroup`; `onRowDragEnd` on the receiver; demos `105-row-reorder` (custom) + `180-row-dragging` (managed) | ✓ |\n\n## Cells\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | ✓ |\n| ~~Formula language~~ | **shipped** - a real module in `@svgrid/enterprise/sheet`: parser, evaluator, the common functions plus financial, math, statistics, text and date packs (the [formulas page](./spreadsheet-formulas.md) lists them), cross-sheet and whole-column refs, short-circuiting `IF` / `IFERROR`, a dependency graph for incremental recalc, and `translateFormula` / `fixupReferences`. Absolute refs are now absolute (the demo copies stripped `$`, so anything filled or copied was quietly wrong) | ✓ |\n| ~~Formula editor (bar, autocomplete, Name Box)~~ | **shipped** - `<SvFormulaBar>` shows the raw text behind the active cell, autocompletes function names closest-match-first with signature hints, and its Name Box jumps to an address or a defined name; `createNames` defines them. Demo `453` | ✓ |\n| ~~Per-cell number formats and styles~~ | **shipped** - an Excel format-string compiler (`compileNumberFormat`) plus a store keyed on row id so formatting survives a sort, wired to Ctrl+1, Ctrl+Shift+1..6 and Ctrl+B/I/U. Demo `453` | ✓ |\n| ~~AutoSum~~ | **shipped** - Alt+= over the run Excel would guess | ✓ |\n| ~~Reference highlighting~~ | **shipped** - the sheet shell outlines every cell a formula in progress refers to, each in its own colour, whether the formula is typed in the cell or in the bar | ✓ |\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F marks every match (the current one more strongly) and matches what a cell shows as well as its value; demo `87-find-in-grid` | ✓ |\n| ~~Excel keyboard shortcuts~~ | **shipped** - `enableSheet()` binds Ctrl+Arrow (a run-boundary search, so it hops gaps), Ctrl+Shift+Arrow, Ctrl+A region-then-sheet, Ctrl+Space / Shift+Space, Ctrl+D / Ctrl+R, Ctrl+; and Ctrl+'; demo `452-excel-shortcuts`. Bind your own through the free `registerGridShortcuts` seam | ✓ |\n| ~~Reference-aware fill~~ | **shipped** - `enableSheet()` wires `translateFormula` into Ctrl+D / Ctrl+R, so a filled formula shifts its relative refs and keeps its pinned ones | ✓ |\n| ~~Paste Special~~ | **shipped** - values / formulas / formats / transpose / add-subtract-multiply-divide / skip-blanks, plus a `text/html` clipboard flavour so formats and formulas survive a round trip through Excel. `Ctrl+Shift+V` | ✓ |\n| ~~Find and Replace~~ | **shipped** - match case, whole cell, look in values or formulas, scope to the selection, Replace All as one undo. `Ctrl+H` | ✓ |\n| ~~Text to Columns, Remove Duplicates~~ | **shipped** - `textToColumns` / `splitText` / `guessDelimiter` split a column, quoted fields and doubled quotes included; `findDuplicates` / `removeDuplicates` report before they remove, and match case-insensitively as Excel does | done |\n| ~~Open and save spreadsheet files~~ | **shipped** - the shell's File tab opens an .xlsx, an .ods, an .xls or a .csv into the document (decided by the bytes, not the file name) and saves the document back as any of the first three, formulas, formats, sizes, hidden lines and sheets, panes, merges, validation, conditional formatting, protection, comments and names included as far as each format carries them; `documentToXlsx` / `documentFromXlsx`, `documentToOds` / `documentFromOds`, `documentToXls` / `documentFromXls` and `documentFromFile` in `@svgrid/enterprise/sheet` | done |\n| ~~Multi-sheet workbook~~ | **shipped** - `createWorkbook` holds named sheets that read each other, with the dependency graph and structural edits spanning them; `<SvSheetTabs>` plus Ctrl+PageUp/PageDown and Shift+F11 | done |\n| ~~Goal Seek~~ | **shipped** - `goalSeekCell` solves for the input that makes a formula hit a target, secant with a bisection fallback, restoring the input cell so a dialog can ask before applying | done |\n| ~~Tables with structured references~~ | **shipped** - `Orders[Amount]`, `[@Amount]`, `[#Headers]` / `[#Totals]` / `[#All]`, column spans and auto-expansion. Resolved at evaluation, so a total keeps covering rows added after it was typed | done |\n| ~~Rewriting formulas on a sheet RENAME~~ | **shipped** - `renameSheet` rewrites every qualified reference and defined name through the parser, so a string literal that happens to hold the old name is untouched and a new name with a space comes out quoted | ✓ |\n| ~~Insert / delete with reference fixup~~ | **shipped** - `Ctrl+Shift+Plus` / `Ctrl+Minus` rewrite every formula and named range through `fixupReferences` | ✓ |\n| ~~Freeze panes: the ROW half~~ | **shipped** - `frozenRows={N}` on the grid keeps the first N rows under the header, the grid's own rows still, with the virtual window skipping them; `applyFreeze` / `freezeAtActiveCell` set both halves through the api and the shell keeps the panes per sheet | ✓ |\n| ~~`Alt+Enter`, `F4`~~ | **shipped** - Alt+Enter is a line break in the grid's multiline text editor and turns on Wrap Text in the sheet, whose rows grow to fit; F4 turns the reference at the caret through its anchorings, in the cell and in the formula bar | ✓ |\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | ✓ |\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | ✓ |\n| ~~Drag a selected range to move / copy it~~ | **shipped** - `moveCells`, on by default with cell selection; demo `429-move-cells` | ✓ |\n| ~~Render inside a shadow root~~ | **shipped** - `<sv-grid-shadow>` in `@svgrid/grid-wc`. Open roots only; closed is unsupported, because a null `shadowRoot` leaves you unable to style, query or test your own grid | ✓ |\n| Eight draggable resize handles on a selection | **declined** - shift-click, shift-arrow, pointer drag and Ctrl+drag already resize a range four ways. Eight more hit targets per range in the hottest render path buys an interaction users already have. | - |\n\n## Export / Print\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Excel / xlsx, PDF, styled-HTML export, Print~~ | **shipped** in `@svgrid/enterprise` (CSV / TSV / JSON export is free in `@svgrid/grid`) - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | ✓ |\n\n## Filtering\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | ✓ |\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | ✓ |\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | ✓ |\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | ✓ |\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | ✓ |\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | ✓ |\n\n## Editing\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | ✓ |\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | ✓ |\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | ✓ |\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | ✓ |\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | ✓ |\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | ✓ |\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | ✓ |\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | ✓ |\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | ✓ |\n| ~~Async / server-loaded editor option lists~~ | **shipped** - `editorOptions` may return a Promise (per column or per row), with a loading state, caching and `api.refreshEditorOptions()`; demo `428-async-editor-options` | ✓ |\n\n## The real remaining gaps (short list)\n\nThe previous round shipped declarative col/row spanning, cell flash,\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\nparity items, mostly UX affordances on top of engines that already exist:\n\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\nsparklines, collaboration). The genuine remaining gaps:\n\n| Gap | What exists today | Effort |\n| --- | ----------------- | ------ |\n| ~~**Multiple range selection** (Ctrl-drag additional cell ranges)~~ | **shipped** - Ctrl/Cmd+drag adds ranges; all highlight + copy together; `api.selectCells([...])` takes many; demo `118` | ✓ |\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | ✓ |\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | ✓ |\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | ✓ |\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | ✓ |\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | ✓ |\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | ✓ |\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | ✓ |\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | ✓ |\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | ✓ |\n\n### Still open (medium / large)\n\n| Gap | Note | Effort |\n| --- | ---- | ------ |\n| ~~**Multi Filter** (two conditions on one column)~~ | **shipped** - a column filter takes a second condition joined by AND / OR, in the menu and via `api.setFilter` | ✓ |\n| **Custom filter / floating-filter component** slot | first-class pluggable filter. Needs a MODEL seam as well as a render one: menu filters compile through `compileExcelFilter` from the closed `FilterOperator` union, so there is nowhere for a consumer predicate to enter today | L |\n| **Custom tool panels** | panel is fixed Columns + Filters (`toolPanelDefaultTab` is a `\"columns\" \\| \"filters\"` union, with no registry) | M |\n| ~~**UI-string localisation**~~ | **shipped** - the `localization` prop takes `{ locale, text }`, where `text` is a partial `GridMessages`; every menu/panel/chrome string is overridable. Note there is no `localeText` prop, and no translation catalogues ship in the box - you supply the strings. See [i18n and RTL](./i18n-rtl.md) | ✓ |\n| ~~**Row-grouping display modes** + group-level footers~~ | **shipped** - `groupDisplayMode: 'groupRows' \\| 'singleColumn' \\| 'multipleColumns'` plus `groupFooters` and `grandTotalRow`; demo `427-group-footers` | ✓ |\n| ~~**In-grid pivot mode** (toggle on the main grid)~~ | **shipped** - `enablePivot()` registers the engine and the main grid pivots in place | ✓ |\n| ~~**Integrated-chart depth** (chart toolbar, cross-filtering)~~ | **shipped** - `crossFilter` config plus `applyChartCrossFilter` / `clearChartCrossFilter`, wired from chart selection in `SvGridChartPanel`, with a Clear filter button; the panel toolbar has chart-type switching, export, AI, add-chart, tabs, maximize, dock and pop out. The picker now reaches all 15 types (it offered 4), gated on whether the current columns can feed each one, and `--sg-chart-*` tokens let a theme recolour the series | ✓ |\n| ~~**Chart depth program** (axis model, 13 more series types, interaction, financial toolkit, builder)~~ | **shipped** 12 Sep 2026, all free in `@svgrid/grid` - per-axis config with a numeric x axis and reference bands; histogram, range, lollipop, dumbbell, pareto, stream, sunburst, radial, nightingale, chord and bullet; wheel / pinch / pan zoom, range presets, `syncGroup`, a context menu, animation, drilldown, point selection, LTTB decimation; eight indicators with `SvChartPanes`, `rowsToOhlcSpec` + resampling, a last-price line, flags, drawing tools; PDF and print; the in-panel builder with a type gallery, Format tab and link / unlink; `<sv-chart>` with React / Vue / Angular wrappers. Demos 434 to 437 | ✓ |\n| ~~**Server-side pivot**~~ | **shipped** (Enterprise) - `createServerRowModel` sends `pivotBy` + `pivotMode`, builds the columns from `pivotResultFields`, and `SvPivotDesigner` drives it in server mode; see [Server pivot](./server/server-pivot.md) | ✓ |\n| **Viewport row model** (the server pushes the visible window over a socket) | not built; [real-time](./real-time.md) merges deltas into loaded rows instead, and a socket can push a whole level through `applyRowData` | M |\n\n## What's already there\n\nThe stable, built-in feature surface is large. Highlights: sorting (single +\nmulti), per-column filtering (menu + row + global) with a `between` range\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\ndata, master/detail + full-width detail rows, row + column virtualization\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\nhandle + drag-the-border move/copy, inline editing with 14 editor types plus a custom `cellEditor` slot,\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\nrow pinning, a formula engine (+ HyperFormula adapter), a server-side row model (flat paging and infinite scroll free; grouping, tree, pivot, transactions and selection across unloaded rows in Enterprise),\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\nCSP-clean runtime.\n\n## How to contribute\n\n1. Pick a gap from **The real remaining gaps** above.\n2. Open an issue describing the API you'd want - names, types, the minimal change.\n3. If you can write the patch, do so, and keep tests with the change.\n"
|
|
4858
5152
|
},
|
|
4859
5153
|
{
|
|
4860
5154
|
"slug": "help/mobile-card-view",
|
|
@@ -4919,6 +5213,13 @@ export const docs = [
|
|
|
4919
5213
|
"section": "Rows",
|
|
4920
5214
|
"markdown": "# Full-width rows\n\n\"Full-width rows\" are rows whose content takes the entire grid width\ninstead of being cell-by-cell - useful for inline editors, banner ads,\nsection dividers, and detail-row expansions.\n<div data-docs-demo=\"08-tree-and-master-detail\" data-height=\"540\"></div>\n\n## Detail-row API (shipped)\n\nFull-width rows are built in via `isDetailRow` + `renderDetailRow`: mark a row as\na detail row and it renders as a real full-width `colspan` cell hosting your\nsnippet - a nested grid, a form, timelines, anything.\n\n```svelte\n<SvGrid\n {data} {columns}\n isDetailRow={(row) => row.kind === 'detail'}\n renderDetailRow={DetailPanel}\n/>\n```\n\nSee [Master / detail (nested grids)](./master-detail.md) for the full\nexpand/collapse pattern, and demo `106-detail-rows` for a multi-panel detail.\n\nA single sticky footer row is also available via `summary` (off by default):\n\n```svelte\n<SvGrid {data} {columns} summary />\n```\n\nIt sums a numeric column and counts the rest. Pick a different aggregate per\ncolumn with that column's own `summary` option (`'avg'`, `'min'`, `'max'`,\n`'count'`, a custom function), or `summary: false` to leave the cell blank.\n\n\n## A detail row under its parent\n\nMark the row, render the panel. `isDetailRow` answers \"is this one of them\",\n`renderDetailRow` is a snippet that gets the row and its index, and the grid\ngives it a real `colspan` cell across every column.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n // One flat type, not a union: `field` is `keyof TData`, and `keyof` over a\n // union keeps only the shared keys, so `field: 'customer'` would not type.\n type Row = {\n kind: 'order' | 'detail'\n id: string\n customer?: string\n total?: number\n lines?: string[]\n }\n\n const rows: Row[] = [\n { kind: 'order', id: 'A-1', customer: 'Ada Lovelace', total: 240 },\n { kind: 'detail', id: 'A-1d', lines: ['2 x Cycling cap', '1 x Road bottle'] },\n { kind: 'order', id: 'A-2', customer: 'Grace Hopper', total: 96 },\n { kind: 'detail', id: 'A-2d', lines: ['3 x Patch kit'] },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'id', header: 'Order', width: 110 },\n { field: 'customer', header: 'Customer', width: 190 },\n { field: 'total', header: 'Total', width: 120,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n{#snippet DetailPanel(props: { row: Row; rowIndex: number })}\n <div style=\"padding: 8px 14px; font-size: 12px; opacity: 0.85;\">\n {#each props.row.lines ?? [] as line}\n <div>{line}</div>\n {/each}\n </div>\n{/snippet}\n\n<SvGrid\n data={rows}\n {columns}\n rowHeight={40}\n isDetailRow={(row) => row.kind === 'detail'}\n renderDetailRow={DetailPanel}\n/>\n```\n\n## Section dividers are the same mechanism\n\nNothing says a full-width row has to be a detail panel. Interleave marker\nrows into the data and the same two props give you group headers, banners, or an\nend-of-list note - no extra API, and sorting still applies to the real rows if\nyou turn it on for the columns that carry values.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Row = {\n kind: 'person' | 'divider'\n id: number\n name?: string\n city?: string\n label?: string\n }\n\n const rows: Row[] = [\n { kind: 'divider', id: 100, label: 'Engineering' },\n { kind: 'person', id: 1, name: 'Ada Lovelace', city: 'London' },\n { kind: 'person', id: 2, name: 'Grace Hopper', city: 'New York' },\n { kind: 'divider', id: 101, label: 'Platform' },\n { kind: 'person', id: 3, name: 'Linus Torvalds', city: 'Portland' },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'city', header: 'City', width: 170 },\n ]\n</script>\n\n{#snippet Divider(props: { row: Row; rowIndex: number })}\n <div style=\"padding: 6px 14px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.65;\">\n {props.row.label}\n </div>\n{/snippet}\n\n<SvGrid\n data={rows}\n {columns}\n rowHeight={32}\n isDetailRow={(row) => row.kind === 'divider'}\n renderDetailRow={Divider}\n/>\n```\n\n## See also\n\n- [Master / detail (nested grids)](./master-detail.md)\n- [Grouping](./row-data.md)\n"
|
|
4921
5215
|
},
|
|
5216
|
+
{
|
|
5217
|
+
"slug": "help/rows/gallery",
|
|
5218
|
+
"path": "docs/help/rows/gallery.md",
|
|
5219
|
+
"title": "Gallery mode",
|
|
5220
|
+
"section": "Rows",
|
|
5221
|
+
"markdown": "# Gallery mode\n\nSet the `gallery` prop and the grid renders its rows as **cards** instead of a\ntable: a cover image, a title and a few fields per row, in a wall that fills\nthe width. It is the same `<SvGrid>`, the same `data` and `columns`; the cards\nare the rows the table would show, so the search box, column filters and the\nsort carry over, and turning the prop off brings the table back in the same\norder.\n\n<div data-docs-demo=\"505-gallery-catalog\" data-height=\"640\"></div>\n\nThe gallery *renderer* ships in `@svgrid/enterprise`, so `enableGalleryView()`\n(or `installEnterprise(api)`) is what lights it up. The `gallery` prop and the\n`GalleryConfig` type are part of the free grid; without the renderer the grid\nshows a short note where the cards would be.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import { enableGalleryView } from '@svgrid/enterprise'\n\n enableGalleryView()\n\n type Product = { id: number; name: string; image: string; category: string; price: number }\n let { data }: { data: Product[] } = $props()\n\n const columns: GridColumns<Product> = [\n { field: 'name', header: 'Product' },\n { field: 'category', header: 'Category' },\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid {data} {columns} getRowId={(r) => String(r.id)}\n gallery={{ coverField: 'image', titleField: 'name', cardFields: ['category', 'price'] }} />\n```\n\n## What a card shows\n\n| Option | What it does |\n|---|---|\n| `coverField` | The field holding the cover: an image URL (`https:`, `blob:` or a `data:image/...` URL), or a list of files (`{ url, type, name, thumbnailUrl }`), whose first image is used. Omit it for cards without a cover. |\n| `coverFit` | `'cover'` (default) crops the image to fill the cover's box; `'contain'` shows all of it. |\n| `titleField` | The card's title. Defaults to the first column's field. |\n| `cardFields` | The fields under the title, in this order. Defaults to the next columns, up to six, leaving out the title and the cover. |\n| `showLabels` | Each value under its field's name (default `true`). |\n| `cardSize` | `'small'` (180px), `'medium'` (240px, default), `'large'` (320px) or a width in pixels. The wall fits as many columns as the width holds. |\n| `emptyText` | What the wall says when no row is shown (default \"No records\"). |\n| `searchable`, `searchPlaceholder` | The search box above the cards (on by default); it is the grid's quick filter. |\n| `cellGrid` | `(width) => grid`: what a field type's cell on a card gets as its grid. A base's gallery view sets it to the grid itself at the card's width, with editing off; without it a cell gets an object with `getColumnWidth` only. |\n\nA value is written the way the table cell writes it: through the column's\n`formatter`, else its `format` config (currency, percent, dates), else as\ntext. A column declared by field type (`@svgrid/enterprise/fields`) draws on\nthe card with its own cell: a select as its colored pill, a rating as stars, a\nperson with an avatar, and the field's type icon sits beside its name.\n\n<div data-docs-demo=\"508-gallery-field-types\" data-height=\"620\"></div>\n\n## Clicks\n\n`onCardClick(row)` is called when a card is clicked, or when its title gets\nEnter or Space from the keyboard (the title is the card's button). Without\nit the cards are not clickable. Values on a card are read, not edited.\n\n```ts\nconst gallery = { coverField: 'image', onCardClick: (row: Product) => (picked = row) }\n```\n\n## Your own card\n\n`card` is a snippet that receives each row; the grid lays the cards out and\nyou draw what is inside.\n\n```svelte\n{#snippet profile(p: Person)}\n <article class=\"profile\">\n <img src={p.photo} alt=\"\" width=\"72\" height=\"72\" />\n <strong>{p.name}</strong>\n <span>{p.role}</span>\n </article>\n{/snippet}\n\n<SvGrid {data} {columns} gallery={{ card: profile, cardSize: 220 }} />\n```\n\n<div data-docs-demo=\"506-gallery-team-directory\" data-height=\"620\"></div>\n\n## Filters, sort and search\n\nThe cards are the grid's filtered and sorted rows, so everything that shapes\nthe table shapes the wall: `api.setFilter`, `api.setSort`, the quick filter,\nan advanced filter. A new filter or search starts the wall from the top.\n\n<div data-docs-demo=\"507-gallery-listings\" data-height=\"640\"></div>\n\n## Many rows\n\nThe wall draws its cards in batches: the first 120, then the next 120 a\nlittle before the end comes into view. A gallery over thousands of rows costs\nabout the same to open as a small one. A cover can come from a computed\ncolumn (`fieldFn`), so the image need not be stored in the rows.\n\n<div data-docs-demo=\"509-gallery-at-scale\" data-height=\"620\"></div>\n\n## In a table with views\n\nThe [fields module](../fields.md)'s gallery view is this gallery, set up from\nthe view: the cover field, the primary field as the title, the fields the\ngrid shows as the card's fields, and a click that opens the record.\n"
|
|
5222
|
+
},
|
|
4922
5223
|
{
|
|
4923
5224
|
"slug": "help/rows/gantt",
|
|
4924
5225
|
"path": "docs/help/rows/gantt.md",
|
|
@@ -4952,7 +5253,7 @@ export const docs = [
|
|
|
4952
5253
|
"path": "docs/help/rows/row-dragging.md",
|
|
4953
5254
|
"title": "Row dragging",
|
|
4954
5255
|
"section": "Rows",
|
|
4955
|
-
"markdown": "# Row dragging\n\nManaged row dragging lets users **reorder rows** within a grid and **move rows\nbetween grids**. Turn on `rowDragManaged` and every row
|
|
5256
|
+
"markdown": "# Row dragging\n\nManaged row dragging lets users **reorder rows** within a grid and **move rows\nbetween grids**. Turn on `rowDragManaged` and every row can be dragged by its\nrow-number cell (a grip shows while the pointer is over it); a glowing line\nshows where the row will land, and the grid splices its own data on drop.\n<div data-docs-demo=\"180-row-dragging\" data-height=\"560\"></div>\n\n## Reorder within one grid\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<SvGrid {data} {columns} showRowNumbers rowDragManaged />\n```\n\nDrag a row by its grip and drop it above or below another row. The grid mutates\nits internal data so the new order sticks - no wiring required.\n\n## Move rows between grids\n\nGive two (or more) grids the **same** `rowDragGroup`. A row dragged out of one\nand dropped into another is removed from the source and inserted into the\ntarget:\n\n```svelte\n<SvGrid data={backlog} {columns} showRowNumbers\n rowDragManaged rowDragGroup=\"sprint-board\" />\n\n<SvGrid data={sprint} {columns} showRowNumbers\n rowDragManaged rowDragGroup=\"sprint-board\" />\n```\n\nGrids with **different** groups (or no group) can only reorder within\nthemselves. Drop below the last row - or into an empty grid - to append.\n\n## External drop zones\n\nDrop a dragged row onto any element outside the grid - a trash can, an\n\"assign to\" bucket, a second pane - with the `rowDropZone` action. It accepts\nmanaged drags from a grid with the matching `rowDragGroup`, removes the row from\nits source grid, and hands it to your `onDrop`:\n\n```svelte\n<script>\n import { SvGrid, rowDropZone } from '@svgrid/grid'\n let archived = $state([])\n</script>\n\n<SvGrid {data} {columns} rowDragManaged rowDragGroup=\"tasks\" />\n\n<div use:rowDropZone={{ group: 'tasks', onDrop: (e) => (archived = [...archived, e.row]) }}>\n Drop here to archive\n</div>\n```\n\nThe element gets a `sv-grid-row-dropzone-over` class while a matching row hovers\nit (style it or override). Omit `group` to accept a managed drag from any grid.\n\n<div data-docs-demo=\"184-external-drop-zone\" data-height=\"420\"></div>\n\n## React to a move\n\n`onRowDragEnd` fires on the **receiving** grid after the drop settles. The grid\nhas already applied the change to its own data; use the event to mirror the move\ninto your own state (persistence, server sync):\n\n```svelte {runnable}\n<SvGrid\n {data} {columns}\n rowDragManaged rowDragGroup=\"sprint-board\"\n onRowDragEnd={(e) => {\n // e.row - the moved row (your data object)\n // e.toIndex - landing index in this grid\n // e.sameGrid - true = reorder, false = arrived from another grid\n // e.fromGridId / e.toGridId - stable numeric ids of source / target\n console.log(e.sameGrid ? 'reordered' : 'received', e.row, '→', e.toIndex)\n }}\n/>\n```\n\n## Touch\n\nRow dragging works on touch as well as with a mouse. It runs on pointer\nevents with a long-press to start, so an ordinary swipe still scrolls the\ngrid instead of picking a row up. Once a drag begins you get hit-testing\nagainst the row under the finger, autoscroll near the top and bottom edges,\nand the drop happening on lift.\n\n### What lifting your finger does\n\nA lift **commits** the drop to wherever the indicator was last showing, even\nif your finger has travelled outside the grid. Dragging to the bottom edge is\nhow you move a row to the end on a phone, and cancelling there would throw\naway a move the interface was actively promising.\n\nNote this differs from the desktop path, where dropping outside a target\ncancels. What does abort a touch drag is a real interruption - an incoming\ncall, the app switcher, a system edge swipe - which arrives as\n`pointercancel` and leaves the order untouched.\n\n### Automated coverage\n\n`tests/e2e/mobile/row-drag-touch.spec.ts` drives this with real touch input\nthrough Chromium's input pipeline (CDP `Input.dispatchTouchEvent`, not synthetic\nDOM events), so it exercises `touch-action` handling and compositor scrolling\nthat jsdom cannot. It covers:\n\n- a swipe past the slop scrolls instead of picking a row up,\n- a long press followed by a move reorders on lift,\n- lifting past the bottom edge commits to the last indicated row,\n- an interrupted gesture aborts and leaves the order unchanged,\n- a second finger mid-drag loses or duplicates no row.\n\nRun it with `pnpm test:e2e:mobile`.\n\n### Still needs a real device\n\nChromium with touch emulation is not iOS Safari. These are WebKit-on-hardware\nbehaviours no emulator reproduces, so they have to be checked by hand:\n\n| Check | Expected |\n| --- | --- |\n| Swipe over rows with momentum, then let it coast | The grid keeps scrolling. No row is picked up mid-fling. |\n| Scroll to the very top and keep pulling | Rubber-banding, and no drag starts out of the bounce. |\n| Drag near the left screen edge on iOS | Back-swipe navigation does not fire mid-drag. |\n| Long-press a row on iOS | No text-selection callout or magnifier appears. |\n| Drag with the URL bar collapsing or expanding | The drop indicator stays aligned with the row under the finger. |\n| Rotate the device mid-drag | The drag cancels cleanly or keeps tracking. It must not leave a stuck row. |\n| With VoiceOver or TalkBack on | The handle is reachable and announces itself; keyboard reorder still works. |\n\nIf any row fails, the detail belongs on [issue #66](https://github.com/sv-grid/sv-grid/issues/66)\nwith the device and OS version, because that is what the fix has to be tested\nagainst.\n## Notes\n\n- **Managed data.** On drop the grid updates its internal data directly, so\n dragging works out of the box. If you later replace the `data` prop with a new\n array (a \"Reset\" button), the grid re-syncs to it. Use `onRowDragEnd` to keep\n your own array in step if you need it as the source of truth.\n- **Row matching.** Rows are moved by object identity, so `getRowId` is not\n required - though setting it is still recommended for stable selection and\n keyed rendering.\n- **The handle.** With `showRowNumbers` the row-number cell is the handle:\n its grip shows while it is hovered, and a press in a data cell (a pinned\n one too) selects or edits as it does anywhere else. Without row numbers\n the whole row is the handle (grab cursor).\n\n## See also\n\n- [Updating data](./row-data.md)\n- [Transactions](./transactions.md)\n- [Row sorting](./row-sorting.md) - drag order is manual; sorting overrides it\n"
|
|
4956
5257
|
},
|
|
4957
5258
|
{
|
|
4958
5259
|
"slug": "help/rows/row-height",
|
|
@@ -4980,7 +5281,7 @@ export const docs = [
|
|
|
4980
5281
|
"path": "docs/help/rows/row-sorting.md",
|
|
4981
5282
|
"title": "Row sorting",
|
|
4982
5283
|
"section": "Rows",
|
|
4983
|
-
"markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto - blanks, then numbers numerically, then text (default for unknown types)\n// sortFns.number - numeric; blanks and non-numeric values ('n/a') first\n// sortFns.date - Date-parsed; blanks and invalid dates first\n```\n\nAll three sort blanks (`null`, `undefined`, `''`) first ascending and last\ndescending, and never return `NaN`, so a column with placeholder text still\nsorts into a consistent order.\n\nThe grid picks the comparator based on the column's `editorType`:\n\n| editorType | comparator |\n| ---------- | ---------- |\n| `'number'` | `sortFns.number` |\n| `'date'` \\| `'datetime'` | `sortFns.date` |\n| anything else | `sortFns.auto` |\n\nIf your column has a non-trivial type, set `editorType` even if you do not\nwant inline editing - it is what tells sort and filter how to behave.\n\n## Programmatic sort\n\n```ts\napi.setSort('age', 'desc') // sort by age descending\napi.setSort('age', null) // clear sort on this column\napi.clearSort() // clear all sort\n```\n\n`setSort` replaces any existing sort with a single clause. For\nmulti-column sort, the user clicks the second column header with\n**Shift** held (the in-grid affordance). A multi-sort setter on the API\nis tracked in [Missing features](../missing-features.md).\n\nTo observe sort changes from outside the grid:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onSortingChange={(next) => (sorting = next)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class - there is no `enableSorting: false` field. To\nprevent a column from being sortable, omit `rowSortingFeature` from\n`tableFeatures(...)`, or render a custom header via `header: () =>\nrenderSnippet(...)` that doesn't bind to the sort button.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, pair `externalSort={true}` with\n`onSortingChange` and round-trip the sort clauses through your\nfetcher. See the [`09-server-side` demo](../../../examples/src/demos/09-server-side.svelte).\n\n## Multi-column sort\n\nShift-click a second header and the sort becomes ordered: department first,\nthen salary within it. The order you click is the order applied, which is why\nthe header shows a position number once more than one is active.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'department', header: 'Department', width: 170 },\n { field: 'name', header: 'Name', width: 180 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable />\n```\n\n\n## Reading the sort model\n\n`onSortingChange` reports the model as an ordered array, which is exactly the\nshape you persist to a URL or hand to a server.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n let model = $state('[]')\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'city', header: 'City', width: 150 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid\n data={seed}\n {columns}\n sortable\n onSortingChange={(s) => (model = JSON.stringify(s))}\n/>\n\n<p>Sort model: <code>{model}</code></p>\n```\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
|
|
5284
|
+
"markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto - blanks, then numbers numerically, then text (default for unknown types)\n// sortFns.number - numeric; blanks and non-numeric values ('n/a') first\n// sortFns.date - Date-parsed; blanks and invalid dates first\n```\n\nAll three sort blanks (`null`, `undefined`, `''`) first ascending and last\ndescending, and never return `NaN`, so a column with placeholder text still\nsorts into a consistent order.\n\nThe grid picks the comparator based on the column's `editorType`:\n\n| editorType | comparator |\n| ---------- | ---------- |\n| `'number'` | `sortFns.number` |\n| `'date'` \\| `'datetime'` | `sortFns.date` |\n| anything else | `sortFns.auto` |\n\nIf your column has a non-trivial type, set `editorType` even if you do not\nwant inline editing - it is what tells sort and filter how to behave.\n\n### Sort by a rank\n\nA status column sorted as text puts \"Done\" before \"To do\". `sortValue` gives\nthe number to sort a value by instead, and grouped rows follow it too:\n\n```ts\nconst STATUS = ['To do', 'In progress', 'Done']\nconst columns = [\n { field: 'status', header: 'Status', sortValue: (v: unknown) => STATUS.indexOf(String(v)) },\n]\n```\n\nBlanks, and anything that is not a number, sort first ascending, as in a\nnumber column.\n\n## Programmatic sort\n\n```ts\napi.setSort('age', 'desc') // sort by age descending\napi.setSort('age', null) // clear sort on this column\napi.clearSort() // clear all sort\n```\n\n`setSort` replaces any existing sort with a single clause. For\nmulti-column sort, the user clicks the second column header with\n**Shift** held (the in-grid affordance). A multi-sort setter on the API\nis tracked in [Missing features](../missing-features.md).\n\nTo observe sort changes from outside the grid:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onSortingChange={(next) => (sorting = next)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class - there is no `enableSorting: false` field. To\nprevent a column from being sortable, omit `rowSortingFeature` from\n`tableFeatures(...)`, or render a custom header via `header: () =>\nrenderSnippet(...)` that doesn't bind to the sort button.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, pair `externalSort={true}` with\n`onSortingChange` and round-trip the sort clauses through your\nfetcher. See the [`09-server-side` demo](../../../examples/src/demos/09-server-side.svelte).\n\n## Multi-column sort\n\nShift-click a second header and the sort becomes ordered: department first,\nthen salary within it. The order you click is the order applied, which is why\nthe header shows a position number once more than one is active.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'department', header: 'Department', width: 170 },\n { field: 'name', header: 'Name', width: 180 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable />\n```\n\n\n## Reading the sort model\n\n`onSortingChange` reports the model as an ordered array, which is exactly the\nshape you persist to a URL or hand to a server.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n let model = $state('[]')\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'city', header: 'City', width: 150 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid\n data={seed}\n {columns}\n sortable\n onSortingChange={(s) => (model = JSON.stringify(s))}\n/>\n\n<p>Sort model: <code>{model}</code></p>\n```\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
|
|
4984
5285
|
},
|
|
4985
5286
|
{
|
|
4986
5287
|
"slug": "help/rows/row-spanning",
|
|
@@ -4994,14 +5295,14 @@ export const docs = [
|
|
|
4994
5295
|
"path": "docs/help/rows/scheduler.md",
|
|
4995
5296
|
"title": "Scheduler / calendar mode",
|
|
4996
5297
|
"section": "Scheduler",
|
|
4997
|
-
"markdown": "# Scheduler / calendar mode\n\nSet one `scheduler` prop and the grid renders its rows as **events on a\ncalendar** instead of a table. It is the same `<SvGrid>`, the same `data` and\n`columns` - only the presentation changes. Events are placed by time across\nfour views (Month, Week, Day, Agenda); dragging an event to a new day or time\ntells you to reassign its start/end on your own data.\n\nFor a calendar built up option by option, see [Build a booking calendar](../scheduler/booking-calendar.md); this page is the reference for everything the `scheduler` prop can say.\n\nLike [Kanban board mode](/help/rows/kanban-board), the scheduler is a pure\n**view of the grid**: it renders the grid's already filtered, sorted, and\nsearched rows and writes back only through callbacks - it never mutates your\ndata.\n\n> **Enterprise feature.** The `scheduler` prop and its config types are part of\n> the free grid, but the calendar *renderer* ships in `@svgrid/enterprise`.\n> Register it once and the view lights up:\n>\n> ```ts\n> import { setLicenseKey, enableSchedulerView } from '@svgrid/enterprise'\n> setLicenseKey('YOUR-KEY') // omit to evaluate, with the watermark and console message\n> enableSchedulerView()\n> ```\n>\n> `installEnterprise(api)` also calls `enableSchedulerView()` for you. Without\n> the renderer registered, a grid with a `scheduler` prop shows an upgrade note.\n\n<div data-docs-demo=\"363-scheduler-intro\" data-height=\"620\"></div>\n\nEvery example below runs against this setup. `enableSchedulerView()` registers\nthe enterprise renderer; the `scheduler` prop itself is part of the free grid.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type RecurrenceRule } from '@svgrid/grid'\n import { enableSchedulerView } from '@svgrid/enterprise'\n\n enableSchedulerView()\n\n // One row type wide enough for every example on this page - resources,\n // recurrence, per-event colour and the booking-rule samples all read from it.\n type Event = {\n id: number\n title: string\n start: string\n end: string\n allDay?: boolean\n color?: string\n staffColor?: string\n roleColor?: string\n room?: string\n machine?: string\n provider?: string\n team?: string\n assignees?: string[]\n repeat?: RecurrenceRule | RecurrenceRule[] | null\n exceptions?: unknown[]\n }\n\n // Anchored on today, so the calendar always opens with events in view.\n const at = (dayOffset: number, hour: number, mins = 0) => {\n const d = new Date()\n d.setDate(d.getDate() + dayOffset)\n d.setHours(hour, mins, 0, 0)\n const p = (n: number) => String(n).padStart(2, '0')\n return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`\n }\n\n let data = $state<Event[]>([\n { id: 1, title: 'Standup', start: at(0, 9), end: at(0, 9, 15), color: '#3b82f6', room: 'Aurora', team: 'Platform' },\n { id: 2, title: 'Design review', start: at(0, 11), end: at(0, 12), color: '#8b5cf6', room: 'Borealis', team: 'Design' },\n { id: 3, title: 'Team offsite', start: at(1, 0), end: at(3, 0), allDay: true, color: '#22c55e', team: 'Platform' },\n { id: 4, title: '1:1', start: at(2, 15), end: at(2, 15, 30), color: '#f59e0b', room: 'Aurora', team: 'Design' },\n ])\n\n const rows = data\n const iso = at\n const makeEvent = (start: Date, end: Date): Event => ({\n id: Math.max(0, ...data.map((e) => e.id)) + 1,\n title: \"New event\",\n start: at(0, start.getHours(), start.getMinutes()),\n end: at(0, end.getHours(), end.getMinutes()),\n })\n const rooms = [\n { id: \"Aurora\", label: \"Aurora\" },\n { id: \"Borealis\", label: \"Borealis\" },\n ]\n const resources = rooms\n const tasks = data\n let selected = $state<Event | null>(null)\n const initials = (name: string) =>\n name.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2).toUpperCase()\n\n const columns: GridColumns<Event> = [\n { field: 'title', header: 'Event', width: 220 },\n { field: 'start', header: 'Start', width: 170 },\n { field: 'end', header: 'End', width: 170 },\n ]\n</script>\n```\n\n## The minimum\n\nPoint `scheduler.startField` at the field that holds each event's start. That\nis the only required option. With an `endField` too, events get their real\nduration; without one they use `defaultDurationMin` (60 by default). The title\ndefaults to the first column's field.\n\n```svelte {runnable}\n<SvGrid {data} {columns} scheduler={{ startField: 'start', endField: 'end' }} />\n```\n\nStart and end values may be a `Date`, an epoch number, or a parseable/ISO\nstring - the same coercion the date pickers use.\n\n## Views\n\nThe toolbar offers **Month**, **Week**, **Day**, and **Agenda**. Restrict or\nreorder them with `views`, and pick the one shown first with `initialView`.\nOpen on a specific date with `initialDate` (defaults to today).\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n views: ['week', 'day', 'agenda'],\n initialView: 'week',\n weekStartsOn: 1, // Monday\n dayStartHour: 7, // time-grid band 07:00..20:00\n dayEndHour: 20,\n slotMinutes: 30, // snap granularity\n }} />\n```\n\n- **Month** - a fixed six-week grid. Multi-day events render as **one continuous\n bar** spanning the day columns they cover (split into a fresh segment on each\n week row they continue into), stacked into lanes so they never overlap; a\n per-day **\"+N more\"** collapses events beyond the visible lanes into a popover.\n- **Week / Day** - a time-grid with an hourly band spanning `dayStartHour`..\n `dayEndHour` (default the full `0..24` day; a full-day band opens scrolled to\n business hours rather than midnight). Overlapping events are laid out by the\n chosen [collision mode](#colliding-overlapping-events). **All-day and multi-day\n events** render in a separate **all-day row** at the top (in Week view as\n continuous spanning bars), never filling the hourly columns. Drag an event\n between the all-day row and the grid to flip it between all-day and timed, or\n drag a bar's **left / right edge** to extend or shorten it by whole days. A\n **current-time line** tracks the local time on today's column (`nowIndicator`,\n on by default); the timeline draws it as a vertical line at \"now\".\n- **Agenda** - a chronological list grouped by day. Its span is `agendaDays`\n (default 30).\n\n## Colliding (overlapping) events\n\nWhen events overlap in the time-grid, `collisionMode` decides how they're shown:\n\n- **`split`** (default) - every collision divides the column width evenly. Two\n overlaps → halves, three → thirds, N → `1/N`. All stay visible.\n- **`cap`** - show up to `maxColumns` columns (default 3); the events that don't\n fit collapse into a clickable **`+N more`** tile. Clicking it opens a popover\n listing those events; clicking one opens it. Best when a slot can pile up deep.\n- **`stack`** - overlapping events overlap with a horizontal offset and z-order\n instead of shrinking, so each stays wide and readable; hovering brings one to\n the front.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{ startField: 'start', endField: 'end', collisionMode: 'cap', maxColumns: 3 }} />\n```\n\nThe **Month** view uses the same idea vertically: a day cell shows its first few\nevents, then a **`+N more`** button that opens the day's full list in a popover.\n\n## Titles, colors\n\n`titleField` sets the event label; `colorField` gives each event an accent\ncolor (any CSS color), or set one `color` for all of them.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{ startField: 'start', endField: 'end', titleField: 'title', colorField: 'color' }} />\n```\n\nAdd **`secondaryColorField`** to encode a *second* dimension: it paints a strip\non the **left edge** of each event, distinct from the main fill. A shift roster,\nfor instance, can fill each event with the **person**'s color (matching the\nresource legend) while the left strip shows the **role**:\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n colorField: 'staffColor', // fill = person\n secondaryColorField: 'roleColor', // left strip = role\n }} />\n```\n\n## Drag to move, resize\n\nSet **`editable`** to turn on drag-and-drop and resizing in the time-grid (and\ndrag-to-another-day in Month). An editable event shows a grab cursor; hovering\nit reveals **resize grips at its top and bottom edges**. Drag the body to move\nit, the bottom grip to change the end, or the top grip to change the start. The\nscheduler computes the new time itself and applies it as an overlay, then fires\na notification so you can mirror it onto your row or persist it. Moves and\nresizes snap to `slotMinutes`, and dragging across columns (days or resources)\nalso reassigns the day / resource.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end', editable: true,\n onEventMove: (e) => { e.row.start = iso(e.start); e.row.end = iso(e.end) },\n onEventResize: (e) => { e.row.end = iso(e.end) },\n }} />\n```\n\nIn the **Month** view an editable event can be **moved** (drag the bar to\nanother day) and **resized** (hover to reveal left/right grips, then drag an edge\nacross days to change its start or end date, keeping the time-of-day) - the bar\ngrows/shrinks live as you drag, and the day under the cursor highlights. The\n**all-day spanning bars** in Week / Day view resize the same way: drag a bar's\nleft or right edge across day columns to change how many days it covers.\n\nA focused event can also be nudged with the keyboard: **arrow left/right** move\nit a day, **arrow up/down** a slot, and **Enter** opens its editor.\n\n<div data-docs-demo=\"382-scheduler-app-clinic\" data-height=\"700\"></div>\n\n## Resources (group by person / room)\n\nPoint `resourceField` at a field and **every** time-grid view groups by\nresource - the **Day** view shows one column per resource, and the **Week** view\ngroups each resource across all seven days (a spanning group-header row sits\nabove the columns; the grid scrolls horizontally when there are many). Pass an\nexplicit `resources` array to fix their order, titles, and colors, or let them\nderive from the data. Dragging an event across columns reports both the new time\nand the new resource (`e.toResource`).\n\nA **resource legend** appears in *every* view (Month and Agenda included): each\nchip is a colour key that toggles that resource's events on or off.\n\nSet **`groupByDate: true`** to flip the grouping to date-major (each day groups\nthe resources) instead of the default resource-major (each resource groups the\ndays) - the same choice as Smart's `groupByDate`.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n resourceField: 'room',\n resources: [\n { id: 'Aurora', title: 'Aurora (12)', color: '#4f46e5' },\n { id: 'Borealis', title: 'Borealis (6)', color: '#0891b2' },\n ],\n initialView: 'week', editable: true,\n // groupByDate: true, // day -> resources instead of resource -> days\n onEventMove: (e) => { /* e.toResource is the dropped-on room */ },\n }} />\n```\n\n<div data-docs-demo=\"383-scheduler-app-dispatch\" data-height=\"700\"></div>\n\n## Timeline views (resources as rows)\n\nThe `timeline*` views flip the layout **horizontal**: time runs left→right on a\nscrollable axis and **each resource is a row**, with events as horizontal bars\nlane-packed so overlaps stack. Add any of them to `views` (and pick one with\n`initialView`):\n\n- **`timelineDay`** - hour ticks across one day (clamped to the\n `dayStartHour..dayEndHour` band).\n- **`timelineWeek`** - day ticks across a week.\n- **`timelineMonth`** - day ticks across a month.\n- **`timelineYear`** - month ticks across a year (grouped into quarters).\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n resourceField: 'room', resources,\n views: ['timelineDay', 'timelineWeek', 'timelineMonth', 'timelineYear'],\n initialView: 'timelineWeek',\n editable: true,\n onEventMove: (e) => { /* e.toResource = the row it was dropped on */ },\n }} />\n```\n\nWithout a `resourceField` the timeline shows a single **All** row. When editable,\ndrag a bar **sideways** to re-time it, drag it to **another row** to reassign the\nresource, or drag an **edge** to resize (day-granular on the multi-day zooms,\n`timelineSlotMinutes` on the day zoom). Tune the layout with `resourceAreaWidth`\n(left gutter), `timelineLaneHeight`, and `timelineSlotMinutes`.\n\nThe same [`rangeSelectable` / `eventSelectable`](#selecting-cells--events) modes\nwork in the timeline: **click or drag empty space** to mark a range (a slot in\nDay, whole days in Week/Month, a month in Year), then **arrow keys** move the\nselected cell along the axis (Left/Right) and between resource rows (Up/Down),\n**Shift+arrows** extend it, and **Enter** creates the event (**Esc** cancels).\n**Ctrl/Cmd-click** bars to multi-select, **Delete** to remove.\n\nThe `timeline*` views cover any horizon. Short-range planning (Day / Week) and\nthe long-range **roadmap** (Month / Year) read best with data scaled to the\nzoom, so the demos split them:\n\n<div data-docs-demo=\"371-scheduler-timeline\" data-height=\"560\"></div>\n\n<div data-docs-demo=\"384-scheduler-app-roadmap\" data-height=\"700\"></div>\n\n## Recurring events\n\nA row can carry a `recurrenceField` holding a\n[`RecurrenceRule`](/help/ui-components/sv-calendar) (or an array of them). The\nscheduler renders one event per matching day in the visible window - reusing\nthe same pure recurrence engine behind `SvCalendar`. Each instance keeps the\nbase row's time-of-day and duration.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n recurrenceField: 'repeat',\n }} />\n\n<!-- a row -->\n{ title: 'Daily standup', start: '2026-07-06T09:30', end: '2026-07-06T09:45',\n repeat: { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } }\n```\n\nThe rule shape covers the patterns an enterprise calendar needs:\n\n```ts\n{ freq: 'daily', interval: 2 } // every other day\n{ freq: 'weekly', weekdays: [1, 3, 5] } // Mon / Wed / Fri\n{ freq: 'monthly', day: 1 } // the 1st\n{ freq: 'monthly', day: -1 } // the last day\n{ freq: 'monthly', weekdays: [2], weekOfMonth: 1 } // the first Tuesday\n{ freq: 'monthly', weekdays: [5], weekOfMonth: -1 } // the last Friday\n{ freq: 'yearly', month: 10, weekdays: [4], weekOfMonth: 4 } // 4th Thursday of Nov\n{ freq: 'weekly', weekdays: [1], count: 8, from: '2026-01-05' } // 8 times, then stop\n```\n\n- `weekOfMonth` (1..4 or `-1` for last) with a single `weekdays` entry gives a\n **positional** monthly / yearly rule (\"the 2nd Tuesday\", \"the last Friday\").\n- `day` may be **negative** to count from the end (`-1` = the last day).\n- End the series with an inclusive `until` **date** or a `count` of occurrences\n (counted from `from`, which the editor sets to the event's start).\n\n### Editing patterns\n\nWhenever the **`drawer`** is on, clicking **any** event opens a **recurrence\neditor** in the drawer - so you can turn a one-off into a recurring event (or drop\nits pattern) whenever you decide. You don't need a `recurrenceField`: without one\nthe rule is stored on a default `recurrence` field (set `recurrenceField` to name\nyour own). It offers a full, calendar-app-grade set of controls:\n\n- **Repeat** - Does not repeat / Daily / Weekly / Monthly / Yearly, with an\n **interval** (\"every N …\").\n- **Weekly** - a weekday chip picker.\n- **Monthly** - *on a day of the month*, *on a weekday of the month* (the\n first / second / … / last weekday), or *on the last day*.\n- **Yearly** - pick the **month**, then a specific date or a positional weekday.\n- **Ends** - *Never*, *on a date*, or *after N occurrences*.\n\nUse it to **add** a pattern to a one-off event, **change** an existing one, or\n**remove** it (set Repeat to \"Does not repeat\"). Saving writes the rule back\nthrough `onEventCommit`.\n\nRecurring events are also **drag- and resize-editable**: moving or resizing an\noccurrence edits the whole **series'** time-of-day and duration (the pattern's\ndays are kept), so all occurrences shift together.\n\n### This event vs. all events (per-occurrence exceptions)\n\nSet a **`recurrenceExceptionsField`** (an array on the row) and an\n**`onOccurrenceChange`** handler, and moving / resizing / deleting a single\noccurrence asks **This event** or **All events**. \"All events\" edits the series\nas above; **This event** fires `onOccurrenceChange` with an `exception` to store\non the row - a moved / retitled / **deleted** single instance that leaves the\nrest of the series untouched (like an iCal `RECURRENCE-ID` / `EXDATE`).\n\n```svelte\nscheduler={{\n recurrenceField: 'repeat',\n recurrenceExceptionsField: 'exceptions',\n onOccurrenceChange: (e) => {\n // merge e.exception into the row (keyed by e.occurrenceStart)\n const key = (e.exception.occurrenceStart as Date).getTime()\n e.row.exceptions = [\n ...(e.row.exceptions ?? []).filter((x) => new Date(x.occurrenceStart).getTime() !== key),\n { ...e.exception },\n ]\n },\n}}\n```\n\nEach exception is `{ occurrenceStart, deleted? , start?, end?, title? }` where\n`occurrenceStart` identifies the occurrence (its **original** start). Without a\n`recurrenceExceptionsField` the prompt is skipped and edits apply to the series.\n\n### Showing the pattern in a table\n\nThe rule stored on a row is a structured object, so in the **Table** view give\nthe recurrence field a readable column with **`describeRecurrence`** - it turns a\nrule (or list) into a short summary like *\"Weekly on weekdays\"*, *\"Every 2 weeks\non Fri\"*, or *\"Monthly on the last Friday\"*:\n\n```svelte\n<script>\n import { describeRecurrence } from '@svgrid/grid'\n const tableColumns = [\n ...columns,\n { id: 'repeat', header: 'Repeat', fieldFn: (r) => describeRecurrence(r.repeat) || '-' },\n ]\n</script>\n```\n\nPass a `RecurrenceLabels` object as the second argument to localize the weekday /\nmonth / ordinal words (and the empty-rule label).\n\n<div data-docs-demo=\"381-scheduler-app-calendar\" data-height=\"700\"></div>\n\n## Add, edit, delete\n\nThe scheduler drives full CRUD back onto your `data` through callbacks - it\nnever mutates your rows itself:\n\n- **Add** - double-clicking an empty time slot fires\n `onEventAdd(start, end, resourceId?)`; append a new row in the handler (a\n toolbar \"New event\" button that pushes a row works the same way).\n- **Edit** - set **`drawer`** (`true`, or a config object) and clicking an event\n opens a detail drawer. It always includes a **When** editor - **start** and\n **end** as full date-and-time pickers, plus an **All day** toggle when\n `allDayField` is set - followed by an `SvForm` of your other columns. The\n footer has **Save** and **Cancel**: Save (or **clicking outside** the drawer)\n commits and fires `onEventCommit` with the changed values; Cancel discards.\n- **Delete** - set **`onEventDelete`** and the drawer shows a **Delete** button;\n remove the row from your data in the handler.\n- **Right-click an event** for a context menu with **Edit** (opens the drawer) and\n **Delete** built in (shown when `drawer` / `onEventDelete` are set); any\n `eventMenu` items you return are appended below them.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n drawer: { title: (row) => row.title, size: '420px' },\n onEventAdd: (start, end) => (rows = [...rows, makeEvent(start, end)]),\n onEventCommit: (e) => Object.assign(e.row, e.values),\n onEventDelete: (row) => (rows = rows.filter((r) => r !== row)),\n }} />\n```\n\nBecause it is a view of the grid, pairing it with a plain `<SvGrid>` table gives\nyou a **Calendar / Table toggle** over the same rows - every scheduler demo ships\none.\n\n## Booking rules\n\nEnterprise schedulers enforce *when* things can be booked. The scheduler shades\nnon-bookable time and can reject double-bookings:\n\n- **`businessHours: { start, end }`** - working-hours window (hours). Time outside\n it is shaded in the Week / Day grid.\n- **`nonWorkingDays: number[]`** - weekday numbers (0 = Sun … 6 = Sat) shaded as\n non-working (e.g. `[0, 6]` for weekends), in the time-grid and month.\n- **`shadeUntilNow`** - shade elapsed time on today's column.\n- **`restrictToBusinessHours`** - **enforce** the shading: a drag / resize /\n create that lands outside `businessHours` or on a `nonWorkingDays` day is\n rejected and snaps back (without it the shading is a visual hint only).\n- **`disableConflicts`** - **no double-booking**: dragging, resizing or creating\n an event so it overlaps another **on the same resource** is rejected and snaps\n back (a brief \"already booked\" flash). Different resources may overlap freely.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n resourceField: 'room', resources: rooms,\n businessHours: { start: 9, end: 17 },\n nonWorkingDays: [0, 6],\n shadeUntilNow: true,\n disableConflicts: true, // enforce no double-booking per room\n }} />\n```\n\nShading is purely visual unless `restrictToBusinessHours` is set; `disableConflicts`\nis always enforced. Use the pure [`hasConflict`](https://svgrid.com/api/) helper to check a\nplacement yourself.\n\n### Per-resource availability\n\n`businessHours` / `nonWorkingDays` are global, but each **resource can declare\nits own working windows** via `resource.availability` - and they can vary by\nweekday. Perfect for a clinic where every doctor keeps different hours: each\nresource's columns shade its own off-hours, and `restrictToBusinessHours` blocks\nbooking a resource outside *its* window.\n\n```ts\nconst doctors = [\n { id: 'smith', title: 'Dr. Smith', availability: [\n { days: [1, 3, 5], start: 9, end: 13 }, // Mon/Wed/Fri mornings\n { days: [2, 4], start: 14, end: 18 }, // Tue/Thu afternoons\n ] },\n { id: 'jones', title: 'Dr. Jones', availability: [\n { days: [1, 2, 3, 4, 5], start: 8, end: 12 },\n { days: [1, 2, 3, 4, 5], start: 13, end: 16 },\n ] },\n]\n```\n\nAn `availability` window is `{ start, end }` hours with optional `days` (weekday\n0-6; omit for every day). A weekday with no matching window is a full day off. A\nresource without `availability` falls back to the global `businessHours` /\n`nonWorkingDays`.\n\n## Custom event content & tooltips\n\nRender events however you like with the **`event`** snippet (it receives the\nrow), and show rich detail on hover with the **`tooltip`** snippet (or\n`tooltip: true` for the built-in title + time + resource). `tooltipDelay` sets\nthe open delay (default 400ms).\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{ startField: 'start', endField: 'end', event: eventBody, tooltip: tip }} />\n\n{#snippet eventBody(row)}\n <span class=\"badge\">{initials(row.owner)}</span> {row.title}\n{/snippet}\n{#snippet tip(row)}\n <strong>{row.title}</strong><br />{row.owner} · {row.status}\n{/snippet}\n```\n\nThe snippet replaces the whole event body (time + title chrome), so you own the\nlayout; resize handles and drag still work around it.\n\n## Import / export, undo & clipboard\n\n- **iCal (Outlook / Google)** - the pure `toICS(events)` / `fromICS(text)`\n helpers round-trip timed, all-day and **recurring (RRULE)** events, plus STATUS,\n DESCRIPTION and LOCATION. Map your rows to the small `ICalEvent` shape and\n export to an `.ics` file; parse a pasted `.ics` back with `fromICS`.\n- **Data export** - because the scheduler is a *view of the grid*, its **Table**\n toggle exports to CSV / Excel with the grid's own export.\n- **Undo / redo** - set **`history: true`** and drag-moves + resizes are undoable\n with `Ctrl/Cmd+Z` / `Ctrl/Cmd+Shift+Z` (the scheduler re-emits the callbacks\n with the reversed values).\n- **Duplicate** - the event context menu gets a **Duplicate** action (when\n `onEventAdd` is set) that copies the event right after itself.\n\n```svelte\nimport { toICS, fromICS } from '@svgrid/grid'\nconst ics = toICS(rows.map((r) => ({ title: r.title, start: new Date(r.start), end: new Date(r.end), rrule: r.repeat })))\nconst events = fromICS(pastedText) // -> { title, start, end, allDay, rrule, ... }[]\n```\n\n## Unscheduled backlog\n\nPass **`unscheduled`** (a list of `{ id, title, durationMin?, color? }`) and a\nbacklog panel appears beside the Week / Day grid. **Drag a task onto a time\nslot** and **`onSchedule(item, start, resourceId)`** fires - create the event and\ndrop the task from your list.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n unscheduled: tasks, // [{ id, title, durationMin }]\n onSchedule: (item, start) => {\n rows = [...rows, makeEvent(item, start)]\n tasks = tasks.filter((t) => t.id !== item.id)\n },\n }} />\n```\n\n## Time zones\n\nSet `timeZone` (an IANA id like `'America/New_York'`) and the whole calendar -\nhour ruler, event positions, day boundaries, all-day grouping and the now-line -\nis rendered in that zone instead of the browser's. `secondaryTimeZones` adds\nread-only \"world clock\" rulers to the left of the primary gutter.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n timeZone: 'America/New_York',\n secondaryTimeZones: [{ id: 'Europe/London' }, { id: 'Asia/Tokyo' }],\n }} />\n```\n\nFor this to be correct the row `start` / `end` must be **instant-unambiguous** -\nUTC / offset ISO (`'2026-08-01T13:00:00Z'`, `'…+02:00'`) or epoch ms; a bare\nlocal string (`'2026-08-01T09:00'`) is read as the browser instant then shown in\nthe zone. Edits made in the calendar are written back as real instants, so store\nthem offset-aware (e.g. `date.toISOString()`); the display zone never mutates the\nstored value - toggle Table on the [time-zones demo](https://svgrid.com/demos/) to see the\nUTC strings stay put while the calendar shifts. The tz math is DST-aware and uses\n`Intl` only (no date library).\n\n<div data-docs-demo=\"385-scheduler-app-content\" data-height=\"700\"></div>\n\n## Selecting cells & events\n\nTwo selection modes work in **every view** - time-grid (week / day), month, and\ntimeline. **Both are on by default** - set `rangeSelectable: false` or\n`eventSelectable: false` to turn one off.\n\n**Select a date/time range** (`rangeSelectable`). Click or drag marks a single\n**continuous `[start, end]` datetime range** - dragging across days extends it\ninto a multi-day span (the start day fills from the start time down, whole middle\ndays, the end day down to the end time), *not* a per-day rectangle. Releasing the\ndrag only **marks** the range - it never creates an event on its own. Confirm it\nexplicitly to create: press **Enter**, or **right-click the selection** and choose\n**Add Event** (**Esc** cancels the marker). Confirming fires `onRangeSelect` with\nthe continuous `start` / `end` (plus the covered `days[]` / `resourceIds[]` for\nreference). Without a handler it falls back to `onEventAdd(start, end, resourceId,\nallDay)` - whose fourth argument is `true` for an all-day selection (the all-day\nrow, a month day cell, or a multi-day timeline zoom), so the handler can set the\n`allDayField` and create a true all-day event.\n\nYou can also select by **keyboard**: **click** an empty cell to select it, then\nthe **arrow keys** move the selected cell and **Shift + arrows** extend the range\n(**Enter** creates, **Esc** clears). What the arrows step by follows the view -\nin the time-grid a slot vertically and a day horizontally, in **month** a day\nhorizontally and a week vertically, in the **timeline** one axis cell (slot / day\n/ month) horizontally and a resource row vertically. In the time-grid the\nselection crosses into the **all-day row**: `ArrowUp` from the top timed cell\nmoves to the all-day cell, and `ArrowDown` from the all-day row drops back into\nthe grid.\n\nDragging the **all-day row** (or, in **month**, any day cells) selects whole days\n(`selection.allDay` is `true`); create an all-day event spanning `start`..`end`\nthe same way.\n\n**Multi-selecting events** (`eventSelectable`) works in every view too, including\nthe **agenda** list: Ctrl/Cmd-click to toggle, Shift-click for a range, `Delete`\nto remove.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n rangeSelectable: true,\n onRangeSelect: (sel) => {\n // one continuous event over the whole marked range\n rows = [...rows, makeEvent(sel.start, sel.end, sel.allDay)]\n },\n }} />\n```\n\n**Multi-select existing events** with `eventSelectable`. **Ctrl / Cmd-click**\ntoggles an event into the selection, **Shift-click** selects a range, and a plain\nclick clears it. Selected events get an outline; **dragging any one of them moves\nthe whole set together**, and pressing **Delete** removes them all (through\n`onEventDelete`). `onEventSelectionChange` reports the selected rows.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end', editable: true,\n eventSelectable: true,\n onEventSelectionChange: (rows) => (selected = rows),\n onEventDelete: (row) => (data = data.filter((r) => r !== row)),\n }} />\n```\n\n<div data-docs-demo=\"372-scheduler-selection\" data-height=\"560\"></div>\n\n## Search, filter, sort flow through\n\nBecause the scheduler renders the grid's processed rows, the built-in search\nbox (`searchable`, on by default) and any column filters or sort applied\nelsewhere all narrow the events shown - the calendar is never a separate data\nsource.\n\n## Scheduler Pro\n\nThe Enterprise renderer adds a set of advanced capabilities on top of the base\nscheduler. They are read from the same `scheduler` prop; type your config as\n`SchedulerProConfig` (from `@svgrid/enterprise`) to get the extra fields typed.\n\n> **Scheduler or Gantt?** SvGrid's Scheduler is a resource / booking / calendar\n> view of the grid - for appointments, staff and asset scheduling, and calendar\n> apps. It is deliberately *not* a project planner. For a plan with a\n> work-breakdown tree, percent-done and rolled-up phases, use\n> [Gantt chart mode](/help/rows/gantt), which is built for exactly that. The\n> **dependencies** below are an optional convenience for *ordered bookings* (a job\n> that must move through stations in sequence); they are fully opt-in - with no\n> dependency config the timeline draws no arrows and does no cascading.\n\n### Dependencies & auto-reschedule (optional, for ordered bookings)\n\nLink events with predecessor -> successor **dependencies** and the timeline draws\nan arrow between them. When a predecessor is dragged or resized, its successors\n**auto-reschedule** forward just enough to keep every link legal - preserving each\nevent's duration. Four link types are supported (`FS` finish-to-start, the default;\n`SS`, `FF`, `SF`) plus an optional `lag` in minutes (negative for a lead).\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { enableSchedulerView, type SchedulerProConfig, type SchedulerDependency } from '@svgrid/enterprise'\n enableSchedulerView()\n\n const dependencies: SchedulerDependency[] = [\n { id: 'd1', from: 't1', to: 't2', type: 'FS' }, // t2 starts when t1 finishes\n { id: 'd2', from: 't1', to: 't3', type: 'FS', lag: 60 }, // + 1h lag\n { id: 'd3', from: 't2', to: 't4' }, // FS (default)\n ]\n\n const cfg: SchedulerProConfig<any, Task> = {\n startField: 'start', endField: 'end',\n resourceField: 'team', resources: teams,\n initialView: 'timelineWeek', editable: true,\n businessHours: { start: 9, end: 17 },\n dependencies,\n autoReschedule: true, // default true when any dependency is set\n dependencyRespectWorkingTime: true, // cascade skips outside business hours\n onDependenciesChange: (moves) => { // persist the cascaded shifts\n for (const m of moves) {\n const row = rows.find((r) => r.id === m.id)\n if (row) { row.start = iso(m.start); row.end = iso(m.end) }\n }\n },\n }\n</script>\n```\n\n`from` / `to` are **row ids** (your `getRowId`). Provide the links as a flat\n`dependencies` array, or per-row via `dependencyField` (an array of\n`SchedulerDependency` or successor-id strings on each row). A dependency that is\nnot currently satisfied (a successor sitting too early) is drawn as a dashed red\narrow; cyclic links are ignored so a cascade can never loop. Cascading only ever\npushes successors **forward** - it never pulls an event earlier.\n\nArrows render in the timeline views (`timelineDay` / `timelineWeek` /\n`timelineMonth` / `timelineYear`), where events are laid out as horizontal bars.\n\n### Multi-assignment, histogram & summaries\n\nBy default one event sits on one resource (`resourceField`). With **assignments**\na single event can be assigned to SEVERAL resources - it then renders under each\nof them in the timeline. Provide a flat `assignments` list, or a per-row\n`assignmentField` holding an array of resource ids.\n\n```svelte\nconst cfg: SchedulerProConfig<any, Shift> = {\n startField: 'start', endField: 'end',\n resourceField: 'assignees', resources: people,\n initialView: 'timelineWeek', editable: true,\n assignmentField: 'assignees', // each row: resourceId[]\n resourceHistogram: { height: 22 }, // utilization bars under each row\n columnSummary: { label: 'Shifts', reducer: 'count' }, // sticky per-column totals\n onAssignmentChange: ({ eventId, from, to }) => { // drag across rows to reassign\n const row = rows.find((r) => r.id === eventId)\n if (row && to) row.assignees = [...row.assignees.filter((a) => a !== from), to]\n },\n}\n```\n\n- **`resourceHistogram`** draws a small bar per axis tick under each resource row,\n showing that resource's load; bars turn red where the load exceeds capacity\n (`{ capacityField }` on the resource, default 1). Set `true` or\n `{ capacityField?, height? }`.\n- **`columnSummary`** renders a sticky bottom strip aligned to the axis, one cell\n per time column. `reducer` is `'count'`, `{ sum: 'field' }`, or a function\n receiving the column-clipped events - e.g. `(items) => items.length`.\n- Dragging an assigned event onto a different resource row fires\n **`onAssignmentChange`** (a reassign) instead of a plain move.\n\n### Non-working-time collapse & zoom\n\nDense timelines waste width on hours nobody works. Turn on **collapse** and the\naxis folds non-working time out: nights (outside `businessHours`) and whole\nnon-working days (`nonWorkingDays`, e.g. weekends) shrink to a thin gap, so the\nworking hours fill the view. A **zoom** preset scales the tick size + pixel\ndensity from minutes out to months (a superset of `slotSizes`).\n\n```svelte\nconst cfg: SchedulerProConfig<any, Job> = {\n startField: 'start', endField: 'end',\n resourceField: 'machine', resources: machines,\n initialView: 'timelineWeek',\n businessHours: { start: 8, end: 18 }, nonWorkingDays: [0, 6],\n collapseNonWorking: true, // fold nights to a gap\n collapseWeekends: true, // fold weekends to a gap\n collapsedGapPx: 14, // width of the gap marker (0 = omit entirely)\n zoom: 3, // ladder index: 0 = 5-min .. 7 = monthly\n onZoomChange: (level) => console.log(level.id),\n}\n```\n\nThe compressed axis is fully consistent - event bars, drag/resize snapping, the\nnow-line and dependency arrows all map through it. A `- <label> +` stepper appears\nin the toolbar when `zoom` is set; supply your own ladder via `zoomLevels`. These\napply to the timeline views only.\n\n### Grouped / tree resources\n\nOrganise the timeline's resource rows into a **collapsible tree** in the gutter -\nbuildings to departments to providers/rooms. Define the group nodes with\n`resourceGroups` (nest via `parentId`) and map each resource to its group with\n`resourceGroupOf`. Click a group header to collapse its rows; the state persists\nvia `groupPersistKey`.\n\n```svelte\nconst cfg: SchedulerProConfig<any, Appt> = {\n startField: 'start', endField: 'end',\n resourceField: 'provider', resources: providers, // flat resources\n resourceGroups: [\n { id: 'bldg-a', title: 'Main building' },\n { id: 'cardio', title: 'Cardiology', parentId: 'bldg-a' },\n { id: 'derm', title: 'Dermatology', parentId: 'bldg-a' },\n ],\n resourceGroupOf: (r) => r.dept, // resource -> group id\n collapsibleGroups: true,\n groupPersistKey: 'clinic-groups',\n initialView: 'timelineDay',\n}\n```\n\nResources whose group is unknown / unset trail in an ungrouped section. Each group\nheader shows a count of the leaf resources beneath it. (To group the timeline by\nan **event field** instead of a resource - e.g. by status or type - simply set\n`resourceField` to that field; the rows then bucket by its values.)\n\n### Skill-based eligibility\n\nRestrict which events may be scheduled onto which resources (provider matching,\nroom type, technician skills). Supply `eligible(event, resource)` - return `false`\nand a drag / resize / create onto that resource is rejected with a \"Not eligible\"\nflash, and ineligible rows hatch while you drag. Or use the declarative\n`requiresField` shortcut: an event's required tag(s) must all be in the resource's\nskills.\n\n```svelte\n// Flexible predicate:\neligible: (job, tech) => tech.skills.includes(job.skill),\n\n// or declarative - a `skills` array on each resource:\nrequiresField: 'skill', // event field holding the required tag(s)\n// resourceSkillsOf: (r) => r.skills, // default reads r.skills\n```\n\n### Utilization heatmap\n\nTint each resource row's **background** by how loaded it is per time-bucket - light\nwhen quiet, hot near capacity, red when over. Distinct from the histogram (a bar\nstrip); the heatmap colours the whole lane so a glance shows the pressure.\n\n```svelte\nresourceHistogram: false,\nutilizationHeatmap: { capacityField: 'cap' }, // over `cap` = a red cell\n```\n\n### Booking rules - buffers, lead time, duration, travel\n\nBeyond conflicts and working hours, gate bookings on real-world rules. A move /\nresize / create that breaks one snaps back with the reason.\n\n```svelte\nbufferBeforeMin: 15, bufferAfterMin: 15, // clear gap around every booking\nminLeadMin: 60, // no bookings within an hour of now\nminDurationMin: 30, maxDurationMin: 90,\ntravelTimeOf: (a, b) => distanceMin(a.location, b.location), // widens the gap\n```\n\n### Bookable slots / find-a-time\n\nTurn the Day timeline into a booking surface: set `bookable` and every OPEN slot of\n`durationMin` lights up per resource - the free time left after existing bookings,\neach resource's own hours (`availability`), buffers and lead time. Clicking a slot\nbooks it.\n\n```svelte\nbookable: { durationMin: 60, stepMin: 30 },\nonSlotPick: (start, end, resourceId) => book(resourceId, start, end),\n```\n\nThe slot math is also a pure export - `availableSlots({ working, busy, durationMin,\nstepMin, bufferBeforeMin, bufferAfterMin, minStart })` from `@svgrid/enterprise`.\n\n### Multi-calendar overlay\n\nOverlay several calendars, each colour-coded and toggleable from a legend. Hiding a\ncalendar filters its events out of every view; an event's colour comes from its\ncalendar unless `colorField` overrides.\n\n```svelte\ncalendars: [\n { id: 'work', title: 'Work', color: '#4f46e5' },\n { id: 'personal', title: 'Personal', color: '#16a34a' },\n { id: 'holidays', title: 'Holidays', color: '#d97706', hidden: true },\n],\ncalendarField: 'cal',\n```\n\n### Free/busy + find-a-time\n\nShade a resource's external busy time (from another calendar) with `freeBusyOf`, and\nfind a slot that works for everyone with the pure `commonFree(busyByPerson, dayStart,\ndayEnd, durationMin)` export - the windows when all attendees are free.\n\n```svelte\nfreeBusyOf: (resource) => externalBusy[resource.id] ?? [],\n// then, to suggest meeting times:\nimport { commonFree } from '@svgrid/enterprise'\nconst windows = commonFree(attendees.map(busyOf), day8, day18, 60)\n```\n\n## Config reference\n\n| Option | Purpose |\n| --- | --- |\n| `startField` *(required)* | Field holding each event's start. |\n| `endField` | Field holding the end; else `defaultDurationMin` from the start. |\n| `allDayField` | Boolean field marking an all-day event; enables the drawer's All-day toggle. |\n| `titleField` | Event label. Defaults to the first column's field. |\n| `colorField` / `color` | Per-event accent color (the fill), or one for all. |\n| `secondaryColorField` | A second per-event color, painted as a left-edge strip (encode two dimensions - e.g. fill = person, strip = role). |\n| `recurrenceField` | Field holding a `RecurrenceRule` (or array). |\n| `recurrenceExceptionsField` / `onOccurrenceChange` | Per-occurrence overrides (moved / edited / deleted single instances); the prompt offers **This event**, **This and following**, and **All events**. `onOccurrenceChange` carries a `scope` - handle `'following'` by splitting the series (see below); it also fires from a drag, delete, and a drawer save on a recurring occurrence. |\n| `defaultDurationMin` | Event length when a row has no `endField` (default 60). |\n| `views` / `initialView` | Which views to offer, and the first shown. Includes the horizontal `timelineDay` / `timelineWeek` / `timelineMonth` / `timelineYear`. |\n| `initialDate` | The date the calendar opens on (default today). |\n| `weekStartsOn` | First day of the week, 0-6 (default 0). |\n| `slotMinutes` | Time-grid slot size and move/resize snap (default 30). Each slot renders 30px tall, so the hour grows with the granularity (1h = 30px rows, 30m = 60px, 15m = 120px). |\n| `slotSizes` | Slot sizes (minutes) offered as a runtime ruler picker in Week / Day, e.g. `[60, 30, 15, 5]`. Shows a size selector in the toolbar; omit to hide it (the ruler still uses `slotMinutes`). |\n| `dayStartHour` / `dayEndHour` | Visible time-grid band (default 0..24). |\n| `nowIndicator` | Show the current-time line on today (Week / Day + Day timeline). On by default; `false` to hide. |\n| `businessHours` / `nonWorkingDays` / `shadeUntilNow` | Shade out-of-hours, non-working weekdays and elapsed time. |\n| `restrictToBusinessHours` | Enforce the shading - reject a drop / create outside working hours or on a non-working day. |\n| `disableConflicts` | Reject a drag / resize / create that double-books the same resource. |\n| `restrictedHours` | Hard-blocked hour bands (e.g. a lunch window `[{ start: 12, end: 13 }]`) - always non-bookable, rendered as a distinct red hatch. |\n| `restrictedDates` | Specific dates that are fully closed (no bookings). |\n| `specialDates` | Highlighted dates (holidays / launches) - a coloured strip + label on the column; decorative, does not block. |\n| `minDate` / `maxDate` | Earliest / latest navigable + bookable date (prev / next nav stop at the bounds). |\n| `maxEventsPerSlot` | Cap concurrent events per resource - reject a move / create that exceeds N overlapping events. |\n| `SchedulerResource.dateOverrides` | Per-date exceptions to a resource's weekly `availability` - a day off (`off: true`) or custom `windows` for one date. |\n| `statusField` | Free/busy status (`busy` / `free` / `tentative` / `oof`) driving an Outlook-style visual (solid / outline / hatched / tinted). Ties to iCal `STATUS`. |\n| `reminderField` / `onReminder` | Minutes-before-start for a reminder; fires `onReminder` (and a built-in toast) once as the lead time is crossed. Ties to iCal `VALARM`. |\n| `onUnschedule` | Drag an event onto the backlog panel to remove it from the schedule (typically push it back to `unscheduled`). |\n| `event` / `tooltip` / `tooltipDelay` | Custom event-body snippet and a hover-tooltip snippet (or `tooltip: true` for the built-in). |\n| `history` | Enable undo / redo of drag-move + resize (`Ctrl/Cmd+Z` / `Ctrl/Cmd+Shift+Z`). |\n| `unscheduled` / `onSchedule` / `backlogTitle` | A drag-to-schedule backlog panel beside the Week / Day grid. |\n| `timeZone` | IANA zone the calendar is shown in (ruler, positions, day boundaries, now-line). Default: browser local. |\n| `secondaryTimeZones` | Extra read-only hour rulers (`{ id, label? }[]`) shown left of the primary gutter - a world clock. |\n| `collisionMode` | Overlap layout: `split` (default) / `cap` / `stack`. |\n| `maxColumns` | `cap` mode: columns before a `+N more` tile (min 2, default 3). |\n| `agendaDays` | How many days the agenda spans (default 30). |\n| `resourceField` / `resources` | Group time-grid columns by resource + a legend/filter in every view; resources become the rows in timeline views. |\n| `groupByDate` | Group day->resources instead of resource->days (default false). |\n| `resourceAreaWidth` | Timeline: width (px) of the left resource-label gutter (default 160). |\n| `timelineSlotMinutes` | Timeline day zoom: tick size + move/resize snap (default `slotMinutes`). |\n| `timelineLaneHeight` | Timeline: height (px) of one event lane in a resource row (default 26). |\n| `editable` | Enable drag-to-move and edge-resize. |\n| `rangeSelectable` / `onRangeSelect` | Click / drag empty slots to mark a continuous date/time range (spans days; arrows move, Shift+arrows extend); confirm with Enter or right-click -> Add Event to create one event. **On by default** (`false` to disable). |\n| `eventSelectable` / `onEventSelectionChange` | Ctrl/Shift-click to multi-select events; drag to move together, Delete to remove. **On by default** (`false` to disable). |\n| `onEventMove` / `onEventResize` | Fired with the new start/end (and resource). |\n| `onEventAdd` | Fired when an empty slot is double-clicked. |\n| `onEventDelete` | Shows a Delete button in the drawer; remove the row here. |\n| `event` | A snippet for a custom event body. |\n| `drawer` / `onEventCommit` | Built-in detail drawer + save callback. |\n| `eventMenu` | Right-click menu items for an event. |\n| `searchable` / `searchPlaceholder` | The toolbar search box. |\n\n### Scheduler Pro options (`SchedulerProConfig`)\n\n| Option | Purpose |\n| --- | --- |\n| `dependencies` / `dependencyField` | Predecessor -> successor links (`{ id, from, to, type?, lag? }`), as a flat list or per-row. Drawn as timeline arrows. |\n| `autoReschedule` | Cascade successors forward on move / resize to keep links legal (default `true` when dependencies are set). |\n| `dependencyRespectWorkingTime` | Cascade skips outside `businessHours` (a shifted successor lands at the next opening). |\n| `onDependenciesChange` | Fired with the cascaded `{ id, start, end }` shifts - persist them here. |\n| `onDependencyAdd` / `onDependencyRemove` | User draws / removes a link. |\n| `assignments` / `assignmentField` | Many-to-many event <-> resource assignments; an event renders under every assigned resource. |\n| `resourceHistogram` | Per-resource utilization bars under each timeline row (`true` or `{ capacityField?, height? }`); over-capacity bars turn red. |\n| `columnSummary` | Sticky per-time-column summary strip: `{ reducer: 'count' \\| { sum } \\| fn, label?, position? }`. |\n| `onAssignmentChange` | Fired when an event is dragged from one resource row to another (reassign). |\n| `collapseNonWorking` / `collapseWeekends` | Fold nights / non-working days out of the timeline axis. |\n| `collapsedGapPx` | Width (px) of a collapsed-gap marker (default 12; `0` omits it). |\n| `zoom` / `zoomLevels` / `onZoomChange` | Continuous zoom preset (minutes -> months) with a toolbar stepper; supersedes `slotSizes`. |\n| `resourceGroups` / `resourceGroupOf` | Group timeline resource rows into a collapsible tree (`{ id, title, parentId? }[]` + a resource -> group-id map). |\n| `collapsibleGroups` / `groupPersistKey` | Allow collapsing groups (default on) and persist the collapsed set to localStorage. |\n| `eligible` / `requiresField` / `resourceSkillsOf` | Skill / eligibility rule - which events may drop on which resources; rejected drops flash \"Not eligible\". |\n| `utilizationHeatmap` | Tint each resource row's background by load per bucket (`true` or `{ capacityField }`); over capacity = red. |\n| `bufferBeforeMin` / `bufferAfterMin` | Required clear minutes around each booking (number or `(event) => number`). |\n| `minLeadMin` / `minDurationMin` / `maxDurationMin` | Minimum notice, and min / max booking duration in minutes. |\n| `travelTimeOf` | Travel minutes required between two consecutive bookings on a resource (widens the buffer). |\n| `bookable` / `onSlotPick` | Show open bookable slots of a duration on the Day timeline (`{ durationMin, stepMin? }`); click to book. |\n| `calendars` / `calendarField` | Overlay several colour-coded calendars with a toggleable legend; map events via `calendarField`. |\n| `freeBusyOf` | External busy intervals to shade on a resource row (for find-a-time). Pair with the `commonFree` export. |\n\n## More examples\n\n### Scheduling rules & policies\n\nEvery booking policy the scheduler can enforce: business hours + a hard-blocked lunch band, a closed date, a highlighted holiday, min/max navigable dates, room capacity, a per-doctor day off, free/busy statuses, per-event reminders, drag-to-unschedule, and This / This-and-following / All recurrence edits.\n\n<div data-docs-demo=\"386-scheduler-rules\" data-height=\"560\"></div>\n\n### Sequenced bookings\n\nA service centre where a job flows through stations in order - a booking can only start once the one it depends on finishes. Drag or resize a booking and the steps that follow slide forward (opening-hours aware). Dependencies are an optional convenience for ordered bookings. Toggle to the Table - same grid rows, just a view.\n\n<div data-docs-demo=\"389-scheduler-dependencies\" data-height=\"560\"></div>\n\n### Staffing board - assignments & utilization\n\nA weekly staffing timeline where one shift can cover several people (it appears under each). A per-person utilization histogram sits under each row (red where over-booked) and a sticky summary strip totals shifts per day. Drag a shift onto another person to reassign.\n\n<div data-docs-demo=\"390-scheduler-capacity\" data-height=\"560\"></div>\n\n### Operations timeline - collapse & zoom\n\nA shop-floor timeline that collapses non-working time: nights fold to a thin gap and weekends to a marker, so working hours fill the width. A zoom stepper scales the axis from hourly detail out to weekly; bars, drag and the now-line all map through the compressed axis.\n\n<div data-docs-demo=\"391-scheduler-zoom\" data-height=\"560\"></div>\n\n### Grouped resources\n\nA clinic day where providers are organised into a collapsible tree in the gutter - buildings to departments to providers/rooms. Click a group to collapse it (persisted); appointments schedule onto the leaf providers. Toggle to the Table - the groups just read the same grid rows.\n\n<div data-docs-demo=\"392-scheduler-resource-tree\" data-height=\"560\"></div>\n\n### Utilization heatmap\n\nA support centre where each queue has a capacity and calls overlap through the day. The row background is tinted by how loaded the queue is each hour - light when quiet, hot near capacity, red when over. A glance shows where the pressure is.\n\n<div data-docs-demo=\"394-scheduler-heatmap\" data-height=\"560\"></div>\n\n### Multi-calendar overlay\n\nSeveral calendars overlaid on one week - Work, Personal, Family, Holidays - each colour-coded from a legend you can toggle. Turning a calendar off hides its events across every view; an event takes its calendar colour.\n\n<div data-docs-demo=\"397-scheduler-multi-calendar\" data-height=\"560\"></div>\n"
|
|
5298
|
+
"markdown": "# Scheduler / calendar mode\n\nSet one `scheduler` prop and the grid renders its rows as **events on a\ncalendar** instead of a table. It is the same `<SvGrid>`, the same `data` and\n`columns` - only the presentation changes. Events are placed by time across\nfour views (Month, Week, Day, Agenda); dragging an event to a new day or time\ntells you to reassign its start/end on your own data.\n\nFor a calendar built up option by option, see [Build a booking calendar](../scheduler/booking-calendar.md); this page is the reference for everything the `scheduler` prop can say.\n\nLike [Kanban board mode](/help/rows/kanban-board), the scheduler is a pure\n**view of the grid**: it renders the grid's already filtered, sorted, and\nsearched rows and writes back only through callbacks - it never mutates your\ndata.\n\n> **Enterprise feature.** The `scheduler` prop and its config types are part of\n> the free grid, but the calendar *renderer* ships in `@svgrid/enterprise`.\n> Register it once and the view lights up:\n>\n> ```ts\n> import { setLicenseKey, enableSchedulerView } from '@svgrid/enterprise'\n> setLicenseKey('YOUR-KEY') // omit to evaluate, with the watermark and console message\n> enableSchedulerView()\n> ```\n>\n> `installEnterprise(api)` also calls `enableSchedulerView()` for you. Without\n> the renderer registered, a grid with a `scheduler` prop shows an upgrade note.\n\n<div data-docs-demo=\"363-scheduler-intro\" data-height=\"620\"></div>\n\nEvery example below runs against this setup. `enableSchedulerView()` registers\nthe enterprise renderer; the `scheduler` prop itself is part of the free grid.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type RecurrenceRule } from '@svgrid/grid'\n import { enableSchedulerView } from '@svgrid/enterprise'\n\n enableSchedulerView()\n\n // One row type wide enough for every example on this page - resources,\n // recurrence, per-event colour and the booking-rule samples all read from it.\n type Event = {\n id: number\n title: string\n start: string\n end: string\n allDay?: boolean\n color?: string\n staffColor?: string\n roleColor?: string\n room?: string\n machine?: string\n provider?: string\n team?: string\n assignees?: string[]\n repeat?: RecurrenceRule | RecurrenceRule[] | null\n exceptions?: unknown[]\n }\n\n // Anchored on today, so the calendar always opens with events in view.\n const at = (dayOffset: number, hour: number, mins = 0) => {\n const d = new Date()\n d.setDate(d.getDate() + dayOffset)\n d.setHours(hour, mins, 0, 0)\n const p = (n: number) => String(n).padStart(2, '0')\n return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`\n }\n\n let data = $state<Event[]>([\n { id: 1, title: 'Standup', start: at(0, 9), end: at(0, 9, 15), color: '#3b82f6', room: 'Aurora', team: 'Platform' },\n { id: 2, title: 'Design review', start: at(0, 11), end: at(0, 12), color: '#8b5cf6', room: 'Borealis', team: 'Design' },\n { id: 3, title: 'Team offsite', start: at(1, 0), end: at(3, 0), allDay: true, color: '#22c55e', team: 'Platform' },\n { id: 4, title: '1:1', start: at(2, 15), end: at(2, 15, 30), color: '#f59e0b', room: 'Aurora', team: 'Design' },\n ])\n\n const rows = data\n const iso = at\n const makeEvent = (start: Date, end: Date): Event => ({\n id: Math.max(0, ...data.map((e) => e.id)) + 1,\n title: \"New event\",\n start: at(0, start.getHours(), start.getMinutes()),\n end: at(0, end.getHours(), end.getMinutes()),\n })\n const rooms = [\n { id: \"Aurora\", label: \"Aurora\" },\n { id: \"Borealis\", label: \"Borealis\" },\n ]\n const resources = rooms\n const tasks = data\n let selected = $state<Event | null>(null)\n const initials = (name: string) =>\n name.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2).toUpperCase()\n\n const columns: GridColumns<Event> = [\n { field: 'title', header: 'Event', width: 220 },\n { field: 'start', header: 'Start', width: 170 },\n { field: 'end', header: 'End', width: 170 },\n ]\n</script>\n```\n\n## The minimum\n\nPoint `scheduler.startField` at the field that holds each event's start. That\nis the only required option. With an `endField` too, events get their real\nduration; without one they use `defaultDurationMin` (60 by default). The title\ndefaults to the first column's field.\n\n```svelte {runnable}\n<SvGrid {data} {columns} scheduler={{ startField: 'start', endField: 'end' }} />\n```\n\nStart and end values may be a `Date`, an epoch number, or a parseable/ISO\nstring - the same coercion the date pickers use.\n\n## Views\n\nThe toolbar offers **Month**, **Week**, **Day**, and **Agenda**. Restrict or\nreorder them with `views`, and pick the one shown first with `initialView`.\nOpen on a specific date with `initialDate` (defaults to today).\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n views: ['week', 'day', 'agenda'],\n initialView: 'week',\n weekStartsOn: 1, // Monday\n dayStartHour: 7, // time-grid band 07:00..20:00\n dayEndHour: 20,\n slotMinutes: 30, // snap granularity\n }} />\n```\n\n- **Month** - a fixed six-week grid. Multi-day events render as **one continuous\n bar** spanning the day columns they cover (split into a fresh segment on each\n week row they continue into), stacked into lanes so they never overlap; a\n per-day **\"+N more\"** collapses events beyond the visible lanes into a popover.\n- **Week / Day** - a time-grid with an hourly band spanning `dayStartHour`..\n `dayEndHour` (default the full `0..24` day; a full-day band opens scrolled to\n business hours rather than midnight). Overlapping events are laid out by the\n chosen [collision mode](#colliding-overlapping-events). **All-day and multi-day\n events** render in a separate **all-day row** at the top (in Week view as\n continuous spanning bars), never filling the hourly columns. Drag an event\n between the all-day row and the grid to flip it between all-day and timed, or\n drag a bar's **left / right edge** to extend or shorten it by whole days. A\n **current-time line** tracks the local time on today's column (`nowIndicator`,\n on by default); the timeline draws it as a vertical line at \"now\".\n- **Agenda** - a chronological list grouped by day. Its span is `agendaDays`\n (default 30).\n\n## Colliding (overlapping) events\n\nWhen events overlap in the time-grid, `collisionMode` decides how they're shown:\n\n- **`split`** (default) - every collision divides the column width evenly. Two\n overlaps → halves, three → thirds, N → `1/N`. All stay visible.\n- **`cap`** - show up to `maxColumns` columns (default 3); the events that don't\n fit collapse into a clickable **`+N more`** tile. Clicking it opens a popover\n listing those events; clicking one opens it. Best when a slot can pile up deep.\n- **`stack`** - overlapping events overlap with a horizontal offset and z-order\n instead of shrinking, so each stays wide and readable; hovering brings one to\n the front.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{ startField: 'start', endField: 'end', collisionMode: 'cap', maxColumns: 3 }} />\n```\n\nThe **Month** view uses the same idea vertically: a day cell shows its first few\nevents, then a **`+N more`** button that opens the day's full list in a popover.\n\n## Titles, colors\n\n`titleField` sets the event label; `colorField` gives each event an accent\ncolor (any CSS color), or set one `color` for all of them.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{ startField: 'start', endField: 'end', titleField: 'title', colorField: 'color' }} />\n```\n\nAdd **`secondaryColorField`** to encode a *second* dimension: it paints a strip\non the **left edge** of each event, distinct from the main fill. A shift roster,\nfor instance, can fill each event with the **person**'s color (matching the\nresource legend) while the left strip shows the **role**:\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n colorField: 'staffColor', // fill = person\n secondaryColorField: 'roleColor', // left strip = role\n }} />\n```\n\n## Drag to move, resize\n\nSet **`editable`** to turn on drag-and-drop and resizing in the time-grid (and\ndrag-to-another-day in Month). An editable event shows a grab cursor; hovering\nit reveals **resize grips at its top and bottom edges**. Drag the body to move\nit, the bottom grip to change the end, or the top grip to change the start. The\nscheduler computes the new time itself and applies it as an overlay, then fires\na notification so you can mirror it onto your row or persist it. Moves and\nresizes snap to `slotMinutes`, and dragging across columns (days or resources)\nalso reassigns the day / resource.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end', editable: true,\n onEventMove: (e) => { e.row.start = iso(e.start); e.row.end = iso(e.end) },\n onEventResize: (e) => { e.row.end = iso(e.end) },\n }} />\n```\n\nIn the **Month** view an editable event can be **moved** (drag the bar to\nanother day) and **resized** (hover to reveal left/right grips, then drag an edge\nacross days to change its start or end date, keeping the time-of-day) - the bar\ngrows/shrinks live as you drag, and the day under the cursor highlights. The\n**all-day spanning bars** in Week / Day view resize the same way: drag a bar's\nleft or right edge across day columns to change how many days it covers.\n\nA focused event can also be nudged with the keyboard: **arrow left/right** move\nit a day, **arrow up/down** a slot, and **Enter** opens its editor.\n\n<div data-docs-demo=\"382-scheduler-app-clinic\" data-height=\"700\"></div>\n\n## Resources (group by person / room)\n\nPoint `resourceField` at a field and **every** time-grid view groups by\nresource - the **Day** view shows one column per resource, and the **Week** view\ngroups each resource across all seven days (a spanning group-header row sits\nabove the columns; the grid scrolls horizontally when there are many). Pass an\nexplicit `resources` array to fix their order, titles, and colors, or let them\nderive from the data. Dragging an event across columns reports both the new time\nand the new resource (`e.toResource`).\n\nA **resource legend** appears in *every* view (Month and Agenda included): each\nchip is a colour key that toggles that resource's events on or off.\n\nSet **`groupByDate: true`** to flip the grouping to date-major (each day groups\nthe resources) instead of the default resource-major (each resource groups the\ndays) - the same choice as Smart's `groupByDate`.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n resourceField: 'room',\n resources: [\n { id: 'Aurora', title: 'Aurora (12)', color: '#4f46e5' },\n { id: 'Borealis', title: 'Borealis (6)', color: '#0891b2' },\n ],\n initialView: 'week', editable: true,\n // groupByDate: true, // day -> resources instead of resource -> days\n onEventMove: (e) => { /* e.toResource is the dropped-on room */ },\n }} />\n```\n\n<div data-docs-demo=\"383-scheduler-app-dispatch\" data-height=\"700\"></div>\n\n## Timeline views (resources as rows)\n\nThe `timeline*` views flip the layout **horizontal**: time runs left→right on a\nscrollable axis and **each resource is a row**, with events as horizontal bars\nlane-packed so overlaps stack. Add any of them to `views` (and pick one with\n`initialView`):\n\n- **`timelineDay`** - hour ticks across one day (clamped to the\n `dayStartHour..dayEndHour` band).\n- **`timelineWeek`** - day ticks across a week.\n- **`timelineMonth`** - day ticks across a month.\n- **`timelineYear`** - month ticks across a year (grouped into quarters).\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n resourceField: 'room', resources,\n views: ['timelineDay', 'timelineWeek', 'timelineMonth', 'timelineYear'],\n initialView: 'timelineWeek',\n editable: true,\n onEventMove: (e) => { /* e.toResource = the row it was dropped on */ },\n }} />\n```\n\nWithout a `resourceField` the timeline shows a single **All** row. When editable,\ndrag a bar **sideways** to re-time it, drag it to **another row** to reassign the\nresource, or drag an **edge** to resize (day-granular on the multi-day zooms,\n`timelineSlotMinutes` on the day zoom). Tune the layout with `resourceAreaWidth`\n(left gutter), `timelineLaneHeight`, and `timelineSlotMinutes`.\n\nThe same [`rangeSelectable` / `eventSelectable`](#selecting-cells--events) modes\nwork in the timeline: **click or drag empty space** to mark a range (a slot in\nDay, whole days in Week/Month, a month in Year), then **arrow keys** move the\nselected cell along the axis (Left/Right) and between resource rows (Up/Down),\n**Shift+arrows** extend it, and **Enter** creates the event (**Esc** cancels).\n**Ctrl/Cmd-click** bars to multi-select, **Delete** to remove.\n\nThe `timeline*` views cover any horizon. Short-range planning (Day / Week) and\nthe long-range **roadmap** (Month / Year) read best with data scaled to the\nzoom, so the demos split them:\n\n<div data-docs-demo=\"371-scheduler-timeline\" data-height=\"560\"></div>\n\n<div data-docs-demo=\"384-scheduler-app-roadmap\" data-height=\"700\"></div>\n\n## Recurring events\n\nA row can carry a `recurrenceField` holding a\n[`RecurrenceRule`](/help/ui-components/sv-calendar) (or an array of them). The\nscheduler renders one event per matching day in the visible window - reusing\nthe same pure recurrence engine behind `SvCalendar`. Each instance keeps the\nbase row's time-of-day and duration.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n recurrenceField: 'repeat',\n }} />\n\n<!-- a row -->\n{ title: 'Daily standup', start: '2026-07-06T09:30', end: '2026-07-06T09:45',\n repeat: { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } }\n```\n\nThe rule shape covers the patterns an enterprise calendar needs:\n\n```ts\n{ freq: 'daily', interval: 2 } // every other day\n{ freq: 'weekly', weekdays: [1, 3, 5] } // Mon / Wed / Fri\n{ freq: 'monthly', day: 1 } // the 1st\n{ freq: 'monthly', day: -1 } // the last day\n{ freq: 'monthly', weekdays: [2], weekOfMonth: 1 } // the first Tuesday\n{ freq: 'monthly', weekdays: [5], weekOfMonth: -1 } // the last Friday\n{ freq: 'yearly', month: 10, weekdays: [4], weekOfMonth: 4 } // 4th Thursday of Nov\n{ freq: 'weekly', weekdays: [1], count: 8, from: '2026-01-05' } // 8 times, then stop\n```\n\n- `weekOfMonth` (1..4 or `-1` for last) with a single `weekdays` entry gives a\n **positional** monthly / yearly rule (\"the 2nd Tuesday\", \"the last Friday\").\n- `day` may be **negative** to count from the end (`-1` = the last day).\n- End the series with an inclusive `until` **date** or a `count` of occurrences\n (counted from `from`, which the editor sets to the event's start).\n\n### Editing patterns\n\nWhenever the **`drawer`** is on, clicking **any** event opens a **recurrence\neditor** in the drawer - so you can turn a one-off into a recurring event (or drop\nits pattern) whenever you decide. You don't need a `recurrenceField`: without one\nthe rule is stored on a default `recurrence` field (set `recurrenceField` to name\nyour own). It offers a full, calendar-app-grade set of controls:\n\n- **Repeat** - Does not repeat / Daily / Weekly / Monthly / Yearly, with an\n **interval** (\"every N …\").\n- **Weekly** - a weekday chip picker.\n- **Monthly** - *on a day of the month*, *on a weekday of the month* (the\n first / second / … / last weekday), or *on the last day*.\n- **Yearly** - pick the **month**, then a specific date or a positional weekday.\n- **Ends** - *Never*, *on a date*, or *after N occurrences*.\n\nUse it to **add** a pattern to a one-off event, **change** an existing one, or\n**remove** it (set Repeat to \"Does not repeat\"). Saving writes the rule back\nthrough `onEventCommit`.\n\nRecurring events are also **drag- and resize-editable**: moving or resizing an\noccurrence edits the whole **series'** time-of-day and duration (the pattern's\ndays are kept), so all occurrences shift together.\n\n### This event vs. all events (per-occurrence exceptions)\n\nSet a **`recurrenceExceptionsField`** (an array on the row) and an\n**`onOccurrenceChange`** handler, and moving / resizing / deleting a single\noccurrence asks **This event** or **All events**. \"All events\" edits the series\nas above; **This event** fires `onOccurrenceChange` with an `exception` to store\non the row - a moved / retitled / **deleted** single instance that leaves the\nrest of the series untouched (like an iCal `RECURRENCE-ID` / `EXDATE`).\n\n```svelte\nscheduler={{\n recurrenceField: 'repeat',\n recurrenceExceptionsField: 'exceptions',\n onOccurrenceChange: (e) => {\n // merge e.exception into the row (keyed by e.occurrenceStart)\n const key = (e.exception.occurrenceStart as Date).getTime()\n e.row.exceptions = [\n ...(e.row.exceptions ?? []).filter((x) => new Date(x.occurrenceStart).getTime() !== key),\n { ...e.exception },\n ]\n },\n}}\n```\n\nEach exception is `{ occurrenceStart, deleted? , start?, end?, title? }` where\n`occurrenceStart` identifies the occurrence (its **original** start). Without a\n`recurrenceExceptionsField` the prompt is skipped and edits apply to the series.\n\n### Showing the pattern in a table\n\nThe rule stored on a row is a structured object, so in the **Table** view give\nthe recurrence field a readable column with **`describeRecurrence`** - it turns a\nrule (or list) into a short summary like *\"Weekly on weekdays\"*, *\"Every 2 weeks\non Fri\"*, or *\"Monthly on the last Friday\"*:\n\n```svelte\n<script>\n import { describeRecurrence } from '@svgrid/grid'\n const tableColumns = [\n ...columns,\n { id: 'repeat', header: 'Repeat', fieldFn: (r) => describeRecurrence(r.repeat) || '-' },\n ]\n</script>\n```\n\nPass a `RecurrenceLabels` object as the second argument to localize the weekday /\nmonth / ordinal words (and the empty-rule label).\n\n<div data-docs-demo=\"381-scheduler-app-calendar\" data-height=\"700\"></div>\n\n## Add, edit, delete\n\nThe scheduler drives full CRUD back onto your `data` through callbacks - it\nnever mutates your rows itself:\n\n- **Add** - double-clicking an empty time slot fires\n `onEventAdd(start, end, resourceId?)`; append a new row in the handler (a\n toolbar \"New event\" button that pushes a row works the same way).\n- **Edit** - set **`drawer`** (`true`, or a config object) and clicking an event\n opens a detail drawer. It always includes a **When** editor - **start** and\n **end** as full date-and-time pickers, plus an **All day** toggle when\n `allDayField` is set - followed by an `SvForm` of your other columns. The\n footer has **Save** and **Cancel**: Save (or **clicking outside** the drawer)\n commits and fires `onEventCommit` with the changed values; Cancel discards.\n- **Delete** - set **`onEventDelete`** and the drawer shows a **Delete** button;\n remove the row from your data in the handler.\n- **Right-click an event** for a context menu with **Edit** (opens the drawer) and\n **Delete** built in (shown when `drawer` / `onEventDelete` are set); any\n `eventMenu` items you return are appended below them.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n drawer: { title: (row) => row.title, size: '420px' },\n onEventAdd: (start, end) => (rows = [...rows, makeEvent(start, end)]),\n onEventCommit: (e) => Object.assign(e.row, e.values),\n onEventDelete: (row) => (rows = rows.filter((r) => r !== row)),\n }} />\n```\n\nBecause it is a view of the grid, pairing it with a plain `<SvGrid>` table gives\nyou a **Calendar / Table toggle** over the same rows - every scheduler demo ships\none.\n\n## Booking rules\n\nEnterprise schedulers enforce *when* things can be booked. The scheduler shades\nnon-bookable time and can reject double-bookings:\n\n- **`businessHours: { start, end }`** - working-hours window (hours). Time outside\n it is shaded in the Week / Day grid.\n- **`nonWorkingDays: number[]`** - weekday numbers (0 = Sun … 6 = Sat) shaded as\n non-working (e.g. `[0, 6]` for weekends), in the time-grid and month.\n- **`shadeUntilNow`** - shade elapsed time on today's column.\n- **`restrictToBusinessHours`** - **enforce** the shading: a drag / resize /\n create that lands outside `businessHours` or on a `nonWorkingDays` day is\n rejected and snaps back (without it the shading is a visual hint only).\n- **`disableConflicts`** - **no double-booking**: dragging, resizing or creating\n an event so it overlaps another **on the same resource** is rejected and snaps\n back (a brief \"already booked\" flash). Different resources may overlap freely.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n resourceField: 'room', resources: rooms,\n businessHours: { start: 9, end: 17 },\n nonWorkingDays: [0, 6],\n shadeUntilNow: true,\n disableConflicts: true, // enforce no double-booking per room\n }} />\n```\n\nShading is purely visual unless `restrictToBusinessHours` is set; `disableConflicts`\nis always enforced. Use the pure [`hasConflict`](https://svgrid.com/api/) helper to check a\nplacement yourself.\n\n### Per-resource availability\n\n`businessHours` / `nonWorkingDays` are global, but each **resource can declare\nits own working windows** via `resource.availability` - and they can vary by\nweekday. Perfect for a clinic where every doctor keeps different hours: each\nresource's columns shade its own off-hours, and `restrictToBusinessHours` blocks\nbooking a resource outside *its* window.\n\n```ts\nconst doctors = [\n { id: 'smith', title: 'Dr. Smith', availability: [\n { days: [1, 3, 5], start: 9, end: 13 }, // Mon/Wed/Fri mornings\n { days: [2, 4], start: 14, end: 18 }, // Tue/Thu afternoons\n ] },\n { id: 'jones', title: 'Dr. Jones', availability: [\n { days: [1, 2, 3, 4, 5], start: 8, end: 12 },\n { days: [1, 2, 3, 4, 5], start: 13, end: 16 },\n ] },\n]\n```\n\nAn `availability` window is `{ start, end }` hours with optional `days` (weekday\n0-6; omit for every day). A weekday with no matching window is a full day off. A\nresource without `availability` falls back to the global `businessHours` /\n`nonWorkingDays`.\n\n## Custom event content & tooltips\n\nRender events however you like with the **`event`** snippet (it receives the\nrow), and show rich detail on hover with the **`tooltip`** snippet (or\n`tooltip: true` for the built-in title + time + resource). `tooltipDelay` sets\nthe open delay (default 400ms).\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{ startField: 'start', endField: 'end', event: eventBody, tooltip: tip }} />\n\n{#snippet eventBody(row)}\n <span class=\"badge\">{initials(row.owner)}</span> {row.title}\n{/snippet}\n{#snippet tip(row)}\n <strong>{row.title}</strong><br />{row.owner} · {row.status}\n{/snippet}\n```\n\nThe snippet replaces the whole event body (time + title chrome), so you own the\nlayout; resize handles and drag still work around it.\n\n## Import / export, undo & clipboard\n\n- **iCal (Outlook / Google)** - the pure `toICS(events)` / `fromICS(text)`\n helpers round-trip timed, all-day and **recurring (RRULE)** events, plus STATUS,\n DESCRIPTION and LOCATION. Map your rows to the small `ICalEvent` shape and\n export to an `.ics` file; parse a pasted `.ics` back with `fromICS`.\n- **Data export** - because the scheduler is a *view of the grid*, its **Table**\n toggle exports to CSV / Excel with the grid's own export.\n- **Undo / redo** - set **`history: true`** and drag-moves + resizes are undoable\n with `Ctrl/Cmd+Z` / `Ctrl/Cmd+Shift+Z` (the scheduler re-emits the callbacks\n with the reversed values).\n- **Duplicate** - the event context menu gets a **Duplicate** action (when\n `onEventAdd` is set) that copies the event right after itself.\n\n```svelte\nimport { toICS, fromICS } from '@svgrid/grid'\nconst ics = toICS(rows.map((r) => ({ title: r.title, start: new Date(r.start), end: new Date(r.end), rrule: r.repeat })))\nconst events = fromICS(pastedText) // -> { title, start, end, allDay, rrule, ... }[]\n```\n\n## Unscheduled backlog\n\nPass **`unscheduled`** (a list of `{ id, title, durationMin?, color? }`) and a\nbacklog panel appears beside the Week / Day grid. **Drag a task onto a time\nslot** and **`onSchedule(item, start, resourceId)`** fires - create the event and\ndrop the task from your list.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n unscheduled: tasks, // [{ id, title, durationMin }]\n onSchedule: (item, start) => {\n rows = [...rows, makeEvent(item, start)]\n tasks = tasks.filter((t) => t.id !== item.id)\n },\n }} />\n```\n\n## Time zones\n\nSet `timeZone` (an IANA id like `'America/New_York'`) and the whole calendar -\nhour ruler, event positions, day boundaries, all-day grouping and the now-line -\nis rendered in that zone instead of the browser's. `secondaryTimeZones` adds\nread-only \"world clock\" rulers to the left of the primary gutter.\n\n```svelte {runnable}\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n timeZone: 'America/New_York',\n secondaryTimeZones: [{ id: 'Europe/London' }, { id: 'Asia/Tokyo' }],\n }} />\n```\n\nFor this to be correct the row `start` / `end` must be **instant-unambiguous** -\nUTC / offset ISO (`'2026-08-01T13:00:00Z'`, `'…+02:00'`) or epoch ms; a bare\nlocal string (`'2026-08-01T09:00'`) is read as the browser instant then shown in\nthe zone. Edits made in the calendar are written back as real instants, so store\nthem offset-aware (e.g. `date.toISOString()`); the display zone never mutates the\nstored value - toggle Table on the [time-zones demo](https://svgrid.com/demos/) to see the\nUTC strings stay put while the calendar shifts. The tz math is DST-aware and uses\n`Intl` only (no date library).\n\n<div data-docs-demo=\"385-scheduler-app-content\" data-height=\"700\"></div>\n\n## Selecting cells & events\n\nTwo selection modes work in **every view** - time-grid (week / day), month, and\ntimeline. **Both are on by default** - set `rangeSelectable: false` or\n`eventSelectable: false` to turn one off.\n\n**Select a date/time range** (`rangeSelectable`). Click or drag marks a single\n**continuous `[start, end]` datetime range** - dragging across days extends it\ninto a multi-day span (the start day fills from the start time down, whole middle\ndays, the end day down to the end time), *not* a per-day rectangle. Releasing the\ndrag only **marks** the range - it never creates an event on its own. Confirm it\nexplicitly to create: press **Enter**, or **right-click the selection** and choose\n**Add Event** (**Esc** cancels the marker). Confirming fires `onRangeSelect` with\nthe continuous `start` / `end` (plus the covered `days[]` / `resourceIds[]` for\nreference). Without a handler it falls back to `onEventAdd(start, end, resourceId,\nallDay)` - whose fourth argument is `true` for an all-day selection (the all-day\nrow, a month day cell, or a multi-day timeline zoom), so the handler can set the\n`allDayField` and create a true all-day event.\n\nYou can also select by **keyboard**: **click** an empty cell to select it, then\nthe **arrow keys** move the selected cell and **Shift + arrows** extend the range\n(**Enter** creates, **Esc** clears). What the arrows step by follows the view -\nin the time-grid a slot vertically and a day horizontally, in **month** a day\nhorizontally and a week vertically, in the **timeline** one axis cell (slot / day\n/ month) horizontally and a resource row vertically. In the time-grid the\nselection crosses into the **all-day row**: `ArrowUp` from the top timed cell\nmoves to the all-day cell, and `ArrowDown` from the all-day row drops back into\nthe grid.\n\nDragging the **all-day row** (or, in **month**, any day cells) selects whole days\n(`selection.allDay` is `true`); create an all-day event spanning `start`..`end`\nthe same way.\n\n**Multi-selecting events** (`eventSelectable`) works in every view too, including\nthe **agenda** list: Ctrl/Cmd-click to toggle, Shift-click for a range, `Delete`\nto remove.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end',\n rangeSelectable: true,\n onRangeSelect: (sel) => {\n // one continuous event over the whole marked range\n rows = [...rows, makeEvent(sel.start, sel.end, sel.allDay)]\n },\n }} />\n```\n\n**Multi-select existing events** with `eventSelectable`. **Ctrl / Cmd-click**\ntoggles an event into the selection, **Shift-click** selects a range, and a plain\nclick clears it. Selected events get an outline; **dragging any one of them moves\nthe whole set together**, and pressing **Delete** removes them all (through\n`onEventDelete`). `onEventSelectionChange` reports the selected rows.\n\n```svelte\n<SvGrid {data} {columns}\n scheduler={{\n startField: 'start', endField: 'end', editable: true,\n eventSelectable: true,\n onEventSelectionChange: (rows) => (selected = rows),\n onEventDelete: (row) => (data = data.filter((r) => r !== row)),\n }} />\n```\n\n<div data-docs-demo=\"372-scheduler-selection\" data-height=\"560\"></div>\n\n## Search, filter, sort flow through\n\nBecause the scheduler renders the grid's processed rows, the built-in search\nbox (`searchable`, on by default) and any column filters or sort applied\nelsewhere all narrow the events shown - the calendar is never a separate data\nsource.\n\n## Scheduler Pro\n\nThe Enterprise renderer adds a set of advanced capabilities on top of the base\nscheduler. They are read from the same `scheduler` prop; type your config as\n`SchedulerProConfig` (from `@svgrid/enterprise`) to get the extra fields typed.\n\n> **Scheduler or Gantt?** SvGrid's Scheduler is a resource / booking / calendar\n> view of the grid - for appointments, staff and asset scheduling, and calendar\n> apps. It is deliberately *not* a project planner. For a plan with a\n> work-breakdown tree, percent-done and rolled-up phases, use\n> [Gantt chart mode](/help/rows/gantt), which is built for exactly that. The\n> **dependencies** below are an optional convenience for *ordered bookings* (a job\n> that must move through stations in sequence); they are fully opt-in - with no\n> dependency config the timeline draws no arrows and does no cascading.\n\n### Dependencies & auto-reschedule (optional, for ordered bookings)\n\nLink events with predecessor -> successor **dependencies** and the timeline draws\nan arrow between them. When a predecessor is dragged or resized, its successors\n**auto-reschedule** forward just enough to keep every link legal - preserving each\nevent's duration. Four link types are supported (`FS` finish-to-start, the default;\n`SS`, `FF`, `SF`) plus an optional `lag` in minutes (negative for a lead).\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { enableSchedulerView, type SchedulerProConfig, type SchedulerDependency } from '@svgrid/enterprise'\n enableSchedulerView()\n\n const dependencies: SchedulerDependency[] = [\n { id: 'd1', from: 't1', to: 't2', type: 'FS' }, // t2 starts when t1 finishes\n { id: 'd2', from: 't1', to: 't3', type: 'FS', lag: 60 }, // + 1h lag\n { id: 'd3', from: 't2', to: 't4' }, // FS (default)\n ]\n\n const cfg: SchedulerProConfig<any, Task> = {\n startField: 'start', endField: 'end',\n resourceField: 'team', resources: teams,\n initialView: 'timelineWeek', editable: true,\n businessHours: { start: 9, end: 17 },\n dependencies,\n autoReschedule: true, // default true when any dependency is set\n dependencyRespectWorkingTime: true, // cascade skips outside business hours\n onDependenciesChange: (moves) => { // persist the cascaded shifts\n for (const m of moves) {\n const row = rows.find((r) => r.id === m.id)\n if (row) { row.start = iso(m.start); row.end = iso(m.end) }\n }\n },\n }\n</script>\n```\n\n`from` / `to` are **row ids** (your `getRowId`). Provide the links as a flat\n`dependencies` array, or per-row via `dependencyField` (an array of\n`SchedulerDependency` or successor-id strings on each row). A dependency that is\nnot currently satisfied (a successor sitting too early) is drawn as a dashed red\narrow; cyclic links are ignored so a cascade can never loop. Cascading only ever\npushes successors **forward** - it never pulls an event earlier.\n\nArrows render in the timeline views (`timelineDay` / `timelineWeek` /\n`timelineMonth` / `timelineYear`), where events are laid out as horizontal bars.\n\n### Multi-assignment, histogram & summaries\n\nBy default one event sits on one resource (`resourceField`). With **assignments**\na single event can be assigned to SEVERAL resources - it then renders under each\nof them in the timeline. Provide a flat `assignments` list, or a per-row\n`assignmentField` holding an array of resource ids.\n\n```svelte\nconst cfg: SchedulerProConfig<any, Shift> = {\n startField: 'start', endField: 'end',\n resourceField: 'assignees', resources: people,\n initialView: 'timelineWeek', editable: true,\n assignmentField: 'assignees', // each row: resourceId[]\n resourceHistogram: { height: 22 }, // utilization bars under each row\n columnSummary: { label: 'Shifts', reducer: 'count' }, // sticky per-column totals\n onAssignmentChange: ({ eventId, from, to }) => { // drag across rows to reassign\n const row = rows.find((r) => r.id === eventId)\n if (row && to) row.assignees = [...row.assignees.filter((a) => a !== from), to]\n },\n}\n```\n\n- **`resourceHistogram`** draws a small bar per axis tick under each resource row,\n showing that resource's load; bars turn red where the load exceeds capacity\n (`{ capacityField }` on the resource, default 1). Set `true` or\n `{ capacityField?, height? }`.\n- **`columnSummary`** renders a sticky bottom strip aligned to the axis, one cell\n per time column. `reducer` is `'count'`, `{ sum: 'field' }`, or a function\n receiving the column-clipped events - e.g. `(items) => items.length`.\n- Dragging an assigned event onto a different resource row fires\n **`onAssignmentChange`** (a reassign) instead of a plain move.\n\n### Non-working-time collapse & zoom\n\nDense timelines waste width on hours nobody works. Turn on **collapse** and the\naxis folds non-working time out: nights (outside `businessHours`) and whole\nnon-working days (`nonWorkingDays`, e.g. weekends) shrink to a thin gap, so the\nworking hours fill the view. A **zoom** preset scales the tick size + pixel\ndensity from minutes out to months (a superset of `slotSizes`).\n\n```svelte\nconst cfg: SchedulerProConfig<any, Job> = {\n startField: 'start', endField: 'end',\n resourceField: 'machine', resources: machines,\n initialView: 'timelineWeek',\n businessHours: { start: 8, end: 18 }, nonWorkingDays: [0, 6],\n collapseNonWorking: true, // fold nights to a gap\n collapseWeekends: true, // fold weekends to a gap\n collapsedGapPx: 14, // width of the gap marker (0 = omit entirely)\n zoom: 3, // ladder index: 0 = 5-min .. 7 = monthly\n onZoomChange: (level) => console.log(level.id),\n}\n```\n\nThe compressed axis is fully consistent - event bars, drag/resize snapping, the\nnow-line and dependency arrows all map through it. A `- <label> +` stepper appears\nin the toolbar when `zoom` is set; supply your own ladder via `zoomLevels`. These\napply to the timeline views only.\n\n### Grouped / tree resources\n\nOrganise the timeline's resource rows into a **collapsible tree** in the gutter -\nbuildings to departments to providers/rooms. Define the group nodes with\n`resourceGroups` (nest via `parentId`) and map each resource to its group with\n`resourceGroupOf`. Click a group header to collapse its rows; the state persists\nvia `groupPersistKey`.\n\n```svelte\nconst cfg: SchedulerProConfig<any, Appt> = {\n startField: 'start', endField: 'end',\n resourceField: 'provider', resources: providers, // flat resources\n resourceGroups: [\n { id: 'bldg-a', title: 'Main building' },\n { id: 'cardio', title: 'Cardiology', parentId: 'bldg-a' },\n { id: 'derm', title: 'Dermatology', parentId: 'bldg-a' },\n ],\n resourceGroupOf: (r) => r.dept, // resource -> group id\n collapsibleGroups: true,\n groupPersistKey: 'clinic-groups',\n initialView: 'timelineDay',\n}\n```\n\nResources whose group is unknown / unset trail in an ungrouped section. Each group\nheader shows a count of the leaf resources beneath it. (To group the timeline by\nan **event field** instead of a resource - e.g. by status or type - simply set\n`resourceField` to that field; the rows then bucket by its values.)\n\n### Skill-based eligibility\n\nRestrict which events may be scheduled onto which resources (provider matching,\nroom type, technician skills). Supply `eligible(event, resource)` - return `false`\nand a drag / resize / create onto that resource is rejected with a \"Not eligible\"\nflash, and ineligible rows hatch while you drag. Or use the declarative\n`requiresField` shortcut: an event's required tag(s) must all be in the resource's\nskills.\n\n```svelte\n// Flexible predicate:\neligible: (job, tech) => tech.skills.includes(job.skill),\n\n// or declarative - a `skills` array on each resource:\nrequiresField: 'skill', // event field holding the required tag(s)\n// resourceSkillsOf: (r) => r.skills, // default reads r.skills\n```\n\n### Utilization heatmap\n\nTint each resource row's **background** by how loaded it is per time-bucket - light\nwhen quiet, hot near capacity, red when over. Distinct from the histogram (a bar\nstrip); the heatmap colours the whole lane so a glance shows the pressure.\n\n```svelte\nresourceHistogram: false,\nutilizationHeatmap: { capacityField: 'cap' }, // over `cap` = a red cell\n```\n\n### Booking rules - buffers, lead time, duration, travel\n\nBeyond conflicts and working hours, gate bookings on real-world rules. A move /\nresize / create that breaks one snaps back with the reason.\n\n```svelte\nbufferBeforeMin: 15, bufferAfterMin: 15, // clear gap around every booking\nminLeadMin: 60, // no bookings within an hour of now\nminDurationMin: 30, maxDurationMin: 90,\ntravelTimeOf: (a, b) => distanceMin(a.location, b.location), // widens the gap\n```\n\n### Bookable slots / find-a-time\n\nTurn the Day timeline into a booking surface: set `bookable` and every OPEN slot of\n`durationMin` lights up per resource - the free time left after existing bookings,\neach resource's own hours (`availability`), buffers and lead time. Clicking a slot\nbooks it.\n\n```svelte\nbookable: { durationMin: 60, stepMin: 30 },\nonSlotPick: (start, end, resourceId) => book(resourceId, start, end),\n```\n\nThe slot math is also a pure export - `availableSlots({ working, busy, durationMin,\nstepMin, bufferBeforeMin, bufferAfterMin, minStart })` from `@svgrid/enterprise`.\n\n### Multi-calendar overlay\n\nOverlay several calendars, each colour-coded and toggleable from a legend. Hiding a\ncalendar filters its events out of every view; an event's colour comes from its\ncalendar unless `colorField` overrides.\n\n```svelte\ncalendars: [\n { id: 'work', title: 'Work', color: '#4f46e5' },\n { id: 'personal', title: 'Personal', color: '#16a34a' },\n { id: 'holidays', title: 'Holidays', color: '#d97706', hidden: true },\n],\ncalendarField: 'cal',\n```\n\n### Free/busy + find-a-time\n\nShade a resource's external busy time (from another calendar) with `freeBusyOf`, and\nfind a slot that works for everyone with the pure `commonFree(busyByPerson, dayStart,\ndayEnd, durationMin)` export - the windows when all attendees are free.\n\n```svelte\nfreeBusyOf: (resource) => externalBusy[resource.id] ?? [],\n// then, to suggest meeting times:\nimport { commonFree } from '@svgrid/enterprise'\nconst windows = commonFree(attendees.map(busyOf), day8, day18, 60)\n```\n\n## Config reference\n\n| Option | Purpose |\n| --- | --- |\n| `startField` *(required)* | Field holding each event's start. |\n| `endField` | Field holding the end; else `defaultDurationMin` from the start. |\n| `allDayField` | Boolean field marking an all-day event; enables the drawer's All-day toggle. |\n| `allDay` | Every event is all-day: the start and end fields hold days, and a bare `'2026-10-14'` is that day where the user is (not midnight UTC). For a date field without a time. |\n| `titleField` | Event label. Defaults to the first column's field. |\n| `colorField` / `color` | Per-event accent color (the fill), or one for all. |\n| `secondaryColorField` | A second per-event color, painted as a left-edge strip (encode two dimensions - e.g. fill = person, strip = role). |\n| `recurrenceField` | Field holding a `RecurrenceRule` (or array). |\n| `recurrenceExceptionsField` / `onOccurrenceChange` | Per-occurrence overrides (moved / edited / deleted single instances); the prompt offers **This event**, **This and following**, and **All events**. `onOccurrenceChange` carries a `scope` - handle `'following'` by splitting the series (see below); it also fires from a drag, delete, and a drawer save on a recurring occurrence. |\n| `defaultDurationMin` | Event length when a row has no `endField` (default 60). |\n| `views` / `initialView` | Which views to offer, and the first shown. Includes the horizontal `timelineDay` / `timelineWeek` / `timelineMonth` / `timelineYear`. |\n| `initialDate` | The date the calendar opens on (default today). |\n| `weekStartsOn` | First day of the week, 0-6 (default 0). |\n| `slotMinutes` | Time-grid slot size and move/resize snap (default 30). Each slot renders 30px tall, so the hour grows with the granularity (1h = 30px rows, 30m = 60px, 15m = 120px). |\n| `slotSizes` | Slot sizes (minutes) offered as a runtime ruler picker in Week / Day, e.g. `[60, 30, 15, 5]`. Shows a size selector in the toolbar; omit to hide it (the ruler still uses `slotMinutes`). |\n| `dayStartHour` / `dayEndHour` | Visible time-grid band (default 0..24). |\n| `nowIndicator` | Show the current-time line on today (Week / Day + Day timeline). On by default; `false` to hide. |\n| `businessHours` / `nonWorkingDays` / `shadeUntilNow` | Shade out-of-hours, non-working weekdays and elapsed time. |\n| `restrictToBusinessHours` | Enforce the shading - reject a drop / create outside working hours or on a non-working day. |\n| `disableConflicts` | Reject a drag / resize / create that double-books the same resource. |\n| `restrictedHours` | Hard-blocked hour bands (e.g. a lunch window `[{ start: 12, end: 13 }]`) - always non-bookable, rendered as a distinct red hatch. |\n| `restrictedDates` | Specific dates that are fully closed (no bookings). |\n| `specialDates` | Highlighted dates (holidays / launches) - a coloured strip + label on the column; decorative, does not block. |\n| `minDate` / `maxDate` | Earliest / latest navigable + bookable date (prev / next nav stop at the bounds). |\n| `maxEventsPerSlot` | Cap concurrent events per resource - reject a move / create that exceeds N overlapping events. |\n| `SchedulerResource.dateOverrides` | Per-date exceptions to a resource's weekly `availability` - a day off (`off: true`) or custom `windows` for one date. |\n| `statusField` | Free/busy status (`busy` / `free` / `tentative` / `oof`) driving an Outlook-style visual (solid / outline / hatched / tinted). Ties to iCal `STATUS`. |\n| `reminderField` / `onReminder` | Minutes-before-start for a reminder; fires `onReminder` (and a built-in toast) once as the lead time is crossed. Ties to iCal `VALARM`. |\n| `onUnschedule` | Drag an event onto the backlog panel to remove it from the schedule (typically push it back to `unscheduled`). |\n| `event` / `tooltip` / `tooltipDelay` | Custom event-body snippet and a hover-tooltip snippet (or `tooltip: true` for the built-in). |\n| `history` | Enable undo / redo of drag-move + resize (`Ctrl/Cmd+Z` / `Ctrl/Cmd+Shift+Z`). |\n| `unscheduled` / `onSchedule` / `backlogTitle` | A drag-to-schedule backlog panel beside the Week / Day grid. |\n| `timeZone` | IANA zone the calendar is shown in (ruler, positions, day boundaries, now-line). Default: browser local. |\n| `secondaryTimeZones` | Extra read-only hour rulers (`{ id, label? }[]`) shown left of the primary gutter - a world clock. |\n| `collisionMode` | Overlap layout: `split` (default) / `cap` / `stack`. |\n| `maxColumns` | `cap` mode: columns before a `+N more` tile (min 2, default 3). |\n| `agendaDays` | How many days the agenda spans (default 30). |\n| `resourceField` / `resources` | Group time-grid columns by resource + a legend/filter in every view; resources become the rows in timeline views. |\n| `groupByDate` | Group day->resources instead of resource->days (default false). |\n| `resourceAreaWidth` | Timeline: width (px) of the left resource-label gutter (default 160). |\n| `timelineSlotMinutes` | Timeline day zoom: tick size + move/resize snap (default `slotMinutes`). |\n| `timelineLaneHeight` | Timeline: height (px) of one event lane in a resource row (default 26). |\n| `editable` | Enable drag-to-move and edge-resize. |\n| `rangeSelectable` / `onRangeSelect` | Click / drag empty slots to mark a continuous date/time range (spans days; arrows move, Shift+arrows extend); confirm with Enter or right-click -> Add Event to create one event. **On by default** (`false` to disable). |\n| `eventSelectable` / `onEventSelectionChange` | Ctrl/Shift-click to multi-select events; drag to move together, Delete to remove. **On by default** (`false` to disable). |\n| `onEventMove` / `onEventResize` | Fired with the new start/end (and resource). |\n| `onEventAdd` | Fired when an empty slot is double-clicked. |\n| `onEventDelete` | Shows a Delete button in the drawer; remove the row here. |\n| `event` | A snippet for a custom event body. |\n| `drawer` / `onEventCommit` | Built-in detail drawer + save callback. |\n| `eventMenu` | Right-click menu items for an event. |\n| `searchable` / `searchPlaceholder` | The toolbar search box. |\n\n### Scheduler Pro options (`SchedulerProConfig`)\n\n| Option | Purpose |\n| --- | --- |\n| `dependencies` / `dependencyField` | Predecessor -> successor links (`{ id, from, to, type?, lag? }`), as a flat list or per-row. Drawn as timeline arrows. |\n| `autoReschedule` | Cascade successors forward on move / resize to keep links legal (default `true` when dependencies are set). |\n| `dependencyRespectWorkingTime` | Cascade skips outside `businessHours` (a shifted successor lands at the next opening). |\n| `onDependenciesChange` | Fired with the cascaded `{ id, start, end }` shifts - persist them here. |\n| `onDependencyAdd` / `onDependencyRemove` | User draws / removes a link. |\n| `assignments` / `assignmentField` | Many-to-many event <-> resource assignments; an event renders under every assigned resource. |\n| `resourceHistogram` | Per-resource utilization bars under each timeline row (`true` or `{ capacityField?, height? }`); over-capacity bars turn red. |\n| `columnSummary` | Sticky per-time-column summary strip: `{ reducer: 'count' \\| { sum } \\| fn, label?, position? }`. |\n| `onAssignmentChange` | Fired when an event is dragged from one resource row to another (reassign). |\n| `collapseNonWorking` / `collapseWeekends` | Fold nights / non-working days out of the timeline axis. |\n| `collapsedGapPx` | Width (px) of a collapsed-gap marker (default 12; `0` omits it). |\n| `zoom` / `zoomLevels` / `onZoomChange` | Continuous zoom preset (minutes -> months) with a toolbar stepper; supersedes `slotSizes`. |\n| `resourceGroups` / `resourceGroupOf` | Group timeline resource rows into a collapsible tree (`{ id, title, parentId? }[]` + a resource -> group-id map). |\n| `collapsibleGroups` / `groupPersistKey` | Allow collapsing groups (default on) and persist the collapsed set to localStorage. |\n| `eligible` / `requiresField` / `resourceSkillsOf` | Skill / eligibility rule - which events may drop on which resources; rejected drops flash \"Not eligible\". |\n| `utilizationHeatmap` | Tint each resource row's background by load per bucket (`true` or `{ capacityField }`); over capacity = red. |\n| `bufferBeforeMin` / `bufferAfterMin` | Required clear minutes around each booking (number or `(event) => number`). |\n| `minLeadMin` / `minDurationMin` / `maxDurationMin` | Minimum notice, and min / max booking duration in minutes. |\n| `travelTimeOf` | Travel minutes required between two consecutive bookings on a resource (widens the buffer). |\n| `bookable` / `onSlotPick` | Show open bookable slots of a duration on the Day timeline (`{ durationMin, stepMin? }`); click to book. |\n| `calendars` / `calendarField` | Overlay several colour-coded calendars with a toggleable legend; map events via `calendarField`. |\n| `freeBusyOf` | External busy intervals to shade on a resource row (for find-a-time). Pair with the `commonFree` export. |\n\n## More examples\n\n### Scheduling rules & policies\n\nEvery booking policy the scheduler can enforce: business hours + a hard-blocked lunch band, a closed date, a highlighted holiday, min/max navigable dates, room capacity, a per-doctor day off, free/busy statuses, per-event reminders, drag-to-unschedule, and This / This-and-following / All recurrence edits.\n\n<div data-docs-demo=\"386-scheduler-rules\" data-height=\"560\"></div>\n\n### Sequenced bookings\n\nA service centre where a job flows through stations in order - a booking can only start once the one it depends on finishes. Drag or resize a booking and the steps that follow slide forward (opening-hours aware). Dependencies are an optional convenience for ordered bookings. Toggle to the Table - same grid rows, just a view.\n\n<div data-docs-demo=\"389-scheduler-dependencies\" data-height=\"560\"></div>\n\n### Staffing board - assignments & utilization\n\nA weekly staffing timeline where one shift can cover several people (it appears under each). A per-person utilization histogram sits under each row (red where over-booked) and a sticky summary strip totals shifts per day. Drag a shift onto another person to reassign.\n\n<div data-docs-demo=\"390-scheduler-capacity\" data-height=\"560\"></div>\n\n### Operations timeline - collapse & zoom\n\nA shop-floor timeline that collapses non-working time: nights fold to a thin gap and weekends to a marker, so working hours fill the width. A zoom stepper scales the axis from hourly detail out to weekly; bars, drag and the now-line all map through the compressed axis.\n\n<div data-docs-demo=\"391-scheduler-zoom\" data-height=\"560\"></div>\n\n### Grouped resources\n\nA clinic day where providers are organised into a collapsible tree in the gutter - buildings to departments to providers/rooms. Click a group to collapse it (persisted); appointments schedule onto the leaf providers. Toggle to the Table - the groups just read the same grid rows.\n\n<div data-docs-demo=\"392-scheduler-resource-tree\" data-height=\"560\"></div>\n\n### Utilization heatmap\n\nA support centre where each queue has a capacity and calls overlap through the day. The row background is tinted by how loaded the queue is each hour - light when quiet, hot near capacity, red when over. A glance shows where the pressure is.\n\n<div data-docs-demo=\"394-scheduler-heatmap\" data-height=\"560\"></div>\n\n### Multi-calendar overlay\n\nSeveral calendars overlaid on one week - Work, Personal, Family, Holidays - each colour-coded from a legend you can toggle. Turning a calendar off hides its events across every view; an event takes its calendar colour.\n\n<div data-docs-demo=\"397-scheduler-multi-calendar\" data-height=\"560\"></div>\n"
|
|
4998
5299
|
},
|
|
4999
5300
|
{
|
|
5000
5301
|
"slug": "help/rows/selection-bar",
|
|
5001
5302
|
"path": "docs/help/rows/selection-bar.md",
|
|
5002
5303
|
"title": "Bulk-action bar",
|
|
5003
5304
|
"section": "Rows",
|
|
5004
|
-
"markdown": "# Bulk-action bar\n\nTick some rows and a bar floats over the grid holding the count, the actions\nthat apply to the whole selection, and a way to clear it - the pattern an issue\ntracker uses for bulk edit.\n\n<div data-docs-demo=\"430-selection-bar\" data-height=\"560\"></div>\n\nEvery example below runs against this setup. The bar *renderer* ships in\n`@svgrid/enterprise`, so `enableSelectionBar()` is what lights it up - the\n`selectionBar` prop and its types are part of the free grid. Without the\nrenderer the grid shows a note in the bar's place saying it is an Enterprise\nfeature and pointing at [licensing](https://svgrid.com/pricing/).\n\nLike the rest of Enterprise this comes with the 30-day evaluation period:\nwith the package installed the bar works whether or not a license key is\nset, and without one the grid displays the watermark and the console\nmessage about the Enterprise package. Set a key with `setLicenseKey()`\nbefore shipping.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'\n import { enableSelectionBar } from '@svgrid/enterprise'\n\n enableSelectionBar()\n\n type Task = {\n id: number\n key: string\n title: string\n assignee: string\n status: 'Open' | 'In progress' | 'Done'\n }\n\n const tasks: Task[] = [\n { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log', assignee: 'A. Osei', status: 'Open' },\n { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery', assignee: 'R. Vance', status: 'In progress' },\n { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup', assignee: 'M. Iqbal', status: 'Done' },\n { id: 4, key: 'PLAT-415', title: 'Fix timezone drift', assignee: 'J. Lindqvist', status: 'Open' },\n { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo', status: 'Done' },\n ]\n\n const columns: GridColumns<Task> = [\n { field: 'key', header: 'Key', width: 110 },\n { field: 'title', header: 'Summary', width: 240 },\n { field: 'assignee', header: 'Assignee', width: 150 },\n { field: 'status', header: 'Status', width: 130 },\n ]\n</script>\n```\n\n## The smallest version\n\n`selectionBar` on its own gives the count and a clear button. Nothing else is\nrequired - `showRowSelection` puts the checkboxes there, and the bar reacts to\nwhatever ends up selected:\n\n```svelte {runnable}\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />\n```\n\n## Adding actions\n\nAn array is the shorthand for `{ actions }`. Each action gets the whole\nselection as `{ rows, ids }`, in **display order** - so it reads the same order\nthe user is looking at, even after a sort:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let log = $state<string[]>([])\n\n const actions: SelectionBarAction<Task>[] = [\n {\n key: 'done',\n label: 'Mark done',\n icon: 'M20 6 9 17l-5-5',\n action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',\n danger: true,\n action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />\n\n<ul>\n {#each log as line, i (i)}<li>{line}</li>{/each}\n</ul>\n```\n\n`icon` is the `d` of a single 24x24 SVG path, so an action array stays plain\ndata you can lift into its own module next to the columns.\n\n## Built-in actions\n\nStrings in the array are built-ins, so the common buttons need no wiring:\n\n| Key | Does |\n| --- | --- |\n| `'selectAll'` | Selects every row in the view, then disables itself |\n| `'editFields'` | Opens the bulk-edit drawer - see below |\n| `'separator'` | Draws a divider. Not counted against `maxVisible` |\n\nMix them with your own actions in one array:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'separator'> = [\n 'selectAll',\n 'editFields',\n 'separator',\n { key: 'watch', label: 'Watch', action: () => {} },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />\n```\n\n## Bulk edit\n\n`'editFields'` opens a **drawer** - the same `SvDrawer` + `SvForm` the grid\nuses for single-row editing - holding one control per editable column. Change\nas many fields as you like and they apply to **every selected row**, as a\nsingle undo run.\n\nThe rule that makes that safe: **only fields you actually change are written.**\n\n| The field | Opens showing | Leave it alone and |\n| --- | --- | --- |\n| Every selected row agrees | That shared value | Nothing is written |\n| They disagree | Blank, labelled \"Multiple values\" | Each row keeps its own value |\n\nSo editing Status across twelve issues does not flatten their summaries to\nwhichever one happened to be first.\n\nThe field list is the grid's own editable, field-backed columns. A column with\n`editable: false` is not offered, because writing it in bulk would be a\nfootgun, and `editorOptions` carry over - a status column with a fixed set\nbecomes a picker in the drawer rather than a free-text box.\n\nWhat it does per cell:\n\n- skips rows whose per-row `editable` rule refuses the write,\n- skips cells already holding the value, so they do not become empty undo steps,\n- fires `onCellValueChange` for every cell it does change,\n- appends the whole run's undo steps together, so `Ctrl+Z` walks the edit back\n rather than needing one press per cell.\n\nThe engine is exported if you want the same write from your own UI:\n\n```ts\nimport { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'\n\nconst fields = bulkEditableFields(ctrl) // what can be targeted\nconst { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show\nconst { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })\n```\n\n## On a server-side row model\n\nUnder `<SvGrid rowModel={ctl}>` with the Enterprise row model's selection\nturned on, the bar reads the model instead of the loaded rows: the count\nchip says what the rule selects - a million under select-all, even with\ntwenty rows on screen - and **Edit fields** sends the patch and the rule\nto the datasource's `updateWhere` as one request, rather than writing the\nloaded cells. See [Server selection](../server/server-selection.md).\n\n## Where it floats\n\n`position` pins the bar to the `'bottom'` (default) or `'top'` edge of the\ngrid.\n\nThe bar **floats** - it never resizes the grid, so a layout holding one does\nnot jump when a checkbox is ticked. To stop it hiding anything, the scroll area\ngains bottom padding the height of the bar while a selection is live: the last\nrow can always be scrolled clear, and the bar overlays that padding rather than\na row.\n\n```svelte {runnable}\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}\n/>\n```\n\n## Buttons that come and go\n\n`hidden` and `disabled` are re-checked on every render against the live\nselection, so buttons appear and grey out as the set changes rather than\nfreezing at first paint. Select one row and **Merge** is not there; select two\nand it appears:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const conditional: SelectionBarAction<Task>[] = [\n {\n key: 'merge',\n label: 'Merge',\n // Merging one task into itself is meaningless.\n hidden: ({ ids }) => ids.length < 2,\n action: () => {},\n },\n {\n key: 'close',\n label: 'Close',\n // Nothing to do when everything picked is already closed.\n disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),\n action: () => {},\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />\n```\n\n## More actions than fit\n\nPast `maxVisible` (6 by default) the tail folds into a `···` menu, which opens\n**away** from the edge the bar is pinned to - upward from a bottom bar, downward\nfrom a top one - so it never falls off the grid. A bar wide enough to scroll is\na bar whose last button nobody finds:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const many: SelectionBarAction<Task>[] = [\n 'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',\n ].map((label) => ({ key: label, label, action: () => {} }))\n</script>\n\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ actions: many, maxVisible: 3 }}\n/>\n```\n\n## Keyboard and screen readers\n\nThe bar is a `role=\"toolbar\"`, which is a promise about how it behaves, so it\nkeeps that promise:\n\n| Key | Does |\n| --- | --- |\n| `Tab` | One stop for the whole bar, not one per button |\n| `Left` / `Right` | Move between buttons, wrapping at each end |\n| `Esc` | Closes the overflow menu if it is open, otherwise clears the selection |\n\nThese are handled on the bar, so they apply while focus is inside it. `Esc`\npressed in the grid body still does whatever the grid does with it - the bar\ndoes not install a global key handler that would fight cell editing or an open\nmenu.\n\nThe count carries `aria-live=\"polite\"` so it is read as the selection changes.\nThe bar itself is not live - that would re-read every button on each tick.\n\n## Options\n\n| Option | Default | Does |\n| --- | --- | --- |\n| `actions` | `[]` | The buttons, left to right |\n| `position` | `'bottom'` | Which edge the bar floats against |\n| `maxVisible` | `6` | How many stay on the bar before the rest collapse. Separators do not count |\n| `hideClear` | `false` | Drop the trailing clear button |\n\n`selectionBar={true}` is shorthand for all defaults with no actions, and\n`selectionBar={[...]}` for `{ actions: [...] }`.\n\n## Related\n\nDemo `23-bulk-actions` builds the same idea by hand - a toolbar of your own\nmarkup driven by `onRowSelectionChange`. Reach for `selectionBar` when you want\nit working out of the box; hand-roll it when you want full control of the\nlayout.\n\n<!-- tutorial:learn-5-selection -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-5-selection\" data-docs-tutorial=\"learn-5-selection\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-5-selection.mp4\" poster=\"/tutorials/learn-5-selection.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 5: selection and bulk actions, 86 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-5-selection.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-5-selection.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 5: selection and bulk actions</strong> (1:26, silent). <a href=\"https://www.youtube.com/watch?v=NO_xuVJdqVQ\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Selecting rows is easy. Knowing which rows are selected after a sort, a filter or a refresh is the part that catches people out, and it comes down to one prop.</p>\n<p>A small backlog of tasks and the columns to show them.</p>\n<p>Three things on the component. showRowSelection adds the checkbox column. selectionBar floats the action bar. And getRowId is the one that matters: it tells the grid what identifies a row, so a selection survives sorting, filtering and new data arriving. Without it the grid falls back to position, and position changes.</p>\n<p>Tick a couple of rows and the bar appears with a live count and the actions you defined. It stays out of the way until there is something to act on.</p>\n<p>Now sort the table while those rows are ticked. The same three stay selected, because the grid is tracking ids rather than row numbers.</p>\n<p>Over server data the same idea scales: select-all becomes a rule, every row except these, rather than a list of ids the browser would have to hold. Lesson six groups the rows and adds totals.</p>\n<p>The file is on the page below. See you in lesson six.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-5-selection -->\n\n## See also\n\n- [Row selection](../accessibility.md)\n- [Server selection](../server/server-selection.md) - the bar over a million rows the grid never loaded\n- [Kanban board mode](./kanban-board.md)\n- [Scheduler](./scheduler.md)\n"
|
|
5305
|
+
"markdown": "# Bulk-action bar\n\nTick some rows and a bar floats over the grid holding the count, the actions\nthat apply to the whole selection, and a way to clear it - the pattern an issue\ntracker uses for bulk edit.\n\n<div data-docs-demo=\"430-selection-bar\" data-height=\"560\"></div>\n\nEvery example below runs against this setup. The bar *renderer* ships in\n`@svgrid/enterprise`, so `enableSelectionBar()` is what lights it up - the\n`selectionBar` prop and its types are part of the free grid. Without the\nrenderer the grid shows a note in the bar's place saying it is an Enterprise\nfeature and pointing at [licensing](https://svgrid.com/pricing/).\n\nLike the rest of Enterprise this comes with the 30-day evaluation period:\nwith the package installed the bar works whether or not a license key is\nset, and without one the grid displays the watermark and the console\nmessage about the Enterprise package. Set a key with `setLicenseKey()`\nbefore shipping.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'\n import { enableSelectionBar } from '@svgrid/enterprise'\n\n enableSelectionBar()\n\n type Task = {\n id: number\n key: string\n title: string\n assignee: string\n status: 'Open' | 'In progress' | 'Done'\n }\n\n const tasks: Task[] = [\n { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log', assignee: 'A. Osei', status: 'Open' },\n { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery', assignee: 'R. Vance', status: 'In progress' },\n { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup', assignee: 'M. Iqbal', status: 'Done' },\n { id: 4, key: 'PLAT-415', title: 'Fix timezone drift', assignee: 'J. Lindqvist', status: 'Open' },\n { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo', status: 'Done' },\n ]\n\n const columns: GridColumns<Task> = [\n { field: 'key', header: 'Key', width: 110 },\n { field: 'title', header: 'Summary', width: 240 },\n { field: 'assignee', header: 'Assignee', width: 150 },\n { field: 'status', header: 'Status', width: 130 },\n ]\n</script>\n```\n\n## The smallest version\n\n`selectionBar` on its own gives the count and a clear button. Nothing else is\nrequired - `showRowSelection` puts the checkboxes there, and the bar reacts to\nwhatever ends up selected:\n\n```svelte {runnable}\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />\n```\n\n## Adding actions\n\nAn array is the shorthand for `{ actions }`. Each action gets the whole\nselection as `{ rows, ids }`, in **display order** - so it reads the same order\nthe user is looking at, even after a sort:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let log = $state<string[]>([])\n\n const actions: SelectionBarAction<Task>[] = [\n {\n key: 'done',\n label: 'Mark done',\n icon: 'M20 6 9 17l-5-5',\n action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',\n danger: true,\n action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />\n\n<ul>\n {#each log as line, i (i)}<li>{line}</li>{/each}\n</ul>\n```\n\n`icon` is the `d` of a single 24x24 SVG path, so an action array stays plain\ndata you can lift into its own module next to the columns.\n\n## Built-in actions\n\nStrings in the array are built-ins, so the common buttons need no wiring:\n\n| Key | Does |\n| --- | --- |\n| `'selectAll'` | Selects every row in the view, then disables itself |\n| `'editFields'` | Opens the bulk-edit drawer - see below |\n| `'deleteRows'` | Deletes the selected rows after an inline \"Delete N rows?\" confirm on the bar, then fires `onRowsDelete` with the rows, their ids and the data left. Not offered over a server row model |\n| `'separator'` | Draws a divider. Not counted against `maxVisible` |\n\nMix them with your own actions in one array:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'deleteRows' | 'separator'> = [\n 'selectAll',\n 'editFields',\n 'deleteRows',\n 'separator',\n { key: 'watch', label: 'Watch', action: () => {} },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />\n```\n\n## Bulk edit\n\n`'editFields'` opens a **drawer** - the same `SvDrawer` + `SvForm` the grid\nuses for single-row editing - holding one control per editable column. Change\nas many fields as you like and they apply to **every selected row**, as a\nsingle undo run.\n\nThe rule that makes that safe: **only fields you actually change are written.**\n\n| The field | Opens showing | Leave it alone and |\n| --- | --- | --- |\n| Every selected row agrees | That shared value | Nothing is written |\n| They disagree | Blank, labelled \"Multiple values\" | Each row keeps its own value |\n\nSo editing Status across twelve issues does not flatten their summaries to\nwhichever one happened to be first.\n\nThe field list is the grid's own editable, field-backed columns. A column with\n`editable: false` is not offered, because writing it in bulk would be a\nfootgun, and `editorOptions` carry over - a status column with a fixed set\nbecomes a picker in the drawer rather than a free-text box.\n\nOver [field types](../fields.md) the controls follow the field: a select\nor a user field becomes a picker (several values for a multiple select), a\nrating its stars, long text a text area, a date the date picker; a number is\ntyped as the field writes it (`$1,200`, `42` for 42%) and read by the\nfield's own parser. Attachments, links and computed fields are not offered.\n\nWhat it does per cell:\n\n- skips rows whose per-row `editable` rule refuses the write,\n- skips cells already holding the value, so they do not become empty undo steps,\n- fires `onCellValueChange` for every cell it does change,\n- appends the whole run's undo steps together, so `Ctrl+Z` walks the edit back\n rather than needing one press per cell.\n\nThe engine is exported if you want the same write from your own UI:\n\n```ts\nimport { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'\n\nconst fields = bulkEditableFields(ctrl) // what can be targeted\nconst { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show\nconst { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })\n```\n\n## On a server-side row model\n\nUnder `<SvGrid rowModel={ctl}>` with the Enterprise row model's selection\nturned on, the bar reads the model instead of the loaded rows: the count\nchip says what the rule selects - a million under select-all, even with\ntwenty rows on screen - and **Edit fields** sends the patch and the rule\nto the datasource's `updateWhere` as one request, rather than writing the\nloaded cells. See [Server selection](../server/server-selection.md).\n\n## Where it floats\n\n`position` pins the bar to the `'bottom'` (default) or `'top'` edge of the\ngrid.\n\nThe bar **floats** - it never resizes the grid, so a layout holding one does\nnot jump when a checkbox is ticked. To stop it hiding anything, the scroll area\ngains bottom padding the height of the bar while a selection is live: the last\nrow can always be scrolled clear, and the bar overlays that padding rather than\na row.\n\n```svelte {runnable}\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}\n/>\n```\n\n## Buttons that come and go\n\n`hidden` and `disabled` are re-checked on every render against the live\nselection, so buttons appear and grey out as the set changes rather than\nfreezing at first paint. Select one row and **Merge** is not there; select two\nand it appears:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const conditional: SelectionBarAction<Task>[] = [\n {\n key: 'merge',\n label: 'Merge',\n // Merging one task into itself is meaningless.\n hidden: ({ ids }) => ids.length < 2,\n action: () => {},\n },\n {\n key: 'close',\n label: 'Close',\n // Nothing to do when everything picked is already closed.\n disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),\n action: () => {},\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />\n```\n\n## More actions than fit\n\nPast `maxVisible` (6 by default) the tail folds into a `···` menu, which opens\n**away** from the edge the bar is pinned to - upward from a bottom bar, downward\nfrom a top one - so it never falls off the grid. A bar wide enough to scroll is\na bar whose last button nobody finds:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const many: SelectionBarAction<Task>[] = [\n 'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',\n ].map((label) => ({ key: label, label, action: () => {} }))\n</script>\n\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ actions: many, maxVisible: 3 }}\n/>\n```\n\n## Keyboard and screen readers\n\nThe bar is a `role=\"toolbar\"`, which is a promise about how it behaves, so it\nkeeps that promise:\n\n| Key | Does |\n| --- | --- |\n| `Tab` | One stop for the whole bar, not one per button |\n| `Left` / `Right` | Move between buttons, wrapping at each end |\n| `Esc` | Closes the overflow menu if it is open, otherwise clears the selection |\n\nThese are handled on the bar, so they apply while focus is inside it. `Esc`\npressed in the grid body still does whatever the grid does with it - the bar\ndoes not install a global key handler that would fight cell editing or an open\nmenu.\n\nThe count carries `aria-live=\"polite\"` so it is read as the selection changes.\nThe bar itself is not live - that would re-read every button on each tick.\n\n## Options\n\n| Option | Default | Does |\n| --- | --- | --- |\n| `actions` | `[]` | The buttons, left to right |\n| `position` | `'bottom'` | Which edge the bar floats against |\n| `maxVisible` | `6` | How many stay on the bar before the rest collapse. Separators do not count |\n| `hideClear` | `false` | Drop the trailing clear button |\n\n`selectionBar={true}` is shorthand for all defaults with no actions, and\n`selectionBar={[...]}` for `{ actions: [...] }`.\n\n## Related\n\nDemo `23-bulk-actions` builds the same idea by hand - a toolbar of your own\nmarkup driven by `onRowSelectionChange`. Reach for `selectionBar` when you want\nit working out of the box; hand-roll it when you want full control of the\nlayout.\n\n<!-- tutorial:learn-5-selection -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-5-selection\" data-docs-tutorial=\"learn-5-selection\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-5-selection.mp4\" poster=\"/tutorials/learn-5-selection.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 5: selection and bulk actions, 86 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-5-selection.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-5-selection.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 5: selection and bulk actions</strong> (1:26, silent). <a href=\"https://www.youtube.com/watch?v=NO_xuVJdqVQ\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Selecting rows is easy. Knowing which rows are selected after a sort, a filter or a refresh is the part that catches people out, and it comes down to one prop.</p>\n<p>A small backlog of tasks and the columns to show them.</p>\n<p>Three things on the component. showRowSelection adds the checkbox column. selectionBar floats the action bar. And getRowId is the one that matters: it tells the grid what identifies a row, so a selection survives sorting, filtering and new data arriving. Without it the grid falls back to position, and position changes.</p>\n<p>Tick a couple of rows and the bar appears with a live count and the actions you defined. It stays out of the way until there is something to act on.</p>\n<p>Now sort the table while those rows are ticked. The same three stay selected, because the grid is tracking ids rather than row numbers.</p>\n<p>Over server data the same idea scales: select-all becomes a rule, every row except these, rather than a list of ids the browser would have to hold. Lesson six groups the rows and adds totals.</p>\n<p>The file is on the page below. See you in lesson six.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-5-selection -->\n\n## See also\n\n- [Row selection](../accessibility.md)\n- [Server selection](../server/server-selection.md) - the bar over a million rows the grid never loaded\n- [Kanban board mode](./kanban-board.md)\n- [Scheduler](./scheduler.md)\n"
|
|
5005
5306
|
},
|
|
5006
5307
|
{
|
|
5007
5308
|
"slug": "help/rows/styling-rows",
|
|
@@ -5344,7 +5645,7 @@ export const docs = [
|
|
|
5344
5645
|
"path": "docs/help/ui-components/index.md",
|
|
5345
5646
|
"title": "SvGrid UI",
|
|
5346
5647
|
"section": "UI components",
|
|
5347
|
-
"markdown": "# SvGrid UI\n\n**SvGrid UI** is the Svelte 5 component suite that ships in the free\n`@svgrid/grid` package - 70+ buttons, inputs, pickers, overlays, and layout\nprimitives, each with its own tutorial page. Every one is **two things at once**:\n\n1. a **standalone control** you can drop into any Svelte app, and\n2. a **grid cell editor** - the same component SvGrid mounts when you edit a cell.\n\nThat dual nature is the point: the grid stays the flagship, and its editor kit is\nalso a full component library you can use on its own. Every component is\ntheme-driven, accessible, and dependency-free (see below). Try them live under the\n**SvGrid Editors** switcher in the examples gallery, or jump straight to any\ncomponent's tutorial in [the catalogue](#the-catalogue).\n\n> **Quick start with the CLI.** `npx @svgrid/ui try calendar` opens any component\n> in a sandbox in your browser; `npx @svgrid/ui add calendar` drops a ready-to-edit\n> starter into your app (and installs the dep). See [Add components with the CLI](./cli.md).\n\n\n\n## Design principles\n\n- **Theme-driven.** Every component styles itself from the grid's `--sg-*` theme\n tokens (`--sg-accent`, `--sg-bg`, `--sg-fg`, `--sg-border`, `--sg-radius`,\n `--sg-focus-ring`, ...). Pick any preset theme and the components follow - no\n per-component theming needed.\n- **Accessible.** Each control maps to its WAI-ARIA APG pattern (listbox, combobox,\n radiogroup, slider, tabs, tree, ...) with full keyboard support and roving\n tabindex where the pattern calls for it.\n- **Controlled or bound.** Components take a `value` and emit `onChange`, and the\n value is `$bindable` - so `<SvTextInput bind:value={name} />` works too (the\n editors, plus `SvMultiSelect` / `SvTreeSelect` / `SvGridSelect`). Overlay controls\n additionally emit `onCommit` / `onCancel` so the grid can save or cancel an edit.\n- **Portalled overlays.** Dropdown-style controls render their panel to `<body>`\n (carrying the theme tokens with them) so they are never clipped by the grid's\n scroll container.\n- **Dependency-free.** Icons are inline SVG; the date engine, mask engine and\n country data are plain TypeScript. No external UI or date libraries.\n\n## The catalogue\n\nEvery component has its own tutorial page - what it is, a live demo, the full\nprops table, common patterns, and accessibility notes. The overview link on each\ngroup heading is the at-a-glance rollup.\n\n### [Buttons & toggles](./buttons.md)\n\n[SvButton](./sv-button.md) · [SvButtonGroup](./sv-button-group.md) ·\n[SvRepeatButton](./sv-repeat-button.md) · [SvToggleButton](./sv-toggle-button.md) ·\n[SvSwitchButton](./sv-switch-button.md) · [SvCheckBox](./sv-check-box.md) ·\n[SvRadioGroup](./sv-radio-group.md) · [SvRating](./sv-rating.md)\n\n### [Inputs](./inputs.md)\n\n[SvTextInput](./sv-text-input.md) · [SvTextArea](./sv-text-area.md) ·\n[SvNumberInput](./sv-number-input.md) · [SvPasswordInput](./sv-password-input.md) ·\n[SvMaskedInput](./sv-masked-input.md) · [SvPhoneInput](./sv-phone-input.md) ·\n[SvColorInput](./sv-color-input.md) · [SvOtpInput](./sv-otp-input.md) ·\n[SvDurationInput](./sv-duration-input.md) · [SvTagsInput](./sv-tags-input.md)\n\n### [Selection](./selection.md)\n\n[SvListBox](./sv-list-box.md) · [SvDropDownList](./sv-drop-down-list.md) ·\n[SvComboBox](./sv-combo-box.md) · [SvAutoComplete](./sv-auto-complete.md) ·\n[SvMultiSelect](./sv-multi-select.md) · [SvTreeSelect](./sv-tree-select.md) ·\n[SvGridSelect](./sv-grid-select.md) · [SvGridDropdown](./sv-grid-dropdown.md) ·\n[SvCountryInput](./sv-country-input.md)\n\n### [Date & time](./date-time.md)\n\n[SvCalendar](./sv-calendar.md) · [SvTimePicker](./sv-time-picker.md) ·\n[SvDateTimePicker](./sv-date-time-picker.md) · [SvDateRangeInput](./sv-date-range-input.md)\n\n### [Range & meters](./range.md)\n\n[SvSlider](./sv-slider.md) · [SvGauge](./sv-gauge.md) · [SvProgress](./sv-progress.md) ·\n[SvCircularProgress](./sv-circular-progress.md) · [SvSparkline](./sv-sparkline.md) ·\n[SvStat](./sv-stat.md)\n\n### [Overlays & menus](./overlays.md)\n\n[SvPopover](./sv-popover.md) · [SvTooltip](./sv-tooltip.md) · [SvModal](./sv-modal.md) ·\n[SvDrawer](./sv-drawer.md) · [SvToaster](./sv-toaster.md) ·\n[SvContextMenu](./sv-context-menu.md) · [SvMenu](./sv-menu.md) · [SvMenuList](./sv-menu-list.md)\n\n### [Layout & composite](./layout.md)\n\n[SvTabs](./sv-tabs.md) · [SvAccordion](./sv-accordion.md) · [SvSplitter](./sv-splitter.md) ·\n[SvDockLayout](./sv-dock-layout.md) · [SvDockManager](./sv-dock-manager.md) ·\n[SvCard](./sv-card.md) · [SvDivider](./sv-divider.md) · [SvScrollArea](./sv-scroll-area.md) ·\n[SvGridChart](./sv-grid-chart.md) · [SvChartPanes](./sv-chart-panes.md) · [SvForm](./sv-form.md) · [SvField](./sv-field.md) ·\n[SvFileUpload](./sv-file-upload.md) · [SvCollapsible](./sv-collapsible.md) ·\n[Layout primitives](./sv-layout-primitives.md) (SvStack · SvGroup · SvSimpleGrid) ·\n[Typography](./sv-typography.md) (SvTitle · SvText · SvAnchor · SvBlockquote · SvMark · SvList) ·\n[Display primitives](./sv-display-primitives.md) (SvKbd · SvCode · SvAspectRatio · SvVisuallyHidden)\n\n### [Feedback & display](./feedback.md)\n\n[SvBadge](./sv-badge.md) · [SvSkeleton](./sv-skeleton.md) · [SvAlert](./sv-alert.md) ·\n[SvEmptyState](./sv-empty-state.md) · [SvChip](./sv-chip.md) · [SvTimeline](./sv-timeline.md) ·\n[SvAvatar](./sv-avatar.md) · [SvAvatarGroup](./sv-avatar-group.md) · [SvCarousel](./sv-carousel.md) ·\n[SvSpinner](./sv-spinner.md) · [SvLoadingOverlay](./sv-loading-overlay.md) · [SvResult](./sv-result.md)\n\n### [Navigation & rich](./navigation.md)\n\n[SvBreadcrumb](./sv-breadcrumb.md) · [SvPagination](./sv-pagination.md) ·\n[SvStepper](./sv-stepper.md) · [SvNavPane](./sv-nav-pane.md) · [SvTree](./sv-tree.md) ·\n[SvCommand](./sv-command.md) · [SvTour](./sv-tour.md) · [SvRichText](./sv-rich-text.md)\n\n## As grid cell editors\n\nThe date/time controls are the **rich-by-default** editors for `date`, `datetime`\nand `time` columns:\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n const columns = [\n { field: 'due', header: 'Due', editorType: 'date' }, // -> SvCalendar popover\n { field: 'at', header: 'At', editorType: 'datetime' }, // -> SvDateTimePicker\n { field: 'start', header: 'Start', editorType: 'time' }, // -> SvTimePicker dial\n ]\n</script>\n```\n\nTo opt back out to the plain native inputs, use `editorType: 'date-native'`,\n`'datetime-native'` or `'time-native'`.\n\n## Custom cell editors\n\nAny component - a built-in `Sv*` control or one you author - can be mounted as a\ncell editor by registering it under a type name, then naming that type on a\ncolumn. The grid hands it a uniform context: `value` plus `onChange` (update the\nin-progress value), `onCommit` (save + stop editing) and `onCancel` (discard).\n\n```svelte\n<script>\n import { SvGrid, registerCellEditor, SvRating } from '@svgrid/grid'\n\n // Map the grid's edit context onto the component's props.\n registerCellEditor('stars', {\n component: SvRating,\n props: (ctx) => ({ value: ctx.value, onChange: (v) => ctx.onCommit(v) }),\n })\n\n const columns = [{ field: 'score', header: 'Score', editorType: 'stars' }]\n</script>\n```\n\n`registerCellEditor(type, Component)` uses the default mapping (spreads `value`,\n`onChange`, `onCommit`, `onCancel`); pass a `{ component, props }` object for a\ncustom mapping. Also available: `getCellEditor`, `hasCellEditor`,\n`unregisterCellEditor`, `registeredCellEditorTypes`.\n\n**Built-in shortcuts.** Call `registerBuiltinEditors()` once to register the\nconfig-free editors so columns can use them by name straight away:\n\n```ts\nimport { registerBuiltinEditors } from '@svgrid/grid'\nregisterBuiltinEditors() // enables editorType 'otp' and 'duration'\n\nconst columns = [\n { field: 'code', editorType: 'otp' },\n { field: 'estimate', editorType: 'duration' },\n]\n```\n\nIt's opt-in (so the components tree-shake when unused). Option/structured editors\n(`SvMultiSelect`, `SvTreeSelect`, `SvGridSelect`) need their `options`/`nodes`/\n`columns`, so register those yourself with a `props` mapping as shown above.\n\n## More examples\n\n### Login block\n\nA two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an \"or\" SvDivider and forgot-password link. Drops into any route as-is.\n\n<div data-docs-demo=\"420-block-login\" data-height=\"520\"></div>\n\n### Sign-up block\n\nA centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.\n\n<div data-docs-demo=\"421-block-signup\" data-height=\"520\"></div>\n\n### Two-factor / OTP block\n\nThe verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.\n\n<div data-docs-demo=\"422-block-otp\" data-height=\"520\"></div>\n\n### Analytics dashboard block\n\nA full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.\n\n<div data-docs-demo=\"423-block-dashboard\" data-height=\"520\"></div>\n\n### App shell block\n\nA complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.\n\n<div data-docs-demo=\"424-block-app-shell\" data-height=\"520\"></div>\n\n### Pricing block\n\nA three-tier plan grid with a monthly/annual SvSegmented toggle (annual applies the discount live), a highlighted \"most popular\" card, per-plan feature SvLists with check marks, and a CTA SvButton on each.\n\n<div data-docs-demo=\"425-block-pricing\" data-height=\"520\"></div>\n"
|
|
5648
|
+
"markdown": "# SvGrid UI\n\n**SvGrid UI** is the Svelte 5 component suite that ships in the free\n`@svgrid/grid` package - 70+ buttons, inputs, pickers, overlays, and layout\nprimitives, each with its own tutorial page. Every one is **two things at once**:\n\n1. a **standalone control** you can drop into any Svelte app, and\n2. a **grid cell editor** - the same component SvGrid mounts when you edit a cell.\n\nThat dual nature is the point: the grid stays the flagship, and its editor kit is\nalso a full component library you can use on its own. Every component is\ntheme-driven, accessible, and dependency-free (see below). Try them live under the\n**SvGrid Editors** switcher in the examples gallery, or jump straight to any\ncomponent's tutorial in [the catalogue](#the-catalogue).\n\n> **Quick start with the CLI.** `npx @svgrid/ui try calendar` opens any component\n> in a sandbox in your browser; `npx @svgrid/ui add calendar` drops a ready-to-edit\n> starter into your app (and installs the dep). See [Add components with the CLI](./cli.md).\n\n\n\n## Design principles\n\n- **Theme-driven.** Every component styles itself from the grid's `--sg-*` theme\n tokens (`--sg-accent`, `--sg-bg`, `--sg-fg`, `--sg-border`, `--sg-radius`,\n `--sg-focus-ring`, ...). Pick any preset theme and the components follow - no\n per-component theming needed.\n- **Accessible.** Each control maps to its WAI-ARIA APG pattern (listbox, combobox,\n radiogroup, slider, tabs, tree, ...) with full keyboard support and roving\n tabindex where the pattern calls for it.\n- **Controlled or bound.** Components take a `value` and emit `onChange`, and the\n value is `$bindable` - so `<SvTextInput bind:value={name} />` works too (the\n editors, plus `SvMultiSelect` / `SvTreeSelect` / `SvGridSelect`). Overlay controls\n additionally emit `onCommit` / `onCancel` so the grid can save or cancel an edit.\n- **Portalled overlays.** Dropdown-style controls render their panel to `<body>`\n (carrying the theme tokens with them) so they are never clipped by the grid's\n scroll container.\n- **Dependency-free.** Icons are inline SVG; the date engine, mask engine and\n country data are plain TypeScript. No external UI or date libraries.\n\n## The catalogue\n\nEvery component has its own tutorial page - what it is, a live demo, the full\nprops table, common patterns, and accessibility notes. The overview link on each\ngroup heading is the at-a-glance rollup.\n\n### [The grid](../../getting-started.md)\n\n[SvGrid](../../getting-started.md) - the data grid and the hero component of the\nsuite. Every component below also works as a cell editor inside it.\n\n### [Buttons & toggles](./buttons.md)\n\n[SvButton](./sv-button.md) · [SvButtonGroup](./sv-button-group.md) ·\n[SvRepeatButton](./sv-repeat-button.md) · [SvToggleButton](./sv-toggle-button.md) ·\n[SvSwitchButton](./sv-switch-button.md) · [SvCheckBox](./sv-check-box.md) ·\n[SvRadioGroup](./sv-radio-group.md) · [SvRating](./sv-rating.md)\n\n### [Inputs](./inputs.md)\n\n[SvTextInput](./sv-text-input.md) · [SvTextArea](./sv-text-area.md) ·\n[SvNumberInput](./sv-number-input.md) · [SvPasswordInput](./sv-password-input.md) ·\n[SvMaskedInput](./sv-masked-input.md) · [SvPhoneInput](./sv-phone-input.md) ·\n[SvColorInput](./sv-color-input.md) · [SvOtpInput](./sv-otp-input.md) ·\n[SvDurationInput](./sv-duration-input.md) · [SvTagsInput](./sv-tags-input.md)\n\n### [Selection](./selection.md)\n\n[SvListBox](./sv-list-box.md) · [SvDropDownList](./sv-drop-down-list.md) ·\n[SvComboBox](./sv-combo-box.md) · [SvAutoComplete](./sv-auto-complete.md) ·\n[SvMultiSelect](./sv-multi-select.md) · [SvTreeSelect](./sv-tree-select.md) ·\n[SvGridSelect](./sv-grid-select.md) · [SvGridDropdown](./sv-grid-dropdown.md) ·\n[SvCountryInput](./sv-country-input.md)\n\n### [Date & time](./date-time.md)\n\n[SvCalendar](./sv-calendar.md) · [SvTimePicker](./sv-time-picker.md) ·\n[SvDateTimePicker](./sv-date-time-picker.md) · [SvDateRangeInput](./sv-date-range-input.md)\n\n### [Range & meters](./range.md)\n\n[SvSlider](./sv-slider.md) · [SvGauge](./sv-gauge.md) · [SvProgress](./sv-progress.md) ·\n[SvCircularProgress](./sv-circular-progress.md) · [SvSparkline](./sv-sparkline.md) ·\n[SvStat](./sv-stat.md)\n\n### [Overlays & menus](./overlays.md)\n\n[SvPopover](./sv-popover.md) · [SvTooltip](./sv-tooltip.md) · [SvModal](./sv-modal.md) ·\n[SvDrawer](./sv-drawer.md) · [SvToaster](./sv-toaster.md) ·\n[SvContextMenu](./sv-context-menu.md) · [SvMenu](./sv-menu.md) · [SvMenuList](./sv-menu-list.md)\n\n### [Layout & composite](./layout.md)\n\n[SvTabs](./sv-tabs.md) · [SvAccordion](./sv-accordion.md) · [SvSplitter](./sv-splitter.md) ·\n[SvDockLayout](./sv-dock-layout.md) · [SvDockManager](./sv-dock-manager.md) ·\n[SvCard](./sv-card.md) · [SvDivider](./sv-divider.md) · [SvScrollArea](./sv-scroll-area.md) ·\n[SvGridChart](./sv-grid-chart.md) · [SvChartPanes](./sv-chart-panes.md) · [SvForm](./sv-form.md) · [SvField](./sv-field.md) ·\n[SvFileUpload](./sv-file-upload.md) · [SvCollapsible](./sv-collapsible.md) ·\n[Layout primitives](./sv-layout-primitives.md) (SvStack · SvGroup · SvSimpleGrid) ·\n[Typography](./sv-typography.md) (SvTitle · SvText · SvAnchor · SvBlockquote · SvMark · SvList) ·\n[Display primitives](./sv-display-primitives.md) (SvKbd · SvCode · SvAspectRatio · SvVisuallyHidden)\n\n### [Feedback & display](./feedback.md)\n\n[SvBadge](./sv-badge.md) · [SvSkeleton](./sv-skeleton.md) · [SvAlert](./sv-alert.md) ·\n[SvEmptyState](./sv-empty-state.md) · [SvChip](./sv-chip.md) · [SvTimeline](./sv-timeline.md) ·\n[SvAvatar](./sv-avatar.md) · [SvAvatarGroup](./sv-avatar-group.md) · [SvCarousel](./sv-carousel.md) ·\n[SvSpinner](./sv-spinner.md) · [SvLoadingOverlay](./sv-loading-overlay.md) · [SvResult](./sv-result.md)\n\n### [Navigation & rich](./navigation.md)\n\n[SvBreadcrumb](./sv-breadcrumb.md) · [SvPagination](./sv-pagination.md) ·\n[SvStepper](./sv-stepper.md) · [SvNavPane](./sv-nav-pane.md) · [SvTree](./sv-tree.md) ·\n[SvCommand](./sv-command.md) · [SvTour](./sv-tour.md) · [SvRichText](./sv-rich-text.md)\n\n## As grid cell editors\n\nThe date/time controls are the **rich-by-default** editors for `date`, `datetime`\nand `time` columns:\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n const columns = [\n { field: 'due', header: 'Due', editorType: 'date' }, // -> SvCalendar popover\n { field: 'at', header: 'At', editorType: 'datetime' }, // -> SvDateTimePicker\n { field: 'start', header: 'Start', editorType: 'time' }, // -> SvTimePicker dial\n ]\n</script>\n```\n\nTo opt back out to the plain native inputs, use `editorType: 'date-native'`,\n`'datetime-native'` or `'time-native'`.\n\n## Custom cell editors\n\nAny component - a built-in `Sv*` control or one you author - can be mounted as a\ncell editor by registering it under a type name, then naming that type on a\ncolumn. The grid hands it a uniform context: `value` plus `onChange` (update the\nin-progress value), `onCommit` (save + stop editing) and `onCancel` (discard).\n\n```svelte\n<script>\n import { SvGrid, registerCellEditor, SvRating } from '@svgrid/grid'\n\n // Map the grid's edit context onto the component's props.\n registerCellEditor('stars', {\n component: SvRating,\n props: (ctx) => ({ value: ctx.value, onChange: (v) => ctx.onCommit(v) }),\n })\n\n const columns = [{ field: 'score', header: 'Score', editorType: 'stars' }]\n</script>\n```\n\n`registerCellEditor(type, Component)` uses the default mapping (spreads `value`,\n`onChange`, `onCommit`, `onCancel`); pass a `{ component, props }` object for a\ncustom mapping. Also available: `getCellEditor`, `hasCellEditor`,\n`unregisterCellEditor`, `registeredCellEditorTypes`.\n\n**Built-in shortcuts.** Call `registerBuiltinEditors()` once to register the\nconfig-free editors so columns can use them by name straight away:\n\n```ts\nimport { registerBuiltinEditors } from '@svgrid/grid'\nregisterBuiltinEditors() // enables editorType 'otp' and 'duration'\n\nconst columns = [\n { field: 'code', editorType: 'otp' },\n { field: 'estimate', editorType: 'duration' },\n]\n```\n\nIt's opt-in (so the components tree-shake when unused). Option/structured editors\n(`SvMultiSelect`, `SvTreeSelect`, `SvGridSelect`) need their `options`/`nodes`/\n`columns`, so register those yourself with a `props` mapping as shown above.\n\n## More examples\n\n### Login block\n\nA two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an \"or\" SvDivider and forgot-password link. Drops into any route as-is.\n\n<div data-docs-demo=\"420-block-login\" data-height=\"520\"></div>\n\n### Sign-up block\n\nA centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.\n\n<div data-docs-demo=\"421-block-signup\" data-height=\"520\"></div>\n\n### Two-factor / OTP block\n\nThe verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.\n\n<div data-docs-demo=\"422-block-otp\" data-height=\"520\"></div>\n\n### Analytics dashboard block\n\nA full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.\n\n<div data-docs-demo=\"423-block-dashboard\" data-height=\"520\"></div>\n\n### App shell block\n\nA complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.\n\n<div data-docs-demo=\"424-block-app-shell\" data-height=\"520\"></div>\n\n### Pricing block\n\nA three-tier plan grid with a monthly/annual SvSegmented toggle (annual applies the discount live), a highlighted \"most popular\" card, per-plan feature SvLists with check marks, and a CTA SvButton on each.\n\n<div data-docs-demo=\"425-block-pricing\" data-height=\"520\"></div>\n"
|
|
5348
5649
|
},
|
|
5349
5650
|
{
|
|
5350
5651
|
"slug": "help/ui-components/inputs",
|
|
@@ -6002,7 +6303,7 @@ export const docs = [
|
|
|
6002
6303
|
"path": "docs/help/web-components.md",
|
|
6003
6304
|
"title": "Web Components & Custom Elements",
|
|
6004
6305
|
"section": "Help",
|
|
6005
|
-
"markdown": "# Web Components & Custom Elements\n\nShip SvGrid as a framework-agnostic web component and use the Svelte 5 data grid\nin React, Vue, Angular, or plain HTML - no Svelte required in the host app.\n\n\n\nSvGrid is authored in Svelte 5, but Svelte compiles to standards-based\n[custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).\n`@svgrid/grid-wc` publishes two of them, prebuilt: drop in one `<script>` tag\nand use `<sv-grid>` like any built-in element. If you searched for a \"web\ncomponent data grid\" or a \"framework-agnostic Svelte table\", this is the\nintegration path.\n\n<!-- tutorial:mk-web-component -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-mk-web-component\" data-docs-tutorial=\"mk-web-component\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-web-component.mp4\" poster=\"/tutorials/mk-web-component.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"A data grid web component: one script tag, any framework, 133 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-web-component.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-web-component.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>A data grid web component: one script tag, any framework</strong> (2:13, with narration). <a href=\"https://www.youtube.com/watch?v=_zWVutDXlVY\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-web-component -->\n\n## Start here\n\n| Page | What it covers |\n| --- | --- |\n| [**React, Vue or Angular**](./web-components/frameworks.md) | **Start here if you use one.** A running project in about thirty seconds, and 27 examples you can open and edit. |\n| [Quick start](./web-components/quick-start.md) | The CDN path, and the attribute-vs-property rule that trips everyone up once. |\n| [`<sv-grid>` reference](./web-components/sv-grid.md) | Every property, attribute and event. Generated from the grid's own types. |\n| [Shadow DOM](./web-components/shadow-dom.md) | `<sv-grid-shadow>`, for pages whose CSS you do not control. |\n| [React](./web-components/react.md) | The `@svgrid/grid-wc/react` component, and why React 18 needs it. |\n| [Vue](./web-components/vue.md) | The `@svgrid/grid-wc/vue` component - no `isCustomElement` config, no `.prop` modifiers. |\n| [Angular](./web-components/angular.md) | The `@svgrid/grid-wc/angular` standalone component, with typed inputs and outputs. |\n| [TypeScript](./web-components/typescript.md) | Typing the element, its properties and its events. |\n| [Enterprise features](./web-components/enterprise.md) | Excel / PDF export, import, print and pivot from a non-Svelte host - and the two views that need a Svelte-aware bundler. |\n| [Limitations](./web-components/limitations.md) | What cannot cross the boundary, and why. |\n\n## Framework wrappers\n\nThe package ships a component for each of the three big frameworks, generated\nfrom the same types as the element, as subpath imports:\n\n| Framework | Import |\n| --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` |\n\nEach framework is an OPTIONAL peer dependency, so a plain-HTML consumer never\ninstalls any of them, and each wrapper is a couple of KB that reuses the one\nelement bundle rather than shipping a second copy of the grid.\n\n**[Start here](./web-components/frameworks.md)** for the thirty-second path and\nnine runnable examples per framework - each one opens as a full editable\nproject, with no local setup.\n\n## Why a custom element\n\n- **Use it anywhere.** React, Vue, Angular, Solid, Astro, Rails/Laravel\n server-rendered pages, or a plain `<script>` tag. The browser treats\n `<sv-grid>` like any built-in element.\n- **One bundle, no framework lock-in.** Ship a single file to a CDN; consumers\n add one `<script type=\"module\">` and a tag.\n- **The whole grid.**
|
|
6306
|
+
"markdown": "# Web Components & Custom Elements\n\nShip SvGrid as a framework-agnostic web component and use the Svelte 5 data grid\nin React, Vue, Angular, or plain HTML - no Svelte required in the host app.\n\n\n\nSvGrid is authored in Svelte 5, but Svelte compiles to standards-based\n[custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).\n`@svgrid/grid-wc` publishes two of them, prebuilt: drop in one `<script>` tag\nand use `<sv-grid>` like any built-in element. If you searched for a \"web\ncomponent data grid\" or a \"framework-agnostic Svelte table\", this is the\nintegration path.\n\n<!-- tutorial:mk-web-component -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-mk-web-component\" data-docs-tutorial=\"mk-web-component\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-web-component.mp4\" poster=\"/tutorials/mk-web-component.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"A data grid web component: one script tag, any framework, 133 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-web-component.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-web-component.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>A data grid web component: one script tag, any framework</strong> (2:13, with narration). <a href=\"https://www.youtube.com/watch?v=_zWVutDXlVY\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-web-component -->\n\n## Start here\n\n| Page | What it covers |\n| --- | --- |\n| [**React, Vue or Angular**](./web-components/frameworks.md) | **Start here if you use one.** A running project in about thirty seconds, and 27 examples you can open and edit. |\n| [Quick start](./web-components/quick-start.md) | The CDN path, and the attribute-vs-property rule that trips everyone up once. |\n| [`<sv-grid>` reference](./web-components/sv-grid.md) | Every property, attribute and event. Generated from the grid's own types. |\n| [Shadow DOM](./web-components/shadow-dom.md) | `<sv-grid-shadow>`, for pages whose CSS you do not control. |\n| [React](./web-components/react.md) | The `@svgrid/grid-wc/react` component, and why React 18 needs it. |\n| [Vue](./web-components/vue.md) | The `@svgrid/grid-wc/vue` component - no `isCustomElement` config, no `.prop` modifiers. |\n| [Angular](./web-components/angular.md) | The `@svgrid/grid-wc/angular` standalone component, with typed inputs and outputs. |\n| [TypeScript](./web-components/typescript.md) | Typing the element, its properties and its events. |\n| [Enterprise features](./web-components/enterprise.md) | Excel / PDF export, import, print and pivot from a non-Svelte host - and the two views that need a Svelte-aware bundler. |\n| [Limitations](./web-components/limitations.md) | What cannot cross the boundary, and why. |\n\n## Framework wrappers\n\nThe package ships a component for each of the three big frameworks, generated\nfrom the same types as the element, as subpath imports:\n\n| Framework | Import |\n| --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` |\n\nEach framework is an OPTIONAL peer dependency, so a plain-HTML consumer never\ninstalls any of them, and each wrapper is a couple of KB that reuses the one\nelement bundle rather than shipping a second copy of the grid.\n\n**[Start here](./web-components/frameworks.md)** for the thirty-second path and\nnine runnable examples per framework - each one opens as a full editable\nproject, with no local setup.\n\n## Why a custom element\n\n- **Use it anywhere.** React, Vue, Angular, Solid, Astro, Rails/Laravel\n server-rendered pages, or a plain `<script>` tag. The browser treats\n `<sv-grid>` like any built-in element.\n- **One bundle, no framework lock-in.** Ship a single file to a CDN; consumers\n add one `<script type=\"module\">` and a tag.\n- **The whole grid.** 124 properties and 33 events, so virtualization,\n Excel-style filters, inline editing, grouping, tree data, pinning, pagination\n and the enterprise features are all reachable - not a hand-picked subset. The\n surface is **generated** from `<SvGrid>`'s own `Props` type and CI fails if\n the two drift. Exactly two props are left out, and the\n [reference](./web-components/sv-grid.md) says why.\n\nIf your team *is* on Svelte 5, use the component directly\n([First grid](../getting-started/2-first-grid.md)) - the custom element exists\nto reach non-Svelte hosts, and it costs you the Svelte runtime to do it.\n\n## Quick look\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc\"></script>\n\n<sv-grid id=\"grid\" sortable filterable pageable page-size=\"25\"\n style=\"display:block;height:420px\"></sv-grid>\n\n<script type=\"module\">\n const grid = document.getElementById('grid')\n // Arrays and objects are properties; primitives can be attributes.\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'role', header: 'Role' }]\n grid.data = [{ name: 'Ada', role: 'Engineering' }, { name: 'Alan', role: 'Research' }]\n grid.addEventListener('cellvaluechange', (e) => console.log(e.detail))\n</script>\n```\n\nFull walkthrough in [Quick start](./web-components/quick-start.md).\n\n## Building your own element\n\nThe published package is the recommended path, but the wrapper is about sixty\nlines and you may want a narrower surface, your own tag name, or extra\nbehaviour. The source is `packages/grid-wc/src` in the repository:\n`sv-grid-element.svelte` is the light-DOM wrapper, `GridBody.svelte` is the part\nboth elements share, and `scripts/generate-surface.mjs` writes the prop and\nevent declarations from `<SvGrid>`'s `Props` type.\n\n## See also\n\n- [Enterprise features from the element](./web-components/enterprise.md) -\n export, import, print and pivot need no Svelte in your build; the board and\n scheduler views do.\n- [Tailwind integration](./tailwind.md) - the full `--sg-*` token list.\n"
|
|
6006
6307
|
},
|
|
6007
6308
|
{
|
|
6008
6309
|
"slug": "help/web-components/angular",
|
|
@@ -6023,7 +6324,7 @@ export const docs = [
|
|
|
6023
6324
|
"path": "docs/help/web-components/frameworks.md",
|
|
6024
6325
|
"title": "SvGrid with React, Vue or Angular",
|
|
6025
6326
|
"section": "help/web-components",
|
|
6026
|
-
"markdown": "# SvGrid with React, Vue or Angular\n\nPick your framework and you have a running grid in about thirty seconds - no\nlocal setup, nothing to configure.\n\n## The 30-second path\n\n**1.** Install one package:\n\n<div data-docs-install=\"@svgrid/grid-wc\"></div>\n\n**2.** Import the component for your framework and render it:\n\n| Framework | Import | Tag |\n| --- | --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` | `<SvGrid data={rows} columns={cols} sortable filterable />` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` | `<SvGrid :data=\"rows\" :columns=\"cols\" sortable filterable />` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` | `<sv-grid [data]=\"rows\" [columns]=\"cols\" [sortable]=\"true\" />` |\n\nThat is the whole integration. All
|
|
6327
|
+
"markdown": "# SvGrid with React, Vue or Angular\n\nPick your framework and you have a running grid in about thirty seconds - no\nlocal setup, nothing to configure.\n\n## The 30-second path\n\n**1.** Install one package:\n\n<div data-docs-install=\"@svgrid/grid-wc\"></div>\n\n**2.** Import the component for your framework and render it:\n\n| Framework | Import | Tag |\n| --- | --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` | `<SvGrid data={rows} columns={cols} sortable filterable />` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` | `<SvGrid :data=\"rows\" :columns=\"cols\" sortable filterable />` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` | `<sv-grid [data]=\"rows\" [columns]=\"cols\" [sortable]=\"true\" />` |\n\nThat is the whole integration. All 124 properties and 33 events of the grid are\ntyped props on every one of them.\n\n**Or skip step 1** and open a working project right now:\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"vue:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"angular:basic\" data-title=\"A first grid\"></div>\n\n## The examples\n\n<!-- BEGIN generated recipe table - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\nThe same nine apps in each framework, so you can compare them\ndirectly - only the framework differs, never the data or the grid\nconfiguration. Every one is compiled in CI, so what you open is what works.\n\n| Recipe | What it shows | React | Vue | Angular |\n| --- | --- | --- | --- | --- |\n| A first grid | Rows, columns, and the two features almost every table wants | [→](./react.md#a-first-grid) | [→](./vue.md#a-first-grid) | [→](./angular.md#a-first-grid) |\n| Sorting and filtering | A filter row under the headers, multi-column sort, and the current sort read back into your own state | [→](./react.md#sorting-and-filtering) | [→](./vue.md#sorting-and-filtering) | [→](./angular.md#sorting-and-filtering) |\n| Editing and saving | Inline editing, with each committed edit arriving through `cellvaluechange` | [→](./react.md#editing-and-saving) | [→](./vue.md#editing-and-saving) | [→](./angular.md#editing-and-saving) |\n| Row selection | Checkboxes, with the selected rows handed straight to you - both the selection map and the rows themselves | [→](./react.md#row-selection) | [→](./vue.md#row-selection) | [→](./angular.md#row-selection) |\n| Grouping and totals | Group by one or two columns with an aggregate in the group row | [→](./react.md#grouping-and-totals) | [→](./vue.md#grouping-and-totals) | [→](./angular.md#grouping-and-totals) |\n| Pagination | Client-side paging | [→](./react.md#pagination) | [→](./vue.md#pagination) | [→](./angular.md#pagination) |\n| Server-side data | The grid renders the page you hand it and tells you when the user wants another | [→](./react.md#server-side-data) | [→](./vue.md#server-side-data) | [→](./angular.md#server-side-data) |\n| Theming | The `--sg-*` custom properties | [→](./react.md#theming) | [→](./vue.md#theming) | [→](./angular.md#theming) |\n| Excel export (Enterprise) | The paid pack from a non-Svelte host | [→](./react.md#excel-export-enterprise) | [→](./vue.md#excel-export-enterprise) | [→](./angular.md#excel-export-enterprise) |\n\n<!-- END generated recipe table -->\n## The chart element\n\nThe standalone chart ships as its own element, `<sv-chart>`, in the same\npackage: `import '@svgrid/grid-wc/chart'` registers it, and\n`@svgrid/grid-wc/react/chart`, `@svgrid/grid-wc/vue/chart` and the\n`SvChartComponent` export of the Angular package wrap it the same way the\ngrid is wrapped. Set `spec` as a property; the primitives (`zoomable`,\n`range-presets`, `legend=\"right\"`, `animate`, `drawable`) are attributes.\nThe [`<sv-chart>` reference](sv-chart.md) lists all of it, and the\n[charts guide](../charts.md) says what goes in a spec. It carries none of the\ngrid, so a page that only charts loads the chart.\n\n## What each wrapper is actually for\n\nThe grid is a custom element underneath, and a custom element is awkward in each\nframework in a different way. Each wrapper removes exactly that awkwardness:\n\n- **React** - React 18 and earlier stringify object props onto **attributes**,\n so `columns={cols}` silently becomes `\"[object Object]\"` and the grid renders\n empty with no error. The wrapper assigns them as properties on every version.\n- **Vue** - removes the `isCustomElement` build config and the `.prop` modifier\n that every object binding would otherwise need.\n- **Angular** - removes `CUSTOM_ELEMENTS_SCHEMA` from every component that shows\n a grid, and gives you typed `@Input` / `@Output` instead of an untyped\n element. Its selector is the element's own tag, so there is no extra wrapper\n element in your DOM.\n\nAll three also handle two ordering problems you would otherwise meet yourself:\nthe element renders **before** a framework assigns properties in an effect, and\n`apiready` fires once during that first mount - before React can bind a listener\nat all.\n\n## The imperative api\n\nEvery wrapper exposes the grid api once it is ready:\n\n- **React** - a ref: `grid.current?.api`\n- **Vue** - a template ref: `grid.value?.api`\n- **Angular** - the component instance: `grid.api`\n\n## Not using a framework?\n\n`<sv-grid>` works in plain HTML with one `<script>` tag - see\n[Quick start](./quick-start.md).\n\n## See also\n\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [Shadow DOM](./shadow-dom.md) - for pages whose CSS you do not control.\n- [TypeScript](./typescript.md) - typing the raw element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
|
|
6027
6328
|
},
|
|
6028
6329
|
{
|
|
6029
6330
|
"slug": "help/web-components/limitations",
|
|
@@ -6044,14 +6345,14 @@ export const docs = [
|
|
|
6044
6345
|
"path": "docs/help/web-components/react.md",
|
|
6045
6346
|
"title": "SvGrid in React",
|
|
6046
6347
|
"section": "help/web-components",
|
|
6047
|
-
"markdown": "# SvGrid in React\n\n```bash\nnpm install @svgrid/grid-wc\n```\n\n```tsx\nimport { SvGrid } from '@svgrid/grid-wc/react'\n\nexport default function Screen() {\n return (\n <div style={{ height: 480 }}>\n <SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n onCellValueChange={(e) => save(e)}\n />\n </div>\n )\n}\n```\n\nThat is the whole integration. The component is generated from `<SvGrid>`'s\nown types, so all 116 properties and 27 events are typed props, and it works the\nsame on React 17, 18 and 19.\n\n## Why the wrapper rather than the raw element\n\n- **React 18 and earlier stringify object props onto attributes.**\n `columns={cols}` becomes the attribute `\"[object Object]\"` and the grid\n renders empty, with no error. React 19 assigns them as properties; the wrapper\n does so on every version.\n- **The element renders before your props arrive.** Both React and Angular\n create the element first and assign properties in an effect, so the grid\n mounts once with nothing. The wrapper handles that ordering.\n- **`apiready` fires once, during that first mount** - before React can bind a\n listener, so a raw `addEventListener` misses it entirely. The wrapper replays\n it.\n- Typed props and handlers, instead of `el.columns = ...` on an `HTMLElement`.\n\n## Shadow DOM\n\n`shadow` picks the style-isolated element. Same props, same events; the extra\nbundle loads only when you ask for it.\n\n```tsx\n<SvGrid shadow data={rows} columns={columns} />\n```\n\n## The imperative api\n\n```tsx\nimport { useRef } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\n\nexport function Screen() {\n const grid = useRef<SvGridHandle>(null)\n\n const exportCsv = () => {\n // `api` is the grid api; `element` is the host custom element.\n ;(grid.current?.api as { exportCsv(): void } | undefined)?.exportCsv()\n }\n\n return <SvGrid ref={grid} data={rows} columns={columns} />\n}\n```\n\n<!-- BEGIN generated examples - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\n## Examples\n\nNine complete apps. Each one is **running on this page** - sort it,\nfilter it, edit a cell - above the code that produces it. **Open in StackBlitz**\nthen boots the whole project, editable, with no local install and nothing to\nconfigure, and every one is compiled in this repository's CI, so what you open\nis what works.\n\nThey all share the same typed `data.ts`, so the only thing that changes\nbetween recipes is the grid.\n\nA note on the previews: the grid you can touch is driven by a Svelte host\nrather than a React one. It is not a recording or a\nlookalike - it renders through the same component `<sv-grid>` renders, with\nthe same data and the same props as the listing below it, so the only thing\nthat differs from the React app is who sets those props. Click **Open in\nStackBlitz** to run the real React one.\n\n### A first grid\n\nRows, columns, and the two features almost every table wants.\n\n<div data-docs-mirror=\"basic\" data-height=\"420\"></div>\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * The smallest useful grid: rows, columns, and the two features almost every\n * table wants. `data` and `columns` are arrays, so they cross as properties -\n * the wrapper handles that on every React version.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid data={people} columns={columns} sortable filterable />\n </div>\n )\n}\n```\n\n### Sorting and filtering\n\nA filter row under the headers, multi-column sort, and the current sort read back into your own state.\n\n<div data-docs-mirror=\"sorting-filtering\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:sorting-filtering\" data-title=\"Sorting and filtering\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * A filter row under the headers, multi-column sort, and the current sort read\n * back into React state. `onSortingChange` hands you the same array the grid\n * holds, so it can drive your own UI.\n */\nexport default function App() {\n const [sorting, setSorting] = useState<Array<{ id: string; desc: boolean }>>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Sorted by:{' '}\n {sorting.length === 0\n ? 'nothing yet - click a header, then shift-click a second one'\n : sorting.map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`).join(', ')}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showFilterRow\n onSortingChange={setSorting}\n />\n </div>\n </div>\n )\n}\n```\n\n### Editing and saving\n\nInline editing, with each committed edit arriving through `cellvaluechange`. Swap the local update for your save call.\n\n<div data-docs-mirror=\"editing\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:editing\" data-title=\"Editing and saving\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Inline editing, with the change written back into React state.\n *\n * `cellvaluechange` fires once per committed edit and its detail is typed, so\n * `rowIndex`, `columnId` and `newValue` come through without a cast. Replace\n * the setState with your own save call.\n */\nexport default function App() {\n const [rows, setRows] = useState<Person[]>(people)\n const [log, setLog] = useState<string[]>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Double-click a Name or Amount cell. Last edits: {log.slice(-3).join(' · ') || 'none yet'}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={rows}\n columns={columns.map((c) =>\n c.id === 'name' || c.id === 'amount' ? { ...c, editable: true } : c,\n )}\n sortable\n filterable\n editable\n onCellValueChange={(e) => {\n setRows((prev) =>\n prev.map((row, i) => (i === e.rowIndex ? { ...row, [e.columnId]: e.newValue } : row)),\n )\n setLog((prev) => [...prev, `${e.columnId} = ${String(e.newValue)}`])\n }}\n />\n </div>\n </div>\n )\n}\n```\n\n### Row selection\n\nCheckboxes, with the selected rows handed straight to you - both the selection map and the rows themselves.\n\n<div data-docs-mirror=\"selection\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:selection\" data-title=\"Row selection\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Row checkboxes, with the selected rows handed to React so you can act on\n * them. `rowselectionchange` carries both the selection map and the rows\n * themselves, so you rarely need to look anything up.\n */\nexport default function App() {\n const [selected, setSelected] = useState<Person[]>([])\n const total = selected.reduce((sum, r) => sum + r.amount, 0)\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ font: '13px system-ui', color: '#64748b' }}>\n {selected.length === 0\n ? 'Tick some rows to total them'\n : `${selected.length} selected · ${total.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showRowSelection\n onRowSelectionChange={(e) => setSelected(e.rows as Person[])}\n />\n </div>\n </div>\n )\n}\n```\n\n### Grouping and totals\n\nGroup by one or two columns with an aggregate in the group row. `groupBy` is an array, so it is one of the props that can only be a property.\n\n<div data-docs-mirror=\"grouping\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:grouping\" data-title=\"Grouping and totals\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Grouping by one or two columns, with an aggregate in the group row.\n *\n * `groupBy` is an array, so it is one of the props that can only be a\n * property - which is the whole reason the React wrapper exists.\n */\nexport default function App() {\n const [groupBy, setGroupBy] = useState<string[]>(['team'])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ display: 'flex', gap: 8, font: '13px system-ui' }}>\n {[['team'], ['country'], ['team', 'country'], []].map((g) => (\n <button\n key={g.join('+') || 'none'}\n onClick={() => setGroupBy(g)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: groupBy.join() === g.join() ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {g.join(' + ') || 'No grouping'}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns.map((c) => (c.id === 'amount' ? { ...c, aggregate: 'sum' } : c))}\n sortable\n filterable\n groupable\n groupBy={groupBy}\n groupFooters\n />\n </div>\n </div>\n )\n}\n```\n\n### Pagination\n\nClient-side paging. `pageSize` is the INITIAL page size, read once at mount.\n\n<div data-docs-mirror=\"pagination\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:pagination\" data-title=\"Pagination\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Client-side paging. `pageSize` is read once at mount - it is the INITIAL\n * page size - so change pages through the footer or the grid api rather than\n * by reassigning the prop.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n pageable\n pageSize={25}\n pageSizeOptions={[10, 25, 50, 100]}\n paginationPosition=\"bottom\"\n />\n </div>\n )\n}\n```\n\n### Server-side data\n\nThe grid renders the page you hand it and tells you when the user wants another. `externalSort` and `externalPagination` stop it doing the work locally; `rowCount` is how it knows how many pages exist.\n\n<div data-docs-mirror=\"server-data\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:server-data\" data-title=\"Server-side data\"></div>\n\n```tsx {nocheck}\nimport { useEffect, useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\ntype Query = { page: number; sort: Array<{ id: string; desc: boolean }> }\n\n/**\n * Server-side data: the grid renders whatever page you hand it and tells you\n * when the user wants a different one.\n *\n * `externalSort` and `externalPagination` stop the grid doing the work\n * locally; `rowCount` is how it knows how many pages exist. `fetchPage` here\n * stands in for your API call.\n */\nconst PAGE_SIZE = 25\n\nfunction fetchPage({ page, sort }: Query): Promise<{ rows: Person[]; total: number }> {\n // Pretend this is a network call.\n const sorted = [...people].sort((a, b) => {\n for (const s of sort) {\n const av = a[s.id as keyof Person]\n const bv = b[s.id as keyof Person]\n if (av === bv) continue\n return (av > bv ? 1 : -1) * (s.desc ? -1 : 1)\n }\n return 0\n })\n const start = page * PAGE_SIZE\n return new Promise((resolve) =>\n setTimeout(() => resolve({ rows: sorted.slice(start, start + PAGE_SIZE), total: sorted.length }), 250),\n )\n}\n\nexport default function App() {\n const [query, setQuery] = useState<Query>({ page: 0, sort: [] })\n const [rows, setRows] = useState<Person[]>([])\n const [total, setTotal] = useState(0)\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n let cancelled = false\n setLoading(true)\n fetchPage(query).then((res) => {\n if (cancelled) return\n setRows(res.rows)\n setTotal(res.total)\n setLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [query])\n\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={rows}\n columns={columns}\n loading={loading}\n sortable\n externalSort\n externalPagination\n pageable\n pageSize={PAGE_SIZE}\n rowCount={total}\n pageIndex={query.page}\n onSortingChange={(sort) => setQuery((q) => ({ ...q, sort, page: 0 }))}\n onPaginationChange={(p) => setQuery((q) => ({ ...q, page: (p as { pageIndex: number }).pageIndex }))}\n />\n </div>\n )\n}\n```\n\n### Theming\n\nThe `--sg-*` custom properties. Ordinary CSS custom properties, so they cascade from any ancestor - which is why they also reach inside `<sv-grid-shadow>`.\n\n<div data-docs-mirror=\"theming\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:theming\" data-title=\"Theming\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Theming through `--sg-*` custom properties.\n *\n * They are ordinary CSS custom properties, so they cascade: set them on any\n * ancestor and the grid inherits. That is also why they reach into\n * `<sv-grid-shadow>` - inheritance crosses a shadow boundary.\n */\nconst THEMES = {\n Light: {},\n Dark: {\n '--sg-bg': '#0b1220',\n '--sg-fg': '#e2e8f0',\n '--sg-border': '#1e293b',\n '--sg-header-bg': '#111a2e',\n '--sg-row-hover': '#111a2e',\n },\n Warm: {\n '--sg-bg': '#fffaf5',\n '--sg-fg': '#42302a',\n '--sg-border': '#f0dcc9',\n '--sg-header-bg': '#fdf1e4',\n '--sg-accent': '#c2410c',\n },\n} satisfies Record<string, Record<string, string>>\n\nexport default function App() {\n const [theme, setTheme] = useState<keyof typeof THEMES>('Light')\n\n return (\n <div\n style={{\n height: '100%',\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n gap: 12,\n ...THEMES[theme],\n }}\n >\n <div style={{ display: 'flex', gap: 8 }}>\n {(Object.keys(THEMES) as Array<keyof typeof THEMES>).map((name) => (\n <button\n key={name}\n onClick={() => setTheme(name)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: theme === name ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {name}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid data={people} columns={columns} sortable filterable zebraRows />\n </div>\n </div>\n )\n}\n```\n\n### Excel export (Enterprise)\n\nThe paid pack from a non-Svelte host. `@svgrid/enterprise/export` is plain JavaScript, so it needs no Svelte in your build - the same goes for `/import`, `/print`, `/pivot` and `/license`. See [Enterprise features](./enterprise.md) for what those subpaths cover and what needs a Svelte-aware bundler.\n\n<div data-docs-mirror=\"enterprise\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:enterprise\" data-title=\"Excel export (Enterprise)\"></div>\n\n```tsx {nocheck}\nimport { useRef, useState } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\nimport { exportGrid } from '@svgrid/enterprise/export'\nimport { people, columns } from '../data'\n\n/**\n * Excel export from a React host.\n *\n * `@svgrid/enterprise/export` is plain JavaScript - no Svelte in your build -\n * so this works in any bundler. The same is true of `/import`, `/print`,\n * `/pivot` and `/license`. The Kanban and scheduler views are the exception:\n * they are Svelte components, so they need a Svelte-aware bundler.\n *\n * The api arrives on the handle once `apiready` has fired. It is typed\n * `unknown` there because the wrapper does not depend on the grid's types, so\n * the enterprise call needs a cast.\n */\nexport default function App() {\n const grid = useRef<SvGridHandle>(null)\n const [ready, setReady] = useState(false)\n const [busy, setBusy] = useState(false)\n\n async function exportXlsx() {\n if (!grid.current?.api) return\n setBusy(true)\n try {\n await exportGrid(grid.current.api as Parameters<typeof exportGrid>[0], {\n format: 'xlsx',\n filename: 'people',\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <button type=\"button\" onClick={exportXlsx} disabled={!ready || busy}>\n {busy ? 'Building…' : 'Export to Excel'}\n </button>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n ref={grid}\n data={people}\n columns={columns}\n sortable\n filterable\n onApiReady={() => setReady(true)}\n />\n </div>\n </div>\n )\n}\n```\n\n<!-- END generated examples -->\n\n## Rendering into cells\n\nYou cannot put a React component inside a cell: cell rendering is a Svelte\ncompile-time feature, and there is nothing to hand across the boundary. Column\n`format` options, `fieldFn` and HTML-string renderers cover badges, links and\nformatted values.\n\nColumn `format` and HTML-string renderers, which is what to reach for instead:\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"480\"></div>\n\nIf a grid's cells must render your own components, use the Svelte component\ndirectly. See [limitations](./limitations.md).\n\n## Why it lives in `@svgrid/grid-wc`\n\nRather than a separate `@svgrid/react` package: one version to keep in step\nwith the element instead of two, and `react` is an OPTIONAL peer dependency, so\na plain-HTML consumer never installs React. The wrapper is a subpath import and\nabout 1.4 KB - it reuses the one element bundle rather than shipping a second\ncopy of the grid.\n\n## The raw element\n\n`<SvGrid>` wraps `<sv-grid>`, and nothing stops you using the element\ndirectly - it is the same grid, and the wrapper is a thin layer over it. You\nwould be taking on the property assignment, the event binding and the two\nordering problems the wrapper handles, so do it only if you have a reason to.\n\n[Quick start](./quick-start.md) covers the element, and the\n[reference](./sv-grid.md) lists every property, attribute and event.\n\n## See also\n\n- [All frameworks](./frameworks.md) - the same examples in Vue and Angular.\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [TypeScript](./typescript.md) - typing the element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
|
|
6348
|
+
"markdown": "# SvGrid in React\n\n```bash\nnpm install @svgrid/grid-wc\n```\n\n```tsx\nimport { SvGrid } from '@svgrid/grid-wc/react'\n\nexport default function Screen() {\n return (\n <div style={{ height: 480 }}>\n <SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n onCellValueChange={(e) => save(e)}\n />\n </div>\n )\n}\n```\n\nThat is the whole integration. The component is generated from `<SvGrid>`'s\nown types, so all 124 properties and 33 events are typed props, and it works the\nsame on React 17, 18 and 19.\n\n## Why the wrapper rather than the raw element\n\n- **React 18 and earlier stringify object props onto attributes.**\n `columns={cols}` becomes the attribute `\"[object Object]\"` and the grid\n renders empty, with no error. React 19 assigns them as properties; the wrapper\n does so on every version.\n- **The element renders before your props arrive.** Both React and Angular\n create the element first and assign properties in an effect, so the grid\n mounts once with nothing. The wrapper handles that ordering.\n- **`apiready` fires once, during that first mount** - before React can bind a\n listener, so a raw `addEventListener` misses it entirely. The wrapper replays\n it.\n- Typed props and handlers, instead of `el.columns = ...` on an `HTMLElement`.\n\n## Shadow DOM\n\n`shadow` picks the style-isolated element. Same props, same events; the extra\nbundle loads only when you ask for it.\n\n```tsx\n<SvGrid shadow data={rows} columns={columns} />\n```\n\n## The imperative api\n\n```tsx\nimport { useRef } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\n\nexport function Screen() {\n const grid = useRef<SvGridHandle>(null)\n\n const exportCsv = () => {\n // `api` is the grid api; `element` is the host custom element.\n ;(grid.current?.api as { exportCsv(): void } | undefined)?.exportCsv()\n }\n\n return <SvGrid ref={grid} data={rows} columns={columns} />\n}\n```\n\n<!-- BEGIN generated examples - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\n## Examples\n\nNine complete apps. Each one is **running on this page** - sort it,\nfilter it, edit a cell - above the code that produces it. **Open in StackBlitz**\nthen boots the whole project, editable, with no local install and nothing to\nconfigure, and every one is compiled in this repository's CI, so what you open\nis what works.\n\nThey all share the same typed `data.ts`, so the only thing that changes\nbetween recipes is the grid.\n\nA note on the previews: the grid you can touch is driven by a Svelte host\nrather than a React one. It is not a recording or a\nlookalike - it renders through the same component `<sv-grid>` renders, with\nthe same data and the same props as the listing below it, so the only thing\nthat differs from the React app is who sets those props. Click **Open in\nStackBlitz** to run the real React one.\n\n### A first grid\n\nRows, columns, and the two features almost every table wants.\n\n<div data-docs-mirror=\"basic\" data-height=\"420\"></div>\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * The smallest useful grid: rows, columns, and the two features almost every\n * table wants. `data` and `columns` are arrays, so they cross as properties -\n * the wrapper handles that on every React version.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid data={people} columns={columns} sortable filterable />\n </div>\n )\n}\n```\n\n### Sorting and filtering\n\nA filter row under the headers, multi-column sort, and the current sort read back into your own state.\n\n<div data-docs-mirror=\"sorting-filtering\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:sorting-filtering\" data-title=\"Sorting and filtering\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * A filter row under the headers, multi-column sort, and the current sort read\n * back into React state. `onSortingChange` hands you the same array the grid\n * holds, so it can drive your own UI.\n */\nexport default function App() {\n const [sorting, setSorting] = useState<Array<{ id: string; desc: boolean }>>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Sorted by:{' '}\n {sorting.length === 0\n ? 'nothing yet - click a header, then shift-click a second one'\n : sorting.map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`).join(', ')}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showFilterRow\n onSortingChange={setSorting}\n />\n </div>\n </div>\n )\n}\n```\n\n### Editing and saving\n\nInline editing, with each committed edit arriving through `cellvaluechange`. Swap the local update for your save call.\n\n<div data-docs-mirror=\"editing\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:editing\" data-title=\"Editing and saving\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Inline editing, with the change written back into React state.\n *\n * `cellvaluechange` fires once per committed edit and its detail is typed, so\n * `rowIndex`, `columnId` and `newValue` come through without a cast. Replace\n * the setState with your own save call.\n */\nexport default function App() {\n const [rows, setRows] = useState<Person[]>(people)\n const [log, setLog] = useState<string[]>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Double-click a Name or Amount cell. Last edits: {log.slice(-3).join(' · ') || 'none yet'}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={rows}\n columns={columns.map((c) =>\n c.id === 'name' || c.id === 'amount' ? { ...c, editable: true } : c,\n )}\n sortable\n filterable\n editable\n onCellValueChange={(e) => {\n setRows((prev) =>\n prev.map((row, i) => (i === e.rowIndex ? { ...row, [e.columnId]: e.newValue } : row)),\n )\n setLog((prev) => [...prev, `${e.columnId} = ${String(e.newValue)}`])\n }}\n />\n </div>\n </div>\n )\n}\n```\n\n### Row selection\n\nCheckboxes, with the selected rows handed straight to you - both the selection map and the rows themselves.\n\n<div data-docs-mirror=\"selection\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:selection\" data-title=\"Row selection\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Row checkboxes, with the selected rows handed to React so you can act on\n * them. `rowselectionchange` carries both the selection map and the rows\n * themselves, so you rarely need to look anything up.\n */\nexport default function App() {\n const [selected, setSelected] = useState<Person[]>([])\n const total = selected.reduce((sum, r) => sum + r.amount, 0)\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ font: '13px system-ui', color: '#64748b' }}>\n {selected.length === 0\n ? 'Tick some rows to total them'\n : `${selected.length} selected · ${total.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showRowSelection\n onRowSelectionChange={(e) => setSelected(e.rows as Person[])}\n />\n </div>\n </div>\n )\n}\n```\n\n### Grouping and totals\n\nGroup by one or two columns with an aggregate in the group row. `groupBy` is an array, so it is one of the props that can only be a property.\n\n<div data-docs-mirror=\"grouping\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:grouping\" data-title=\"Grouping and totals\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Grouping by one or two columns, with an aggregate in the group row.\n *\n * `groupBy` is an array, so it is one of the props that can only be a\n * property - which is the whole reason the React wrapper exists.\n */\nexport default function App() {\n const [groupBy, setGroupBy] = useState<string[]>(['team'])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ display: 'flex', gap: 8, font: '13px system-ui' }}>\n {[['team'], ['country'], ['team', 'country'], []].map((g) => (\n <button\n key={g.join('+') || 'none'}\n onClick={() => setGroupBy(g)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: groupBy.join() === g.join() ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {g.join(' + ') || 'No grouping'}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns.map((c) => (c.id === 'amount' ? { ...c, aggregate: 'sum' } : c))}\n sortable\n filterable\n groupable\n groupBy={groupBy}\n groupFooters\n />\n </div>\n </div>\n )\n}\n```\n\n### Pagination\n\nClient-side paging. `pageSize` is the INITIAL page size, read once at mount.\n\n<div data-docs-mirror=\"pagination\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:pagination\" data-title=\"Pagination\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Client-side paging. `pageSize` is read once at mount - it is the INITIAL\n * page size - so change pages through the footer or the grid api rather than\n * by reassigning the prop.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n pageable\n pageSize={25}\n pageSizeOptions={[10, 25, 50, 100]}\n paginationPosition=\"bottom\"\n />\n </div>\n )\n}\n```\n\n### Server-side data\n\nThe grid renders the page you hand it and tells you when the user wants another. `externalSort` and `externalPagination` stop it doing the work locally; `rowCount` is how it knows how many pages exist.\n\n<div data-docs-mirror=\"server-data\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:server-data\" data-title=\"Server-side data\"></div>\n\n```tsx {nocheck}\nimport { useEffect, useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\ntype Query = { page: number; sort: Array<{ id: string; desc: boolean }> }\n\n/**\n * Server-side data: the grid renders whatever page you hand it and tells you\n * when the user wants a different one.\n *\n * `externalSort` and `externalPagination` stop the grid doing the work\n * locally; `rowCount` is how it knows how many pages exist. `fetchPage` here\n * stands in for your API call.\n */\nconst PAGE_SIZE = 25\n\nfunction fetchPage({ page, sort }: Query): Promise<{ rows: Person[]; total: number }> {\n // Pretend this is a network call.\n const sorted = [...people].sort((a, b) => {\n for (const s of sort) {\n const av = a[s.id as keyof Person]\n const bv = b[s.id as keyof Person]\n if (av === bv) continue\n return (av > bv ? 1 : -1) * (s.desc ? -1 : 1)\n }\n return 0\n })\n const start = page * PAGE_SIZE\n return new Promise((resolve) =>\n setTimeout(() => resolve({ rows: sorted.slice(start, start + PAGE_SIZE), total: sorted.length }), 250),\n )\n}\n\nexport default function App() {\n const [query, setQuery] = useState<Query>({ page: 0, sort: [] })\n const [rows, setRows] = useState<Person[]>([])\n const [total, setTotal] = useState(0)\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n let cancelled = false\n setLoading(true)\n fetchPage(query).then((res) => {\n if (cancelled) return\n setRows(res.rows)\n setTotal(res.total)\n setLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [query])\n\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={rows}\n columns={columns}\n loading={loading}\n sortable\n externalSort\n externalPagination\n pageable\n pageSize={PAGE_SIZE}\n rowCount={total}\n pageIndex={query.page}\n onSortingChange={(sort) => setQuery((q) => ({ ...q, sort, page: 0 }))}\n onPaginationChange={(p) => setQuery((q) => ({ ...q, page: (p as { pageIndex: number }).pageIndex }))}\n />\n </div>\n )\n}\n```\n\n### Theming\n\nThe `--sg-*` custom properties. Ordinary CSS custom properties, so they cascade from any ancestor - which is why they also reach inside `<sv-grid-shadow>`.\n\n<div data-docs-mirror=\"theming\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:theming\" data-title=\"Theming\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Theming through `--sg-*` custom properties.\n *\n * They are ordinary CSS custom properties, so they cascade: set them on any\n * ancestor and the grid inherits. That is also why they reach into\n * `<sv-grid-shadow>` - inheritance crosses a shadow boundary.\n */\nconst THEMES = {\n Light: {},\n Dark: {\n '--sg-bg': '#0b1220',\n '--sg-fg': '#e2e8f0',\n '--sg-border': '#1e293b',\n '--sg-header-bg': '#111a2e',\n '--sg-row-hover': '#111a2e',\n },\n Warm: {\n '--sg-bg': '#fffaf5',\n '--sg-fg': '#42302a',\n '--sg-border': '#f0dcc9',\n '--sg-header-bg': '#fdf1e4',\n '--sg-accent': '#c2410c',\n },\n} satisfies Record<string, Record<string, string>>\n\nexport default function App() {\n const [theme, setTheme] = useState<keyof typeof THEMES>('Light')\n\n return (\n <div\n style={{\n height: '100%',\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n gap: 12,\n ...THEMES[theme],\n }}\n >\n <div style={{ display: 'flex', gap: 8 }}>\n {(Object.keys(THEMES) as Array<keyof typeof THEMES>).map((name) => (\n <button\n key={name}\n onClick={() => setTheme(name)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: theme === name ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {name}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid data={people} columns={columns} sortable filterable zebraRows />\n </div>\n </div>\n )\n}\n```\n\n### Excel export (Enterprise)\n\nThe paid pack from a non-Svelte host. `@svgrid/enterprise/export` is plain JavaScript, so it needs no Svelte in your build - the same goes for `/import`, `/print`, `/pivot` and `/license`. See [Enterprise features](./enterprise.md) for what those subpaths cover and what needs a Svelte-aware bundler.\n\n<div data-docs-mirror=\"enterprise\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:enterprise\" data-title=\"Excel export (Enterprise)\"></div>\n\n```tsx {nocheck}\nimport { useRef, useState } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\nimport { exportGrid } from '@svgrid/enterprise/export'\nimport { people, columns } from '../data'\n\n/**\n * Excel export from a React host.\n *\n * `@svgrid/enterprise/export` is plain JavaScript - no Svelte in your build -\n * so this works in any bundler. The same is true of `/import`, `/print`,\n * `/pivot` and `/license`. The Kanban and scheduler views are the exception:\n * they are Svelte components, so they need a Svelte-aware bundler.\n *\n * The api arrives on the handle once `apiready` has fired. It is typed\n * `unknown` there because the wrapper does not depend on the grid's types, so\n * the enterprise call needs a cast.\n */\nexport default function App() {\n const grid = useRef<SvGridHandle>(null)\n const [ready, setReady] = useState(false)\n const [busy, setBusy] = useState(false)\n\n async function exportXlsx() {\n if (!grid.current?.api) return\n setBusy(true)\n try {\n await exportGrid(grid.current.api as Parameters<typeof exportGrid>[0], {\n format: 'xlsx',\n filename: 'people',\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <button type=\"button\" onClick={exportXlsx} disabled={!ready || busy}>\n {busy ? 'Building…' : 'Export to Excel'}\n </button>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n ref={grid}\n data={people}\n columns={columns}\n sortable\n filterable\n onApiReady={() => setReady(true)}\n />\n </div>\n </div>\n )\n}\n```\n\n<!-- END generated examples -->\n\n## Rendering into cells\n\nYou cannot put a React component inside a cell: cell rendering is a Svelte\ncompile-time feature, and there is nothing to hand across the boundary. Column\n`format` options, `fieldFn` and HTML-string renderers cover badges, links and\nformatted values.\n\nColumn `format` and HTML-string renderers, which is what to reach for instead:\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"480\"></div>\n\nIf a grid's cells must render your own components, use the Svelte component\ndirectly. See [limitations](./limitations.md).\n\n## Why it lives in `@svgrid/grid-wc`\n\nRather than a separate `@svgrid/react` package: one version to keep in step\nwith the element instead of two, and `react` is an OPTIONAL peer dependency, so\na plain-HTML consumer never installs React. The wrapper is a subpath import and\nabout 1.4 KB - it reuses the one element bundle rather than shipping a second\ncopy of the grid.\n\n## The raw element\n\n`<SvGrid>` wraps `<sv-grid>`, and nothing stops you using the element\ndirectly - it is the same grid, and the wrapper is a thin layer over it. You\nwould be taking on the property assignment, the event binding and the two\nordering problems the wrapper handles, so do it only if you have a reason to.\n\n[Quick start](./quick-start.md) covers the element, and the\n[reference](./sv-grid.md) lists every property, attribute and event.\n\n## See also\n\n- [All frameworks](./frameworks.md) - the same examples in Vue and Angular.\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [TypeScript](./typescript.md) - typing the element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
|
|
6048
6349
|
},
|
|
6049
6350
|
{
|
|
6050
6351
|
"slug": "help/web-components/shadow-dom",
|
|
6051
6352
|
"path": "docs/help/web-components/shadow-dom.md",
|
|
6052
6353
|
"title": "Shadow DOM: <sv-grid-shadow>",
|
|
6053
6354
|
"section": "help/web-components",
|
|
6054
|
-
"markdown": "# Shadow DOM: `<sv-grid-shadow>`\n\nTwo elements ship, and which one you want is a styling decision:\n\n| Element | Renders in | Page CSS | Use when |\n| --- | --- | --- | --- |\n| `<sv-grid>` | the light DOM | applies normally | Almost always - a data grid usually wants to inherit your app's theme. |\n| `<sv-grid-shadow>` | an open shadow root | **cannot reach the grid** | You are embedding in a page whose CSS you do not control, or shipping into someone else's site. |\n\nThey are two elements rather than one with a `shadow` attribute because Svelte's\n`customElement.shadow` is resolved when the component is COMPILED. No runtime\nflag can switch it, so no attribute can either.\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc/dist/shadow/sv-grid-shadow-element.js\"></script>\n\n<sv-grid-shadow id=\"grid\" sortable filterable style=\"display:block;height:420px\"></sv-grid-shadow>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'city', header: 'City' }]\n grid.data = [{ name: 'Ada', city: 'London' }, { name: 'Linus', city: 'Helsinki' }]\n</script>\n```\n\nEverything else is identical: the same
|
|
6355
|
+
"markdown": "# Shadow DOM: `<sv-grid-shadow>`\n\nTwo elements ship, and which one you want is a styling decision:\n\n| Element | Renders in | Page CSS | Use when |\n| --- | --- | --- | --- |\n| `<sv-grid>` | the light DOM | applies normally | Almost always - a data grid usually wants to inherit your app's theme. |\n| `<sv-grid-shadow>` | an open shadow root | **cannot reach the grid** | You are embedding in a page whose CSS you do not control, or shipping into someone else's site. |\n\nThey are two elements rather than one with a `shadow` attribute because Svelte's\n`customElement.shadow` is resolved when the component is COMPILED. No runtime\nflag can switch it, so no attribute can either.\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc/dist/shadow/sv-grid-shadow-element.js\"></script>\n\n<sv-grid-shadow id=\"grid\" sortable filterable style=\"display:block;height:420px\"></sv-grid-shadow>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'city', header: 'City' }]\n grid.data = [{ name: 'Ada', city: 'London' }, { name: 'Linus', city: 'Helsinki' }]\n</script>\n```\n\nEverything else is identical: the same 124 properties, the same 84 attributes,\nthe same 33 events. Events are dispatched `composed: true`, so\n`addEventListener` on the host works exactly as it does on `<sv-grid>`.\n\n\n## What the shadow root does and does not isolate\n\nTheming with `--sg-*` tokens, which reach into the shadow root by inheritance.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"500\"></div>\n\nThe root is **open**, never closed. A closed root leaves `el.shadowRoot` null,\nwhich would stop you styling, querying, or testing your own grid - so it is not\noffered.\n\nIsolation is one-directional, and worth being precise about:\n\n- **Page CSS cannot reach the grid.** A page rule like\n `table td { background: fuchsia }` hits `<sv-grid>` and stops at\n `<sv-grid-shadow>`'s boundary. That is the point of the element.\n- **The grid's CSS is still in your page.** About twenty overlay surfaces - the\n cell dropdown, the date picker, tooltips, toasts, modals - portal to\n `document.body` deliberately, so they escape every ancestor `overflow` and\n clip. Those land outside the shadow root, so the bundle injects its stylesheet\n into `document.head` as well as adopting it into the root. Every rule is\n scoped to an `.sv-grid-*` class, so it does not style your page, but it is\n present in it.\n- **Styles arrive with the code that needs them.** The entry carries the\n grid's own sheet; a lazy chunk (the chart, the date pickers, the menus)\n carries the styles of the components inside it and adds them to the page\n and to every open root when it loads. A page that never charts never\n downloads the chart's styles.\n\n## Theming\n\n`--sg-*` tokens work in both elements, unchanged:\n\n```css\nsv-grid, sv-grid-shadow {\n --sg-bg: #fff;\n --sg-header-bg: #f8fafc;\n}\n```\n\nA theme stylesheet works too, including its dark variant, even though its\nselectors are `:root` and `:root[data-theme='dark']` and neither can MATCH\ninside a shadow tree. Custom properties are *inherited* properties, and\ninheritance crosses a shadow boundary - so the values land on the host and are\ninherited by everything in the root. Verified in a browser rather than reasoned\nabout: it was assumed to be broken for a while, and it is not.\n\n\n## Constraints\n\nSelection, clipboard and the menus that portal out of the root - all identical inside a shadow tree.\n\n<div data-docs-demo=\"04-selection-copy-paste\" data-height=\"460\"></div>\n\n- **The root is resolved on mount.** Moving a live `<sv-grid-shadow>` into a\n different document needs a fresh element, not a reparent.\n- **Do not give the host a `transform`, `filter`, or `contain`.** Any of those\n makes it a containing block for fixed positioning, and the grid's menus -\n which are `position: fixed` - would anchor to the host instead of the\n viewport.\n- **Closed roots are unsupported**, for the reason above.\n\n\n## See also\n\n- [Quick start](./quick-start.md)\n- [`<sv-grid>` reference](./sv-grid.md)\n- [Limitations](./limitations.md)\n"
|
|
6055
6356
|
},
|
|
6056
6357
|
{
|
|
6057
6358
|
"slug": "help/web-components/sv-chart",
|
|
@@ -6065,7 +6366,7 @@ export const docs = [
|
|
|
6065
6366
|
"path": "docs/help/web-components/sv-grid.md",
|
|
6066
6367
|
"title": "<sv-grid> reference",
|
|
6067
6368
|
"section": "help/web-components",
|
|
6068
|
-
"markdown": "# `<sv-grid>` reference\n\nEvery property, attribute and event of the custom element.\n\n**This page is generated** from `<SvGrid>`'s own `Props` type by\n`packages/grid-wc/scripts/generate-surface.mjs`, and CI fails if it drifts. The\nelement used to declare 7 props and 2 events by hand while the docs claimed\n\"grouping, sorting, pagination all come along\" - a hand-written table is how\nthat happens.\n\n## Attributes vs properties\n\n`groupable` is an attribute; `groupBy` is an array, so it is property-only. This grid uses both.\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"480\"></div>\n\nThe distinction matters more here than anywhere else in the API:\n\n- **An attribute is a string.** Primitives (`boolean`, `number`, `string`) can\n be written in HTML: `<sv-grid page-size=\"25\" show-row-numbers>`.\n- **Arrays, objects and functions cannot.** `columns='[object Object]'` is not\n a thing. Assign them in script: `el.columns = [...]`.\n\nBoth work through the property, so `el.pageSize = 25` is equally valid. The\nattribute is the extra convenience, offered only where it can work.\n\n```html\n<sv-grid id=\"grid\" sortable filterable page-size=\"25\" row-height=\"32\"></sv-grid>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }] // property: an array\n grid.data = rows // property: an array\n</script>\n```\n\n<!-- BEGIN generated reference - packages/grid-wc/scripts/generate-surface.mjs -->\n\n### Attributes (78)\n\nPrimitives, so they work in plain HTML as well as through a property.\n\n| Attribute | Property | Type |\n| --- | --- | --- |\n| `pivot-mode` | `pivotMode` | `boolean` |\n| `context-menu` | `contextMenu` | `boolean \\| ReadonlyArray<ContextMenuItem<TData>>` |\n| `selection-bar` | `selectionBar` | `\\| boolean \\| ReadonlyArray<SelectionBarAction<TData> \\| SelectionBarBuiltin> \\| SelectionB...` |\n| `sortable` | `sortable` | `boolean` |\n| `filterable` | `filterable` | `boolean` |\n| `editable` | `editable` | `boolean` |\n| `groupable` | `groupable` | `boolean` |\n| `group-footers` | `groupFooters` | `boolean` |\n| `grand-total-row` | `grandTotalRow` | `boolean` |\n| `group-display-mode` | `groupDisplayMode` | `GroupDisplayType` |\n| `auto-group-column-header` | `autoGroupColumnHeader` | `string` |\n| `auto-group-column-width` | `autoGroupColumnWidth` | `number` |\n| `pageable` | `pageable` | `boolean` |\n| `loading` | `loading` | `boolean` |\n| `loading-overlay` | `loadingOverlay` | `boolean` |\n| `loading-skeleton-rows` | `loadingSkeletonRows` | `number` |\n| `error` | `error` | `string \\| null` |\n| `empty-message` | `emptyMessage` | `string` |\n| `show-global-filter` | `showGlobalFilter` | `boolean` |\n| `show-column-filters` | `showColumnFilters` | `boolean` |\n| `filter-mode` | `filterMode` | `\"row\" \\| \"menu\" \\| \"global\" \\| \"none\"` |\n| `show-grouping-controls` | `showGroupingControls` | `boolean` |\n| `show-row-selection` | `showRowSelection` | `boolean` |\n| `show-pagination` | `showPagination` | `boolean` |\n| `page-size` | `pageSize` | `number` |\n| `pagination-position` | `paginationPosition` | `'top' \\| 'bottom' \\| 'both'` |\n| `external-pagination` | `externalPagination` | `boolean` |\n| `row-count` | `rowCount` | `number` |\n| `page-index` | `pageIndex` | `number` |\n| `virtualization` | `virtualization` | `boolean` |\n| `row-height` | `rowHeight` | `number \\| ((rowIndex: number) => number)` |\n| `auto-row-height` | `autoRowHeight` | `boolean` |\n| `row-resize` | `rowResize` | `boolean` |\n| `header-height` | `headerHeight` | `number` |\n| `overscan` | `overscan` | `number` |\n| `container-height` | `containerHeight` | `number \\| string` |\n| `column-virtualization` | `columnVirtualization` | `boolean` |\n| `column-overscan` | `columnOverscan` | `number` |\n| `column-width` | `columnWidth` | `number` |\n| `fit-columns` | `fitColumns` | `boolean` |\n| `column-resize` | `columnResize` | `boolean` |\n| `responsive` | `responsive` | `boolean \\| { breakpoint?: number }` |\n| `show-filter-menu` | `showFilterMenu` | `boolean` |\n| `show-filter-row` | `showFilterRow` | `boolean` |\n| `enable-cell-selection` | `enableCellSelection` | `boolean` |\n| `move-cells` | `moveCells` | `boolean` |\n| `enable-row-hover` | `enableRowHover` | `boolean` |\n| `copy-headers-to-clipboard` | `copyHeadersToClipboard` | `boolean` |\n| `enable-inline-editing` | `enableInlineEditing` | `boolean` |\n| `edit-on-second-click` | `editOnSecondClick` | `boolean` |\n| `full-row-editing` | `fullRowEditing` | `boolean` |\n| `enable-row-summaries` | `enableRowSummaries` | `boolean` |\n| `summary` | `summary` | `boolean` |\n| `status-bar` | `statusBar` | `\\| boolean \\| { ... }` |\n| `tool-panel` | `toolPanel` | `boolean` |\n| `charting` | `charting` | `boolean \\| ChartingConfig<TData>` |\n| `column-menu-tabs` | `columnMenuTabs` | `boolean` |\n| `tool-panel-default-open` | `toolPanelDefaultOpen` | `boolean` |\n| `tool-panel-default-tab` | `toolPanelDefaultTab` | `\"columns\" \\| \"filters\"` |\n| `selection-mode` | `selectionMode` | `\"row\" \\| \"cell\" \\| \"both\" \\| \"none\"` |\n| `show-row-numbers` | `showRowNumbers` | `boolean` |\n| `zebra-rows` | `zebraRows` | `boolean` |\n| `row-number-width` | `rowNumberWidth` | `number` |\n| `external-sort` | `externalSort` | `boolean` |\n| `external-filter` | `externalFilter` | `boolean` |\n| `editable-comments` | `editableComments` | `boolean` |\n| `conditional-stat-scope` | `conditionalStatScope` | `\"filtered\" \\| \"visible\" \\| \"all\"` |\n| `detail-row-height` | `detailRowHeight` | `number \\| ((row: TData) => number)` |\n| `show-detail-toggle` | `showDetailToggle` | `boolean` |\n| `frozen-rows` | `frozenRows` | `number` |\n| `sticky-group-rows` | `stickyGroupRows` | `boolean` |\n| `row-layout` | `rowLayout` | `\"flex\" \\| \"table\"` |\n| `enable-column-reorder` | `enableColumnReorder` | `boolean` |\n| `infer-column-types` | `inferColumnTypes` | `boolean` |\n| `row-drag-managed` | `rowDragManaged` | `boolean` |\n| `row-drag-group` | `rowDragGroup` | `string` |\n| `aligned-grid-group` | `alignedGridGroup` | `string` |\n| `filter-locale` | `filterLocale` | `string \\| ReadonlyArray<string>` |\n\n### Properties only (38)\n\nArrays, objects and functions. An HTML attribute is a string, so these can\nonly be assigned in script: `el.columns = [...]`.\n\n| Property | Type |\n| --- | --- |\n| `data` | `ReadonlyArray<TData>` |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` |\n| `board` | `BoardConfig<TFeatures, TData>` |\n| `scheduler` | `SchedulerConfig<TFeatures, TData>` |\n| `gantt` | `GanttConfig<TFeatures, TData>` |\n| `chart` | `ChartViewConfig<TFeatures, TData>` |\n| `pivot` | `GridPivotConfig<TData>` |\n| `features` | `TFeatures` |\n| `treeData` | `{ ... }` |\n| `groupBy` | `ReadonlyArray<string>` |\n| `expanded` | `Record<string, boolean>` |\n| `localization` | `GridLocalization` |\n| `pageSizeOptions` | `number[]` |\n| `initialColumnPinning` | `{ ... }` |\n| `processCellForFill` | `(params: { value: unknown; delta: { rows: number; cols: number }; rowIndex: number; col...` |\n| `processCellForClipboard` | `(params: { ... }) => unknown` |\n| `includeCollapsedRows` | `(rowIndex: number) => boolean` |\n| `clipboardHtml` | `(params: { rects: ReadonlyArray<{ ... }>; text: string; }) => string \\| null \\| undefined` |\n| `processCellFromClipboard` | `(params: { ... }) => unknown` |\n| `initialSorting` | `Array<{ id: string; desc: boolean }>` |\n| `initialAdvancedFilter` | `GridPredicateExpr \\| null` |\n| `getRowId` | `(row: TData, index: number) => string` |\n| `rowClass` | `(ctx: { row: TData; rowIndex: number; }) => string \\| ReadonlyArray<string> \\| Record<str...` |\n| `notes` | `Record<string, Record<string, string>>` |\n| `conditionalFormats` | `ReadonlyArray<ConditionalFormat<TData>>` |\n| `rowPlaceholder` | `(row: TData, rowIndex: number) => \"loading\" \\| \"failed\" \\| null` |\n| `rowModel` | `GridRowModel<TData>` |\n| `pivotResultColumns` | `Array<ColumnDef<TFeatures, TData>> \\| null` |\n| `rowSelectionModel` | `{ ... }` |\n| `isDetailRow` | `(row: TData, rowIndex: number) => boolean` |\n| `isDetailOpen` | `(row: TData) => boolean` |\n| `hasDetail` | `(row: TData) => boolean` |\n| `serverGroup` | `{ ... }` |\n| `serverFilterValues` | `(columnId: string) => Promise<string[]>` |\n| `pinnedTopRows` | `ReadonlyArray<TData>` |\n| `mergedCells` | `ReadonlyArray<{ ... }>` |\n| `pinnedBottomRows` | `ReadonlyArray<TData>` |\n| `columnOrder` | `ReadonlyArray<string>` |\n\n### Events (26)\n\n`detail` is the callback's argument. The one callback that takes two carries\nan object keyed by its parameter names.\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `pivotmodechange` | `onPivotModeChange` | `on` |\n| `expandedchange` | `onExpandedChange` | `expanded` |\n| `paginationchange` | `onPaginationChange` | `pagination` |\n| `columnresize` | `onColumnResize` | `event` |\n| `rowresize` | `onRowResize` | `event` |\n| `pasteclipboard` | `onPasteClipboard` | `payload` |\n| `apiready` | `onApiReady` | `api` |\n| `rowselectionchange` | `onRowSelectionChange` | `{ selection, rows }` |\n| `cellselectionchange` | `onCellSelectionChange` | `ranges` |\n| `sortingchange` | `onSortingChange` | `sorting` |\n| `advancedfilterchange` | `onAdvancedFilterChange` | `expr` |\n| `filterschange` | `onFiltersChange` | `filters` |\n| `notechange` | `onNoteChange` | `event` |\n| `cellvaluechange` | `onCellValueChange` | `event` |\n| `activecellchange` | `onActiveCellChange` | `cell` |\n| `cellclick` | `onCellClick` | `event` |\n| `rowclick` | `onRowClick` | `row` - published detail is the row, not the whole click event |\n| `celldoubleclick` | `onCellDoubleClick` | `event` |\n| `rowdoubleclick` | `onRowDoubleClick` | `event` |\n| `scrollbottomreached` | `onScrollBottomReached` | `event` |\n| `visiblerangechange` | `onVisibleRangeChange` | `range` |\n| `retryrow` | `onRetryRow` | `{ row, rowIndex }` |\n| `detailtoggle` | `onDetailToggle` | `{ row, rowIndex }` |\n| `columnorderchange` | `onColumnOrderChange` | `order` |\n| `rowdragend` | `onRowDragEnd` | `event` |\n| `rowdrop` | `onRowDrop` | `event` |\n\nPlus one alias kept from before the surface was generated:\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `selectionchange` | `onRowSelectionChange` | `rows` - published alias of rowselectionchange, detail is the selected rows |\n\n### Not exposed (3)\n\n| Prop | Why |\n| --- | --- |\n| `selectable` | element-level shorthand for row selection - see GridBody.svelte |\n| `icons` | Svelte snippets keyed by icon name - cannot cross the custom-element boundary |\n| `renderDetailRow` | Svelte snippet - cannot cross the custom-element boundary |\n\n<!-- END generated reference -->\n\n\n## The imperative API\n\nThe api surface `apiready` hands you.\n\n<div data-docs-demo=\"90-selection-api\" data-height=\"460\"></div>\n\n`onApiReady` fires once with the grid's api. Because a listener bound after\nmount would miss it, the handle is also parked on the element:\n\n```js\ngrid.addEventListener('apiready', (e) => e.detail.exportCsv())\n// or, at any time later:\ngrid.api.exportCsv()\n```\n\n\n## See also\n\n- [Quick start](./quick-start.md) - the CDN path, no build step.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n- [Shadow DOM](./shadow-dom.md) - `<sv-grid-shadow>` and style isolation.\n"
|
|
6369
|
+
"markdown": "# `<sv-grid>` reference\n\nEvery property, attribute and event of the custom element.\n\n**This page is generated** from `<SvGrid>`'s own `Props` type by\n`packages/grid-wc/scripts/generate-surface.mjs`, and CI fails if it drifts. The\nelement used to declare 7 props and 2 events by hand while the docs claimed\n\"grouping, sorting, pagination all come along\" - a hand-written table is how\nthat happens.\n\n## Attributes vs properties\n\n`groupable` is an attribute; `groupBy` is an array, so it is property-only. This grid uses both.\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"480\"></div>\n\nThe distinction matters more here than anywhere else in the API:\n\n- **An attribute is a string.** Primitives (`boolean`, `number`, `string`) can\n be written in HTML: `<sv-grid page-size=\"25\" show-row-numbers>`.\n- **Arrays, objects and functions cannot.** `columns='[object Object]'` is not\n a thing. Assign them in script: `el.columns = [...]`.\n\nBoth work through the property, so `el.pageSize = 25` is equally valid. The\nattribute is the extra convenience, offered only where it can work.\n\n```html\n<sv-grid id=\"grid\" sortable filterable page-size=\"25\" row-height=\"32\"></sv-grid>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }] // property: an array\n grid.data = rows // property: an array\n</script>\n```\n\n<!-- BEGIN generated reference - packages/grid-wc/scripts/generate-surface.mjs -->\n\n### Attributes (84)\n\nPrimitives, so they work in plain HTML as well as through a property.\n\n| Attribute | Property | Type |\n| --- | --- | --- |\n| `pivot-mode` | `pivotMode` | `boolean` |\n| `context-menu` | `contextMenu` | `boolean \\| ReadonlyArray<ContextMenuItem<TData>>` |\n| `selection-bar` | `selectionBar` | `\\| boolean \\| ReadonlyArray<SelectionBarAction<TData> \\| SelectionBarBuiltin> \\| SelectionB...` |\n| `row-adder` | `rowAdder` | `boolean \\| RowAdderConfig<TData>` |\n| `column-adder` | `columnAdder` | `boolean \\| ColumnAdderConfig` |\n| `field-menu` | `fieldMenu` | `boolean` |\n| `expand-rows` | `expandRows` | `boolean` |\n| `pin-splitter` | `pinSplitter` | `boolean` |\n| `primary-field` | `primaryField` | `string` |\n| `sortable` | `sortable` | `boolean` |\n| `filterable` | `filterable` | `boolean` |\n| `editable` | `editable` | `boolean` |\n| `groupable` | `groupable` | `boolean` |\n| `group-footers` | `groupFooters` | `boolean` |\n| `grand-total-row` | `grandTotalRow` | `boolean` |\n| `group-display-mode` | `groupDisplayMode` | `GroupDisplayType` |\n| `auto-group-column-header` | `autoGroupColumnHeader` | `string` |\n| `auto-group-column-width` | `autoGroupColumnWidth` | `number` |\n| `pageable` | `pageable` | `boolean` |\n| `loading` | `loading` | `boolean` |\n| `loading-overlay` | `loadingOverlay` | `boolean` |\n| `loading-skeleton-rows` | `loadingSkeletonRows` | `number` |\n| `error` | `error` | `string \\| null` |\n| `empty-message` | `emptyMessage` | `string` |\n| `show-global-filter` | `showGlobalFilter` | `boolean` |\n| `show-column-filters` | `showColumnFilters` | `boolean` |\n| `filter-mode` | `filterMode` | `\"row\" \\| \"menu\" \\| \"global\" \\| \"none\"` |\n| `show-grouping-controls` | `showGroupingControls` | `boolean` |\n| `show-row-selection` | `showRowSelection` | `boolean` |\n| `show-pagination` | `showPagination` | `boolean` |\n| `page-size` | `pageSize` | `number` |\n| `pagination-position` | `paginationPosition` | `'top' \\| 'bottom' \\| 'both'` |\n| `external-pagination` | `externalPagination` | `boolean` |\n| `row-count` | `rowCount` | `number` |\n| `page-index` | `pageIndex` | `number` |\n| `virtualization` | `virtualization` | `boolean` |\n| `row-height` | `rowHeight` | `number \\| ((rowIndex: number) => number)` |\n| `auto-row-height` | `autoRowHeight` | `boolean` |\n| `row-resize` | `rowResize` | `boolean` |\n| `header-height` | `headerHeight` | `number` |\n| `overscan` | `overscan` | `number` |\n| `container-height` | `containerHeight` | `number \\| string` |\n| `column-virtualization` | `columnVirtualization` | `boolean` |\n| `column-overscan` | `columnOverscan` | `number` |\n| `column-width` | `columnWidth` | `number` |\n| `fit-columns` | `fitColumns` | `boolean` |\n| `column-resize` | `columnResize` | `boolean` |\n| `responsive` | `responsive` | `boolean \\| { breakpoint?: number }` |\n| `show-filter-menu` | `showFilterMenu` | `boolean` |\n| `show-filter-row` | `showFilterRow` | `boolean` |\n| `enable-cell-selection` | `enableCellSelection` | `boolean` |\n| `move-cells` | `moveCells` | `boolean` |\n| `enable-row-hover` | `enableRowHover` | `boolean` |\n| `copy-headers-to-clipboard` | `copyHeadersToClipboard` | `boolean` |\n| `enable-inline-editing` | `enableInlineEditing` | `boolean` |\n| `edit-on-second-click` | `editOnSecondClick` | `boolean` |\n| `full-row-editing` | `fullRowEditing` | `boolean` |\n| `enable-row-summaries` | `enableRowSummaries` | `boolean` |\n| `summary` | `summary` | `boolean` |\n| `status-bar` | `statusBar` | `\\| boolean \\| { ... }` |\n| `tool-panel` | `toolPanel` | `boolean` |\n| `charting` | `charting` | `boolean \\| ChartingConfig<TData>` |\n| `column-menu-tabs` | `columnMenuTabs` | `boolean` |\n| `tool-panel-default-open` | `toolPanelDefaultOpen` | `boolean` |\n| `tool-panel-default-tab` | `toolPanelDefaultTab` | `\"columns\" \\| \"filters\"` |\n| `selection-mode` | `selectionMode` | `\"row\" \\| \"cell\" \\| \"both\" \\| \"none\"` |\n| `show-row-numbers` | `showRowNumbers` | `boolean` |\n| `zebra-rows` | `zebraRows` | `boolean` |\n| `row-number-width` | `rowNumberWidth` | `number` |\n| `external-sort` | `externalSort` | `boolean` |\n| `external-filter` | `externalFilter` | `boolean` |\n| `editable-comments` | `editableComments` | `boolean` |\n| `conditional-stat-scope` | `conditionalStatScope` | `\"filtered\" \\| \"visible\" \\| \"all\"` |\n| `detail-row-height` | `detailRowHeight` | `number \\| ((row: TData) => number)` |\n| `show-detail-toggle` | `showDetailToggle` | `boolean` |\n| `frozen-rows` | `frozenRows` | `number` |\n| `sticky-group-rows` | `stickyGroupRows` | `boolean` |\n| `row-layout` | `rowLayout` | `\"flex\" \\| \"table\"` |\n| `enable-column-reorder` | `enableColumnReorder` | `boolean` |\n| `infer-column-types` | `inferColumnTypes` | `boolean` |\n| `row-drag-managed` | `rowDragManaged` | `boolean` |\n| `row-drag-group` | `rowDragGroup` | `string` |\n| `aligned-grid-group` | `alignedGridGroup` | `string` |\n| `filter-locale` | `filterLocale` | `string \\| ReadonlyArray<string>` |\n\n### Properties only (40)\n\nArrays, objects and functions. An HTML attribute is a string, so these can\nonly be assigned in script: `el.columns = [...]`.\n\n| Property | Type |\n| --- | --- |\n| `data` | `ReadonlyArray<TData>` |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` |\n| `board` | `BoardConfig<TFeatures, TData>` |\n| `gallery` | `GalleryConfig<TFeatures, TData>` |\n| `scheduler` | `SchedulerConfig<TFeatures, TData>` |\n| `gantt` | `GanttConfig<TFeatures, TData>` |\n| `chart` | `ChartViewConfig<TFeatures, TData>` |\n| `pivot` | `GridPivotConfig<TData>` |\n| `rowColor` | `RowColorConfig<TData>` |\n| `features` | `TFeatures` |\n| `treeData` | `{ ... }` |\n| `groupBy` | `ReadonlyArray<string>` |\n| `expanded` | `Record<string, boolean>` |\n| `localization` | `GridLocalization` |\n| `pageSizeOptions` | `number[]` |\n| `initialColumnPinning` | `{ ... }` |\n| `processCellForFill` | `(params: { value: unknown; delta: { rows: number; cols: number }; rowIndex: number; col...` |\n| `processCellForClipboard` | `(params: { ... }) => unknown` |\n| `includeCollapsedRows` | `(rowIndex: number) => boolean` |\n| `clipboardHtml` | `(params: { rects: ReadonlyArray<{ ... }>; text: string; }) => string \\| null \\| undefined` |\n| `processCellFromClipboard` | `(params: { ... }) => unknown` |\n| `initialSorting` | `Array<{ id: string; desc: boolean }>` |\n| `initialAdvancedFilter` | `GridPredicateExpr \\| null` |\n| `getRowId` | `(row: TData, index: number) => string` |\n| `rowClass` | `(ctx: { row: TData; rowIndex: number; }) => string \\| ReadonlyArray<string> \\| Record<str...` |\n| `notes` | `Record<string, Record<string, string>>` |\n| `conditionalFormats` | `ReadonlyArray<ConditionalFormat<TData>>` |\n| `rowPlaceholder` | `(row: TData, rowIndex: number) => \"loading\" \\| \"failed\" \\| null` |\n| `rowModel` | `GridRowModel<TData>` |\n| `pivotResultColumns` | `Array<ColumnDef<TFeatures, TData>> \\| null` |\n| `rowSelectionModel` | `{ ... }` |\n| `isDetailRow` | `(row: TData, rowIndex: number) => boolean` |\n| `isDetailOpen` | `(row: TData) => boolean` |\n| `hasDetail` | `(row: TData) => boolean` |\n| `serverGroup` | `{ ... }` |\n| `serverFilterValues` | `(columnId: string) => Promise<string[]>` |\n| `pinnedTopRows` | `ReadonlyArray<TData>` |\n| `mergedCells` | `ReadonlyArray<{ ... }>` |\n| `pinnedBottomRows` | `ReadonlyArray<TData>` |\n| `columnOrder` | `ReadonlyArray<string>` |\n\n### Events (32)\n\n`detail` is the callback's argument. The one callback that takes two carries\nan object keyed by its parameter names.\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `pivotmodechange` | `onPivotModeChange` | `on` |\n| `rowadd` | `onRowAdd` | `event` |\n| `columnadd` | `onColumnAdd` | `event` |\n| `columnchange` | `onColumnChange` | `event` |\n| `columnremove` | `onColumnRemove` | `event` |\n| `rowsdelete` | `onRowsDelete` | `event` |\n| `rowcolorchange` | `onRowColorChange` | `config` |\n| `expandedchange` | `onExpandedChange` | `expanded` |\n| `paginationchange` | `onPaginationChange` | `pagination` |\n| `columnresize` | `onColumnResize` | `event` |\n| `rowresize` | `onRowResize` | `event` |\n| `pasteclipboard` | `onPasteClipboard` | `payload` |\n| `apiready` | `onApiReady` | `api` |\n| `rowselectionchange` | `onRowSelectionChange` | `{ selection, rows }` |\n| `cellselectionchange` | `onCellSelectionChange` | `ranges` |\n| `sortingchange` | `onSortingChange` | `sorting` |\n| `advancedfilterchange` | `onAdvancedFilterChange` | `expr` |\n| `filterschange` | `onFiltersChange` | `filters` |\n| `notechange` | `onNoteChange` | `event` |\n| `cellvaluechange` | `onCellValueChange` | `event` |\n| `activecellchange` | `onActiveCellChange` | `cell` |\n| `cellclick` | `onCellClick` | `event` |\n| `rowclick` | `onRowClick` | `row` - published detail is the row, not the whole click event |\n| `celldoubleclick` | `onCellDoubleClick` | `event` |\n| `rowdoubleclick` | `onRowDoubleClick` | `event` |\n| `scrollbottomreached` | `onScrollBottomReached` | `event` |\n| `visiblerangechange` | `onVisibleRangeChange` | `range` |\n| `retryrow` | `onRetryRow` | `{ row, rowIndex }` |\n| `detailtoggle` | `onDetailToggle` | `{ row, rowIndex }` |\n| `columnorderchange` | `onColumnOrderChange` | `order` |\n| `rowdragend` | `onRowDragEnd` | `event` |\n| `rowdrop` | `onRowDrop` | `event` |\n\nPlus one alias kept from before the surface was generated:\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `selectionchange` | `onRowSelectionChange` | `rows` - published alias of rowselectionchange, detail is the selected rows |\n\n### Not exposed (3)\n\n| Prop | Why |\n| --- | --- |\n| `selectable` | element-level shorthand for row selection - see GridBody.svelte |\n| `icons` | Svelte snippets keyed by icon name - cannot cross the custom-element boundary |\n| `renderDetailRow` | Svelte snippet - cannot cross the custom-element boundary |\n\n<!-- END generated reference -->\n\n\n## The imperative API\n\nThe api surface `apiready` hands you.\n\n<div data-docs-demo=\"90-selection-api\" data-height=\"460\"></div>\n\n`onApiReady` fires once with the grid's api. Because a listener bound after\nmount would miss it, the handle is also parked on the element:\n\n```js\ngrid.addEventListener('apiready', (e) => e.detail.exportCsv())\n// or, at any time later:\ngrid.api.exportCsv()\n```\n\n\n## See also\n\n- [Quick start](./quick-start.md) - the CDN path, no build step.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n- [Shadow DOM](./shadow-dom.md) - `<sv-grid-shadow>` and style isolation.\n"
|
|
6069
6370
|
},
|
|
6070
6371
|
{
|
|
6071
6372
|
"slug": "help/web-components/sv-sheet",
|
|
@@ -6912,7 +7213,21 @@ export const docs = [
|
|
|
6912
7213
|
"path": "docs/_data/comparisons/ag-grid.json",
|
|
6913
7214
|
"title": "SvGrid vs AG Grid (community + enterprise)",
|
|
6914
7215
|
"section": "Comparisons",
|
|
6915
|
-
"markdown": "# SvGrid vs AG Grid (community + enterprise)\n\nCanonical URL: https://svgrid.com/compare/ag-grid/\nFacts verified: 2026-09-12\nCompetitor package: ag-grid-community\nCompetitor site: https://www.ag-grid.com/\n\nAG Grid is the most widely used JavaScript data grid, sold for React, Angular, Vue and plain JavaScript, with Svelte covered only by community-built integrations.\n\n## Verdict\n\nAG Grid has the longer track record and the wider Enterprise catalogue; SvGrid is Svelte-5-native, smaller on a Svelte page, and puts most of what AG Grid sells as Enterprise into its MIT core. Pick AG Grid for parity with React and Angular teams; pick SvGrid for a Svelte-first stack.\n\nMigration guide: moving from AG Grid (community + enterprise) to SvGrid: https://svgrid.com/docs/help/migrating-from-ag-grid/\n\n## At a glance\n\n| Fact | SvGrid | AG Grid (community + enterprise) | Checked |\n| --- | --- | --- | --- |\n| Latest version | 3.0.3 | 36.1.0 | 12 Sep 2026 |\n| Licence field on npm | MIT | MIT | 12 Sep 2026 |\n| Last published | 11 Sep 2026 | 5 Aug 2026 | 12 Sep 2026 |\n| Svelte 5 support | Native (Svelte 5 runes) | No Svelte package (JavaScript, React, Angular, Vue) | 12 Sep 2026 |\n| npm downloads, 30 days to 10 Sep 2026 | 16,900 | 12,400,000 | 12 Sep 2026 |\n| Bundle, minified + gzip | 97.6 KB JS + 10.5 KB CSS | 317.5 KB JS, no separate stylesheet | 12 Sep 2026 |\n| Frameworks | Svelte 5 | JavaScript, React, Angular, Vue | 12 Sep 2026 |\n| Pricing | MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise | AG Grid Community is free under MIT. AG Grid Enterprise is listed at $999 USD per developer with one year of updates and Zendesk support; the Enterprise Bundle with AG Charts Enterprise is $1,498 USD per developer | 12 Sep 2026 |\n\nBundle sizes: each package built alone with Vite in library mode, minified, gzip level 9, Svelte kept external where a package uses it (it is a peer dependency). SvGrid 3.0.4 measured 22 Sep 2026 with packages/grid/scripts/measure-size.mjs; ag-grid-community 36.1.0 entry `createGrid + AllCommunityModule + themeQuartz` measured 12 Sep 2026 with packages/grid/scripts/measure-competitor-bundles.mjs.\n\n## Overview\n\nAG Grid is the heavyweight of the category: a Community edition under MIT and an Enterprise edition sold per developer that adds the set filter, range selection, master/detail, tree data, row grouping, pivoting, integrated charts, Excel export and the server-side row model. Its own pages mark each of those as Enterprise, and the sources at the bottom of this page link to them.\n\nFor Svelte there is no official package. ag-grid.com names React, Angular, Vue and JavaScript and points Svelte users to community-built integrations, so in practice you call the JavaScript API from onMount and manage the DOM yourself. SvGrid is the inverse: a Svelte 5 component with runes and snippets, and a headless core underneath it.\n\n## Looking for a AG Grid alternative for Svelte?\n\nThe usual reason to look is that AG Grid on Svelte means the JavaScript API inside onMount plus a wrapper you do not control. SvGrid gives you a component that speaks runes natively and an MIT core with no row cap and no licence key. What you give up is breadth: AG Grid has more Enterprise modules, more integrations and a far larger body of examples, and if you need one grid across three frameworks it is still the right answer.\n\n## What they have in common\n\n- A full grid with sorting, filtering, grouping, virtualization, editing and selection.\n- Documentation with a runnable example per feature.\n- TypeScript-first APIs with an imperative grid API for code outside the component.\n- An MIT core plus a paid tier with support.\n- An official MCP server so AI assistants read the real docs instead of guessing.\n\n## Where SvGrid is stronger\n\n- Svelte 5 native: runes for state, snippets for cells, no wrapper layer and no DOM imperatives.\n- A smaller bundle on a Svelte page; the At a glance table above carries both measured numbers and the method.\n- Set-style Excel filters, cell-range selection with clipboard, master/detail, tree data, row grouping with aggregation, integrated charts, the column tool panel and the context menu are in the MIT core. AG Grid lists each of these under Enterprise. Flat server-side data (paging, infinite block scroll) is free in both; the server-side row model with lazy grouping, tree, pivot, transactions and selection across unloaded rows is a paid module in both.\n- In-grid AI helpers (natural-language filter, smart fill, summarise, classify, anomalies) ship free in @svgrid/grid with your own model provider.\n- @svgrid/mcp validates props and imports against the real API surface and scaffolds a project, on top of docs and examples.\n- @svgrid/enterprise costs $599 per developer per year for the enterprise grid, unlimited applications; AG Grid Enterprise's per-developer price is in the pricing line above.\n\n## Where AG Grid (community + enterprise) is stronger\n\n- A longer production record across very large enterprise deployments, and the largest body of third-party examples and answers of any grid.\n- The viewport row model, where the server pushes the visible window over a socket; SvGrid has no equivalent.\n- AG Charts, a separately licensed charting product with far more chart types than SvGridChart.\n- One grid API across React, Angular and Vue teams.\n- Enterprise support through Zendesk with one year of updates on the licence.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | AG Grid (community + enterprise) |\n| --- | --- | --- |\n| Svelte 5 native | Yes - runes and snippets | No - JavaScript build; Svelte is pointed to community-built integrations |\n| Sorting and filtering | Yes | Yes |\n| Excel-style set filter | Yes - MIT core | Paid tier - the Set Filter page is marked Enterprise |\n| Row and column virtualization | Yes | Yes |\n| Inline editing | Yes | Yes |\n| Cell-range selection and clipboard | Yes - MIT core, with a fill handle | Paid tier - Range Selection and Clipboard are Enterprise |\n| Master/detail | Yes - MIT core | Paid tier - Enterprise |\n| Tree data | Yes - MIT core | Paid tier - the Tree Data page is marked Enterprise |\n| Row grouping with aggregation | Yes - MIT core | Paid tier - Enterprise |\n| Pivot tables | Paid tier - @svgrid/enterprise | Paid tier - Enterprise |\n| Integrated charts | Yes - SvGridChart in the MIT core | Paid tier - Enterprise, through AG Charts |\n| Server-side data, flat (sort, filter, paging, infinite scroll) | Yes - MIT core: createServerDataSource, page or infinite block scroll | Yes - Infinite Row Model, Community |\n| Server-side row model (lazy grouping, tree, pivot, transactions, select-all across unloaded rows) | Paid tier - @svgrid/enterprise: createServerRowModel | Paid tier - Server-Side Row Model, Enterprise |\n| Global search over server-side data | Yes - filterModel.global goes to the server | No - the Quick Filter is not supported by the Infinite or Server-Side row models |\n| Viewport row model (server pushes the visible window) | No - not built; real-time deltas merge into loaded rows instead | Paid tier - Viewport Row Model, Enterprise |\n| CSV export | Yes - MIT core | Yes - Community, through the API |\n| Excel export | Paid tier - @svgrid/enterprise | Paid tier - Enterprise |\n| Column tool panel | Yes - MIT core | Paid tier - Tool Panels are Enterprise |\n| Context menu | Yes - MIT core | Paid tier - Enterprise |\n| MCP server for AI assistants | Yes - @svgrid/mcp, MIT: docs, examples, code check, scaffold, preview | Yes - ag-mcp, MIT: docs search, version detection, upgrade prompts |\n| In-grid AI helpers (natural-language filter, smart fill) | Yes - MIT core, bring your own model | No - not in the feature list |\n| Paid support | Yes - with @svgrid/enterprise, per developer per year | Yes - Enterprise licence, Zendesk |\n| Core licence | Yes - MIT | Yes - MIT (Community) |\n\n## Measured performance\n\nMeasured 12 Sep 2026 with the harness in the repository (pnpm bench:compare): 100,000 rows, fastest of 7 samples per operation, each grid in a fresh page, Chromium through Playwright, headless, on win32 10.0.26200 x64, Intel(R) Core(TM) Ultra 7 255U; a developer workstation, not a dedicated bench rig. Versions: @svgrid/grid 3.0.3, ag-grid-community 36.1.0. Lower is better.\n\n| Operation | SvGrid | AG Grid (community + enterprise) |\n| --- | --- | --- |\n| Mount | 75.5 ms | 51.4 ms |\n| Sort, text column | 149 ms | 282.8 ms |\n| Sort, numeric column | 115.7 ms | 270 ms |\n| Filter, one column (indicative only) | 99.6 ms | 170.4 ms |\n| Scroll, p95 frame | 34.6 ms | 35.9 ms |\n| Rows kept in the DOM (virtualization on) | 27 | 35 |\n\nThe filter row is not like for like: each grid is driven through its own single-column filter API, and the amount of work differs. The comparison guide explains the method, what is left out and why.\n\n## When to choose SvGrid\n\n- You are on Svelte 5 and want a grid shaped by Svelte idioms rather than a wrapper around a JavaScript build.\n- You want the set filter, master/detail, tree data, range selection, grouping and flat server-side infinite scroll without an Enterprise licence.\n- You want a paid support tier priced per developer per year at $599.\n- You want an assistant that can check the code it writes against the real API, not only read the docs.\n\n## When to choose AG Grid (community + enterprise)\n\n- You depend on a specific AG Grid Enterprise module, the viewport row model or server-side pivoting.\n- You run the same grid across React, Angular and Vue teams and want one library.\n- Your procurement already covers AG Grid Enterprise, or you need AG Charts.\n- You want the largest possible pool of existing examples, answers and contractors.\n\n## Migration effort\n\nEstimated effort: 1-3 hours per grid.\n\nWhat you give up:\n\n- AG Charts as a separately licensed charting product with far more chart types than SvGridChart.\n- The viewport row model; SvGrid's server-side row model covers sort, filter, grouping, tree, pivot, transactions and select-all across unloaded rows, but the server cannot push the visible window.\n- One grid API across React, Angular and Vue teams.\n- The largest body of third-party examples, Stack Overflow answers and contractors of any grid.\n\nMigration guide: moving from AG Grid (community + enterprise) to SvGrid: https://svgrid.com/docs/help/migrating-from-ag-grid/\n\n## Live example\n\nMigration From Ag Grid: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/139-migration-from-ag-grid/\n\n## The bottom line\n\nAG Grid is the safer pick for a multi-framework organisation, or when you need its deepest Enterprise modules, AG Charts or the viewport row model. For a Svelte-first team, SvGrid covers the Enterprise surface most teams actually buy AG Grid for, in the MIT core: master/detail, tree data, range selection, grouping, flat server-side infinite scroll and integrated charts, with pivot tables, the server-side row model and Excel, PDF and print output in the @svgrid/enterprise pack. The bundle and benchmark numbers on this page are measured on the dates shown, so read them rather than either vendor's adjectives.\n\n## Frequently asked questions\n\n### Does AG Grid support Svelte?\n\nNot with an official package. ag-grid.com names React, Angular, Vue and JavaScript, and points Svelte, Solid and Python users to community-built integrations. In practice you create the grid from the JavaScript API inside onMount and keep its state in sync with your Svelte state by hand.\n\n### Which AG Grid Enterprise features does SvGrid include in its MIT core?\n\nThe set-style Excel filter, cell-range selection with clipboard, master/detail, tree data, row grouping with aggregation, integrated charts, the column tool panel and the context menu. AG Grid's own pages list each of these under Enterprise. Pivot tables, the server-side row model (grouping, tree, pivot, transactions, selection) and Excel, PDF and print output are in @svgrid/enterprise, as they are in AG Grid Enterprise; flat server-side infinite scroll is free in both.\n\n### What does SvGrid not have that AG Grid Enterprise has?\n\nThe viewport row model, AG Charts as a full charting product, and the breadth of niche Enterprise modules that come from a decade of enterprise customers. SvGrid's server-side row model handles sort, filter, grouping, tree, pivot, transactions and selection across unloaded rows; if your requirement is the viewport model, AG Grid Enterprise is the better fit.\n\n### Does AG Grid have an MCP server too?\n\nYes. ag-mcp (MIT) gives an assistant AG Grid's docs, examples and version-specific upgrade prompts. @svgrid/mcp covers the same ground for SvGrid and adds a code checker that validates props and imports against the real API surface, a scaffold tool and a preview. Neither is an in-grid feature; SvGrid's natural-language filter, smart fill and summarise helpers are, and they ship free in @svgrid/grid with your own model provider.\n\n### How much does AG Grid Enterprise cost compared with SvGrid?\n\nAG Grid Enterprise is sold per developer with one year of updates; the price as ag-grid.com listed it on the date we read it is in the pricing line at the top of this page. SvGrid's core is MIT and @svgrid/enterprise is $599 per developer per year for the enterprise grid or $999 with the spreadsheet and Studio, both for unlimited applications. Compare what each paid tier adds before comparing prices: most of what AG Grid sells as Enterprise is in SvGrid's free core.\n\n### How long does a migration from AG Grid to SvGrid take?\n\nThe migration guide budgets one to three hours per grid. Column definitions map almost one to one, the imperative API has equivalents on SvGridApi, and most of the time goes into moving cell renderers to snippets. The guide also lists what does not map, so you can decide before you start.\n\n### Is SvGrid a good AG Grid alternative for Svelte?\n\nThe usual reason to look is that AG Grid on Svelte means the JavaScript API inside onMount plus a wrapper you do not control. SvGrid gives you a component that speaks runes natively and an MIT core with no row cap and no licence key. What you give up is breadth: AG Grid has more Enterprise modules, more integrations and a far larger body of examples, and if you need one grid across three frameworks it is still the right answer.\n\n## Related comparisons and docs\n\n- [SvGrid vs MUI X DataGrid](https://svgrid.com/compare/mui-x-datagrid/)\n- [SvGrid vs Handsontable](https://svgrid.com/compare/handsontable/)\n- [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/)\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](https://svgrid.com/docs/help/comparison/)\n- [Performance benchmarks](https://svgrid.com/docs/help/benchmarks/)\n- [Server-side data](https://svgrid.com/docs/help/server-side-data/)\n- [MCP server](https://svgrid.com/docs/help/mcp-server/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 12 Sep 2026. Versions, licences and download counts come from the npm registry; feature and pricing statements from the pages listed below.\n\n- ag-pricing: Enterprise is per developer at $999 USD with one year of updates and Zendesk support; the Enterprise Bundle is $1,498 USD; Community is free under MIT (checked 12 Sep 2026) https://www.ag-grid.com/license-pricing/\n- ag-community-vs-enterprise: Lists Range Selection, Clipboard, Master/Detail, Row Grouping, Pivot Tables and Aggregations, Integrated Charts, Excel export, Server-Side Row Model, Tool Panels and Context Menu under Enterprise Features; no AI feature in either list (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/community-vs-enterprise/\n- ag-set-filter: The Set Filter page is marked enterprise (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/filter-set/\n- ag-tree-data: The Tree Data page is marked enterprise (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/tree-data/\n- ag-csv-export: CSV export is available in Community through the API; the context-menu export and Excel export are Enterprise (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/csv-export/\n- ag-mcp: Official MCP server installed with npx ag-mcp: search_docs, detect_version, set_version and list_versions tools, quick-start and upgrade-grid prompts, docs and examples as resources; described as help for integrating and maintaining grid code, not an in-grid feature. The ag-mcp package on npm is MIT (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/mcp-server/\n- ag-home: Names React, Angular, Vue and JavaScript; says Svelte, Solid and Python users can use community-built integrations; the 36.1.0 release notes list an AI Coding Skill (checked 12 Sep 2026) https://www.ag-grid.com/\n- ag-row-models: The row-models feature table lists the Infinite Row Model as Community and the Server-Side and Viewport row models as Enterprise; the Quick Filter row is marked unsupported for the Infinite, Server-Side and Viewport models (checked 15 Sep 2026) https://www.ag-grid.com/javascript-data-grid/row-models/\n"
|
|
7216
|
+
"markdown": "# SvGrid vs AG Grid (community + enterprise)\n\nCanonical URL: https://svgrid.com/compare/ag-grid/\nFacts verified: 2026-09-12\nCompetitor package: ag-grid-community\nCompetitor site: https://www.ag-grid.com/\n\nAG Grid is the most widely used JavaScript data grid, sold for React, Angular, Vue and plain JavaScript, with Svelte covered only by community-built integrations.\n\n## Verdict\n\nAG Grid has the longer track record and the wider Enterprise catalogue; SvGrid is Svelte-5-native, smaller on a Svelte page, and puts most of what AG Grid sells as Enterprise into its MIT core. Pick AG Grid for parity with React and Angular teams; pick SvGrid for a Svelte-first stack.\n\nMigration guide: moving from AG Grid (community + enterprise) to SvGrid: https://svgrid.com/docs/help/migrating-from-ag-grid/\n\n## At a glance\n\n| Fact | SvGrid | AG Grid (community + enterprise) | Checked |\n| --- | --- | --- | --- |\n| Latest version | 3.0.3 | 36.1.0 | 12 Sep 2026 |\n| Licence field on npm | MIT | MIT | 12 Sep 2026 |\n| Last published | 11 Sep 2026 | 5 Aug 2026 | 12 Sep 2026 |\n| Svelte 5 support | Native (Svelte 5 runes) | No Svelte package (JavaScript, React, Angular, Vue) | 12 Sep 2026 |\n| npm downloads, 30 days to 10 Sep 2026 | 16,900 | 12,400,000 | 12 Sep 2026 |\n| Bundle, minified + gzip | 97.6 KB JS + 10.5 KB CSS | 317.5 KB JS, no separate stylesheet | 12 Sep 2026 |\n| Frameworks | Svelte 5 | JavaScript, React, Angular, Vue | 12 Sep 2026 |\n| Pricing | MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise | AG Grid Community is free under MIT. AG Grid Enterprise is listed at $999 USD per developer with one year of updates and Zendesk support; the Enterprise Bundle with AG Charts Enterprise is $1,498 USD per developer | 12 Sep 2026 |\n\nBundle sizes: each package built alone with Vite in library mode, minified, gzip level 9, Svelte kept external where a package uses it (it is a peer dependency). SvGrid 3.0.4 measured 22 Sep 2026 with packages/grid/scripts/measure-size.mjs; ag-grid-community 36.1.0 entry `createGrid + AllCommunityModule + themeQuartz` measured 12 Sep 2026 with packages/grid/scripts/measure-competitor-bundles.mjs.\n\n## Overview\n\nAG Grid is the heavyweight of the category: a Community edition under MIT and an Enterprise edition sold per developer that adds the set filter, range selection, master/detail, tree data, row grouping, pivoting, integrated charts, Excel export and the server-side row model. Its own pages mark each of those as Enterprise, and the sources at the bottom of this page link to them.\n\nFor Svelte there is no official package. ag-grid.com names React, Angular, Vue and JavaScript and points Svelte users to community-built integrations, so in practice you call the JavaScript API from onMount and manage the DOM yourself. SvGrid is the inverse: a Svelte 5 component with runes and snippets, and a headless core underneath it.\n\n## Looking for an AG Grid alternative for Svelte?\n\nThe usual reason to look is that AG Grid on Svelte means the JavaScript API inside onMount plus a wrapper you do not control. SvGrid gives you a component that speaks runes natively and an MIT core with no row cap and no licence key. What you give up is breadth: AG Grid has more Enterprise modules, more integrations and a far larger body of examples, and if you need one grid across three frameworks it is still the right answer.\n\n## What they have in common\n\n- A full grid with sorting, filtering, grouping, virtualization, editing and selection.\n- Documentation with a runnable example per feature.\n- TypeScript-first APIs with an imperative grid API for code outside the component.\n- An MIT core plus a paid tier with support.\n- An official MCP server so AI assistants read the real docs instead of guessing.\n\n## Where SvGrid is stronger\n\n- Svelte 5 native: runes for state, snippets for cells, no wrapper layer and no DOM imperatives.\n- A smaller bundle on a Svelte page; the At a glance table above carries both measured numbers and the method.\n- Set-style Excel filters, cell-range selection with clipboard, master/detail, tree data, row grouping with aggregation, integrated charts, the column tool panel and the context menu are in the MIT core. AG Grid lists each of these under Enterprise. Flat server-side data (paging, infinite block scroll) is free in both; the server-side row model with lazy grouping, tree, pivot, transactions and selection across unloaded rows is a paid module in both.\n- In-grid AI helpers (natural-language filter, smart fill, summarise, classify, anomalies) ship free in @svgrid/grid with your own model provider.\n- @svgrid/mcp validates props and imports against the real API surface and scaffolds a project, on top of docs and examples.\n- @svgrid/enterprise costs $599 per developer per year for the enterprise grid, unlimited applications; AG Grid Enterprise's per-developer price is in the pricing line above.\n\n## Where AG Grid (community + enterprise) is stronger\n\n- A longer production record across very large enterprise deployments, and the largest body of third-party examples and answers of any grid.\n- The viewport row model, where the server pushes the visible window over a socket; SvGrid has no equivalent.\n- AG Charts, a separately licensed charting product with far more chart types than SvGridChart.\n- One grid API across React, Angular and Vue teams.\n- Enterprise support through Zendesk with one year of updates on the licence.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | AG Grid (community + enterprise) |\n| --- | --- | --- |\n| Svelte 5 native | Yes - runes and snippets | No - JavaScript build; Svelte is pointed to community-built integrations |\n| Sorting and filtering | Yes | Yes |\n| Excel-style set filter | Yes - MIT core | Paid tier - the Set Filter page is marked Enterprise |\n| Row and column virtualization | Yes | Yes |\n| Inline editing | Yes | Yes |\n| Cell-range selection and clipboard | Yes - MIT core, with a fill handle | Paid tier - Range Selection and Clipboard are Enterprise |\n| Master/detail | Yes - MIT core | Paid tier - Enterprise |\n| Tree data | Yes - MIT core | Paid tier - the Tree Data page is marked Enterprise |\n| Row grouping with aggregation | Yes - MIT core | Paid tier - Enterprise |\n| Pivot tables | Paid tier - @svgrid/enterprise | Paid tier - Enterprise |\n| Integrated charts | Yes - SvGridChart in the MIT core | Paid tier - Enterprise, through AG Charts |\n| Server-side data, flat (sort, filter, paging, infinite scroll) | Yes - MIT core: createServerDataSource, page or infinite block scroll | Yes - Infinite Row Model, Community |\n| Server-side row model (lazy grouping, tree, pivot, transactions, select-all across unloaded rows) | Paid tier - @svgrid/enterprise: createServerRowModel | Paid tier - Server-Side Row Model, Enterprise |\n| Global search over server-side data | Yes - filterModel.global goes to the server | No - the Quick Filter is not supported by the Infinite or Server-Side row models |\n| Viewport row model (server pushes the visible window) | No - not built; real-time deltas merge into loaded rows instead | Paid tier - Viewport Row Model, Enterprise |\n| CSV export | Yes - MIT core | Yes - Community, through the API |\n| Excel export | Paid tier - @svgrid/enterprise | Paid tier - Enterprise |\n| Column tool panel | Yes - MIT core | Paid tier - Tool Panels are Enterprise |\n| Context menu | Yes - MIT core | Paid tier - Enterprise |\n| MCP server for AI assistants | Yes - @svgrid/mcp, MIT: docs, examples, code check, scaffold, preview | Yes - ag-mcp, MIT: docs search, version detection, upgrade prompts |\n| In-grid AI helpers (natural-language filter, smart fill) | Yes - MIT core, bring your own model | No - not in the feature list |\n| Paid support | Yes - with @svgrid/enterprise, per developer per year | Yes - Enterprise licence, Zendesk |\n| Core licence | Yes - MIT | Yes - MIT (Community) |\n\n## Measured performance\n\nMeasured 12 Sep 2026 with the harness in the repository (pnpm bench:compare): 100,000 rows, fastest of 7 samples per operation, each grid in a fresh page, Chromium through Playwright, headless, on win32 10.0.26200 x64, Intel(R) Core(TM) Ultra 7 255U; a developer workstation, not a dedicated bench rig. Versions: @svgrid/grid 3.0.3, ag-grid-community 36.1.0. Lower is better.\n\n| Operation | SvGrid | AG Grid (community + enterprise) |\n| --- | --- | --- |\n| Mount | 75.5 ms | 51.4 ms |\n| Sort, text column | 149 ms | 282.8 ms |\n| Sort, numeric column | 115.7 ms | 270 ms |\n| Filter, one column (indicative only) | 99.6 ms | 170.4 ms |\n| Scroll, p95 frame | 34.6 ms | 35.9 ms |\n| Rows kept in the DOM (virtualization on) | 27 | 35 |\n\nThe filter row is not like for like: each grid is driven through its own single-column filter API, and the amount of work differs. The comparison guide explains the method, what is left out and why.\n\n## When to choose SvGrid\n\n- You are on Svelte 5 and want a grid shaped by Svelte idioms rather than a wrapper around a JavaScript build.\n- You want the set filter, master/detail, tree data, range selection, grouping and flat server-side infinite scroll without an Enterprise licence.\n- You want a paid support tier priced per developer per year at $599.\n- You want an assistant that can check the code it writes against the real API, not only read the docs.\n\n## When to choose AG Grid (community + enterprise)\n\n- You depend on a specific AG Grid Enterprise module, the viewport row model or server-side pivoting.\n- You run the same grid across React, Angular and Vue teams and want one library.\n- Your procurement already covers AG Grid Enterprise, or you need AG Charts.\n- You want the largest possible pool of existing examples, answers and contractors.\n\n## Migration effort\n\nEstimated effort: 1-3 hours per grid.\n\nWhat you give up:\n\n- AG Charts as a separately licensed charting product with far more chart types than SvGridChart.\n- The viewport row model; SvGrid's server-side row model covers sort, filter, grouping, tree, pivot, transactions and select-all across unloaded rows, but the server cannot push the visible window.\n- One grid API across React, Angular and Vue teams.\n- The largest body of third-party examples, Stack Overflow answers and contractors of any grid.\n\nMigration guide: moving from AG Grid (community + enterprise) to SvGrid: https://svgrid.com/docs/help/migrating-from-ag-grid/\n\n## Live example\n\nMigration From Ag Grid: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/139-migration-from-ag-grid/\n\n## The bottom line\n\nAG Grid is the safer pick for a multi-framework organisation, or when you need its deepest Enterprise modules, AG Charts or the viewport row model. For a Svelte-first team, SvGrid covers the Enterprise surface most teams actually buy AG Grid for, in the MIT core: master/detail, tree data, range selection, grouping, flat server-side infinite scroll and integrated charts, with pivot tables, the server-side row model and Excel, PDF and print output in the @svgrid/enterprise pack. The bundle and benchmark numbers on this page are measured on the dates shown, so read them rather than either vendor's adjectives.\n\n## Frequently asked questions\n\n### Does AG Grid support Svelte?\n\nNot with an official package. ag-grid.com names React, Angular, Vue and JavaScript, and points Svelte, Solid and Python users to community-built integrations. In practice you create the grid from the JavaScript API inside onMount and keep its state in sync with your Svelte state by hand.\n\n### Which AG Grid Enterprise features does SvGrid include in its MIT core?\n\nThe set-style Excel filter, cell-range selection with clipboard, master/detail, tree data, row grouping with aggregation, integrated charts, the column tool panel and the context menu. AG Grid's own pages list each of these under Enterprise. Pivot tables, the server-side row model (grouping, tree, pivot, transactions, selection) and Excel, PDF and print output are in @svgrid/enterprise, as they are in AG Grid Enterprise; flat server-side infinite scroll is free in both.\n\n### What does SvGrid not have that AG Grid Enterprise has?\n\nThe viewport row model, AG Charts as a full charting product, and the breadth of niche Enterprise modules that come from a decade of enterprise customers. SvGrid's server-side row model handles sort, filter, grouping, tree, pivot, transactions and selection across unloaded rows; if your requirement is the viewport model, AG Grid Enterprise is the better fit.\n\n### Does AG Grid have an MCP server too?\n\nYes. ag-mcp (MIT) gives an assistant AG Grid's docs, examples and version-specific upgrade prompts. @svgrid/mcp covers the same ground for SvGrid and adds a code checker that validates props and imports against the real API surface, a scaffold tool and a preview. Neither is an in-grid feature; SvGrid's natural-language filter, smart fill and summarise helpers are, and they ship free in @svgrid/grid with your own model provider.\n\n### How much does AG Grid Enterprise cost compared with SvGrid?\n\nAG Grid Enterprise is sold per developer with one year of updates; the price as ag-grid.com listed it on the date we read it is in the pricing line at the top of this page. SvGrid's core is MIT and @svgrid/enterprise is $599 per developer per year for the enterprise grid or $999 with the spreadsheet and Studio, both for unlimited applications. Compare what each paid tier adds before comparing prices: most of what AG Grid sells as Enterprise is in SvGrid's free core.\n\n### How long does a migration from AG Grid to SvGrid take?\n\nThe migration guide budgets one to three hours per grid. Column definitions map almost one to one, the imperative API has equivalents on SvGridApi, and most of the time goes into moving cell renderers to snippets. The guide also lists what does not map, so you can decide before you start.\n\n### Is SvGrid a good AG Grid alternative for Svelte?\n\nThe usual reason to look is that AG Grid on Svelte means the JavaScript API inside onMount plus a wrapper you do not control. SvGrid gives you a component that speaks runes natively and an MIT core with no row cap and no licence key. What you give up is breadth: AG Grid has more Enterprise modules, more integrations and a far larger body of examples, and if you need one grid across three frameworks it is still the right answer.\n\n## Related comparisons and docs\n\n- [SvGrid vs MUI X DataGrid](https://svgrid.com/compare/mui-x-datagrid/)\n- [SvGrid vs Handsontable](https://svgrid.com/compare/handsontable/)\n- [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/)\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](https://svgrid.com/docs/help/comparison/)\n- [Performance benchmarks](https://svgrid.com/docs/help/benchmarks/)\n- [Server-side data](https://svgrid.com/docs/help/server-side-data/)\n- [MCP server](https://svgrid.com/docs/help/mcp-server/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 12 Sep 2026. Versions, licences and download counts come from the npm registry; feature and pricing statements from the pages listed below.\n\n- ag-pricing: Enterprise is per developer at $999 USD with one year of updates and Zendesk support; the Enterprise Bundle is $1,498 USD; Community is free under MIT (checked 12 Sep 2026) https://www.ag-grid.com/license-pricing/\n- ag-community-vs-enterprise: Lists Range Selection, Clipboard, Master/Detail, Row Grouping, Pivot Tables and Aggregations, Integrated Charts, Excel export, Server-Side Row Model, Tool Panels and Context Menu under Enterprise Features; no AI feature in either list (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/community-vs-enterprise/\n- ag-set-filter: The Set Filter page is marked enterprise (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/filter-set/\n- ag-tree-data: The Tree Data page is marked enterprise (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/tree-data/\n- ag-csv-export: CSV export is available in Community through the API; the context-menu export and Excel export are Enterprise (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/csv-export/\n- ag-mcp: Official MCP server installed with npx ag-mcp: search_docs, detect_version, set_version and list_versions tools, quick-start and upgrade-grid prompts, docs and examples as resources; described as help for integrating and maintaining grid code, not an in-grid feature. The ag-mcp package on npm is MIT (checked 12 Sep 2026) https://www.ag-grid.com/javascript-data-grid/mcp-server/\n- ag-home: Names React, Angular, Vue and JavaScript; says Svelte, Solid and Python users can use community-built integrations; the 36.1.0 release notes list an AI Coding Skill (checked 12 Sep 2026) https://www.ag-grid.com/\n- ag-row-models: The row-models feature table lists the Infinite Row Model as Community and the Server-Side and Viewport row models as Enterprise; the Quick Filter row is marked unsupported for the Infinite, Server-Side and Viewport models (checked 15 Sep 2026) https://www.ag-grid.com/javascript-data-grid/row-models/\n"
|
|
7217
|
+
},
|
|
7218
|
+
{
|
|
7219
|
+
"slug": "compare/airtable",
|
|
7220
|
+
"path": "docs/_data/comparisons/airtable.json",
|
|
7221
|
+
"title": "SvGrid vs Airtable",
|
|
7222
|
+
"section": "Comparisons",
|
|
7223
|
+
"markdown": "# SvGrid vs Airtable\n\nCanonical URL: https://svgrid.com/compare/airtable/\nFacts verified: 2026-10-14\nCompetitor site: https://www.airtable.com/\n\nAirtable is a hosted table-database app: bases of linked tables with views, interfaces, automations and AI, sold per seat as a cloud service.\n\n## Verdict\n\nAirtable is the pick when a team wants the finished service and is happy to work in it. SvGrid Base is the pick when the same experience has to live inside your own Svelte app, on your servers and your database, with no per-seat bill and no Airtable branding.\n\nMigration guide: moving from Airtable to SvGrid: https://svgrid.com/docs/help/migrating-from-airtable/\n\n## At a glance\n\n| Fact | SvGrid | Airtable | Checked |\n| --- | --- | --- | --- |\n| What it is | A Svelte 5 component (<SvBase>) inside your own app, on your database | Hosted web app (cloud) | 14 Oct 2026 |\n| Pricing | SvGrid Base ships in Enterprise Suite of @svgrid/enterprise, $999 per developer per year, unlimited apps and end users; the grid it runs on is MIT | Airtable Free has no cost. airtable.com/pricing lists Team at $20 per seat per month billed annually ($24 monthly) and Business at $45 per seat per month billed annually ($54 monthly); Enterprise Scale pricing is custom. Read-only collaborators are not charged on Team or Business | 14 Oct 2026 |\n\n## Overview\n\nAirtable organizes data into bases of linked tables with typed fields, and shows them through grid, form, calendar, gallery, Kanban, list, timeline and Gantt views. On top of a base it offers Interface Designer for app-like pages, automations with record, schedule, form and webhook triggers, Omni and other AI features, comments, revision history and native mobile apps. It is a cloud service only: Airtable says it does not plan to offer a self-hosted version.\n\nSvGrid Base is that kind of table database shipped as a Svelte 5 component in @svgrid/enterprise. You add <SvBase> to a page, give it tables or let it read your database, and your users work with typed fields, saved views, filters, groups, row colors, linked records, automations and records with comments, inside your app and under your login. The records stay in the database you already run.\n\n## Looking for an Airtable alternative for Svelte?\n\nMost searches for an Airtable alternative come from developers who like what Airtable does but cannot use it where they need it: inside their own product, for their own customers, on data that has to stay in their database. Airtable is cloud-only and its embed is a read-only iframe. SvGrid Base gives you the parts people know from it (typed fields, linked records, grid, Kanban, calendar, gallery, Gantt and form views, automations, an expanded record with comments) as one Svelte component, <SvBase>, that you host in your app and point at Postgres, MySQL, SQLite, Supabase or your REST API.\n\n## What they have in common\n\n- Bases of several tables, linked to each other, with lookup, rollup and count fields.\n- Typed fields: single and multiple select, user, date, attachment, rating, barcode, button, formula, autonumber, created and last-modified.\n- Grid, form, calendar, gallery, Kanban and Gantt views, each with its own filters, sorts, groups and hidden fields.\n- Automations triggered by a new record, a change, a matching condition, a form, a button or a schedule.\n- An expanded record with every field and its comments.\n- Row colors driven by a field or by conditions.\n\n## Where SvGrid is stronger\n\n- Runs where you need it: inside your own Svelte app, on your servers, with your login; Airtable is cloud-only and its embed is a read-only iframe.\n- Your data stays in your database: Postgres, MySQL, SQLite, Supabase or your API, read and written through your own endpoint, with no data residency question.\n- No Airtable branding to remove: the base takes your app's look from --sg-* tokens.\n- Automations run in the browser or on your server with no monthly run quota, and their secrets never leave your server.\n- No record cap set by a plan: the database decides, and the page loads up to a limit you choose.\n- Priced per developer, not per seat: the people who use the app do not each need a licence.\n\n## Where Airtable is stronger\n\n- A finished service: sign up and work, with nothing to host and no developer needed.\n- Interface Designer, Omni AI and AI credits on every plan, and a large set of automation integrations (Slack, Google Workspace, Microsoft Teams, Salesforce, Jira and more).\n- Native iOS and Android apps, and a list and inbox view.\n- Field and table editing permissions, sharing, and record revision history kept for up to three years on Enterprise Scale.\n- Import from CSV, Excel, Google Sheets and Smartsheet, and a public REST API with webhooks.\n- A large template gallery and community.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | Airtable |\n| --- | --- | --- |\n| Runs inside your own app's pages | Yes - a Svelte 5 component, <SvBase> | No - a live, read-only iframe of a view or base |\n| Edit records from the embedded view | Yes | No - embeds are read-only |\n| Runs on your own servers | Yes - it is part of your app | No - cloud SaaS only, no self-hosted option planned |\n| Data stays in your own database | Yes - Postgres, MySQL, SQLite, Supabase or a REST API | No - stored in Airtable's cloud; region choice on Enterprise Scale |\n| Grid, form, gallery, Kanban and calendar views | Yes | Yes - every plan |\n| Gantt and timeline views | Yes - Gantt view with dependencies and progress | Paid tier - Team plan and up |\n| Linked records, lookup, rollup, count and formula fields | Yes | Yes |\n| Barcode, button and autonumber fields | Yes | Yes |\n| Automations | Yes - in the browser or on your server, no run quota | Partial - runs metered per month by plan |\n| Call your own webhook from an automation | Yes - POST the record to any URL, secrets read on the server | Partial - through a run-a-script action, not on Free |\n| Record comments | Yes - stored wherever your app stores them | Yes - every plan |\n| Record revision history | Partial - this session's changes in the record; a server audit hook logs every write | Yes - kept from two weeks to three years by plan |\n| Field and table editing permissions | Partial - your server's authorize hook or the database's row level security; no permissions screen | Paid tier - Team plan and up |\n| Changes from other users appear live | Partial - through a realtime source (Supabase Realtime, a WebSocket, a poll) | Yes |\n| Import CSV or Excel files | Partial - paste a range, or load the file into your database and open it | Yes - CSV, Excel, Google Sheets and Smartsheet |\n| Your branding only | Yes - your app's look through --sg-* tokens | No - not possible to remove all Airtable branding |\n| Records per table | Partial - the page holds a table's records, up to a limit you set (10,000 by default) | Partial - a per-base cap set by the plan |\n| Works offline | Yes - with Postgres in the browser (PGlite) | No - offline access is not supported |\n| Native mobile apps | No - a web component in your app | Yes - iOS and Android |\n| Usable with no developer | No - a developer adds it to an app | Yes - sign up and start |\n\n## When to choose SvGrid\n\n- The table database has to live inside your own Svelte or SvelteKit app, for your users or your customers, under your login and your look.\n- The data must stay in your database or on your servers.\n- You do not want a per-seat bill that grows with every person who uses the app.\n- You need automations whose secrets and side effects stay on your server.\n\n## When to choose Airtable\n\n- You want a finished service a team can use today, with no developer and nothing to host.\n- You want built-in AI, interfaces and a wide set of ready-made automation integrations.\n- You need native mobile apps.\n- You want permissions, sharing and long revision history handled for you.\n\n## Migration effort\n\nEstimated effort: half a day for a typical base, plus rebuilding automations.\n\nWhat you give up:\n\n- The hosted service: someone has to add <SvBase> to a Svelte app and run the database it reads.\n- Interfaces, Omni and the AI credits; the screens around the base are your own Svelte pages.\n- Native mobile apps and the list and inbox views.\n- The permissions screen, sharing links and long revision history; access rules move into your server or database, and your audit hook keeps the history.\n\nMigration guide: moving from Airtable to SvGrid: https://svgrid.com/docs/help/migrating-from-airtable/\n\n## Live example\n\nProject Tracker: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/504-project-tracker/\n\n## The bottom line\n\nAirtable is the reference table database for a team that wants a finished cloud service. When that experience has to be part of your own product, SvGrid Base fits better: it is a Svelte component you host, it works on the database you already run, it carries your branding and no one else's, and it is priced per developer rather than per seat.\n\n## Frequently asked questions\n\n### Can I put Airtable inside my own app?\n\nAirtable lets you embed a live, read-only copy of a view or base as an iframe, and Business and Enterprise Scale plans can embed a single interface page. It is a cloud service only, so the data and the app stay on Airtable. SvGrid Base is a Svelte component: its views render and edit records in your own pages, behind your login, on your database.\n\n### Is there a self-hosted Airtable?\n\nNot from Airtable: its technical requirements page says it is a cloud SaaS product with no plan to offer an on-premise or self-hosted version. SvGrid Base runs wherever your Svelte app runs, and keeps records in Postgres, MySQL, SQLite, Supabase or behind your REST API.\n\n### Which Airtable features does SvGrid Base have?\n\nSeveral linked tables per base; single and multiple select, user, date, attachment, rating, barcode, button, formula, autonumber, lookup, rollup and count fields; grid, Kanban, calendar, gallery, Gantt, chart and form views with their own filters, sorts and groups; row colors; automations with record, form, button and schedule triggers and update, create, email and webhook actions; and an expanded record with comments.\n\n### How do I move a base from Airtable to SvGrid Base?\n\nDownload each view as CSV, load the files into your own database tables, add foreign keys for the links, and point createBaseEndpoint at the database: the base opens with a tab per table, typed fields and links. Views and automations are rebuilt in a few lines of configuration. The migration guide has the steps.\n\n### How is SvGrid Base priced compared with Airtable?\n\nAirtable is priced per seat per month; the prices it listed on the day we read them are in the pricing line at the top of this page. SvGrid Base ships in Enterprise Suite at $999 per developer per year, for unlimited apps and unlimited end users, so the number of people using the app does not change the price.\n\n### What does SvGrid Base not do that Airtable does?\n\nIt is not a hosted service and needs a developer to add it to an app. There are no interfaces, no built-in AI credits, no native mobile apps, no permissions screen or sharing links (access rules live in your server or database), no long revision history kept for you, and every table's records are held in the page up to a limit you set.\n\n### Is SvGrid a good Airtable alternative for Svelte?\n\nMost searches for an Airtable alternative come from developers who like what Airtable does but cannot use it where they need it: inside their own product, for their own customers, on data that has to stay in their database. Airtable is cloud-only and its embed is a read-only iframe. SvGrid Base gives you the parts people know from it (typed fields, linked records, grid, Kanban, calendar, gallery, Gantt and form views, automations, an expanded record with comments) as one Svelte component, <SvBase>, that you host in your app and point at Postgres, MySQL, SQLite, Supabase or your REST API.\n\n## Related comparisons and docs\n\n- [SvGrid vs Baserow](https://svgrid.com/compare/baserow/)\n- [SvGrid vs NocoDB](https://svgrid.com/compare/nocodb/)\n- [Build a project tracker](https://svgrid.com/docs/help/base/start/)\n- [Ready-to-use bases](https://svgrid.com/docs/help/base/templates/)\n- [From a database to an app](https://svgrid.com/docs/help/base/quick-start/)\n- [Automations](https://svgrid.com/docs/help/base/automations/)\n- [Field types](https://svgrid.com/docs/help/fields/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 14 Oct 2026. Feature and pricing statements come from the pages listed below.\n\n- at-pricing: Free plan at no cost with up to 5 creators or editors; Team $20 per user per month billed annually; Business $45 per user per month billed annually; Enterprise Scale pricing is custom; read-only collaborators are not charged on Team or Business (checked 14 Oct 2026) https://airtable.com/pricing\n- at-plans: Team $24 per collaborator per month billed monthly or $20 billed annually; Business $54 monthly; records per base 1,000 on Free, 50,000 on Team and 125,000 on Business; Team and above include timeline and Gantt views (checked 14 Oct 2026) https://support.airtable.com/articles/2277136852-airtable-plans-overview\n- at-embed: Embedding places a live, read-only copy of a view or base inside another web page as an HTML iframe (checked 14 Oct 2026) https://support.airtable.com/articles/6973404921-embedding-airtable-views-and-bases\n- at-tech: Airtable is a cloud-based SaaS product and does not plan on offering an on-premise or self-hosted solution; offline access is not supported (checked 14 Oct 2026) https://support.airtable.com/articles/5016991853-airtable-technical-requirements\n- at-residency: Data residency in the United States by default, Europe or Australia for Enterprise Scale customers; some data stays in US data centers whatever the region (checked 14 Oct 2026) https://support.airtable.com/articles/3345708696-data-residency-at-airtable\n- at-views: Grid is the default view; other types include form, calendar, gallery, Kanban, timeline, list and Gantt (checked 14 Oct 2026) https://support.airtable.com/articles/5189551686-getting-started-with-airtable-views\n- at-fields: Field types include linked record, lookup, rollup, count, formula, attachment, rating, barcode, button, autonumber, created and last modified time and by, user, duration, currency and percent (checked 14 Oct 2026) https://support.airtable.com/articles/4808831313-supported-field-types-in-airtable-overview\n- at-automations: Triggers include record created, updated, entering a view or matching a condition, form submitted, scheduled time, webhook received and button clicked; actions include record actions, send email and run a script, unavailable on Free; runs per workspace per month from 100 on Free to 500,000 on Enterprise Scale (checked 14 Oct 2026) https://support.airtable.com/articles/3669392397-getting-started-with-airtable-automations\n- at-comments: Commenting is available on all plan types for collaborators with commenter permissions or higher (checked 14 Oct 2026) https://support.airtable.com/articles/1999477400-commenting-in-airtable\n- at-history: Record revision history covers the past 2 weeks on Free, 1 year on Team, 2 years on Business and 3 years on Enterprise Scale (checked 14 Oct 2026) https://support.airtable.com/articles/3516802427-record-level-revision-history-in-airtable\n- at-permissions: Field and table editing permissions are available on Team, Business and Enterprise Scale and do not affect who can see information (checked 14 Oct 2026) https://support.airtable.com/articles/2296468758-airtable-field-and-table-editing-permissions\n- at-guide: Teams work from the same data sources, updated in real time (checked 14 Oct 2026) https://www.airtable.com/guides/start/what-is-airtable\n- at-import: Native import of CSV and Excel files, and imports from Google Sheets and Smartsheet, on all plan types (checked 14 Oct 2026) https://support.airtable.com/articles/9412806945-importing-third-party-data-into-airtable\n- at-forms: The Airtable label can be removed from forms on paid plans, but it is not currently possible to remove all Airtable branding or add custom branding within the Airtable UI (checked 14 Oct 2026) https://support.airtable.com/articles/8588280258-getting-started-with-airtable-form-views\n- at-mobile: iOS and Android apps exist; automations, Gantt, timeline and CSV import and export are not available in them (checked 14 Oct 2026) https://support.airtable.com/articles/9609457174-airtable-desktop-and-mobile-feature-differences\n"
|
|
7224
|
+
},
|
|
7225
|
+
{
|
|
7226
|
+
"slug": "compare/baserow",
|
|
7227
|
+
"path": "docs/_data/comparisons/baserow.json",
|
|
7228
|
+
"title": "SvGrid vs Baserow",
|
|
7229
|
+
"section": "Comparisons",
|
|
7230
|
+
"markdown": "# SvGrid vs Baserow\n\nCanonical URL: https://svgrid.com/compare/baserow/\nFacts verified: 2026-10-14\nCompetitor site: https://baserow.io/\n\nBaserow is an open-source table-database app with an application builder and automations, run on its cloud or self-hosted with Docker on PostgreSQL.\n\n## Verdict\n\nBaserow is the pick for a team that wants an open-source app to sign into, self-hosted for free. SvGrid Base is the pick when the table database has to be part of your own Svelte app: editable in your pages, on your database, with Kanban, calendar, Gantt and comments in the one licence.\n\nMigration guide: moving from Baserow to SvGrid: https://svgrid.com/docs/help/migrating-from-baserow/\n\n## At a glance\n\n| Fact | SvGrid | Baserow | Checked |\n| --- | --- | --- | --- |\n| What it is | A Svelte 5 component (<SvBase>) inside your own app, on your database | Hosted web app (cloud), Self-hosted web app (Docker) | 14 Oct 2026 |\n| Pricing | SvGrid Base ships in Enterprise Suite of @svgrid/enterprise, $999 per developer per year, unlimited apps and end users; the grid it runs on is MIT | Baserow's self-hosted open-source edition is free with unlimited rows. baserow.io/pricing lists Premium at $10 per user per month billed yearly ($12 monthly), Advanced at $18 per user per month billed yearly ($22 monthly) and Enterprise on request, at the same prices on cloud and self-hosted; cloud Free is $0 with 3,000 rows per workspace | 14 Oct 2026 |\n\n## Overview\n\nBaserow gives a team tables of typed fields with grid, form and gallery views for free, and Kanban, calendar, timeline and survey views on paid plans. It has linked records with lookups, rollups and counts, formulas, an automation builder with email, HTTP and AI actions, an application builder for portals and internal tools, a REST API and real-time collaboration. The core is MIT; premium and enterprise features need a subscription, self-hosted or on cloud.\n\nSvGrid Base is a table database you ship as part of your own Svelte 5 app. It comes in @svgrid/enterprise: your users get typed fields, saved views, filters, groups, row colors, linked records, automations and an expanded record with comments, and you keep the data in the database you already run. Nothing runs beside your app; the base is a component in it.\n\n## Looking for a Baserow alternative for Svelte?\n\nPeople searching for a Baserow alternative usually want the Baserow experience somewhere Baserow does not go: inside their own product. Baserow can show a public view in an iframe, read-only except for forms, or publish an app made in its application builder on its own domain. SvGrid Base is a Svelte component instead: <SvBase> renders editable grid, Kanban, calendar, gallery, Gantt, chart and form views in your pages, behind your login, and writes every change to your Postgres, MySQL, SQLite, Supabase or REST API.\n\n## What they have in common\n\n- Typed fields with grid, form and gallery views, and saved filters, sorts and groups per view.\n- Linked records between tables, with lookup, rollup and count fields and formulas.\n- Autonumber, created and last-modified fields, and collaborator fields.\n- Automations triggered by record changes, a schedule or a webhook, with email and HTTP actions.\n- Data kept in PostgreSQL.\n- Real-time updates when other people change records.\n\n## Where SvGrid is stronger\n\n- It is a component, not an app: <SvBase> renders inside your own Svelte pages and edits records there, where Baserow's embed is a read-only iframe of a public view.\n- Kanban, calendar, Gantt, chart, row colors and record comments are all in the one Suite licence; Baserow puts Kanban, calendar, timeline, comments and row coloring on paid plans.\n- Works on your own tables: it reads your schema, columns and foreign keys and writes back to them, where Baserow keeps data in its own database and syncs other sources in.\n- No vendor branding to remove: the base takes your app's look from --sg-* tokens.\n- Automations run in the browser or on your server with no credits to count, and their secrets stay on your server.\n- Priced per developer, not per user: the people who use the app do not need seats.\n\n## Where Baserow is stronger\n\n- Open source with an MIT core, and free to self-host with unlimited rows and storage.\n- A finished product with sign-in, workspaces, roles and sharing; no developer needed to start.\n- An application builder for portals and internal tools on top of the data, with custom domains.\n- Long row change history, audit logs on Advanced, and a REST API and MCP server.\n- File import (CSV, Excel, JSON, XML, and Airtable bases) and AI fields and an AI assistant.\n- No record limit per table when self-hosted; SvGrid Base keeps each table's records in the page, up to a configurable limit.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | Baserow |\n| --- | --- | --- |\n| Runs inside your own app's pages | Yes - a Svelte 5 component, <SvBase> | No - public views embed as an iframe; no UI component or SDK found |\n| Edit records from the embedded view | Yes | No - shared views are read-only except forms |\n| Source licence | Paid tier - commercial (Enterprise Suite); the grid underneath is MIT | Yes - MIT core; premium and enterprise code needs a subscription in production |\n| Works on your existing database | Yes - Postgres, MySQL, SQLite, Supabase or a REST API | Partial - data lives in Baserow's PostgreSQL; a PostgreSQL table syncs in, two-way optional, not real time |\n| Grid, form and gallery views | Yes | Yes - every plan |\n| Kanban and calendar views | Yes | Paid tier - Premium and up |\n| Gantt or timeline view | Yes - Gantt view with dependencies and progress | Paid tier - timeline view on Premium and up |\n| Linked records, lookup, rollup, count and formula fields | Yes | Yes |\n| Autonumber, created and last-modified fields | Yes | Yes |\n| Automations | Yes - in the browser or on your server, no credits | Yes - credits per month on cloud, unlimited self-hosted |\n| Record comments | Yes - stored wherever your app stores them | Paid tier - paid plans |\n| Row colors | Yes - by a field or by conditions, per view | Paid tier - Premium and up |\n| Row change history | Partial - this session's changes in the record; a server audit hook logs every write | Yes - kept by plan on cloud |\n| Who can see and edit what | Partial - your server's authorize hook or the database's row level security; no roles screen | Paid tier - role, field and view permissions on Advanced and Enterprise |\n| Changes from other users appear live | Partial - through a realtime source (Supabase Realtime, a WebSocket, a poll) | Yes |\n| Import CSV or Excel files | Partial - paste a range, or load the file into your database and open it | Yes - CSV, Excel, JSON, XML and Airtable |\n| Your branding only | Yes - your app's look through --sg-* tokens | Paid tier - logo removal on paid plans |\n| Records per table | Partial - the page holds a table's records, up to a limit you set (10,000 by default) | Yes - unlimited self-hosted; capped per workspace on cloud |\n| Usable with no developer | No - a developer adds it to an app | Yes - cloud sign-up or a Docker install |\n\n## When to choose SvGrid\n\n- The table database has to appear, editable, inside your own Svelte or SvelteKit app, under your login and your look.\n- You want Kanban, calendar, Gantt, comments and row colors without per-user paid plans.\n- Your data already lives in Postgres, MySQL, SQLite, Supabase or behind your API and should stay in those tables.\n- You would rather pay per developer than per user.\n\n## When to choose Baserow\n\n- You want an open-source app a team can self-host and use today, with no developer.\n- You need roles, workspaces and sharing managed from a screen.\n- You want an application builder for portals on your own domain without writing a front end.\n- You want file import, AI fields and long row history built in.\n\n## Migration effort\n\nEstimated effort: 1-3 hours per database, plus rebuilding automations.\n\nWhat you give up:\n\n- Workspaces, roles and the sharing screen; who may do what moves into your server's authorize hook or your database's row level security.\n- The application builder; the screens around the base are your own Svelte pages.\n- Long row change history and audit logs kept for you; the base shows a session's changes and your audit hook keeps the rest.\n- File import, AI fields and the AI assistant inside the tool.\n\nMigration guide: moving from Baserow to SvGrid: https://svgrid.com/docs/help/migrating-from-baserow/\n\n## Live example\n\nProject Tracker: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/504-project-tracker/\n\n## The bottom line\n\nBaserow is a good open-source table database for a team that will sign into it, and free to self-host. When the table database belongs inside your own product, SvGrid Base fits better: it renders editable views in your Svelte pages instead of a read-only iframe, keeps the data in the tables you already run, and includes Kanban, calendar, Gantt, comments and row colors in one licence priced per developer.\n\n## Frequently asked questions\n\n### Can I embed Baserow in my own app?\n\nBaserow can embed any publicly shared view with an iframe, and shared views are read-only except form views. Its application builder publishes apps on a domain rather than inside your pages. SvGrid Base is a Svelte component, so its views render and edit records in your own pages, behind your app's login.\n\n### Is SvGrid Base open source like Baserow?\n\nNo. SvGrid Base is commercial and ships in Enterprise Suite of @svgrid/enterprise; the grid it is built on, @svgrid/grid, is MIT. Baserow's core is MIT, and its premium and enterprise directories are under licences that need a subscription in production.\n\n### Can SvGrid Base work on the PostgreSQL database Baserow uses?\n\nNot on Baserow's internal tables, which belong to Baserow. Export each table to CSV, load it into your own PostgreSQL tables, and point createBaseEndpoint at them: it reads the columns and foreign keys and opens the tables with typed fields and links. If your data already lives in your own PostgreSQL and is synced into Baserow, point the base at the source tables directly.\n\n### Which Baserow paid features are included in SvGrid Base?\n\nKanban and calendar views, a timeline (SvGrid Base has a Gantt view with dependencies), row colors and record comments are paid on Baserow and part of SvGrid Base. Role, field and view permissions are Advanced on Baserow; in SvGrid Base they are rules you write in your server or your database.\n\n### How is SvGrid Base priced compared with Baserow?\n\nBaserow's paid plans are per user per month; the prices it listed on the day we read them are in the pricing line at the top of this page. SvGrid Base ships in Enterprise Suite at $999 per developer per year, for unlimited apps and unlimited end users, so the number of people using the app does not change the price.\n\n### What does SvGrid Base not do that Baserow does?\n\nThere are no workspaces, roles or sharing screen, no application builder, no file import dialog or AI fields in the base itself, no long row history kept for you, and every table's records are held in the page up to a limit you set. It also needs a developer to add it to an app.\n\n### Is SvGrid a good Baserow alternative for Svelte?\n\nPeople searching for a Baserow alternative usually want the Baserow experience somewhere Baserow does not go: inside their own product. Baserow can show a public view in an iframe, read-only except for forms, or publish an app made in its application builder on its own domain. SvGrid Base is a Svelte component instead: <SvBase> renders editable grid, Kanban, calendar, gallery, Gantt, chart and form views in your pages, behind your login, and writes every change to your Postgres, MySQL, SQLite, Supabase or REST API.\n\n## Related comparisons and docs\n\n- [SvGrid vs Airtable](https://svgrid.com/compare/airtable/)\n- [SvGrid vs NocoDB](https://svgrid.com/compare/nocodb/)\n- [Build a project tracker](https://svgrid.com/docs/help/base/start/)\n- [Ready-to-use bases](https://svgrid.com/docs/help/base/templates/)\n- [Keep a base in a database](https://svgrid.com/docs/help/base/data/)\n- [Automations](https://svgrid.com/docs/help/base/automations/)\n- [Field types](https://svgrid.com/docs/help/fields/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 14 Oct 2026. Feature and pricing statements come from the pages listed below.\n\n- br-pricing: Cloud Free $0 with 3,000 rows per workspace and grid, form and gallery views; Premium $10 per user per month billed yearly or $12 monthly with all views; Advanced $18 billed yearly or $22 monthly with role based permissions and audit logs; Enterprise on request; Kanban, survey, calendar and timeline views and row comments and coloring on Premium and up; the self-hosted tab lists the open source plan free with unlimited rows and storage (checked 14 Oct 2026) https://baserow.io/pricing\n- br-license: Content outside the premium and enterprise directories is under the MIT Expat license; premium code may only be used in production with a valid Baserow Premium Edition subscription (checked 14 Oct 2026) https://gitlab.com/baserow/baserow/-/raw/develop/LICENSE\n- br-sharing: Any publicly shared view can be embedded with an iframe; shared views are read-only except form views; paid subscribers can hide the Baserow logo from shared views (checked 14 Oct 2026) https://baserow.io/user-docs/public-sharing\n- br-datasync: External sources sync into Baserow tables; real-time sync is not available; PostgreSQL sync supports optional two-way sync (checked 14 Oct 2026) https://baserow.io/user-docs/data-sync-in-baserow\n- br-fields: Field types include link-to-table, lookup, count, rollup, formula, autonumber, UUID, created on, last modified, created by, last modified by, duration, file, collaborator, rating and AI prompt (checked 14 Oct 2026) https://baserow.io/user-docs/baserow-field-overview\n- br-automations: Automation triggers include periodic, rows created, updated or deleted, an incoming HTTP request and a manual trigger; actions include sending an email and an HTTP request; cloud automation credits are counted per plan and self-hosted is unlimited (checked 14 Oct 2026) https://baserow.io/user-docs/automation-triggers\n- br-comments: Row comments and mentions are available on paid plans (checked 14 Oct 2026) https://baserow.io/user-docs/row-commenting\n- br-history: Row change history is on every plan with retention that varies by plan (checked 14 Oct 2026) https://baserow.io/user-docs/row-change-history\n- br-permissions: Free and Premium plans offer two workspace-level roles without table-level restrictions; Advanced and Enterprise add roles with granular permissions (checked 14 Oct 2026) https://baserow.io/user-docs/permissions-overview\n- br-faq: Collaborate in real time with changes immediately visible; the self-hosted version has no row, storage or API request limits (checked 14 Oct 2026) https://baserow.io/faq\n- br-import: Tables can be created by pasting or importing CSV, JSON, XML and Excel files; a separate guide imports Airtable bases from a share link (checked 14 Oct 2026) https://baserow.io/user-docs/create-a-table-via-import\n- br-docker: Baserow runs from a Docker image with PostgreSQL and Redis inside by default; DATABASE_URL points it at an external PostgreSQL (checked 14 Oct 2026) https://baserow.io/docs/installation/install-with-docker\n"
|
|
6916
7231
|
},
|
|
6917
7232
|
{
|
|
6918
7233
|
"slug": "compare/devextreme-datagrid",
|
|
@@ -6963,6 +7278,13 @@ export const docs = [
|
|
|
6963
7278
|
"section": "Comparisons",
|
|
6964
7279
|
"markdown": "# SvGrid vs MUI X DataGrid\n\nCanonical URL: https://svgrid.com/compare/mui-x-datagrid/\nFacts verified: 2026-09-12\nCompetitor package: @mui/x-data-grid\nCompetitor site: https://mui.com/x/react-data-grid/\n\nMUI X DataGrid is the data grid of the Material UI ecosystem on React, split into a free Community tier and paid Pro and Premium tiers.\n\n## Verdict\n\nMUI X is the obvious pick if you are already on Material UI and React. SvGrid is Svelte-5-native, ships more in its MIT core (pinning, master/detail, tree data, grouping and cell selection are not Pro or Premium features), and carries no Material Design opinion.\n\nMigration guide: moving from MUI X DataGrid to SvGrid: https://svgrid.com/docs/help/migrating-from-mui-x/\n\n## At a glance\n\n| Fact | SvGrid | MUI X DataGrid | Checked |\n| --- | --- | --- | --- |\n| Latest version | 3.0.3 | 9.13.0 | 12 Sep 2026 |\n| Licence field on npm | MIT | MIT | 12 Sep 2026 |\n| Last published | 11 Sep 2026 | 4 Sep 2026 | 12 Sep 2026 |\n| Svelte 5 support | Native (Svelte 5 runes) | No Svelte package (React) | 12 Sep 2026 |\n| npm downloads, 30 days to 10 Sep 2026 | 16,900 | 11,800,000 | 12 Sep 2026 |\n| Bundle, minified + gzip | 97.6 KB JS + 10.5 KB CSS | 155.0 KB JS, no separate stylesheet, @emotion/react + @emotion/styled + @mui/material + @mui/system + react + react-dom external | 12 Sep 2026 |\n| Frameworks | Svelte 5 | React | 12 Sep 2026 |\n| Pricing | MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise | MUI X Community is free under MIT. mui.com lists Pro at $299 per year per developer, Premium at $599 per year per developer and Enterprise at $1,399 per year per developer, with perpetual and annual licence models | 12 Sep 2026 |\n\nBundle sizes: each package built alone with Vite in library mode, minified, gzip level 9, Svelte kept external where a package uses it (it is a peer dependency). SvGrid 3.0.4 measured 22 Sep 2026 with packages/grid/scripts/measure-size.mjs; @mui/x-data-grid 9.13.0 entry `DataGrid component` measured 12 Sep 2026 with packages/grid/scripts/measure-competitor-bundles.mjs.\n\n## Overview\n\nMUI X DataGrid is what most React teams on Material UI reach for. The Community edition is MIT and covers sorting, filtering, pagination, CSV export and row virtualization. Column pinning, column virtualization, row reordering, tree data and master/detail are Pro; row grouping, aggregation, cell selection, Excel export, pivoting and the AI assistant are Premium. Both paid tiers are sold per developer per year, and the sources below link to the pages that mark each feature.\n\nSvGrid is the inverse trade: one Svelte-5-native grid whose MIT core already includes pinning, master/detail, tree data, grouping with aggregation, cell-range selection with a fill handle, integrated charts and in-grid AI helpers you point at your own model, plus an optional @svgrid/enterprise pack for Excel, PDF and print output, import and pivot tables.\n\n## Looking for a MUI X DataGrid alternative for Svelte?\n\nMUI X DataGrid is React-only, so this is less an alternative than a port. The concepts carry over almost one to one: column definitions, a free tier and a paid tier, server-side rows, an imperative API. What does not carry over is Material Design: SvGrid is themed through CSS custom properties, so you bring your own look, and Emotion and Material UI stay out of the bundle.\n\n## What they have in common\n\n- A Community / paid split with an MIT free tier.\n- Sorting, filtering, pagination, virtualization and inline editing.\n- Server-side data through a row model the app feeds.\n- TypeScript-first with an imperative API for code outside the component.\n- An official MCP server so AI assistants read the real docs.\n- An in-grid AI assistant driven by natural language.\n\n## Where SvGrid is stronger\n\n- Svelte 5 native: runes and snippets, not a React component behind a wrapper.\n- Column pinning, master/detail, tree data, row grouping with aggregation and cell-range selection are in the MIT core; MUI X sells them as Pro or Premium.\n- The in-grid AI helpers (natural-language filter, smart fill, summarise, classify) are free in @svgrid/grid and run against a provider you register; MUI X's AI assistant is Premium and routes prompts through MUI's hosted service unless you build your own.\n- No Material Design opinion: theme through --sg-* tokens, with no Emotion or Material UI in the bundle. The At a glance table has the measured sizes.\n- Integrated charts in the MIT core, without a separate charts package.\n\n## Where MUI X DataGrid is stronger\n\n- First-class fit if the app is already built on Material UI and React: theming, date pickers and charts share one design system.\n- Pro and Premium features are vendor-supported with priority support and guaranteed response times on the Enterprise plan.\n- A very large React community and years of production use.\n- The Premium AI assistant comes with a hosted prompt service and starter credits, so there is nothing to run yourself.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | MUI X DataGrid |\n| --- | --- | --- |\n| Svelte 5 native | Yes - runes and snippets | No - React only |\n| Sorting, filtering, pagination | Yes | Yes - Community |\n| Row virtualization | Yes | Yes - Community |\n| Column virtualization | Yes | Paid tier - Pro |\n| Column pinning | Yes - MIT core, left and right | Paid tier - Pro |\n| Row reordering by drag | Yes - MIT core | Paid tier - Pro |\n| Master/detail | Yes - MIT core | Paid tier - Pro |\n| Tree data | Yes - MIT core | Paid tier - Pro |\n| Row grouping with aggregation | Yes - MIT core | Paid tier - Premium |\n| Cell-range selection and clipboard | Yes - MIT core, with a fill handle | Paid tier - Premium |\n| CSV export | Yes - MIT core | Yes - Community |\n| Excel export | Paid tier - @svgrid/enterprise | Paid tier - Premium |\n| Pivot tables | Paid tier - @svgrid/enterprise | Paid tier - Premium |\n| Integrated charts | Yes - SvGridChart in the MIT core | Partial - MUI X Charts is a separate package |\n| In-grid AI assistant | Yes - MIT core, bring your own model | Paid tier - Premium, through MUI's hosted service or your own proxy |\n| MCP server for AI assistants | Yes - @svgrid/mcp, MIT: docs, examples, code check, scaffold, preview | Yes - @mui/mcp, free: docs and examples |\n| Inline editing | Yes | Yes - Community |\n| Design-system independence | Yes - --sg-* tokens, no UI framework dependency | Partial - Material UI, Emotion and the MUI system are peer dependencies |\n| Core licence | Yes - MIT | Yes - MIT (Community) |\n\n## When to choose SvGrid\n\n- You are on Svelte 5 and want a grid shaped by runes, not a React component.\n- You want pinning, master/detail, tree data, grouping and cell selection without buying Pro or Premium.\n- You do not want Material Design, Emotion or the MUI system in your bundle.\n- You want the in-grid AI helpers to talk to a model you choose and host.\n\n## When to choose MUI X DataGrid\n\n- Your app is already built on Material UI and React.\n- You need Premium grouping, aggregation, pivoting or Excel export as vendor-supported features with a support contract.\n- You want one grid that matches React teammates across the MUI X suite, including date pickers and charts.\n- You want a hosted AI assistant with nothing to run yourself.\n\n## Migration effort\n\nEstimated effort: 1-3 hours per grid.\n\nWhat you give up:\n\n- Material Design out of the box; SvGrid ships neutral tokens and you theme it.\n- The rest of MUI X in the same design system: date pickers, charts and the tree view.\n- A hosted AI assistant with starter credits; SvGrid's helpers need a model provider you register.\n- React-specific patterns such as slots and hooks; SvGrid uses snippets and props.\n\nMigration guide: moving from MUI X DataGrid to SvGrid: https://svgrid.com/docs/help/migrating-from-mui-x/\n\n## Live example\n\nTree And Master Detail: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/08-tree-and-master-detail/\n\n## The bottom line\n\nIf your app already lives in Material UI and React, MUI X is the path of least resistance, and its Premium tier now includes a hosted AI assistant. On Svelte 5, SvGrid is the better fit: it puts pinning, master/detail, tree data, grouping and cell selection in the MIT core that MUI X reserves for Pro and Premium, keeps Material UI and Emotion out of the bundle, and runs its AI helpers against a model you choose.\n\n## Frequently asked questions\n\n### SvGrid vs MUI X DataGrid: which one for Svelte?\n\nSvGrid, if your stack is Svelte. MUI X is React-only and depends on Material UI, Emotion and the MUI system as peer dependencies; SvGrid is Svelte-5-native with no design-system dependency.\n\n### Which MUI X Pro and Premium features are free in SvGrid?\n\nColumn pinning, column virtualization, row reordering, master/detail and tree data (Pro in MUI X), and row grouping, aggregation and cell-range selection (Premium in MUI X) ship in the MIT core of SvGrid. Excel export and pivoting are paid on both sides: Premium in MUI X, @svgrid/enterprise in SvGrid.\n\n### Does MUI X have an AI assistant like SvGrid?\n\nYes, in Premium. mui.com describes a natural-language assistant (sort by name, show amounts larger than a value) that sends prompts through a proxy to MUI's hosted service with an API key, with starter credits included and the option to build your own processing service. SvGrid's natural-language filter, smart fill, summarise and classify helpers are free in @svgrid/grid and only run against a model provider you register, so nothing leaves your infrastructure unless you route it there.\n\n### Does MUI ship an MCP server?\n\nYes. @mui/mcp gives an AI client the official docs and code examples for Material UI and MUI X, including the Data Grid. @svgrid/mcp does the same for SvGrid and adds a code checker that validates props and imports against the real API surface, a scaffold tool and a preview.\n\n### Is SvGrid cheaper than MUI X Pro or Premium?\n\nThe prices mui.com listed on the date we read them are in the pricing line at the top of this page, per developer per year for Pro and Premium. SvGrid's core is MIT and @svgrid/enterprise is $599 per developer per year for the enterprise grid or $999 with the spreadsheet and Studio, both for unlimited applications. The difference is what the free tier holds: most features MUI X sells in Pro and Premium are in SvGrid's MIT core, so many teams never need the paid pack.\n\n### How long does a MUI X to SvGrid migration take?\n\nThe migration guide budgets one to three hours per grid. Column definitions and the row model map directly; the time goes into replacing React slots and renderCell functions with Svelte snippets and re-theming away from Material Design.\n\n### Is SvGrid a good MUI X DataGrid alternative for Svelte?\n\nMUI X DataGrid is React-only, so this is less an alternative than a port. The concepts carry over almost one to one: column definitions, a free tier and a paid tier, server-side rows, an imperative API. What does not carry over is Material Design: SvGrid is themed through CSS custom properties, so you bring your own look, and Emotion and Material UI stay out of the bundle.\n\n## Related comparisons and docs\n\n- [SvGrid vs AG Grid (community + enterprise)](https://svgrid.com/compare/ag-grid/)\n- [SvGrid vs React Data Grid (adazzle)](https://svgrid.com/compare/react-data-grid/)\n- [SvGrid vs Glide Data Grid](https://svgrid.com/compare/glide-data-grid/)\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](https://svgrid.com/docs/help/comparison/)\n- [AI assistant](https://svgrid.com/docs/help/ai/)\n- [Column pinning](https://svgrid.com/docs/help/columns/column-pinning/)\n- [Master / detail (nested grids)](https://svgrid.com/docs/help/rows/master-detail/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 12 Sep 2026. Versions, licences and download counts come from the npm registry; feature and pricing statements from the pages listed below.\n\n- mui-overview: Feature list with plan badges: CSV export, cell editing and row virtualization in Community; column pinning, column reordering, row reordering, tree data and column virtualization Pro; row grouping, aggregation, Excel export and pivoting Premium; React package with Material UI peer dependencies (checked 12 Sep 2026) https://mui.com/x/react-data-grid/\n- mui-master-detail: Master-detail row panels carry the Pro plan badge (checked 12 Sep 2026) https://mui.com/x/react-data-grid/master-detail/\n- mui-cell-selection: Cell selection carries the Premium plan badge: with the Data Grid Premium, you can apply the cellSelection prop (checked 12 Sep 2026) https://mui.com/x/react-data-grid/cell-selection/\n- mui-ai-assistant: AI Assistant is Premium: natural-language prompts such as sort by name or show amounts larger than 1000, sent through a server-side proxy to MUI's hosted service with an API key from MUI Console; Premium licence holders get starter credits; a self-built prompt service is optional (checked 12 Sep 2026) https://mui.com/x/react-data-grid/ai-assistant/\n- mui-mcp: Official MCP server (@mui/mcp) that quotes the official docs and code examples for MUI X components including the Data Grid; installed with npx, no payment (checked 12 Sep 2026) https://mui.com/x/introduction/mcp/\n- mui-pricing: Community free forever under MIT; Pro $299 per year per dev; Premium $599 per year per dev; Enterprise $1,399 per year per dev; perpetual and annual models both allow perpetual production use (checked 12 Sep 2026) https://mui.com/pricing/\n"
|
|
6965
7280
|
},
|
|
7281
|
+
{
|
|
7282
|
+
"slug": "compare/nocodb",
|
|
7283
|
+
"path": "docs/_data/comparisons/nocodb.json",
|
|
7284
|
+
"title": "SvGrid vs NocoDB",
|
|
7285
|
+
"section": "Comparisons",
|
|
7286
|
+
"markdown": "# SvGrid vs NocoDB\n\nCanonical URL: https://svgrid.com/compare/nocodb/\nFacts verified: 2026-10-14\nCompetitor site: https://nocodb.com/\n\nNocoDB is a table-database app with a spreadsheet interface, run on its cloud or self-hosted with Docker, that can sit on top of an existing PostgreSQL or MySQL database.\n\n## Verdict\n\nNocoDB is the pick when you want a finished app your team signs into. SvGrid Base is the pick when the table database has to live inside your own Svelte app, under your login, your branding and your licence terms, on the database you already run.\n\nMigration guide: moving from NocoDB to SvGrid: https://svgrid.com/docs/help/migrating-from-nocodb/\n\n## At a glance\n\n| Fact | SvGrid | NocoDB | Checked |\n| --- | --- | --- | --- |\n| What it is | A Svelte 5 component (<SvBase>) inside your own app, on your database | Hosted web app (cloud), Self-hosted web app (Docker) | 14 Oct 2026 |\n| Pricing | SvGrid Base ships in Enterprise Suite of @svgrid/enterprise, $999 per developer per year, unlimited apps and end users; the grid it runs on is MIT | NocoDB self-hosted Community is $0 under the Sustainable Use License. nocodb.com/pricing lists cloud Plus at $12 per seat per month billed annually ($15 monthly), Business at $24 per seat per month billed annually ($30 monthly), Scale at $45 per seat per month billed annually with 3 minimum seats, and Enterprise on request; self-hosted Business and Scale are priced the same per seat | 14 Oct 2026 |\n\n## Overview\n\nNocoDB turns database tables into a collaborative app: grid, form, gallery, Kanban, calendar and map views, linked records with lookups and rollups, record comments, webhooks and workflows. It runs on NocoDB Cloud, priced per seat, or self-hosted with Docker, where the Community edition has no record or storage limits. It can store data in its own database or connect to an existing PostgreSQL or MySQL database. Since January 2026 the source is under the Sustainable Use License rather than AGPL.\n\nSvGrid Base is the same kind of tool shipped as a Svelte 5 component in @svgrid/enterprise. You install it, give it your tables (or point it at your database), and your users get typed fields, saved views, filters, groups, linked records, automations and an expanded record with comments, all inside your app. There is no separate server to run and no second login: the base talks to your database through the endpoint you already have.\n\n## Looking for a NocoDB alternative for Svelte?\n\nPeople searching for a NocoDB alternative usually hit one of three walls: the app has to appear inside their own product rather than in a NocoDB tab or iframe, the January 2026 licence change rules out offering it to customers without a commercial agreement, or the views they need (timeline, Gantt) sit on the Business plan. SvGrid Base answers all three by being a component: <SvBase> renders the grid, Kanban, calendar, gallery, Gantt, chart and form views inside your Svelte app and reads and writes your Postgres, MySQL, SQLite, Supabase or REST API through your own server.\n\n## What they have in common\n\n- A grid of typed fields with saved views: grid, form, gallery, Kanban and calendar.\n- Linked records between tables, with lookup and rollup fields and formulas.\n- Can work on top of an existing PostgreSQL or MySQL database instead of a private store.\n- Automations that react to record changes, send email and call webhooks.\n- Record comments in an expanded record view.\n- Filters, sorts, groups and hidden fields saved per view.\n\n## Where SvGrid is stronger\n\n- It is a component, not an app: <SvBase> renders inside your own Svelte pages, under your login and your design tokens, with no iframe and no second product for users to sign into.\n- Built to ship to customers: an Enterprise Suite licence covers unlimited apps and end users, so putting the base inside a product your customers use needs no separate agreement.\n- Gantt, chart and autonumber are part of the base itself, with no plan above them.\n- Automations run in the browser or on your server with no monthly run quota, and their secrets stay on your server.\n- Reads and writes Postgres, MySQL, SQLite, Supabase or any REST API through the data source your app already uses, with one parameterized endpoint and an authorize hook you write.\n- Priced per developer, not per editor: the people who use the app do not need seats.\n\n## Where NocoDB is stronger\n\n- A finished product: sign up or run one Docker command and a team is working, with no developer involved.\n- Roles out of the box (owner, creator, editor, commenter, viewer) with a sharing screen, and table and field permissions on paid plans.\n- Realtime presence shows who else is in a table, and record revision history is kept for days to a year on cloud and without limit self-hosted.\n- A REST API, an SDK, an MCP server and a workflow builder with integration nodes (Slack, Gmail, GitHub and more).\n- A map view and file import (CSV, Excel, JSON, and Airtable bases) in the free edition.\n- No record limit per table when self-hosted; SvGrid Base keeps each table's records in the page, up to a configurable limit.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | NocoDB |\n| --- | --- | --- |\n| Runs inside your own app's pages | Yes - a Svelte 5 component, <SvBase> | No - shared views and bases embed as an iframe; no UI component or front-end SDK found |\n| Offered to your customers inside your product | Yes - unlimited apps and end users on the Suite licence | Paid tier - commercial licence needed to embed NocoDB where customers use it |\n| Source licence | Paid tier - commercial (Enterprise Suite); the grid underneath is MIT | Partial - Sustainable Use License since January 2026, not OSI-approved |\n| Works on your existing database | Yes - Postgres, MySQL, SQLite, Supabase or a REST API | Yes - external PostgreSQL or MySQL; SQL Server is an Enterprise add-on |\n| Grid, form, gallery, Kanban and calendar views | Yes | Yes - every plan |\n| Gantt or timeline view | Yes - Gantt view with dependencies and progress | Paid tier - Business plan and above |\n| Chart view | Yes - bar, line, pie and donut, per view | Paid tier - dashboards on Business and above |\n| Linked records, lookup, rollup and formula fields | Yes | Yes |\n| Autonumber field | Yes | Paid tier - Business plan and above, PostgreSQL only |\n| Automations | Yes - in the browser or on your server, no run quota | Partial - 2 workflows per base in Community; cloud runs metered by plan |\n| Email and webhook actions | Yes - email sent by your server; secrets stay server-side | Yes |\n| Record comments | Yes - stored wherever your app stores them | Yes - every plan; mentions on paid plans |\n| Record change history | Partial - this session's changes in the record; a server audit hook logs every write | Yes - kept by plan on cloud, unlimited self-hosted |\n| Who can see and edit what | Partial - your server's authorize hook or the database's row level security; no roles screen | Yes - five roles free; table and field permissions paid |\n| Changes from other users appear live | Partial - through a realtime source (Supabase Realtime, a WebSocket, a poll); no presence | Yes - realtime presence on every plan |\n| Import CSV or Excel files | Partial - paste a range, or load the file into your database and open it | Yes - CSV, Excel, JSON and Airtable |\n| Your branding only | Yes - your app's look through --sg-* tokens | Paid tier - white-label is a self-hosted Enterprise add-on |\n| Records per table | Partial - the page holds a table's records, up to a limit you set (10,000 by default) | Yes - unlimited self-hosted; capped by plan on cloud |\n| Usable with no developer | No - a developer adds it to an app | Yes - cloud sign-up or a Docker install |\n\n## When to choose SvGrid\n\n- The table database has to appear inside your own Svelte or SvelteKit app, under your login and your look.\n- You want to offer it to your customers without a separate commercial agreement for the table tool.\n- You need Gantt, charts and autonumber without moving everyone to a higher plan.\n- Your data already lives in Postgres, MySQL, SQLite, Supabase or behind your API and should stay there, reached through your own server.\n- You would rather pay per developer than per editor seat.\n\n## When to choose NocoDB\n\n- You want a finished app a team can use today, with no developer.\n- You need roles, sharing links and per-table or per-field permissions managed from a screen.\n- You want long record revision history and presence without building anything.\n- You want workflows with ready-made Slack, Gmail or GitHub nodes.\n\n## Migration effort\n\nEstimated effort: 1-2 hours per base on PostgreSQL or MySQL; more to rebuild workflows.\n\nWhat you give up:\n\n- The roles screen and sharing links; who may do what moves into your server's authorize hook or your database's row level security.\n- Long record revision history; the base shows a session's changes and your audit hook keeps the rest.\n- Workflow integration nodes; SvGrid Base automations call webhooks, send email and run actions you register in code.\n- A no-code setup path: someone has to add <SvBase> to a Svelte app.\n\nMigration guide: moving from NocoDB to SvGrid: https://svgrid.com/docs/help/migrating-from-nocodb/\n\n## Live example\n\nProject Tracker: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/504-project-tracker/\n\n## The bottom line\n\nNocoDB is a strong self-hosted table database for a team that wants a finished app and will sign into it. When the table database is part of your own product, SvGrid Base fits better: it renders inside your Svelte app with your login and branding, works on the database you already run, ships Gantt, charts and server-side automations without plan gates, and its licence already covers putting it in front of your customers.\n\n## Frequently asked questions\n\n### Can I embed NocoDB inside my own app?\n\nOnly as an iframe: NocoDB lets you embed a shared base or a read-only shared view with an HTML iframe, and its licence page says embedding NocoDB in a platform where customers interact with it needs a commercial licence. SvGrid Base is a Svelte component, so it renders in your own pages, edits records in place and uses your app's login.\n\n### Is NocoDB still open source?\n\nIts source is public, but since January 2026 it is under the Sustainable Use License, which NocoDB's own docs say is not OSI-approved; before that it was AGPL-3.0. SvGrid Base is commercial (Enterprise Suite of @svgrid/enterprise); the grid it is built on, @svgrid/grid, is MIT.\n\n### Can SvGrid Base use the same database NocoDB is connected to?\n\nYes, if NocoDB was connected to your own PostgreSQL or MySQL database. createBaseEndpoint reads the tables, columns and foreign keys from that database and <SvBase> opens them as tabs with typed fields and links. Tables NocoDB created in its own metadata database are better exported to CSV and loaded into your database first; the migration guide walks through both.\n\n### Does SvGrid Base have a Gantt view like NocoDB's paid plans?\n\nYes. A view of type gantt takes a start and end date field, an optional progress field and a link field for dependencies. NocoDB lists its Gantt and timeline views on the Business plan and above.\n\n### How is SvGrid Base priced compared with NocoDB?\n\nNocoDB charges per seat on cloud and on its paid self-hosted plans; the prices it listed on the day we read them are in the pricing line at the top of this page. SvGrid Base ships in Enterprise Suite at $999 per developer per year, for unlimited apps and unlimited end users, so the number of people using the app does not change the price.\n\n### What does SvGrid Base not do that NocoDB does?\n\nThere is no roles and sharing screen (access rules live in your server or database), no presence indicator, no long revision history kept for you, no map view and no file import dialog in the base itself, and every table's records are held in the page up to a limit you set. It also needs a developer to add it to an app.\n\n### Is SvGrid a good NocoDB alternative for Svelte?\n\nPeople searching for a NocoDB alternative usually hit one of three walls: the app has to appear inside their own product rather than in a NocoDB tab or iframe, the January 2026 licence change rules out offering it to customers without a commercial agreement, or the views they need (timeline, Gantt) sit on the Business plan. SvGrid Base answers all three by being a component: <SvBase> renders the grid, Kanban, calendar, gallery, Gantt, chart and form views inside your Svelte app and reads and writes your Postgres, MySQL, SQLite, Supabase or REST API through your own server.\n\n## Related comparisons and docs\n\n- [SvGrid vs Airtable](https://svgrid.com/compare/airtable/)\n- [SvGrid vs Baserow](https://svgrid.com/compare/baserow/)\n- [Build a project tracker](https://svgrid.com/docs/help/base/start/)\n- [From a database to an app](https://svgrid.com/docs/help/base/quick-start/)\n- [Keep a base in a database](https://svgrid.com/docs/help/base/data/)\n- [Automations](https://svgrid.com/docs/help/base/automations/)\n- [Field types](https://svgrid.com/docs/help/fields/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 14 Oct 2026. Feature and pricing statements come from the pages listed below.\n\n- noco-pricing: Plan cards: Free $0 for up to 3 users with 1,000 records; Plus $12 per seat per month billed annually or $15 monthly; Business $24 annually or $30 monthly; Scale $45 annually or $54 monthly with 3 minimum seats; Enterprise schedule a call; self-hosted Community $0 with unlimited records and storage; dashboards on Business; record revision history from 3 days on Free to 365 days on Enterprise, unlimited self-hosted (checked 14 Oct 2026) https://nocodb.com/pricing\n- noco-license: From January 09, 2026 NocoDB changes from AGPL-3.0 to a Sustainable Use License, not OSI-approved; a commercial licence is needed to embed NocoDB in a platform where customers interact with NocoDB itself (checked 14 Oct 2026) https://nocodb.com/docs/self-hosting/license\n- noco-share: A shared project can be embedded in another application with an HTML iframe; shared views are read-only (checked 14 Oct 2026) https://nocodb.com/docs/product/projects/share-project\n- noco-datasource: Connect an external PostgreSQL or MySQL database as a data source; Microsoft SQL Server is an add-on for Enterprise customers (checked 14 Oct 2026) https://nocodb.com/docs/product/integrations/data-sources/connect-to-data-source\n- noco-views: View types: grid, form, gallery, Kanban, calendar, map, with list, timeline and Gantt marked for paid plans (checked 14 Oct 2026) https://nocodb.com/docs/product/tables/views\n- noco-timeline: Timeline view is available on NocoDB Cloud Business plan and above and licensed self-hosted deployments Business plan and above; the Gantt view page says the same (checked 14 Oct 2026) https://nocodb.com/docs/product/tables/views/view-types/timeline\n- noco-editions: Community edition includes link, lookup, rollup, formula, attachment, rating, user, barcode, QR code and button fields, Airtable import and CSV and Excel export (checked 14 Oct 2026) https://nocodb.com/docs/product/account-settings/cloud-enterprise-edition/community-vs-paid-editions\n- noco-autonumber: Auto Number is available on NocoDB Cloud plans and licensed self-hosted deployments Business plan and above, PostgreSQL only (checked 14 Oct 2026) https://nocodb.com/docs/product/tables/fields/field-types/identifier-types/auto-number\n- noco-workflows: The Community Edition includes 2 workflows per project with record and manual triggers; workflow actions include send email, HTTP request and integration nodes; cloud automation runs are metered per plan (checked 14 Oct 2026) https://nocodb.com/docs/workflows\n- noco-comments: Record comments on every plan; mentions and the comment-resolve workflow need cloud Plus or self-hosted Business and above (checked 14 Oct 2026) https://nocodb.com/docs/product/tables/records/expand-record\n- noco-roles: Owner, creator, editor, commenter and viewer roles on every plan; table and field permissions on cloud Plus or self-hosted Business and above; record level security on Scale (checked 14 Oct 2026) https://nocodb.com/docs/product/collaboration/roles-and-permissions\n- noco-presence: Realtime presence shows who else works in a table; it does not lock anything (checked 14 Oct 2026) https://nocodb.com/docs/product/tables/table-operations/realtime-presence\n- noco-import: Create a table by importing a CSV, Excel or JSON file, maximum file size 5 MB (checked 14 Oct 2026) https://nocodb.com/docs/product/tables/create-table-via-import\n- noco-whitelabel: White-label is available on self-hosted instances as an add-on to the Enterprise plan and not on NocoDB Cloud (checked 14 Oct 2026) https://nocodb.com/docs/self-hosting/white-label\n- noco-install: One command makes a Docker Compose stack with NocoDB, a worker, Postgres and Redis (checked 14 Oct 2026) https://nocodb.com/docs/self-hosting/installation/quickstart\n"
|
|
7287
|
+
},
|
|
6966
7288
|
{
|
|
6967
7289
|
"slug": "compare/primevue-datatable",
|
|
6968
7290
|
"path": "docs/_data/comparisons/primevue-datatable.json",
|
|
@@ -7003,7 +7325,7 @@ export const docs = [
|
|
|
7003
7325
|
"path": "docs/_data/comparisons/svelte-ui-kit-tables.json",
|
|
7004
7326
|
"title": "SvGrid vs Flowbite / Skeleton / shadcn-svelte tables",
|
|
7005
7327
|
"section": "Comparisons",
|
|
7006
|
-
"markdown": "# SvGrid vs Flowbite / Skeleton / shadcn-svelte tables\n\nCanonical URL: https://svgrid.com/compare/svelte-ui-kit-tables/\nFacts verified: 2026-09-12\nCompetitor site: https://flowbite-svelte.com/docs/components/table\n\nFlowbite-Svelte, Skeleton, and shadcn-svelte ship styled table components - great for static tables, not data grids.\n\n## Verdict\n\nUI-kit tables are perfect for small, mostly-static tables that match your design system. Reach for SvGrid the moment you need sorting, filtering, virtualization, editing, or large datasets.\n\nMigration guide: moving from Flowbite / Skeleton / shadcn-svelte tables to SvGrid: https://svgrid.com/docs/help/migrating-from-ui-kit-tables/\n\n## At a glance\n\n| Fact | SvGrid | Flowbite / Skeleton / shadcn-svelte tables | Checked |\n| --- | --- | --- | --- |\n| Frameworks | Svelte 5 | Svelte | 12 Sep 2026 |\n| Pricing | MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise | Flowbite Svelte, Skeleton and shadcn-svelte are MIT component libraries; their table components are free and no grid licence is sold | 12 Sep 2026 |\n\n## Overview\n\nFlowbite-Svelte, Skeleton, and shadcn-svelte all provide beautifully styled table components. They render the rows you pass in and match your design tokens, but they are presentation components - sorting, filtering, virtualization, and editing are yours to build.\n\nSvGrid is a data grid: the table behaviors (sort, Excel-style filter, group, virtualize, edit, select) are the product. Style it with your own tokens or Tailwind so it still fits the design system.\n\n## Looking for a Flowbite / Skeleton / shadcn-svelte tables alternative for Svelte?\n\nA styled table component and a data grid solve different problems. These kits give you markup that already matches your design system; they do not virtualize, edit in place, group rows or talk to a server. SvGrid does those and is themed through CSS custom properties, so it can be made to match the same design system rather than replacing it.\n\n## What they have in common\n\n- Render tabular data in Svelte.\n- MIT-licensed.\n- Themeable to match a design system.\n\n## Where SvGrid is stronger\n\n- Sorting, filtering, grouping, and pagination are built in, not hand-rolled.\n- Row + column virtualization for thousands of rows.\n- Inline editing, cell selection, and clipboard.\n- Headless engine + imperative API + @svgrid/mcp.\n- Still fully themeable via --sg-* tokens / Tailwind.\n\n## Where Flowbite / Skeleton / shadcn-svelte tables is stronger\n\n- Zero learning curve for a small, static table.\n- Pixel-perfect match with the rest of the UI kit out of the box.\n- Tiny footprint when you truly only need a styled table.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | Flowbite / Skeleton / shadcn-svelte tables |\n| --- | --- | --- |\n| Built-in sorting | Yes | No - BYO |\n| Built-in filtering | Yes | No - BYO |\n| Virtualization (1000s of rows) | Yes | No |\n| Inline editing | Yes | No - BYO |\n| Grouping / tree / master-detail | Yes | No |\n| Cell selection + copy | Yes | No |\n| Design-system theming | Yes - --sg-* / Tailwind | Yes - Native to the kit |\n| Footprint for a static table | Partial - Larger | Yes - Tiny |\n\n## When to choose SvGrid\n\n- Your table needs sorting, filtering, or large-dataset virtualization.\n- You need inline editing, selection, grouping, or tree data.\n- The table is becoming the centre of the screen, not a layout detail.\n\n## When to choose Flowbite / Skeleton / shadcn-svelte tables\n\n- You render a small, static table that never sorts or filters.\n- You want a pixel-perfect match with your UI kit and nothing more.\n- Bundle size for a trivial table is the priority.\n\n## Migration effort\n\nEstimated effort: 30 minutes to 1 hour per table.\n\nWhat you give up:\n\n- A table that is exactly the kit's markup and nothing else.\n- The smallest possible footprint for a static table.\n\nMigration guide: moving from Flowbite / Skeleton / shadcn-svelte tables to SvGrid: https://svgrid.com/docs/help/migrating-from-ui-kit-tables/\n\n## Live example\n\nExcel Filters: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/03-excel-filters/\n\n## The bottom line\n\nA styled table from Flowbite, Skeleton, or shadcn-svelte is perfect until the behaviour becomes the work. Once you are hand-writing sort, filter, or virtualization, SvGrid replaces that code and still themes to match your design system.\n\n## Frequently asked questions\n\n### When should I replace a UI-kit table with SvGrid?\n\nWhen the table needs sorting, filtering, virtualization, editing, or large datasets. UI-kit tables are presentation components; SvGrid is a data grid.\n\n### Can SvGrid match my Flowbite or shadcn styling?\n\nYes - it themes through --sg-* CSS variables you can wire to your Tailwind or design-system tokens.\n\n### Is SvGrid overkill for a static table?\n\nFor a small static table, a styled table element is lighter and fine. Switch when behaviour, not just styling, becomes the work.\n\n### Is SvGrid a good Flowbite / Skeleton / shadcn-svelte tables alternative for Svelte?\n\nA styled table component and a data grid solve different problems. These kits give you markup that already matches your design system; they do not virtualize, edit in place, group rows or talk to a server. SvGrid does those and is themed through CSS custom properties, so it can be made to match the same design system rather than replacing it.\n\n## Related comparisons and docs\n\n- [SvGrid vs @vincjo/datatables](https://svgrid.com/compare/vincjo-datatables/)\n- [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/)\n- [SvGrid vs svelte-headless-table](https://svgrid.com/compare/svelte-headless-table/)\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](https://svgrid.com/docs/help/comparison/)\n- [shadcn-svelte integration](https://svgrid.com/docs/help/shadcn/)\n- [Tailwind integration](https://svgrid.com/docs/help/tailwind/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 12 Sep 2026.
|
|
7328
|
+
"markdown": "# SvGrid vs Flowbite / Skeleton / shadcn-svelte tables\n\nCanonical URL: https://svgrid.com/compare/svelte-ui-kit-tables/\nFacts verified: 2026-09-12\nCompetitor site: https://flowbite-svelte.com/docs/components/table\n\nFlowbite-Svelte, Skeleton, and shadcn-svelte ship styled table components - great for static tables, not data grids.\n\n## Verdict\n\nUI-kit tables are perfect for small, mostly-static tables that match your design system. Reach for SvGrid the moment you need sorting, filtering, virtualization, editing, or large datasets.\n\nMigration guide: moving from Flowbite / Skeleton / shadcn-svelte tables to SvGrid: https://svgrid.com/docs/help/migrating-from-ui-kit-tables/\n\n## At a glance\n\n| Fact | SvGrid | Flowbite / Skeleton / shadcn-svelte tables | Checked |\n| --- | --- | --- | --- |\n| Frameworks | Svelte 5 | Svelte | 12 Sep 2026 |\n| Pricing | MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise | Flowbite Svelte, Skeleton and shadcn-svelte are MIT component libraries; their table components are free and no grid licence is sold | 12 Sep 2026 |\n\n## Overview\n\nFlowbite-Svelte, Skeleton, and shadcn-svelte all provide beautifully styled table components. They render the rows you pass in and match your design tokens, but they are presentation components - sorting, filtering, virtualization, and editing are yours to build.\n\nSvGrid is a data grid: the table behaviors (sort, Excel-style filter, group, virtualize, edit, select) are the product. Style it with your own tokens or Tailwind so it still fits the design system.\n\n## Looking for a Flowbite / Skeleton / shadcn-svelte tables alternative for Svelte?\n\nA styled table component and a data grid solve different problems. These kits give you markup that already matches your design system; they do not virtualize, edit in place, group rows or talk to a server. SvGrid does those and is themed through CSS custom properties, so it can be made to match the same design system rather than replacing it.\n\n## What they have in common\n\n- Render tabular data in Svelte.\n- MIT-licensed.\n- Themeable to match a design system.\n\n## Where SvGrid is stronger\n\n- Sorting, filtering, grouping, and pagination are built in, not hand-rolled.\n- Row + column virtualization for thousands of rows.\n- Inline editing, cell selection, and clipboard.\n- Headless engine + imperative API + @svgrid/mcp.\n- Still fully themeable via --sg-* tokens / Tailwind.\n\n## Where Flowbite / Skeleton / shadcn-svelte tables is stronger\n\n- Zero learning curve for a small, static table.\n- Pixel-perfect match with the rest of the UI kit out of the box.\n- Tiny footprint when you truly only need a styled table.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | Flowbite / Skeleton / shadcn-svelte tables |\n| --- | --- | --- |\n| Built-in sorting | Yes | No - BYO |\n| Built-in filtering | Yes | No - BYO |\n| Virtualization (1000s of rows) | Yes | No |\n| Inline editing | Yes | No - BYO |\n| Grouping / tree / master-detail | Yes | No |\n| Cell selection + copy | Yes | No |\n| Design-system theming | Yes - --sg-* / Tailwind | Yes - Native to the kit |\n| Footprint for a static table | Partial - Larger | Yes - Tiny |\n\n## When to choose SvGrid\n\n- Your table needs sorting, filtering, or large-dataset virtualization.\n- You need inline editing, selection, grouping, or tree data.\n- The table is becoming the centre of the screen, not a layout detail.\n\n## When to choose Flowbite / Skeleton / shadcn-svelte tables\n\n- You render a small, static table that never sorts or filters.\n- You want a pixel-perfect match with your UI kit and nothing more.\n- Bundle size for a trivial table is the priority.\n\n## Migration effort\n\nEstimated effort: 30 minutes to 1 hour per table.\n\nWhat you give up:\n\n- A table that is exactly the kit's markup and nothing else.\n- The smallest possible footprint for a static table.\n\nMigration guide: moving from Flowbite / Skeleton / shadcn-svelte tables to SvGrid: https://svgrid.com/docs/help/migrating-from-ui-kit-tables/\n\n## Live example\n\nExcel Filters: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/03-excel-filters/\n\n## The bottom line\n\nA styled table from Flowbite, Skeleton, or shadcn-svelte is perfect until the behaviour becomes the work. Once you are hand-writing sort, filter, or virtualization, SvGrid replaces that code and still themes to match your design system.\n\n## Frequently asked questions\n\n### When should I replace a UI-kit table with SvGrid?\n\nWhen the table needs sorting, filtering, virtualization, editing, or large datasets. UI-kit tables are presentation components; SvGrid is a data grid.\n\n### Can SvGrid match my Flowbite or shadcn styling?\n\nYes - it themes through --sg-* CSS variables you can wire to your Tailwind or design-system tokens.\n\n### Is SvGrid overkill for a static table?\n\nFor a small static table, a styled table element is lighter and fine. Switch when behaviour, not just styling, becomes the work.\n\n### Is SvGrid a good Flowbite / Skeleton / shadcn-svelte tables alternative for Svelte?\n\nA styled table component and a data grid solve different problems. These kits give you markup that already matches your design system; they do not virtualize, edit in place, group rows or talk to a server. SvGrid does those and is themed through CSS custom properties, so it can be made to match the same design system rather than replacing it.\n\n## Related comparisons and docs\n\n- [SvGrid vs @vincjo/datatables](https://svgrid.com/compare/vincjo-datatables/)\n- [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/)\n- [SvGrid vs svelte-headless-table](https://svgrid.com/compare/svelte-headless-table/)\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](https://svgrid.com/docs/help/comparison/)\n- [shadcn-svelte integration](https://svgrid.com/docs/help/shadcn/)\n- [Tailwind integration](https://svgrid.com/docs/help/tailwind/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 12 Sep 2026. Feature and pricing statements come from the pages listed below.\n\n- flowbite-readme: The official Flowbite UI component library for Svelte, MIT; a Table is one of its styled Tailwind components (checked 12 Sep 2026) https://github.com/themesberg/flowbite-svelte\n"
|
|
7007
7329
|
},
|
|
7008
7330
|
{
|
|
7009
7331
|
"slug": "compare/syncfusion-grid",
|
|
@@ -7108,8 +7430,8 @@ export const apiReference = {
|
|
|
7108
7430
|
]
|
|
7109
7431
|
};
|
|
7110
7432
|
export const apiSurface = {
|
|
7111
|
-
"gridVersion": "3.0.
|
|
7112
|
-
"enterpriseVersion": "3.1.
|
|
7433
|
+
"gridVersion": "3.0.12",
|
|
7434
|
+
"enterpriseVersion": "3.1.9",
|
|
7113
7435
|
"grid": {
|
|
7114
7436
|
"values": [
|
|
7115
7437
|
"ALL_FILTER_OPERATORS",
|
|
@@ -7295,6 +7617,7 @@ export const apiSurface = {
|
|
|
7295
7617
|
"clearToasts",
|
|
7296
7618
|
"closeWindow",
|
|
7297
7619
|
"coerceExportDate",
|
|
7620
|
+
"columnDataType",
|
|
7298
7621
|
"columnFilteringFeature",
|
|
7299
7622
|
"columnGroupingFeature",
|
|
7300
7623
|
"columnResize",
|
|
@@ -7442,9 +7765,11 @@ export const apiSurface = {
|
|
|
7442
7765
|
"getEntryStep",
|
|
7443
7766
|
"getExportProvider",
|
|
7444
7767
|
"getFocusable",
|
|
7768
|
+
"getGalleryView",
|
|
7445
7769
|
"getGanttView",
|
|
7446
7770
|
"getGridCellA11yProps",
|
|
7447
7771
|
"getGridCellDomId",
|
|
7772
|
+
"getGridExtension",
|
|
7448
7773
|
"getGridHeaderA11yProps",
|
|
7449
7774
|
"getGridRootA11yProps",
|
|
7450
7775
|
"getGridRowA11yProps",
|
|
@@ -7461,6 +7786,7 @@ export const apiSurface = {
|
|
|
7461
7786
|
"hasCellEditor",
|
|
7462
7787
|
"hasChartView",
|
|
7463
7788
|
"hasConflict",
|
|
7789
|
+
"hasGalleryView",
|
|
7464
7790
|
"hasGanttView",
|
|
7465
7791
|
"hasGridShortcuts",
|
|
7466
7792
|
"hasGroups",
|
|
@@ -7579,7 +7905,9 @@ export const apiSurface = {
|
|
|
7579
7905
|
"registerCellEditor",
|
|
7580
7906
|
"registerChartView",
|
|
7581
7907
|
"registerExportProvider",
|
|
7908
|
+
"registerGalleryView",
|
|
7582
7909
|
"registerGanttView",
|
|
7910
|
+
"registerGridExtension",
|
|
7583
7911
|
"registerGridShortcuts",
|
|
7584
7912
|
"registerPivotEngine",
|
|
7585
7913
|
"registerSchedulerView",
|
|
@@ -7894,6 +8222,7 @@ export const apiSurface = {
|
|
|
7894
8222
|
"ColorScaleFormat",
|
|
7895
8223
|
"ColorScaleStop",
|
|
7896
8224
|
"Column",
|
|
8225
|
+
"ColumnAdderConfig",
|
|
7897
8226
|
"ColumnDef",
|
|
7898
8227
|
"ColumnDefTemplate",
|
|
7899
8228
|
"ColumnGroupMeta",
|
|
@@ -7977,6 +8306,7 @@ export const apiSurface = {
|
|
|
7977
8306
|
"FormFieldType",
|
|
7978
8307
|
"FormMessages",
|
|
7979
8308
|
"FormSection",
|
|
8309
|
+
"GalleryConfig",
|
|
7980
8310
|
"GanttConfig",
|
|
7981
8311
|
"GanttDependency",
|
|
7982
8312
|
"GanttDependencyType",
|
|
@@ -8006,6 +8336,8 @@ export const apiSurface = {
|
|
|
8006
8336
|
"GridExportProvider",
|
|
8007
8337
|
"GridExportRunOptions",
|
|
8008
8338
|
"GridExportScope",
|
|
8339
|
+
"GridExtension",
|
|
8340
|
+
"GridExtensionMenuItem",
|
|
8009
8341
|
"GridFilterState",
|
|
8010
8342
|
"GridIconName",
|
|
8011
8343
|
"GridIcons",
|
|
@@ -8110,6 +8442,9 @@ export const apiSurface = {
|
|
|
8110
8442
|
"RestrictOptions",
|
|
8111
8443
|
"RichTextTool",
|
|
8112
8444
|
"Row",
|
|
8445
|
+
"RowAdderConfig",
|
|
8446
|
+
"RowColorConfig",
|
|
8447
|
+
"RowColorTarget",
|
|
8113
8448
|
"RowData",
|
|
8114
8449
|
"RowDragEndEvent",
|
|
8115
8450
|
"RowDropZoneOptions",
|
|
@@ -8327,6 +8662,7 @@ export const apiSurface = {
|
|
|
8327
8662
|
"SvGridBoard",
|
|
8328
8663
|
"SvGridBulkEditDrawer",
|
|
8329
8664
|
"SvGridEditPanel",
|
|
8665
|
+
"SvGridGallery",
|
|
8330
8666
|
"SvGridGantt",
|
|
8331
8667
|
"SvGridMasterDetail",
|
|
8332
8668
|
"SvGridScheduler",
|
|
@@ -8538,6 +8874,7 @@ export const apiSurface = {
|
|
|
8538
8874
|
"enableAdvancedFilter",
|
|
8539
8875
|
"enableAlerts",
|
|
8540
8876
|
"enableBoardView",
|
|
8877
|
+
"enableGalleryView",
|
|
8541
8878
|
"enableGanttView",
|
|
8542
8879
|
"enablePivot",
|
|
8543
8880
|
"enableSchedulerView",
|
|
@@ -8600,6 +8937,7 @@ export const apiSurface = {
|
|
|
8600
8937
|
"getLicenseExpiry",
|
|
8601
8938
|
"getLicenseKey",
|
|
8602
8939
|
"getLiveDataSample",
|
|
8940
|
+
"getPredicateKind",
|
|
8603
8941
|
"getSampleApp",
|
|
8604
8942
|
"getStarterDataset",
|
|
8605
8943
|
"getStructureTarget",
|
|
@@ -8772,6 +9110,7 @@ export const apiSurface = {
|
|
|
8772
9110
|
"reduceValue",
|
|
8773
9111
|
"referenceSpans",
|
|
8774
9112
|
"registerPdfFonts",
|
|
9113
|
+
"registerPredicateKind",
|
|
8775
9114
|
"remapNotes",
|
|
8776
9115
|
"removeBlock",
|
|
8777
9116
|
"removeCf",
|
|
@@ -8833,6 +9172,7 @@ export const apiSurface = {
|
|
|
8833
9172
|
"serverGroupText",
|
|
8834
9173
|
"serverRowModelNav",
|
|
8835
9174
|
"serverRowModelRows",
|
|
9175
|
+
"setAlertConditionEditor",
|
|
8836
9176
|
"setDataSource",
|
|
8837
9177
|
"setEntityDataSource",
|
|
8838
9178
|
"setEntityForm",
|
|
@@ -8969,6 +9309,7 @@ export const apiSurface = {
|
|
|
8969
9309
|
"AlertActionKind",
|
|
8970
9310
|
"AlertActionStyle",
|
|
8971
9311
|
"AlertAttachment",
|
|
9312
|
+
"AlertConditionEditor",
|
|
8972
9313
|
"AlertEngine",
|
|
8973
9314
|
"AlertEngineOptions",
|
|
8974
9315
|
"AlertEvent",
|
|
@@ -9218,6 +9559,8 @@ export const apiSurface = {
|
|
|
9218
9559
|
"PlanOptions",
|
|
9219
9560
|
"PlanPredicate",
|
|
9220
9561
|
"PredicateExpr",
|
|
9562
|
+
"PredicateKind",
|
|
9563
|
+
"PredicateKindContext",
|
|
9221
9564
|
"PrintOptions",
|
|
9222
9565
|
"ProjectIssue",
|
|
9223
9566
|
"ProjectTheme",
|
|
@@ -9408,8 +9751,10 @@ export const apiSurface = {
|
|
|
9408
9751
|
],
|
|
9409
9752
|
"subpaths": [
|
|
9410
9753
|
"@svgrid/enterprise",
|
|
9754
|
+
"@svgrid/enterprise/base",
|
|
9411
9755
|
"@svgrid/enterprise/cdn/svelte-external",
|
|
9412
9756
|
"@svgrid/enterprise/export",
|
|
9757
|
+
"@svgrid/enterprise/fields",
|
|
9413
9758
|
"@svgrid/enterprise/gantt",
|
|
9414
9759
|
"@svgrid/enterprise/import",
|
|
9415
9760
|
"@svgrid/enterprise/license",
|
|
@@ -9440,6 +9785,11 @@ export const apiSurface = {
|
|
|
9440
9785
|
"optional": true,
|
|
9441
9786
|
"type": "BoardConfig<TFeatures, TData>"
|
|
9442
9787
|
},
|
|
9788
|
+
{
|
|
9789
|
+
"name": "gallery",
|
|
9790
|
+
"optional": true,
|
|
9791
|
+
"type": "GalleryConfig<TFeatures, TData>"
|
|
9792
|
+
},
|
|
9443
9793
|
{
|
|
9444
9794
|
"name": "scheduler",
|
|
9445
9795
|
"optional": true,
|
|
@@ -9480,6 +9830,71 @@ export const apiSurface = {
|
|
|
9480
9830
|
"optional": true,
|
|
9481
9831
|
"type": ""
|
|
9482
9832
|
},
|
|
9833
|
+
{
|
|
9834
|
+
"name": "rowAdder",
|
|
9835
|
+
"optional": true,
|
|
9836
|
+
"type": "boolean | RowAdderConfig<TData>"
|
|
9837
|
+
},
|
|
9838
|
+
{
|
|
9839
|
+
"name": "onRowAdd",
|
|
9840
|
+
"optional": true,
|
|
9841
|
+
"type": "(event: { row: TData }) => void"
|
|
9842
|
+
},
|
|
9843
|
+
{
|
|
9844
|
+
"name": "rowColor",
|
|
9845
|
+
"optional": true,
|
|
9846
|
+
"type": "RowColorConfig<TData>"
|
|
9847
|
+
},
|
|
9848
|
+
{
|
|
9849
|
+
"name": "columnAdder",
|
|
9850
|
+
"optional": true,
|
|
9851
|
+
"type": "boolean | ColumnAdderConfig"
|
|
9852
|
+
},
|
|
9853
|
+
{
|
|
9854
|
+
"name": "fieldMenu",
|
|
9855
|
+
"optional": true,
|
|
9856
|
+
"type": "boolean"
|
|
9857
|
+
},
|
|
9858
|
+
{
|
|
9859
|
+
"name": "expandRows",
|
|
9860
|
+
"optional": true,
|
|
9861
|
+
"type": "boolean"
|
|
9862
|
+
},
|
|
9863
|
+
{
|
|
9864
|
+
"name": "pinSplitter",
|
|
9865
|
+
"optional": true,
|
|
9866
|
+
"type": "boolean"
|
|
9867
|
+
},
|
|
9868
|
+
{
|
|
9869
|
+
"name": "primaryField",
|
|
9870
|
+
"optional": true,
|
|
9871
|
+
"type": "string"
|
|
9872
|
+
},
|
|
9873
|
+
{
|
|
9874
|
+
"name": "onColumnAdd",
|
|
9875
|
+
"optional": true,
|
|
9876
|
+
"type": "(event: { column: ColumnDef<TFeatures, TData>; columns: ColumnDef<TFeatures, TData>[]; data?: TData[] }) => void"
|
|
9877
|
+
},
|
|
9878
|
+
{
|
|
9879
|
+
"name": "onColumnChange",
|
|
9880
|
+
"optional": true,
|
|
9881
|
+
"type": "(event: { column: ColumnDef<TFeatures, TData>; previous: ColumnDef<TFeatures, TData>; columns: ColumnDef<TFeatures, TDat"
|
|
9882
|
+
},
|
|
9883
|
+
{
|
|
9884
|
+
"name": "onColumnRemove",
|
|
9885
|
+
"optional": true,
|
|
9886
|
+
"type": "(event: { columnId: string; columns: ColumnDef<TFeatures, TData>[] }) => void"
|
|
9887
|
+
},
|
|
9888
|
+
{
|
|
9889
|
+
"name": "onRowsDelete",
|
|
9890
|
+
"optional": true,
|
|
9891
|
+
"type": "(event: { rows: TData[]; ids: string[]; data: TData[] }) => void"
|
|
9892
|
+
},
|
|
9893
|
+
{
|
|
9894
|
+
"name": "onRowColorChange",
|
|
9895
|
+
"optional": true,
|
|
9896
|
+
"type": "(config: RowColorConfig<TData> | null) => void"
|
|
9897
|
+
},
|
|
9483
9898
|
{
|
|
9484
9899
|
"name": "features",
|
|
9485
9900
|
"optional": true,
|
|
@@ -10182,6 +10597,16 @@ export const apiSurface = {
|
|
|
10182
10597
|
"optional": true,
|
|
10183
10598
|
"type": "ColumnDefTemplate<CellContext<TData>>"
|
|
10184
10599
|
},
|
|
10600
|
+
{
|
|
10601
|
+
"name": "fieldType",
|
|
10602
|
+
"optional": true,
|
|
10603
|
+
"type": "string"
|
|
10604
|
+
},
|
|
10605
|
+
{
|
|
10606
|
+
"name": "fieldProps",
|
|
10607
|
+
"optional": true,
|
|
10608
|
+
"type": "Record<string, unknown>"
|
|
10609
|
+
},
|
|
10185
10610
|
{
|
|
10186
10611
|
"name": "columns",
|
|
10187
10612
|
"optional": true,
|
|
@@ -10227,6 +10652,11 @@ export const apiSurface = {
|
|
|
10227
10652
|
"optional": true,
|
|
10228
10653
|
"type": "ColumnDefTemplate<EditorContext<TData>>"
|
|
10229
10654
|
},
|
|
10655
|
+
{
|
|
10656
|
+
"name": "editorFormatString",
|
|
10657
|
+
"optional": true,
|
|
10658
|
+
"type": "string"
|
|
10659
|
+
},
|
|
10230
10660
|
{
|
|
10231
10661
|
"name": "tooltip",
|
|
10232
10662
|
"optional": true,
|
|
@@ -10297,6 +10727,11 @@ export const apiSurface = {
|
|
|
10297
10727
|
"optional": true,
|
|
10298
10728
|
"type": "CellFormatter<TData>"
|
|
10299
10729
|
},
|
|
10730
|
+
{
|
|
10731
|
+
"name": "sortValue",
|
|
10732
|
+
"optional": true,
|
|
10733
|
+
"type": "(value: unknown) => number | null | undefined"
|
|
10734
|
+
},
|
|
10300
10735
|
{
|
|
10301
10736
|
"name": "aggregate",
|
|
10302
10737
|
"optional": true,
|