create-grist-widget 0.2.9 → 0.2.10

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.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,9 @@ Base template to build a Grist widget with `grist-widget-sdk`.
4
4
 
5
5
  `src/main.tsx` wraps the app with `GristWidgetProvider`, `GristBoundary`, and `GristSdkAlerts`. The latter maps `getGristSdkAlertDescriptors()` from the SDK to shadcn `Alert` (`src/components/grist-sdk-alerts.tsx` + `src/components/ui/alert.tsx`); keep them in sync with the playground when you change alert styling.
6
6
 
7
- Opened outside a Grist iframe, `main.tsx` picks between two components purely by URL shape (`src/lib/showcase-routing.ts`, no router needed): a bare path with no recognized channel suffix renders `TemplateLanding` (onboarding + released-version index); `/latest/`, `/dev/`, or `/v<version>/` renders the minimal `ChannelNotice` instead — which build this is, a link back to the hub, and a copy-this-URL helper for pasting into Grist's custom widget field.
7
+ Opened outside a Grist iframe, `main.tsx` picks between two components purely by URL shape (`src/lib/showcase-routing.ts`, no router needed): a bare path with no recognized channel suffix renders `TemplateLanding` (its own hero, onboarding, and released-version index); `/latest/`, `/dev/`, or `/v<version>/` renders `ChannelNotice` instead — a distinct hero explaining which build this is, chips to jump to any other version/channel (`src/lib/showcase-versions.ts`), and a copy-this-URL helper for pasting into Grist's custom widget field.
8
+
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 `useGristHandshakeContext()` from `grist-widget-sdk/advanced` in its own `<GristHandshakeProvider>`. Safe to mount alongside `GristWidgetProvider`: both share the page's `ensureGristReady()` singleton (see `apps/docs/api/handshake.md`), so this is purely observational and never duplicates the real handshake.
8
10
 
9
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.
10
12
 
@@ -10,13 +10,58 @@ import {
10
10
  } from "@/components/ui/card"
11
11
  import { Input } from "@/components/ui/input"
12
12
  import { channelLabel, type Channel } from "@/lib/showcase-routing"
13
+ import { DEV_URL, useVersions, versionUrl } from "@/lib/showcase-versions"
14
+ import { cn } from "@/lib/utils"
13
15
 
14
16
  // Shown instead of the widget tree on a specific channel build
15
17
  // (/latest/, /dev/, /v<version>/) when it's opened directly in a browser tab
16
18
  // -- not the rich showcase hub (that's `TemplateLanding`, shown when the URL
