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.
- package/README.md +79 -7
- package/bin/create-grist-widget.mjs +485 -90
- package/examples/attachment-gallery/WidgetApp.tsx +202 -0
- package/examples/bulk-mark-done/WidgetApp.tsx +43 -0
- package/examples/declared-columns/WidgetApp.tsx +42 -0
- package/examples/form-edit/WidgetApp.tsx +225 -0
- package/examples/hello-world/WidgetApp.tsx +15 -0
- package/examples/mark-done/WidgetApp.tsx +44 -0
- package/examples/reads-demo/WidgetApp.tsx +69 -0
- package/examples/rest-fetch/WidgetApp.tsx +49 -0
- package/examples/safe-parse/WidgetApp.tsx +61 -0
- package/examples/schema-migration/WidgetApp.tsx +67 -0
- package/examples/schema-preview/WidgetApp.tsx +26 -0
- package/examples/slice-hooks/WidgetApp.tsx +47 -0
- package/examples/spreadsheet-grid/GristDataSheetGrid.tsx +376 -0
- package/examples/spreadsheet-grid/GristRdgGrid.tsx +189 -0
- package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +293 -0
- package/examples/spreadsheet-grid/WidgetApp.tsx +133 -0
- package/examples/spreadsheet-grid/grist-rdg-choice-column.tsx +616 -0
- package/examples/spreadsheet-grid/grist-rdg-columns.ts +397 -0
- package/examples/task-board/WidgetApp.tsx +229 -0
- package/examples/theme-demo/WidgetApp.tsx +26 -0
- package/examples/widget-options/WidgetApp.tsx +34 -0
- package/examples/writes-demo/WidgetApp.tsx +75 -0
- package/manifest.json +304 -0
- package/package.json +14 -2
- package/src/git-gate.mjs +30 -0
- package/template/scripts/deploy.mjs +35 -1
- package/ui-source/ShadcnDataSheetGrid.css +117 -0
- package/ui-source/ShadcnDataSheetGrid.tsx +46 -0
- package/ui-source/badge.tsx +49 -0
- 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
|
+
}
|