create-grist-widget 0.2.8 → 0.2.9

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.8",
3
+ "version": "0.2.9",
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,6 +4,8 @@ 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.
8
+
7
9
  `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.
8
10
 
9
11
  - **ESLint** blocks direct `grist` global usage in `src/` — use the SDK only.
@@ -0,0 +1,75 @@
1
+ import { useState } from "react"
2
+
3
+ import { Button } from "@/components/ui/button"
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from "@/components/ui/card"
11
+ import { Input } from "@/components/ui/input"
12
+ import { channelLabel, type Channel } from "@/lib/showcase-routing"
13
+
14
+ // Shown instead of the widget tree on a specific channel build
15
+ // (/latest/, /dev/, /v<version>/) when it's opened directly in a browser tab
16
+ // -- 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.
20
+ export function ChannelNotice({
21
+ channel,
22
+ hubPath,
23
+ }: {
24
+ channel: Channel
25
+ hubPath: string
26
+ }) {
27
+ const [copied, setCopied] = useState(false)
28
+ const url = window.location.href
29
+
30
+ function copyUrl() {
31
+ navigator.clipboard.writeText(url).then(() => {
32
+ setCopied(true)
33
+ setTimeout(() => setCopied(false), 1500)
34
+ })
35
+ }
36
+
37
+ return (
38
+ <div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
39
+ <p className="mb-2 text-center text-sm font-medium text-muted-foreground">
40
+ grist-widget-template · {channelLabel(channel)}
41
+ </p>
42
+ <h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
43
+ Grist isn't loaded here
44
+ </h1>
45
+ <p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
46
+ You're viewing the <span className="font-mono">{channelLabel(channel)}</span>{" "}
47
+ build directly, outside of Grist.
48
+ </p>
49
+
50
+ <Card className="mb-4">
51
+ <CardHeader>
52
+ <CardTitle>Use this as a Grist custom widget</CardTitle>
53
+ <CardDescription>
54
+ Paste this URL into Grist's custom widget URL field.
55
+ </CardDescription>
56
+ </CardHeader>
57
+ <CardContent>
58
+ <div className="flex gap-2">
59
+ <Input readOnly value={url} onFocus={(e) => e.target.select()} />
60
+ <Button type="button" onClick={copyUrl}>
61
+ {copied ? "Copied" : "Copy"}
62
+ </Button>
63
+ </div>
64
+ </CardContent>
65
+ </Card>
66
+
67
+ <a
68
+ href={hubPath}
69
+ className="text-center text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
70
+ >
71
+ ← Back to the template home
72
+ </a>
73
+ </div>
74
+ )
75
+ }
@@ -2,16 +2,19 @@ import { useEffect, useState } from "react"
2
2
 
3
3
  import { Button } from "@/components/ui/button"
4
4
 
5
- // Shown instead of the widget tree when this page is opened directly in a
6
- // browser tab (not embedded in a Grist iframe) -- i.e. exactly the situation
7
- // anyone visiting a deployed /latest/, /v<version>/, or /dev/ URL directly is
8
- // in, whether that's this monorepo's own showcase deploy or a real scaffolded
9
- // widget someone hasn't customized yet. Same component either way; the only
10
- // per-deploy difference is which /v<version>/ dirs actually exist upstream,
11
- // which is why the version list is fetched from a fixed absolute URL rather
12
- // than a path relative to wherever this happens to be deployed.
5
+ // The showcase "hub" page: rendered whenever this deploy's URL has no
6
+ // recognized channel suffix (see src/lib/showcase-routing.ts) -- this repo's
7
+ // own bare /template/, or a local `pnpm dev` server. Individual channel
8
+ // 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.
13
15
  const VERSIONS_URL =
14
16
  "https://arthurblanchon.github.io/grist-widget-sdk/template/versions.json"
17
+ const DEV_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/dev/"
15
18
 
16
19
  type ReleasedVersion = {
17
20
  version: string
@@ -104,9 +107,17 @@ git push origin dev`}</code>
104
107
  </div>
105
108
 
106
109
  <section>