17
- // has no recognized channel suffix). Deliberately minimal: this page's job is
18
- // just to explain what you're looking at, link back to the hub, and help you
19
- // paste this exact URL into Grist.
19
+ // has no recognized channel suffix). Deliberately minimal, with its own hero
20
+ // distinct from the hub's: this page's job is just to explain what you're
21
+ // looking at, help you jump to a different build, and help you paste this
22
+ // exact URL into Grist.
23
+ function VersionChips({ current }: { current: Channel }) {
24
+ const { versions } = useVersions()
25
+
26
+ function chipClass(active: boolean) {
27
+ return cn(
28
+ "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
29
+ active
30
+ ? "border-primary bg-primary text-primary-foreground"
31
+ : "border-border text-muted-foreground hover:text-foreground",
32
+ )
33
+ }
34
+
35
+ return (
36
+ <div className="mb-8 flex flex-wrap justify-center gap-2">
37
+ {versions !== null && versions.length > 0 && (
38
+ <a
39
+ href={versionUrl(versions[0].version)}
40
+ className={chipClass(
41
+ current.kind === "version" && current.version === versions[0].version,
42
+ )}
43
+ >
44
+ latest
45
+ </a>
46
+ )}
47
+ <a href={DEV_URL} className={chipClass(current.kind === "dev")}>
48
+ dev
49
+ </a>
50
+ {versions?.map((v) => (
51
+ <a
52
+ key={v.version}
53
+ href={versionUrl(v.version)}
54
+ className={chipClass(
55
+ current.kind === "version" && current.version === v.version,
56
+ )}
57
+ >
58
+ v{v.version}
59
+ </a>
60
+ ))}
61
+ </div>
62
+ )
63
+ }
64
+
20
65
  export function ChannelNotice({
21
66
  channel,
22
67
  hubPath,
@@ -47,6 +92,8 @@ export function ChannelNotice({
47
92
  build directly, outside of Grist.
48
93
  </p>
49
94
 
95
+ <VersionChips current={channel} />
96
+
50
97
  <Card className="mb-4">
51
98
  <CardHeader>
52
99
  <CardTitle>Use this as a Grist custom widget</CardTitle>
@@ -0,0 +1,105 @@
1
+ import { useEffect, useState } from "react"
2
+ import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"
3
+
4
+ import { deriveDisplayStatus } from "@/lib/grist-handshake-status"
5
+ import { cn } from "@/lib/utils"
6
+
7
+ // Fixed, approximate countdown shown while retrying -- not a mirror of the
8
+ // SDK's actual internal poll backoff (an implementation detail, not part of
9
+ // the public API surface, which ramps up from ~200ms before settling into a
10
+ // steady cadence). Most production widgets connect in under a second per the
11
+ // SDK's own docs, so a visibly "retrying" chip is already in that settled
12
+ // long tail -- this just picks the same round number as a display countdown.
13
+ const RETRY_COUNTDOWN_SECONDS = 5
14
+
15
+ function useRetryCountdown(attempts: number, active: boolean) {
16
+ const [state, setState] = useState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
17
+
18
+ // Reset the countdown the moment a new retry attempt begins -- an
19
+ // "adjust state during render" pattern, not a setState-in-effect (which
20
+ // the linter flags): https://react.dev/learn/you-might-not-need-an-effect
21
+ if (active && state.attempts !== attempts) {
22
+ setState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
23
+ }
24
+
25
+ useEffect(() => {
26
+ if (!active) return
27
+ const id = setInterval(() => {
28
+ setState((s) => ({ ...s, secondsLeft: s.secondsLeft > 0 ? s.secondsLeft - 1 : 0 }))
29
+ }, 1000)
30
+ return () => clearInterval(id)
31
+ }, [attempts, active])
32
+
33
+ return state.secondsLeft
34
+ }
35
+
36
+ function StatusDot({ pulsing, className }: { pulsing: boolean; className: string }) {
37
+ return (
38
+ <span className="relative flex size-2">
39
+ {pulsing && (
40
+ <span
41
+ className={cn(
42
+ "absolute inline-flex size-full animate-ping rounded-full opacity-75",
43
+ className,
44
+ )}
45
+ />
46
+ )}
47
+ <span className={cn("relative inline-flex size-2 rounded-full", className)} />
48
+ </span>
49
+ )
50
+ }
51
+
52
+ function ChipContents() {
53
+ const { snapshot } = useGristHandshakeContext()
54
+ const display = deriveDisplayStatus(snapshot.lifecycle)
55
+ const retryAttempts = display.kind === "retrying" ? display.attempts : 0
56
+ const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
57
+
58
+ switch (display.kind) {
59
+ case "connecting":
60
+ return (
61
+ <>
62
+ <StatusDot pulsing className="bg-amber-500" />
63
+ Connecting to Grist
64
+ </>
65
+ )
66
+ case "retrying":
67
+ return (
68
+ <>
69
+ <StatusDot pulsing className="bg-amber-500" />
70
+ Retry Grist connection in {secondsLeft}s
71
+ </>
72
+ )
73
+ case "connected":
74
+ return (
75
+ <>
76
+ <StatusDot pulsing={false} className="bg-emerald-500" />
77
+ Grist connected
78
+ </>
79
+ )
80
+ case "unavailable":
81
+ return (
82
+ <>
83
+ <StatusDot pulsing={false} className="bg-destructive" />
84
+ Grist unavailable
85
+ </>
86
+ )
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Small pill showing live Grist handshake status. Mounts its own
92
+ * `<GristHandshakeProvider>` alongside `<GristWidgetProvider>` -- documented
93
+ * (grist-widget-sdk/advanced) as safe to coexist, since both share the
94
+ * page's `ensureGristReady()` singleton. This chip is purely observational:
95
+ * it never drives the real widget's connection, just reflects it.
96
+ */
97
+ export function GristStatusChip() {
98
+ return (
99
+ <GristHandshakeProvider>
100
+ <div className="pointer-events-none fixed top-2 right-2 z-50 flex items-center gap-1.5 rounded-full border bg-background/90 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
101
+ <ChipContents />
102
+ </div>
103
+ </GristHandshakeProvider>
104
+ )
105
+ }
@@ -1,56 +1,16 @@
1
- import { useEffect, useState } from "react"
2
-
3
1
  import { Button } from "@/components/ui/button"
2
+ import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
4
3
 
5
4
  // The showcase "hub" page: rendered whenever this deploy's URL has no
6
5
  // recognized channel suffix (see src/lib/showcase-routing.ts) -- this repo's
7
6
  // own bare /template/, or a local `pnpm dev` server. Individual channel
8
7
  // builds (/latest/, /dev/, /v<version>/) render `ChannelNotice` instead when
9
- // not embedded in Grist, with a link back here. Same component for this
10
- // repo's own showcase as for a real scaffolded widget that happens to have
11
- // something deployed at its own bare root; the only per-deploy difference is
12
- // which /v<version>/ dirs actually exist upstream, which is why the version
13
- // list is fetched from a fixed absolute URL rather than a path relative to
14
- // wherever this happens to be deployed.
15
- const VERSIONS_URL =
16
- "https://arthurblanchon.github.io/grist-widget-sdk/template/versions.json"
17
- const DEV_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/dev/"
18
-
19
- type ReleasedVersion = {
20
- version: string
21
- publishedAt: string
22
- }
23
-
24
- function useVersions() {
25
- const [versions, setVersions] = useState<ReleasedVersion[] | null>(null)
26
- const [error, setError] = useState(false)
27
-
28
- useEffect(() => {
29
- let cancelled = false
30
- fetch(VERSIONS_URL, { cache: "no-store" })
31
- .then((res) => (res.ok ? res.json() : Promise.reject(res)))
32
- .then((data: ReleasedVersion[]) => {
33
- if (!cancelled) setVersions(data)
34
- })
35
- .catch(() => {
36
- if (!cancelled) setError(true)
37
- })
38
- return () => {
39
- cancelled = true
40
- }
41
- }, [])
42
-
43
- return { versions, error }
44
- }
45
-
46
- function formatDate(iso: string) {
47
- return new Date(iso).toLocaleDateString(undefined, {
48
- year: "numeric",
49
- month: "short",
50
- day: "numeric",
51
- })
52
- }
53
-
8
+ // not embedded in Grist, with a link back here. Its hero introduces the
9
+ // template project itself -- deliberately different from ChannelNotice's
10
+ // hero, which is about the specific build you're looking at. Same component
11
+ // for this repo's own showcase as for a real scaffolded widget that happens
12
+ // to have something deployed at its own bare root; the only per-deploy
13
+ // difference is which /v<version>/ dirs actually exist upstream.
54
14
  export function TemplateLanding() {
55
15
  const { versions, error } = useVersions()
56
16
 
@@ -61,12 +21,12 @@ export function TemplateLanding() {
61
21
  grist-widget-template
62
22
  </p>
63
23
  <h1 className="font-heading text-3xl font-semibold tracking-tight">
64
- Grist isn't loaded here
24
+ Build a Grist custom widget in minutes
65
25
  </h1>
66
26
  <p className="mx-auto mt-3 max-w-md text-sm text-muted-foreground">
67
- This page only renders inside a Grist custom widget iframe. Paste
68
- its URL into Grist's custom widget field, or read on to scaffold
69
- your own copy.
27
+ A React + TypeScript + Tailwind starting point for{" "}
28
+ <code className="text-foreground">grist-widget-sdk</code>. Scaffold
29
+ your own copy, or browse what's already been released below.
70
30
  </p>
71
31
  </header>
72
32
 
@@ -147,7 +107,7 @@ git push origin dev`}</code>
147
107
  </span>
148
108
  </div>
149
109
  <a
150
- href={`https://arthurblanchon.github.io/grist-widget-sdk/template/v${v.version}/`}
110
+ href={versionUrl(v.version)}
151
111
  className="text-sm underline underline-offset-4 hover:text-foreground"
152
112
  >
153
113
  Preview
@@ -0,0 +1,26 @@
1
+ import type { GristLifecycle } from "grist-widget-sdk/advanced"
2
+
3
+ // Projects the SDK's full GristLifecycle (advanced API, grist-widget-sdk/advanced)
4
+ // down to the handful of states a small status chip needs to show.
5
+ export type HandshakeDisplayStatus =
6
+ | { kind: "connecting" }
7
+ | { kind: "retrying"; attempts: number }
8
+ | { kind: "connected" }
9
+ | { kind: "unavailable" }
10
+
11
+ export function deriveDisplayStatus(lifecycle: GristLifecycle): HandshakeDisplayStatus {
12
+ switch (lifecycle.phase) {
13
+ case "idle":
14
+ return { kind: "connecting" }
15
+ case "detecting":
16
+ return lifecycle.attempts > 0
17
+ ? { kind: "retrying", attempts: lifecycle.attempts }
18
+ : { kind: "connecting" }
19
+ case "negotiating":
20
+ return { kind: "connecting" }
21
+ case "online":
22
+ return { kind: "connected" }
23
+ case "terminated":
24
+ return { kind: "unavailable" }
25
+ }
26
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ // Shared by the showcase hub (TemplateLanding) and the per-channel notice
4
+ // (ChannelNotice) -- both need the same released-version list, fetched from
5
+ // a fixed absolute URL rather than a path relative to wherever this happens
6
+ // to be deployed (see src/components/template-landing.tsx for why).
7
+ export const VERSIONS_URL =
8
+ "https://arthurblanchon.github.io/grist-widget-sdk/template/versions.json"
9
+ export const DEV_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/dev/"
10
+ export const HUB_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/"
11
+
12
+ export type ReleasedVersion = {
13
+ version: string
14
+ publishedAt: string
15
+ }
16
+
17
+ export function versionUrl(version: string): string {
18
+ return `https://arthurblanchon.github.io/grist-widget-sdk/template/v${version}/`
19
+ }
20
+
21
+ export function useVersions() {
22
+ const [versions, setVersions] = useState<ReleasedVersion[] | null>(null)
23
+ const [error, setError] = useState(false)
24
+
25
+ useEffect(() => {
26
+ let cancelled = false
27
+ fetch(VERSIONS_URL, { cache: "no-store" })
28
+ .then((res) => (res.ok ? res.json() : Promise.reject(res)))
29
+ .then((data: ReleasedVersion[]) => {
30
+ if (!cancelled) setVersions(data)
31
+ })
32
+ .catch(() => {
33
+ if (!cancelled) setError(true)
34
+ })
35
+ return () => {
36
+ cancelled = true
37
+ }
38
+ }, [])
39
+
40
+ return { versions, error }
41
+ }
42
+
43
+ export function formatDate(iso: string) {
44
+ return new Date(iso).toLocaleDateString(undefined, {
45
+ year: "numeric",
46
+ month: "short",
47
+ day: "numeric",
48
+ })
49
+ }
@@ -4,6 +4,7 @@ import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
4
4
 
5
5
  import { ChannelNotice } from "@/components/channel-notice"
6
6
  import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
7
+ import { GristStatusChip } from "@/components/grist-status-chip"
7
8
  import { TemplateLanding } from "@/components/template-landing"
8
9
  import { ThemeProvider } from "@/components/theme-provider.tsx"
9
10
  import { parseShowcasePath } from "@/lib/showcase-routing"
@@ -34,17 +35,20 @@ createRoot(document.getElementById("root")!).render(
34
35
  <TemplateLanding />
35
36
  </div>
36
37
  ) : isEmbedded ? (
37
- <GristWidgetProvider options={GRIST_OPTIONS}>
38
- <GristBoundary
39
- gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
40
- >
41
- <div className="min-h-full w-full bg-background text-foreground">
42
- <GristSdkAlerts>
43
- <App />
44
- </GristSdkAlerts>
45
- </div>
46
- </GristBoundary>
47
- </GristWidgetProvider>
38
+ <>
39
+ <GristStatusChip />
40
+ <GristWidgetProvider options={GRIST_OPTIONS}>
41
+ <GristBoundary
42
+ gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
43
+ >
44
+ <div className="min-h-full w-full bg-background text-foreground">
45
+ <GristSdkAlerts>
46
+ <App />
47
+ </GristSdkAlerts>
48
+ </div>
49
+ </GristBoundary>
50
+ </GristWidgetProvider>
51
+ </>
48
52
  ) : (
49
53
  <div className="min-h-full w-full bg-background text-foreground">
50
54
  <ChannelNotice channel={channel} hubPath={hubPath} />