create-grist-widget 0.3.5 → 0.3.7

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.5",
3
+ "version": "0.3.7",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,8 +12,12 @@ name: Deploy widget to GitHub Pages
12
12
  # v0.0.1 + /latest/ + root right away, discarding any inherited canary
13
13
  # version. On that same first release, the "Reset repo to a clean
14
14
  # first-release state" step below rewrites package.json's version back to
15
- # 0.0.1 in the repo itself, so the *next* release bumps to 0.0.2 rather
16
- # than the inherited number.
15
+ # 0.0.1 in the repo itself (so the *next* release bumps to 0.0.2 rather
16
+ # than the inherited number) and, since "Use this template" never runs
17
+ # the CLI's own rename step, also personalizes the four placeholder-
18
+ # identity spots (package.json's name, README.md's heading, index.html's
19
+ # <title>, App.tsx's title) to match this repo's real name -- a no-op
20
+ # wherever that placeholder text is already gone.
17
21
  # - push to dev -> dev channel: mutable /<repo>/dev/
18
22
  # with version.json + a self-reload snippet, for live review inside Grist.
19
23
  #
@@ -158,7 +162,7 @@ jobs:
158
162
  # run sees v0.0.1 already published (firstRelease=false) and no-ops.
159
163
  - name: Reset repo to a clean first-release state
160
164
  if: steps.plan.outputs.context == 'release' && steps.plan.outputs.firstRelease == 'true'
161
- run: node scripts/deploy.mjs reset-branches
165
+ run: node scripts/deploy.mjs reset-branches --repo "$REPO_NAME"
162
166
 
163
167
  cleanup:
164
168
  # When the `dev` branch is deleted, remove /dev/ so the dev URL retires
@@ -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. **No test command yet.** `package.json` has no `test` script in this template today —
39
- don't invent one or assume `pnpm test` works here.
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 `pnpm test` command or a seeded offline `pnpm dev` preview — neither exists
89
- in this template yet
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
@@ -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
 
@@ -40,8 +40,9 @@ This template has no live-preview deploy of its own inside this monorepo —
40
40
  that used to exist (a `/template/` showcase on this repo's own GitHub
41
41
  Pages) but has been retired. The only live preview of this template's
42
42
  current source is external: `template-canary.yml` scaffolds it via the
43
- *published* `create-grist-widget` package and pushes to the `dev` and
44
- `canary/latest` branches of
43
+ *published* `create-grist-widget` package and pushes to the `dev` branch
44
+ (pre-release preview) and directly to `main` (stable `@latest`, promotion is
45
+ automatic) of
45
46
  [`grist-widget-template`](https://github.com/ArthurBlanchon/grist-widget-template)
46
47
  after every release — see https://gristwidgets.com/docs/contributing/releasing for the full
47
48
  mechanism. There is no live preview of *unreleased* changes to this
@@ -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
  }
@@ -39,11 +39,13 @@
39
39
  // Regenerates versions.json (release runs only) and commits + pushes the
40
40
  // gh-pages tree with a rebase-and-retry loop.
41
41
  //
42
- // reset-branches [--repo-dir <dir>]
42
+ // reset-branches [--repo-dir <dir>] [--repo <name>]
43
43
  // On a repo's first genuine release only (caller gates on plan()'s
44
- // firstRelease): resets package.json's version to 0.0.1, prunes every
45
- // branch except main/dev/gh-pages, and
46
- // force-resets dev to main's current tip.
44
+ // firstRelease): renames the four placeholder-identity spots (only if
45
+ // --repo is given and they still carry the template's own placeholder
46
+ // text -- a no-op for a CLI scaffold that already renamed itself), resets
47
+ // package.json's version to 0.0.1, prunes every branch except
48
+ // main/dev/gh-pages, and force-resets dev to main's current tip.
47
49
 
48
50
  import {
49
51
  existsSync,
@@ -348,10 +350,56 @@ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteB
348
350
  throw lastErr
349
351
  }
350
352
 
