Cheat sheet
Copy-pasteable widget shapes for daily reference, organized by the pattern each one demonstrates.
One page for daily reference once you understand the
mental model. Every TypeScript example below is the full
WidgetApp.tsx from examples/<id>/, deployed standalone at
/examples/<id>/latest/ and viewable with the emulator inspector at
/playground/?url=/examples/<id>/latest/.
The deployed example wraps it in GristWidgetProvider and GristBoundary;
snippets show only WidgetApp (and GRIST_OPTIONS).
Provider + hook (selection modes)
import { useGrist, type UseGristOptions } from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "read table",
}
export function WidgetApp() {
const w = useGrist()
const rowKey =
w.record && typeof w.record.id === "number" ? String(w.record.id) : w.mode
if (w.mode === "empty") return <p>Select a row.</p>
if (w.mode === "new-row") return <p>New row flow</p>
return <p key={rowKey}>Selected row #{String(w.record!.id)}</p>
}
w.mode | Meaning |
|---|---|
"booting" | Plugin API not ready yet. <GristBoundary> handles this for you. |
"empty" | The Custom Widget section has no selected row. |
"row" | A real row is selected. w.record and w.mappedRecord are populated. |
"new-row" | The user is in the new-row flow. w.isNewRecord === true. |
Mappings + column gate
form-edit —
mappedRecord, mapBack, columnMappingStatus, and writes:
import { useState, type FormEvent, type ReactNode } from "react"
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
type UseGristResult,
} from "grist-widget-sdk"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [
{ name: "Title", type: "Text" },
{ name: "Description", type: "Text", optional: true },
{ name: "Done", type: "Bool" },
{ name: "Priority", type: "Text", optional: true },
],
}
export const WIDGET_METADATA = {
title: "Row editor",
description:
"Edit the selected row in a polished form. Showcases column mappings, write status, and error UI.",
} as const
type FormEditMappedRecord = {
Title?: unknown
Description?: unknown
Done?: unknown
Priority?: unknown
}
type FormEditGrist = UseGristResult<Record<string, unknown>, FormEditMappedRecord>
type Draft = {
title: string
description: string
done: boolean
priority: string
}
function readDraft(mapped: FormEditMappedRecord | null): Draft {
return {
title:
typeof mapped?.Title === "string"
? mapped.Title
: mapped?.Title == null
? ""
: String(mapped.Title),
description:
typeof mapped?.Description === "string"
? mapped.Description
: mapped?.Description == null
? ""
: String(mapped.Description),
done: mapped?.Done === true,
priority:
typeof mapped?.Priority === "string"
? mapped.Priority
: mapped?.Priority == null
? ""
: String(mapped.Priority),
}
}
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist<Record<string, unknown>, FormEditMappedRecord>()
if (w.status === "booting") {
return <Empty title="Connecting to Grist…" body="" />
}
if (!w.columnMappingStatus.ok) {
return (
<Empty
title="Missing column mappings"
body="Open the widget configuration panel and map the four required columns: Title, Done, Description (optional), Priority (optional)."
/>
)
}
if (w.mode === "empty") {
return (
<Empty
title="No row selected"
body="Select a row in the host Grist section to edit it here."
/>
)
}
return <RowEditor key={String(w.record!.id)} w={w} />
}
function RowEditor({ w }: { w: FormEditGrist }) {
const [draft, setDraft] = useState<Draft>(() => readDraft(w.mappedRecord))
const [saveError, setSaveError] = useState<string | null>(null)
async function save(e: FormEvent) {
e.preventDefault()
if (!w.record) return
setSaveError(null)
try {
await w.table.update({
id: w.record.id as number,
fields: w.mapBack({
Title: draft.title,
Description: draft.description || null,
Done: draft.done,
Priority: draft.priority || null,
}),
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err))
}
}
return (
<form
onSubmit={save}
className="flex h-svh min-h-0 flex-col gap-4 overflow-auto p-6"
>
<header>
<h1 className="text-lg font-medium">Row #{String(w.record!.id)}</h1>
<p className="text-sm text-muted-foreground">
Edit fields, then save back to Grist via{" "}
<code className="rounded bg-muted px-1 text-xs">table.update</code>.
</p>
</header>
<Field label="Title">
<Input
required
value={draft.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
}
/>
</Field>
<Field label="Description">
<Input
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
/>
</Field>
<Field label="Priority">
<Input
placeholder="High / Medium / Low"
value={draft.priority}
onChange={(e) =>
setDraft((d) => ({ ...d, priority: e.target.value }))
}
/>
</Field>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-input"
checked={draft.done}
onChange={(e) =>
setDraft((d) => ({ ...d, done: e.target.checked }))
}
/>
<span>Mark as done</span>
</label>
{saveError ? (
<Alert variant="destructive">
<AlertTitle>Save failed</AlertTitle>
<AlertDescription>{saveError}</AlertDescription>
</Alert>
) : null}
<div className="mt-auto flex items-center gap-2 border-t pt-3">
<Button type="submit" disabled={w.actionStatus === "running"}>
{w.actionStatus === "running" ? "Saving…" : "Save"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setDraft(readDraft(w.mappedRecord))}
disabled={w.actionStatus === "running"}
>
Reset
</Button>
{w.actionStatus === "error" && w.actionError ? (
<span className="text-xs text-destructive">{w.actionError}</span>
) : null}
</div>
</form>
)
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
{children}
</label>
)
}
function Empty({ title, body }: { title: string; body: string }) {
return (
<div className="flex h-svh items-center justify-center p-6">
<div className="max-w-sm text-center">
<h2 className="font-medium">{title}</h2>
{body ? (
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
) : null}
</div>
</div>
)
}
Declared columns only (mapping panel):
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [
{ name: "Title", type: "Text" },
{ name: "Done", type: "Bool" },
{ name: "Tags", type: "ChoiceList", allowMultiple: true, optional: true },
],
}
export const WIDGET_METADATA = {
title: "Declared columns",
description:
"Shows GRIST_OPTIONS column declarations and mappedRecord after the user maps columns.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
if (!w.columnMappingStatus.ok) {
return (
<p className="p-4 text-sm">
Map columns in the widget panel:{" "}
{w.columnMappingStatus.missing.join(", ")}
</p>
)
}
if (w.mode === "empty") return <p className="p-4 text-sm">Select a row.</p>
return (
<pre className="overflow-auto p-4 text-xs">
{JSON.stringify(w.mappedRecord, null, 2)}
</pre>
)
}
Writes
writes-demo —
create, update, upsert, destroy (single or array via table):
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [{ name: "Title", type: "Text" }],
}
export const WIDGET_METADATA = {
title: "Table writes",
description: "create, update, upsert, destroy via w.table.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
if (!w.columnMappingStatus.ok) {
return <p className="p-4 text-sm">Map the Title column.</p>
}
return (
<div className="flex flex-wrap gap-2 p-4">
<Button
type="button"
onClick={() =>
void w.table.create({ fields: w.mapBack({ Title: "New" }) })
}
>
create
</Button>
<Button
type="button"
disabled={!w.record}
onClick={() => {
if (!w.record) return
void w.table.update({
id: w.record.id as number,
fields: w.mapBack({ Title: "Updated" }),
})
}}
>
update
</Button>
<Button
type="button"
onClick={() =>
void w.table.upsert({
require: w.mapBack({ Title: "Existing" }),
fields: w.mapBack({ Title: "Existing" }),
})
}
>
upsert
</Button>
<Button
type="button"
disabled={!w.records?.length}
onClick={() => {
const ids = (w.records ?? [])
.slice(0, 3)
.map((r) => r.id as number)
if (ids.length) void w.table.destroy(ids)
}}
>
destroy (first 3)
</Button>
</div>
)
}
Single-row update:
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [{ name: "Done", type: "Bool" }],
}
export const WIDGET_METADATA = {
title: "Mark done",
description: "Single-row write via table.update.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
if (!w.columnMappingStatus.ok) {
return <p className="p-4 text-sm">Map the Done column.</p>
}
if (w.mode !== "row" || !w.record) {
return <p className="p-4 text-sm">Select a row.</p>
}
async function onClick() {
await w.table.update({
id: w.record!.id as number,
fields: w.mapBack({ Done: true }),
})
}
return (
<div className="p-4">
<Button type="button" onClick={() => void onClick()}>
Mark done
</Button>
</div>
)
}
Schema mutations
import {
gristAddVisibleColumnAction,
gristRemoveColumnAction,
gristRenameColumnAction,
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
}
export const WIDGET_METADATA = {
title: "Schema migration",
description:
"applyActions with gristAddVisibleColumnAction, gristRenameColumnAction, and gristRemoveColumnAction.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
async function runMigration() {
const tableId = w.currentTableId
if (!tableId) return
await w.applyActions(
[
gristAddVisibleColumnAction(tableId, "Priority", { type: "Choice" }),
gristAddVisibleColumnAction(tableId, "DueAt", { type: "Date" }),
gristRenameColumnAction(tableId, "DueAt", "DueDate"),
],
{ desc: "Add Priority + DueAt; rename DueAt → DueDate" },
)
}
async function rollback() {
const tableId = w.currentTableId
if (!tableId) return
await w.applyActions(
[
gristRemoveColumnAction(tableId, "Priority"),
gristRemoveColumnAction(tableId, "DueDate"),
],
{ desc: "Remove Priority + DueDate" },
)
}
return (
<div className="flex gap-2 p-4">
<Button type="button" onClick={() => void runMigration()}>
Run migration
</Button>
<Button type="button" variant="destructive" onClick={() => void rollback()}>
Rollback
</Button>
{w.actionStatus === "running" ? (
<p className="mt-2 text-xs text-muted-foreground">Applying…</p>
) : null}
{w.actionError ? (
<p className="mt-2 text-xs text-destructive">{w.actionError}</p>
) : null}
</div>
)
}
Reads
import { useEffect, useState } from "react"
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "read table",
}
export const WIDGET_METADATA = {
title: "Reads demo",
description: "listTables, fetchTableRows, fetchRow, fetchSelectedRecord.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const {
isReady,
currentTableId,
record,
listTables,
fetchTableRows,
fetchRow,
fetchSelectedRecord,
} = useGrist()
const [summary, setSummary] = useState<string>("Loading…")
useEffect(() => {
if (!isReady) return
let cancelled = false
void (async () => {
const tableIds = await listTables()
const tableId = currentTableId ?? tableIds[0]
if (!tableId) return
const rows = await fetchTableRows(tableId)
const single =
rows[0]?.id != null ? await fetchRow(tableId, rows[0].id as number) : null
const here =
record?.id != null ? await fetchSelectedRecord(record.id as number) : null
if (!cancelled) {
setSummary(
JSON.stringify(
{ tableIds, rowCount: rows.length, single, here },
null,
2,
),
)
}
})()
return () => {
cancelled = true
}
}, [
isReady,
currentTableId,
record?.id,
listTables,
fetchTableRows,
fetchRow,
fetchSelectedRecord,
])
return (
<pre className="max-h-svh overflow-auto p-4 text-xs">{summary}</pre>
)
}
Safe-parse (Zod-style cell validation)
import { useEffect, useState } from "react"
import {
safeParseGristTableData,
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
type TaskRow = { Title: string; Due: Date | null }
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "read table",
suppressAlerts: ["section-not-linked"],
}
export const WIDGET_METADATA = {
title: "Safe parse",
description: "safeParseGristTableData with per-cell issues.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const { isReady, currentTableId, fetchTable } = useGrist()
const [rows, setRows] = useState<TaskRow[]>([])
const [issues, setIssues] = useState<unknown>(null)
useEffect(() => {
if (!isReady) return
let cancelled = false
void (async () => {
const tableId = currentTableId
if (!tableId) return
const columnar = await fetchTable(tableId)
const parsed = safeParseGristTableData<TaskRow>(columnar, {
columns: {
Title: { type: "Text" },
Due: { type: "Date" },
},
})
if (!cancelled) {
setRows(parsed.rows)
setIssues(parsed.cellIssues)
}
})()
return () => {
cancelled = true
}
}, [isReady, currentTableId, fetchTable])
return (
<div className="grid gap-2 p-4 text-sm">
<p>{rows.length} parsed row(s)</p>
{issues ? (
<pre className="overflow-auto text-xs">{JSON.stringify(issues, null, 2)}</pre>
) : (
<p className="text-muted-foreground">No cell issues.</p>
)}
</div>
)
}
Attachments
import { useEffect, useState } from "react"
import {
extractGristAttachmentIdsFromCell,
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [{ name: "Attachments", type: "Attachments" }],
}
export const WIDGET_METADATA = {
title: "Attachment gallery",
description:
"Thumbnail grid of every attachment in the selected row. Showcases extractGristAttachmentIdsFromCell + useGrist().fetchAttachmentBlob.",
} as const
type Thumb = {
id: string
url: string
contentType: string
}
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
const cellValue = w.mappedRecord?.Attachments ?? null
const ids = extractGristAttachmentIdsFromCell(cellValue)
const idsKey = ids.join(",")
if (w.status === "booting") {
return <Empty title="Connecting to Grist…" body="" />
}
if (!w.columnMappingStatus.ok) {
return (
<Empty
title="Missing column mapping"
body="Open the widget configuration panel and map the Attachments column."
/>
)
}
if (w.mode === "empty") {
return (
<Empty
title="No row selected"
body="Select a row in the host Grist section to see its attachments."
/>
)
}
if (ids.length === 0) {
return (
<Empty
title="No attachments in this row"
body="The mapped Attachments column is empty for the selected row."
/>
)
}
const rowKey = `${String(w.record?.id ?? "")}:${idsKey}`
return (
<AttachmentGrid
key={rowKey}
ids={ids}
recordId={w.record?.id}
fetchAttachmentBlob={w.fetchAttachmentBlob}
/>
)
}
function AttachmentGrid({
ids,
recordId,
fetchAttachmentBlob,
}: {
ids: string[]
recordId: unknown
fetchAttachmentBlob: ReturnType<typeof useGrist>["fetchAttachmentBlob"]
}) {
const [thumbs, setThumbs] = useState<Thumb[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
// Stable string key so the effect doesn't re-fire when the parent
// re-renders with a structurally identical but referentially new array.
const idsKey = ids.join(",")
useEffect(() => {
const currentIds = idsKey ? idsKey.split(",") : []
let cancelled = false
const objectUrls: string[] = []
void (async () => {
try {
const next: Thumb[] = []
for (const id of currentIds) {
const fetched = await fetchAttachmentBlob(id)
if (cancelled) return
const url = URL.createObjectURL(fetched.blob)
objectUrls.push(url)
next.push({
id,
url,
contentType: fetched.contentType,
})
}
if (!cancelled) setThumbs(next)
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
for (const u of objectUrls) URL.revokeObjectURL(u)
}
}, [idsKey, fetchAttachmentBlob])
return (
<div className="flex h-svh min-h-0 flex-col gap-3 p-4">
<header>
<h1 className="text-lg font-medium">Attachments</h1>
<p className="text-sm text-muted-foreground">
{ids.length} attachment{ids.length === 1 ? "" : "s"} on row{" "}
<code className="rounded bg-muted px-1 text-xs">
{String(recordId ?? "?")}
</code>
</p>
</header>
{error ? (
<p className="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
) : null}
{loading ? (
<p className="text-xs text-muted-foreground">Loading thumbnails…</p>
) : null}
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-auto sm:grid-cols-3 lg:grid-cols-4">
{thumbs.map((thumb) => (
<Thumbnail key={thumb.id} thumb={thumb} />
))}
</div>
</div>
)
}
function Thumbnail({ thumb }: { thumb: Thumb }) {
const isImage = thumb.contentType.startsWith("image/")
const label = `attachment-${thumb.id}`
return (
<figure className="group flex flex-col gap-2 rounded-lg border bg-card p-2">
<div className="flex aspect-square items-center justify-center overflow-hidden rounded bg-muted">
{isImage ? (
<img
src={thumb.url}
alt={label}
className="size-full object-cover"
/>
) : (
<span className="text-xs text-muted-foreground">
{thumb.contentType || "binary"}
</span>
)}
</div>
<figcaption className="flex items-center justify-between gap-2">
<span className="truncate text-xs">{label}</span>
<Button asChild variant="outline" size="sm">
<a href={thumb.url} download={label}>
Save
</a>
</Button>
</figcaption>
</figure>
)
}
function Empty({ title, body }: { title: string; body: string }) {
return (
<div className="flex h-svh items-center justify-center p-6">
<div className="max-w-sm text-center">
<h2 className="font-medium">{title}</h2>
{body ? (
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
) : null}
</div>
</div>
)
}REST
import { useState } from "react"
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
suppressAlerts: ["section-not-linked"],
}
export const WIDGET_METADATA = {
title: "REST fetch",
description: "fetchWithAuth against the Grist REST API.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
const [body, setBody] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function loadTables() {
setError(null)
try {
const res = await w.fetchWithAuth("/tables")
const json = await res.json()
setBody(JSON.stringify(json, null, 2))
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
return (
<div className="flex flex-col gap-2 p-4">
<Button type="button" onClick={() => void loadTables()}>
GET /tables
</Button>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
{body ? (
<pre className="max-h-64 overflow-auto text-xs">{body}</pre>
) : null}
</div>
)
}
Theme
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "read table",
suppressAlerts: ["section-not-linked"],
}
export const WIDGET_METADATA = {
title: "Theme",
description: "Read w.theme from the host document.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
return (
<div className="p-4" data-theme={w.theme ?? "light"}>
<p className="text-sm">Host theme: {w.theme ?? "light"}</p>
</div>
)
}
Linked sections
task-board —
setCursorPosition on card click:
import { useMemo, useState, type DragEvent } from "react"
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
allowSelectBy: true,
columns: [
{ name: "Title", type: "Text" },
{ name: "Status", type: "Text" },
{ name: "Priority", type: "Text", optional: true },
],
}
export const WIDGET_METADATA = {
title: "Task board",
description:
"Mini Kanban board grouped by Status. Drag a card to update Status; click to focus the row in linked sections.",
} as const
const DEFAULT_STATUSES = ["To do", "Doing", "Done"] as const
type Task = {
id: number
title: string
status: string
priority: string | null
}
function pickStatuses(rows: readonly Task[]): readonly string[] {
const seen = new Set<string>(DEFAULT_STATUSES)
for (const r of rows) {
if (r.status && !seen.has(r.status)) seen.add(r.status)
}
return [...seen]
}
function asString(value: unknown): string {
if (typeof value === "string") return value
if (value == null) return ""
return String(value)
}
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
// Resolve the physical column ids once per render. We can't call mapBack on
// every row record because mapBack is for a single record write; here we
// want to project the rows from raw `records` into a typed shape.
const titleColId = w.resolveMappedColumnId("Title")
const statusColId = w.resolveMappedColumnId("Status")
const priorityColId = w.resolveMappedColumnId("Priority")
const tasks = useMemo<Task[]>(() => {
if (
!w.columnMappingStatus.ok ||
!titleColId ||
!statusColId ||
!w.records
) {
return []
}
return w.records.map((row) => {
const status = asString(row[statusColId])
const priority = priorityColId ? asString(row[priorityColId]) : ""
return {
id: row.id as number,
title: asString(row[titleColId]) || "(untitled)",
status: status || "To do",
priority: priority || null,
}
})
}, [
w.records,
w.columnMappingStatus,
titleColId,
statusColId,
priorityColId,
])
const statuses = useMemo(() => pickStatuses(tasks), [tasks])
const [dragId, setDragId] = useState<number | null>(null)
if (w.status === "booting") {
return <Empty title="Connecting to Grist…" body="" />
}
if (!w.columnMappingStatus.ok) {
return (
<Empty
title="Missing column mappings"
body="Open the widget configuration panel and map the columns: Title (Text), Status (Text), Priority (Text, optional)."
/>
)
}
if (tasks.length === 0) {
return (
<Empty
title="No rows to show"
body="Add rows to the selected table — they will appear grouped by Status."
/>
)
}
async function moveToStatus(task: Task, nextStatus: string) {
if (task.status === nextStatus) return
try {
await w.table.update({
id: task.id,
fields: w.mapBack({ Status: nextStatus }),
})
} catch {
// The card will snap back via the next `records` tick when the
// optimistic update is rejected by Grist.
}
}
return (
<div className="flex h-svh min-h-0 flex-col gap-3 p-4">
<header className="flex items-baseline justify-between">
<h1 className="text-lg font-medium">Task board</h1>
<span className="text-xs text-muted-foreground">
{tasks.length} task{tasks.length === 1 ? "" : "s"}
</span>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-auto sm:grid-cols-2 lg:grid-cols-3">
{statuses.map((status) => {
const inStatus = tasks.filter((c) => c.status === status)
return (
<section
key={status}
className="flex min-h-32 flex-col gap-2 rounded-lg border bg-muted/40 p-3"
onDragOver={(e) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
}}
onDrop={(e) => {
e.preventDefault()
if (dragId == null) return
const dragged = tasks.find((c) => c.id === dragId)
setDragId(null)
if (dragged) void moveToStatus(dragged, status)
}}
>
<header className="flex items-center justify-between">
<h2 className="text-sm font-semibold">{status}</h2>
<Badge variant="secondary">{inStatus.length}</Badge>
</header>
<div className="flex flex-col gap-2">
{inStatus.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={w.record?.id === task.id}
onClick={() => void w.setCursorPosition({ rowId: task.id })}
onDragStart={() => setDragId(task.id)}
onDragEnd={() => setDragId(null)}
/>
))}
{inStatus.length === 0 ? (
<p className="text-xs text-muted-foreground">No tasks.</p>
) : null}
</div>
</section>
)
})}
</div>
</div>
)
}
function TaskCard({
task,
isSelected,
onClick,
onDragStart,
onDragEnd,
}: {
task: Task
isSelected: boolean
onClick: () => void
onDragStart: (e: DragEvent<HTMLDivElement>) => void
onDragEnd: (e: DragEvent<HTMLDivElement>) => void
}) {
return (
<Card
draggable
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
className={
"cursor-grab transition-shadow hover:shadow-md " +
(isSelected ? "ring-2 ring-primary" : "")
}
>
<CardContent className="flex flex-col gap-1 p-3">
<span className="text-sm font-medium">{task.title}</span>
{task.priority ? (
<Badge variant="outline" className="self-start">
{task.priority}
</Badge>
) : null}
</CardContent>
</Card>
)
}
function Empty({ title, body }: { title: string; body: string }) {
return (
<div className="flex h-svh items-center justify-center p-6">
<div className="max-w-sm text-center">
<h2 className="font-medium">{title}</h2>
{body ? (
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
) : null}
</div>
</div>
)
}
Widget options
import {
useGrist,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
import { Button } from "@/components/ui/button"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "read table",
suppressAlerts: ["section-not-linked"],
}
export const WIDGET_METADATA = {
title: "Widget options",
description: "Persist UI state with setWidgetOption / widgetOptions.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const w = useGrist()
const compact = w.widgetOptions?.compact === true
return (
<div className="p-4">
<Button
type="button"
onClick={() => void w.setWidgetOption("compact", !compact)}
>
{compact ? "Expand" : "Compact"}
</Button>
</div>
)
}
Slice hooks (performance)
When parts of a big widget need only one slice of state, subscribe narrowly so unrelated state changes do not re-render them:
import {
useGristSelection,
useGristStatus,
useGristTheme,
useGristWrites,
useWidgetMetadata,
type UseGristOptions,
} from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "full",
columns: [{ name: "Title", type: "Text" }],
}
export const WIDGET_METADATA = {
title: "Slice hooks",
description:
"useGristStatus, useGristSelection, useGristWrites, useGristTheme in separate child components.",
} as const
function Header() {
const { status } = useGristStatus()
return <h1 className="text-sm font-medium">status: {status}</h1>
}
function Editor() {
const { record } = useGristSelection()
useGristWrites()
return <p className="text-sm">Editing #{String(record?.id ?? "none")}</p>
}
function ThemeToggle() {
const { theme } = useGristTheme()
return <span className="text-xs text-muted-foreground">{theme ?? "no theme"}</span>
}
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
return (
<div className="flex flex-col gap-2 p-4">
<Header />
<Editor />
<ThemeToggle />
</div>
)
}
Schema for LLM context
import { useGristSchema, useWidgetMetadata, type UseGristOptions } from "grist-widget-sdk"
export const GRIST_OPTIONS: UseGristOptions = {
requiredAccess: "read table",
suppressAlerts: ["section-not-linked"],
}
export const WIDGET_METADATA = {
title: "Schema preview",
description: "useGristSchema with replicaRowMode schema+samples for LLM context.",
} as const
export function WidgetApp() {
useWidgetMetadata(WIDGET_METADATA)
const schema = useGristSchema({ replicaRowMode: "schema+samples" })
if (!schema.replicaDocument) {
return <p className="p-4 text-sm text-muted-foreground">Loading schema…</p>
}
return (
<pre className="max-h-svh overflow-auto p-4 text-xs">
{JSON.stringify(schema.replicaDocument, null, 2)}
</pre>
)
}