@specbook/ui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
- package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
- package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
- package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
- package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +60 -3
- package/src/app.tsx +363 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +46 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +114 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +432 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +16 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +237 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +193 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +625 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +156 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1897 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +78 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +285 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +51 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +154 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +171 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { RunResult, StepResult } from '@specbook/core'
|
|
2
|
+
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
3
|
+
import { EnvBadge } from '../components/EnvBadge'
|
|
4
|
+
import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
|
|
5
|
+
import type { SequenceInfo } from '../engine'
|
|
6
|
+
import { engine } from '../runtime'
|
|
7
|
+
import { env, locale, seed, version } from '../store'
|
|
8
|
+
import { ControlsGrid } from './ControlsGrid'
|
|
9
|
+
import { SecretsBar } from './SecretsBar'
|
|
10
|
+
import { StepExchange } from './StepExchange'
|
|
11
|
+
import { useSequenceNotes } from './stepnotes'
|
|
12
|
+
|
|
13
|
+
/** `Check items #2` belongs to the step `Check items`. */
|
|
14
|
+
const baseName = (n: string) => n.replace(/ #\d+$/, '')
|
|
15
|
+
|
|
16
|
+
type Live = Record<string, 'running' | 'pass' | 'fail' | 'error' | 'skipped' | 'paused'>
|
|
17
|
+
|
|
18
|
+
const ICON = {
|
|
19
|
+
pass: '✓',
|
|
20
|
+
fail: '✗',
|
|
21
|
+
error: '!',
|
|
22
|
+
skipped: '–',
|
|
23
|
+
running: '…',
|
|
24
|
+
paused: '⏸',
|
|
25
|
+
pending: '',
|
|
26
|
+
}
|
|
27
|
+
const short = (v: unknown) => {
|
|
28
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
29
|
+
return s.length > 28 ? `${s.slice(0, 27)}…` : s
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** |timeline|request|: the journey on the left, the selected step's exchange on the right. */
|
|
33
|
+
export function SequencePanel({ info }: { info: SequenceInfo }) {
|
|
34
|
+
const { seq } = info
|
|
35
|
+
const specs = useMemo(
|
|
36
|
+
() =>
|
|
37
|
+
Object.fromEntries(
|
|
38
|
+
Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([k, c]) => [
|
|
39
|
+
k,
|
|
40
|
+
specFromControl(c),
|
|
41
|
+
]),
|
|
42
|
+
) as Record<string, FieldSpec>,
|
|
43
|
+
[seq],
|
|
44
|
+
)
|
|
45
|
+
const notes = useSequenceNotes(info)
|
|
46
|
+
// the services the flow calls; steps built by functions are only known when they run, so any such step asks for all
|
|
47
|
+
const serviceNames = useMemo(() => {
|
|
48
|
+
const names = new Set<string>()
|
|
49
|
+
for (const n of seq.nodes) {
|
|
50
|
+
if (n.kind === 'step' && typeof n.def !== 'function') names.add(n.def.op.service)
|
|
51
|
+
else if (n.kind === 'step' || n.kind === 'each') return undefined
|
|
52
|
+
}
|
|
53
|
+
return [...names]
|
|
54
|
+
}, [seq])
|
|
55
|
+
const [params, setParams] = useState<Record<string, unknown>>({})
|
|
56
|
+
const [result, setResult] = useState<RunResult>()
|
|
57
|
+
const [live, setLive] = useState<Live>({})
|
|
58
|
+
const [running, setRunning] = useState(false)
|
|
59
|
+
const [pause, setPause] = useState<{
|
|
60
|
+
name: string
|
|
61
|
+
controls: Record<string, FieldSpec>
|
|
62
|
+
resolve: (v: Record<string, unknown>) => void
|
|
63
|
+
}>()
|
|
64
|
+
const [choice, setChoice] = useState<Record<string, unknown>>({})
|
|
65
|
+
const [picked, setPicked] = useState<number>(0)
|
|
66
|
+
// a phone shows the selected step's request and response as a sheet over the timeline
|
|
67
|
+
const [sheet, setSheet] = useState(false)
|
|
68
|
+
const [error, setError] = useState<string>()
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
setParams(Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)])))
|
|
72
|
+
setResult(undefined)
|
|
73
|
+
setLive({})
|
|
74
|
+
setPause(undefined)
|
|
75
|
+
}, [info.file])
|
|
76
|
+
|
|
77
|
+
const planned = seq.nodes.map((n) => n.name)
|
|
78
|
+
const rows: { name: string; step?: StepResult }[] = result
|
|
79
|
+
? result.steps.filter((s) => s.kind !== 'auth').map((s) => ({ name: s.name, step: s }))
|
|
80
|
+
: planned.map((n) => ({ name: n }))
|
|
81
|
+
const current = rows[picked]?.step
|
|
82
|
+
|
|
83
|
+
const run = async () => {
|
|
84
|
+
setRunning(true)
|
|
85
|
+
setResult(undefined)
|
|
86
|
+
setError(undefined)
|
|
87
|
+
setLive({})
|
|
88
|
+
setPicked(0)
|
|
89
|
+
setSheet(false)
|
|
90
|
+
try {
|
|
91
|
+
const r = await engine.run(seq, {
|
|
92
|
+
env: env.value,
|
|
93
|
+
version: version.value,
|
|
94
|
+
params,
|
|
95
|
+
locale: locale.value,
|
|
96
|
+
seed: seed.value,
|
|
97
|
+
onEvent: (e) => {
|
|
98
|
+
if (e.type === 'step:start') setLive((l) => ({ ...l, [e.name]: 'running' }))
|
|
99
|
+
if (e.type === 'step:end') setLive((l) => ({ ...l, [e.name]: e.status }))
|
|
100
|
+
},
|
|
101
|
+
onInput: ({ name, controls }) =>
|
|
102
|
+
new Promise((resolve) => {
|
|
103
|
+
const c = controls as Record<string, FieldSpec>
|
|
104
|
+
setLive((l) => ({ ...l, [name]: 'paused' }))
|
|
105
|
+
setChoice(Object.fromEntries(Object.entries(c).map(([k, v]) => [k, defaultFor(v)])))
|
|
106
|
+
setPause({
|
|
107
|
+
name,
|
|
108
|
+
controls: c,
|
|
109
|
+
resolve: (values) => {
|
|
110
|
+
setPause(undefined)
|
|
111
|
+
resolve(values)
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
setResult(r)
|
|
117
|
+
} catch (e) {
|
|
118
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
119
|
+
} finally {
|
|
120
|
+
setRunning(false)
|
|
121
|
+
setPause(undefined)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const overall = result?.status
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div class={`sequence-panel ${sheet && current ? 'sheet-open' : ''}`} data-seq={seq.meta.id}>
|
|
129
|
+
<div class="seq-left">
|
|
130
|
+
<SecretsBar services={serviceNames} />
|
|
131
|
+
<ControlsGrid title="Controls" specs={specs} values={params} onChange={setParams} />
|
|
132
|
+
<div class="seq-run">
|
|
133
|
+
<button type="button" class="btn primary" disabled={running} onClick={run}>
|
|
134
|
+
{running ? 'Running…' : '▶ Run'}
|
|
135
|
+
</button>
|
|
136
|
+
<EnvBadge />
|
|
137
|
+
{result && (
|
|
138
|
+
<span class={`badge ${overall}`}>
|
|
139
|
+
{ICON[overall as 'pass']}{' '}
|
|
140
|
+
{overall === 'pass' ? 'Passed' : overall === 'fail' ? 'Failed' : 'Error'} ·{' '}
|
|
141
|
+
{result.totals.passed}/{result.totals.steps} steps · {result.durationMs}ms
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
{error && <p class="error-text">{error}</p>}
|
|
146
|
+
<ol class="journey" part="timeline" aria-label="Timeline">
|
|
147
|
+
{rows.map((row, i) => {
|
|
148
|
+
const step = row.step
|
|
149
|
+
const status = step?.status ?? live[row.name] ?? 'pending'
|
|
150
|
+
const failed = step?.assertions.filter((a) => !a.pass) ?? []
|
|
151
|
+
return (
|
|
152
|
+
<li class={`jstep ${status} ${picked === i ? 'picked' : ''}`}>
|
|
153
|
+
<div class="jmark">{ICON[status as keyof typeof ICON] || i + 1}</div>
|
|
154
|
+
<div class="jbody">
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
class="jhead"
|
|
158
|
+
disabled={!step}
|
|
159
|
+
onClick={(e) => {
|
|
160
|
+
const row = e.currentTarget
|
|
161
|
+
setPicked(i)
|
|
162
|
+
setSheet(true)
|
|
163
|
+
// keep the tapped step visible above the sheet that is about to cover the bottom
|
|
164
|
+
requestAnimationFrame(() =>
|
|
165
|
+
row.scrollIntoView?.({ block: 'start', behavior: 'smooth' }),
|
|
166
|
+
)
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<span class="jname">{row.name}</span>
|
|
170
|
+
{step?.response && (
|
|
171
|
+
<span class="muted">
|
|
172
|
+
{step.response.status} · {step.durationMs}ms
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
{step && step.assertions.length > 0 && (
|
|
176
|
+
<span class={failed.length ? 'error-text' : 'muted'}>
|
|
177
|
+
{step.assertions.length - failed.length}/{step.assertions.length} checks
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</button>
|
|
181
|
+
{step?.uses
|
|
182
|
+
?.filter((u) => u.source === 'capture')
|
|
183
|
+
.map((u) => (
|
|
184
|
+
<span class="cap use" title={`from step "${u.step}"`}>
|
|
185
|
+
← {u.key} <em>from {u.step}</em>
|
|
186
|
+
</span>
|
|
187
|
+
))}
|
|
188
|
+
{step &&
|
|
189
|
+
Object.entries(step.captured).map(([k, v]) => (
|
|
190
|
+
<span class="cap make" title={JSON.stringify(v)}>
|
|
191
|
+
→ {k} <b>{short(v)}</b>
|
|
192
|
+
</span>
|
|
193
|
+
))}
|
|
194
|
+
{step?.error && <div class="error-text">{step.error}</div>}
|
|
195
|
+
{failed.map((a) => (
|
|
196
|
+
<div class="error-text">✗ {a.label}</div>
|
|
197
|
+
))}
|
|
198
|
+
{status === 'skipped' && (
|
|
199
|
+
<div class="muted">Skipped: an earlier step did not pass.</div>
|
|
200
|
+
)}
|
|
201
|
+
{pause && live[row.name] === 'paused' && (
|
|
202
|
+
<div class="pause">
|
|
203
|
+
<strong>Your turn: {pause.name}</strong>
|
|
204
|
+
<ControlsGrid
|
|
205
|
+
title="Choose"
|
|
206
|
+
specs={pause.controls}
|
|
207
|
+
values={choice}
|
|
208
|
+
onChange={setChoice}
|
|
209
|
+
/>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
class="btn primary"
|
|
213
|
+
onClick={() => pause.resolve(choice)}
|
|
214
|
+
>
|
|
215
|
+
Continue ▶
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</li>
|
|
221
|
+
)
|
|
222
|
+
})}
|
|
223
|
+
</ol>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div class={`seq-right ${sheet && current ? 'sheet-open' : ''}`}>
|
|
227
|
+
{current && (
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
class="sheet-close"
|
|
231
|
+
aria-label="Hide step details"
|
|
232
|
+
onClick={() => setSheet(false)}
|
|
233
|
+
>
|
|
234
|
+
<span aria-hidden="true">⌄</span> {current.name}
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
237
|
+
{!current && (
|
|
238
|
+
<div class="rs-empty">
|
|
239
|
+
<strong>Nothing run yet</strong>
|
|
240
|
+
<span class="muted">Press Run, then pick a step to see its request and response.</span>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
{current && <StepExchange step={current} note={notes.notes.get(baseName(current.name))} />}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Encoding, StepResult } from '@specbook/core'
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
3
|
+
import { engine, registry } from '../runtime'
|
|
4
|
+
import type { Defs, JsonSchema } from '../schema'
|
|
5
|
+
import { statusClass, statusName } from '../status'
|
|
6
|
+
import { version } from '../store'
|
|
7
|
+
import { StepCodeSample } from './CodeSample'
|
|
8
|
+
import { queryString, RequestView } from './RequestView'
|
|
9
|
+
import { ResponseView } from './ResponseView'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The selected step of a run, laid out like an operation: a bar with method, URL and result, then
|
|
13
|
+
* Request and Response tabs (the response checked against the operation's schema).
|
|
14
|
+
*/
|
|
15
|
+
export function StepExchange({ step, note }: { step: StepResult; note?: string }) {
|
|
16
|
+
const [tab, setTab] = useState<'request' | 'response'>('response')
|
|
17
|
+
const [schemas, setSchemas] = useState<Record<string, JsonSchema>>()
|
|
18
|
+
const [defs, setDefs] = useState<Defs>()
|
|
19
|
+
const [reqSchema, setReqSchema] = useState<JsonSchema>()
|
|
20
|
+
const [encoding, setEncoding] = useState<Encoding>('json')
|
|
21
|
+
const req = step.request
|
|
22
|
+
const key = req?.opId ? `${req.service}/${req.opId}` : undefined
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setTab('response')
|
|
26
|
+
}, [step.name])
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let live = true
|
|
30
|
+
setSchemas(undefined)
|
|
31
|
+
setDefs(undefined)
|
|
32
|
+
setReqSchema(undefined)
|
|
33
|
+
if (key && req) {
|
|
34
|
+
registry.schemas[key]?.().then(
|
|
35
|
+
(m: { default: JsonSchema }) => live && setReqSchema(m.default),
|
|
36
|
+
)
|
|
37
|
+
engine
|
|
38
|
+
.operationMeta(key, version.value)
|
|
39
|
+
.then((m) => live && setEncoding(m?.encoding ?? 'json'))
|
|
40
|
+
registry.responseSchemas[key]?.().then(
|
|
41
|
+
(m: { default: Record<string, JsonSchema> }) => live && setSchemas(m.default),
|
|
42
|
+
)
|
|
43
|
+
registry.definitions[req.service]?.().then(
|
|
44
|
+
(m: { default: Defs }) => live && setDefs(m.default),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
return () => {
|
|
48
|
+
live = false
|
|
49
|
+
}
|
|
50
|
+
}, [key])
|
|
51
|
+
|
|
52
|
+
const response = step.response
|
|
53
|
+
const url = req ? `${req.url}${queryString(req.query)}` : ''
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div class="step-exchange">
|
|
57
|
+
{req && (
|
|
58
|
+
<div class="rq-bar">
|
|
59
|
+
<span class={`method ${req.method}`}>{req.method}</span>
|
|
60
|
+
<div class="rq-url" title={url}>
|
|
61
|
+
<code>{url}</code>
|
|
62
|
+
</div>
|
|
63
|
+
{response && (
|
|
64
|
+
<span class={`result-chip ${statusClass(response.status)}`}>
|
|
65
|
+
{response.status} {statusName(response.status)} · {response.durationMs} ms
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{note && (
|
|
72
|
+
<details class="step-about" key={step.name}>
|
|
73
|
+
<summary>About this step</summary>
|
|
74
|
+
<div class="doc-custom" dangerouslySetInnerHTML={{ __html: note }} />
|
|
75
|
+
</details>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
<div class="rq-tabs" role="tablist">
|
|
79
|
+
{(
|
|
80
|
+
[
|
|
81
|
+
['request', 'Request'],
|
|
82
|
+
['response', 'Response'],
|
|
83
|
+
] as const
|
|
84
|
+
).map(([id, label]) => (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
role="tab"
|
|
88
|
+
aria-selected={tab === id}
|
|
89
|
+
class={`rq-tab ${tab === id ? 'on' : ''}`}
|
|
90
|
+
onClick={() => setTab(id)}
|
|
91
|
+
>
|
|
92
|
+
{label}
|
|
93
|
+
{id === 'response' && response && (
|
|
94
|
+
<span class={`state-dot ${statusClass(response.status)}`}>{response.status}</span>
|
|
95
|
+
)}
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="rq-pane">
|
|
101
|
+
<div class="tab-pane" hidden={tab !== 'request'}>
|
|
102
|
+
{req ? (
|
|
103
|
+
<StepCodeSample
|
|
104
|
+
request={req}
|
|
105
|
+
encoding={encoding}
|
|
106
|
+
sent={<RequestView request={req} hideBar docs={{ schema: reqSchema, defs }} />}
|
|
107
|
+
/>
|
|
108
|
+
) : (
|
|
109
|
+
<RequestView request={req} hideBar docs={{ schema: reqSchema, defs }} />
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
<div class="tab-pane" hidden={tab !== 'response'}>
|
|
113
|
+
<ResponseView step={step} schemas={schemas} defs={defs} hideRequest />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Defs,
|
|
3
|
+
describe,
|
|
4
|
+
type JsonSchema,
|
|
5
|
+
kindOf,
|
|
6
|
+
propsOf,
|
|
7
|
+
schemaAt,
|
|
8
|
+
summary,
|
|
9
|
+
unwrap,
|
|
10
|
+
} from '../schema'
|
|
11
|
+
import { jsonCursor } from './jsonCursor'
|
|
12
|
+
|
|
13
|
+
/** The shape the editor's autocomplete extension consumes (see prism-code-editor's `CompletionResult`). */
|
|
14
|
+
export interface Option {
|
|
15
|
+
label: string
|
|
16
|
+
detail?: string
|
|
17
|
+
icon?: string
|
|
18
|
+
insert?: string
|
|
19
|
+
tabStops?: number[]
|
|
20
|
+
boost?: number
|
|
21
|
+
from?: number
|
|
22
|
+
/** a longer description for the documentation popup */
|
|
23
|
+
doc?: string
|
|
24
|
+
}
|
|
25
|
+
export interface Completions {
|
|
26
|
+
from: number
|
|
27
|
+
to: number
|
|
28
|
+
options: Option[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Descriptions are markdown; the popup shows plain text, so links keep just their label. */
|
|
32
|
+
const plain = (s: string | undefined) =>
|
|
33
|
+
s?.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[`*_]{1,2}/g, '')
|
|
34
|
+
|
|
35
|
+
const typeLabel = (u: JsonSchema, defs: Defs) => {
|
|
36
|
+
const kind = kindOf(u)
|
|
37
|
+
if (kind === 'array') return `${kindOf(unwrap(u.items, defs))}[]`
|
|
38
|
+
return u.format ? `${kind} · ${u.format}` : kind
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The empty value of the right kind that goes after `"key": `, with the cursor placed inside it. */
|
|
42
|
+
function skeleton(u: JsonSchema): { text: string; stop: number } {
|
|
43
|
+
const kind = kindOf(u)
|
|
44
|
+
if (kind === 'object') return { text: '{}', stop: 1 }
|
|
45
|
+
if (kind === 'array') return { text: '[]', stop: 1 }
|
|
46
|
+
if (kind === 'boolean') return { text: 'false', stop: 5 }
|
|
47
|
+
if (kind === 'number' || kind === 'integer') return { text: '0', stop: 1 }
|
|
48
|
+
return { text: '""', stop: 1 }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Completions for the JSON body editor: the properties the schema allows at the cursor (minus the ones
|
|
53
|
+
* already there), or the allowed values for the property being written. `root` is the request schema and
|
|
54
|
+
* `prefix` the path of the body inside it (`['data']`).
|
|
55
|
+
*/
|
|
56
|
+
export function jsonCompletions(
|
|
57
|
+
root: JsonSchema | undefined,
|
|
58
|
+
defs: Defs,
|
|
59
|
+
prefix: (string | number)[],
|
|
60
|
+
text: string,
|
|
61
|
+
pos: number,
|
|
62
|
+
): Completions | undefined {
|
|
63
|
+
if (!root) return undefined
|
|
64
|
+
const cur = jsonCursor(text, pos)
|
|
65
|
+
if (!cur) return undefined
|
|
66
|
+
const at = schemaAt(root, [...prefix, ...cur.path], defs)
|
|
67
|
+
if (!at) return undefined
|
|
68
|
+
const u = unwrap(at, defs)
|
|
69
|
+
|
|
70
|
+
if (cur.mode === 'key') {
|
|
71
|
+
if (kindOf(u) !== 'object' && !u.properties) return undefined
|
|
72
|
+
const props = propsOf(u, defs).filter((p) => !cur.existing.has(p.name))
|
|
73
|
+
if (props.length === 0) return undefined
|
|
74
|
+
const options: Option[] = props.map((p) => {
|
|
75
|
+
const pu = unwrap(p.schema, defs)
|
|
76
|
+
const sk = skeleton(pu)
|
|
77
|
+
const key = JSON.stringify(p.name)
|
|
78
|
+
const head = cur.hasColon ? key : `${key}: `
|
|
79
|
+
const insert = cur.hasColon ? key : `${head}${sk.text}`
|
|
80
|
+
return {
|
|
81
|
+
label: p.name,
|
|
82
|
+
detail: `${typeLabel(pu, defs)}${p.required ? ' · required' : ''}`,
|
|
83
|
+
icon: pu.deprecated ? 'text' : 'property',
|
|
84
|
+
insert,
|
|
85
|
+
tabStops: cur.hasColon ? undefined : [head.length + sk.stop],
|
|
86
|
+
boost: p.required ? 10 : 0,
|
|
87
|
+
// inside a string the filter should match the letters typed after the opening quote
|
|
88
|
+
from: cur.inString ? cur.from + 1 : undefined,
|
|
89
|
+
doc: plain(summary(describe(pu), 400)),
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
return { from: cur.from, to: cur.to, options }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// value mode
|
|
96
|
+
const values: Option[] = []
|
|
97
|
+
const add = (v: unknown, detail?: string) => {
|
|
98
|
+
const json = JSON.stringify(v)
|
|
99
|
+
values.push({
|
|
100
|
+
label: typeof v === 'string' ? v : json,
|
|
101
|
+
insert: json,
|
|
102
|
+
icon: 'enum',
|
|
103
|
+
detail,
|
|
104
|
+
from: cur.inString ? cur.from + 1 : undefined,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(u.enum)) for (const v of u.enum) add(v)
|
|
108
|
+
else if (kindOf(u) === 'boolean') {
|
|
109
|
+
add(true)
|
|
110
|
+
add(false)
|
|
111
|
+
} else return undefined
|
|
112
|
+
if (u.nullable === true) add(null, 'null')
|
|
113
|
+
return { from: cur.from, to: cur.to, options: values }
|
|
114
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Encoding,
|
|
3
|
+
type Example,
|
|
4
|
+
fromFormRows,
|
|
5
|
+
fromRows,
|
|
6
|
+
type Row,
|
|
7
|
+
toFormRows,
|
|
8
|
+
toRows,
|
|
9
|
+
} from '@specbook/core'
|
|
10
|
+
|
|
11
|
+
export type FieldRow = Row & { file?: File }
|
|
12
|
+
|
|
13
|
+
/** The request as the user sees and edits it. Path/query/headers are grid rows; the body depends on encoding. */
|
|
14
|
+
export interface Draft {
|
|
15
|
+
path: Row[]
|
|
16
|
+
query: Row[]
|
|
17
|
+
headers: Row[]
|
|
18
|
+
/** json, graphql and text bodies */
|
|
19
|
+
body: string
|
|
20
|
+
/** form, multipart and binary bodies */
|
|
21
|
+
fields: FieldRow[]
|
|
22
|
+
/**
|
|
23
|
+
* How a form body is edited: nested JSON (the default; flattened to brackets when sent) or the
|
|
24
|
+
* key/value grid. Drafts stored before this existed have only a grid, so they keep it.
|
|
25
|
+
*/
|
|
26
|
+
formView?: 'json' | 'grid'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether the body is edited as rows: multipart always, form when its grid view is chosen. */
|
|
30
|
+
export const gridMode = (d: Draft, e: Encoding) =>
|
|
31
|
+
e === 'multipart' || (e === 'form' && (d.formView ?? 'grid') === 'grid')
|
|
32
|
+
|
|
33
|
+
export function draftFrom(ex: Example, encoding: Encoding): Draft {
|
|
34
|
+
const body = ex.body
|
|
35
|
+
const rows = encoding === 'multipart' || encoding === 'form'
|
|
36
|
+
return {
|
|
37
|
+
path: toRows(ex.path),
|
|
38
|
+
query: toRows(ex.query),
|
|
39
|
+
headers: toRows(ex.headers),
|
|
40
|
+
body:
|
|
41
|
+
body === undefined || encoding === 'multipart' || encoding === 'binary'
|
|
42
|
+
? ''
|
|
43
|
+
: typeof body === 'string' && encoding === 'text'
|
|
44
|
+
? body
|
|
45
|
+
: JSON.stringify(body, null, 2),
|
|
46
|
+
fields: rows
|
|
47
|
+
? toFormRows(body)
|
|
48
|
+
: encoding === 'binary' && typeof body === 'string'
|
|
49
|
+
? [{ key: 'file', value: body, enabled: true }]
|
|
50
|
+
: [],
|
|
51
|
+
formView: encoding === 'form' ? 'json' : undefined,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RequestParts {
|
|
56
|
+
path?: Record<string, unknown>
|
|
57
|
+
query?: Record<string, unknown>
|
|
58
|
+
headers?: Record<string, string>
|
|
59
|
+
data?: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nonEmpty = <T extends object>(o: T) => (Object.keys(o).length ? o : undefined)
|
|
63
|
+
|
|
64
|
+
/** Throws a readable message when the JSON body does not parse. */
|
|
65
|
+
export function requestParts(d: Draft, encoding: Encoding): RequestParts {
|
|
66
|
+
let data: unknown
|
|
67
|
+
if (gridMode(d, encoding)) data = fromFormRows(d.fields)
|
|
68
|
+
else if (encoding === 'binary') {
|
|
69
|
+
const f = d.fields.find((r) => r.enabled)
|
|
70
|
+
data = f?.file ?? f?.value
|
|
71
|
+
} else if (d.body.trim() !== '') {
|
|
72
|
+
if (encoding === 'text') data = d.body
|
|
73
|
+
else
|
|
74
|
+
try {
|
|
75
|
+
data = JSON.parse(d.body)
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new Error(`Invalid JSON: ${(e as Error).message}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
path: nonEmpty(fromRows(d.path)),
|
|
82
|
+
query: nonEmpty(fromRows(d.query)),
|
|
83
|
+
headers: nonEmpty(fromRows(d.headers)) as Record<string, string> | undefined,
|
|
84
|
+
data,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The editable state for storage; picked files are never persisted. */
|
|
89
|
+
export const forStorage = (d: Draft) => ({
|
|
90
|
+
...d,
|
|
91
|
+
fields: d.fields.map(({ file: _file, ...r }) => r),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/** What the request will be called with, for the URL bar: path params filled in, enabled query rows appended. */
|
|
95
|
+
export function previewUrl(template: string, path: Row[], query: Row[], base = ''): string {
|
|
96
|
+
const enc = (v: string) => (v.includes('{{') ? v : encodeURIComponent(v))
|
|
97
|
+
const p = template.replace(/\{([^}]+)\}/g, (m, name: string) => {
|
|
98
|
+
const r = path.find((x) => x.key === name && x.enabled && x.value !== '')
|
|
99
|
+
return r ? enc(r.value) : m
|
|
100
|
+
})
|
|
101
|
+
const qs = query
|
|
102
|
+
.filter((r) => r.enabled && r.key !== '')
|
|
103
|
+
.map((r) => `${enc(r.key)}=${enc(r.value)}`)
|
|
104
|
+
.join('&')
|
|
105
|
+
return `${base.replace(/\/+$/, '')}${p}${qs ? `?${qs}` : ''}`
|
|
106
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Section a field belongs to in a request schema (`{ path, query, headers, data }`). */
|
|
2
|
+
export type FieldSection = 'path' | 'query' | 'headers' | 'data'
|
|
3
|
+
|
|
4
|
+
const SECTIONS = new Set<string>(['path', 'query', 'headers', 'data'])
|
|
5
|
+
|
|
6
|
+
export interface FieldNotes {
|
|
7
|
+
/** the page without the field sections */
|
|
8
|
+
intro: string
|
|
9
|
+
/** `section.path` -> HTML, e.g. `data.address.city` */
|
|
10
|
+
notes: Map<string, string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `items[0].price` and `items.price` are the same field. */
|
|
14
|
+
const segments = (s: string) => (s.match(/[^.[\]]+/g) ?? []).filter((p) => !/^\d+$/.test(p))
|
|
15
|
+
|
|
16
|
+
/** The key notes are stored and looked up by. */
|
|
17
|
+
export function noteKey(section: FieldSection, path: string): string {
|
|
18
|
+
return [section, ...segments(path)].join('.')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A heading's text becomes a key when it names a field: `name`, `data.name`, `query.status`. */
|
|
22
|
+
function headingKey(text: string): string | undefined {
|
|
23
|
+
const parts = segments(text.trim())
|
|
24
|
+
if (parts.length === 0) return undefined
|
|
25
|
+
const first = parts[0] as string
|
|
26
|
+
if (parts.length >= 2 && SECTIONS.has(first)) return parts.join('.')
|
|
27
|
+
return ['data', ...parts].join('.')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const plain = (h: string) =>
|
|
31
|
+
h
|
|
32
|
+
.replace(/<[^>]+>/g, '')
|
|
33
|
+
.replace(/&/g, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A heading made of one inline code span (`## \`data.name\``) attaches the text under it, up to the next
|
|
39
|
+
* heading of that kind, to that field. Everything else stays in the intro.
|
|
40
|
+
*/
|
|
41
|
+
export function splitFieldNotes(html: string): FieldNotes {
|
|
42
|
+
const notes = new Map<string, string>()
|
|
43
|
+
if (!html) return { intro: html, notes }
|
|
44
|
+
const intro: string[] = []
|
|
45
|
+
for (const chunk of html.split(/(?=<h[1-6][ >])/)) {
|
|
46
|
+
const m = /^<h([1-6])[^>]*>\s*<code>([^<]+)<\/code>\s*<\/h\1>/.exec(chunk)
|
|
47
|
+
const key = m ? headingKey(plain(m[2] as string)) : undefined
|
|
48
|
+
if (m && key) {
|
|
49
|
+
const prev = notes.get(key)
|
|
50
|
+
const body = chunk.slice(m[0].length)
|
|
51
|
+
notes.set(key, prev ? prev + body : body)
|
|
52
|
+
} else intro.push(chunk)
|
|
53
|
+
}
|
|
54
|
+
return { intro: intro.join(''), notes }
|
|
55
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
2
|
+
import { registry } from '../runtime'
|
|
3
|
+
import { type FieldNotes, splitFieldNotes } from './fieldnotes-split'
|
|
4
|
+
|
|
5
|
+
export { type FieldNotes, type FieldSection, noteKey, splitFieldNotes } from './fieldnotes-split'
|
|
6
|
+
|
|
7
|
+
export interface OperationNotes extends FieldNotes {
|
|
8
|
+
/** the page replaces the spec's description instead of following it */
|
|
9
|
+
replace: boolean
|
|
10
|
+
/** an operation page exists */
|
|
11
|
+
found: boolean
|
|
12
|
+
/** the page has finished loading (or there is none), so what follows it will not move */
|
|
13
|
+
loaded: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const NONE = { intro: '', notes: new Map(), replace: false, found: false }
|
|
17
|
+
|
|
18
|
+
/** The markdown that sits with an operation (`.../<Operation>/README.md` or `requests/<service>/docs/<Operation>.md`), split into intro and field notes. */
|
|
19
|
+
export function useOperationNotes(key: string): OperationNotes {
|
|
20
|
+
const [html, setHtml] = useState<string>()
|
|
21
|
+
const [loaded, setLoaded] = useState(false)
|
|
22
|
+
const page = registry.docs.find((d) => d.operation === key)
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
let live = true
|
|
25
|
+
setHtml(undefined)
|
|
26
|
+
setLoaded(!page)
|
|
27
|
+
page
|
|
28
|
+
?.load()
|
|
29
|
+
.then((h: string) => live && setHtml(h))
|
|
30
|
+
.catch(() => {})
|
|
31
|
+
.finally(() => live && setLoaded(true))
|
|
32
|
+
return () => {
|
|
33
|
+
live = false
|
|
34
|
+
}
|
|
35
|
+
}, [key, page])
|
|
36
|
+
return useMemo(() => {
|
|
37
|
+
if (!page || html === undefined) return { ...NONE, loaded }
|
|
38
|
+
return { ...splitFieldNotes(html), replace: !!page.replace, found: true, loaded }
|
|
39
|
+
}, [html, page, loaded])
|
|
40
|
+
}
|