Cheat sheet
One page for daily reference once you understand the mental model. Every TypeScript example below is the full WidgetApp.tsx from a playground widget — type-checked by pnpm --filter playground build:widgets and runnable at https://demo.grist-widgets.com/?url=widget.html?id=<id>.
The playground widget.html shell adds 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>
)
}