@specbook/ui 0.1.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/THIRD-PARTY-NOTICES.txt +581 -0
- 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-DfwluJy4.js +2 -0
- package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
- 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 +7 -5
- package/src/app.tsx +71 -13
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +24 -4
- 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 +287 -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 +416 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +14 -1
- package/src/nav.ts +12 -1
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +195 -0
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -8
- 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 +167 -0
- package/src/panel/Panel.tsx +50 -13
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +93 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +43 -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 +10 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +277 -2
- package/src/viewer/embed.ts +5 -0
- package/src/viewer/main.tsx +5 -1
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +9 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +48 -3
- package/src/viewer/worker.ts +20 -7
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
- package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
- package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
- package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
- package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
- package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
- package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
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,19 @@
|
|
|
1
1
|
import { collectPlaceholders, type Encoding, type Example, type RunResult } from '@specbook/core'
|
|
2
|
-
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
3
|
import { EnvBadge } from '../components/EnvBadge'
|
|
4
4
|
import type { Variant } from '../engine'
|
|
5
5
|
import { fieldDoc, withNote } from '../fielddoc'
|
|
6
6
|
import { engine, isStatic, registry, saveFile } from '../runtime'
|
|
7
7
|
import { type Defs, type JsonSchema, propsOf, unwrap } from '../schema'
|
|
8
8
|
import { statusClass, statusName } from '../status'
|
|
9
|
-
import {
|
|
9
|
+
import { kv } from '../storage'
|
|
10
|
+
import { env, envSettingsOpen, envVarsVersion, features, locale, seed, version } from '../store'
|
|
11
|
+
import { announcement, describeRun } from './announce'
|
|
10
12
|
import { Body } from './Body'
|
|
11
13
|
import { DraftCodeSample } from './CodeSample'
|
|
12
14
|
import { type Draft, draftFrom, forStorage, previewUrl, requestParts } from './draft'
|
|
13
15
|
import { useOperationNotes } from './fieldnotes'
|
|
14
|
-
import { PaperPlane } from './icons'
|
|
16
|
+
import { PaperPlane, StopSquare } from './icons'
|
|
15
17
|
import { type Documented, ParamGrid } from './ParamGrid'
|
|
16
18
|
import { RequestView } from './RequestView'
|
|
17
19
|
import { ResponseView } from './ResponseView'
|
|
@@ -33,6 +35,8 @@ interface Props {
|
|
|
33
35
|
variants: Variant[]
|
|
34
36
|
/** example name to open, '' for the first */
|
|
35
37
|
initial: string
|
|
38
|
+
/** run once as soon as the request is ready (an embed that asks for it, never against production) */
|
|
39
|
+
autoRun?: boolean
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
const storeKey = (key: string, sel: string) => `specbook:draft:${key}:${sel}`
|
|
@@ -44,7 +48,7 @@ const slug = (s: string) =>
|
|
|
44
48
|
|
|
45
49
|
function readDraft(key: string): Draft | undefined {
|
|
46
50
|
try {
|
|
47
|
-
const raw =
|
|
51
|
+
const raw = kv.local.getItem(key)
|
|
48
52
|
return raw ? (JSON.parse(raw) as Draft) : undefined
|
|
49
53
|
} catch {
|
|
50
54
|
return undefined
|
|
@@ -64,21 +68,28 @@ interface SavedExample {
|
|
|
64
68
|
|
|
65
69
|
function takeSaved(key: string): SavedExample | undefined {
|
|
66
70
|
try {
|
|
67
|
-
const raw =
|
|
71
|
+
const raw = kv.session.getItem(SAVED_KEY)
|
|
68
72
|
const saved = raw ? (JSON.parse(raw) as SavedExample) : undefined
|
|
69
73
|
if (saved?.key !== key) return undefined
|
|
70
|
-
|
|
74
|
+
kv.session.removeItem(SAVED_KEY)
|
|
71
75
|
return saved
|
|
72
76
|
} catch {
|
|
73
77
|
return undefined
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
81
|
+
export function RequestPanel({ service, op, variants, initial, autoRun }: Props) {
|
|
78
82
|
const key = `${service}/${op.id}`
|
|
83
|
+
const panelRoot = useRef<HTMLDivElement>(null)
|
|
84
|
+
const autoRan = useRef(false)
|
|
85
|
+
/** ends the request that is out: a person pressing Stop, so a request that never answers is not the end of the panel */
|
|
86
|
+
const stopper = useRef<AbortController | undefined>(undefined)
|
|
79
87
|
const examples = engine.examplesOf(key)
|
|
80
88
|
const [notice, setNotice] = useState<SavedExample | undefined>(() => takeSaved(key))
|
|
81
89
|
const [sel, setSel] = useState(() => {
|
|
90
|
+
// a link to a saved request ("saved:<file>") opens on it
|
|
91
|
+
if (initial?.startsWith('saved:') && variants.some((v) => `saved:${v.file}` === initial))
|
|
92
|
+
return initial
|
|
82
93
|
const named = examples.find((e) => e.name === (notice?.name ?? initial))
|
|
83
94
|
return named
|
|
84
95
|
? `ex:${named.name}`
|
|
@@ -110,11 +121,27 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
110
121
|
const [baseUrl, setBaseUrl] = useState('')
|
|
111
122
|
const [known, setKnown] = useState<string[]>()
|
|
112
123
|
|
|
124
|
+
const [addressError, setAddressError] = useState<string>()
|
|
113
125
|
useEffect(() => {
|
|
126
|
+
let on = true
|
|
127
|
+
setAddressError(undefined)
|
|
128
|
+
// mock has no address; elsewhere it is whatever this environment's variables make it
|
|
129
|
+
if (env.value === 'mock') {
|
|
130
|
+
setBaseUrl('')
|
|
131
|
+
return
|
|
132
|
+
}
|
|
114
133
|
engine
|
|
115
|
-
.
|
|
116
|
-
.then((
|
|
117
|
-
|
|
134
|
+
.baseUrlOf(service, env.value, version.value)
|
|
135
|
+
.then((url) => on && setBaseUrl(url ?? ''))
|
|
136
|
+
.catch((e: unknown) => {
|
|
137
|
+
if (!on) return
|
|
138
|
+
setBaseUrl('')
|
|
139
|
+
setAddressError(e instanceof Error ? e.message : String(e))
|
|
140
|
+
})
|
|
141
|
+
return () => {
|
|
142
|
+
on = false
|
|
143
|
+
}
|
|
144
|
+
}, [service, env.value, version.value, envVarsVersion.value])
|
|
118
145
|
|
|
119
146
|
useEffect(() => {
|
|
120
147
|
let live = true
|
|
@@ -174,8 +201,8 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
174
201
|
try {
|
|
175
202
|
const k = storeKey(key, sel)
|
|
176
203
|
if (baseDraft && JSON.stringify(forStorage(d)) === JSON.stringify(forStorage(baseDraft)))
|
|
177
|
-
|
|
178
|
-
else
|
|
204
|
+
kv.local.removeItem(k)
|
|
205
|
+
else kv.local.setItem(k, JSON.stringify(forStorage(d)))
|
|
179
206
|
} catch {
|
|
180
207
|
/* optional */
|
|
181
208
|
}
|
|
@@ -217,35 +244,49 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
217
244
|
|
|
218
245
|
// placeholders nothing supplies: not in config or the environment's files. They stay listed while
|
|
219
246
|
// you type, so the field you are filling does not disappear under you.
|
|
220
|
-
|
|
247
|
+
// a saved request brings the values of its own `init`: they are what a run uses, so nothing is missing
|
|
248
|
+
const own = Object.keys(saved?.def.input.init ?? {})
|
|
249
|
+
const needed = known ? placeholders.filter((n) => !known.includes(n) && !own.includes(n)) : []
|
|
221
250
|
const unfilled = needed.filter((n) => (vars[n] ?? '') === '')
|
|
222
251
|
|
|
223
252
|
const play = async (forceStatus?: number) => {
|
|
224
|
-
if (!draft) return
|
|
253
|
+
if (!draft || stopper.current) return
|
|
225
254
|
setError(undefined)
|
|
226
255
|
setRunning(true)
|
|
256
|
+
const stop = new AbortController()
|
|
257
|
+
stopper.current = stop
|
|
227
258
|
try {
|
|
228
259
|
const def = saved
|
|
229
260
|
? saved.def
|
|
230
261
|
: await engine.operationRequest(key, version.value, requestParts(draft, encoding))
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
)
|
|
262
|
+
const ran = await engine.run(def, {
|
|
263
|
+
env: env.value,
|
|
264
|
+
version: version.value,
|
|
265
|
+
mockStatus: env.value === 'mock' ? (forceStatus ?? forced) : undefined,
|
|
266
|
+
locale: locale.value,
|
|
267
|
+
seed: seed.value,
|
|
268
|
+
vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => v !== '')),
|
|
269
|
+
fileDir: base?.dir,
|
|
270
|
+
signal: stop.signal,
|
|
271
|
+
})
|
|
272
|
+
setResult(ran)
|
|
273
|
+
announcement.value = describeRun(ran)
|
|
242
274
|
} catch (e) {
|
|
243
275
|
setError(e instanceof Error ? e.message : String(e))
|
|
244
276
|
} finally {
|
|
277
|
+
stopper.current = undefined
|
|
245
278
|
setRunning(false)
|
|
279
|
+
// what you sent a request to see is the response, so show it (the tab stays one click away)
|
|
280
|
+
setTab('response')
|
|
246
281
|
}
|
|
247
282
|
}
|
|
248
283
|
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (!autoRun || !draft || autoRan.current) return
|
|
286
|
+
autoRan.current = true
|
|
287
|
+
void play()
|
|
288
|
+
}, [draft])
|
|
289
|
+
|
|
249
290
|
const saveExample = async (name: string) => {
|
|
250
291
|
if (!draft) return
|
|
251
292
|
const parts = requestParts(draft, encoding)
|
|
@@ -263,7 +304,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
263
304
|
// set before writing: the watcher reloads the page as soon as the file lands
|
|
264
305
|
const saved: SavedExample = { key, path, name: file }
|
|
265
306
|
try {
|
|
266
|
-
|
|
307
|
+
kv.session.setItem(SAVED_KEY, JSON.stringify(saved))
|
|
267
308
|
} catch {
|
|
268
309
|
/* the confirmation below still shows when there is no reload */
|
|
269
310
|
}
|
|
@@ -299,12 +340,14 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
299
340
|
play()
|
|
300
341
|
}
|
|
301
342
|
}
|
|
302
|
-
|
|
303
|
-
|
|
343
|
+
// on the page's own root (a shadow root in an embed), not the window, so a host page's keys are left alone
|
|
344
|
+
const root: EventTarget = panelRoot.current?.getRootNode() ?? window
|
|
345
|
+
root.addEventListener('keydown', send as EventListener)
|
|
346
|
+
return () => root.removeEventListener('keydown', send as EventListener)
|
|
304
347
|
})
|
|
305
348
|
|
|
306
349
|
return (
|
|
307
|
-
<div class="request-panel" data-op={key}>
|
|
350
|
+
<div class="request-panel" data-op={key} ref={panelRoot}>
|
|
308
351
|
<div class="rq">
|
|
309
352
|
<div class="rq-bar">
|
|
310
353
|
<span class={`method ${op.method}`}>{op.method}</span>
|
|
@@ -330,15 +373,26 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
330
373
|
<button
|
|
331
374
|
type="button"
|
|
332
375
|
class="btn primary send"
|
|
333
|
-
title=
|
|
334
|
-
aria-label=
|
|
335
|
-
disabled={running
|
|
336
|
-
onClick={() => play()}
|
|
376
|
+
title={running ? 'Stop' : 'Send (⌘/Ctrl + Enter)'}
|
|
377
|
+
aria-label={running ? 'Stop' : 'Send'}
|
|
378
|
+
disabled={!running && !draft}
|
|
379
|
+
onClick={() => (running ? stopper.current?.abort() : play())}
|
|
337
380
|
>
|
|
338
|
-
{running ?
|
|
381
|
+
{running ? <StopSquare /> : <PaperPlane />}
|
|
339
382
|
</button>
|
|
340
383
|
</div>
|
|
341
384
|
|
|
385
|
+
{addressError && (
|
|
386
|
+
<p class="envmenu-warn" role="alert">
|
|
387
|
+
{addressError}.{' '}
|
|
388
|
+
{features.value.environmentSettings && (
|
|
389
|
+
<button type="button" class="link" onClick={() => (envSettingsOpen.value = true)}>
|
|
390
|
+
Open Environment settings
|
|
391
|
+
</button>
|
|
392
|
+
)}
|
|
393
|
+
</p>
|
|
394
|
+
)}
|
|
395
|
+
|
|
342
396
|
{requestNote && (
|
|
343
397
|
<details class="step-about" key={saved?.file}>
|
|
344
398
|
<summary>About this request</summary>
|
|
@@ -466,8 +520,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
466
520
|
|
|
467
521
|
{saved && (
|
|
468
522
|
<p class="muted lock">
|
|
469
|
-
A saved request: its own values, checks and mock
|
|
470
|
-
here.
|
|
523
|
+
A saved request: its own values, checks and mock hook apply, and it is read-only here.
|
|
471
524
|
</p>
|
|
472
525
|
)}
|
|
473
526
|
|
|
@@ -538,6 +591,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
538
591
|
title="Query"
|
|
539
592
|
rows={draft.query}
|
|
540
593
|
documented={sections.query}
|
|
594
|
+
readOnly={!!saved}
|
|
541
595
|
onChange={(query) => edit({ ...draft, query })}
|
|
542
596
|
/>
|
|
543
597
|
)}
|
|
@@ -547,6 +601,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
547
601
|
rows={draft.path}
|
|
548
602
|
documented={sections.path}
|
|
549
603
|
fixed
|
|
604
|
+
readOnly={!!saved}
|
|
550
605
|
onChange={(path) => edit({ ...draft, path })}
|
|
551
606
|
/>
|
|
552
607
|
)}
|
|
@@ -557,6 +612,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
557
612
|
title="Headers"
|
|
558
613
|
rows={draft.headers}
|
|
559
614
|
documented={sections.headers}
|
|
615
|
+
readOnly={!!saved}
|
|
560
616
|
onChange={(headers) => edit({ ...draft, headers })}
|
|
561
617
|
/>
|
|
562
618
|
)}
|
|
@@ -571,6 +627,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
571
627
|
defs={defs}
|
|
572
628
|
files={meta?.files}
|
|
573
629
|
notes={opNotes.notes}
|
|
630
|
+
readOnly={!!saved}
|
|
574
631
|
/>
|
|
575
632
|
)}
|
|
576
633
|
</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>
|
package/src/panel/SecretsBar.tsx
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { absoluteBase, type OAuthSettings } from '@specbook/core'
|
|
1
2
|
import { useEffect, useState } from 'preact/hooks'
|
|
2
|
-
import {
|
|
3
|
+
import { expiryOf, noteExpiry } from '../oauth'
|
|
3
4
|
import { engine } from '../runtime'
|
|
4
5
|
import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
|
|
5
|
-
import { env, version } from '../store'
|
|
6
|
+
import { env, envVarsVersion, version } from '../store'
|
|
7
|
+
import { OAuthSignIn } from './OAuthSignIn'
|
|
6
8
|
|
|
7
9
|
interface Need {
|
|
8
10
|
service: string
|
|
9
11
|
origin: string
|
|
10
12
|
title: string
|
|
11
13
|
names: string[]
|
|
14
|
+
/** the config's name for the profile (the key a sign-in's token exchange is addressed by) */
|
|
15
|
+
profile: string
|
|
16
|
+
oauth?: OAuthSettings
|
|
12
17
|
}
|
|
13
18
|
|
|
19
|
+
const minutesLeft = (at: number | undefined) =>
|
|
20
|
+
at === undefined ? undefined : Math.max(0, Math.round((at - Date.now()) / 60_000))
|
|
21
|
+
|
|
14
22
|
const SOURCE: Record<string, string> = {
|
|
15
23
|
session: 'this session',
|
|
16
24
|
process: 'environment variable',
|
|
@@ -51,15 +59,17 @@ export function SecretsBar({
|
|
|
51
59
|
}
|
|
52
60
|
let on = true
|
|
53
61
|
if (!supplied) engine.managedSecrets(env.value).then((m) => on && setManaged(m))
|
|
54
|
-
engine.config(version.value).then((cfg) => {
|
|
55
|
-
if (!on) return
|
|
62
|
+
engine.config(version.value).then(async (cfg) => {
|
|
56
63
|
const out: Need[] = []
|
|
64
|
+
const where = await engine.addressesIn(cfg, env.value)
|
|
57
65
|
for (const [service, s] of Object.entries(cfg.services ?? {})) {
|
|
58
66
|
if (services && !services.includes(service)) continue
|
|
59
67
|
const profile = s.auth ? cfg.auth?.[s.auth] : undefined
|
|
60
|
-
|
|
68
|
+
// the address this environment's variables give; one that cannot be worked out has no origin
|
|
69
|
+
const base = where.baseUrl(service)
|
|
61
70
|
const origin = originOf(base ? absoluteBase(base, globalThis.location?.href) : undefined)
|
|
62
71
|
if (!profile || !origin) continue
|
|
72
|
+
const oauth = s.auth ? where.oauth(s.auth) : undefined
|
|
63
73
|
const names = [
|
|
64
74
|
...new Set(
|
|
65
75
|
[...JSON.stringify(profile.apply).matchAll(SECRET_PLACEHOLDER)].map(
|
|
@@ -67,14 +77,22 @@ export function SecretsBar({
|
|
|
67
77
|
),
|
|
68
78
|
),
|
|
69
79
|
]
|
|
70
|
-
if (names.length)
|
|
80
|
+
if (names.length)
|
|
81
|
+
out.push({
|
|
82
|
+
service,
|
|
83
|
+
origin,
|
|
84
|
+
title: profile.title ?? s.auth ?? '',
|
|
85
|
+
names,
|
|
86
|
+
profile: s.auth as string,
|
|
87
|
+
oauth,
|
|
88
|
+
})
|
|
71
89
|
}
|
|
72
|
-
setNeeds(out)
|
|
90
|
+
if (on) setNeeds(out)
|
|
73
91
|
})
|
|
74
92
|
return () => {
|
|
75
93
|
on = false
|
|
76
94
|
}
|
|
77
|
-
}, [live, env.value, wanted, version.value])
|
|
95
|
+
}, [live, env.value, wanted, version.value, envVarsVersion.value])
|
|
78
96
|
|
|
79
97
|
if (!live || needs.length === 0) return null
|
|
80
98
|
const have = (n: Need, name: string) =>
|
|
@@ -109,12 +127,18 @@ export function SecretsBar({
|
|
|
109
127
|
<code>{name}</code>
|
|
110
128
|
{has ? (
|
|
111
129
|
<>
|
|
112
|
-
<span class="muted"
|
|
130
|
+
<span class="muted">
|
|
131
|
+
✓ set
|
|
132
|
+
{n.oauth && minutesLeft(expiryOf(n.origin, name)) !== undefined
|
|
133
|
+
? ` · expires in ${minutesLeft(expiryOf(n.origin, name))} min`
|
|
134
|
+
: ''}
|
|
135
|
+
</span>
|
|
113
136
|
<button
|
|
114
137
|
type="button"
|
|
115
138
|
class="link"
|
|
116
139
|
onClick={() => {
|
|
117
140
|
secretStore.remove(n.origin, name)
|
|
141
|
+
noteExpiry(n.origin, name, undefined)
|
|
118
142
|
bump((x) => x + 1)
|
|
119
143
|
}}
|
|
120
144
|
>
|
|
@@ -124,6 +148,16 @@ export function SecretsBar({
|
|
|
124
148
|
) : (
|
|
125
149
|
<>
|
|
126
150
|
{from && <span class="muted">from {SOURCE[from] ?? from}</span>}
|
|
151
|
+
{n.oauth && (
|
|
152
|
+
<OAuthSignIn
|
|
153
|
+
service={n.service}
|
|
154
|
+
profile={n.profile}
|
|
155
|
+
origin={n.origin}
|
|
156
|
+
secret={name}
|
|
157
|
+
oauth={n.oauth}
|
|
158
|
+
onSignedIn={() => bump((x) => x + 1)}
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
127
161
|
<input
|
|
128
162
|
type="password"
|
|
129
163
|
autocomplete="off"
|
|
@@ -2,6 +2,7 @@ import { useMemo } from 'preact/hooks'
|
|
|
2
2
|
import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
|
|
3
3
|
import type { SequenceInfo } from '../engine'
|
|
4
4
|
import { go } from '../nav'
|
|
5
|
+
import { show } from './short'
|
|
5
6
|
import { useSequenceNotes } from './stepnotes'
|
|
6
7
|
|
|
7
8
|
const KIND: Record<string, string> = {
|
|
@@ -11,7 +12,7 @@ const KIND: Record<string, string> = {
|
|
|
11
12
|
check: 'check',
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
const
|
|
15
|
+
const showValue = (v: unknown) => (typeof v === 'string' ? v : show(v))
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* What a sequence does, as documentation: its description (markdown), an optional markdown page from
|
|
@@ -21,9 +22,9 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
21
22
|
const { seq } = info
|
|
22
23
|
const { intro, notes } = useSequenceNotes(info)
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const inputs = useMemo(
|
|
25
26
|
() =>
|
|
26
|
-
Object.entries((seq.meta.
|
|
27
|
+
Object.entries((seq.meta.inputs ?? {}) as Record<string, unknown>).map(([name, c]) => {
|
|
27
28
|
const spec: FieldSpec = specFromControl(c)
|
|
28
29
|
return { name, spec, initial: defaultFor(spec) }
|
|
29
30
|
}),
|
|
@@ -42,7 +43,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
42
43
|
|
|
43
44
|
{intro && <div class="doc-custom" dangerouslySetInnerHTML={{ __html: intro }} />}
|
|
44
45
|
|
|
45
|
-
{
|
|
46
|
+
{inputs.length > 0 && (
|
|
46
47
|
<section>
|
|
47
48
|
<h2>Controls</h2>
|
|
48
49
|
<table class="fields">
|
|
@@ -55,7 +56,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
55
56
|
</tr>
|
|
56
57
|
</thead>
|
|
57
58
|
<tbody>
|
|
58
|
-
{
|
|
59
|
+
{inputs.map((p) => (
|
|
59
60
|
<tr>
|
|
60
61
|
<td>
|
|
61
62
|
<code>{p.name}</code>
|
|
@@ -64,7 +65,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
64
65
|
<span class="chip">{p.spec.kind}</span>
|
|
65
66
|
</td>
|
|
66
67
|
<td>
|
|
67
|
-
<code>{
|
|
68
|
+
<code>{showValue(p.initial)}</code>
|
|
68
69
|
</td>
|
|
69
70
|
<td>{p.spec.description}</td>
|
|
70
71
|
</tr>
|