@specbook/ui 0.2.0 → 0.4.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-DsO9W5X8.js +41 -0
- package/dist-viewer/assets/index-D59fkVA9.js +1 -0
- package/dist-viewer/assets/main-i06Egg5h.js +14 -0
- package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-CLHBtIzR.js} +2 -2
- package/dist-viewer/assets/panel-l1xvY4kI.css +1 -0
- package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-nV09I7Wr.js} +1 -1
- package/dist-viewer/assets/registry-b_jzfqy0.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 +67 -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/OperationDocs.tsx +1 -1
- package/src/panel/Panel.tsx +43 -10
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +102 -39
- 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 +82 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +235 -4
- package/src/viewer/embed.ts +9 -2
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/mount.ts +2 -0
- package/src/viewer/protocol.ts +6 -0
- package/src/viewer/registry.ts +48 -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/panel/Panel.tsx
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ComponentChildren } from 'preact'
|
|
2
|
-
import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'
|
|
2
|
+
import { useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks'
|
|
3
3
|
import { env, environmentNote, environments } from '../store'
|
|
4
|
+
import { announcement } from './announce'
|
|
5
|
+
import { ErrorBoundary } from './ErrorBoundary'
|
|
6
|
+
|
|
7
|
+
let panelIds = 0
|
|
4
8
|
|
|
5
9
|
interface Props {
|
|
6
10
|
title: string
|
|
@@ -9,6 +13,8 @@ interface Props {
|
|
|
9
13
|
flush?: boolean
|
|
10
14
|
/** dims the page and traps focus (embeds); the standalone site leaves its list usable beside the panel */
|
|
11
15
|
modal?: boolean
|
|
16
|
+
/** `popover`: a centered dialog on a desktop and a bottom sheet on a phone; `side` is the app's sheet from the right */
|
|
17
|
+
variant?: 'side' | 'popover'
|
|
12
18
|
onPrev?: () => void
|
|
13
19
|
onNext?: () => void
|
|
14
20
|
onClose: () => void
|
|
@@ -24,12 +30,22 @@ export function Panel({
|
|
|
24
30
|
subtitle,
|
|
25
31
|
flush,
|
|
26
32
|
modal = true,
|
|
33
|
+
variant = 'side',
|
|
27
34
|
onPrev,
|
|
28
35
|
onNext,
|
|
29
36
|
onClose,
|
|
30
37
|
children,
|
|
31
38
|
}: Props) {
|
|
32
39
|
const aside = useRef<HTMLElement>(null)
|
|
40
|
+
const titleId = useMemo(() => `sb-panel-title-${++panelIds}`, [])
|
|
41
|
+
|
|
42
|
+
// what was announced for one run is not read out again when the next panel opens
|
|
43
|
+
useEffect(
|
|
44
|
+
() => () => {
|
|
45
|
+
announcement.value = ''
|
|
46
|
+
},
|
|
47
|
+
[],
|
|
48
|
+
)
|
|
33
49
|
|
|
34
50
|
useEffect(() => {
|
|
35
51
|
const root = aside.current?.getRootNode() as Document | ShadowRoot
|
|
@@ -63,14 +79,23 @@ export function Panel({
|
|
|
63
79
|
const top = vv?.offsetTop ?? 0
|
|
64
80
|
const height = vv?.height ?? innerHeight
|
|
65
81
|
const at = field.getBoundingClientRect().top
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
// a dropdown field needs its list visible too, and picking from it needs no keyboard: it is moved
|
|
83
|
+
// only when it is down where the keyboard would be, and not as far
|
|
84
|
+
const dropdown = field.getAttribute('role') === 'combobox'
|
|
85
|
+
const want = top + height * (dropdown ? 0.3 : 0.2)
|
|
86
|
+
if (at > top + height * (dropdown ? 0.55 : 0.4) || at < top) box.scrollTop += at - want
|
|
68
87
|
}
|
|
69
88
|
const focusin = (e: Event) => {
|
|
70
89
|
const field = e.target as HTMLElement
|
|
71
90
|
if (!phone.matches || !field.matches?.(FIELD)) return
|
|
72
91
|
const box = scroller(field)
|
|
73
|
-
|
|
92
|
+
// a dropdown field high enough on the screen is not moved, so it needs no room below (which would
|
|
93
|
+
// only show a scrollbar)
|
|
94
|
+
const vv = typeof visualViewport === 'undefined' ? undefined : visualViewport
|
|
95
|
+
const low =
|
|
96
|
+
field.getBoundingClientRect().top >
|
|
97
|
+
(vv?.offsetTop ?? 0) + (vv?.height ?? innerHeight) * 0.55
|
|
98
|
+
if (box && padded?.box !== box && (field.getAttribute('role') !== 'combobox' || low)) {
|
|
74
99
|
if (padded) padded.box.style.paddingBottom = padded.was
|
|
75
100
|
padded = { box, was: box.style.paddingBottom }
|
|
76
101
|
box.style.paddingBottom = '70vh'
|
|
@@ -121,15 +146,18 @@ export function Panel({
|
|
|
121
146
|
first.focus()
|
|
122
147
|
}
|
|
123
148
|
}
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
// a dialog over someone else's page listens on itself (focus is trapped in it), not on the window; the app's
|
|
150
|
+
// docked panel keeps its window-wide Escape
|
|
151
|
+
const target: EventTarget = modal && aside.current ? aside.current : window
|
|
152
|
+
target.addEventListener('keydown', key as EventListener)
|
|
153
|
+
return () => target.removeEventListener('keydown', key as EventListener)
|
|
126
154
|
}, [onClose, modal])
|
|
127
155
|
|
|
128
156
|
// a dialog when it dims the page (embeds), a complementary region when docked beside the list
|
|
129
157
|
const attrs = {
|
|
130
|
-
class: `panel ${modal ? '' : 'docked'}`,
|
|
158
|
+
class: `panel ${modal ? '' : 'docked'} ${variant === 'popover' ? 'popover' : ''}`,
|
|
131
159
|
part: 'panel',
|
|
132
|
-
'aria-
|
|
160
|
+
'aria-labelledby': titleId,
|
|
133
161
|
'aria-modal': modal ? true : undefined,
|
|
134
162
|
role: (modal ? 'dialog' : 'complementary') as 'dialog',
|
|
135
163
|
tabIndex: -1,
|
|
@@ -143,7 +171,7 @@ export function Panel({
|
|
|
143
171
|
<section {...attrs} ref={aside}>
|
|
144
172
|
<header class="panel-head" part="header">
|
|
145
173
|
<div class="panel-title">
|
|
146
|
-
<h2>{title}</h2>
|
|
174
|
+
<h2 id={titleId}>{title}</h2>
|
|
147
175
|
{subtitle && <div class="muted small">{subtitle}</div>}
|
|
148
176
|
</div>
|
|
149
177
|
<span class="spacer" />
|
|
@@ -190,7 +218,12 @@ export function Panel({
|
|
|
190
218
|
×
|
|
191
219
|
</button>
|
|
192
220
|
</header>
|
|
193
|
-
<div class={`panel-body ${flush ? 'flush' : ''}`}>
|
|
221
|
+
<div class={`panel-body ${flush ? 'flush' : ''}`}>
|
|
222
|
+
<ErrorBoundary>{children}</ErrorBoundary>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="visually-hidden" role="status" aria-live="polite" aria-atomic="true">
|
|
225
|
+
{announcement.value}
|
|
226
|
+
</div>
|
|
194
227
|
</section>
|
|
195
228
|
</>
|
|
196
229
|
)
|
package/src/panel/ParamGrid.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { signal } from '@preact/signals'
|
|
|
2
2
|
import { useEffect, useRef } from 'preact/hooks'
|
|
3
3
|
import type { FieldDoc } from '../fielddoc'
|
|
4
4
|
import { inlineSummary, plainText } from '../markdown'
|
|
5
|
+
import { kv } from '../storage'
|
|
5
6
|
import { Combo } from './Combo'
|
|
6
7
|
import type { FieldRow } from './draft'
|
|
7
8
|
|
|
@@ -28,6 +29,8 @@ interface Props {
|
|
|
28
29
|
placeholder?: string
|
|
29
30
|
/** the grid sits under a tab that already names it */
|
|
30
31
|
bare?: boolean
|
|
32
|
+
/** values are shown, not edited (a saved request carries its own) */
|
|
33
|
+
readOnly?: boolean
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
type Shown = FieldRow & { virtual?: true }
|
|
@@ -41,7 +44,7 @@ const cols = signal(loadCols())
|
|
|
41
44
|
|
|
42
45
|
function loadCols(): { key: number; val: number } {
|
|
43
46
|
try {
|
|
44
|
-
const c = JSON.parse(
|
|
47
|
+
const c = JSON.parse(kv.local.getItem(COLS_KEY) ?? 'null')
|
|
45
48
|
if (c && c.key >= MIN_COL && c.val >= MIN_COL && c.key + c.val <= 100 - MIN_COL) return c
|
|
46
49
|
} catch {
|
|
47
50
|
/* fall through */
|
|
@@ -55,8 +58,8 @@ function setCols(next: { key: number; val: number } | undefined) {
|
|
|
55
58
|
const val = Math.min(100 - key - MIN_COL, Math.max(MIN_COL, c.val))
|
|
56
59
|
cols.value = { key: Math.round(key * 10) / 10, val: Math.round(val * 10) / 10 }
|
|
57
60
|
try {
|
|
58
|
-
if (next)
|
|
59
|
-
else
|
|
61
|
+
if (next) kv.local.setItem(COLS_KEY, JSON.stringify(cols.value))
|
|
62
|
+
else kv.local.removeItem(COLS_KEY)
|
|
60
63
|
} catch {
|
|
61
64
|
/* optional */
|
|
62
65
|
}
|
|
@@ -167,6 +170,7 @@ export function ParamGrid({
|
|
|
167
170
|
bad,
|
|
168
171
|
placeholder,
|
|
169
172
|
bare,
|
|
173
|
+
readOnly,
|
|
170
174
|
}: Props) {
|
|
171
175
|
const root = useRef<HTMLElement>(null)
|
|
172
176
|
const table = useRef<HTMLTableElement>(null)
|
|
@@ -252,7 +256,7 @@ export function ParamGrid({
|
|
|
252
256
|
{all.map((r, i) => {
|
|
253
257
|
const d = docOf(r.key)
|
|
254
258
|
const isFile = info(r.key)?.file
|
|
255
|
-
const locked = fixed || r.virtual || info(r.key)
|
|
259
|
+
const locked = fixed || readOnly || r.virtual || info(r.key)
|
|
256
260
|
return (
|
|
257
261
|
<tr class={`${r.enabled ? '' : 'off'} ${bad === r.key ? 'bad' : ''}`} key={i}>
|
|
258
262
|
<td class="g-check">
|
|
@@ -260,7 +264,7 @@ export function ParamGrid({
|
|
|
260
264
|
type="checkbox"
|
|
261
265
|
aria-label={`Send ${r.key}`}
|
|
262
266
|
checked={r.enabled}
|
|
263
|
-
disabled={fixed}
|
|
267
|
+
disabled={fixed || readOnly}
|
|
264
268
|
onChange={(e) => patch(i, { enabled: (e.target as HTMLInputElement).checked })}
|
|
265
269
|
/>
|
|
266
270
|
</td>
|
|
@@ -292,6 +296,7 @@ export function ParamGrid({
|
|
|
292
296
|
<label class="file-cell">
|
|
293
297
|
<input
|
|
294
298
|
type="file"
|
|
299
|
+
disabled={readOnly}
|
|
295
300
|
aria-label={`File for ${r.key}`}
|
|
296
301
|
onChange={(e) => {
|
|
297
302
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
@@ -306,6 +311,7 @@ export function ParamGrid({
|
|
|
306
311
|
options={d.enum.map(String)}
|
|
307
312
|
label={r.key}
|
|
308
313
|
cell={`${i}:${VALUE}`}
|
|
314
|
+
readOnly={readOnly}
|
|
309
315
|
placeholder="value"
|
|
310
316
|
onKeyDown={keys(i, VALUE)}
|
|
311
317
|
onChange={(value) => patch(i, { value, enabled: true })}
|
|
@@ -315,6 +321,7 @@ export function ParamGrid({
|
|
|
315
321
|
class="cell"
|
|
316
322
|
data-cell={`${i}:${VALUE}`}
|
|
317
323
|
value={r.value}
|
|
324
|
+
readOnly={readOnly}
|
|
318
325
|
placeholder={placeholder ?? 'value'}
|
|
319
326
|
aria-label={`Value of ${r.key}`}
|
|
320
327
|
onInput={(e) =>
|
|
@@ -336,7 +343,7 @@ export function ParamGrid({
|
|
|
336
343
|
)}
|
|
337
344
|
</td>
|
|
338
345
|
<td class="g-act">
|
|
339
|
-
{!fixed && r.key && (
|
|
346
|
+
{!fixed && !readOnly && r.key && (
|
|
340
347
|
<button
|
|
341
348
|
type="button"
|
|
342
349
|
class="icon"
|
|
@@ -347,7 +354,7 @@ export function ParamGrid({
|
|
|
347
354
|
<Icon d="M5.5 5.5h7v7h-7zM3.5 10.5v-7h7" />
|
|
348
355
|
</button>
|
|
349
356
|
)}
|
|
350
|
-
{!fixed && !r.virtual && (
|
|
357
|
+
{!fixed && !readOnly && !r.virtual && (
|
|
351
358
|
<button
|
|
352
359
|
type="button"
|
|
353
360
|
class="icon"
|
|
@@ -362,7 +369,7 @@ export function ParamGrid({
|
|
|
362
369
|
</tr>
|
|
363
370
|
)
|
|
364
371
|
})}
|
|
365
|
-
{!fixed && (
|
|
372
|
+
{!fixed && !readOnly && (
|
|
366
373
|
<tr class="ghost" key={ghost}>
|
|
367
374
|
<td class="g-check" />
|
|
368
375
|
<td class="g-key">
|
|
@@ -1,17 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
collectPlaceholders,
|
|
3
|
+
type Encoding,
|
|
4
|
+
type Example,
|
|
5
|
+
type RunResult,
|
|
6
|
+
rootName,
|
|
7
|
+
} from '@specbook/core'
|
|
8
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
9
|
import { EnvBadge } from '../components/EnvBadge'
|
|
4
10
|
import type { Variant } from '../engine'
|
|
5
11
|
import { fieldDoc, withNote } from '../fielddoc'
|
|
6
12
|
import { engine, isStatic, registry, saveFile } from '../runtime'
|
|
7
13
|
import { type Defs, type JsonSchema, propsOf, unwrap } from '../schema'
|
|
8
14
|
import { statusClass, statusName } from '../status'
|
|
9
|
-
import {
|
|
15
|
+
import { kv } from '../storage'
|
|
16
|
+
import { env, envSettingsOpen, envVarsVersion, features, locale, seed, version } from '../store'
|
|
17
|
+
import { announcement, describeRun } from './announce'
|
|
10
18
|
import { Body } from './Body'
|
|
11
19
|
import { DraftCodeSample } from './CodeSample'
|
|
12
20
|
import { type Draft, draftFrom, forStorage, previewUrl, requestParts } from './draft'
|
|
13
21
|
import { useOperationNotes } from './fieldnotes'
|
|
14
|
-
import { PaperPlane } from './icons'
|
|
22
|
+
import { PaperPlane, StopSquare } from './icons'
|
|
15
23
|
import { type Documented, ParamGrid } from './ParamGrid'
|
|
16
24
|
import { RequestView } from './RequestView'
|
|
17
25
|
import { ResponseView } from './ResponseView'
|
|
@@ -33,6 +41,8 @@ interface Props {
|
|
|
33
41
|
variants: Variant[]
|
|
34
42
|
/** example name to open, '' for the first */
|
|
35
43
|
initial: string
|
|
44
|
+
/** run once as soon as the request is ready (an embed that asks for it, never against production) */
|
|
45
|
+
autoRun?: boolean
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
const storeKey = (key: string, sel: string) => `specbook:draft:${key}:${sel}`
|
|
@@ -44,7 +54,7 @@ const slug = (s: string) =>
|
|
|
44
54
|
|
|
45
55
|
function readDraft(key: string): Draft | undefined {
|
|
46
56
|
try {
|
|
47
|
-
const raw =
|
|
57
|
+
const raw = kv.local.getItem(key)
|
|
48
58
|
return raw ? (JSON.parse(raw) as Draft) : undefined
|
|
49
59
|
} catch {
|
|
50
60
|
return undefined
|
|
@@ -64,21 +74,28 @@ interface SavedExample {
|
|
|
64
74
|
|
|
65
75
|
function takeSaved(key: string): SavedExample | undefined {
|
|
66
76
|
try {
|
|
67
|
-
const raw =
|
|
77
|
+
const raw = kv.session.getItem(SAVED_KEY)
|
|
68
78
|
const saved = raw ? (JSON.parse(raw) as SavedExample) : undefined
|
|
69
79
|
if (saved?.key !== key) return undefined
|
|
70
|
-
|
|
80
|
+
kv.session.removeItem(SAVED_KEY)
|
|
71
81
|
return saved
|
|
72
82
|
} catch {
|
|
73
83
|
return undefined
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
87
|
+
export function RequestPanel({ service, op, variants, initial, autoRun }: Props) {
|
|
78
88
|
const key = `${service}/${op.id}`
|
|
89
|
+
const panelRoot = useRef<HTMLDivElement>(null)
|
|
90
|
+
const autoRan = useRef(false)
|
|
91
|
+
/** ends the request that is out: a person pressing Stop, so a request that never answers is not the end of the panel */
|
|
92
|
+
const stopper = useRef<AbortController | undefined>(undefined)
|
|
79
93
|
const examples = engine.examplesOf(key)
|
|
80
94
|
const [notice, setNotice] = useState<SavedExample | undefined>(() => takeSaved(key))
|
|
81
95
|
const [sel, setSel] = useState(() => {
|
|
96
|
+
// a link to a saved request ("saved:<file>") opens on it
|
|
97
|
+
if (initial?.startsWith('saved:') && variants.some((v) => `saved:${v.file}` === initial))
|
|
98
|
+
return initial
|
|
82
99
|
const named = examples.find((e) => e.name === (notice?.name ?? initial))
|
|
83
100
|
return named
|
|
84
101
|
? `ex:${named.name}`
|
|
@@ -110,11 +127,27 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
110
127
|
const [baseUrl, setBaseUrl] = useState('')
|
|
111
128
|
const [known, setKnown] = useState<string[]>()
|
|
112
129
|
|
|
130
|
+
const [addressError, setAddressError] = useState<string>()
|
|
113
131
|
useEffect(() => {
|
|
132
|
+
let on = true
|
|
133
|
+
setAddressError(undefined)
|
|
134
|
+
// mock has no address; elsewhere it is whatever this environment's variables make it
|
|
135
|
+
if (env.value === 'mock') {
|
|
136
|
+
setBaseUrl('')
|
|
137
|
+
return
|
|
138
|
+
}
|
|
114
139
|
engine
|
|
115
|
-
.
|
|
116
|
-
.then((
|
|
117
|
-
|
|
140
|
+
.baseUrlOf(service, env.value, version.value)
|
|
141
|
+
.then((url) => on && setBaseUrl(url ?? ''))
|
|
142
|
+
.catch((e: unknown) => {
|
|
143
|
+
if (!on) return
|
|
144
|
+
setBaseUrl('')
|
|
145
|
+
setAddressError(e instanceof Error ? e.message : String(e))
|
|
146
|
+
})
|
|
147
|
+
return () => {
|
|
148
|
+
on = false
|
|
149
|
+
}
|
|
150
|
+
}, [service, env.value, version.value, envVarsVersion.value])
|
|
118
151
|
|
|
119
152
|
useEffect(() => {
|
|
120
153
|
let live = true
|
|
@@ -174,8 +207,8 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
174
207
|
try {
|
|
175
208
|
const k = storeKey(key, sel)
|
|
176
209
|
if (baseDraft && JSON.stringify(forStorage(d)) === JSON.stringify(forStorage(baseDraft)))
|
|
177
|
-
|
|
178
|
-
else
|
|
210
|
+
kv.local.removeItem(k)
|
|
211
|
+
else kv.local.setItem(k, JSON.stringify(forStorage(d)))
|
|
179
212
|
} catch {
|
|
180
213
|
/* optional */
|
|
181
214
|
}
|
|
@@ -208,7 +241,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
208
241
|
.filter(
|
|
209
242
|
(n) => !n.startsWith('faker.') && !n.startsWith('secrets.') && !n.startsWith('session.'),
|
|
210
243
|
)
|
|
211
|
-
.map(
|
|
244
|
+
.map(rootName)
|
|
212
245
|
.filter((n, i, a) => a.indexOf(n) === i)
|
|
213
246
|
} catch {
|
|
214
247
|
return []
|
|
@@ -217,37 +250,49 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
217
250
|
|
|
218
251
|
// placeholders nothing supplies: not in config or the environment's files. They stay listed while
|
|
219
252
|
// you type, so the field you are filling does not disappear under you.
|
|
220
|
-
|
|
253
|
+
// a saved request brings the values of its own `init`: they are what a run uses, so nothing is missing
|
|
254
|
+
const own = Object.keys(saved?.def.input.init ?? {})
|
|
255
|
+
const needed = known ? placeholders.filter((n) => !known.includes(n) && !own.includes(n)) : []
|
|
221
256
|
const unfilled = needed.filter((n) => (vars[n] ?? '') === '')
|
|
222
257
|
|
|
223
258
|
const play = async (forceStatus?: number) => {
|
|
224
|
-
if (!draft) return
|
|
259
|
+
if (!draft || stopper.current) return
|
|
225
260
|
setError(undefined)
|
|
226
261
|
setRunning(true)
|
|
262
|
+
const stop = new AbortController()
|
|
263
|
+
stopper.current = stop
|
|
227
264
|
try {
|
|
228
265
|
const def = saved
|
|
229
266
|
? saved.def
|
|
230
267
|
: await engine.operationRequest(key, version.value, requestParts(draft, encoding))
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
)
|
|
268
|
+
const ran = await engine.run(def, {
|
|
269
|
+
env: env.value,
|
|
270
|
+
version: version.value,
|
|
271
|
+
mockStatus: env.value === 'mock' ? (forceStatus ?? forced) : undefined,
|
|
272
|
+
locale: locale.value,
|
|
273
|
+
seed: seed.value,
|
|
274
|
+
vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => v !== '')),
|
|
275
|
+
fileDir: base?.dir,
|
|
276
|
+
signal: stop.signal,
|
|
277
|
+
})
|
|
278
|
+
setResult(ran)
|
|
279
|
+
announcement.value = describeRun(ran)
|
|
242
280
|
} catch (e) {
|
|
243
281
|
setError(e instanceof Error ? e.message : String(e))
|
|
244
282
|
} finally {
|
|
283
|
+
stopper.current = undefined
|
|
245
284
|
setRunning(false)
|
|
246
285
|
// what you sent a request to see is the response, so show it (the tab stays one click away)
|
|
247
286
|
setTab('response')
|
|
248
287
|
}
|
|
249
288
|
}
|
|
250
289
|
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (!autoRun || !draft || autoRan.current) return
|
|
292
|
+
autoRan.current = true
|
|
293
|
+
void play()
|
|
294
|
+
}, [draft])
|
|
295
|
+
|
|
251
296
|
const saveExample = async (name: string) => {
|
|
252
297
|
if (!draft) return
|
|
253
298
|
const parts = requestParts(draft, encoding)
|
|
@@ -265,7 +310,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
265
310
|
// set before writing: the watcher reloads the page as soon as the file lands
|
|
266
311
|
const saved: SavedExample = { key, path, name: file }
|
|
267
312
|
try {
|
|
268
|
-
|
|
313
|
+
kv.session.setItem(SAVED_KEY, JSON.stringify(saved))
|
|
269
314
|
} catch {
|
|
270
315
|
/* the confirmation below still shows when there is no reload */
|
|
271
316
|
}
|
|
@@ -301,12 +346,14 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
301
346
|
play()
|
|
302
347
|
}
|
|
303
348
|
}
|
|
304
|
-
|
|
305
|
-
|
|
349
|
+
// on the page's own root (a shadow root in an embed), not the window, so a host page's keys are left alone
|
|
350
|
+
const root: EventTarget = panelRoot.current?.getRootNode() ?? window
|
|
351
|
+
root.addEventListener('keydown', send as EventListener)
|
|
352
|
+
return () => root.removeEventListener('keydown', send as EventListener)
|
|
306
353
|
})
|
|
307
354
|
|
|
308
355
|
return (
|
|
309
|
-
<div class="request-panel" data-op={key}>
|
|
356
|
+
<div class="request-panel" data-op={key} ref={panelRoot}>
|
|
310
357
|
<div class="rq">
|
|
311
358
|
<div class="rq-bar">
|
|
312
359
|
<span class={`method ${op.method}`}>{op.method}</span>
|
|
@@ -332,15 +379,26 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
332
379
|
<button
|
|
333
380
|
type="button"
|
|
334
381
|
class="btn primary send"
|
|
335
|
-
title=
|
|
336
|
-
aria-label=
|
|
337
|
-
disabled={running
|
|
338
|
-
onClick={() => play()}
|
|
382
|
+
title={running ? 'Stop' : 'Send (⌘/Ctrl + Enter)'}
|
|
383
|
+
aria-label={running ? 'Stop' : 'Send'}
|
|
384
|
+
disabled={!running && !draft}
|
|
385
|
+
onClick={() => (running ? stopper.current?.abort() : play())}
|
|
339
386
|
>
|
|
340
|
-
{running ?
|
|
387
|
+
{running ? <StopSquare /> : <PaperPlane />}
|
|
341
388
|
</button>
|
|
342
389
|
</div>
|
|
343
390
|
|
|
391
|
+
{addressError && (
|
|
392
|
+
<p class="envmenu-warn" role="alert">
|
|
393
|
+
{addressError}.{' '}
|
|
394
|
+
{features.value.environmentSettings && (
|
|
395
|
+
<button type="button" class="link" onClick={() => (envSettingsOpen.value = true)}>
|
|
396
|
+
Open Environment settings
|
|
397
|
+
</button>
|
|
398
|
+
)}
|
|
399
|
+
</p>
|
|
400
|
+
)}
|
|
401
|
+
|
|
344
402
|
{requestNote && (
|
|
345
403
|
<details class="step-about" key={saved?.file}>
|
|
346
404
|
<summary>About this request</summary>
|
|
@@ -383,7 +441,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
383
441
|
</option>
|
|
384
442
|
))}
|
|
385
443
|
{variants.map((v) => (
|
|
386
|
-
<option value={`saved:${v.file}`}>{v.name} ·
|
|
444
|
+
<option value={`saved:${v.file}`}>{v.name} · request</option>
|
|
387
445
|
))}
|
|
388
446
|
</select>
|
|
389
447
|
</label>
|
|
@@ -468,8 +526,9 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
468
526
|
|
|
469
527
|
{saved && (
|
|
470
528
|
<p class="muted lock">
|
|
471
|
-
A
|
|
472
|
-
|
|
529
|
+
A request from your project: it runs with its own values and checks. Edit its file to
|
|
530
|
+
change it.
|
|
531
|
+
{env.value === 'mock' && saved.def.mockHook ? ' Its mock hook shapes the answer.' : ''}
|
|
473
532
|
</p>
|
|
474
533
|
)}
|
|
475
534
|
|
|
@@ -540,6 +599,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
540
599
|
title="Query"
|
|
541
600
|
rows={draft.query}
|
|
542
601
|
documented={sections.query}
|
|
602
|
+
readOnly={!!saved}
|
|
543
603
|
onChange={(query) => edit({ ...draft, query })}
|
|
544
604
|
/>
|
|
545
605
|
)}
|
|
@@ -549,6 +609,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
549
609
|
rows={draft.path}
|
|
550
610
|
documented={sections.path}
|
|
551
611
|
fixed
|
|
612
|
+
readOnly={!!saved}
|
|
552
613
|
onChange={(path) => edit({ ...draft, path })}
|
|
553
614
|
/>
|
|
554
615
|
)}
|
|
@@ -559,6 +620,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
559
620
|
title="Headers"
|
|
560
621
|
rows={draft.headers}
|
|
561
622
|
documented={sections.headers}
|
|
623
|
+
readOnly={!!saved}
|
|
562
624
|
onChange={(headers) => edit({ ...draft, headers })}
|
|
563
625
|
/>
|
|
564
626
|
)}
|
|
@@ -573,6 +635,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
573
635
|
defs={defs}
|
|
574
636
|
files={meta?.files}
|
|
575
637
|
notes={opNotes.notes}
|
|
638
|
+
readOnly={!!saved}
|
|
576
639
|
/>
|
|
577
640
|
)}
|
|
578
641
|
</div>
|
|
@@ -4,8 +4,9 @@ import { fieldDoc, withNote } from '../fielddoc'
|
|
|
4
4
|
import { type Defs, type JsonSchema, linePaths, pathString } from '../schema'
|
|
5
5
|
import { BodyEditor } from './BodyEditor'
|
|
6
6
|
import { FieldDoc } from './FieldDoc'
|
|
7
|
+
import { show } from './short'
|
|
7
8
|
|
|
8
|
-
const pretty = (v: unknown) => (typeof v === 'string' ? v :
|
|
9
|
+
const pretty = (v: unknown) => (typeof v === 'string' ? v : show(v, 2))
|
|
9
10
|
|
|
10
11
|
/** Repeated keys for arrays, as the transport sends them. */
|
|
11
12
|
export function queryString(query: Record<string, string | string[]>) {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { StepResult } from '@specbook/core'
|
|
2
|
-
import { useMemo, useState } from 'preact/hooks'
|
|
2
|
+
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
3
3
|
import { fieldDoc } from '../fielddoc'
|
|
4
4
|
import { type Defs, type JsonSchema, linePaths, validate } from '../schema'
|
|
5
5
|
import { statusClass, statusName } from '../status'
|
|
6
6
|
import { BodyEditor } from './BodyEditor'
|
|
7
|
+
import { Checks } from './Checks'
|
|
7
8
|
import { FieldDoc } from './FieldDoc'
|
|
8
9
|
import { PaperPlane } from './icons'
|
|
9
10
|
import { queryString } from './RequestView'
|
|
11
|
+
import { show } from './short'
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
step?: StepResult
|
|
@@ -17,7 +19,7 @@ interface Props {
|
|
|
17
19
|
hideRequest?: boolean
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const pretty = (v: unknown) =>
|
|
22
|
+
const pretty = (v: unknown) => show(v, 2)
|
|
21
23
|
const size = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`)
|
|
22
24
|
|
|
23
25
|
export function ResponseView({ step, error, schemas, defs, hideRequest }: Props) {
|
|
@@ -25,10 +27,14 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
|
|
|
25
27
|
const [line, setLine] = useState(0)
|
|
26
28
|
const response = step?.response
|
|
27
29
|
const schema = response ? (schemas?.[String(response.status)] ?? schemas?.default) : undefined
|
|
28
|
-
const issues = useMemo(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const issues = useMemo(() => {
|
|
31
|
+
try {
|
|
32
|
+
return response && schema ? validate(response.body, schema, defs, { reportExtra: true }) : []
|
|
33
|
+
} catch {
|
|
34
|
+
// an answer the schema check cannot make sense of is shown as it came, without the check
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}, [response, schema, defs])
|
|
32
38
|
const text =
|
|
33
39
|
response === undefined || response.body === undefined || response.body === ''
|
|
34
40
|
? ''
|
|
@@ -42,6 +48,10 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
|
|
|
42
48
|
}, [paths, line, schema, defs])
|
|
43
49
|
const checks = step?.assertions ?? []
|
|
44
50
|
const failed = checks.filter((a) => !a.pass).length
|
|
51
|
+
// a run that failed a check opens on it: that is what the person came to see
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (step && failed > 0) setTab('checks')
|
|
54
|
+
}, [step])
|
|
45
55
|
const headers = Object.entries(response?.headers ?? {})
|
|
46
56
|
const empty = !response && !error && !step?.error
|
|
47
57
|
|
|
@@ -112,7 +122,14 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
|
|
|
112
122
|
>
|
|
113
123
|
{label}
|
|
114
124
|
{n > 0 && (
|
|
115
|
-
<span
|
|
125
|
+
<span
|
|
126
|
+
class={`count ${id === 'checks' && failed ? 'bad' : ''}`}
|
|
127
|
+
title={
|
|
128
|
+
id === 'checks' && failed ? `${failed} of ${n} checks failed` : undefined
|
|
129
|
+
}
|
|
130
|
+
>
|
|
131
|
+
{id === 'checks' && failed ? failed : n}
|
|
132
|
+
</span>
|
|
116
133
|
)}
|
|
117
134
|
</button>
|
|
118
135
|
))}
|
|
@@ -165,13 +182,7 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
|
|
|
165
182
|
{checks.length === 0 ? (
|
|
166
183
|
<p class="muted">This request has no checks.</p>
|
|
167
184
|
) : (
|
|
168
|
-
<
|
|
169
|
-
{checks.map((a) => (
|
|
170
|
-
<li class={a.pass ? 'ok' : 'bad'}>
|
|
171
|
-
{a.pass ? '✓' : '✗'} {a.label}
|
|
172
|
-
</li>
|
|
173
|
-
))}
|
|
174
|
-
</ul>
|
|
185
|
+
<Checks assertions={checks} />
|
|
175
186
|
)}
|
|
176
187
|
</div>
|
|
177
188
|
</div>
|