107
- <h2 className="mb-3 font-heading text-base font-semibold">
108
- Released template versions
109
- </h2>
110
+ <div className="mb-3 flex items-center justify-between">
111
+ <h2 className="font-heading text-base font-semibold">
112
+ Released template versions
113
+ </h2>
114
+ <a
115
+ href={DEV_URL}
116
+ className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
117
+ >
118
+ Live dev preview →
119
+ </a>
120
+ </div>
110
121
  {error ? (
111
122
  <p className="text-sm text-muted-foreground">
112
123
  Couldn't load the version list.
@@ -0,0 +1,103 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Card({
6
+ className,
7
+ size = "default",
8
+ ...props
9
+ }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
10
+ return (
11
+ <div
12
+ data-slot="card"
13
+ data-size={size}
14
+ className={cn(
15
+ "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
24
+ return (
25
+ <div
26
+ data-slot="card-header"
27
+ className={cn(
28
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
29
+ className
30
+ )}
31
+ {...props}
32
+ />
33
+ )
34
+ }
35
+
36
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
37
+ return (
38
+ <div
39
+ data-slot="card-title"
40
+ className={cn(
41
+ "font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
42
+ className
43
+ )}
44
+ {...props}
45
+ />
46
+ )
47
+ }
48
+
49
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
50
+ return (
51
+ <div
52
+ data-slot="card-description"
53
+ className={cn("text-sm text-muted-foreground", className)}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
60
+ return (
61
+ <div
62
+ data-slot="card-action"
63
+ className={cn(
64
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
65
+ className
66
+ )}
67
+ {...props}
68
+ />
69
+ )
70
+ }
71
+
72
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
73
+ return (
74
+ <div
75
+ data-slot="card-content"
76
+ className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
77
+ {...props}
78
+ />
79
+ )
80
+ }
81
+
82
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
83
+ return (
84
+ <div
85
+ data-slot="card-footer"
86
+ className={cn(
87
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ )
93
+ }
94
+
95
+ export {
96
+ Card,
97
+ CardHeader,
98
+ CardFooter,
99
+ CardTitle,
100
+ CardAction,
101
+ CardDescription,
102
+ CardContent,
103
+ }
@@ -0,0 +1,19 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+
19
+ export { Input }
@@ -0,0 +1,47 @@
1
+ // Parses the deployed URL shape every channel of this template (or a real
2
+ // scaffolded widget) uses -- /latest/, /dev/, /v<version>/ -- so main.tsx can
3
+ // tell a "hub" page (no recognized channel suffix, e.g. this repo's own
4
+ // /template/) from a "channel" page (a specific release/dev build) using
5
+ // nothing but window.location.pathname. No router needed for two states.
6
+ export type Channel =
7
+ | { kind: "latest" }
8
+ | { kind: "dev" }
9
+ | { kind: "version"; version: string }
10
+
11
+ export function channelLabel(channel: Channel): string {
12
+ switch (channel.kind) {
13
+ case "latest":
14
+ return "latest"
15
+ case "dev":
16
+ return "dev"
17
+ case "version":
18
+ return `v${channel.version}`
19
+ }
20
+ }
21
+
22
+ /**
23
+ * `hubPath` is the current path with the trailing channel segment stripped,
24
+ * e.g. "/grist-widget-sdk/template/latest/" -> "/grist-widget-sdk/template/".
25
+ * `channel` is null when the path itself already looks like a hub (no
26
+ * recognized suffix) -- e.g. this repo's own bare /template/, or a local
27
+ * `pnpm dev` server at "/".
28
+ */
29
+ export function parseShowcasePath(pathname: string): {
30
+ channel: Channel | null
31
+ hubPath: string
32
+ } {
33
+ const trimmed = pathname.replace(/\/+$/, "")
34
+ const segments = trimmed.split("/")
35
+ const last = segments[segments.length - 1] ?? ""
36
+
37
+ let channel: Channel | null = null
38
+ if (last === "latest") channel = { kind: "latest" }
39
+ else if (last === "dev") channel = { kind: "dev" }
40
+ else {
41
+ const match = /^v(\d+\.\d+\.\d+(?:-[\w.]+)?)$/.exec(last)
42
+ if (match) channel = { kind: "version", version: match[1] }
43
+ }
44
+
45
+ const hubPath = channel ? segments.slice(0, -1).join("/") + "/" : pathname
46
+ return { channel, hubPath }
47
+ }
@@ -2,25 +2,38 @@ import { StrictMode } from "react"
2
2
  import { createRoot } from "react-dom/client"
3
3
  import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
4
4
 
5
+ import { ChannelNotice } from "@/components/channel-notice"
5
6
  import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
6
7
  import { TemplateLanding } from "@/components/template-landing"
7
8
  import { ThemeProvider } from "@/components/theme-provider.tsx"
9
+ import { parseShowcasePath } from "@/lib/showcase-routing"
8
10
  import "./index.css"
9
11
  import App, { GRIST_OPTIONS } from "./App.tsx"
10
12
 
11
13
  // A Grist custom widget only ever runs embedded in Grist's own iframe.
12
14
  // Opened directly in a browser tab (window.self === window.top), there's no
13
- // Grist to connect to -- show the template's own landing content instead of
14
- // mounting the widget tree at all. Same for a real scaffolded widget nobody
15
- // has customized yet as for this repo's own showcase deploy of the
16
- // unmodified template; nothing here is monorepo-specific except the fixed
17
- // URL TemplateLanding fetches its version list from.
15
+ // Grist to connect to. Two different "not embedded" experiences, told apart
16
+ // purely by URL shape (see src/lib/showcase-routing.ts):
17
+ // - no recognized channel suffix (this repo's own bare /template/, or a
18
+ // local `pnpm dev` server) -> the rich showcase hub (TemplateLanding):
19
+ // onboarding + a link to every released version and the dev channel.
20
+ // - a recognized channel suffix (/latest/, /dev/, /v<version>/) -> a
21
+ // minimal notice: which build this is, a link back to the hub, and a
22
+ // copy-this-URL helper for pasting into Grist's custom widget field.
23
+ // The hub always wins over embedding -- /template/ is never meant to
24
+ // function as an actual widget, even if someone pastes it into Grist by
25
+ // mistake.
18
26
  const isEmbedded = window.self !== window.top
27
+ const { channel, hubPath } = parseShowcasePath(window.location.pathname)
19
28
 
20
29
  createRoot(document.getElementById("root")!).render(
21
30
  <StrictMode>
22
31
  <ThemeProvider>
23
- {isEmbedded ? (
32
+ {!channel ? (
33
+ <div className="min-h-full w-full bg-background text-foreground">
34
+ <TemplateLanding />
35
+ </div>
36
+ ) : isEmbedded ? (
24
37
  <GristWidgetProvider options={GRIST_OPTIONS}>
25
38
  <GristBoundary
26
39
  gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
@@ -34,7 +47,7 @@ createRoot(document.getElementById("root")!).render(
34
47
  </GristWidgetProvider>
35
48
  ) : (
36
49
  <div className="min-h-full w-full bg-background text-foreground">
37
- <TemplateLanding />
50
+ <ChannelNotice channel={channel} hubPath={hubPath} />
38
51
  </div>
39
52
  )}
40
53
  </ThemeProvider>