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 +1 -1
- package/template/.github/workflows/deploy.yml +7 -3
- package/template/AGENTS.md +6 -4
- package/template/README.md +6 -5
- package/template/package.json +7 -2
- package/template/scripts/deploy.mjs +93 -17
- 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
|
@@ -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
|
|
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
|
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
|
|
|
@@ -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`
|
|
44
|
-
`
|
|
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
|
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
|
}
|
|
@@ -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):
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
-
|
|
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
|
+
})
|
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
|
})
|