create-grist-widget 0.3.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,92 @@
1
+ # Agent guide — start here
2
+
3
+ > Audience: an agent working inside a widget repo scaffolded from
4
+ > `create-grist-widget` (this file travels with the scaffold), or working on
5
+ > this template's own source under
6
+ > `templates/grist-widget-template-vite/` inside the `grist-widget-sdk`
7
+ > monorepo. This repo is a **distribution artifact** — `main` is force-pushed
8
+ > from a fresh scaffold on every release, not developed on directly upstream.
9
+ > If you're in the monorepo working on the SDK itself, read the root
10
+ > `AGENTS.md` instead.
11
+
12
+ ## The widget contract
13
+
14
+ `src/App.tsx` is the whole widget — one file, three exports:
15
+
16
+ ```tsx
17
+ export const GRIST_OPTIONS: UseGristOptions = { requiredAccess: "read table" }
18
+ export const WIDGET_METADATA = { title: "...", description: "..." } as const
19
+ export function App() {
20
+ const w = useGrist()
21
+ // ...
22
+ }
23
+ ```
24
+
25
+ `src/main.tsx` wires it into Grist: `GristWidgetProvider` (reads `GRIST_OPTIONS`) →
26
+ `GristBoundary` → `GristSdkAlerts` → `<App />`. Access level, column mapping, and
27
+ rendering all live in `src/App.tsx` — that's the one file to edit.
28
+
29
+ ## Operating contract
30
+
31
+ 1. **Widgets use the SDK.** No direct `window.grist.*` calls — use `useGrist()` and the
32
+ other `grist-widget-sdk` hooks.
33
+ 2. **`pnpm dev` shows the showcase hub today, not a seeded preview.** Opened outside Grist
34
+ it renders `TemplateLanding` (onboarding + links to released versions); the widget
35
+ itself only renders once actually embedded in a Grist document. An offline, seeded
36
+ `pnpm dev` loop is planned but not shipped in this template yet — don't describe one as
37
+ if it exists.
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
+ 4. **Always develop on `dev`, release by merging to `main`.** Commit and push to `dev` for
41
+ every change — it auto-deploys a live preview at `.../dev/` that self-reloads inside an
42
+ open Grist document a few seconds later. Bump `package.json`'s `version` *before*
43
+ opening the `dev` → `main` PR: merging without a version bump publishes nothing (the
44
+ release build silently no-ops whenever that version's directory already exists — the PR
45
+ merges cleanly and CI reports success either way).
46
+
47
+ ## Commands
48
+
49
+ | Intent | Command |
50
+ | --- | --- |
51
+ | Install | `pnpm install` |
52
+ | Dev server (showcase hub outside Grist) | `pnpm dev` |
53
+ | Build | `pnpm build` |
54
+ | Test | `pnpm test` |
55
+ | Lint | `pnpm lint` |
56
+ | Format | `pnpm format` |
57
+ | Type-check | `pnpm typecheck` |
58
+ | Preview a production build | `pnpm preview` |
59
+
60
+ ## Path map
61
+
62
+ | Concept | Path |
63
+ | --- | --- |
64
+ | The widget | `src/App.tsx` |
65
+ | Widget test (the pattern to copy for new tests) | `src/App.test.tsx` |
66
+ | Grist wiring / embed vs. showcase detection | `src/main.tsx` |
67
+ | ui primitives | `src/components/ui/` |
68
+ | Deploy workflow | `.github/workflows/deploy.yml` |
69
+ | Deploy script (version guard, channel logic) | `scripts/deploy.mjs` |
70
+ | Full walkthrough (dev loop, deploying, embedding) | `README.md` |
71
+ | Changelog | `CHANGELOG.md` |
72
+
73
+ ## One-time repo settings
74
+
75
+ Only needed once, after the first push triggers the deploy workflow's first run (it
76
+ creates a `gh-pages` branch — these settings can't be applied before that exists):
77
+
78
+ 1. Settings → Pages → Source → **Deploy from a branch** → branch `gh-pages`, folder
79
+ `/ (root)`. Not `main` — pointing Pages at `main` serves this repo's raw, unbuilt source
80
+ instead of the built site (symptom: a blank page with a `/src/main.tsx` 404).
81
+ 2. If that first workflow run fails with a permissions error pushing to `gh-pages`:
82
+ Settings → Actions → General → Workflow permissions → **Read and write permissions**.
83
+ Most repos don't need this; it depends on account/org defaults.
84
+
85
+ Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `README.md`.
86
+
87
+ ## Anti-patterns
88
+
89
+ - Calling `window.grist.*` inside widget code instead of the SDK's hooks
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)
92
+ - Merging `dev` into `main` without bumping `package.json`'s `version` first
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -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
- - Uncomment `GRIST_OPTIONS.columns` in `App.tsx` to enable column mapping; `main.tsx` sets `GristBoundary gate="canRender"` when columns are declared. Mapping alerts use `GristSdkAlerts`.
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
- To add widget tests later, see [Testing](https://gristwidgets.com/docs/sdk/guide/testing) (`renderWithGrist` from `grist-widget-sdk/emulator/testing`).
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
 
@@ -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
+ })
@@ -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: "read table",
13
- // Uncomment to require column mapping in the widget config panel:
14
- // columns: [{ name: "title", type: "Text" }, { name: "done", type: "Bool" }],
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
- function GristSelectionDebug({ w }: { w: UseGristResult }) {
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
- function TemplateBody({ w }: { w: UseGristResult }) {
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
- return (
68
- <div className="flex min-h-svh p-6">
69
- <div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
70
- <div>
71
- <h1 className="font-medium">Widget connected to Grist</h1>
72
- <p>Record id: {String(w.record?.id ?? "")}</p>
73
- <p>You can now render values from the selected record.</p>
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
- </div>
82
- )
163
+ )
164
+ }
165
+
166
+ return <RowEditor key={String(w.record!.id)} w={w} />
83
167
  }
84
168
 
85
169
  /**
@@ -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
- import { defineConfig } from "vite"
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
  })