create-grist-widget 0.3.5 → 0.3.6
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/package.json +1 -1
- package/template/AGENTS.md +6 -4
- package/template/README.md +3 -3
- package/template/package.json +7 -2
- package/template/src/App.test.tsx +81 -0
- package/template/src/App.tsx +105 -21
- package/template/vite.config.ts +8 -1
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -35,8 +35,8 @@ rendering all live in `src/App.tsx` — that's the one file to edit.
|
|
|
35
35
|
itself only renders once actually embedded in a Grist document. An offline, seeded
|
|
36
36
|
`pnpm dev` loop is planned but not shipped in this template yet — don't describe one as
|
|
37
37
|
if it exists.
|
|
38
|
-
3. **
|
|
39
|
-
|
|
38
|
+
3. **Widget changes get a test.** `src/App.test.tsx` uses `renderWithGrist` + `presets` from
|
|
39
|
+
`grist-widget-sdk/emulator/testing` — no browser or real Grist doc needed. Run `pnpm test`.
|
|
40
40
|
4. **Always develop on `dev`, release by merging to `main`.** Commit and push to `dev` for
|
|
41
41
|
every change — it auto-deploys a live preview at `.../dev/` that self-reloads inside an
|
|
42
42
|
open Grist document a few seconds later. Bump `package.json`'s `version` *before*
|
|
@@ -51,6 +51,7 @@ rendering all live in `src/App.tsx` — that's the one file to edit.
|
|
|
51
51
|
| Install | `pnpm install` |
|
|
52
52
|
| Dev server (showcase hub outside Grist) | `pnpm dev` |
|
|
53
53
|
| Build | `pnpm build` |
|
|
54
|
+
| Test | `pnpm test` |
|
|
54
55
|
| Lint | `pnpm lint` |
|
|
55
56
|
| Format | `pnpm format` |
|
|
56
57
|
| Type-check | `pnpm typecheck` |
|
|
@@ -61,6 +62,7 @@ rendering all live in `src/App.tsx` — that's the one file to edit.
|
|
|
61
62
|
| Concept | Path |
|
|
62
63
|
| --- | --- |
|
|
63
64
|
| The widget | `src/App.tsx` |
|
|
65
|
+
| Widget test (the pattern to copy for new tests) | `src/App.test.tsx` |
|
|
64
66
|
| Grist wiring / embed vs. showcase detection | `src/main.tsx` |
|
|
65
67
|
| ui primitives | `src/components/ui/` |
|
|
66
68
|
| Deploy workflow | `.github/workflows/deploy.yml` |
|
|
@@ -85,6 +87,6 @@ Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `READM
|
|
|
85
87
|
## Anti-patterns
|
|
86
88
|
|
|
87
89
|
- Calling `window.grist.*` inside widget code instead of the SDK's hooks
|
|
88
|
-
- Describing a
|
|
89
|
-
|
|
90
|
+
- Describing a seeded offline `pnpm dev` preview — that doesn't exist in this template yet
|
|
91
|
+
(only a seeded offline `pnpm test`, via `renderWithGrist`, does)
|
|
90
92
|
- Merging `dev` into `main` without bumping `package.json`'s `version` first
|
package/template/README.md
CHANGED
|
@@ -8,12 +8,12 @@ Opened outside a Grist iframe, `main.tsx` picks between two components purely by
|
|
|
8
8
|
|
|
9
9
|
When actually embedded, `GristStatusChip` (`src/components/grist-status-chip.tsx`) shows a small pill with live handshake status — connecting, retrying with a countdown, connected, or unavailable — using `useAmbientGristHandshake()` from `grist-widget-sdk/advanced`. It must be mounted *inside* `GristWidgetProvider` (see `src/main.tsx`): it only observes that provider's own handshake manager and never mounts a second one, so it's purely observational and can never duplicate or race the real handshake (see the [handshake API reference](https://gristwidgets.com/docs/sdk/api/handshake)).
|
|
10
10
|
|
|
11
|
-
`src/App.tsx` uses `useGrist<TaskRow, TaskMapped>()` for the selected row (`w.record`, `w.mode`) and remounts the UI with `key={rowKey}` when the row changes — same pattern as `widgets/create-email-draft`. See `src/grist-types.example.ts` for typing patterns.
|
|
11
|
+
`src/App.tsx` uses `useGrist<TaskRow, TaskMapped>()` for the selected row (`w.record`, `w.mode`) and remounts the UI with `key={rowKey}` when the row changes — same pattern as `widgets/create-email-draft`. It ships with two mapped columns (`title`, `done`) and an editable form that saves back via `w.table.update(...)`/`w.mapBack(...)` — the smallest real starting point, not a placeholder. See `src/grist-types.example.ts` for typing patterns.
|
|
12
12
|
|
|
13
13
|
- **ESLint** blocks direct `grist` global usage in `src/` — use the SDK only.
|
|
14
|
-
-
|
|
14
|
+
- Edit `GRIST_OPTIONS.columns` in `App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `main.tsx` sets `GristBoundary gate="canRender"` whenever columns are declared. Mapping alerts use `GristSdkAlerts`.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Run `pnpm test` — see `src/App.test.tsx` for the pattern (`renderWithGrist` + `presets` from `grist-widget-sdk/emulator/testing`, no browser or real Grist doc needed). Full guide: [Testing](https://gristwidgets.com/docs/sdk/guide/testing).
|
|
17
17
|
|
|
18
18
|
**Monorepo dev:** this template resolves `grist-widget-sdk` from `packages/core/dist` (like the other widgets), not from SDK source. After changing the SDK, run `pnpm prebuild` or `pnpm --filter grist-widget-sdk build` before `pnpm dev`.
|
|
19
19
|
|
package/template/package.json
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"lint": "eslint .",
|
|
10
10
|
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
|
11
11
|
"typecheck": "tsc -b",
|
|
12
|
-
"preview": "vite preview"
|
|
12
|
+
"preview": "vite preview",
|
|
13
|
+
"test": "vitest run"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"@fontsource-variable/geist": "^5.2.8",
|
|
@@ -28,6 +29,8 @@
|
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@eslint/js": "^9.39.4",
|
|
32
|
+
"@testing-library/jest-dom": "^6",
|
|
33
|
+
"@testing-library/react": "^16.3.2",
|
|
31
34
|
"@types/node": "^24.12.0",
|
|
32
35
|
"@types/react": "^19.2.14",
|
|
33
36
|
"@types/react-dom": "^19.2.3",
|
|
@@ -36,12 +39,14 @@
|
|
|
36
39
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
37
40
|
"eslint-plugin-react-refresh": "^0.5.2",
|
|
38
41
|
"globals": "^16.5.0",
|
|
42
|
+
"jsdom": "^29.0.2",
|
|
39
43
|
"prettier": "^3.8.1",
|
|
40
44
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
41
45
|
"shadcn": "^4.16.1",
|
|
42
46
|
"typescript": "~5.9.3",
|
|
43
47
|
"typescript-eslint": "^8.57.1",
|
|
44
|
-
"vite": "^7.3.1"
|
|
48
|
+
"vite": "^7.3.1",
|
|
49
|
+
"vitest": "^4.1.4"
|
|
45
50
|
},
|
|
46
51
|
"packageManager": "pnpm@11.1.3"
|
|
47
52
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirrors `main.tsx`'s real wiring (`GristWidgetProvider` -> `GristBoundary`
|
|
3
|
+
* -> the widget) without importing `main.tsx` itself, so this test exercises
|
|
4
|
+
* the same gate logic the embedded widget actually runs under. See
|
|
5
|
+
* `packages/core/tests/sdk/template-app.test.tsx` in the SDK repo for the
|
|
6
|
+
* same pattern applied to a bare shell.
|
|
7
|
+
*/
|
|
8
|
+
import "@testing-library/jest-dom/vitest"
|
|
9
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
10
|
+
import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
|
|
11
|
+
import {
|
|
12
|
+
act,
|
|
13
|
+
actionsOf,
|
|
14
|
+
cleanup,
|
|
15
|
+
fireEvent,
|
|
16
|
+
presets,
|
|
17
|
+
renderWithGrist,
|
|
18
|
+
screen,
|
|
19
|
+
waitFor,
|
|
20
|
+
} from "grist-widget-sdk/emulator/testing"
|
|
21
|
+
|
|
22
|
+
import App, { GRIST_OPTIONS } from "./App"
|
|
23
|
+
|
|
24
|
+
// Not using vitest's `globals: true` (see vite.config.ts), so
|
|
25
|
+
// @testing-library/react's automatic afterEach cleanup never registers
|
|
26
|
+
// itself — do it explicitly, or the second test in this file finds two
|
|
27
|
+
// "Title" inputs left over from the first render.
|
|
28
|
+
afterEach(() => cleanup())
|
|
29
|
+
|
|
30
|
+
function Wrapped() {
|
|
31
|
+
return (
|
|
32
|
+
<GristWidgetProvider options={GRIST_OPTIONS}>
|
|
33
|
+
<GristBoundary gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}>
|
|
34
|
+
<App />
|
|
35
|
+
</GristBoundary>
|
|
36
|
+
</GristWidgetProvider>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("App", () => {
|
|
41
|
+
it("shows the selected row's mapped title and done state", async () => {
|
|
42
|
+
const { emulator } = renderWithGrist(<Wrapped />, {
|
|
43
|
+
emulator: { document: presets.todoList() },
|
|
44
|
+
})
|
|
45
|
+
// `columns` in GRIST_OPTIONS names the widget's own logical fields — a
|
|
46
|
+
// real Grist host maps them to real column ids via the config panel;
|
|
47
|
+
// the emulator needs the same mapping told to it explicitly (name
|
|
48
|
+
// matching alone doesn't auto-map, here or in real Grist).
|
|
49
|
+
emulator.setColumnMappings({ title: "title", done: "done" })
|
|
50
|
+
|
|
51
|
+
await waitFor(() => {
|
|
52
|
+
expect(screen.getByLabelText("Title")).toHaveValue("Buy milk")
|
|
53
|
+
expect(screen.getByLabelText("Mark as done")).not.toBeChecked()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("saves an edited title back to Grist", async () => {
|
|
58
|
+
const { emulator } = renderWithGrist(<Wrapped />, {
|
|
59
|
+
emulator: { document: presets.todoList() },
|
|
60
|
+
})
|
|
61
|
+
emulator.setColumnMappings({ title: "title", done: "done" })
|
|
62
|
+
|
|
63
|
+
await waitFor(() => expect(screen.getByLabelText("Title")).toHaveValue("Buy milk"))
|
|
64
|
+
|
|
65
|
+
fireEvent.change(screen.getByLabelText("Title"), {
|
|
66
|
+
target: { value: "Buy oat milk" },
|
|
67
|
+
})
|
|
68
|
+
await act(async () => {
|
|
69
|
+
fireEvent.click(screen.getByText("Save"))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect(actionsOf(emulator)).toContainEqual([
|
|
74
|
+
"UpdateRecord",
|
|
75
|
+
"Tasks",
|
|
76
|
+
1,
|
|
77
|
+
{ title: "Buy oat milk", done: false },
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
package/template/src/App.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { useState, type FormEvent } from "react"
|
|
2
|
+
|
|
1
3
|
import { Button } from "@/components/ui/button"
|
|
4
|
+
import { Input } from "@/components/ui/input"
|
|
2
5
|
import {
|
|
3
6
|
useGrist,
|
|
4
7
|
useWidgetMetadata,
|
|
@@ -9,18 +12,21 @@ import {
|
|
|
9
12
|
import type { TaskMapped, TaskRow } from "./grist-types.example"
|
|
10
13
|
|
|
11
14
|
export const GRIST_OPTIONS: UseGristOptions = {
|
|
12
|
-
requiredAccess: "
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
requiredAccess: "full",
|
|
16
|
+
columns: [
|
|
17
|
+
{ name: "title", type: "Text" },
|
|
18
|
+
{ name: "done", type: "Bool" },
|
|
19
|
+
],
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export const WIDGET_METADATA = {
|
|
18
23
|
title: "Grist Widget Template",
|
|
19
|
-
description:
|
|
20
|
-
"Starter template for building Grist widgets with React and Vite.",
|
|
24
|
+
description: "Edit the selected row's title and mark it done.",
|
|
21
25
|
} as const
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
type TemplateGrist = UseGristResult<TaskRow, TaskMapped>
|
|
28
|
+
|
|
29
|
+
function GristSelectionDebug({ w }: { w: TemplateGrist }) {
|
|
24
30
|
return (
|
|
25
31
|
<details className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 p-3 text-xs">
|
|
26
32
|
<summary className="cursor-pointer font-medium text-muted-foreground">
|
|
@@ -45,7 +51,88 @@ function GristSelectionDebug({ w }: { w: UseGristResult }) {
|
|
|
45
51
|
)
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
type Draft = { title: string; done: boolean }
|
|
55
|
+
|
|
56
|
+
function readDraft(mapped: TaskMapped | null): Draft {
|
|
57
|
+
return { title: mapped?.title ?? "", done: mapped?.done ?? false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function RowEditor({ w }: { w: TemplateGrist }) {
|
|
61
|
+
const [draft, setDraft] = useState<Draft>(() => readDraft(w.mappedRecord))
|
|
62
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
63
|
+
|
|
64
|
+
async function save(e: FormEvent) {
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
if (!w.record) return
|
|
67
|
+
setSaveError(null)
|
|
68
|
+
try {
|
|
69
|
+
await w.table.update({
|
|
70
|
+
id: w.record.id,
|
|
71
|
+
fields: w.mapBack({ title: draft.title, done: draft.done }),
|
|
72
|
+
})
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setSaveError(err instanceof Error ? err.message : String(err))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<form onSubmit={save} className="flex flex-col gap-4 p-6 text-sm">
|
|
80
|
+
<header>
|
|
81
|
+
<h1 className="font-medium">Row #{String(w.record!.id)}</h1>
|
|
82
|
+
<p className="text-muted-foreground">
|
|
83
|
+
Edit the fields, then save back to Grist via{" "}
|
|
84
|
+
<code className="rounded bg-muted px-1 text-xs">table.update</code>.
|
|
85
|
+
</p>
|
|
86
|
+
</header>
|
|
87
|
+
|
|
88
|
+
<label className="flex flex-col gap-1">
|
|
89
|
+
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
90
|
+
Title
|
|
91
|
+
</span>
|
|
92
|
+
<Input
|
|
93
|
+
required
|
|
94
|
+
value={draft.title}
|
|
95
|
+
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
|
|
96
|
+
/>
|
|
97
|
+
</label>
|
|
98
|
+
|
|
99
|
+
<label className="flex items-center gap-2">
|
|
100
|
+
<input
|
|
101
|
+
type="checkbox"
|
|
102
|
+
className="size-4 rounded border-input"
|
|
103
|
+
checked={draft.done}
|
|
104
|
+
onChange={(e) => setDraft((d) => ({ ...d, done: e.target.checked }))}
|
|
105
|
+
/>
|
|
106
|
+
<span>Mark as done</span>
|
|
107
|
+
</label>
|
|
108
|
+
|
|
109
|
+
{saveError ? (
|
|
110
|
+
<p className="text-xs text-destructive">{saveError}</p>
|
|
111
|
+
) : null}
|
|
112
|
+
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<Button type="submit" disabled={w.actionStatus === "running"}>
|
|
115
|
+
{w.actionStatus === "running" ? "Saving…" : "Save"}
|
|
116
|
+
</Button>
|
|
117
|
+
<Button
|
|
118
|
+
type="button"
|
|
119
|
+
variant="outline"
|
|
120
|
+
onClick={() => setDraft(readDraft(w.mappedRecord))}
|
|
121
|
+
disabled={w.actionStatus === "running"}
|
|
122
|
+
>
|
|
123
|
+
Reset
|
|
124
|
+
</Button>
|
|
125
|
+
{w.actionStatus === "error" && w.actionError ? (
|
|
126
|
+
<span className="text-xs text-destructive">{w.actionError}</span>
|
|
127
|
+
) : null}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<GristSelectionDebug w={w} />
|
|
131
|
+
</form>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function TemplateBody({ w }: { w: TemplateGrist }) {
|
|
49
136
|
if (w.mode === "empty") {
|
|
50
137
|
return (
|
|
51
138
|
<div className="flex flex-col gap-4 p-6 text-sm">
|
|
@@ -64,22 +151,19 @@ function TemplateBody({ w }: { w: UseGristResult }) {
|
|
|
64
151
|
)
|
|
65
152
|
}
|
|
66
153
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<div className="flex
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<Button className="mt-2">Button</Button>
|
|
75
|
-
</div>
|
|
154
|
+
if (!w.columnMappingStatus.ok) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="flex flex-col gap-4 p-6 text-sm">
|
|
157
|
+
<p>
|
|
158
|
+
Open the widget configuration panel and map the two required
|
|
159
|
+
columns: Title, Done.
|
|
160
|
+
</p>
|
|
76
161
|
<GristSelectionDebug w={w} />
|
|
77
|
-
<div className="font-mono text-xs text-muted-foreground">
|
|
78
|
-
(Press <kbd>d</kbd> to toggle dark mode)
|
|
79
|
-
</div>
|
|
80
162
|
</div>
|
|
81
|
-
|
|
82
|
-
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return <RowEditor key={String(w.record!.id)} w={w} />
|
|
83
167
|
}
|
|
84
168
|
|
|
85
169
|
/**
|
package/template/vite.config.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { readFileSync } from "fs"
|
|
|
2
2
|
import path from "path"
|
|
3
3
|
import tailwindcss from "@tailwindcss/vite"
|
|
4
4
|
import react from "@vitejs/plugin-react"
|
|
5
|
-
|
|
5
|
+
// `vitest/config`'s defineConfig is a superset of vite's own -- same build
|
|
6
|
+
// config still works, and it adds the `test` field below. Importing it here
|
|
7
|
+
// (rather than a separate vitest.config.ts) means `pnpm test` reuses this
|
|
8
|
+
// file's `@` alias and plugins instead of duplicating them.
|
|
9
|
+
import { defineConfig } from "vitest/config"
|
|
6
10
|
|
|
7
11
|
// Read here (Node context) rather than importing package.json into src/ --
|
|
8
12
|
// keeps the whole file out of the bundle, exposing only the one field the
|
|
@@ -20,4 +24,7 @@ export default defineConfig({
|
|
|
20
24
|
define: {
|
|
21
25
|
__CREATE_GRIST_WIDGET_VERSION__: JSON.stringify(pkg.createGristWidgetVersion ?? null),
|
|
22
26
|
},
|
|
27
|
+
test: {
|
|
28
|
+
environment: "jsdom",
|
|
29
|
+
},
|
|
23
30
|
})
|