create-grist-widget 0.3.7 → 0.3.9

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.
Files changed (32) hide show
  1. package/README.md +79 -7
  2. package/bin/create-grist-widget.mjs +485 -90
  3. package/examples/attachment-gallery/WidgetApp.tsx +202 -0
  4. package/examples/bulk-mark-done/WidgetApp.tsx +43 -0
  5. package/examples/declared-columns/WidgetApp.tsx +42 -0
  6. package/examples/form-edit/WidgetApp.tsx +225 -0
  7. package/examples/hello-world/WidgetApp.tsx +15 -0
  8. package/examples/mark-done/WidgetApp.tsx +44 -0
  9. package/examples/reads-demo/WidgetApp.tsx +69 -0
  10. package/examples/rest-fetch/WidgetApp.tsx +49 -0
  11. package/examples/safe-parse/WidgetApp.tsx +61 -0
  12. package/examples/schema-migration/WidgetApp.tsx +67 -0
  13. package/examples/schema-preview/WidgetApp.tsx +26 -0
  14. package/examples/slice-hooks/WidgetApp.tsx +47 -0
  15. package/examples/spreadsheet-grid/GristDataSheetGrid.tsx +376 -0
  16. package/examples/spreadsheet-grid/GristRdgGrid.tsx +189 -0
  17. package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +293 -0
  18. package/examples/spreadsheet-grid/WidgetApp.tsx +133 -0
  19. package/examples/spreadsheet-grid/grist-rdg-choice-column.tsx +616 -0
  20. package/examples/spreadsheet-grid/grist-rdg-columns.ts +397 -0
  21. package/examples/task-board/WidgetApp.tsx +229 -0
  22. package/examples/theme-demo/WidgetApp.tsx +26 -0
  23. package/examples/widget-options/WidgetApp.tsx +34 -0
  24. package/examples/writes-demo/WidgetApp.tsx +75 -0
  25. package/manifest.json +304 -0
  26. package/package.json +14 -2
  27. package/src/git-gate.mjs +30 -0
  28. package/template/scripts/deploy.mjs +35 -1
  29. package/ui-source/ShadcnDataSheetGrid.css +117 -0
  30. package/ui-source/ShadcnDataSheetGrid.tsx +46 -0
  31. package/ui-source/badge.tsx +49 -0
  32. package/vendor/compose-example.mjs +252 -0
