create-grist-widget 0.3.6 → 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/.github/workflows/deploy.yml +7 -3
- package/template/README.md +3 -2
- package/template/scripts/deploy.mjs +128 -18
- 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,61 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
safeParseGristTableData,
|
|
5
|
+
useGrist,
|
|
6
|
+
useWidgetMetadata,
|
|
7
|
+
type UseGristOptions,
|
|
8
|
+
} from "grist-widget-sdk"
|
|
9
|
+
|
|
10
|
+
type TaskRow = { Title: string; Due: Date | null }
|
|
11
|
+
|
|
12
|
+
export const GRIST_OPTIONS: UseGristOptions = {
|
|
13
|
+
requiredAccess: "read table",
|
|
14
|
+
suppressAlerts: ["section-not-linked"],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const WIDGET_METADATA = {
|
|
18
|
+
title: "Safe parse",
|
|
19
|
+
description: "safeParseGristTableData with per-cell issues.",
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
export function WidgetApp() {
|
|
23
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
24
|
+
const { isReady, currentTableId, fetchTable } = useGrist()
|
|
25
|
+
const [rows, setRows] = useState<TaskRow[]>([])
|
|
26
|
+
const [issues, setIssues] = useState<unknown>(null)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!isReady) return
|
|
30
|
+
let cancelled = false
|
|
31
|
+
void (async () => {
|
|
32
|
+
const tableId = currentTableId
|
|
33
|
+
if (!tableId) return
|
|
34
|
+
const columnar = await fetchTable(tableId)
|
|
35
|
+
const parsed = safeParseGristTableData<TaskRow>(columnar, {
|
|
36
|
+
columns: {
|
|
37
|
+
Title: { type: "Text" },
|
|
38
|
+
Due: { type: "Date" },
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
if (!cancelled) {
|
|
42
|
+
setRows(parsed.rows)
|
|
43
|
+
setIssues(parsed.cellIssues)
|
|
44
|
+
}
|
|
45
|
+
})()
|
|
46
|
+
return () => {
|
|
47
|
+
cancelled = true
|
|
48
|
+
}
|
|
49
|
+
}, [isReady, currentTableId, fetchTable])
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="grid gap-2 p-4 text-sm">
|
|
53
|
+
<p>{rows.length} parsed row(s)</p>
|
|
54
|
+
{issues ? (
|
|
55
|
+
<pre className="overflow-auto text-xs">{JSON.stringify(issues, null, 2)}</pre>
|
|
56
|
+
) : (
|
|
57
|
+
<p className="text-muted-foreground">No cell issues.</p>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
gristAddVisibleColumnAction,
|
|
3
|
+
gristRemoveColumnAction,
|
|
4
|
+
gristRenameColumnAction,
|
|
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
|
+
}
|
|
15
|
+
|
|
16
|
+
export const WIDGET_METADATA = {
|
|
17
|
+
title: "Schema migration",
|
|
18
|
+
description:
|
|
19
|
+
"applyActions with gristAddVisibleColumnAction, gristRenameColumnAction, and gristRemoveColumnAction.",
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
export function WidgetApp() {
|
|
23
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
24
|
+
const w = useGrist()
|
|
25
|
+
|
|
26
|
+
async function runMigration() {
|
|
27
|
+
const tableId = w.currentTableId
|
|
28
|
+
if (!tableId) return
|
|
29
|
+
await w.applyActions(
|
|
30
|
+
[
|
|
31
|
+
gristAddVisibleColumnAction(tableId, "Priority", { type: "Choice" }),
|
|
32
|
+
gristAddVisibleColumnAction(tableId, "DueAt", { type: "Date" }),
|
|
33
|
+
gristRenameColumnAction(tableId, "DueAt", "DueDate"),
|
|
34
|
+
],
|
|
35
|
+
{ desc: "Add Priority + DueAt; rename DueAt → DueDate" },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function rollback() {
|
|
40
|
+
const tableId = w.currentTableId
|
|
41
|
+
if (!tableId) return
|
|
42
|
+
await w.applyActions(
|
|
43
|
+
[
|
|
44
|
+
gristRemoveColumnAction(tableId, "Priority"),
|
|
45
|
+
gristRemoveColumnAction(tableId, "DueDate"),
|
|
46
|
+
],
|
|
47
|
+
{ desc: "Remove Priority + DueDate" },
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex gap-2 p-4">
|
|
53
|
+
<Button type="button" onClick={() => void runMigration()}>
|
|
54
|
+
Run migration
|
|
55
|
+
</Button>
|
|
56
|
+
<Button type="button" variant="destructive" onClick={() => void rollback()}>
|
|
57
|
+
Rollback
|
|
58
|
+
</Button>
|
|
59
|
+
{w.actionStatus === "running" ? (
|
|
60
|
+
<p className="mt-2 text-xs text-muted-foreground">Applying…</p>
|
|
61
|
+
) : null}
|
|
62
|
+
{w.actionError ? (
|
|
63
|
+
<p className="mt-2 text-xs text-destructive">{w.actionError}</p>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useGristSchema, useWidgetMetadata, type UseGristOptions } from "grist-widget-sdk"
|
|
2
|
+
|
|
3
|
+
export const GRIST_OPTIONS: UseGristOptions = {
|
|
4
|
+
requiredAccess: "read table",
|
|
5
|
+
suppressAlerts: ["section-not-linked"],
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const WIDGET_METADATA = {
|
|
9
|
+
title: "Schema preview",
|
|
10
|
+
description: "useGristSchema with replicaRowMode schema+samples for LLM context.",
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
export function WidgetApp() {
|
|
14
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
15
|
+
const schema = useGristSchema({ replicaRowMode: "schema+samples" })
|
|
16
|
+
|
|
17
|
+
if (!schema.replicaDocument) {
|
|
18
|
+
return <p className="p-4 text-sm text-muted-foreground">Loading schema…</p>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<pre className="max-h-svh overflow-auto p-4 text-xs">
|
|
23
|
+
{JSON.stringify(schema.replicaDocument, null, 2)}
|
|
24
|
+
</pre>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useGristSelection,
|
|
3
|
+
useGristStatus,
|
|
4
|
+
useGristTheme,
|
|
5
|
+
useGristWrites,
|
|
6
|
+
useWidgetMetadata,
|
|
7
|
+
type UseGristOptions,
|
|
8
|
+
} from "grist-widget-sdk"
|
|
9
|
+
|
|
10
|
+
export const GRIST_OPTIONS: UseGristOptions = {
|
|
11
|
+
requiredAccess: "full",
|
|
12
|
+
columns: [{ name: "Title", type: "Text" }],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const WIDGET_METADATA = {
|
|
16
|
+
title: "Slice hooks",
|
|
17
|
+
description:
|
|
18
|
+
"useGristStatus, useGristSelection, useGristWrites, useGristTheme in separate child components.",
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
function Header() {
|
|
22
|
+
const { status } = useGristStatus()
|
|
23
|
+
return <h1 className="text-sm font-medium">status: {status}</h1>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Editor() {
|
|
27
|
+
const { record } = useGristSelection()
|
|
28
|
+
useGristWrites()
|
|
29
|
+
return <p className="text-sm">Editing #{String(record?.id ?? "none")}</p>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ThemeToggle() {
|
|
33
|
+
const { theme } = useGristTheme()
|
|
34
|
+
return <span className="text-xs text-muted-foreground">{theme ?? "no theme"}</span>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function WidgetApp() {
|
|
38
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex flex-col gap-2 p-4">
|
|
42
|
+
<Header />
|
|
43
|
+
<Editor />
|
|
44
|
+
<ThemeToggle />
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
safeParseRowsToDisplayRows,
|
|
5
|
+
useGristContext,
|
|
6
|
+
type UseGristSchemaResult,
|
|
7
|
+
} from "grist-widget-sdk"
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
encodeRowFields,
|
|
11
|
+
gristColumnToRdgColumn,
|
|
12
|
+
writableColIds,
|
|
13
|
+
type GristRdgRow,
|
|
14
|
+
} from "./grist-rdg-columns"
|
|
15
|
+
import { GristRdgGrid, type GristGridOperation } from "./GristRdgGrid"
|
|
16
|
+
|
|
17
|
+
type SafeCellMetaLite = {
|
|
18
|
+
rowIndex: number
|
|
19
|
+
colId: string
|
|
20
|
+
issues: Array<{ code: string }>
|
|
21
|
+
}
|
|
22
|
+
type SafeCellMetaByKeyLite = Record<string, SafeCellMetaLite>
|
|
23
|
+
|
|
24
|
+
export type GristDataSheetGridProps = {
|
|
25
|
+
/**
|
|
26
|
+
* Pre-fetched schema shared with other SDK consumers (e.g. toolbar), so
|
|
27
|
+
* grid rendering and fetchTableRows safe-parse use the same column metadata.
|
|
28
|
+
*/
|
|
29
|
+
schema: UseGristSchemaResult
|
|
30
|
+
/** RDG `height` — defaults to 500. */
|
|
31
|
+
height?: number
|
|
32
|
+
/** RDG `rowHeight` — defaults to 32. */
|
|
33
|
+
rowHeight?: number
|
|
34
|
+
/** RDG `autoAddRow`. Pressing Enter on the last row appends a new row. */
|
|
35
|
+
autoAddRow?: boolean
|
|
36
|
+
/** RDG `lockRows`. Hides "+ Add row" and disables row delete. */
|
|
37
|
+
lockRows?: boolean
|
|
38
|
+
/** Forwarded to `<ShadcnDataSheetGrid className=…>` for layout overrides. */
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The selected Grist section as a fully-synced spreadsheet.
|
|
44
|
+
*
|
|
45
|
+
* ## Why we don't read `useGrist().records`
|
|
46
|
+
*
|
|
47
|
+
* The SDK's `records` channel is wired to `grist.onRecords`, which under the
|
|
48
|
+
* hood calls `viewApi.fetchSelectedTable`. In a real Grist Custom Widget
|
|
49
|
+
* section, that endpoint returns **only the columns currently visible in the
|
|
50
|
+
* section's view fields**. Sections start empty by default, so `records` rows
|
|
51
|
+
* arrive with just `{id}` and our cells render blank.
|
|
52
|
+
*
|
|
53
|
+
* Instead we fetch table rows via `w.fetchTableRows(tableId, ...)`, which
|
|
54
|
+
* always returns every column the table actually has — matching what
|
|
55
|
+
* `useGristSchema()` reports. The call enables `safeParse` so schema-aware
|
|
56
|
+
* decoding, validation, and issue reporting are centralized in SDK helpers.
|
|
57
|
+
*
|
|
58
|
+
* ## Refresh triggers
|
|
59
|
+
*
|
|
60
|
+
* 1. `tableId` change (initial mount + section switch).
|
|
61
|
+
* 2. `useGrist().records` arriving (used purely as a "section data changed"
|
|
62
|
+
* pulse — we ignore the row payload itself, but the SDK delivers this
|
|
63
|
+
* signal whenever the host posts `dataChange: true`, including for cell
|
|
64
|
+
* edits, row inserts/removes, and undo/redo).
|
|
65
|
+
* 3. After our own writes settle (`actionStatus` returns to `"idle"`).
|
|
66
|
+
* 4. Periodic 5 s backstop in case the host swallows a notification (this is
|
|
67
|
+
* cheap — `fetchTable` is a single round trip).
|
|
68
|
+
*
|
|
69
|
+
* ## Single source of truth
|
|
70
|
+
*
|
|
71
|
+
* `value` is read directly from the fetched rows state — no local copy of
|
|
72
|
+
* unsaved edits. RDG holds the user's in-progress edit internally; once we
|
|
73
|
+
* apply it via `table.update`, Grist round-trips and our next refresh shows
|
|
74
|
+
* the canonical value. This avoids the merge logic of optimistic updates
|
|
75
|
+
* while still feeling instant for typical typing latency.
|
|
76
|
+
*/
|
|
77
|
+
export function GristDataSheetGrid({
|
|
78
|
+
schema,
|
|
79
|
+
height,
|
|
80
|
+
rowHeight = 32,
|
|
81
|
+
autoAddRow = false,
|
|
82
|
+
lockRows = false,
|
|
83
|
+
className,
|
|
84
|
+
}: GristDataSheetGridProps) {
|
|
85
|
+
const w = useGristContext()
|
|
86
|
+
const tableId = w.currentTableId
|
|
87
|
+
const selectedRowId = typeof w.record?.id === "number" ? w.record.id : null
|
|
88
|
+
const records = w.records
|
|
89
|
+
const tableIdRef = useRef(tableId)
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
tableIdRef.current = tableId
|
|
92
|
+
}, [tableId])
|
|
93
|
+
|
|
94
|
+
const schemaColumns = useMemo(
|
|
95
|
+
() => schema.table?.columns ?? {},
|
|
96
|
+
[schema.table]
|
|
97
|
+
)
|
|
98
|
+
// ---- Data: fetchTableRows(tableId) with safeParse --------------------------
|
|
99
|
+
|
|
100
|
+
const fetchTableRows = w.fetchTableRows
|
|
101
|
+
const [rowsState, setRowsState] = useState<{
|
|
102
|
+
tableId: string
|
|
103
|
+
rows: GristRdgRow[]
|
|
104
|
+
} | null>(null)
|
|
105
|
+
const [numericFormatIssueKeys, setNumericFormatIssueKeys] = useState<
|
|
106
|
+
Set<string>
|
|
107
|
+
>(() => new Set())
|
|
108
|
+
|
|
109
|
+
// Latest schema columns, accessible from inside the async fetch without
|
|
110
|
+
// adding it to the effect deps (we don't want to refetch just because
|
|
111
|
+
// schema metadata changed — we'll re-decode the existing in-memory data via
|
|
112
|
+
// the row mapping below).
|
|
113
|
+
const schemaColumnsRef = useRef(schemaColumns)
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
schemaColumnsRef.current = schemaColumns
|
|
116
|
+
}, [schemaColumns])
|
|
117
|
+
|
|
118
|
+
const refresh = useCallback(
|
|
119
|
+
async (forTableId: string, signal: { cancelled: boolean }) => {
|
|
120
|
+
try {
|
|
121
|
+
let lastCellMetaByKey: SafeCellMetaByKeyLite = {}
|
|
122
|
+
const fetchOptions = {
|
|
123
|
+
columns: schemaColumnsRef.current,
|
|
124
|
+
order: "grist" as const,
|
|
125
|
+
safeParse: {
|
|
126
|
+
refMode: "detailed" as const,
|
|
127
|
+
},
|
|
128
|
+
onSafeParseCellMetaByKey: (metaByKey: SafeCellMetaByKeyLite) => {
|
|
129
|
+
lastCellMetaByKey = metaByKey
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
const rowsRaw = await fetchTableRows<Record<string, unknown>>(
|
|
133
|
+
forTableId,
|
|
134
|
+
fetchOptions
|
|
135
|
+
)
|
|
136
|
+
const rows = safeParseRowsToDisplayRows(rowsRaw) as GristRdgRow[]
|
|
137
|
+
const numericKeys = new Set<string>()
|
|
138
|
+
for (const [cellKey, meta] of Object.entries(lastCellMetaByKey)) {
|
|
139
|
+
const hasFormatIssue = meta.issues.some(
|
|
140
|
+
(issue) =>
|
|
141
|
+
issue.code === "type_mismatch" || issue.code === "invalid_format"
|
|
142
|
+
)
|
|
143
|
+
if (!hasFormatIssue) continue
|
|
144
|
+
const colType = schemaColumnsRef.current[meta.colId]?.type
|
|
145
|
+
if (colType !== "Int" && colType !== "Numeric") continue
|
|
146
|
+
numericKeys.add(cellKey)
|
|
147
|
+
}
|
|
148
|
+
if (signal.cancelled || forTableId !== tableIdRef.current) return
|
|
149
|
+
setRowsState({ tableId: forTableId, rows })
|
|
150
|
+
setNumericFormatIssueKeys(numericKeys)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (signal.cancelled) return
|
|
153
|
+
setNumericFormatIssueKeys(new Set())
|
|
154
|
+
void err
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
[fetchTableRows]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// 1) Refetch when `tableId` changes + 5s periodic backstop.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!tableId) return
|
|
163
|
+
const signal = { cancelled: false }
|
|
164
|
+
void refresh(tableId, signal)
|
|
165
|
+
const id = window.setInterval(() => {
|
|
166
|
+
void refresh(tableId, signal)
|
|
167
|
+
}, 5000)
|
|
168
|
+
return () => {
|
|
169
|
+
signal.cancelled = true
|
|
170
|
+
window.clearInterval(id)
|
|
171
|
+
}
|
|
172
|
+
}, [tableId, refresh])
|
|
173
|
+
|
|
174
|
+
// 2) Refetch on every `records` pulse from the SDK. We don't read the
|
|
175
|
+
// payload — we just react to the host telling us "the section's data
|
|
176
|
+
// changed". This catches cell edits, inserts, deletes, undo/redo
|
|
177
|
+
// initiated outside the widget.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!tableId) return
|
|
180
|
+
if (records === null) return
|
|
181
|
+
const signal = { cancelled: false }
|
|
182
|
+
void refresh(tableId, signal)
|
|
183
|
+
return () => {
|
|
184
|
+
signal.cancelled = true
|
|
185
|
+
}
|
|
186
|
+
}, [tableId, records, refresh])
|
|
187
|
+
|
|
188
|
+
// 3) Refetch after our own writes settle, so the canonical row from Grist
|
|
189
|
+
// replaces RDG's optimistic in-progress state quickly.
|
|
190
|
+
const actionStatus = w.actionStatus
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!tableId) return
|
|
193
|
+
if (actionStatus !== "idle") return
|
|
194
|
+
const signal = { cancelled: false }
|
|
195
|
+
void refresh(tableId, signal)
|
|
196
|
+
return () => {
|
|
197
|
+
signal.cancelled = true
|
|
198
|
+
}
|
|
199
|
+
}, [tableId, actionStatus, refresh])
|
|
200
|
+
|
|
201
|
+
// 4) Re-decode the in-memory rows when schema metadata content changes
|
|
202
|
+
// (e.g. column type changed via the toolbar). We use the *raw* fetched
|
|
203
|
+
// data we kept around, but since `rowsState` only stores decoded rows,
|
|
204
|
+
// we just trigger a refetch — cheap.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!tableId) return
|
|
207
|
+
const signal = { cancelled: false }
|
|
208
|
+
void refresh(tableId, signal)
|
|
209
|
+
return () => {
|
|
210
|
+
signal.cancelled = true
|
|
211
|
+
}
|
|
212
|
+
}, [tableId, schemaColumns, refresh])
|
|
213
|
+
|
|
214
|
+
// ---- Derive `value` and `columns` for RDG --------------------------------
|
|
215
|
+
|
|
216
|
+
const rows = useMemo<GristRdgRow[]>(
|
|
217
|
+
() =>
|
|
218
|
+
rowsState && tableId && rowsState.tableId === tableId
|
|
219
|
+
? rowsState.rows
|
|
220
|
+
: [],
|
|
221
|
+
[rowsState, tableId]
|
|
222
|
+
)
|
|
223
|
+
// Keep a ref to the rows we last passed to RDG so we can resolve `DELETE`
|
|
224
|
+
// operations (whose indices refer to the *old* value, per RDG's contract).
|
|
225
|
+
const prevRowsRef = useRef<GristRdgRow[]>(rows)
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
prevRowsRef.current = rows
|
|
228
|
+
}, [rows])
|
|
229
|
+
|
|
230
|
+
// Visible column ids: schema is the single source. We do NOT fall back to
|
|
231
|
+
// `Object.keys(rows[0])` because `fetchTable` returns *every* column
|
|
232
|
+
// (including system columns) and we want the schema's filtered ordering.
|
|
233
|
+
const visibleColIds = useMemo<string[]>(
|
|
234
|
+
() =>
|
|
235
|
+
Object.keys(schemaColumns).filter(
|
|
236
|
+
(colId) => !colId.startsWith("gristHelper_")
|
|
237
|
+
),
|
|
238
|
+
[schemaColumns]
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const columns = useMemo(
|
|
242
|
+
() =>
|
|
243
|
+
visibleColIds.map((colId) => {
|
|
244
|
+
const col = gristColumnToRdgColumn(colId, schemaColumns[colId])
|
|
245
|
+
return {
|
|
246
|
+
id: colId,
|
|
247
|
+
column: col,
|
|
248
|
+
minWidth: colId === "id" ? 70 : 120,
|
|
249
|
+
}
|
|
250
|
+
}),
|
|
251
|
+
[visibleColIds, schemaColumns]
|
|
252
|
+
)
|
|
253
|
+
const effectiveRowHeight = useMemo(() => {
|
|
254
|
+
const hasChoiceList = visibleColIds.some(
|
|
255
|
+
(colId) => schemaColumns[colId]?.type === "ChoiceList"
|
|
256
|
+
)
|
|
257
|
+
return hasChoiceList ? Math.max(rowHeight, 52) : rowHeight
|
|
258
|
+
}, [rowHeight, schemaColumns, visibleColIds])
|
|
259
|
+
|
|
260
|
+
const writable = useMemo(
|
|
261
|
+
() => writableColIds(visibleColIds, schemaColumns),
|
|
262
|
+
[visibleColIds, schemaColumns]
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// ---- Schema reload triggers ----------------------------------------------
|
|
266
|
+
//
|
|
267
|
+
// Conservative — we never re-create the `columns` prop while the user is
|
|
268
|
+
// editing on a row-only change:
|
|
269
|
+
// 1. Selected table changes (initial mount + section switch).
|
|
270
|
+
// 2. 5s backstop poll for external column add/remove/rename/retype.
|
|
271
|
+
// 3. Toolbar mutations call `schema.reload()` directly.
|
|
272
|
+
// 4. Manual "Reload" button in the toolbar.
|
|
273
|
+
const reloadRef = useRef(schema.reload)
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
reloadRef.current = schema.reload
|
|
276
|
+
}, [schema.reload])
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!tableId) return
|
|
280
|
+
const id = window.setInterval(() => void reloadRef.current(), 5000)
|
|
281
|
+
return () => window.clearInterval(id)
|
|
282
|
+
}, [tableId])
|
|
283
|
+
|
|
284
|
+
// ---- Write-back: RDG onChange → Grist table ops --------------------------
|
|
285
|
+
|
|
286
|
+
const handleChange = useCallback(
|
|
287
|
+
(nextRows: GristRdgRow[], operations: GristGridOperation[]) => {
|
|
288
|
+
if (!tableId) return
|
|
289
|
+
const ops = w.table
|
|
290
|
+
const oldRows = prevRowsRef.current
|
|
291
|
+
|
|
292
|
+
const creates: Array<{ fields: Record<string, unknown> }> = []
|
|
293
|
+
const updates: Array<{ id: number; fields: Record<string, unknown> }> = []
|
|
294
|
+
const deletes: number[] = []
|
|
295
|
+
|
|
296
|
+
for (const op of operations) {
|
|
297
|
+
if (op.type === "CREATE") {
|
|
298
|
+
for (let i = op.fromRowIndex; i < op.toRowIndex; i++) {
|
|
299
|
+
const row = nextRows[i]
|
|
300
|
+
if (!row) continue
|
|
301
|
+
const fields = encodeRowFields(row, schemaColumns, writable)
|
|
302
|
+
creates.push({ fields })
|
|
303
|
+
}
|
|
304
|
+
} else if (op.type === "UPDATE") {
|
|
305
|
+
for (let i = op.fromRowIndex; i < op.toRowIndex; i++) {
|
|
306
|
+
const row = nextRows[i]
|
|
307
|
+
// The optimistic row created by RDG (before our `table.create`
|
|
308
|
+
// round-trips) has no id yet; skip it — its UPDATE will arrive
|
|
309
|
+
// again once the canonical row from Grist replaces it.
|
|
310
|
+
if (typeof row?.id !== "number") continue
|
|
311
|
+
const fields = encodeRowFields(row, schemaColumns, writable)
|
|
312
|
+
updates.push({ id: row.id, fields })
|
|
313
|
+
}
|
|
314
|
+
} else if (op.type === "DELETE") {
|
|
315
|
+
for (let i = op.fromRowIndex; i < op.toRowIndex; i++) {
|
|
316
|
+
const row = oldRows[i]
|
|
317
|
+
if (typeof row?.id === "number") deletes.push(row.id)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Sequence: deletes → updates → creates. Errors are tracked by the SDK
|
|
323
|
+
// and surfaced through `<GristSdkAlerts>`.
|
|
324
|
+
void (async () => {
|
|
325
|
+
try {
|
|
326
|
+
if (deletes.length > 0) await ops.destroy(deletes)
|
|
327
|
+
if (updates.length > 0) await ops.update(updates)
|
|
328
|
+
if (creates.length > 0) await ops.create(creates)
|
|
329
|
+
} catch (err) {
|
|
330
|
+
void err
|
|
331
|
+
}
|
|
332
|
+
})()
|
|
333
|
+
},
|
|
334
|
+
[tableId, w.table, schemaColumns, writable]
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const handleActiveRowIdChange = useCallback(
|
|
338
|
+
(rowId: number) => {
|
|
339
|
+
void (async () => {
|
|
340
|
+
try {
|
|
341
|
+
await w.setCursorPosition({ rowId })
|
|
342
|
+
} catch {
|
|
343
|
+
// Some Grist hosts block cursor moves in specific linking setups;
|
|
344
|
+
// fallback to linked-row selection when available.
|
|
345
|
+
await w.setLinkedRowSelection([rowId])
|
|
346
|
+
}
|
|
347
|
+
})()
|
|
348
|
+
},
|
|
349
|
+
[w]
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if (!tableId) {
|
|
353
|
+
return (
|
|
354
|
+
<p className="text-sm text-muted-foreground">
|
|
355
|
+
Waiting for the selected section…
|
|
356
|
+
</p>
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<GristRdgGrid<GristRdgRow>
|
|
362
|
+
rows={rows}
|
|
363
|
+
columns={columns}
|
|
364
|
+
onChange={handleChange}
|
|
365
|
+
onActiveRowIdChange={handleActiveRowIdChange}
|
|
366
|
+
selectedRowId={selectedRowId}
|
|
367
|
+
invalidCellKeys={numericFormatIssueKeys}
|
|
368
|
+
height={height}
|
|
369
|
+
rowHeight={effectiveRowHeight}
|
|
370
|
+
autoAddRow={autoAddRow}
|
|
371
|
+
lockRows={lockRows}
|
|
372
|
+
className={className}
|
|
373
|
+
footer={`Selected Grist row: ${selectedRowId ?? "none"}`}
|
|
374
|
+
/>
|
|
375
|
+
)
|
|
376
|
+
}
|