Cookbook
Ten end-to-end recipes for the most common widget shapes. Each recipe below embeds the canonical source from apps/playground/src/widgets/<id>/WidgetApp.tsx — type-checked when you run pnpm --filter playground build:widgets (root pnpm test includes that step). Open the Live demo link to run the widget in the playground or paste the raw widget URL into Grist.
Edit the selected row
Live demo: Row editor · source
Declares mappings on the provider, reads the current selection via mappedRecord, writes back via table.update + mapBack.
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>
)
}Group rows on a Kanban board
Live demo: Task board · source
Render every row from the selected table in columns grouped by a Status field. Iterate w.records, resolve the physical column id via resolveMappedColumnId, project into typed cards.
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>
)
}Bulk update many rows
Live demo: Bulk mark done · source
table.update accepts an array. The SDK forwards it to Grist as a single BulkUpdateRecord action — one round-trip, all-or-nothing.
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: "Bulk mark done",
description: "Bulk table.update with an array of row patches.",
} 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>
}
async function markAll() {
if (!w.records) return
const updates = w.records.map((row) => ({
id: row.id as number,
fields: w.mapBack({ Done: true }),
}))
await w.table.update(updates)
}
return (
<div className="p-4">
<Button type="button" onClick={() => void markAll()}>
Mark all rows done
</Button>
</div>
)
}Read the schema and feed it to an LLM
Live demo: Schema preview · source
useGristSchema() returns a GristReplicaDocument — a single JSON object describing every table, column, and (optionally) sample rows. Drop it into an LLM prompt as 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>
)
}Run a schema migration
Live demo: Schema migration · source
Schema mutations go through applyActions with the exported builders. Each builder returns a GristUserActionTuple; the array runs as one transaction.
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>
)
}Display attachments
Live demo: Attachment gallery · source
extractGristAttachmentIdsFromCell reads a Grist attachment column value (which is an array of file refs). useGrist().fetchAttachmentBlob returns a Blob you can hand to URL.createObjectURL for previews.
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>
)
}Drive the cursor in linked sections
Live demo: Task board · source
A widget that displays a list of rows can move the cursor in linked sections of the same Grist page by calling setCursorPosition. Set allowSelectBy: true on the provider so Grist treats this widget as a linking source (see GRIST_OPTIONS in the task-board source).
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>
)
}Persist a widget option
Live demo: Widget options · source
widgetOptions survive reloads and travel with the document. Store a view mode, a filter, a layout flag — anything that should not be re-asked on every open.
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>
)
}Safe-parse table data with Zod-style results
Live demo: Safe parse · source
safeParseGristTableData validates columnar table data per column and returns both decoded rows and per-cell issues — no exceptions thrown.
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>
)
}Test a widget with the emulator
renderWithGrist from grist-widget-sdk/emulator/testing boots the real SDK inline against an in-memory fake host. The hook code paths run unchanged — see Testing and the SDK unit tests under packages/core/tests/sdk/ for full examples (this recipe is a test harness pattern, not a Custom Widget iframe bundle).