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,189 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import type { Column } from "react-datasheet-grid"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ShadcnDataSheetGrid,
|
|
6
|
+
type DataSheetGridProps,
|
|
7
|
+
} from "@/components/ui/ShadcnDataSheetGrid"
|
|
8
|
+
|
|
9
|
+
type RowWithOptionalId = { id?: number } & Record<string, unknown>
|
|
10
|
+
|
|
11
|
+
export type GristGridOperation = {
|
|
12
|
+
type: "UPDATE" | "DELETE" | "CREATE"
|
|
13
|
+
fromRowIndex: number
|
|
14
|
+
toRowIndex: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type GristRdgGridColumn<RowT extends RowWithOptionalId> = {
|
|
18
|
+
id: string
|
|
19
|
+
column: Partial<Column<RowT, unknown, unknown>>
|
|
20
|
+
minWidth?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type GristRdgGridProps<RowT extends RowWithOptionalId> = {
|
|
24
|
+
rows: RowT[]
|
|
25
|
+
columns: GristRdgGridColumn<RowT>[]
|
|
26
|
+
onChange: NonNullable<DataSheetGridProps<RowT>["onChange"]>
|
|
27
|
+
onActiveRowIdChange?: (rowId: number) => void
|
|
28
|
+
selectedRowId?: number | null
|
|
29
|
+
invalidCellKeys?: ReadonlySet<string>
|
|
30
|
+
height?: number
|
|
31
|
+
rowHeight?: number
|
|
32
|
+
autoAddRow?: boolean
|
|
33
|
+
lockRows?: boolean
|
|
34
|
+
className?: string
|
|
35
|
+
footer?: React.ReactNode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function GristRdgGrid<RowT extends RowWithOptionalId>({
|
|
39
|
+
rows,
|
|
40
|
+
columns,
|
|
41
|
+
onChange,
|
|
42
|
+
onActiveRowIdChange,
|
|
43
|
+
selectedRowId,
|
|
44
|
+
invalidCellKeys,
|
|
45
|
+
height,
|
|
46
|
+
rowHeight = 32,
|
|
47
|
+
autoAddRow = false,
|
|
48
|
+
lockRows = false,
|
|
49
|
+
className,
|
|
50
|
+
footer,
|
|
51
|
+
}: GristRdgGridProps<RowT>) {
|
|
52
|
+
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
|
|
53
|
+
const resizeStateRef = useRef<{
|
|
54
|
+
colId: string
|
|
55
|
+
startX: number
|
|
56
|
+
startWidth: number
|
|
57
|
+
} | null>(null)
|
|
58
|
+
|
|
59
|
+
const startColumnResize = useCallback(
|
|
60
|
+
(e: React.PointerEvent, colId: string, minWidth: number) => {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
e.stopPropagation()
|
|
63
|
+
const current = columnWidths[colId]
|
|
64
|
+
const startWidth = typeof current === "number" ? current : minWidth
|
|
65
|
+
resizeStateRef.current = {
|
|
66
|
+
colId,
|
|
67
|
+
startX: e.clientX,
|
|
68
|
+
startWidth,
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
[columnWidths]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const onMove = (e: PointerEvent) => {
|
|
76
|
+
const state = resizeStateRef.current
|
|
77
|
+
if (!state) return
|
|
78
|
+
const next = Math.max(70, state.startWidth + (e.clientX - state.startX))
|
|
79
|
+
setColumnWidths((prev) => ({ ...prev, [state.colId]: next }))
|
|
80
|
+
}
|
|
81
|
+
const onUp = () => {
|
|
82
|
+
resizeStateRef.current = null
|
|
83
|
+
}
|
|
84
|
+
window.addEventListener("pointermove", onMove)
|
|
85
|
+
window.addEventListener("pointerup", onUp)
|
|
86
|
+
return () => {
|
|
87
|
+
window.removeEventListener("pointermove", onMove)
|
|
88
|
+
window.removeEventListener("pointerup", onUp)
|
|
89
|
+
}
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
const resolvedColumns = useMemo<Partial<Column<RowT, unknown, unknown>>[]>(
|
|
93
|
+
() =>
|
|
94
|
+
columns.map(({ id: colId, column, minWidth: explicitMinWidth }) => {
|
|
95
|
+
const minWidth = explicitMinWidth ?? (colId === "id" ? 70 : 120)
|
|
96
|
+
const hasCustomWidth = typeof columnWidths[colId] === "number"
|
|
97
|
+
const basis = hasCustomWidth
|
|
98
|
+
? (columnWidths[colId] as number)
|
|
99
|
+
: minWidth
|
|
100
|
+
const existingCellClassName = column.cellClassName
|
|
101
|
+
return {
|
|
102
|
+
...column,
|
|
103
|
+
cellClassName: (opt: {
|
|
104
|
+
rowData: RowT
|
|
105
|
+
rowIndex: number
|
|
106
|
+
columnId?: string
|
|
107
|
+
}) => {
|
|
108
|
+
const baseClass =
|
|
109
|
+
typeof existingCellClassName === "function"
|
|
110
|
+
? (
|
|
111
|
+
existingCellClassName as (
|
|
112
|
+
arg: typeof opt
|
|
113
|
+
) => string | undefined
|
|
114
|
+
)(opt)
|
|
115
|
+
: (existingCellClassName ?? "")
|
|
116
|
+
const isInvalid =
|
|
117
|
+
typeof opt.rowData?.id === "number" &&
|
|
118
|
+
Boolean(invalidCellKeys?.has(`${opt.rowData.id}:${colId}`))
|
|
119
|
+
if (!isInvalid) return baseClass
|
|
120
|
+
return baseClass
|
|
121
|
+
? `${baseClass} grist-invalid-number-cell`
|
|
122
|
+
: "grist-invalid-number-cell"
|
|
123
|
+
},
|
|
124
|
+
minWidth,
|
|
125
|
+
basis,
|
|
126
|
+
grow: hasCustomWidth ? 0 : 1,
|
|
127
|
+
shrink: hasCustomWidth ? 0 : 1,
|
|
128
|
+
title: (
|
|
129
|
+
<div className="relative flex h-full w-full items-center pr-3">
|
|
130
|
+
<span className="block w-full truncate pr-2">
|
|
131
|
+
{String(column.title ?? colId)}
|
|
132
|
+
</span>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
aria-label={`Resize ${colId}`}
|
|
136
|
+
className="absolute inset-y-0 right-0 w-2 cursor-col-resize bg-border/40 hover:bg-border"
|
|
137
|
+
onPointerDown={(e) => startColumnResize(e, colId, minWidth)}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
),
|
|
141
|
+
} as Partial<Column<RowT, unknown, unknown>>
|
|
142
|
+
}),
|
|
143
|
+
[columns, columnWidths, invalidCellKeys, startColumnResize]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const handleActiveCellChange = useCallback(
|
|
147
|
+
(opts: unknown) => {
|
|
148
|
+
if (!onActiveRowIdChange) return
|
|
149
|
+
const cell =
|
|
150
|
+
typeof opts === "object" && opts !== null && "cell" in opts
|
|
151
|
+
? (opts as { cell?: { rowId?: unknown; row?: unknown } | null }).cell
|
|
152
|
+
: (opts as { rowId?: unknown; row?: unknown } | null)
|
|
153
|
+
if (!cell) return
|
|
154
|
+
let rowId: number | null = null
|
|
155
|
+
if (typeof cell.rowId === "number") {
|
|
156
|
+
rowId = cell.rowId
|
|
157
|
+
} else if (typeof cell.row === "number") {
|
|
158
|
+
const row = rows[cell.row]
|
|
159
|
+
rowId = typeof row?.id === "number" ? row.id : null
|
|
160
|
+
}
|
|
161
|
+
if (rowId === null || rowId === selectedRowId) return
|
|
162
|
+
onActiveRowIdChange(rowId)
|
|
163
|
+
},
|
|
164
|
+
[onActiveRowIdChange, rows, selectedRowId]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex h-full min-h-0 flex-col gap-2">
|
|
169
|
+
<style>{`.grist-invalid-number-cell{background:rgba(239,68,68,0.12)!important;}`}</style>
|
|
170
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
171
|
+
<ShadcnDataSheetGrid<RowT>
|
|
172
|
+
value={rows}
|
|
173
|
+
columns={resolvedColumns}
|
|
174
|
+
onChange={onChange}
|
|
175
|
+
onActiveCellChange={handleActiveCellChange as never}
|
|
176
|
+
rowKey="id"
|
|
177
|
+
height={height}
|
|
178
|
+
rowHeight={rowHeight}
|
|
179
|
+
autoAddRow={autoAddRow}
|
|
180
|
+
lockRows={lockRows}
|
|
181
|
+
className={className}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
{footer ? (
|
|
185
|
+
<p className="shrink-0 text-xs text-muted-foreground">{footer}</p>
|
|
186
|
+
) : null}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { Plus, RefreshCw, X } from "lucide-react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
gristModifyColumnAction,
|
|
6
|
+
gristRemoveColumnAction,
|
|
7
|
+
gristRenameColumnAction,
|
|
8
|
+
useGristContext,
|
|
9
|
+
useGristSchema,
|
|
10
|
+
type GristUserActionTuple,
|
|
11
|
+
type UseGristSchemaResult,
|
|
12
|
+
} from "grist-widget-sdk"
|
|
13
|
+
|
|
14
|
+
import { Button } from "@/components/ui/button"
|
|
15
|
+
import { Input } from "@/components/ui/input"
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
GRIST_TOOLBAR_COLUMN_TYPES,
|
|
19
|
+
type GristToolbarColumnType,
|
|
20
|
+
} from "./grist-rdg-columns"
|
|
21
|
+
|
|
22
|
+
export type GristSchemaToolbarProps = {
|
|
23
|
+
/**
|
|
24
|
+
* Optional pre-fetched schema. Shared with `<GristDataSheetGrid>` so we
|
|
25
|
+
* don't issue duplicate `_grist_Tables_column` reads.
|
|
26
|
+
*/
|
|
27
|
+
schema?: UseGristSchemaResult
|
|
28
|
+
/** Per-action toggles. All enabled by default. */
|
|
29
|
+
enable?: {
|
|
30
|
+
add?: boolean
|
|
31
|
+
rename?: boolean
|
|
32
|
+
retype?: boolean
|
|
33
|
+
remove?: boolean
|
|
34
|
+
}
|
|
35
|
+
className?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Renders inline controls for column add / rename / type-change / remove
|
|
40
|
+
* against the currently-selected Grist table.
|
|
41
|
+
*
|
|
42
|
+
* Writes use `useGrist().applyActions(...)` with the SDK's exported action
|
|
43
|
+
* builders. Schema is reloaded after each mutation so the chip list reflects
|
|
44
|
+
* the new state immediately.
|
|
45
|
+
*/
|
|
46
|
+
export function GristSchemaToolbar({
|
|
47
|
+
schema: schemaProp,
|
|
48
|
+
enable,
|
|
49
|
+
className,
|
|
50
|
+
}: GristSchemaToolbarProps) {
|
|
51
|
+
const w = useGristContext()
|
|
52
|
+
const internalSchema = useGristSchema({
|
|
53
|
+
tableId: schemaProp ? undefined : (w.currentTableId ?? undefined),
|
|
54
|
+
requiredAccess: "full",
|
|
55
|
+
})
|
|
56
|
+
const schema = schemaProp ?? internalSchema
|
|
57
|
+
|
|
58
|
+
const [adding, setAdding] = useState(false)
|
|
59
|
+
const [newColId, setNewColId] = useState("")
|
|
60
|
+
const [newType, setNewType] = useState<GristToolbarColumnType>("Text")
|
|
61
|
+
|
|
62
|
+
const tableId = w.currentTableId
|
|
63
|
+
const can = {
|
|
64
|
+
add: enable?.add ?? true,
|
|
65
|
+
rename: enable?.rename ?? true,
|
|
66
|
+
retype: enable?.retype ?? true,
|
|
67
|
+
remove: enable?.remove ?? true,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!tableId) return null
|
|
71
|
+
if (!schema.table) {
|
|
72
|
+
return (
|
|
73
|
+
<p className={className ?? "text-xs text-muted-foreground"}>
|
|
74
|
+
Loading schema…
|
|
75
|
+
</p>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const cols = Object.entries(schema.table.columns ?? {})
|
|
80
|
+
|
|
81
|
+
const handleAdd = async () => {
|
|
82
|
+
const id = newColId.trim()
|
|
83
|
+
if (!id) return
|
|
84
|
+
try {
|
|
85
|
+
// `AddVisibleColumn` (instead of plain `AddColumn`) also adds the new
|
|
86
|
+
// column to the *active* section's view fields, so Grist users see it
|
|
87
|
+
// straight away in the table view they're working with. There's no
|
|
88
|
+
// dedicated builder for this in the SDK; we hand-write the tuple.
|
|
89
|
+
const action: GristUserActionTuple = [
|
|
90
|
+
"AddVisibleColumn",
|
|
91
|
+
tableId,
|
|
92
|
+
id,
|
|
93
|
+
{ type: newType, label: id },
|
|
94
|
+
]
|
|
95
|
+
await w.applyActions([action])
|
|
96
|
+
await schema.reload()
|
|
97
|
+
setAdding(false)
|
|
98
|
+
setNewColId("")
|
|
99
|
+
setNewType("Text")
|
|
100
|
+
} catch {
|
|
101
|
+
// Surfaced via <GristSdkAlerts>.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const handleRename = async (oldId: string, rawNewId: string) => {
|
|
106
|
+
const newId = rawNewId.trim()
|
|
107
|
+
if (!newId || newId === oldId) return
|
|
108
|
+
try {
|
|
109
|
+
await w.applyActions([gristRenameColumnAction(tableId, oldId, newId)])
|
|
110
|
+
await schema.reload()
|
|
111
|
+
} catch {
|
|
112
|
+
/* surfaced via <GristSdkAlerts> */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const handleType = async (colId: string, type: string) => {
|
|
117
|
+
try {
|
|
118
|
+
await w.applyActions([gristModifyColumnAction(tableId, colId, { type })])
|
|
119
|
+
await schema.reload()
|
|
120
|
+
} catch {
|
|
121
|
+
/* surfaced via <GristSdkAlerts> */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const handleDelete = async (colId: string) => {
|
|
126
|
+
if (!window.confirm(`Remove column "${colId}"?`)) return
|
|
127
|
+
try {
|
|
128
|
+
await w.applyActions([gristRemoveColumnAction(tableId, colId)])
|
|
129
|
+
await schema.reload()
|
|
130
|
+
} catch {
|
|
131
|
+
/* surfaced via <GristSdkAlerts> */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isPending = w.actionStatus === "running"
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className={className ?? "flex flex-wrap items-center gap-1.5"}>
|
|
139
|
+
<span className="mr-1 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
140
|
+
Columns
|
|
141
|
+
</span>
|
|
142
|
+
<Button
|
|
143
|
+
type="button"
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="icon-xs"
|
|
146
|
+
disabled={isPending || schema.loading}
|
|
147
|
+
onClick={() => void schema.reload()}
|
|
148
|
+
aria-label="Reload schema"
|
|
149
|
+
title="Reload schema"
|
|
150
|
+
>
|
|
151
|
+
<RefreshCw
|
|
152
|
+
className={schema.loading ? "size-3 animate-spin" : "size-3"}
|
|
153
|
+
/>
|
|
154
|
+
</Button>
|
|
155
|
+
|
|
156
|
+
{cols.map(([colId, col]) => {
|
|
157
|
+
const currentType =
|
|
158
|
+
(typeof col.type === "string" ? col.type : "Text") || "Text"
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
key={colId}
|
|
162
|
+
className="inline-flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 text-xs"
|
|
163
|
+
>
|
|
164
|
+
<Input
|
|
165
|
+
key={colId}
|
|
166
|
+
defaultValue={colId}
|
|
167
|
+
disabled={!can.rename || isPending}
|
|
168
|
+
className="h-6 w-24 px-1.5 text-xs"
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
if (e.key === "Enter") {
|
|
171
|
+
e.currentTarget.blur()
|
|
172
|
+
} else if (e.key === "Escape") {
|
|
173
|
+
e.currentTarget.value = colId
|
|
174
|
+
e.currentTarget.blur()
|
|
175
|
+
}
|
|
176
|
+
}}
|
|
177
|
+
onBlur={(e) => {
|
|
178
|
+
if (!can.rename) return
|
|
179
|
+
void handleRename(colId, e.currentTarget.value)
|
|
180
|
+
}}
|
|
181
|
+
aria-label={`Rename ${colId}`}
|
|
182
|
+
/>
|
|
183
|
+
<select
|
|
184
|
+
value={
|
|
185
|
+
GRIST_TOOLBAR_COLUMN_TYPES.includes(
|
|
186
|
+
currentType as GristToolbarColumnType
|
|
187
|
+
)
|
|
188
|
+
? (currentType as GristToolbarColumnType)
|
|
189
|
+
: "Any"
|
|
190
|
+
}
|
|
191
|
+
disabled={!can.retype || isPending}
|
|
192
|
+
onChange={(e) => void handleType(colId, e.target.value)}
|
|
193
|
+
className="h-6 rounded border border-input bg-background px-1 text-xs"
|
|
194
|
+
aria-label={`Type of ${colId}`}
|
|
195
|
+
>
|
|
196
|
+
{GRIST_TOOLBAR_COLUMN_TYPES.map((t) => (
|
|
197
|
+
<option key={t} value={t}>
|
|
198
|
+
{t}
|
|
199
|
+
</option>
|
|
200
|
+
))}
|
|
201
|
+
{/* Show the actual type if it's not in the toolbar's curated list (e.g. Ref:Foo). */}
|
|
202
|
+
{!GRIST_TOOLBAR_COLUMN_TYPES.includes(
|
|
203
|
+
currentType as GristToolbarColumnType
|
|
204
|
+
) ? (
|
|
205
|
+
<option value={currentType}>{currentType}</option>
|
|
206
|
+
) : null}
|
|
207
|
+
</select>
|
|
208
|
+
<Button
|
|
209
|
+
type="button"
|
|
210
|
+
variant="ghost"
|
|
211
|
+
size="icon-xs"
|
|
212
|
+
disabled={!can.remove || isPending}
|
|
213
|
+
onClick={() => void handleDelete(colId)}
|
|
214
|
+
aria-label={`Remove ${colId}`}
|
|
215
|
+
>
|
|
216
|
+
<X className="size-3" />
|
|
217
|
+
</Button>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
})}
|
|
221
|
+
|
|
222
|
+
{can.add ? (
|
|
223
|
+
adding ? (
|
|
224
|
+
<div className="inline-flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 text-xs">
|
|
225
|
+
<Input
|
|
226
|
+
autoFocus
|
|
227
|
+
placeholder="colId"
|
|
228
|
+
value={newColId}
|
|
229
|
+
disabled={isPending}
|
|
230
|
+
className="h-6 w-24 px-1.5 text-xs"
|
|
231
|
+
onChange={(e) => setNewColId(e.target.value)}
|
|
232
|
+
onKeyDown={(e) => {
|
|
233
|
+
if (e.key === "Enter") {
|
|
234
|
+
e.preventDefault()
|
|
235
|
+
void handleAdd()
|
|
236
|
+
} else if (e.key === "Escape") {
|
|
237
|
+
setAdding(false)
|
|
238
|
+
setNewColId("")
|
|
239
|
+
}
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
<select
|
|
243
|
+
value={newType}
|
|
244
|
+
disabled={isPending}
|
|
245
|
+
onChange={(e) =>
|
|
246
|
+
setNewType(e.target.value as GristToolbarColumnType)
|
|
247
|
+
}
|
|
248
|
+
className="h-6 rounded border border-input bg-background px-1 text-xs"
|
|
249
|
+
aria-label="New column type"
|
|
250
|
+
>
|
|
251
|
+
{GRIST_TOOLBAR_COLUMN_TYPES.map((t) => (
|
|
252
|
+
<option key={t} value={t}>
|
|
253
|
+
{t}
|
|
254
|
+
</option>
|
|
255
|
+
))}
|
|
256
|
+
</select>
|
|
257
|
+
<Button
|
|
258
|
+
type="button"
|
|
259
|
+
size="xs"
|
|
260
|
+
disabled={isPending || !newColId.trim()}
|
|
261
|
+
onClick={() => void handleAdd()}
|
|
262
|
+
>
|
|
263
|
+
Add
|
|
264
|
+
</Button>
|
|
265
|
+
<Button
|
|
266
|
+
type="button"
|
|
267
|
+
size="xs"
|
|
268
|
+
variant="ghost"
|
|
269
|
+
disabled={isPending}
|
|
270
|
+
onClick={() => {
|
|
271
|
+
setAdding(false)
|
|
272
|
+
setNewColId("")
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
Cancel
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
) : (
|
|
279
|
+
<Button
|
|
280
|
+
type="button"
|
|
281
|
+
size="xs"
|
|
282
|
+
variant="outline"
|
|
283
|
+
onClick={() => setAdding(true)}
|
|
284
|
+
disabled={isPending}
|
|
285
|
+
>
|
|
286
|
+
<Plus className="size-3" />
|
|
287
|
+
Column
|
|
288
|
+
</Button>
|
|
289
|
+
)
|
|
290
|
+
) : null}
|
|
291
|
+
</div>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useGristContext,
|
|
5
|
+
useGristSchema,
|
|
6
|
+
useWidgetMetadata,
|
|
7
|
+
type UseGristOptions,
|
|
8
|
+
} from "grist-widget-sdk"
|
|
9
|
+
|
|
10
|
+
import { Button } from "@/components/ui/button"
|
|
11
|
+
|
|
12
|
+
import { GristDataSheetGrid } from "./GristDataSheetGrid"
|
|
13
|
+
import { GristSchemaToolbar } from "./GristSchemaToolbar"
|
|
14
|
+
|
|
15
|
+
function downloadReplicaJson(label: string, doc: unknown) {
|
|
16
|
+
const safe = label.replace(/[^a-z0-9-]+/gi, "-").toLowerCase()
|
|
17
|
+
const blob = new Blob([JSON.stringify(doc, null, 2)], {
|
|
18
|
+
type: "application/json",
|
|
19
|
+
})
|
|
20
|
+
const url = URL.createObjectURL(blob)
|
|
21
|
+
const a = document.createElement("a")
|
|
22
|
+
a.href = url
|
|
23
|
+
a.download = `replica-${safe}.json`
|
|
24
|
+
a.click()
|
|
25
|
+
URL.revokeObjectURL(url)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const GRIST_OPTIONS: UseGristOptions = {
|
|
29
|
+
/** Required so `table.update` / `applyUserActions` succeed. */
|
|
30
|
+
requiredAccess: "full",
|
|
31
|
+
/** Needed so `setCursorPos` / linked selection can drive host row focus. */
|
|
32
|
+
allowSelectBy: true,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const WIDGET_METADATA = {
|
|
36
|
+
title: "Spreadsheet grid",
|
|
37
|
+
description:
|
|
38
|
+
"Selected Grist section rendered as a fully-synced, editable spreadsheet via react-datasheet-grid, plus a schema toolbar and replica-document export.",
|
|
39
|
+
} as const
|
|
40
|
+
|
|
41
|
+
export function WidgetApp() {
|
|
42
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
43
|
+
|
|
44
|
+
// Hoist the schema fetch so the grid + toolbar share it (single
|
|
45
|
+
// `_grist_Tables_column` round-trip per reload).
|
|
46
|
+
const w = useGristContext()
|
|
47
|
+
const schema = useGristSchema({
|
|
48
|
+
tableId: w.currentTableId ?? undefined,
|
|
49
|
+
requiredAccess: "full",
|
|
50
|
+
excludeManualSort: true,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const [exporting, setExporting] = useState<string | null>(null)
|
|
54
|
+
const [showSchemaToolbar, setShowSchemaToolbar] = useState(false)
|
|
55
|
+
const gridHostRef = useRef<HTMLDivElement | null>(null)
|
|
56
|
+
const [gridHeight, setGridHeight] = useState<number | undefined>(undefined)
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const host = gridHostRef.current
|
|
60
|
+
if (!host) return
|
|
61
|
+
const update = () => {
|
|
62
|
+
const next = Math.floor(host.getBoundingClientRect().height)
|
|
63
|
+
if (next > 0) setGridHeight(next)
|
|
64
|
+
}
|
|
65
|
+
update()
|
|
66
|
+
const observer = new ResizeObserver(update)
|
|
67
|
+
observer.observe(host)
|
|
68
|
+
return () => observer.disconnect()
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
async function exportReplica(
|
|
72
|
+
key: string,
|
|
73
|
+
includeRows: false | "samples" | "full"
|
|
74
|
+
) {
|
|
75
|
+
if (!w.isReady) return
|
|
76
|
+
setExporting(key)
|
|
77
|
+
try {
|
|
78
|
+
const doc = await w.buildReplicaDocumentFromDocApi({
|
|
79
|
+
includeRows,
|
|
80
|
+
source: "spreadsheet-grid-export",
|
|
81
|
+
})
|
|
82
|
+
downloadReplicaJson(key, doc)
|
|
83
|
+
} finally {
|
|
84
|
+
setExporting(null)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex h-svh min-h-0 flex-col gap-3 p-4">
|
|
90
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
91
|
+
<Button
|
|
92
|
+
type="button"
|
|
93
|
+
variant="outline"
|
|
94
|
+
size="sm"
|
|
95
|
+
disabled={!w.isReady || exporting !== null}
|
|
96
|
+
onClick={() => void exportReplica("schema-only", false)}
|
|
97
|
+
>
|
|
98
|
+
{exporting === "schema-only" ? "…" : "Export schema"}
|
|
99
|
+
</Button>
|
|
100
|
+
<Button
|
|
101
|
+
type="button"
|
|
102
|
+
variant="outline"
|
|
103
|
+
size="sm"
|
|
104
|
+
disabled={!w.isReady || exporting !== null}
|
|
105
|
+
onClick={() => void exportReplica("schema-samples", "samples")}
|
|
106
|
+
>
|
|
107
|
+
{exporting === "schema-samples" ? "…" : "Export schema + samples"}
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
type="button"
|
|
111
|
+
variant="outline"
|
|
112
|
+
size="sm"
|
|
113
|
+
disabled={!w.isReady || exporting !== null}
|
|
114
|
+
onClick={() => void exportReplica("schema-data", "full")}
|
|
115
|
+
>
|
|
116
|
+
{exporting === "schema-data" ? "…" : "Export schema + data"}
|
|
117
|
+
</Button>
|
|
118
|
+
<Button
|
|
119
|
+
type="button"
|
|
120
|
+
variant="outline"
|
|
121
|
+
size="sm"
|
|
122
|
+
onClick={() => setShowSchemaToolbar((v) => !v)}
|
|
123
|
+
>
|
|
124
|
+
{showSchemaToolbar ? "Hide schema toolbar" : "Show schema toolbar"}
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
{showSchemaToolbar ? <GristSchemaToolbar schema={schema} /> : null}
|
|
128
|
+
<div ref={gridHostRef} className="min-h-0 flex-1 overflow-hidden">
|
|
129
|
+
<GristDataSheetGrid schema={schema} autoAddRow height={gridHeight} />
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|