@specbook/ui 0.2.0 → 0.3.0
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/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-DfwluJy4.js} +2 -2
- package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-9eYU7Gz6.js} +1 -1
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +6 -5
- package/src/app.tsx +65 -12
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +21 -3
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +286 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +274 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +7 -2
- package/src/nav.ts +12 -1
- package/src/oauth.ts +9 -8
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -9
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +6 -2
- package/src/panel/Panel.tsx +43 -10
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +91 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +10 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +8 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +233 -0
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +6 -0
- package/src/viewer/registry.ts +46 -10
- package/src/viewer/worker.ts +9 -1
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
- package/dist-viewer/assets/index-ez9c20v3.js +0 -1
- package/dist-viewer/assets/main-38QGGpNT.js +0 -14
- package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
- package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
- package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
package/dist-viewer/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<title>Specbook</title>
|
|
8
8
|
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
|
9
9
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
11
|
-
<link rel="modulepreload" crossorigin href="./assets/panel-
|
|
12
|
-
<link rel="modulepreload" crossorigin href="./assets/mount-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/panel-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-BBlFcQUE.js"></script>
|
|
11
|
+
<link rel="modulepreload" crossorigin href="./assets/panel-9eYU7Gz6.js">
|
|
12
|
+
<link rel="modulepreload" crossorigin href="./assets/mount-DfwluJy4.js">
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/panel-hNOojwiM.css">
|
|
14
14
|
<link rel="stylesheet" crossorigin href="./assets/index-Ds6DZpGP.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
package/dist-viewer/specbook.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as e,r as t,t as n}from"./assets/mount-
|
|
1
|
+
import{a as e,r as t,t as n}from"./assets/mount-DfwluJy4.js";function r(){let e=document.getElementById(`specbook-config`);if(e?.textContent)try{return JSON.parse(e.textContent)}catch{}let t=document.querySelector(`script[src$="specbook.js"][data-spec]`);return{spec:t?.dataset.spec,baseUrl:t?.dataset.baseUrl}}function i(e){let t=e?document.querySelector(e):document.getElementById(`app`);if(t)return t;let n=document.createElement(`div`);return n.id=`app`,document.body.append(n),n}var a=(e,t,n=!1)=>{e.textContent=t,e.style.cssText=`font:16px system-ui,sans-serif;padding:24px;color:${n?`#b42318`:`#5b6270`}`};async function o(){if(await e({stripWhenUnclaimed:!1}))return;let o=r(),s=i(o.target);if(!o.spec)return a(s,`Specbook: no spec was given (set "spec" in #specbook-config).`,!0);let c;try{c=new URL(o.spec,location.href).toString()}catch{return a(s,`Specbook: "${o.spec}" is not a valid address.`,!0)}a(s,`Loading the API…`);let l=new t;try{let e=await l.open({kind:`url`,url:c}).promise;s.textContent=``,s.removeAttribute(`style`),await n({result:e,worker:l,source:{kind:`url`,url:c},baseUrl:o.baseUrl,shareable:!1,setTitle:!1})}catch(e){a(s,`Specbook: ${e instanceof Error?e.message:String(e)}`,!0)}}o();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specbook/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The Specbook docs app, spec viewer and embeddable specbook.js.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"specbook",
|
|
@@ -29,14 +29,14 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@preact/signals": "2.11.3",
|
|
32
|
-
"@specbook/codegen": "0.
|
|
33
|
-
"@specbook/core": "0.
|
|
32
|
+
"@specbook/codegen": "0.3.0",
|
|
33
|
+
"@specbook/core": "0.3.0",
|
|
34
34
|
"preact": "11.0.1",
|
|
35
35
|
"prism-code-editor": "^5.4.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@faker-js/faker": "9.3.0",
|
|
39
|
-
"@specbook/vite": "0.
|
|
39
|
+
"@specbook/vite": "0.3.0",
|
|
40
40
|
"@vitest/browser-playwright": "5.0.3",
|
|
41
41
|
"playwright": "1.64.0",
|
|
42
42
|
"typescript": "7.0.2",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"test:viewer": "pnpm run build:viewer && vitest run --config vitest.viewer.config.ts",
|
|
60
60
|
"test:oauth": "pnpm --filter @specbook/cli build && pnpm run build:viewer && vitest run --config vitest.oauth.config.ts",
|
|
61
61
|
"build:site": "pnpm run build:viewer && node scripts/assemble-site.mjs",
|
|
62
|
-
"deploy:site": "pnpm run build:viewer && node scripts/assemble-site.mjs --previous https://ui.specbook.dev && npx --yes wrangler@4 pages deploy dist-site --project-name specbook-ui --branch main"
|
|
62
|
+
"deploy:site": "pnpm run build:viewer && node scripts/assemble-site.mjs --previous https://ui.specbook.dev && npx --yes wrangler@4 pages deploy dist-site --project-name specbook-ui --branch main",
|
|
63
|
+
"test:embed": "pnpm --filter @specbook/cli build && vitest run --config vitest.embed.config.ts"
|
|
63
64
|
}
|
|
64
65
|
}
|
package/src/app.tsx
CHANGED
|
@@ -7,21 +7,40 @@ import { EnvBadge } from './components/EnvBadge'
|
|
|
7
7
|
import { EnvironmentMenu } from './components/EnvironmentMenu'
|
|
8
8
|
import { GitHubStar } from './components/GitHubStar'
|
|
9
9
|
import { ListResizer, listWidth } from './components/ListResizer'
|
|
10
|
+
import { NewEnvironmentDialog } from './components/NewEnvironmentDialog'
|
|
10
11
|
import { Palette } from './components/Palette'
|
|
11
12
|
import { ShareMenu } from './components/ShareMenu'
|
|
12
13
|
import { ThemeToggle } from './components/ThemeToggle'
|
|
13
14
|
import type { SequenceInfo, Variant } from './engine'
|
|
14
15
|
import { type Entry, go, pageOf, paletteOpen, parseHash, route, setDefaultService } from './nav'
|
|
15
|
-
import { engine, registry } from './runtime'
|
|
16
|
-
import {
|
|
16
|
+
import { engine, isMinimal, registry } from './runtime'
|
|
17
|
+
import {
|
|
18
|
+
env,
|
|
19
|
+
environmentNote,
|
|
20
|
+
environmentRisk,
|
|
21
|
+
environments,
|
|
22
|
+
locale,
|
|
23
|
+
newEnvironmentOpen,
|
|
24
|
+
seed,
|
|
25
|
+
version,
|
|
26
|
+
} from './store'
|
|
27
|
+
|
|
28
|
+
/** The picker's last entry: not an environment, a way to make one. */
|
|
29
|
+
const NEW_ENVIRONMENT = '\u0000new-environment'
|
|
17
30
|
|
|
18
31
|
// the hosted viewer is Specbook itself, so its header carries our mark unless the API brings a logo
|
|
19
32
|
const ownMark = globalThis.__SPECBOOK_VIEWER__?.source ? markUrl : undefined
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
/**
|
|
35
|
+
* One API: its links leave the service out. The services' lists are fetched before the app first renders, not
|
|
36
|
+
* before its code loads, so this is read as the app first renders and not as the module loads. It also reads the
|
|
37
|
+
* route again from the address, so it runs once: on every render it would throw away the route's state.
|
|
38
|
+
*/
|
|
39
|
+
function useDefaultService() {
|
|
40
|
+
useState(() => {
|
|
41
|
+
const names = Object.keys(registry.manifests)
|
|
42
|
+
setDefaultService(names.length === 1 ? names[0] : undefined)
|
|
43
|
+
})
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
const fakerLocales = signal<string[]>([])
|
|
@@ -39,6 +58,7 @@ function usePanelHost() {
|
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
export function App() {
|
|
61
|
+
useDefaultService()
|
|
42
62
|
const [variants, setVariants] = useState<Record<string, Variant[]>>({})
|
|
43
63
|
const [sequences, setSequences] = useState<SequenceInfo[] | undefined>(undefined)
|
|
44
64
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
@@ -134,6 +154,20 @@ export function App() {
|
|
|
134
154
|
for (const m of Object.values(registry.manifests)) {
|
|
135
155
|
for (const o of m.operations) {
|
|
136
156
|
const key = `${m.service}/${o.id}`
|
|
157
|
+
// each saved request is listed on its own too, and opens its operation on that request
|
|
158
|
+
for (const v of variants[key] ?? [])
|
|
159
|
+
out.push({
|
|
160
|
+
kind: 'req',
|
|
161
|
+
route: { kind: 'op', key, play: `saved:${v.file}` },
|
|
162
|
+
title: v.name,
|
|
163
|
+
group: 'request',
|
|
164
|
+
service: m.service,
|
|
165
|
+
tag: o.tags[0] ?? 'default',
|
|
166
|
+
method: o.method,
|
|
167
|
+
path: o.path,
|
|
168
|
+
tags: v.def.input.tags,
|
|
169
|
+
hay: `${o.id} ${o.method} ${o.path} ${(v.def.input.tags ?? []).join(' ')} ${v.file}`,
|
|
170
|
+
})
|
|
137
171
|
const saved = (variants[key] ?? []).map((v) => v.name).join(' ')
|
|
138
172
|
out.push({
|
|
139
173
|
kind: 'op',
|
|
@@ -186,14 +220,14 @@ export function App() {
|
|
|
186
220
|
if (r.kind !== 'op' && r.kind !== 'seq') return {}
|
|
187
221
|
const active = entries.find(
|
|
188
222
|
(e) =>
|
|
189
|
-
(e.
|
|
190
|
-
(e.
|
|
223
|
+
(e.kind === 'op' && r.kind === 'op' && e.route.kind === 'op' && e.route.key === r.key) ||
|
|
224
|
+
(e.kind === 'seq' && r.kind === 'seq' && e.route.kind === 'seq' && e.route.id === r.id),
|
|
191
225
|
)
|
|
192
226
|
if (!active) return {}
|
|
193
227
|
const sibs = entries.filter((e) =>
|
|
194
228
|
active.kind === 'seq'
|
|
195
229
|
? e.kind === 'seq'
|
|
196
|
-
: e.service === active.service && e.tag === active.tag,
|
|
230
|
+
: e.kind === 'op' && e.service === active.service && e.tag === active.tag,
|
|
197
231
|
)
|
|
198
232
|
const at = sibs.indexOf(active)
|
|
199
233
|
const to = (e?: Entry) => (e ? () => go({ ...e.route, play: '' } as typeof r) : undefined)
|
|
@@ -235,7 +269,7 @@ export function App() {
|
|
|
235
269
|
<span class="brand-name">{brand.value.name ?? 'Specbook'}</span>
|
|
236
270
|
</button>
|
|
237
271
|
<button type="button" class="palette-btn" onClick={() => (paletteOpen.value = true)}>
|
|
238
|
-
<span>Jump to an operation or flow
|
|
272
|
+
<span>{isMinimal ? 'Jump to a request or flow…' : 'Jump to an operation or flow…'}</span>
|
|
239
273
|
<kbd>⌘K</kbd>
|
|
240
274
|
</button>
|
|
241
275
|
<span class="spacer" />
|
|
@@ -306,13 +340,23 @@ export function App() {
|
|
|
306
340
|
Environment
|
|
307
341
|
<select
|
|
308
342
|
value={env.value}
|
|
309
|
-
onChange={(e) =>
|
|
343
|
+
onChange={(e) => {
|
|
344
|
+
const picked = (e.target as HTMLSelectElement).value
|
|
345
|
+
if (picked === NEW_ENVIRONMENT) {
|
|
346
|
+
// not an environment: open the dialog and leave the selection alone
|
|
347
|
+
;(e.target as HTMLSelectElement).value = env.value
|
|
348
|
+
newEnvironmentOpen.value = true
|
|
349
|
+
} else env.value = picked
|
|
350
|
+
}}
|
|
310
351
|
>
|
|
311
352
|
{environments.value.map((e) => (
|
|
312
353
|
<option value={e} title={environmentNote.value[e]}>
|
|
313
354
|
{e}
|
|
314
355
|
</option>
|
|
315
356
|
))}
|
|
357
|
+
{engine.canCreateEnvironment() && (
|
|
358
|
+
<option value={NEW_ENVIRONMENT}>New environment…</option>
|
|
359
|
+
)}
|
|
316
360
|
</select>
|
|
317
361
|
</label>
|
|
318
362
|
<GitHubStar />
|
|
@@ -326,10 +370,19 @@ export function App() {
|
|
|
326
370
|
</nav>
|
|
327
371
|
<ListResizer />
|
|
328
372
|
<main class="stage">
|
|
329
|
-
{(r.kind === 'home' || r.kind === 'browse') &&
|
|
373
|
+
{(r.kind === 'home' || r.kind === 'browse') && (
|
|
374
|
+
<Overview
|
|
375
|
+
services={services}
|
|
376
|
+
counts={{
|
|
377
|
+
flows: entries.filter((e) => e.kind === 'seq').length,
|
|
378
|
+
requests: entries.filter((e) => e.kind === 'req').length,
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
330
382
|
</main>
|
|
331
383
|
</div>
|
|
332
384
|
<Palette entries={entries} />
|
|
385
|
+
<NewEnvironmentDialog />
|
|
333
386
|
{panel && r.kind === 'op' && opInfo && (
|
|
334
387
|
<panel.Host
|
|
335
388
|
kind="op"
|
package/src/branding.ts
CHANGED
|
@@ -53,14 +53,22 @@ const tokens = (accent: string) =>
|
|
|
53
53
|
* The accent as CSS: unlayered, so it wins over the default tokens. A separate dark value applies
|
|
54
54
|
* under `prefers-color-scheme: dark` (unless the light theme is forced) and `data-theme="dark"`.
|
|
55
55
|
*/
|
|
56
|
-
export function brandCss(
|
|
56
|
+
export function brandCss(
|
|
57
|
+
b: Pick<BrandingConfig, 'accent' | 'accentDark'>,
|
|
58
|
+
scope: 'root' | 'host' = 'root',
|
|
59
|
+
): string {
|
|
57
60
|
if (!b.accent && !b.accentDark) return ''
|
|
58
|
-
|
|
61
|
+
// in a shadow root the tokens sit on the host element, which :root does not match
|
|
62
|
+
const base = scope === 'host' ? ':host' : ':root'
|
|
63
|
+
const notLight =
|
|
64
|
+
scope === 'host' ? ':host(:not([data-theme="light"]))' : ':root:not([data-theme="light"])'
|
|
65
|
+
const dark = scope === 'host' ? ':host([data-theme="dark"])' : ':root[data-theme="dark"]'
|
|
66
|
+
const light = b.accent ? `${base}{${tokens(b.accent)}}` : ''
|
|
59
67
|
if (!b.accentDark) return light
|
|
60
68
|
return (
|
|
61
69
|
`${light}` +
|
|
62
|
-
`@media (prefers-color-scheme: dark){
|
|
63
|
-
|
|
70
|
+
`@media (prefers-color-scheme: dark){${notLight}{${tokens(b.accentDark)}}}` +
|
|
71
|
+
`${dark}{${tokens(b.accentDark)}}`
|
|
64
72
|
)
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
|
2
2
|
import { brand } from '../branding'
|
|
3
3
|
import { catalogFilter, catalogService, type Entry, go, route, search } from '../nav'
|
|
4
4
|
import { Lock } from '../panel/icons'
|
|
5
|
+
import { isMinimal } from '../runtime'
|
|
5
6
|
import { ModeHint } from './ModeHint'
|
|
6
7
|
import { PoweredBy } from './PoweredBy'
|
|
7
8
|
|
|
@@ -19,10 +20,12 @@ const DEFAULT_FLOW_GROUP = 'Default'
|
|
|
19
20
|
|
|
20
21
|
function isActive(e: Entry): boolean {
|
|
21
22
|
const r = route.value
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
if (e.route.kind === 'op' && r.kind === 'op' && e.route.key === r.key) {
|
|
24
|
+
// a saved request is active on its own link; the operation row stays quiet while one of its requests is open
|
|
25
|
+
const onSaved = (r.play ?? '').startsWith('saved:')
|
|
26
|
+
return e.kind === 'req' ? r.play === e.route.play : !onSaved
|
|
27
|
+
}
|
|
28
|
+
return e.route.kind === 'seq' && r.kind === 'seq' && e.route.id === r.id
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
function OpRow({ e }: { e: Entry }) {
|
|
@@ -54,6 +57,11 @@ function useCatalog(entries: Entry[], services: ServiceInfo[]) {
|
|
|
54
57
|
const svc = single ? (services[0]?.id ?? '') : catalogService.value
|
|
55
58
|
const ops = useMemo(() => entries.filter((e) => e.kind === 'op'), [entries])
|
|
56
59
|
const flows = useMemo(() => entries.filter((e) => e.kind === 'seq'), [entries])
|
|
60
|
+
const reqs = useMemo(() => entries.filter((e) => e.kind === 'req'), [entries])
|
|
61
|
+
const shownReqs = useMemo(() => {
|
|
62
|
+
const scoped = svc ? reqs.filter((e) => e.service === svc) : reqs
|
|
63
|
+
return q ? search(scoped, q, 200) : scoped
|
|
64
|
+
}, [reqs, svc, q])
|
|
57
65
|
const shownOps = useMemo(() => {
|
|
58
66
|
const scoped = svc ? ops.filter((e) => e.service === svc) : ops
|
|
59
67
|
return q ? search(scoped, q, 500) : scoped
|
|
@@ -62,7 +70,7 @@ function useCatalog(entries: Entry[], services: ServiceInfo[]) {
|
|
|
62
70
|
const scoped = svc && !single ? flows.filter((e) => e.tags?.includes(svc)) : flows
|
|
63
71
|
return q ? search(scoped, q, 100) : scoped
|
|
64
72
|
}, [flows, svc, q, single])
|
|
65
|
-
return { q, single, svc, shownOps, shownFlows }
|
|
73
|
+
return { q, single, svc, shownOps, shownFlows, shownReqs }
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/** The left column: pills, filter and grouped rows. It never changes width, so opening the panel moves nothing. */
|
|
@@ -71,7 +79,14 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
|
|
|
71
79
|
const list = useRef<HTMLDivElement>(null)
|
|
72
80
|
// what the user chose per group; groups they haven't touched follow the default
|
|
73
81
|
const [chosen, setChosen] = useState<Map<string, boolean>>(new Map())
|
|
74
|
-
const { q, single, svc, shownOps, shownFlows } = useCatalog(entries, services)
|
|
82
|
+
const { q, single, svc, shownOps, shownFlows, shownReqs } = useCatalog(entries, services)
|
|
83
|
+
// the flows and the requests are each a section that folds, open until the person closes it
|
|
84
|
+
const sectionOpen = (name: string) => chosen.get(`section:${name}`) ?? true
|
|
85
|
+
const toggleSection = (name: string) => {
|
|
86
|
+
const n = new Map(chosen)
|
|
87
|
+
n.set(`section:${name}`, !sectionOpen(name))
|
|
88
|
+
setChosen(n)
|
|
89
|
+
}
|
|
75
90
|
|
|
76
91
|
useEffect(() => {
|
|
77
92
|
const key = (e: KeyboardEvent) => {
|
|
@@ -165,7 +180,13 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
|
|
|
165
180
|
ref={filter}
|
|
166
181
|
type="text"
|
|
167
182
|
class="cat-filter"
|
|
168
|
-
placeholder={
|
|
183
|
+
placeholder={
|
|
184
|
+
svc
|
|
185
|
+
? `Filter ${svc}`
|
|
186
|
+
: isMinimal
|
|
187
|
+
? 'Filter requests and flows'
|
|
188
|
+
: 'Filter operations and flows'
|
|
189
|
+
}
|
|
169
190
|
aria-label="Filter"
|
|
170
191
|
value={catalogFilter.value}
|
|
171
192
|
onInput={(e) => (catalogFilter.value = (e.target as HTMLInputElement).value)}
|
|
@@ -185,48 +206,89 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
|
|
|
185
206
|
|
|
186
207
|
{flowGroups.length > 0 && (
|
|
187
208
|
<section class="flows" aria-label="Flows">
|
|
188
|
-
<
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
class="flows-title"
|
|
212
|
+
aria-expanded={sectionOpen('flows')}
|
|
213
|
+
onClick={() => toggleSection('flows')}
|
|
214
|
+
>
|
|
215
|
+
<span class="caret">{sectionOpen('flows') ? '▾' : '▸'}</span>
|
|
189
216
|
<span class="method flow">FLOW</span>
|
|
190
|
-
<span class="muted small">Multi-step
|
|
217
|
+
<span class="muted small">Multi-step flows you can run end to end</span>
|
|
191
218
|
<span class="count">{shownFlows.length}</span>
|
|
192
|
-
</
|
|
193
|
-
{
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
type="button"
|
|
202
|
-
class="group-head flow-head"
|
|
203
|
-
aria-expanded={expanded}
|
|
204
|
-
onClick={() => {
|
|
205
|
-
const n = new Map(chosen)
|
|
206
|
-
n.set(key, !expanded)
|
|
207
|
-
setChosen(n)
|
|
208
|
-
}}
|
|
209
|
-
>
|
|
210
|
-
<span class="caret">{expanded ? '▾' : '▸'}</span> {name}{' '}
|
|
211
|
-
<span class="count">{rows.length}</span>
|
|
212
|
-
</button>
|
|
213
|
-
)}
|
|
214
|
-
{expanded &&
|
|
215
|
-
rows.map((e) => (
|
|
219
|
+
</button>
|
|
220
|
+
{sectionOpen('flows') &&
|
|
221
|
+
flowGroups.map(([name, rows]) => {
|
|
222
|
+
const key = `flows:${name}`
|
|
223
|
+
const solo = flowGroups.length === 1
|
|
224
|
+
const expanded = solo || (chosen.get(key) ?? true)
|
|
225
|
+
return (
|
|
226
|
+
<div class="flow-group" key={key}>
|
|
227
|
+
{!solo && (
|
|
216
228
|
<button
|
|
217
229
|
type="button"
|
|
218
|
-
class=
|
|
219
|
-
|
|
230
|
+
class="group-head flow-head"
|
|
231
|
+
aria-expanded={expanded}
|
|
232
|
+
onClick={() => {
|
|
233
|
+
const n = new Map(chosen)
|
|
234
|
+
n.set(key, !expanded)
|
|
235
|
+
setChosen(n)
|
|
236
|
+
}}
|
|
220
237
|
>
|
|
221
|
-
<span class="
|
|
222
|
-
|
|
223
|
-
</span>
|
|
224
|
-
<span class="op-title">{e.title}</span>
|
|
238
|
+
<span class="caret">{expanded ? '▾' : '▸'}</span> {name}{' '}
|
|
239
|
+
<span class="count">{rows.length}</span>
|
|
225
240
|
</button>
|
|
241
|
+
)}
|
|
242
|
+
{expanded &&
|
|
243
|
+
rows.map((e) => (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
class={`op-row flow-row ${isActive(e) ? 'active' : ''}`}
|
|
247
|
+
onClick={() => go(e.route)}
|
|
248
|
+
>
|
|
249
|
+
<span class="flow-play" aria-hidden="true">
|
|
250
|
+
▶
|
|
251
|
+
</span>
|
|
252
|
+
<span class="op-title">{e.title}</span>
|
|
253
|
+
</button>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
})}
|
|
258
|
+
</section>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{shownReqs.length > 0 && (
|
|
262
|
+
<section class="flows requests" aria-label="Requests">
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
class="flows-title"
|
|
266
|
+
aria-expanded={sectionOpen('requests')}
|
|
267
|
+
onClick={() => toggleSection('requests')}
|
|
268
|
+
>
|
|
269
|
+
<span class="caret">{sectionOpen('requests') ? '▾' : '▸'}</span>
|
|
270
|
+
<span class="method req">REQ</span>
|
|
271
|
+
<span class="muted small">Requests you can run and check</span>
|
|
272
|
+
<span class="count">{shownReqs.length}</span>
|
|
273
|
+
</button>
|
|
274
|
+
{sectionOpen('requests') &&
|
|
275
|
+
shownReqs.map((e) => (
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
class={`op-row req-row ${isActive(e) ? 'active' : ''}`}
|
|
279
|
+
onClick={() => go(e.route)}
|
|
280
|
+
title={`${e.method} ${e.path}`}
|
|
281
|
+
>
|
|
282
|
+
<span class={`method ${e.method}`}>{e.method}</span>
|
|
283
|
+
<span class="op-title">
|
|
284
|
+
{e.title}
|
|
285
|
+
{(e.tags ?? []).map((t) => (
|
|
286
|
+
<span class="chip req-tag">{t}</span>
|
|
226
287
|
))}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
288
|
+
</span>
|
|
289
|
+
<span class="op-path">{e.path}</span>
|
|
290
|
+
</button>
|
|
291
|
+
))}
|
|
230
292
|
</section>
|
|
231
293
|
)}
|
|
232
294
|
|
|
@@ -247,7 +309,7 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
|
|
|
247
309
|
</div>
|
|
248
310
|
)
|
|
249
311
|
})}
|
|
250
|
-
{groups.length === 0 && flowGroups.length === 0 && (
|
|
312
|
+
{groups.length === 0 && flowGroups.length === 0 && shownReqs.length === 0 && (
|
|
251
313
|
<p class="muted cat-empty">
|
|
252
314
|
{q ? `Nothing matches “${q}”.` : 'No operations.'}{' '}
|
|
253
315
|
{q && (
|
|
@@ -262,7 +324,13 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
|
|
|
262
324
|
}
|
|
263
325
|
|
|
264
326
|
/** The stage when nothing is open: the API's identity, since the list is the content. */
|
|
265
|
-
export function Overview({
|
|
327
|
+
export function Overview({
|
|
328
|
+
services,
|
|
329
|
+
counts,
|
|
330
|
+
}: {
|
|
331
|
+
services: ServiceInfo[]
|
|
332
|
+
counts?: { flows: number; requests: number }
|
|
333
|
+
}) {
|
|
266
334
|
const b = brand.value
|
|
267
335
|
// wait for the project's branding: showing the fallback first and swapping it makes the page jump
|
|
268
336
|
if (!b.ready) return <div class="overview" aria-busy="true" />
|
|
@@ -274,11 +342,16 @@ export function Overview({ services }: { services: ServiceInfo[] }) {
|
|
|
274
342
|
(single
|
|
275
343
|
? (single.title ?? single.id)
|
|
276
344
|
: `${services.length} APIs · ${total.toLocaleString()} operations`)
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
345
|
+
const worth = (n: number, one: string, many: string) =>
|
|
346
|
+
`${n.toLocaleString()} ${n === 1 ? one : many}`
|
|
347
|
+
const meta =
|
|
348
|
+
isMinimal && counts
|
|
349
|
+
? `${worth(counts.flows, 'flow', 'flows')} · ${worth(counts.requests, 'request', 'requests')}`
|
|
350
|
+
: single
|
|
351
|
+
? `${single.ops} ${single.ops === 1 ? 'operation' : 'operations'}${single.version ? ` · v${single.version}` : ''}`
|
|
352
|
+
: b.name || b.title
|
|
353
|
+
? `${services.length} APIs · ${total.toLocaleString()} operations`
|
|
354
|
+
: undefined
|
|
282
355
|
return (
|
|
283
356
|
<div class="overview">
|
|
284
357
|
{b.logo && (
|
|
@@ -297,14 +370,18 @@ export function Overview({ services }: { services: ServiceInfo[] }) {
|
|
|
297
370
|
<h1>{title}</h1>
|
|
298
371
|
{b.tagline && <p class="tagline">{b.tagline}</p>}
|
|
299
372
|
{meta && <p class="muted">{meta}</p>}
|
|
300
|
-
<p class="overview-hint">
|
|
373
|
+
<p class="overview-hint">
|
|
374
|
+
{isMinimal
|
|
375
|
+
? 'Choose a request or flow on the left to run it.'
|
|
376
|
+
: 'Choose an operation or flow on the left to read it and try it.'}
|
|
377
|
+
</p>
|
|
301
378
|
<ModeHint />
|
|
302
379
|
<button
|
|
303
380
|
type="button"
|
|
304
381
|
class="btn primary overview-browse"
|
|
305
382
|
onClick={() => go({ kind: 'browse' })}
|
|
306
383
|
>
|
|
307
|
-
Browse operations and flows
|
|
384
|
+
{isMinimal ? 'Browse requests and flows' : 'Browse operations and flows'}
|
|
308
385
|
</button>
|
|
309
386
|
{b.links.length > 0 && (
|
|
310
387
|
<p class="overview-links">
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { engine } from '../runtime'
|
|
2
|
+
import {
|
|
3
|
+
env,
|
|
4
|
+
environmentNote,
|
|
5
|
+
environmentRisk,
|
|
6
|
+
environments,
|
|
7
|
+
features,
|
|
8
|
+
newEnvironmentOpen,
|
|
9
|
+
} from '../store'
|
|
10
|
+
|
|
11
|
+
const NEW_ENVIRONMENT = '\u0000new-environment'
|
|
2
12
|
|
|
3
13
|
/**
|
|
4
14
|
* The environment a request will run in, and a way to change it. On a phone the header with the
|
|
@@ -19,7 +29,8 @@ export function EnvBadge() {
|
|
|
19
29
|
: risk === 'production' || (risk !== 'sandbox' && /prod/i.test(name))
|
|
20
30
|
? 'prod'
|
|
21
31
|
: 'live'
|
|
22
|
-
const
|
|
32
|
+
const canCreate = features.value.createEnvironment && engine.canCreateEnvironment()
|
|
33
|
+
const switchable = names.length > 1 || canCreate
|
|
23
34
|
return (
|
|
24
35
|
<span
|
|
25
36
|
class={`env-badge ${kind} ${switchable ? 'switch' : ''}`}
|
|
@@ -34,13 +45,20 @@ export function EnvBadge() {
|
|
|
34
45
|
<select
|
|
35
46
|
aria-label="Environment"
|
|
36
47
|
value={name}
|
|
37
|
-
onChange={(e) =>
|
|
48
|
+
onChange={(e) => {
|
|
49
|
+
const picked = (e.target as HTMLSelectElement).value
|
|
50
|
+
if (picked === NEW_ENVIRONMENT) {
|
|
51
|
+
;(e.target as HTMLSelectElement).value = env.value
|
|
52
|
+
newEnvironmentOpen.value = true
|
|
53
|
+
} else env.value = picked
|
|
54
|
+
}}
|
|
38
55
|
>
|
|
39
56
|
{names.map((n) => (
|
|
40
57
|
<option value={n} title={environmentNote.value[n]}>
|
|
41
58
|
{n}
|
|
42
59
|
</option>
|
|
43
60
|
))}
|
|
61
|
+
{canCreate && <option value={NEW_ENVIRONMENT}>New environment…</option>}
|
|
44
62
|
</select>
|
|
45
63
|
)}
|
|
46
64
|
</span>
|