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,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
+ }