@@ -0,0 +1,202 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ import {
4
+ extractGristAttachmentIdsFromCell,
5
+ useGrist,
6
+ useWidgetMetadata,
7
+ type UseGristOptions,
8
+ } from "grist-widget-sdk"
9
+
10
+ import { Button } from "@/components/ui/button"
11
+
12
+ export const GRIST_OPTIONS: UseGristOptions = {
13
+ requiredAccess: "full",
14
+ columns: [{ name: "Attachments", type: "Attachments" }],
15
+ }
16
+
17
+ export const WIDGET_METADATA = {
18
+ title: "Attachment gallery",
19
+ description:
20
+ "Thumbnail grid of every attachment in the selected row. Showcases extractGristAttachmentIdsFromCell + useGrist().fetchAttachmentBlob.",
21
+ } as const
22
+
23
+ type Thumb = {
24
+ id: string
25
+ url: string
26
+ contentType: string
27
+ }
28
+
29
+ export function WidgetApp() {
30
+ useWidgetMetadata(WIDGET_METADATA)
31
+ const w = useGrist()
32
+
33
+ const cellValue = w.mappedRecord?.Attachments ?? null
34
+ const ids = extractGristAttachmentIdsFromCell(cellValue)
35
+ const idsKey = ids.join(",")
36
+
37
+ if (w.status === "booting") {
38
+ return <Empty title="Connecting to Grist…" body="" />
39
+ }
40
+
41
+ if (!w.columnMappingStatus.ok) {
42
+ return (
43
+ <Empty
44
+ title="Missing column mapping"
45
+ body="Open the widget configuration panel and map the Attachments column."
46
+ />
47
+ )
48
+ }
49
+
50
+ if (w.mode === "empty") {
51
+ return (
52
+ <Empty
53
+ title="No row selected"
54
+ body="Select a row in the host Grist section to see its attachments."
55
+ />
56
+ )
57
+ }
58
+
59
+ if (ids.length === 0) {
60
+ return (
61
+ <Empty
62
+ title="No attachments in this row"
63
+ body="The mapped Attachments column is empty for the selected row."
64
+ />
65
+ )
66
+ }
67
+
68
+ const rowKey = `${String(w.record?.id ?? "")}:${idsKey}`
69
+
70
+ return (
71
+ <AttachmentGrid
72
+ key={rowKey}
73
+ ids={ids}
74
+ recordId={w.record?.id}
75
+ fetchAttachmentBlob={w.fetchAttachmentBlob}
76
+ />
77
+ )
78
+ }
79
+
80
+ function AttachmentGrid({
81
+ ids,
82
+ recordId,
83
+ fetchAttachmentBlob,
84
+ }: {
85
+ ids: string[]
86
+ recordId: unknown
87
+ fetchAttachmentBlob: ReturnType<typeof useGrist>["fetchAttachmentBlob"]
88
+ }) {
89
+ const [thumbs, setThumbs] = useState<Thumb[]>([])
90
+ const [error, setError] = useState<string | null>(null)
91
+ const [loading, setLoading] = useState(true)
92
+
93
+ // Stable string key so the effect doesn't re-fire when the parent
94
+ // re-renders with a structurally identical but referentially new array.
95
+ const idsKey = ids.join(",")
96
+
97
+ useEffect(() => {
98
+ const currentIds = idsKey ? idsKey.split(",") : []
99
+ let cancelled = false
100
+ const objectUrls: string[] = []
101
+ void (async () => {
102
+ try {
103
+ const next: Thumb[] = []
104
+ for (const id of currentIds) {
105
+ const fetched = await fetchAttachmentBlob(id)
106
+ if (cancelled) return
107
+ const url = URL.createObjectURL(fetched.blob)
108
+ objectUrls.push(url)
109
+ next.push({
110
+ id,
111
+ url,
112
+ contentType: fetched.contentType,
113
+ })
114
+ }
115
+ if (!cancelled) setThumbs(next)
116
+ } catch (err) {
117
+ if (!cancelled)
118
+ setError(err instanceof Error ? err.message : String(err))
119
+ } finally {
120
+ if (!cancelled) setLoading(false)
121
+ }
122
+ })()
123
+ return () => {
124
+ cancelled = true
125
+ for (const u of objectUrls) URL.revokeObjectURL(u)
126
+ }
127
+ }, [idsKey, fetchAttachmentBlob])
128
+
129
+ return (
130
+ <div className="flex h-svh min-h-0 flex-col gap-3 p-4">
131
+ <header>
132
+ <h1 className="text-lg font-medium">Attachments</h1>
133
+ <p className="text-sm text-muted-foreground">
134
+ {ids.length} attachment{ids.length === 1 ? "" : "s"} on row{" "}
135
+ <code className="rounded bg-muted px-1 text-xs">
136
+ {String(recordId ?? "?")}
137
+ </code>
138
+ </p>
139
+ </header>
140
+
141
+ {error ? (
142
+ <p className="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
143
+ {error}
144
+ </p>
145
+ ) : null}
146
+
147
+ {loading ? (
148
+ <p className="text-xs text-muted-foreground">Loading thumbnails…</p>
149
+ ) : null}
150
+
151
+ <div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-auto sm:grid-cols-3 lg:grid-cols-4">
152
+ {thumbs.map((thumb) => (
153
+ <Thumbnail key={thumb.id} thumb={thumb} />
154
+ ))}
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function Thumbnail({ thumb }: { thumb: Thumb }) {
161
+ const isImage = thumb.contentType.startsWith("image/")
162
+ const label = `attachment-${thumb.id}`
163
+
164
+ return (
165
+ <figure className="group flex flex-col gap-2 rounded-lg border bg-card p-2">
166
+ <div className="flex aspect-square items-center justify-center overflow-hidden rounded bg-muted">
167
+ {isImage ? (
168
+ <img
169
+ src={thumb.url}
170
+ alt={label}
171
+ className="size-full object-cover"
172
+ />
173
+ ) : (
174
+ <span className="text-xs text-muted-foreground">
175
+ {thumb.contentType || "binary"}
176
+ </span>
177
+ )}
178
+ </div>
179
+ <figcaption className="flex items-center justify-between gap-2">
180
+ <span className="truncate text-xs">{label}</span>
181
+ <Button asChild variant="outline" size="sm">
182
+ <a href={thumb.url} download={label}>
183
+ Save
184
+ </a>
185
+ </Button>
186
+ </figcaption>
187
+ </figure>
188
+ )
189
+ }
190
+
191
+ function Empty({ title, body }: { title: string; body: string }) {
192
+ return (
193
+ <div className="flex h-svh items-center justify-center p-6">
194
+ <div className="max-w-sm text-center">
195
+ <h2 className="font-medium">{title}</h2>
196
+ {body ? (
197
+ <p className="mt-2 text-sm text-muted-foreground">{body}</p>
198
+ ) : null}
199
+ </div>
200
+ </div>
201
+ )
202
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ useGrist,
3
+ useWidgetMetadata,
4
+ type UseGristOptions,
5
+ } from "grist-widget-sdk"
6
+
7
+ import { Button } from "@/components/ui/button"
8
+
9
+ export const GRIST_OPTIONS: UseGristOptions = {
10
+ requiredAccess: "full",
11
+ columns: [{ name: "Done", type: "Bool" }],
12
+ }
13
+
14
+ export const WIDGET_METADATA = {
15
+ title: "Bulk mark done",
16
+ description: "Bulk table.update with an array of row patches.",
17
+ } as const
18
+
19
+ export function WidgetApp() {
20
+ useWidgetMetadata(WIDGET_METADATA)
21
+ const w = useGrist()
22
+
23
+ if (!w.columnMappingStatus.ok) {
24
+ return <p className="p-4 text-sm">Map the Done column.</p>
25
+ }
26
+
27
+ async function markAll() {
28
+ if (!w.records) return
29
+ const updates = w.records.map((row) => ({
30
+ id: row.id as number,
31
+ fields: w.mapBack({ Done: true }),
32
+ }))
33
+ await w.table.update(updates)
34
+ }
35
+
36
+ return (
37
+ <div className="p-4">
38
+ <Button type="button" onClick={() => void markAll()}>
39
+ Mark all rows done
40
+ </Button>
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,42 @@
1
+ import {
2
+ useGrist,
3
+ useWidgetMetadata,
4
+ type UseGristOptions,
5
+ } from "grist-widget-sdk"
6
+
7
+ export const GRIST_OPTIONS: UseGristOptions = {
8
+ requiredAccess: "full",
9
+ columns: [
10
+ { name: "Title", type: "Text" },
11
+ { name: "Done", type: "Bool" },
12
+ { name: "Tags", type: "ChoiceList", allowMultiple: true, optional: true },
13
+ ],
14
+ }
15
+
16
+ export const WIDGET_METADATA = {
17
+ title: "Declared columns",
18
+ description:
19
+ "Shows GRIST_OPTIONS column declarations and mappedRecord after the user maps columns.",
20
+ } as const
21
+
22
+ export function WidgetApp() {
23
+ useWidgetMetadata(WIDGET_METADATA)
24
+ const w = useGrist()
25
+
26
+ if (!w.columnMappingStatus.ok) {
27
+ return (
28
+ <p className="p-4 text-sm">
29
+ Map columns in the widget panel:{" "}
30
+ {w.columnMappingStatus.missing.join(", ")}
31
+ </p>
32
+ )
33
+ }
34
+
35
+ if (w.mode === "empty") return <p className="p-4 text-sm">Select a row.</p>
36
+
37
+ return (
38
+ <pre className="overflow-auto p-4 text-xs">
39
+ {JSON.stringify(w.mappedRecord, null, 2)}
40
+ </pre>
41
+ )
42
+ }
@@ -0,0 +1,225 @@
1
+ import { useState, type FormEvent, type ReactNode } from "react"
2
+
3
+ import {
4
+ useGrist,
5
+ useWidgetMetadata,
6
+ type UseGristOptions,
7
+ type UseGristResult,
8
+ } from "grist-widget-sdk"
9
+
10
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
11
+ import { Button } from "@/components/ui/button"
12
+ import { Input } from "@/components/ui/input"
13
+
14
+ export const GRIST_OPTIONS: UseGristOptions = {
15
+ requiredAccess: "full",
16
+ columns: [
17
+ { name: "Title", type: "Text" },
18
+ { name: "Description", type: "Text", optional: true },
19
+ { name: "Done", type: "Bool" },
20
+ { name: "Priority", type: "Text", optional: true },
21
+ ],
22
+ }
23
+
24
+ export const WIDGET_METADATA = {
25
+ title: "Row editor",
26
+ description:
27
+ "Edit the selected row in a polished form. Showcases column mappings, write status, and error UI.",
28
+ } as const
29
+
30
+ type FormEditMappedRecord = {
31
+ Title?: unknown
32
+ Description?: unknown
33
+ Done?: unknown
34
+ Priority?: unknown
35
+ }
36
+
37
+ type FormEditGrist = UseGristResult<Record<string, unknown>, FormEditMappedRecord>
38
+
39
+ type Draft = {
40
+ title: string
41
+ description: string
42
+ done: boolean
43
+ priority: string
44
+ }
45
+
46
+ function readDraft(mapped: FormEditMappedRecord | null): Draft {
47
+ return {
48
+ title:
49
+ typeof mapped?.Title === "string"
50
+ ? mapped.Title
51
+ : mapped?.Title == null
52
+ ? ""
53
+ : String(mapped.Title),
54
+ description:
55
+ typeof mapped?.Description === "string"
56
+ ? mapped.Description
57
+ : mapped?.Description == null
58
+ ? ""
59
+ : String(mapped.Description),
60
+ done: mapped?.Done === true,
61
+ priority:
62
+ typeof mapped?.Priority === "string"
63
+ ? mapped.Priority
64
+ : mapped?.Priority == null
65
+ ? ""
66
+ : String(mapped.Priority),
67
+ }
68
+ }
69
+
70
+ export function WidgetApp() {
71
+ useWidgetMetadata(WIDGET_METADATA)
72
+ const w = useGrist<Record<string, unknown>, FormEditMappedRecord>()
73
+
74
+ if (w.status === "booting") {
75
+ return <Empty title="Connecting to Grist…" body="" />
76
+ }
77
+
78
+ if (!w.columnMappingStatus.ok) {
79
+ return (
80
+ <Empty
81
+ title="Missing column mappings"
82
+ body="Open the widget configuration panel and map the four required columns: Title, Done, Description (optional), Priority (optional)."
83
+ />
84
+ )
85
+ }
86
+
87
+ if (w.mode === "empty") {
88
+ return (
89
+ <Empty
90
+ title="No row selected"
91
+ body="Select a row in the host Grist section to edit it here."
92
+ />
93
+ )
94
+ }
95
+
96
+ return <RowEditor key={String(w.record!.id)} w={w} />
97
+ }
98
+
99
+ function RowEditor({ w }: { w: FormEditGrist }) {
100
+ const [draft, setDraft] = useState<Draft>(() => readDraft(w.mappedRecord))
101
+ const [saveError, setSaveError] = useState<string | null>(null)
102
+
103
+ async function save(e: FormEvent) {
104
+ e.preventDefault()
105
+ if (!w.record) return
106
+ setSaveError(null)
107
+ try {
108
+ await w.table.update({
109
+ id: w.record.id as number,
110
+ fields: w.mapBack({
111
+ Title: draft.title,
112
+ Description: draft.description || null,
113
+ Done: draft.done,
114
+ Priority: draft.priority || null,
115
+ }),
116
+ })
117
+ } catch (err) {
118
+ setSaveError(err instanceof Error ? err.message : String(err))
119
+ }
120
+ }
121
+
122
+ return (
123
+ <form
124
+ onSubmit={save}
125
+ className="flex h-svh min-h-0 flex-col gap-4 overflow-auto p-6"
126
+ >
127
+ <header>
128
+ <h1 className="text-lg font-medium">Row #{String(w.record!.id)}</h1>
129
+ <p className="text-sm text-muted-foreground">
130
+ Edit fields, then save back to Grist via{" "}
131
+ <code className="rounded bg-muted px-1 text-xs">table.update</code>.
132
+ </p>
133
+ </header>
134
+
135
+ <Field label="Title">
136
+ <Input
137
+ required
138
+ value={draft.title}
139
+ onChange={(e) =>
140
+ setDraft((d) => ({ ...d, title: e.target.value }))
141
+ }
142
+ />
143
+ </Field>
144
+
145
+ <Field label="Description">
146
+ <Input
147
+ value={draft.description}
148
+ onChange={(e) =>
149
+ setDraft((d) => ({ ...d, description: e.target.value }))
150
+ }
151
+ />
152
+ </Field>
153
+
154
+ <Field label="Priority">
155
+ <Input
156
+ placeholder="High / Medium / Low"
157
+ value={draft.priority}
158
+ onChange={(e) =>
159
+ setDraft((d) => ({ ...d, priority: e.target.value }))
160
+ }
161
+ />
162
+ </Field>
163
+
164
+ <label className="flex items-center gap-2 text-sm">
165
+ <input
166
+ type="checkbox"
167
+ className="size-4 rounded border-input"
168
+ checked={draft.done}
169
+ onChange={(e) =>
170
+ setDraft((d) => ({ ...d, done: e.target.checked }))
171
+ }
172
+ />
173
+ <span>Mark as done</span>
174
+ </label>
175
+
176
+ {saveError ? (
177
+ <Alert variant="destructive">
178
+ <AlertTitle>Save failed</AlertTitle>
179
+ <AlertDescription>{saveError}</AlertDescription>
180
+ </Alert>
181
+ ) : null}
182
+
183
+ <div className="mt-auto flex items-center gap-2 border-t pt-3">
184
+ <Button type="submit" disabled={w.actionStatus === "running"}>
185
+ {w.actionStatus === "running" ? "Saving…" : "Save"}
186
+ </Button>
187
+ <Button
188
+ type="button"
189
+ variant="outline"
190
+ onClick={() => setDraft(readDraft(w.mappedRecord))}
191
+ disabled={w.actionStatus === "running"}
192
+ >
193
+ Reset
194
+ </Button>
195
+ {w.actionStatus === "error" && w.actionError ? (
196
+ <span className="text-xs text-destructive">{w.actionError}</span>
197
+ ) : null}
198
+ </div>
199
+ </form>
200
+ )
201
+ }
202
+
203
+ function Field({ label, children }: { label: string; children: ReactNode }) {
204
+ return (
205
+ <label className="flex flex-col gap-1">
206
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
207
+ {label}
208
+ </span>
209
+ {children}
210
+ </label>
211
+ )
212
+ }
213
+
214
+ function Empty({ title, body }: { title: string; body: string }) {
215
+ return (
216
+ <div className="flex h-svh items-center justify-center p-6">
217
+ <div className="max-w-sm text-center">
218
+ <h2 className="font-medium">{title}</h2>
219
+ {body ? (
220
+ <p className="mt-2 text-sm text-muted-foreground">{body}</p>
221
+ ) : null}
222
+ </div>
223
+ </div>
224
+ )
225
+ }
@@ -0,0 +1,15 @@
1
+ import { useGrist, type UseGristOptions } from "grist-widget-sdk"
2
+
3
+ export const GRIST_OPTIONS: UseGristOptions = {
4
+ requiredAccess: "read table",
5
+ }
6
+
7
+ export function WidgetApp() {
8
+ const w = useGrist()
9
+ const rowKey =
10
+ w.record && typeof w.record.id === "number" ? String(w.record.id) : w.mode
11
+
12
+ if (w.mode === "empty") return <p>Select a row.</p>
13
+ if (w.mode === "new-row") return <p>New row flow</p>
14
+ return <p key={rowKey}>Selected row #{String(w.record!.id)}</p>
15
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ useGrist,
3
+ useWidgetMetadata,
4
+ type UseGristOptions,
5
+ } from "grist-widget-sdk"
6
+
7
+ import { Button } from "@/components/ui/button"
8
+
9
+ export const GRIST_OPTIONS: UseGristOptions = {
10
+ requiredAccess: "full",
11
+ columns: [{ name: "Done", type: "Bool" }],
12
+ }
13
+
14
+ export const WIDGET_METADATA = {
15
+ title: "Mark done",
16
+ description: "Single-row write via table.update.",
17
+ } as const
18
+
19
+ export function WidgetApp() {
20
+ useWidgetMetadata(WIDGET_METADATA)
21
+ const w = useGrist()
22
+
23
+ if (!w.columnMappingStatus.ok) {
24
+ return <p className="p-4 text-sm">Map the Done column.</p>
25
+ }
26
+ if (w.mode !== "row" || !w.record) {
27
+ return <p className="p-4 text-sm">Select a row.</p>
28
+ }
29
+
30
+ async function onClick() {
31
+ await w.table.update({
32
+ id: w.record!.id as number,
33
+ fields: w.mapBack({ Done: true }),
34
+ })
35
+ }
36
+
37
+ return (
38
+ <div className="p-4">
39
+ <Button type="button" onClick={() => void onClick()}>
40
+ Mark done
41
+ </Button>
42
+ </div>
43
+ )
44
+ }
@@ -0,0 +1,69 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ import {
4
+ useGrist,
5
+ useWidgetMetadata,
6
+ type UseGristOptions,
7
+ } from "grist-widget-sdk"
8
+
9
+ export const GRIST_OPTIONS: UseGristOptions = {
10
+ requiredAccess: "read table",
11
+ }
12
+
13
+ export const WIDGET_METADATA = {
14
+ title: "Reads demo",
15
+ description: "listTables, fetchTableRows, fetchRow, fetchSelectedRecord.",
16
+ } as const
17
+
18
+ export function WidgetApp() {
19
+ useWidgetMetadata(WIDGET_METADATA)
20
+ const {
21
+ isReady,
22
+ currentTableId,
23
+ record,
24
+ listTables,
25
+ fetchTableRows,
26
+ fetchRow,
27
+ fetchSelectedRecord,
28
+ } = useGrist()
29
+ const [summary, setSummary] = useState<string>("Loading…")
30
+
31
+ useEffect(() => {
32
+ if (!isReady) return
33
+ let cancelled = false
34
+ void (async () => {
35
+ const tableIds = await listTables()
36
+ const tableId = currentTableId ?? tableIds[0]
37
+ if (!tableId) return
38
+ const rows = await fetchTableRows(tableId)
39
+ const single =
40
+ rows[0]?.id != null ? await fetchRow(tableId, rows[0].id as number) : null
41
+ const here =
42
+ record?.id != null ? await fetchSelectedRecord(record.id as number) : null
43
+ if (!cancelled) {
44
+ setSummary(
45
+ JSON.stringify(
46
+ { tableIds, rowCount: rows.length, single, here },
47
+ null,
48
+ 2,
49
+ ),
50
+ )
51
+ }
52
+ })()
53
+ return () => {
54
+ cancelled = true
55
+ }
56
+ }, [
57
+ isReady,
58
+ currentTableId,
59
+ record?.id,
60
+ listTables,
61
+ fetchTableRows,
62
+ fetchRow,
63
+ fetchSelectedRecord,
64
+ ])
65
+
66
+ return (
67
+ <pre className="max-h-svh overflow-auto p-4 text-xs">{summary}</pre>
68
+ )
69
+ }
@@ -0,0 +1,49 @@
1
+ import { useState } from "react"
2
+
3
+ import {
4
+ useGrist,
5
+ useWidgetMetadata,
6
+ type UseGristOptions,
7
+ } from "grist-widget-sdk"
8
+
9
+ import { Button } from "@/components/ui/button"
10
+
11
+ export const GRIST_OPTIONS: UseGristOptions = {
12
+ requiredAccess: "full",
13
+ suppressAlerts: ["section-not-linked"],
14
+ }
15
+
16
+ export const WIDGET_METADATA = {
17
+ title: "REST fetch",
18
+ description: "fetchWithAuth against the Grist REST API.",
19
+ } as const
20
+
21
+ export function WidgetApp() {
22
+ useWidgetMetadata(WIDGET_METADATA)
23
+ const w = useGrist()
24
+ const [body, setBody] = useState<string | null>(null)
25
+ const [error, setError] = useState<string | null>(null)
26
+
27
+ async function loadTables() {
28
+ setError(null)
29
+ try {
30
+ const res = await w.fetchWithAuth("/tables")
31
+ const json = await res.json()
32
+ setBody(JSON.stringify(json, null, 2))
33
+ } catch (err) {
34
+ setError(err instanceof Error ? err.message : String(err))
35
+ }
36
+ }
37
+
38
+ return (
39
+ <div className="flex flex-col gap-2 p-4">
40
+ <Button type="button" onClick={() => void loadTables()}>
41
+ GET /tables
42
+ </Button>
43
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
44
+ {body ? (
45
+ <pre className="max-h-64 overflow-auto text-xs">{body}</pre>
46
+ ) : null}
47
+ </div>
48
+ )
49
+ }