create-grist-widget 0.3.25 → 0.3.27
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/bin/create-grist-widget.mjs +1 -1
- package/bin/grist-widget-deploy.mjs +92 -0
- package/examples/reads-demo/WidgetApp.tsx +2 -1
- package/examples/safe-parse/WidgetApp.tsx +2 -1
- package/examples/spreadsheet-grid/GristDataSheetGrid.tsx +1 -1
- package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +4 -4
- package/manifest.json +2 -2
- package/package.json +3 -2
- package/{template/scripts → src}/deploy.mjs +14 -85
- package/template/.github/dependabot.yml +5 -4
- package/template/.github/workflows/deploy.yml +32 -11
- package/template/AGENTS.md +9 -8
- package/template/README.md +15 -12
- package/template/package.json +2 -1
- package/template/src/App.smoke.test.tsx +17 -0
- package/template/src/App.test.tsx +6 -13
- package/template/src/components/theme-provider.tsx +5 -229
- package/template/src/main.tsx +7 -53
- package/vendor/compose-example.mjs +7 -2
- package/template/src/components/grist-sdk-alerts.tsx +0 -80
- package/template/src/components/local-dev-notice.tsx +0 -55
- package/template/src/components/release-info.tsx +0 -111
- package/template/src/lib/showcase-routing.ts +0 -59
- package/template/src/lib/showcase-versions.ts +0 -63
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* same pattern applied to a bare shell.
|
|
2
|
+
* Renders the widget the way `main.tsx` mounts it inside Grist
|
|
3
|
+
* (`GristWidgetApp`: provider, boundary gated on the mapped columns, alerts),
|
|
4
|
+
* against an emulated Grist, so this test exercises what the embedded widget
|
|
5
|
+
* actually runs under.
|
|
7
6
|
*/
|
|
8
7
|
import "@testing-library/jest-dom/vitest"
|
|
9
8
|
import { afterEach, describe, expect, it } from "vitest"
|
|
10
|
-
import {
|
|
9
|
+
import { GristWidgetApp } from "grist-widget-sdk/app"
|
|
11
10
|
import {
|
|
12
11
|
act,
|
|
13
12
|
actionsOf,
|
|
@@ -28,13 +27,7 @@ import App, { GRIST_OPTIONS } from "./App"
|
|
|
28
27
|
afterEach(() => cleanup())
|
|
29
28
|
|
|
30
29
|
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
|
-
)
|
|
30
|
+
return <GristWidgetApp App={App} options={GRIST_OPTIONS} />
|
|
38
31
|
}
|
|
39
32
|
|
|
40
33
|
describe("App", () => {
|
|
@@ -1,229 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type ThemeProviderProps = {
|
|
7
|
-
children: React.ReactNode
|
|
8
|
-
defaultTheme?: Theme
|
|
9
|
-
storageKey?: string
|
|
10
|
-
disableTransitionOnChange?: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type ThemeProviderState = {
|
|
14
|
-
theme: Theme
|
|
15
|
-
setTheme: (theme: Theme) => void
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
|
|
19
|
-
const THEME_VALUES: Theme[] = ["dark", "light", "system"]
|
|
20
|
-
|
|
21
|
-
const ThemeProviderContext = React.createContext<
|
|
22
|
-
ThemeProviderState | undefined
|
|
23
|
-
>(undefined)
|
|
24
|
-
|
|
25
|
-
function isTheme(value: string | null): value is Theme {
|
|
26
|
-
if (value === null) {
|
|
27
|
-
return false
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return THEME_VALUES.includes(value as Theme)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function getSystemTheme(): ResolvedTheme {
|
|
34
|
-
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
|
|
35
|
-
return "dark"
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return "light"
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function disableTransitionsTemporarily() {
|
|
42
|
-
const style = document.createElement("style")
|
|
43
|
-
style.appendChild(
|
|
44
|
-
document.createTextNode(
|
|
45
|
-
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
|
|
46
|
-
)
|
|
47
|
-
)
|
|
48
|
-
document.head.appendChild(style)
|
|
49
|
-
|
|
50
|
-
return () => {
|
|
51
|
-
window.getComputedStyle(document.body)
|
|
52
|
-
requestAnimationFrame(() => {
|
|
53
|
-
requestAnimationFrame(() => {
|
|
54
|
-
style.remove()
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isEditableTarget(target: EventTarget | null) {
|
|
61
|
-
if (!(target instanceof HTMLElement)) {
|
|
62
|
-
return false
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (target.isContentEditable) {
|
|
66
|
-
return true
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const editableParent = target.closest(
|
|
70
|
-
"input, textarea, select, [contenteditable='true']"
|
|
71
|
-
)
|
|
72
|
-
if (editableParent) {
|
|
73
|
-
return true
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return false
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function ThemeProvider({
|
|
80
|
-
children,
|
|
81
|
-
defaultTheme = "system",
|
|
82
|
-
storageKey = "theme",
|
|
83
|
-
disableTransitionOnChange = true,
|
|
84
|
-
...props
|
|
85
|
-
}: ThemeProviderProps) {
|
|
86
|
-
const [theme, setThemeState] = React.useState<Theme>(() => {
|
|
87
|
-
const storedTheme = localStorage.getItem(storageKey)
|
|
88
|
-
if (isTheme(storedTheme)) {
|
|
89
|
-
return storedTheme
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return defaultTheme
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
const setTheme = React.useCallback(
|
|
96
|
-
(nextTheme: Theme) => {
|
|
97
|
-
localStorage.setItem(storageKey, nextTheme)
|
|
98
|
-
setThemeState(nextTheme)
|
|
99
|
-
},
|
|
100
|
-
[storageKey]
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
const applyTheme = React.useCallback(
|
|
104
|
-
(nextTheme: Theme) => {
|
|
105
|
-
const root = document.documentElement
|
|
106
|
-
const resolvedTheme =
|
|
107
|
-
nextTheme === "system" ? getSystemTheme() : nextTheme
|
|
108
|
-
const restoreTransitions = disableTransitionOnChange
|
|
109
|
-
? disableTransitionsTemporarily()
|
|
110
|
-
: null
|
|
111
|
-
|
|
112
|
-
root.classList.remove("light", "dark")
|
|
113
|
-
root.classList.add(resolvedTheme)
|
|
114
|
-
|
|
115
|
-
if (restoreTransitions) {
|
|
116
|
-
restoreTransitions()
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
[disableTransitionOnChange]
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
React.useEffect(() => {
|
|
123
|
-
applyTheme(theme)
|
|
124
|
-
|
|
125
|
-
if (theme !== "system") {
|
|
126
|
-
return undefined
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
|
|
130
|
-
const handleChange = () => {
|
|
131
|
-
applyTheme("system")
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
mediaQuery.addEventListener("change", handleChange)
|
|
135
|
-
|
|
136
|
-
return () => {
|
|
137
|
-
mediaQuery.removeEventListener("change", handleChange)
|
|
138
|
-
}
|
|
139
|
-
}, [theme, applyTheme])
|
|
140
|
-
|
|
141
|
-
React.useEffect(() => {
|
|
142
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
143
|
-
if (event.repeat) {
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (isEditableTarget(event.target)) {
|
|
152
|
-
return
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (event.key.toLowerCase() !== "d") {
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
setThemeState((currentTheme) => {
|
|
160
|
-
const nextTheme =
|
|
161
|
-
currentTheme === "dark"
|
|
162
|
-
? "light"
|
|
163
|
-
: currentTheme === "light"
|
|
164
|
-
? "dark"
|
|
165
|
-
: getSystemTheme() === "dark"
|
|
166
|
-
? "light"
|
|
167
|
-
: "dark"
|
|
168
|
-
|
|
169
|
-
localStorage.setItem(storageKey, nextTheme)
|
|
170
|
-
return nextTheme
|
|
171
|
-
})
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
window.addEventListener("keydown", handleKeyDown)
|
|
175
|
-
|
|
176
|
-
return () => {
|
|
177
|
-
window.removeEventListener("keydown", handleKeyDown)
|
|
178
|
-
}
|
|
179
|
-
}, [storageKey])
|
|
180
|
-
|
|
181
|
-
React.useEffect(() => {
|
|
182
|
-
const handleStorageChange = (event: StorageEvent) => {
|
|
183
|
-
if (event.storageArea !== localStorage) {
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (event.key !== storageKey) {
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (isTheme(event.newValue)) {
|
|
192
|
-
setThemeState(event.newValue)
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
setThemeState(defaultTheme)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
window.addEventListener("storage", handleStorageChange)
|
|
200
|
-
|
|
201
|
-
return () => {
|
|
202
|
-
window.removeEventListener("storage", handleStorageChange)
|
|
203
|
-
}
|
|
204
|
-
}, [defaultTheme, storageKey])
|
|
205
|
-
|
|
206
|
-
const value = React.useMemo(
|
|
207
|
-
() => ({
|
|
208
|
-
theme,
|
|
209
|
-
setTheme,
|
|
210
|
-
}),
|
|
211
|
-
[theme, setTheme]
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
return (
|
|
215
|
-
<ThemeProviderContext.Provider {...props} value={value}>
|
|
216
|
-
{children}
|
|
217
|
-
</ThemeProviderContext.Provider>
|
|
218
|
-
)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export const useTheme = () => {
|
|
222
|
-
const context = React.useContext(ThemeProviderContext)
|
|
223
|
-
|
|
224
|
-
if (context === undefined) {
|
|
225
|
-
throw new Error("useTheme must be used within a ThemeProvider")
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return context
|
|
229
|
-
}
|
|
1
|
+
// shadcn components (a toaster, a mode toggle) import the theme from here.
|
|
2
|
+
// grist-widget-sdk/app owns it: inside Grist the widget follows Grist's
|
|
3
|
+
// light/dark theme; on the dev server and the release page, the OS setting,
|
|
4
|
+
// a saved choice and the `d` key.
|
|
5
|
+
export { useTheme } from "grist-widget-sdk/app"
|
package/template/src/main.tsx
CHANGED
|
@@ -1,57 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createRoot } from "react-dom/client"
|
|
3
|
-
import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
|
|
1
|
+
import { mountGristWidget } from "grist-widget-sdk/app"
|
|
4
2
|
|
|
5
|
-
import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
|
|
6
|
-
import { LocalDevNotice } from "@/components/local-dev-notice"
|
|
7
|
-
import { ReleaseInfo } from "@/components/release-info"
|
|
8
|
-
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
|
9
|
-
import { parseShowcasePath } from "@/lib/showcase-routing"
|
|
10
3
|
import "./index.css"
|
|
11
4
|
import App, { GRIST_OPTIONS } from "./App.tsx"
|
|
12
5
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
// verification loop (push to `dev`, open it in a real Grist doc) --
|
|
20
|
-
// deliberately not a live/seeded preview. An emulator can't guarantee
|
|
21
|
-
// parity with real Grist, so pretending otherwise here would teach the
|
|
22
|
-
// wrong lesson; `pnpm test` (renderWithGrist, headless) is the loop
|
|
23
|
-
// worth trusting locally. See AGENTS.md.
|
|
24
|
-
// - any built/deployed URL (root, /latest/, /dev/, /v<version>/, all
|
|
25
|
-
// carrying the same build per scripts/deploy.mjs) -> ReleaseInfo: which
|
|
26
|
-
// build this is, chips to jump to any other version/channel, and a
|
|
27
|
-
// copy-this-URL helper for pasting into Grist's custom widget field.
|
|
28
|
-
const isEmbedded = window.self !== window.top
|
|
29
|
-
const { channel } = parseShowcasePath(window.location.pathname)
|
|
30
|
-
|
|
31
|
-
createRoot(document.getElementById("root")!).render(
|
|
32
|
-
<StrictMode>
|
|
33
|
-
<ThemeProvider>
|
|
34
|
-
{isEmbedded ? (
|
|
35
|
-
<GristWidgetProvider options={GRIST_OPTIONS}>
|
|
36
|
-
<GristBoundary
|
|
37
|
-
gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
|
|
38
|
-
>
|
|
39
|
-
<div className="min-h-full w-full bg-background text-foreground">
|
|
40
|
-
<GristSdkAlerts>
|
|
41
|
-
<App />
|
|
42
|
-
</GristSdkAlerts>
|
|
43
|
-
</div>
|
|
44
|
-
</GristBoundary>
|
|
45
|
-
</GristWidgetProvider>
|
|
46
|
-
) : import.meta.env.DEV ? (
|
|
47
|
-
<div className="min-h-full w-full bg-background text-foreground">
|
|
48
|
-
<LocalDevNotice />
|
|
49
|
-
</div>
|
|
50
|
-
) : (
|
|
51
|
-
<div className="min-h-full w-full bg-background text-foreground">
|
|
52
|
-
<ReleaseInfo channel={channel} />
|
|
53
|
-
</div>
|
|
54
|
-
)}
|
|
55
|
-
</ThemeProvider>
|
|
56
|
-
</StrictMode>
|
|
57
|
-
)
|
|
6
|
+
// Inside Grist: the widget, behind the SDK's connection screens and alerts,
|
|
7
|
+
// in Grist's light/dark theme. Opened anywhere else (`pnpm dev`, a deployed
|
|
8
|
+
// URL): a page saying where to see it, never a fake preview, since an
|
|
9
|
+
// emulator can't stand in for real Grist (see AGENTS.md). `pnpm test` is the
|
|
10
|
+
// local loop to trust.
|
|
11
|
+
mountGristWidget({ App, options: GRIST_OPTIONS, dev: import.meta.env.DEV })
|
|
@@ -98,7 +98,7 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
|
|
|
98
98
|
// only remove files this function itself may have added, so a stale
|
|
99
99
|
// sibling from a *different* previous example doesn't linger.
|
|
100
100
|
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
101
|
-
if (entry.name === "App.tsx" || entry.name === "App.test.tsx") continue
|
|
101
|
+
if (entry.name === "App.tsx" || entry.name === "App.test.tsx" || entry.name === "App.smoke.test.tsx") continue
|
|
102
102
|
if (["components", "lib", "assets", "index.css", "vite-env.d.ts", "main.tsx"].includes(entry.name)) {
|
|
103
103
|
continue
|
|
104
104
|
}
|
|
@@ -175,11 +175,14 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
|
|
|
175
175
|
* `workspace:^` (meaningless outside the pnpm workspace) — a `file:`
|
|
176
176
|
* reference to either `packages/core` (workspace source, already built), a
|
|
177
177
|
* packed tarball, or (for the CLI's `create`) a real published semver range.
|
|
178
|
+
* `cliDependency` does the same for the template's `create-grist-widget`
|
|
179
|
+
* devDependency (its deploy bin); without one, the devDependency is dropped,
|
|
180
|
+
* since a composed example is only typechecked, built and tested here.
|
|
178
181
|
*
|
|
179
182
|
* For composing *many* examples against one shared `pnpm install`, use
|
|
180
183
|
* {@link overlayExampleSrc} directly instead (see its own doc comment).
|
|
181
184
|
*/
|
|
182
|
-
function composeExample({ exampleId, destDir, sdkDependency }) {
|
|
185
|
+
function composeExample({ exampleId, destDir, sdkDependency, cliDependency }) {
|
|
183
186
|
rmSync(destDir, { recursive: true, force: true })
|
|
184
187
|
cpSync(templateDir, destDir, {
|
|
185
188
|
recursive: true,
|
|
@@ -192,6 +195,8 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
|
|
|
192
195
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
|
193
196
|
pkg.name = exampleId
|
|
194
197
|
pkg.dependencies["grist-widget-sdk"] = sdkDependency
|
|
198
|
+
if (cliDependency) pkg.devDependencies["create-grist-widget"] = cliDependency
|
|
199
|
+
else delete pkg.devDependencies["create-grist-widget"]
|
|
195
200
|
Object.assign(pkg.dependencies, EXTRA_DEPENDENCIES[exampleId] ?? {})
|
|
196
201
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
197
202
|
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { useMemo, type ReactNode } from "react"
|
|
2
|
-
import { AlertTriangle, Link2 } from "lucide-react"
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
useGristSdkAlertDescriptors,
|
|
6
|
-
useGristOptional,
|
|
7
|
-
type GetGristSdkAlertDescriptorsOptions,
|
|
8
|
-
type GristSdkAlertSeverity,
|
|
9
|
-
type UseGristResult,
|
|
10
|
-
} from "grist-widget-sdk"
|
|
11
|
-
|
|
12
|
-
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
13
|
-
import { cn } from "@/lib/utils"
|
|
14
|
-
|
|
15
|
-
export type GristSdkAlertsProps = {
|
|
16
|
-
/** Defaults to the nearest `GristWidgetProvider`. */
|
|
17
|
-
widget?: UseGristResult
|
|
18
|
-
children: ReactNode
|
|
19
|
-
className?: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const GRIST_ALERT_OPTIONS: GetGristSdkAlertDescriptorsOptions = {
|
|
23
|
-
sectionNotLinkedHint:
|
|
24
|
-
"In Grist, select this widget section on the page, then use Link to connect a table, card, or chart view as the selector.",
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function alertSeverityClass(severity: GristSdkAlertSeverity): string | undefined {
|
|
28
|
-
switch (severity) {
|
|
29
|
-
case "warning":
|
|
30
|
-
return "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
|
|
31
|
-
case "info":
|
|
32
|
-
return "border-muted-foreground/30 bg-muted/50"
|
|
33
|
-
default:
|
|
34
|
-
return undefined
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Maps {@link getGristSdkAlertDescriptors} from `grist-widget-sdk` to
|
|
40
|
-
* shadcn {@link Alert} (styling follows this app’s `components.json`).
|
|
41
|
-
*/
|
|
42
|
-
export function GristSdkAlerts({ widget, children, className }: GristSdkAlertsProps) {
|
|
43
|
-
const fromContext = useGristOptional()
|
|
44
|
-
const w = widget ?? fromContext
|
|
45
|
-
|
|
46
|
-
if (!w) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
"GristSdkAlerts requires a `widget` prop or <GristWidgetProvider>"
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const alertOptions = useMemo(() => GRIST_ALERT_OPTIONS, [])
|
|
53
|
-
const alerts = useGristSdkAlertDescriptors(w, alertOptions)
|
|
54
|
-
|
|
55
|
-
if (alerts.length === 0) {
|
|
56
|
-
return <>{children}</>
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div className={cn("flex flex-col gap-3", className)}>
|
|
61
|
-
{alerts.map((a) => (
|
|
62
|
-
<Alert
|
|
63
|
-
key={a.id}
|
|
64
|
-
role={a.ariaRole}
|
|
65
|
-
variant={a.severity === "error" ? "destructive" : "default"}
|
|
66
|
-
className={alertSeverityClass(a.severity)}
|
|
67
|
-
>
|
|
68
|
-
{a.kind === "section-not-linked" || a.kind === "source-not-wired" ? (
|
|
69
|
-
<Link2 className="size-4" aria-hidden />
|
|
70
|
-
) : (
|
|
71
|
-
<AlertTriangle className="size-4" aria-hidden />
|
|
72
|
-
)}
|
|
73
|
-
<AlertTitle>{a.title}</AlertTitle>
|
|
74
|
-
<AlertDescription>{a.message}</AlertDescription>
|
|
75
|
-
</Alert>
|
|
76
|
-
))}
|
|
77
|
-
{children}
|
|
78
|
-
</div>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Card,
|
|
3
|
-
CardDescription,
|
|
4
|
-
CardHeader,
|
|
5
|
-
CardTitle,
|
|
6
|
-
} from "@/components/ui/card"
|
|
7
|
-
|
|
8
|
-
// Shown at the root of a local `pnpm dev` server, never inside Grist.
|
|
9
|
-
// Deliberately a bare shell, not a seeded live preview: an emulator can't
|
|
10
|
-
// guarantee parity with real Grist, so the loop worth trusting here is
|
|
11
|
-
// `pnpm test` (renderWithGrist, headless) plus the real `dev`-branch loop
|
|
12
|
-
// this points at below -- see AGENTS.md's note on why this isn't a fake
|
|
13
|
-
// preview instead.
|
|
14
|
-
export function LocalDevNotice() {
|
|
15
|
-
return (
|
|
16
|
-
<div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
|
|
17
|
-
<p className="mb-2 text-center text-sm font-medium text-muted-foreground">
|
|
18
|
-
grist-widget-template · local dev
|
|
19
|
-
</p>
|
|
20
|
-
<h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
|
|
21
|
-
Not embedded in Grist
|
|
22
|
-
</h1>
|
|
23
|
-
<p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
|
|
24
|
-
This is a local dev server — there's no Grist here to render against.
|
|
25
|
-
</p>
|
|
26
|
-
|
|
27
|
-
<Card className="mb-4">
|
|
28
|
-
<CardHeader>
|
|
29
|
-
<CardTitle>Verify against real Grist</CardTitle>
|
|
30
|
-
<CardDescription>
|
|
31
|
-
Commit and push to <code className="text-foreground">dev</code>,
|
|
32
|
-
then open the printed{" "}
|
|
33
|
-
<code className="text-foreground">/dev/</code> URL in a real
|
|
34
|
-
Grist document — every later push self-reloads inside it within
|
|
35
|
-
seconds. See the README's Deployment section for the full loop.
|
|
36
|
-
</CardDescription>
|
|
37
|
-
</CardHeader>
|
|
38
|
-
</Card>
|
|
39
|
-
|
|
40
|
-
<Card>
|
|
41
|
-
<CardHeader>
|
|
42
|
-
<CardTitle>Not building this widget?</CardTitle>
|
|
43
|
-
<CardDescription>
|
|
44
|
-
<code className="text-foreground">npx grist-widget list</code> to
|
|
45
|
-
see other starting points,{" "}
|
|
46
|
-
<code className="text-foreground">
|
|
47
|
-
npx grist-widget use <example>
|
|
48
|
-
</code>{" "}
|
|
49
|
-
to swap this one out.
|
|
50
|
-
</CardDescription>
|
|
51
|
-
</CardHeader>
|
|
52
|
-
</Card>
|
|
53
|
-
</div>
|
|
54
|
-
)
|
|
55
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
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
|
-
import { devUrl, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
|
|
14
|
-
import { cn } from "@/lib/utils"
|
|
15
|
-
|
|
16
|
-
// Shown on every non-embedded, built/deployed URL of this widget -- root,
|
|
17
|
-
// /latest/, /dev/, /v<version>/, all carrying the same build per
|
|
18
|
-
// scripts/deploy.mjs's plan() -- when opened outside Grist. Local `pnpm dev`
|
|
19
|
-
// renders `LocalDevNotice` instead (see src/main.tsx): this component's
|
|
20
|
-
// "paste this URL into Grist" instruction only makes sense for a real,
|
|
21
|
-
// publicly reachable URL, never a bare localhost one.
|
|
22
|
-
function VersionChips({ current }: { current: Channel }) {
|
|
23
|
-
const { versions } = useVersions()
|
|
24
|
-
|
|
25
|
-
function chipClass(active: boolean) {
|
|
26
|
-
return cn(
|
|
27
|
-
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
28
|
-
active
|
|
29
|
-
? "border-primary bg-primary text-primary-foreground"
|
|
30
|
-
: "border-border text-muted-foreground hover:text-foreground",
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<div className="mb-8 flex flex-wrap justify-center gap-2">
|
|
36
|
-
{versions !== null && versions.length > 0 && (
|
|
37
|
-
<a
|
|
38
|
-
href={versionUrl(versions[0].version)}
|
|
39
|
-
title={formatDate(versions[0].publishedAt)}
|
|
40
|
-
className={chipClass(
|
|
41
|
-
current.kind === "version" && current.version === versions[0].version,
|
|
42
|
-
)}
|
|
43
|
-
>
|
|
44
|
-
latest
|
|
45
|
-
</a>
|
|
46
|
-
)}
|
|
47
|
-
<a href={devUrl()} 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
|
-
title={formatDate(v.publishedAt)}
|
|
55
|
-
className={chipClass(
|
|
56
|
-
current.kind === "version" && current.version === v.version,
|
|
57
|
-
)}
|
|
58
|
-
>
|
|
59
|
-
v{v.version}
|
|
60
|
-
</a>
|
|
61
|
-
))}
|
|
62
|
-
</div>
|
|
63
|
-
)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function ReleaseInfo({ channel }: { channel: Channel }) {
|
|
67
|
-
const [copied, setCopied] = useState(false)
|
|
68
|
-
const url = window.location.href
|
|
69
|
-
|
|
70
|
-
function copyUrl() {
|
|
71
|
-
navigator.clipboard.writeText(url).then(() => {
|
|
72
|
-
setCopied(true)
|
|
73
|
-
setTimeout(() => setCopied(false), 1500)
|
|
74
|
-
})
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
|
|
79
|
-
<p className="mb-2 text-center text-sm font-medium text-muted-foreground">
|
|
80
|
-
grist-widget-template · {channelLabel(channel)}
|
|
81
|
-
</p>
|
|
82
|
-
<h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
|
|
83
|
-
Grist isn't loaded here
|
|
84
|
-
</h1>
|
|
85
|
-
<p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
|
|
86
|
-
You're viewing the <span className="font-mono">{channelLabel(channel)}</span>{" "}
|
|
87
|
-
build directly, outside of Grist. Hover a chip below to see when it
|
|
88
|
-
shipped.
|
|
89
|
-
</p>
|
|
90
|
-
|
|
91
|
-
<VersionChips current={channel} />
|
|
92
|
-
|
|
93
|
-
<Card>
|
|
94
|
-
<CardHeader>
|
|
95
|
-
<CardTitle>Use this as a Grist custom widget</CardTitle>
|
|
96
|
-
<CardDescription>
|
|
97
|
-
Paste this URL into Grist's custom widget URL field.
|
|
98
|
-
</CardDescription>
|
|
99
|
-
</CardHeader>
|
|
100
|
-
<CardContent>
|
|
101
|
-
<div className="flex gap-2">
|
|
102
|
-
<Input readOnly value={url} onFocus={(e) => e.target.select()} />
|
|
103
|
-
<Button type="button" onClick={copyUrl}>
|
|
104
|
-
{copied ? "Copied" : "Copy"}
|
|
105
|
-
</Button>
|
|
106
|
-
</div>
|
|
107
|
-
</CardContent>
|
|
108
|
-
</Card>
|
|
109
|
-
</div>
|
|
110
|
-
)
|
|
111
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
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 and
|
|
3
|
-
// ReleaseInfo can label the current build using nothing but
|
|
4
|
-
// window.location.pathname. No router needed.
|
|
5
|
-
export type Channel =
|
|
6
|
-
| { kind: "latest" }
|
|
7
|
-
| { kind: "dev" }
|
|
8
|
-
| { kind: "version"; version: string }
|
|
9
|
-
|
|
10
|
-
export function channelLabel(channel: Channel): string {
|
|
11
|
-
switch (channel.kind) {
|
|
12
|
-
case "latest":
|
|
13
|
-
return "latest"
|
|
14
|
-
case "dev":
|
|
15
|
-
return "dev"
|
|
16
|
-
case "version":
|
|
17
|
-
return `v${channel.version}`
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* `hubPath` is the current path with a recognized trailing channel segment
|
|
23
|
-
* stripped, e.g. "/grist-widget-sdk/template/latest/" ->
|
|
24
|
-
* "/grist-widget-sdk/template/" -- used to build root-relative URLs
|
|
25
|
-
* (versions.json, /dev/, /v<version>/) regardless of how deep this deploy's
|
|
26
|
-
* own base path is (see showcase-versions.ts).
|
|
27
|
-
*
|
|
28
|
-
* `channel` always resolves to something: `scripts/deploy.mjs` places the
|
|
29
|
-
* same build at the bare deployed root as at `/latest/`, so a path with no
|
|
30
|
-
* recognized suffix (the root itself, or a local `pnpm dev` server) defaults
|
|
31
|
-
* to `{ kind: "latest" }` -- that's genuinely what's there.
|
|
32
|
-
*/
|
|
33
|
-
export function parseShowcasePath(pathname: string): {
|
|
34
|
-
channel: Channel
|
|
35
|
-
hubPath: string
|
|
36
|
-
} {
|
|
37
|
-
const trimmed = pathname.replace(/\/+$/, "")
|
|
38
|
-
const segments = trimmed.split("/")
|
|
39
|
-
const last = segments[segments.length - 1] ?? ""
|
|
40
|
-
|
|
41
|
-
let channel: Channel = { kind: "latest" }
|
|
42
|
-
let matchedSuffix = false
|
|
43
|
-
if (last === "latest") {
|
|
44
|
-
channel = { kind: "latest" }
|
|
45
|
-
matchedSuffix = true
|
|
46
|
-
} else if (last === "dev") {
|
|
47
|
-
channel = { kind: "dev" }
|
|
48
|
-
matchedSuffix = true
|
|
49
|
-
} else {
|
|
50
|
-
const match = /^v(\d+\.\d+\.\d+(?:-[\w.]+)?)$/.exec(last)
|
|
51
|
-
if (match) {
|
|
52
|
-
channel = { kind: "version", version: match[1] }
|
|
53
|
-
matchedSuffix = true
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const hubPath = matchedSuffix ? segments.slice(0, -1).join("/") + "/" : pathname
|
|
58
|
-
return { channel, hubPath }
|
|
59
|
-
}
|