@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/src/oauth.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
tokenRequestFields,
|
|
9
9
|
} from '@specbook/core'
|
|
10
10
|
import { OAUTH_CHANNEL, type OAuthMessage } from './oauth-callback'
|
|
11
|
+
import { kv, type Store } from './storage'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Page side of "Sign in": opens the provider in a popup, waits for the return leg
|
|
@@ -137,14 +138,14 @@ export async function signIn(
|
|
|
137
138
|
const CLIENTS = 'specbook.oauth.clients.v1'
|
|
138
139
|
const EXPIRY = 'specbook.oauth.expiry.v1'
|
|
139
140
|
|
|
140
|
-
const readJson = (store:
|
|
141
|
+
const readJson = (store: Store, key: string): Record<string, any> => {
|
|
141
142
|
try {
|
|
142
143
|
return JSON.parse(store.getItem(key) ?? '{}')
|
|
143
144
|
} catch {
|
|
144
145
|
return {}
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
|
-
const writeJson = (store:
|
|
148
|
+
const writeJson = (store: Store, key: string, value: unknown) => {
|
|
148
149
|
try {
|
|
149
150
|
store.setItem(key, JSON.stringify(value))
|
|
150
151
|
} catch {
|
|
@@ -154,7 +155,7 @@ const writeJson = (store: Storage, key: string, value: unknown) => {
|
|
|
154
155
|
|
|
155
156
|
export const recallClientId = (authorizationUrl: string): string | undefined => {
|
|
156
157
|
try {
|
|
157
|
-
return readJson(
|
|
158
|
+
return readJson(kv.local, CLIENTS)[authorizationUrl]
|
|
158
159
|
} catch {
|
|
159
160
|
return undefined
|
|
160
161
|
}
|
|
@@ -162,8 +163,8 @@ export const recallClientId = (authorizationUrl: string): string | undefined =>
|
|
|
162
163
|
|
|
163
164
|
export const rememberClientId = (authorizationUrl: string, clientId: string): void => {
|
|
164
165
|
try {
|
|
165
|
-
writeJson(
|
|
166
|
-
...readJson(
|
|
166
|
+
writeJson(kv.local, CLIENTS, {
|
|
167
|
+
...readJson(kv.local, CLIENTS),
|
|
167
168
|
[authorizationUrl]: clientId,
|
|
168
169
|
})
|
|
169
170
|
} catch {
|
|
@@ -176,10 +177,10 @@ const expiryKey = (origin: string, name: string) => `${origin}\n${name}`
|
|
|
176
177
|
/** Remembered for this tab only, to say "expires in 59 min"; the token itself stays in the secret store. */
|
|
177
178
|
export const noteExpiry = (origin: string, name: string, expiresIn: number | undefined): void => {
|
|
178
179
|
try {
|
|
179
|
-
const t = readJson(
|
|
180
|
+
const t = readJson(kv.session, EXPIRY)
|
|
180
181
|
if (expiresIn === undefined) delete t[expiryKey(origin, name)]
|
|
181
182
|
else t[expiryKey(origin, name)] = Date.now() + expiresIn * 1000
|
|
182
|
-
writeJson(
|
|
183
|
+
writeJson(kv.session, EXPIRY, t)
|
|
183
184
|
} catch {
|
|
184
185
|
/* no storage */
|
|
185
186
|
}
|
|
@@ -187,7 +188,7 @@ export const noteExpiry = (origin: string, name: string, expiresIn: number | und
|
|
|
187
188
|
|
|
188
189
|
export const expiryOf = (origin: string, name: string): number | undefined => {
|
|
189
190
|
try {
|
|
190
|
-
return readJson(
|
|
191
|
+
return readJson(kv.session, EXPIRY)[expiryKey(origin, name)]
|
|
191
192
|
} catch {
|
|
192
193
|
return undefined
|
|
193
194
|
}
|
package/src/panel/Body.tsx
CHANGED
|
@@ -20,10 +20,20 @@ interface Props {
|
|
|
20
20
|
files?: string[]
|
|
21
21
|
/** the project's own notes for body fields, by `data.<path>` */
|
|
22
22
|
notes?: Map<string, string>
|
|
23
|
+
/** shown, not edited */
|
|
24
|
+
readOnly?: boolean
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/** Keeps the typed texts locally, so invalid variables JSON isn't rewritten while the user is typing. */
|
|
26
|
-
function GraphqlBody({
|
|
28
|
+
function GraphqlBody({
|
|
29
|
+
draft,
|
|
30
|
+
onChange,
|
|
31
|
+
readOnly,
|
|
32
|
+
}: {
|
|
33
|
+
draft: Draft
|
|
34
|
+
onChange: (d: Draft) => void
|
|
35
|
+
readOnly?: boolean
|
|
36
|
+
}) {
|
|
27
37
|
const [g, setG] = useState(() => splitGraphql(draft.body) as Graphql)
|
|
28
38
|
const emitted = useRef(draft.body)
|
|
29
39
|
useEffect(() => {
|
|
@@ -46,6 +56,7 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|
|
46
56
|
<span class="chip">graphql</span>
|
|
47
57
|
</div>
|
|
48
58
|
<BodyEditor
|
|
59
|
+
readOnly={readOnly}
|
|
49
60
|
label="GraphQL query"
|
|
50
61
|
language="graphql"
|
|
51
62
|
value={g.query}
|
|
@@ -56,6 +67,7 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|
|
56
67
|
<span class="chip">json</span>
|
|
57
68
|
</div>
|
|
58
69
|
<BodyEditor
|
|
70
|
+
readOnly={readOnly}
|
|
59
71
|
label="GraphQL variables"
|
|
60
72
|
language="json"
|
|
61
73
|
value={g.variables}
|
|
@@ -67,7 +79,16 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|
|
67
79
|
|
|
68
80
|
const language = (e: Encoding) => (e === 'graphql' ? 'graphql' : e === 'text' ? 'text' : 'json')
|
|
69
81
|
|
|
70
|
-
export function Body({
|
|
82
|
+
export function Body({
|
|
83
|
+
encoding,
|
|
84
|
+
draft,
|
|
85
|
+
onChange,
|
|
86
|
+
schema,
|
|
87
|
+
defs,
|
|
88
|
+
files = [],
|
|
89
|
+
notes,
|
|
90
|
+
readOnly,
|
|
91
|
+
}: Props) {
|
|
71
92
|
const [line, setLine] = useState(0)
|
|
72
93
|
const paths = useMemo(() => linePaths(draft.body), [draft.body])
|
|
73
94
|
const documented = encoding === 'json' || encoding === 'form'
|
|
@@ -136,6 +157,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
|
|
|
136
157
|
title={encoding === 'binary' ? 'File' : 'Body'}
|
|
137
158
|
bare={!!viewToggle}
|
|
138
159
|
rows={draft.fields}
|
|
160
|
+
readOnly={readOnly}
|
|
139
161
|
onChange={set}
|
|
140
162
|
documented={[...new Set(fileKeys)].map((name) => ({ name, file: true }))}
|
|
141
163
|
docFor={(k) => withNote(fieldDoc(schema, defs, keyPath(k), ['data']), notes, 'data', k)}
|
|
@@ -153,7 +175,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
|
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
if (encoding === 'graphql' && splitGraphql(draft.body))
|
|
156
|
-
return <GraphqlBody draft={draft} onChange={onChange} />
|
|
178
|
+
return <GraphqlBody draft={draft} onChange={onChange} readOnly={readOnly} />
|
|
157
179
|
|
|
158
180
|
return (
|
|
159
181
|
<section class="body" aria-label="Body">
|
|
@@ -161,7 +183,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
|
|
|
161
183
|
<h3>Body</h3>
|
|
162
184
|
<span class="chip">{encoding}</span>
|
|
163
185
|
<span class="spacer" />
|
|
164
|
-
{encoding !== 'text' && (
|
|
186
|
+
{encoding !== 'text' && !readOnly && (
|
|
165
187
|
<button type="button" class="link" onClick={format}>
|
|
166
188
|
Format
|
|
167
189
|
</button>
|
|
@@ -169,6 +191,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
|
|
|
169
191
|
{viewToggle}
|
|
170
192
|
</div>
|
|
171
193
|
<BodyEditor
|
|
194
|
+
readOnly={readOnly}
|
|
172
195
|
label="Request body"
|
|
173
196
|
language={language(encoding === 'form' ? 'json' : encoding)}
|
|
174
197
|
value={draft.body}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type AssertionResult, type DiffLine, diffAssertion } from '@specbook/core'
|
|
2
|
+
import { registry } from '../runtime'
|
|
3
|
+
|
|
4
|
+
/** The project file an assertion failed in, as `file:line`, when its stack names one of the project's requests or flows. */
|
|
5
|
+
export function whereOf(a: AssertionResult): string | undefined {
|
|
6
|
+
const loc = a.location
|
|
7
|
+
if (!loc) return undefined
|
|
8
|
+
const files = [...registry.requests, ...registry.sequences].map((e) => e.file)
|
|
9
|
+
// the stack names a file by its address in the page; the longest project path it ends with is the file
|
|
10
|
+
const hit = files.filter((f) => loc.file.includes(f)).sort((a, b) => b.length - a.length)[0]
|
|
11
|
+
return hit ? `${hit}:${loc.line}` : undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const GUTTER = { expected: '−', received: '+', same: ' ', skip: ' ' } as const
|
|
15
|
+
const SPOKEN = { expected: 'expected', received: 'received', same: '', skip: '' } as const
|
|
16
|
+
|
|
17
|
+
function Diff({ lines }: { lines: DiffLine[] }) {
|
|
18
|
+
return (
|
|
19
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: a block that scrolls sideways must be reachable by keyboard
|
|
20
|
+
<section class="diff" tabIndex={0} aria-label="Expected and received values">
|
|
21
|
+
{lines.map((l) => (
|
|
22
|
+
<div class={`diff-line diff-${l.kind}`}>
|
|
23
|
+
<span class="diff-gutter" aria-hidden="true">
|
|
24
|
+
{GUTTER[l.kind]}
|
|
25
|
+
</span>
|
|
26
|
+
{SPOKEN[l.kind] && <span class="visually-hidden">{SPOKEN[l.kind]}: </span>}
|
|
27
|
+
<span class="diff-text">{l.text}</span>
|
|
28
|
+
</div>
|
|
29
|
+
))}
|
|
30
|
+
</section>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One failed assertion: what it checks, what was expected and received as a colored diff, and where it is. */
|
|
35
|
+
export function Failure({ a }: { a: AssertionResult }) {
|
|
36
|
+
const lines = diffAssertion(a)
|
|
37
|
+
const where = whereOf(a)
|
|
38
|
+
return (
|
|
39
|
+
<fieldset class="check-fail">
|
|
40
|
+
<legend class="visually-hidden">Failed check: {a.label}</legend>
|
|
41
|
+
<div class="check-head">
|
|
42
|
+
<span class="check-mark" aria-hidden="true">
|
|
43
|
+
✗
|
|
44
|
+
</span>
|
|
45
|
+
<strong>{a.label}</strong>
|
|
46
|
+
<span class="chip">{a.operator}</span>
|
|
47
|
+
</div>
|
|
48
|
+
{lines.length > 0 && (
|
|
49
|
+
<>
|
|
50
|
+
<div class="diff-legend" aria-hidden="true">
|
|
51
|
+
<span class="diff-key diff-expected">− Expected</span>
|
|
52
|
+
<span class="diff-key diff-received">+ Received</span>
|
|
53
|
+
</div>
|
|
54
|
+
<Diff lines={lines} />
|
|
55
|
+
</>
|
|
56
|
+
)}
|
|
57
|
+
{where && <div class="check-where">at {where}</div>}
|
|
58
|
+
</fieldset>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The checks of a step: passing ones in a line, failing ones as cards with their diff. */
|
|
63
|
+
export function Checks({ assertions }: { assertions: AssertionResult[] }) {
|
|
64
|
+
return (
|
|
65
|
+
<ul class="checks">
|
|
66
|
+
{assertions.map((a) =>
|
|
67
|
+
a.pass ? (
|
|
68
|
+
<li class="ok">
|
|
69
|
+
<span class="tick">✓</span> {a.label}
|
|
70
|
+
</li>
|
|
71
|
+
) : (
|
|
72
|
+
<li class="bad">
|
|
73
|
+
<Failure a={a} />
|
|
74
|
+
</li>
|
|
75
|
+
),
|
|
76
|
+
)}
|
|
77
|
+
</ul>
|
|
78
|
+
)
|
|
79
|
+
}
|
package/src/panel/CodeSample.tsx
CHANGED
|
@@ -11,14 +11,15 @@ import {
|
|
|
11
11
|
import type { ComponentChildren } from 'preact'
|
|
12
12
|
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
|
13
13
|
import { engine } from '../runtime'
|
|
14
|
-
import {
|
|
14
|
+
import { kv } from '../storage'
|
|
15
|
+
import { env, envVarsVersion, version } from '../store'
|
|
15
16
|
import { type Draft, requestParts } from './draft'
|
|
16
17
|
|
|
17
18
|
const LANG_KEY = 'specbook:code-lang'
|
|
18
19
|
|
|
19
20
|
function savedLang(): SnippetLang {
|
|
20
21
|
try {
|
|
21
|
-
const v =
|
|
22
|
+
const v = kv.local.getItem(LANG_KEY)
|
|
22
23
|
if (v === 'curl' || v === 'fetch') return v
|
|
23
24
|
} catch {
|
|
24
25
|
/* optional */
|
|
@@ -50,19 +51,25 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
|
|
|
50
51
|
|
|
51
52
|
useEffect(() => {
|
|
52
53
|
let live = true
|
|
53
|
-
engine.config(version.value).then((c) => {
|
|
54
|
-
if (!live) return
|
|
54
|
+
engine.config(version.value).then(async (c) => {
|
|
55
55
|
const svc = c.services?.[service]
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const addressIn = (e: string) =>
|
|
57
|
+
engine.baseUrlOf(service, e, version.value).catch(() => undefined)
|
|
58
|
+
// the environment being used, so a sample for the sandbox points at the sandbox; mock, or an
|
|
59
|
+
// environment with no address, uses `live`, else the first address the service lists
|
|
60
|
+
let address = env.value === 'mock' ? undefined : await addressIn(env.value)
|
|
61
|
+
address ??= await addressIn('live')
|
|
62
|
+
if (!address && svc && typeof svc.baseUrl === 'object')
|
|
63
|
+
address = Object.values(svc.baseUrl)[0]
|
|
64
|
+
if (!live) return
|
|
65
|
+
setBase(address)
|
|
59
66
|
const name = profile === 'service' ? svc?.auth : profile
|
|
60
67
|
setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
|
|
61
68
|
})
|
|
62
69
|
return () => {
|
|
63
70
|
live = false
|
|
64
71
|
}
|
|
65
|
-
}, [service, profile, env.value])
|
|
72
|
+
}, [service, profile, env.value, envVarsVersion.value])
|
|
66
73
|
|
|
67
74
|
const built = useMemo<{ text?: string; error?: string }>(() => {
|
|
68
75
|
try {
|
|
@@ -76,7 +83,7 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
|
|
|
76
83
|
setView(l)
|
|
77
84
|
if (l === 'sent') return
|
|
78
85
|
try {
|
|
79
|
-
|
|
86
|
+
kv.local.setItem(LANG_KEY, l)
|
|
80
87
|
} catch {
|
|
81
88
|
/* optional */
|
|
82
89
|
}
|
package/src/panel/Combo.tsx
CHANGED
|
@@ -9,40 +9,71 @@ interface Props {
|
|
|
9
9
|
/** forwarded so the grid can move between rows with Enter and the arrow keys */
|
|
10
10
|
onKeyDown?: (e: KeyboardEvent) => void
|
|
11
11
|
cell?: string
|
|
12
|
+
/** shown, not edited: no list, no typing */
|
|
13
|
+
readOnly?: boolean
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/** A text input with the spec's allowed values as a dropdown; any other value can still be typed. */
|
|
15
|
-
export function Combo({
|
|
17
|
+
export function Combo({
|
|
18
|
+
value,
|
|
19
|
+
options,
|
|
20
|
+
onChange,
|
|
21
|
+
label,
|
|
22
|
+
placeholder,
|
|
23
|
+
onKeyDown,
|
|
24
|
+
cell,
|
|
25
|
+
readOnly,
|
|
26
|
+
}: Props) {
|
|
16
27
|
const [open, setOpen] = useState(false)
|
|
17
28
|
const [at, setAt] = useState(-1)
|
|
18
29
|
const [pos, setPos] = useState<Record<string, string>>()
|
|
19
30
|
const box = useRef<HTMLDivElement>(null)
|
|
20
31
|
|
|
21
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The list is `fixed` so the grid's rounded, clipped table and the scrolling pane can't cut it off. A
|
|
34
|
+
* `fixed` box is placed against the viewport unless an ancestor contains it (a `container-type` does), so
|
|
35
|
+
* a probe filling the inset-0 box says where its coordinates really start.
|
|
36
|
+
*/
|
|
22
37
|
const place = () => {
|
|
23
|
-
const
|
|
24
|
-
|
|
38
|
+
const el = box.current
|
|
39
|
+
const r = el?.getBoundingClientRect()
|
|
40
|
+
if (!el || !r) return
|
|
41
|
+
const probe = document.createElement('div')
|
|
42
|
+
probe.style.cssText = 'position:fixed;inset:0;visibility:hidden;pointer-events:none'
|
|
43
|
+
el.append(probe)
|
|
44
|
+
const cb = probe.getBoundingClientRect()
|
|
45
|
+
probe.remove()
|
|
25
46
|
const below = innerHeight - r.bottom - 8
|
|
26
47
|
const flip = below < 160 && r.top > below
|
|
27
48
|
// strings with units: Preact 11 sets style properties as given
|
|
28
49
|
setPos({
|
|
29
|
-
left: `${r.left}px`,
|
|
50
|
+
left: `${r.left - cb.left}px`,
|
|
30
51
|
width: `${Math.max(r.width, 160)}px`,
|
|
31
52
|
maxHeight: `${Math.min(240, (flip ? r.top : below) - 4)}px`,
|
|
32
|
-
...(flip ? { bottom: `${
|
|
53
|
+
...(flip ? { bottom: `${cb.bottom - r.top + 2}px` } : { top: `${r.bottom + 2 - cb.top}px` }),
|
|
33
54
|
})
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
useEffect(() => {
|
|
37
|
-
if (!open)
|
|
58
|
+
if (!open) {
|
|
59
|
+
// the next opening starts unplaced, not where it last was
|
|
60
|
+
setPos(undefined)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
38
63
|
place()
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
// the list follows its field when something scrolls (a phone moves the field clear of its keyboard),
|
|
65
|
+
// and goes once the field is out of sight
|
|
66
|
+
const follow = (e: Event) => {
|
|
67
|
+
if (e.target instanceof Node && box.current?.contains(e.target)) return
|
|
68
|
+
const r = box.current?.getBoundingClientRect()
|
|
69
|
+
if (!r || r.bottom < 0 || r.top > innerHeight) setOpen(false)
|
|
70
|
+
else place()
|
|
41
71
|
}
|
|
42
|
-
|
|
72
|
+
const close = () => setOpen(false)
|
|
73
|
+
addEventListener('scroll', follow, true)
|
|
43
74
|
addEventListener('resize', close)
|
|
44
75
|
return () => {
|
|
45
|
-
removeEventListener('scroll',
|
|
76
|
+
removeEventListener('scroll', follow, true)
|
|
46
77
|
removeEventListener('resize', close)
|
|
47
78
|
}
|
|
48
79
|
}, [open])
|
|
@@ -73,7 +104,8 @@ export function Combo({ value, options, onChange, label, placeholder, onKeyDown,
|
|
|
73
104
|
aria-expanded={open}
|
|
74
105
|
role="combobox"
|
|
75
106
|
autocomplete="off"
|
|
76
|
-
|
|
107
|
+
readOnly={readOnly}
|
|
108
|
+
onFocus={() => !readOnly && setOpen(true)}
|
|
77
109
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
78
110
|
onKeyDown={(e) => {
|
|
79
111
|
if (open && e.key === 'ArrowDown') {
|
|
@@ -99,12 +131,19 @@ export function Combo({ value, options, onChange, label, placeholder, onKeyDown,
|
|
|
99
131
|
class="combo-toggle"
|
|
100
132
|
tabIndex={-1}
|
|
101
133
|
aria-label={`Show ${label} options`}
|
|
134
|
+
disabled={readOnly}
|
|
102
135
|
onClick={() => setOpen(!open)}
|
|
103
136
|
>
|
|
104
137
|
▾
|
|
105
138
|
</button>
|
|
106
139
|
{open && (
|
|
107
|
-
|
|
140
|
+
// until it is placed it waits out of sight at the corner: in the flow of the table it would grow
|
|
141
|
+
// the scrolling pane for a moment, and the pane jumps to the bottom
|
|
142
|
+
<div
|
|
143
|
+
class="combo-list"
|
|
144
|
+
role="listbox"
|
|
145
|
+
style={pos ?? { top: '0px', left: '0px', visibility: 'hidden' }}
|
|
146
|
+
>
|
|
108
147
|
{options.map((o, i) => (
|
|
109
148
|
<button
|
|
110
149
|
type="button"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'preact/hooks'
|
|
2
|
-
import { defaultFor, type FieldSpec } from '../components/Fields'
|
|
2
|
+
import { defaultFor, type FieldSpec, generateFor } from '../components/Fields'
|
|
3
3
|
import { Combo } from './Combo'
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
@@ -135,16 +135,48 @@ function ValueCell({
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/** A die: the control that makes another generated value. */
|
|
139
|
+
function Die() {
|
|
140
|
+
return (
|
|
141
|
+
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
|
|
142
|
+
<rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="none" stroke="currentColor" />
|
|
143
|
+
<circle cx="5.2" cy="5.2" r="1.1" fill="currentColor" />
|
|
144
|
+
<circle cx="10.8" cy="10.8" r="1.1" fill="currentColor" />
|
|
145
|
+
<circle cx="8" cy="8" r="1.1" fill="currentColor" />
|
|
146
|
+
</svg>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
/**
|
|
139
|
-
* A
|
|
151
|
+
* A flow's controls (inputs, or the choices of an input step) as the same spreadsheet the request
|
|
140
152
|
* parameters use: name, value, description.
|
|
141
153
|
*/
|
|
142
154
|
export function ControlsGrid({ title, specs, values, onChange }: Props) {
|
|
143
155
|
const names = Object.keys(specs)
|
|
144
156
|
if (names.length === 0) return null
|
|
157
|
+
const generated = names.filter((n) => specs[n]?.generate)
|
|
145
158
|
return (
|
|
146
159
|
<section class="grid controls-grid" aria-label={title}>
|
|
147
|
-
<h3>
|
|
160
|
+
<h3 class="controls-head">
|
|
161
|
+
<span>{title}</span>
|
|
162
|
+
{generated.length > 1 && (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="btn small gen-all"
|
|
166
|
+
title="Make a new value for every generated input"
|
|
167
|
+
onClick={() =>
|
|
168
|
+
onChange({
|
|
169
|
+
...values,
|
|
170
|
+
...Object.fromEntries(
|
|
171
|
+
generated.map((n) => [n, generateFor(specs[n] as FieldSpec)]),
|
|
172
|
+
),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
>
|
|
176
|
+
<Die /> Generate all
|
|
177
|
+
</button>
|
|
178
|
+
)}
|
|
179
|
+
</h3>
|
|
148
180
|
<table>
|
|
149
181
|
<colgroup>
|
|
150
182
|
<col class="c-key" />
|
|
@@ -169,12 +201,25 @@ export function ControlsGrid({ title, specs, values, onChange }: Props) {
|
|
|
169
201
|
</span>
|
|
170
202
|
</td>
|
|
171
203
|
<td class="g-val">
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
204
|
+
<div class={spec.generate ? 'cell-gen' : undefined}>
|
|
205
|
+
<ValueCell
|
|
206
|
+
name={name}
|
|
207
|
+
spec={spec}
|
|
208
|
+
value={values[name]}
|
|
209
|
+
onChange={(v) => onChange({ ...values, [name]: v })}
|
|
210
|
+
/>
|
|
211
|
+
{spec.generate && (
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
class="gen"
|
|
215
|
+
aria-label={`Generate ${spec.label ?? name}`}
|
|
216
|
+
title="Make another value"
|
|
217
|
+
onClick={() => onChange({ ...values, [name]: generateFor(spec) })}
|
|
218
|
+
>
|
|
219
|
+
<Die />
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
178
223
|
</td>
|
|
179
224
|
<td class="g-doc" title={spec.description}>
|
|
180
225
|
<span class="chip">{hint(spec)}</span> {spec.description}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Component, type ComponentChildren } from 'preact'
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
children: ComponentChildren
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What is drawn in a panel comes from a run, and a run is whatever the API answered and whatever the project's code did with
|
|
9
|
+
* it. If drawing that fails anyway, the panel says so in words and offers the run again, instead of stopping where it was
|
|
10
|
+
* with nothing to click: a render that throws leaves the part of the page under it dead.
|
|
11
|
+
*/
|
|
12
|
+
export class ErrorBoundary extends Component<Props, { error?: Error }> {
|
|
13
|
+
state: { error?: Error } = {}
|
|
14
|
+
|
|
15
|
+
componentDidCatch(error: unknown) {
|
|
16
|
+
console.error('specbook: a panel could not be drawn', error)
|
|
17
|
+
this.setState({ error: error instanceof Error ? error : new Error(String(error)) })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
render() {
|
|
21
|
+
const { error } = this.state
|
|
22
|
+
if (!error) return this.props.children
|
|
23
|
+
return (
|
|
24
|
+
<div class="panel-error" role="alert">
|
|
25
|
+
<p>
|
|
26
|
+
<strong>This could not be shown.</strong> The run finished, but what it returned could not
|
|
27
|
+
be drawn.
|
|
28
|
+
</p>
|
|
29
|
+
<pre>{error.message}</pre>
|
|
30
|
+
<button type="button" class="btn" onClick={() => this.setState({ error: undefined })}>
|
|
31
|
+
Try drawing it again
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/panel/Host.tsx
CHANGED
|
@@ -11,6 +11,11 @@ type Props = {
|
|
|
11
11
|
/** dims the page and traps focus; the standalone site docks the panel beside its list instead */
|
|
12
12
|
modal?: boolean
|
|
13
13
|
tab?: PanelTab
|
|
14
|
+
/** `popover`: a centered dialog (a sheet from the bottom on a phone); `side`: the app's sheet from the right */
|
|
15
|
+
variant?: 'side' | 'popover'
|
|
16
|
+
/** an embed's preset flow inputs, and whether to run as soon as it is ready */
|
|
17
|
+
preset?: Record<string, unknown>
|
|
18
|
+
autoRun?: boolean
|
|
14
19
|
/** switch tab, optionally opening a named example */
|
|
15
20
|
onNavigate: (to: { tab: PanelTab; example?: string }) => void
|
|
16
21
|
onPrev?: () => void
|
|
@@ -51,6 +56,7 @@ export function Host(p: Props) {
|
|
|
51
56
|
subtitle="Sequence"
|
|
52
57
|
flush
|
|
53
58
|
modal={p.modal}
|
|
59
|
+
variant={p.variant}
|
|
54
60
|
onPrev={p.onPrev}
|
|
55
61
|
onNext={p.onNext}
|
|
56
62
|
onClose={p.onClose}
|
|
@@ -58,7 +64,11 @@ export function Host(p: Props) {
|
|
|
58
64
|
<div class="panel-split">
|
|
59
65
|
{tabs}
|
|
60
66
|
<div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
|
|
61
|
-
{tab === 'docs' ?
|
|
67
|
+
{tab === 'docs' ? (
|
|
68
|
+
<SequenceDocs info={p.info} />
|
|
69
|
+
) : (
|
|
70
|
+
<SequencePanel info={p.info} preset={p.preset} autoRun={p.autoRun} />
|
|
71
|
+
)}
|
|
62
72
|
</div>
|
|
63
73
|
</div>
|
|
64
74
|
</Panel>
|
|
@@ -69,6 +79,7 @@ export function Host(p: Props) {
|
|
|
69
79
|
subtitle={`${p.op.method} ${p.op.path}`}
|
|
70
80
|
flush
|
|
71
81
|
modal={p.modal}
|
|
82
|
+
variant={p.variant}
|
|
72
83
|
onPrev={p.onPrev}
|
|
73
84
|
onNext={p.onNext}
|
|
74
85
|
onClose={p.onClose}
|
|
@@ -86,11 +97,12 @@ export function Host(p: Props) {
|
|
|
86
97
|
/>
|
|
87
98
|
) : (
|
|
88
99
|
<RequestPanel
|
|
89
|
-
key={`${p.service}/${p.op.id}`}
|
|
100
|
+
key={`${p.service}/${p.op.id}${p.example.startsWith('saved:') ? `@${p.example}` : ''}`}
|
|
90
101
|
service={p.service}
|
|
91
102
|
op={p.op}
|
|
92
103
|
variants={p.variants}
|
|
93
104
|
initial={p.example}
|
|
105
|
+
autoRun={p.autoRun}
|
|
94
106
|
/>
|
|
95
107
|
)}
|
|
96
108
|
</div>
|
|
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'
|
|
|
3
3
|
import { noteExpiry, recallClientId, redirectUri, rememberClientId, signIn } from '../oauth'
|
|
4
4
|
import { engine } from '../runtime'
|
|
5
5
|
import { secretStore } from '../secrets'
|
|
6
|
-
import { env, version } from '../store'
|
|
6
|
+
import { env, envVarsVersion, version } from '../store'
|
|
7
7
|
|
|
8
8
|
type Status = { kind: 'idle' } | { kind: 'busy' } | { kind: 'error'; message: string }
|
|
9
9
|
|
|
@@ -39,6 +39,10 @@ export function OAuthSignIn({
|
|
|
39
39
|
const [clientId, setClientId] = useState(
|
|
40
40
|
() => oauth.clientId ?? recallClientId(providerKey) ?? '',
|
|
41
41
|
)
|
|
42
|
+
// another environment may sign in somewhere else, with its own client
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setClientId(oauth.clientId ?? recallClientId(providerKey) ?? '')
|
|
45
|
+
}, [providerKey, oauth.clientId])
|
|
42
46
|
const [endpoints, setEndpoints] = useState<Endpoints | undefined>(undefined)
|
|
43
47
|
const [picked, setPicked] = useState<string[]>([])
|
|
44
48
|
const [status, setStatus] = useState<Status>({ kind: 'idle' })
|
|
@@ -67,7 +71,7 @@ export function OAuthSignIn({
|
|
|
67
71
|
return () => {
|
|
68
72
|
on = false
|
|
69
73
|
}
|
|
70
|
-
}, [profile, env.value, version.value, attempt])
|
|
74
|
+
}, [profile, env.value, version.value, attempt, envVarsVersion.value])
|
|
71
75
|
|
|
72
76
|
const scopeNames = Object.keys(endpoints?.scopes ?? {})
|
|
73
77
|
|