create-grist-widget 0.3.24 → 0.3.26
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/examples/spreadsheet-grid/GristDataSheetGrid.tsx +1 -1
- package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +4 -4
- package/package.json +1 -1
- package/template/AGENTS.md +6 -5
- package/template/README.md +4 -4
- package/template/package.json +1 -1
- package/template/scripts/deploy.mjs +4 -4
- 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/template/tsconfig.app.json +6 -21
- package/template/tsconfig.node.json +7 -20
- 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
|
@@ -320,7 +320,7 @@ export function GristDataSheetGrid({
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
// Sequence: deletes → updates → creates. Errors are tracked by the SDK
|
|
323
|
-
// and surfaced
|
|
323
|
+
// and surfaced by the SDK's alerts (mountGristWidget).
|
|
324
324
|
void (async () => {
|
|
325
325
|
try {
|
|
326
326
|
if (deletes.length > 0) await ops.destroy(deletes)
|
|
@@ -98,7 +98,7 @@ export function GristSchemaToolbar({
|
|
|
98
98
|
setNewColId("")
|
|
99
99
|
setNewType("Text")
|
|
100
100
|
} catch {
|
|
101
|
-
// Surfaced
|
|
101
|
+
// Surfaced by the SDK's alerts (mountGristWidget).
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -109,7 +109,7 @@ export function GristSchemaToolbar({
|
|
|
109
109
|
await w.applyActions([gristRenameColumnAction(tableId, oldId, newId)])
|
|
110
110
|
await schema.reload()
|
|
111
111
|
} catch {
|
|
112
|
-
/* surfaced
|
|
112
|
+
/* surfaced by the SDK's alerts */
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
@@ -118,7 +118,7 @@ export function GristSchemaToolbar({
|
|
|
118
118
|
await w.applyActions([gristModifyColumnAction(tableId, colId, { type })])
|
|
119
119
|
await schema.reload()
|
|
120
120
|
} catch {
|
|
121
|
-
/* surfaced
|
|
121
|
+
/* surfaced by the SDK's alerts */
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
@@ -128,7 +128,7 @@ export function GristSchemaToolbar({
|
|
|
128
128
|
await w.applyActions([gristRemoveColumnAction(tableId, colId)])
|
|
129
129
|
await schema.reload()
|
|
130
130
|
} catch {
|
|
131
|
-
/* surfaced
|
|
131
|
+
/* surfaced by the SDK's alerts */
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -22,16 +22,17 @@ export function App() {
|
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
`src/main.tsx`
|
|
26
|
-
`
|
|
27
|
-
|
|
25
|
+
`src/main.tsx` is one call, `mountGristWidget({ App, options: GRIST_OPTIONS, dev })` from
|
|
26
|
+
`grist-widget-sdk/app`: inside Grist it renders `<App />` in `GristWidgetProvider` and
|
|
27
|
+
`GristBoundary`, with the SDK's alerts and Grist's light/dark theme. Access level, column
|
|
28
|
+
mapping, and rendering all live in `src/App.tsx` — that's the one file to edit.
|
|
28
29
|
|
|
29
30
|
## Operating contract
|
|
30
31
|
|
|
31
32
|
1. **Widgets use the SDK.** No direct `window.grist.*` calls — use `useGrist()` and the
|
|
32
33
|
other `grist-widget-sdk` hooks.
|
|
33
34
|
2. **`pnpm dev` is a bare shell, on purpose — not a seeded preview.** Opened outside Grist
|
|
34
|
-
it
|
|
35
|
+
it shows the SDK's local-dev page; the widget itself only renders once actually embedded in a
|
|
35
36
|
Grist document. This is deliberate, not a gap: an emulator can't guarantee parity with
|
|
36
37
|
real Grist, so a fake local preview would teach the wrong lesson. Trust `pnpm test`
|
|
37
38
|
(headless, real assertions) for local feedback, and the `dev`-branch loop below for real
|
|
@@ -64,7 +65,7 @@ rendering all live in `src/App.tsx` — that's the one file to edit.
|
|
|
64
65
|
| --- | --- |
|
|
65
66
|
| The widget | `src/App.tsx` |
|
|
66
67
|
| Widget test (the pattern to copy for new tests) | `src/App.test.tsx` |
|
|
67
|
-
| Grist wiring
|
|
68
|
+
| Grist wiring (mountGristWidget, inside vs. outside Grist) | `src/main.tsx` |
|
|
68
69
|
| ui primitives | `src/components/ui/` |
|
|
69
70
|
| Deploy workflow | `.github/workflows/deploy.yml` |
|
|
70
71
|
| Deploy script (version guard, channel logic) | `scripts/deploy.mjs` |
|
package/template/README.md
CHANGED
|
@@ -6,16 +6,16 @@ Base template to build a Grist widget with `grist-widget-sdk`.
|
|
|
6
6
|
**Install URL**: written here by this widget's first release (its GitHub Pages `/latest/` URL).
|
|
7
7
|
<!-- install-url:end -->
|
|
8
8
|
|
|
9
|
-
`src/main.tsx`
|
|
9
|
+
`src/main.tsx` is one call to `mountGristWidget()` from `grist-widget-sdk/app` ([reference](https://gristwidgets.com/docs/sdk/api/app)). Inside Grist it renders `App` in `GristWidgetProvider` and `GristBoundary`, under the SDK's alerts, in Grist's light/dark theme. `src/components/theme-provider.tsx` re-exports its `useTheme()`, for shadcn components that need the theme in JavaScript.
|
|
10
10
|
|
|
11
|
-
Opened outside a Grist iframe,
|
|
11
|
+
Opened outside a Grist iframe, the page is never a live preview of the widget itself, since nothing there proves anything about real Grist behavior. Local `pnpm dev` shows a local-dev page pointing at the real verification loop below (push to `dev`, open it in a real Grist document). Any built or deployed URL (root, `/latest/`, `/dev/`, `/v<version>/`, all carrying the same build, see Deployment below) shows a release page instead: which build this is, links to every other version and channel (hover one to see when it shipped), and the URL to paste into Grist's custom widget field. Both come from the SDK and follow this widget's shadcn colors; `mountGristWidget()` takes your own components instead (`LocalDevPage`, `ReleasePage`).
|
|
12
12
|
|
|
13
|
-
When actually embedded, the widget shows connection state only when something is wrong: until Grist is connected, or if the connection is lost or fails, `GristBoundary` replaces the widget with a connecting / unavailable / error screen (the error one with a retry button); once the widget is showing,
|
|
13
|
+
When actually embedded, the widget shows connection state only when something is wrong: until Grist is connected, or if the connection is lost or fails, `GristBoundary` replaces the widget with a connecting / unavailable / error screen (the error one with a retry button); once the widget is showing, the SDK's alerts report what needs attention (column mapping, access level, a failed write…) above it. Nothing is drawn over the widget while all is well. Pass your own `Alerts` component to `mountGristWidget()` to render them differently, or `Alerts: null` for none.
|
|
14
14
|
|
|
15
15
|
`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.
|
|
16
16
|
|
|
17
17
|
- **ESLint** blocks direct `grist` global usage in `src/` — use the SDK only.
|
|
18
|
-
- Edit `GRIST_OPTIONS.columns` in `App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `
|
|
18
|
+
- Edit `GRIST_OPTIONS.columns` in `App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `mountGristWidget()` gates the widget on `canRender` whenever columns are declared, so it renders once they're mapped.
|
|
19
19
|
|
|
20
20
|
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/emulator/testing).
|
|
21
21
|
|
package/template/package.json
CHANGED
|
@@ -226,11 +226,11 @@ export function injectSelfReload(html, sha) {
|
|
|
226
226
|
/**
|
|
227
227
|
* Scan siteDir for previously-released v<version>/showcase-meta.json files
|
|
228
228
|
* and assemble the versions.json manifest, newest first. Read by the
|
|
229
|
-
*
|
|
230
|
-
*
|
|
229
|
+
* release page grist-widget-sdk/app shows outside Grist (useReleasedVersions)
|
|
230
|
+
* to list this widget's own release history -- not the grist-widget-sdk
|
|
231
231
|
* monorepo's (found live: every scaffolded widget's version index showed
|
|
232
|
-
* the monorepo's versions instead of its own, since
|
|
233
|
-
* used to hardcode that URL). Entries whose meta has no `repo` field, or a
|
|
232
|
+
* the monorepo's versions instead of its own, since the template's version
|
|
233
|
+
* list used to hardcode that URL). Entries whose meta has no `repo` field, or a
|
|
234
234
|
* `repo` that doesn't match this repo, are skipped -- a `gh-pages` branch
|
|
235
235
|
* manually seeded from another repo's export (found live) would otherwise
|
|
236
236
|
* leak that repo's own version history into this one's index.
|
|
@@ -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 })
|
|
@@ -1,28 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
+
// Compiler settings come from grist-widget-sdk and update with it. This
|
|
3
|
+
// file keeps what is specific to this widget: its files, paths and types.
|
|
4
|
+
"extends": "grist-widget-sdk/tsconfig/app.json",
|
|
2
5
|
"compilerOptions": {
|
|
6
|
+
// Also set in the base. Repeated so that a missing base (a grist-widget-sdk
|
|
7
|
+
// too old to ship it) fails the typecheck without writing .js files into src/.
|
|
8
|
+
"noEmit": true,
|
|
3
9
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
-
"target": "ES2022",
|
|
5
|
-
"useDefineForClassFields": true,
|
|
6
|
-
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
-
"module": "ESNext",
|
|
8
10
|
"types": ["vite/client"],
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
|
|
11
|
-
/* Bundler mode */
|
|
12
|
-
"moduleResolution": "bundler",
|
|
13
|
-
"allowImportingTsExtensions": true,
|
|
14
|
-
"verbatimModuleSyntax": true,
|
|
15
|
-
"moduleDetection": "force",
|
|
16
|
-
"noEmit": true,
|
|
17
|
-
"jsx": "react-jsx",
|
|
18
|
-
|
|
19
|
-
/* Linting */
|
|
20
|
-
"strict": true,
|
|
21
|
-
"noUnusedLocals": true,
|
|
22
|
-
"noUnusedParameters": true,
|
|
23
|
-
"erasableSyntaxOnly": true,
|
|
24
|
-
"noFallthroughCasesInSwitch": true,
|
|
25
|
-
"noUncheckedSideEffectImports": true,
|
|
26
11
|
"paths": {
|
|
27
12
|
"@/*": ["./src/*"]
|
|
28
13
|
}
|
|
@@ -1,26 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
+
// Compiler settings come from grist-widget-sdk and update with it. This
|
|
3
|
+
// file keeps what is specific to this widget: its files and types.
|
|
4
|
+
"extends": "grist-widget-sdk/tsconfig/node.json",
|
|
2
5
|
"compilerOptions": {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
"lib": ["ES2023"],
|
|
6
|
-
"module": "ESNext",
|
|
7
|
-
"types": ["node"],
|
|
8
|
-
"skipLibCheck": true,
|
|
9
|
-
|
|
10
|
-
/* Bundler mode */
|
|
11
|
-
"moduleResolution": "bundler",
|
|
12
|
-
"allowImportingTsExtensions": true,
|
|
13
|
-
"verbatimModuleSyntax": true,
|
|
14
|
-
"moduleDetection": "force",
|
|
6
|
+
// Also set in the base. Repeated so that a missing base (a grist-widget-sdk
|
|
7
|
+
// too old to ship it) fails the typecheck without writing a .js file.
|
|
15
8
|
"noEmit": true,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"strict": true,
|
|
19
|
-
"noUnusedLocals": true,
|
|
20
|
-
"noUnusedParameters": true,
|
|
21
|
-
"erasableSyntaxOnly": true,
|
|
22
|
-
"noFallthroughCasesInSwitch": true,
|
|
23
|
-
"noUncheckedSideEffectImports": true
|
|
9
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
10
|
+
"types": ["node"]
|
|
24
11
|
},
|
|
25
12
|
"include": ["vite.config.ts"]
|
|
26
13
|
}
|
|
@@ -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
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from "react"
|
|
2
|
-
|
|
3
|
-
import { parseShowcasePath } from "@/lib/showcase-routing"
|
|
4
|
-
|
|
5
|
-
// Used by ReleaseInfo, which needs the released-version list on every
|
|
6
|
-
// non-embedded, deployed URL. URLs are root-relative to wherever THIS
|
|
7
|
-
// deploy actually lives (derived from
|
|
8
|
-
// window.location.pathname via parseShowcasePath's hubPath), not hardcoded
|
|
9
|
-
// to the grist-widget-sdk monorepo's own showcase: a real scaffolded
|
|
10
|
-
// widget has its own versions.json and its own v<version>/ + dev/ dirs,
|
|
11
|
-
// and previously showed the monorepo's version history and links instead
|
|
12
|
-
// of its own (found live on a real scaffold's landing page).
|
|
13
|
-
function hubPath(): string {
|
|
14
|
-
const path = parseShowcasePath(window.location.pathname).hubPath
|
|
15
|
-
return path.endsWith("/") ? path : `${path}/`
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type ReleasedVersion = {
|
|
19
|
-
version: string
|
|
20
|
-
publishedAt: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function versionsUrl(): string {
|
|
24
|
-
return `${hubPath()}versions.json`
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function devUrl(): string {
|
|
28
|
-
return `${hubPath()}dev/`
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function versionUrl(version: string): string {
|
|
32
|
-
return `${hubPath()}v${version}/`
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function useVersions() {
|
|
36
|
-
const [versions, setVersions] = useState<ReleasedVersion[] | null>(null)
|
|
37
|
-
const [error, setError] = useState(false)
|
|
38
|
-
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
let cancelled = false
|
|
41
|
-
fetch(versionsUrl(), { cache: "no-store" })
|
|
42
|
-
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
|
43
|
-
.then((data: ReleasedVersion[]) => {
|
|
44
|
-
if (!cancelled) setVersions(data)
|
|
45
|
-
})
|
|
46
|
-
.catch(() => {
|
|
47
|
-
if (!cancelled) setError(true)
|
|
48
|
-
})
|
|
49
|
-
return () => {
|
|
50
|
-
cancelled = true
|
|
51
|
-
}
|
|
52
|
-
}, [])
|
|
53
|
-
|
|
54
|
-
return { versions, error }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function formatDate(iso: string) {
|
|
58
|
-
return new Date(iso).toLocaleDateString(undefined, {
|
|
59
|
-
year: "numeric",
|
|
60
|
-
month: "short",
|
|
61
|
-
day: "numeric",
|
|
62
|
-
})
|
|
63
|
-
}
|