353
+ /**
354
+ * Derive an npm-safe package name and a human title from a real GitHub repo
355
+ * name -- which allows characters (uppercase, underscores, periods) npm
356
+ * package names don't. Mirrors the title-casing
357
+ * `packages/create-grist-widget/bin/create-grist-widget.mjs` already applies
358
+ * to its own CLI-provided `rawName`, so a repo named e.g. `Ask-Genial_Widget`
359
+ * produces the same shape of package name / title a CLI user typing
360
+ * `ask-genial-widget` would have gotten.
361
+ */
362
+ export function deriveIdentity(repoName) {
363
+ const packageName =
364
+ (repoName || "")
365
+ .toLowerCase()
366
+ .replace(/[^a-z0-9-]+/g, "-")
367
+ .replace(/^-+|-+$/g, "") || "grist-widget"
368
+ const title = packageName
369
+ .split("-")
370
+ .filter(Boolean)
371
+ .map((w) => w[0].toUpperCase() + w.slice(1))
372
+ .join(" ")
373
+ return { packageName, title }
374
+ }
375
+
376
+ /**
377
+ * The same four placeholder-identity edits
378
+ * `packages/create-grist-widget/bin/create-grist-widget.mjs` applies at
379
+ * scaffold time, parameterized by a derived identity instead of CLI input.
380
+ * Each substitution is a plain string replace, so it's naturally a no-op
381
+ * wherever the placeholder text is already gone -- a CLI-scaffolded repo
382
+ * (whose name may legitimately differ from its GitHub repo name) is never
383
+ * touched by this.
384
+ */
385
+ function identityEdits({ packageName, title }) {
386
+ return [
387
+ ["package.json", [["grist-widget-template-vite", packageName]]],
388
+ ["README.md", [["grist-widget-template-vite", packageName]]],
389
+ ["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
390
+ ["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
391
+ ]
392
+ }
393
+
351
394
  /**
352
395
  * Establish the state every widget repo should start from, on its first
353
396
  * genuine release only (gated by the caller on `plan()`'s `firstRelease`):
354
397
  *
398
+ * 0. If `repo` is given, rewrite the four placeholder-identity spots (see
399
+ * `identityEdits`) to match the real repo name -- the piece "Use this
400
+ * template" skips entirely, since it copies the repo verbatim and never
401
+ * runs the CLI's own rename step. Folded into the same commit as the
402
+ * version reset below, not a separate one.
355
403
  * 1. Reset package.json's version to 0.0.1. The first release already
356
404
  * published v0.0.1 (forced by plan() regardless of package.json), but a
357
405
  * repo copied via GitHub's "Use this template" still carries the source
@@ -372,27 +420,55 @@ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteB
372
420
  * a genuine release already published (firstRelease=false) and skips both
373
421
  * the release and this reset, so there's no loop.
374
422
  */
375
- export function resetBranchesIfFirstRelease(repoDir, keep = ["main", "dev", "gh-pages"]) {
423
+ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev", "gh-pages"]) {
424
+ const changedFiles = new Set()
425
+ const renamedFiles = []
426
+
427
+ if (repo) {
428
+ for (const [file, subs] of identityEdits(deriveIdentity(repo))) {
429
+ const p = join(repoDir, file)
430
+ if (!existsSync(p)) continue
431
+ const before = readFileSync(p, "utf8")
432
+ const after = subs.reduce((text, [search, replace]) => text.split(search).join(replace), before)
433
+ if (after !== before) {
434
+ writeFileSync(p, after)
435
+ changedFiles.add(file)
436
+ renamedFiles.push(file)
437
+ }
438
+ }
439
+ }
440
+ const identityReset = renamedFiles.length > 0
441
+
376
442
  let versionReset = false
377
443
  const pkgPath = join(repoDir, "package.json")
378
444
  if (existsSync(pkgPath)) {
445
+ // Re-read after any identity edit above, so a rewritten `name` survives
446
+ // this JSON round-trip instead of being clobbered by it.
379
447
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
380
448
  if (pkg.version !== "0.0.1") {
381
449
  pkg.version = "0.0.1"
382
450
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
383
- git(["add", "package.json"], repoDir)
384
- git(
385
- [
386
- "-c", "user.name=github-actions[bot]",
387
- "-c", "user.email=41898282+github-actions[bot]@users.noreply.github.com",
388
- "commit", "-m", "chore: reset version to 0.0.1 for a clean first release",
389
- ],
390
- repoDir,
391
- )
392
- git(["push", "origin", "HEAD:refs/heads/main"], repoDir)
451
+ changedFiles.add("package.json")
393
452
  versionReset = true
394
453
  }
395
454
  }
455
+
456
+ if (changedFiles.size > 0) {
457
+ const parts = []
458
+ if (identityReset) parts.push("personalize scaffold")
459
+ if (versionReset) parts.push("reset version to 0.0.1")
460
+ git(["add", ...changedFiles], repoDir)
461
+ git(
462
+ [
463
+ "-c", "user.name=github-actions[bot]",
464
+ "-c", "user.email=41898282+github-actions[bot]@users.noreply.github.com",
465
+ "commit", "-m", `chore: ${parts.join(" + ")} for a clean first release`,
466
+ ],
467
+ repoDir,
468
+ )
469
+ git(["push", "origin", "HEAD:refs/heads/main"], repoDir)
470
+ }
471
+
396
472
  const raw = git(["ls-remote", "--heads", "origin"], repoDir)
397
473
  const branches = raw
398
474
  ? raw.split("\n").map((line) => line.split("refs/heads/")[1]).filter(Boolean)
@@ -404,7 +480,7 @@ export function resetBranchesIfFirstRelease(repoDir, keep = ["main", "dev", "gh-
404
480
  deletedBranches.push(b)
405
481
  }
406
482
  git(["push", "--force", "origin", "HEAD:refs/heads/dev"], repoDir)
407
- return { deletedBranches, devReset: true, versionReset }
483
+ return { deletedBranches, devReset: true, versionReset, identityReset, renamedFiles }
408
484
  }
409
485
 
410
486
  // ----------------------------------------------------------------------------
@@ -467,7 +543,7 @@ function main() {
467
543
  break
468
544
  }
469
545
  case "reset-branches": {
470
- const res = resetBranchesIfFirstRelease(args["repo-dir"] || ".")
546
+ const res = resetBranchesIfFirstRelease(args["repo-dir"] || ".", args.repo)
471
547
  console.log("reset-branches:", JSON.stringify(res))
472
548
  break
473
549
  }
@@ -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
  })