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,397 @@
|
|
|
1
|
+
import React, { memo } from "react"
|
|
2
|
+
import {
|
|
3
|
+
type CellComponent,
|
|
4
|
+
type CellProps,
|
|
5
|
+
createTextColumn,
|
|
6
|
+
keyColumn,
|
|
7
|
+
textColumn,
|
|
8
|
+
type Column,
|
|
9
|
+
} from "react-datasheet-grid"
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
encodeGristValue,
|
|
13
|
+
normalizeGristChoiceListEntries,
|
|
14
|
+
resolveGristFormulaKind,
|
|
15
|
+
type GristReplicaColumn,
|
|
16
|
+
} from "grist-widget-sdk"
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createGristChoiceColumn,
|
|
20
|
+
createGristChoiceListColumn,
|
|
21
|
+
createGristRefListColumn,
|
|
22
|
+
} from "./grist-rdg-choice-column"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decoded row shape exposed to react-datasheet-grid. `useGrist().records`
|
|
26
|
+
* always carries a numeric `id` (it's the Grist row id), and the SDK already
|
|
27
|
+
* decodes cell values via `keepEncoded: false`.
|
|
28
|
+
*/
|
|
29
|
+
export type GristRdgRow = Record<string, unknown> & { id?: number }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Grist column types we let the schema toolbar create / change to.
|
|
33
|
+
*
|
|
34
|
+
* Refs / RefLists are intentionally excluded: they require a target table id
|
|
35
|
+
* and a UX flow we don't want in the minimal demo.
|
|
36
|
+
*/
|
|
37
|
+
export const GRIST_TOOLBAR_COLUMN_TYPES = [
|
|
38
|
+
"Text",
|
|
39
|
+
"Numeric",
|
|
40
|
+
"Int",
|
|
41
|
+
"Bool",
|
|
42
|
+
"Date",
|
|
43
|
+
"DateTime",
|
|
44
|
+
"Choice",
|
|
45
|
+
"Any",
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
export type GristToolbarColumnType = (typeof GRIST_TOOLBAR_COLUMN_TYPES)[number]
|
|
49
|
+
|
|
50
|
+
function looksLikeDateTime(type: string | undefined): boolean {
|
|
51
|
+
return (
|
|
52
|
+
type === "DateTime" ||
|
|
53
|
+
(typeof type === "string" && type.startsWith("DateTime:"))
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function looksLikeComplex(type: string | undefined): boolean {
|
|
58
|
+
if (!type) return false
|
|
59
|
+
return type === "Attachments"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function looksLikeRef(type: string | undefined): boolean {
|
|
63
|
+
return typeof type === "string" && type.startsWith("Ref:")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function looksLikeRefList(type: string | undefined): boolean {
|
|
67
|
+
return typeof type === "string" && type.startsWith("RefList:")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isGristHelperColumnId(colId: string): boolean {
|
|
71
|
+
return colId.startsWith("gristHelper_")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True for `computed` and `trigger` formulas — they cannot be written to. */
|
|
75
|
+
export function isFormulaColumn(col: GristReplicaColumn | undefined): boolean {
|
|
76
|
+
if (!col) return false
|
|
77
|
+
const kind = resolveGristFormulaKind(col)
|
|
78
|
+
return kind === "computed" || kind === "trigger"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True for non-scalar column types we display as read-only JSON. */
|
|
82
|
+
export function isComplexColumn(col: GristReplicaColumn | undefined): boolean {
|
|
83
|
+
return looksLikeComplex(col?.type)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isDetailedRefValue(
|
|
87
|
+
v: unknown
|
|
88
|
+
): v is { rowId: number; display?: string | null } {
|
|
89
|
+
if (!v || typeof v !== "object") return false
|
|
90
|
+
const rowId = (v as { rowId?: unknown }).rowId
|
|
91
|
+
return typeof rowId === "number"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function chipStyle(ok: boolean): Record<string, string | number> {
|
|
95
|
+
if (ok) {
|
|
96
|
+
return {
|
|
97
|
+
border: "1px solid #86efac",
|
|
98
|
+
backgroundColor: "#f0fdf4",
|
|
99
|
+
color: "#166534",
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
border: "1px solid #dc2626",
|
|
104
|
+
backgroundColor: "#fef2f2",
|
|
105
|
+
color: "#991b1b",
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RefChipCell = memo(function RefChipCell({
|
|
110
|
+
rowData,
|
|
111
|
+
}: CellProps<unknown, unknown>) {
|
|
112
|
+
let label = ""
|
|
113
|
+
let ok = false
|
|
114
|
+
if (isDetailedRefValue(rowData)) {
|
|
115
|
+
const display =
|
|
116
|
+
typeof rowData.display === "string" ? rowData.display.trim() : ""
|
|
117
|
+
label = display !== "" ? display : String(rowData.rowId)
|
|
118
|
+
ok = display !== ""
|
|
119
|
+
} else if (typeof rowData === "number") {
|
|
120
|
+
label = String(rowData)
|
|
121
|
+
ok = false
|
|
122
|
+
} else if (rowData != null) {
|
|
123
|
+
label = String(rowData)
|
|
124
|
+
ok = false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const children = label
|
|
128
|
+
? React.createElement(
|
|
129
|
+
"span",
|
|
130
|
+
{
|
|
131
|
+
style: {
|
|
132
|
+
borderRadius: 10,
|
|
133
|
+
padding: "1px 6px",
|
|
134
|
+
lineHeight: 1.4,
|
|
135
|
+
...chipStyle(ok),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
label
|
|
139
|
+
)
|
|
140
|
+
: null
|
|
141
|
+
return React.createElement(
|
|
142
|
+
"div",
|
|
143
|
+
{
|
|
144
|
+
className: "dsg-input",
|
|
145
|
+
style: { pointerEvents: "none", minHeight: 22, padding: "2px 6px" },
|
|
146
|
+
},
|
|
147
|
+
children
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
RefChipCell.displayName = "RefChipCell"
|
|
152
|
+
|
|
153
|
+
function refChipColumn(): Partial<Column<unknown, unknown, unknown>> {
|
|
154
|
+
return {
|
|
155
|
+
...(textColumn as unknown as Partial<Column<unknown, unknown, unknown>>),
|
|
156
|
+
component: RefChipCell as unknown as CellComponent<unknown, unknown>,
|
|
157
|
+
disabled: true,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build the set of writable column ids (we exclude formula and complex
|
|
163
|
+
* columns from row writes — Grist would reject formula writes, and complex
|
|
164
|
+
* columns need their own UX).
|
|
165
|
+
*/
|
|
166
|
+
export function writableColIds(
|
|
167
|
+
colIds: readonly string[],
|
|
168
|
+
schemaColumns: Record<string, GristReplicaColumn>
|
|
169
|
+
): Set<string> {
|
|
170
|
+
const out = new Set<string>()
|
|
171
|
+
for (const colId of colIds) {
|
|
172
|
+
if (colId === "id") continue
|
|
173
|
+
if (colId === "manualSort") continue
|
|
174
|
+
if (isGristHelperColumnId(colId)) continue
|
|
175
|
+
const col = schemaColumns[colId]
|
|
176
|
+
if (isFormulaColumn(col)) continue
|
|
177
|
+
if (isComplexColumn(col)) continue
|
|
178
|
+
out.add(colId)
|
|
179
|
+
}
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function manualSortKey(row: GristRdgRow): number | null {
|
|
184
|
+
const v = row.manualSort
|
|
185
|
+
if (typeof v === "number" && !Number.isNaN(v)) return v
|
|
186
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
187
|
+
const n = Number(v)
|
|
188
|
+
if (!Number.isNaN(n)) return n
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Row order for Grist tables: `manualSort` ascending when present on any row,
|
|
195
|
+
* otherwise numeric `id` (document order fallback).
|
|
196
|
+
*/
|
|
197
|
+
export function sortRowsByGristOrder(
|
|
198
|
+
rows: readonly GristRdgRow[]
|
|
199
|
+
): GristRdgRow[] {
|
|
200
|
+
const hasManual = rows.some((r) => manualSortKey(r) !== null)
|
|
201
|
+
if (!hasManual) {
|
|
202
|
+
return [...rows].sort((a, b) => {
|
|
203
|
+
const ida = typeof a.id === "number" ? a.id : 0
|
|
204
|
+
const idb = typeof b.id === "number" ? b.id : 0
|
|
205
|
+
return ida - idb
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
return [...rows].sort((a, b) => {
|
|
209
|
+
const ma = manualSortKey(a)
|
|
210
|
+
const mb = manualSortKey(b)
|
|
211
|
+
if (ma !== null && mb !== null && ma !== mb) return ma - mb
|
|
212
|
+
if (ma !== null && mb === null) return -1
|
|
213
|
+
if (ma === null && mb !== null) return 1
|
|
214
|
+
const ida = typeof a.id === "number" ? a.id : 0
|
|
215
|
+
const idb = typeof b.id === "number" ? b.id : 0
|
|
216
|
+
return ida - idb
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Encode the writable subset of a row's fields back into Grist wire form.
|
|
222
|
+
* Used for `table.create` / `table.update` payloads.
|
|
223
|
+
*/
|
|
224
|
+
export function encodeRowFields(
|
|
225
|
+
row: Record<string, unknown>,
|
|
226
|
+
schemaColumns: Record<string, GristReplicaColumn>,
|
|
227
|
+
writable: ReadonlySet<string>
|
|
228
|
+
): Record<string, unknown> {
|
|
229
|
+
const out: Record<string, unknown> = {}
|
|
230
|
+
for (const colId of writable) {
|
|
231
|
+
const value = row[colId]
|
|
232
|
+
if (value === undefined) continue
|
|
233
|
+
const type = schemaColumns[colId]?.type
|
|
234
|
+
const encodedInput =
|
|
235
|
+
typeof type === "string" &&
|
|
236
|
+
type.startsWith("RefList:") &&
|
|
237
|
+
value &&
|
|
238
|
+
typeof value === "object" &&
|
|
239
|
+
Array.isArray((value as { rowIds?: unknown }).rowIds)
|
|
240
|
+
? (value as { rowIds: number[] }).rowIds
|
|
241
|
+
: value
|
|
242
|
+
out[colId] = encodeGristValue(encodedInput, schemaColumns[colId])
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Read-only "complex" cell — JSON-stringified copy, no edit / paste. */
|
|
248
|
+
function complexDisplayColumn(): Partial<Column<unknown, unknown, string>> {
|
|
249
|
+
return {
|
|
250
|
+
...(textColumn as Partial<Column<unknown, unknown, string>>),
|
|
251
|
+
copyValue: ({ rowData }) =>
|
|
252
|
+
rowData == null
|
|
253
|
+
? null
|
|
254
|
+
: typeof rowData === "string"
|
|
255
|
+
? rowData
|
|
256
|
+
: JSON.stringify(rowData),
|
|
257
|
+
pasteValue: ({ rowData }) => rowData,
|
|
258
|
+
deleteValue: ({ rowData }) => rowData,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* DateTime variant of `textColumn` — values stay as `Date` objects (the SDK
|
|
264
|
+
* decodes Grist DateTime cells to JS `Date`), and we display / parse ISO 8601.
|
|
265
|
+
*
|
|
266
|
+
* Native HTML `datetime-local` would be nicer but it's a bigger refactor; ISO
|
|
267
|
+
* text is the lowest-friction path for the minimal widget.
|
|
268
|
+
*/
|
|
269
|
+
function dateTimeTextColumn() {
|
|
270
|
+
return createTextColumn<Date | null>({
|
|
271
|
+
placeholder: "YYYY-MM-DDTHH:mm:ssZ",
|
|
272
|
+
parseUserInput: (raw) => {
|
|
273
|
+
const s = raw.trim()
|
|
274
|
+
if (!s) return null
|
|
275
|
+
const d = new Date(s)
|
|
276
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
277
|
+
},
|
|
278
|
+
parsePastedValue: (raw) => {
|
|
279
|
+
const s = raw.trim()
|
|
280
|
+
if (!s) return null
|
|
281
|
+
const d = new Date(s)
|
|
282
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
283
|
+
},
|
|
284
|
+
formatBlurredInput: (value) =>
|
|
285
|
+
value instanceof Date ? value.toISOString() : "",
|
|
286
|
+
formatInputOnFocus: (value) =>
|
|
287
|
+
value instanceof Date ? value.toISOString() : "",
|
|
288
|
+
formatForCopy: (value) =>
|
|
289
|
+
value instanceof Date ? value.toISOString() : "",
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Date variant that is resilient to malformed row values.
|
|
295
|
+
*
|
|
296
|
+
* RDG's built-in `dateColumn` assumes the cell value is always a `Date` and can
|
|
297
|
+
* throw when a bad value leaks through (e.g. from malformed source data).
|
|
298
|
+
* This column accepts Date/string/null and normalizes safely.
|
|
299
|
+
*/
|
|
300
|
+
function safeDateTextColumn() {
|
|
301
|
+
return createTextColumn<Date | string | null>({
|
|
302
|
+
placeholder: "YYYY-MM-DD",
|
|
303
|
+
parseUserInput: (raw) => {
|
|
304
|
+
const s = raw.trim()
|
|
305
|
+
if (!s) return null
|
|
306
|
+
const d = new Date(s)
|
|
307
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
308
|
+
},
|
|
309
|
+
parsePastedValue: (raw) => {
|
|
310
|
+
const s = raw.trim()
|
|
311
|
+
if (!s) return null
|
|
312
|
+
const d = new Date(s)
|
|
313
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
314
|
+
},
|
|
315
|
+
formatBlurredInput: (value) => {
|
|
316
|
+
if (value == null) return ""
|
|
317
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
|
318
|
+
if (typeof value === "string") return value
|
|
319
|
+
return ""
|
|
320
|
+
},
|
|
321
|
+
formatInputOnFocus: (value) => {
|
|
322
|
+
if (value == null) return ""
|
|
323
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
|
324
|
+
if (typeof value === "string") return value
|
|
325
|
+
return ""
|
|
326
|
+
},
|
|
327
|
+
formatForCopy: (value) => {
|
|
328
|
+
if (value == null) return ""
|
|
329
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
|
330
|
+
if (typeof value === "string") return value
|
|
331
|
+
return ""
|
|
332
|
+
},
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Pick the right RDG column variant for a Grist column.
|
|
338
|
+
*
|
|
339
|
+
* The returned column is wrapped in `keyColumn(colId, ...)` so RDG reads /
|
|
340
|
+
* writes the right key on the row object, and is marked `disabled` for
|
|
341
|
+
* formula / complex / id columns.
|
|
342
|
+
*/
|
|
343
|
+
export function gristColumnToRdgColumn(
|
|
344
|
+
colId: string,
|
|
345
|
+
col: GristReplicaColumn | undefined
|
|
346
|
+
): Partial<Column<GristRdgRow, unknown, unknown>> {
|
|
347
|
+
const label = col?.label || colId
|
|
348
|
+
const type = col?.type
|
|
349
|
+
const readOnly =
|
|
350
|
+
colId === "id" ||
|
|
351
|
+
isFormulaColumn(col) ||
|
|
352
|
+
isComplexColumn(col) ||
|
|
353
|
+
looksLikeRef(type)
|
|
354
|
+
|
|
355
|
+
let base: Partial<Column<unknown, unknown, unknown>>
|
|
356
|
+
if (isComplexColumn(col)) {
|
|
357
|
+
base = complexDisplayColumn() as Partial<Column<unknown, unknown, unknown>>
|
|
358
|
+
} else if (looksLikeRefList(type)) {
|
|
359
|
+
base = createGristRefListColumn() as Partial<
|
|
360
|
+
Column<unknown, unknown, unknown>
|
|
361
|
+
>
|
|
362
|
+
} else if (looksLikeRef(type)) {
|
|
363
|
+
base = refChipColumn() as Partial<Column<unknown, unknown, unknown>>
|
|
364
|
+
} else if (type === "Bool") {
|
|
365
|
+
base = textColumn as Partial<Column<unknown, unknown, unknown>>
|
|
366
|
+
} else if (type === "Int") {
|
|
367
|
+
base = textColumn as Partial<Column<unknown, unknown, unknown>>
|
|
368
|
+
} else if (type === "Numeric") {
|
|
369
|
+
base = textColumn as Partial<Column<unknown, unknown, unknown>>
|
|
370
|
+
} else if (type === "Date") {
|
|
371
|
+
base = safeDateTextColumn() as Partial<Column<unknown, unknown, unknown>>
|
|
372
|
+
} else if (looksLikeDateTime(type)) {
|
|
373
|
+
base = dateTimeTextColumn() as Partial<Column<unknown, unknown, unknown>>
|
|
374
|
+
} else if (type === "Choice") {
|
|
375
|
+
const choiceEntries = normalizeGristChoiceListEntries(col?.widgetOptions)
|
|
376
|
+
base =
|
|
377
|
+
choiceEntries.length > 0
|
|
378
|
+
? (createGristChoiceColumn(choiceEntries) as Partial<
|
|
379
|
+
Column<unknown, unknown, unknown>
|
|
380
|
+
>)
|
|
381
|
+
: (textColumn as Partial<Column<unknown, unknown, unknown>>)
|
|
382
|
+
} else if (type === "ChoiceList") {
|
|
383
|
+
const choiceEntries = normalizeGristChoiceListEntries(col?.widgetOptions)
|
|
384
|
+
base = createGristChoiceListColumn(choiceEntries) as Partial<
|
|
385
|
+
Column<unknown, unknown, unknown>
|
|
386
|
+
>
|
|
387
|
+
} else {
|
|
388
|
+
base = textColumn as Partial<Column<unknown, unknown, unknown>>
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
...keyColumn(colId, base as Partial<Column<unknown, unknown, unknown>>),
|
|
393
|
+
id: colId,
|
|
394
|
+
title: label,
|
|
395
|
+
disabled: readOnly,
|
|
396
|
+
} as Partial<Column<GristRdgRow, unknown, unknown>>
|
|
397
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { useMemo, useState, type DragEvent } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useGrist,
|
|
5
|
+
useWidgetMetadata,
|
|
6
|
+
type UseGristOptions,
|
|
7
|
+
} from "grist-widget-sdk"
|
|
8
|
+
|
|
9
|
+
import { Badge } from "@/components/ui/badge"
|
|
10
|
+
import { Card, CardContent } from "@/components/ui/card"
|
|
11
|
+
|
|
12
|
+
export const GRIST_OPTIONS: UseGristOptions = {
|
|
13
|
+
requiredAccess: "full",
|
|
14
|
+
allowSelectBy: true,
|
|
15
|
+
columns: [
|
|
16
|
+
{ name: "Title", type: "Text" },
|
|
17
|
+
{ name: "Status", type: "Text" },
|
|
18
|
+
{ name: "Priority", type: "Text", optional: true },
|
|
19
|
+
],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const WIDGET_METADATA = {
|
|
23
|
+
title: "Task board",
|
|
24
|
+
description:
|
|
25
|
+
"Mini Kanban board grouped by Status. Drag a card to update Status; click to focus the row in linked sections.",
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
const DEFAULT_STATUSES = ["To do", "Doing", "Done"] as const
|
|
29
|
+
|
|
30
|
+
type Task = {
|
|
31
|
+
id: number
|
|
32
|
+
title: string
|
|
33
|
+
status: string
|
|
34
|
+
priority: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pickStatuses(rows: readonly Task[]): readonly string[] {
|
|
38
|
+
const seen = new Set<string>(DEFAULT_STATUSES)
|
|
39
|
+
for (const r of rows) {
|
|
40
|
+
if (r.status && !seen.has(r.status)) seen.add(r.status)
|
|
41
|
+
}
|
|
42
|
+
return [...seen]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asString(value: unknown): string {
|
|
46
|
+
if (typeof value === "string") return value
|
|
47
|
+
if (value == null) return ""
|
|
48
|
+
return String(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function WidgetApp() {
|
|
52
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
53
|
+
const w = useGrist()
|
|
54
|
+
|
|
55
|
+
// Resolve the physical column ids once per render. We can't call mapBack on
|
|
56
|
+
// every row record because mapBack is for a single record write; here we
|
|
57
|
+
// want to project the rows from raw `records` into a typed shape.
|
|
58
|
+
const titleColId = w.resolveMappedColumnId("Title")
|
|
59
|
+
const statusColId = w.resolveMappedColumnId("Status")
|
|
60
|
+
const priorityColId = w.resolveMappedColumnId("Priority")
|
|
61
|
+
|
|
62
|
+
const tasks = useMemo<Task[]>(() => {
|
|
63
|
+
if (
|
|
64
|
+
!w.columnMappingStatus.ok ||
|
|
65
|
+
!titleColId ||
|
|
66
|
+
!statusColId ||
|
|
67
|
+
!w.records
|
|
68
|
+
) {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
return w.records.map((row) => {
|
|
72
|
+
const status = asString(row[statusColId])
|
|
73
|
+
const priority = priorityColId ? asString(row[priorityColId]) : ""
|
|
74
|
+
return {
|
|
75
|
+
id: row.id as number,
|
|
76
|
+
title: asString(row[titleColId]) || "(untitled)",
|
|
77
|
+
status: status || "To do",
|
|
78
|
+
priority: priority || null,
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}, [
|
|
82
|
+
w.records,
|
|
83
|
+
w.columnMappingStatus,
|
|
84
|
+
titleColId,
|
|
85
|
+
statusColId,
|
|
86
|
+
priorityColId,
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const statuses = useMemo(() => pickStatuses(tasks), [tasks])
|
|
90
|
+
const [dragId, setDragId] = useState<number | null>(null)
|
|
91
|
+
|
|
92
|
+
if (w.status === "booting") {
|
|
93
|
+
return <Empty title="Connecting to Grist…" body="" />
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!w.columnMappingStatus.ok) {
|
|
97
|
+
return (
|
|
98
|
+
<Empty
|
|
99
|
+
title="Missing column mappings"
|
|
100
|
+
body="Open the widget configuration panel and map the columns: Title (Text), Status (Text), Priority (Text, optional)."
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (tasks.length === 0) {
|
|
106
|
+
return (
|
|
107
|
+
<Empty
|
|
108
|
+
title="No rows to show"
|
|
109
|
+
body="Add rows to the selected table — they will appear grouped by Status."
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function moveToStatus(task: Task, nextStatus: string) {
|
|
115
|
+
if (task.status === nextStatus) return
|
|
116
|
+
try {
|
|
117
|
+
await w.table.update({
|
|
118
|
+
id: task.id,
|
|
119
|
+
fields: w.mapBack({ Status: nextStatus }),
|
|
120
|
+
})
|
|
121
|
+
} catch {
|
|
122
|
+
// The card will snap back via the next `records` tick when the
|
|
123
|
+
// optimistic update is rejected by Grist.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex h-svh min-h-0 flex-col gap-3 p-4">
|
|
129
|
+
<header className="flex items-baseline justify-between">
|
|
130
|
+
<h1 className="text-lg font-medium">Task board</h1>
|
|
131
|
+
<span className="text-xs text-muted-foreground">
|
|
132
|
+
{tasks.length} task{tasks.length === 1 ? "" : "s"}
|
|
133
|
+
</span>
|
|
134
|
+
</header>
|
|
135
|
+
|
|
136
|
+
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-auto sm:grid-cols-2 lg:grid-cols-3">
|
|
137
|
+
{statuses.map((status) => {
|
|
138
|
+
const inStatus = tasks.filter((c) => c.status === status)
|
|
139
|
+
return (
|
|
140
|
+
<section
|
|
141
|
+
key={status}
|
|
142
|
+
className="flex min-h-32 flex-col gap-2 rounded-lg border bg-muted/40 p-3"
|
|
143
|
+
onDragOver={(e) => {
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
e.dataTransfer.dropEffect = "move"
|
|
146
|
+
}}
|
|
147
|
+
onDrop={(e) => {
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
if (dragId == null) return
|
|
150
|
+
const dragged = tasks.find((c) => c.id === dragId)
|
|
151
|
+
setDragId(null)
|
|
152
|
+
if (dragged) void moveToStatus(dragged, status)
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<header className="flex items-center justify-between">
|
|
156
|
+
<h2 className="text-sm font-semibold">{status}</h2>
|
|
157
|
+
<Badge variant="secondary">{inStatus.length}</Badge>
|
|
158
|
+
</header>
|
|
159
|
+
<div className="flex flex-col gap-2">
|
|
160
|
+
{inStatus.map((task) => (
|
|
161
|
+
<TaskCard
|
|
162
|
+
key={task.id}
|
|
163
|
+
task={task}
|
|
164
|
+
isSelected={w.record?.id === task.id}
|
|
165
|
+
onClick={() => void w.setCursorPosition({ rowId: task.id })}
|
|
166
|
+
onDragStart={() => setDragId(task.id)}
|
|
167
|
+
onDragEnd={() => setDragId(null)}
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
170
|
+
{inStatus.length === 0 ? (
|
|
171
|
+
<p className="text-xs text-muted-foreground">No tasks.</p>
|
|
172
|
+
) : null}
|
|
173
|
+
</div>
|
|
174
|
+
</section>
|
|
175
|
+
)
|
|
176
|
+
})}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function TaskCard({
|
|
183
|
+
task,
|
|
184
|
+
isSelected,
|
|
185
|
+
onClick,
|
|
186
|
+
onDragStart,
|
|
187
|
+
onDragEnd,
|
|
188
|
+
}: {
|
|
189
|
+
task: Task
|
|
190
|
+
isSelected: boolean
|
|
191
|
+
onClick: () => void
|
|
192
|
+
onDragStart: (e: DragEvent<HTMLDivElement>) => void
|
|
193
|
+
onDragEnd: (e: DragEvent<HTMLDivElement>) => void
|
|
194
|
+
}) {
|
|
195
|
+
return (
|
|
196
|
+
<Card
|
|
197
|
+
draggable
|
|
198
|
+
onClick={onClick}
|
|
199
|
+
onDragStart={onDragStart}
|
|
200
|
+
onDragEnd={onDragEnd}
|
|
201
|
+
className={
|
|
202
|
+
"cursor-grab transition-shadow hover:shadow-md " +
|
|
203
|
+
(isSelected ? "ring-2 ring-primary" : "")
|
|
204
|
+
}
|
|
205
|
+
>
|
|
206
|
+
<CardContent className="flex flex-col gap-1 p-3">
|
|
207
|
+
<span className="text-sm font-medium">{task.title}</span>
|
|
208
|
+
{task.priority ? (
|
|
209
|
+
<Badge variant="outline" className="self-start">
|
|
210
|
+
{task.priority}
|
|
211
|
+
</Badge>
|
|
212
|
+
) : null}
|
|
213
|
+
</CardContent>
|
|
214
|
+
</Card>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function Empty({ title, body }: { title: string; body: string }) {
|
|
219
|
+
return (
|
|
220
|
+
<div className="flex h-svh items-center justify-center p-6">
|
|
221
|
+
<div className="max-w-sm text-center">
|
|
222
|
+
<h2 className="font-medium">{title}</h2>
|
|
223
|
+
{body ? (
|
|
224
|
+
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
|
|
225
|
+
) : null}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useGrist,
|
|
3
|
+
useWidgetMetadata,
|
|
4
|
+
type UseGristOptions,
|
|
5
|
+
} from "grist-widget-sdk"
|
|
6
|
+
|
|
7
|
+
export const GRIST_OPTIONS: UseGristOptions = {
|
|
8
|
+
requiredAccess: "read table",
|
|
9
|
+
suppressAlerts: ["section-not-linked"],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const WIDGET_METADATA = {
|
|
13
|
+
title: "Theme",
|
|
14
|
+
description: "Read w.theme from the host document.",
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
export function WidgetApp() {
|
|
18
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
19
|
+
const w = useGrist()
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="p-4" data-theme={w.theme ?? "light"}>
|
|
23
|
+
<p className="text-sm">Host theme: {w.theme ?? "light"}</p>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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: "read table",
|
|
11
|
+
suppressAlerts: ["section-not-linked"],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const WIDGET_METADATA = {
|
|
15
|
+
title: "Widget options",
|
|
16
|
+
description: "Persist UI state with setWidgetOption / widgetOptions.",
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export function WidgetApp() {
|
|
20
|
+
useWidgetMetadata(WIDGET_METADATA)
|
|
21
|
+
const w = useGrist()
|
|
22
|
+
const compact = w.widgetOptions?.compact === true
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="p-4">
|
|
26
|
+
<Button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={() => void w.setWidgetOption("compact", !compact)}
|
|
29
|
+
>
|
|
30
|
+
{compact ? "Expand" : "Compact"}
|
|
31
|
+
</Button>
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|