@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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/apple-touch-icon.png +0 -0
  4. package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
  5. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  6. package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
  9. package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
  10. package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
  11. package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
  12. package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
  13. package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
  14. package/dist-viewer/favicon.svg +1 -0
  15. package/dist-viewer/index.html +19 -0
  16. package/dist-viewer/specbook.js +1 -0
  17. package/index.html +12 -0
  18. package/package.json +60 -3
  19. package/src/app.tsx +363 -0
  20. package/src/assets/mark.svg +1 -0
  21. package/src/badge.ts +14 -0
  22. package/src/branding.ts +92 -0
  23. package/src/components/Catalog.tsx +324 -0
  24. package/src/components/EnvBadge.tsx +46 -0
  25. package/src/components/EnvironmentMenu.tsx +262 -0
  26. package/src/components/Fields.tsx +220 -0
  27. package/src/components/GitHubStar.tsx +22 -0
  28. package/src/components/ListResizer.tsx +70 -0
  29. package/src/components/ModeHint.tsx +17 -0
  30. package/src/components/Palette.tsx +85 -0
  31. package/src/components/PoweredBy.tsx +15 -0
  32. package/src/components/ShareMenu.tsx +94 -0
  33. package/src/components/ThemeToggle.tsx +52 -0
  34. package/src/embed/panel.tsx +114 -0
  35. package/src/embed/styles.ts +27 -0
  36. package/src/embed.ts +65 -0
  37. package/src/engine.ts +432 -0
  38. package/src/extension.ts +101 -0
  39. package/src/fielddoc.ts +83 -0
  40. package/src/main.tsx +16 -0
  41. package/src/markdown.ts +133 -0
  42. package/src/nav.ts +118 -0
  43. package/src/panel/Body.tsx +182 -0
  44. package/src/panel/BodyEditor.tsx +92 -0
  45. package/src/panel/CodeSample.tsx +237 -0
  46. package/src/panel/Combo.tsx +124 -0
  47. package/src/panel/ControlsGrid.tsx +189 -0
  48. package/src/panel/FieldDoc.tsx +42 -0
  49. package/src/panel/FieldTable.tsx +113 -0
  50. package/src/panel/Host.tsx +100 -0
  51. package/src/panel/OperationDocs.tsx +181 -0
  52. package/src/panel/Panel.tsx +193 -0
  53. package/src/panel/ParamGrid.tsx +396 -0
  54. package/src/panel/RequestPanel.tsx +625 -0
  55. package/src/panel/RequestView.tsx +108 -0
  56. package/src/panel/ResponseView.tsx +182 -0
  57. package/src/panel/SecretsBar.tsx +156 -0
  58. package/src/panel/SequenceDocs.tsx +132 -0
  59. package/src/panel/SequencePanel.tsx +247 -0
  60. package/src/panel/StepExchange.tsx +118 -0
  61. package/src/panel/completions.ts +114 -0
  62. package/src/panel/draft.ts +106 -0
  63. package/src/panel/fieldnotes-split.ts +55 -0
  64. package/src/panel/fieldnotes.ts +40 -0
  65. package/src/panel/graphql.ts +31 -0
  66. package/src/panel/icons.tsx +85 -0
  67. package/src/panel/jsonCursor.ts +115 -0
  68. package/src/panel/stepnotes-split.ts +38 -0
  69. package/src/panel/stepnotes.ts +58 -0
  70. package/src/routing.ts +120 -0
  71. package/src/runtime.ts +32 -0
  72. package/src/schema.ts +359 -0
  73. package/src/secrets.ts +61 -0
  74. package/src/status.ts +27 -0
  75. package/src/store.ts +10 -0
  76. package/src/styles/app.css +821 -0
  77. package/src/styles/panel.css +1897 -0
  78. package/src/styles/tokens.css +122 -0
  79. package/src/theme.ts +37 -0
  80. package/src/viewer/Opening.tsx +43 -0
  81. package/src/viewer/bundles.ts +74 -0
  82. package/src/viewer/client.ts +54 -0
  83. package/src/viewer/embed.ts +78 -0
  84. package/src/viewer/faker-en.ts +3 -0
  85. package/src/viewer/main.tsx +285 -0
  86. package/src/viewer/mount.ts +52 -0
  87. package/src/viewer/protocol.ts +51 -0
  88. package/src/viewer/recent.ts +22 -0
  89. package/src/viewer/registry-data.ts +4 -0
  90. package/src/viewer/registry.ts +154 -0
  91. package/src/viewer/share.ts +134 -0
  92. package/src/viewer/site.ts +3 -0
  93. package/src/viewer/uploads.ts +53 -0
  94. package/src/viewer/viewer.css +184 -0
  95. package/src/viewer/worker-factory.ts +14 -0
  96. package/src/viewer/worker.ts +171 -0
  97. package/src/virtual.d.ts +104 -0
@@ -0,0 +1,625 @@
1
+ import { collectPlaceholders, type Encoding, type Example, type RunResult } from '@specbook/core'
2
+ import { useEffect, useMemo, useState } from 'preact/hooks'
3
+ import { EnvBadge } from '../components/EnvBadge'
4
+ import type { Variant } from '../engine'
5
+ import { fieldDoc, withNote } from '../fielddoc'
6
+ import { engine, isStatic, registry, saveFile } from '../runtime'
7
+ import { type Defs, type JsonSchema, propsOf, unwrap } from '../schema'
8
+ import { statusClass, statusName } from '../status'
9
+ import { env, locale, seed, version } from '../store'
10
+ import { Body } from './Body'
11
+ import { DraftCodeSample } from './CodeSample'
12
+ import { type Draft, draftFrom, forStorage, previewUrl, requestParts } from './draft'
13
+ import { useOperationNotes } from './fieldnotes'
14
+ import { PaperPlane } from './icons'
15
+ import { type Documented, ParamGrid } from './ParamGrid'
16
+ import { RequestView } from './RequestView'
17
+ import { ResponseView } from './ResponseView'
18
+ import { SecretsBar } from './SecretsBar'
19
+ import { useRequestNote } from './stepnotes'
20
+
21
+ export interface OpInfo {
22
+ id: string
23
+ method: string
24
+ path: string
25
+ summary?: string
26
+ tags: string[]
27
+ statuses: number[]
28
+ }
29
+
30
+ interface Props {
31
+ service: string
32
+ op: OpInfo
33
+ variants: Variant[]
34
+ /** example name to open, '' for the first */
35
+ initial: string
36
+ }
37
+
38
+ const storeKey = (key: string, sel: string) => `specbook:draft:${key}:${sel}`
39
+ const slug = (s: string) =>
40
+ s
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+
45
+ function readDraft(key: string): Draft | undefined {
46
+ try {
47
+ const raw = localStorage.getItem(key)
48
+ return raw ? (JSON.parse(raw) as Draft) : undefined
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A saved example arrives through the file watcher, which reloads the page; this carries the
56
+ * confirmation (and the selection) across that reload.
57
+ */
58
+ const SAVED_KEY = 'specbook:saved-example'
59
+ interface SavedExample {
60
+ key: string
61
+ path: string
62
+ name: string
63
+ }
64
+
65
+ function takeSaved(key: string): SavedExample | undefined {
66
+ try {
67
+ const raw = sessionStorage.getItem(SAVED_KEY)
68
+ const saved = raw ? (JSON.parse(raw) as SavedExample) : undefined
69
+ if (saved?.key !== key) return undefined
70
+ sessionStorage.removeItem(SAVED_KEY)
71
+ return saved
72
+ } catch {
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ export function RequestPanel({ service, op, variants, initial }: Props) {
78
+ const key = `${service}/${op.id}`
79
+ const examples = engine.examplesOf(key)
80
+ const [notice, setNotice] = useState<SavedExample | undefined>(() => takeSaved(key))
81
+ const [sel, setSel] = useState(() => {
82
+ const named = examples.find((e) => e.name === (notice?.name ?? initial))
83
+ return named
84
+ ? `ex:${named.name}`
85
+ : examples[0]
86
+ ? `ex:${examples[0].name}`
87
+ : variants[0]
88
+ ? `saved:${variants[0].file}`
89
+ : ''
90
+ })
91
+ const saved = sel.startsWith('saved:')
92
+ ? variants.find((v) => `saved:${v.file}` === sel)
93
+ : undefined
94
+
95
+ const requestNote = useRequestNote(saved?.file)
96
+ const opNotes = useOperationNotes(key)
97
+ const [meta, setMeta] = useState<{ encoding: Encoding; files: string[] }>()
98
+ const [base, setBase] = useState<Example>()
99
+ const [draft, setDraft] = useState<Draft>()
100
+ const [vars, setVars] = useState<Record<string, string>>({})
101
+ const [reqSchema, setReqSchema] = useState<JsonSchema>()
102
+ const [respSchemas, setRespSchemas] = useState<Record<string, JsonSchema>>()
103
+ const [defs, setDefs] = useState<Defs>()
104
+ const [forced, setForced] = useState<number>()
105
+ const [result, setResult] = useState<RunResult>()
106
+ const [error, setError] = useState<string>()
107
+ const [running, setRunning] = useState(false)
108
+ const [naming, setNaming] = useState<string>()
109
+ const [tab, setTab] = useState<'params' | 'headers' | 'body' | 'vars' | 'request' | 'response'>()
110
+ const [baseUrl, setBaseUrl] = useState('')
111
+ const [known, setKnown] = useState<string[]>()
112
+
113
+ useEffect(() => {
114
+ engine
115
+ .config(version.value)
116
+ .then((c) => setBaseUrl(c.services?.[service]?.baseUrl?.[env.value] ?? ''))
117
+ }, [service, env.value])
118
+
119
+ useEffect(() => {
120
+ let live = true
121
+ setKnown(undefined)
122
+ engine.knownVars(env.value, version.value).then((k) => live && setKnown(k))
123
+ return () => {
124
+ live = false
125
+ }
126
+ }, [env.value])
127
+
128
+ useEffect(() => {
129
+ let live = true
130
+ registry.schemas[key]?.().then((m: { default: JsonSchema }) => live && setReqSchema(m.default))
131
+ registry.responseSchemas[key]?.().then(
132
+ (m: { default: Record<string, JsonSchema> }) => live && setRespSchemas(m.default),
133
+ )
134
+ registry.definitions[service]?.().then((m: { default: Defs }) => live && setDefs(m.default))
135
+ engine.operationMeta(key, version.value).then((m) => {
136
+ if (live) setMeta({ encoding: m?.encoding ?? 'json', files: m?.files ?? [] })
137
+ })
138
+ return () => {
139
+ live = false
140
+ }
141
+ }, [key, service])
142
+
143
+ const encoding = meta?.encoding ?? 'json'
144
+ const secured = registry.manifests[service]?.operations.find((o) => o.id === op.id)?.secured
145
+
146
+ useEffect(() => {
147
+ if (!meta) return
148
+ let live = true
149
+ setError(undefined)
150
+ setResult(undefined)
151
+ ;(async () => {
152
+ const ex = saved
153
+ ? await engine.savedExample(saved.def, version.value)
154
+ : await engine.loadExample(key, sel.replace(/^ex:/, ''), version.value)
155
+ if (!live) return
156
+ const fresh = ex ?? { name: 'empty', source: 'generated' as const }
157
+ setBase(fresh)
158
+ setDraft((!saved && readDraft(storeKey(key, sel))) || draftFrom(fresh, encoding))
159
+ })().catch((e) => live && setError(e instanceof Error ? e.message : String(e)))
160
+ return () => {
161
+ live = false
162
+ }
163
+ }, [key, sel, meta, saved])
164
+
165
+ const baseDraft = useMemo(() => (base ? draftFrom(base, encoding) : undefined), [base, encoding])
166
+ const modified =
167
+ !!draft &&
168
+ !!baseDraft &&
169
+ JSON.stringify(forStorage(draft)) !== JSON.stringify(forStorage(baseDraft))
170
+
171
+ const edit = (d: Draft) => {
172
+ setDraft(d)
173
+ if (saved) return
174
+ try {
175
+ const k = storeKey(key, sel)
176
+ if (baseDraft && JSON.stringify(forStorage(d)) === JSON.stringify(forStorage(baseDraft)))
177
+ localStorage.removeItem(k)
178
+ else localStorage.setItem(k, JSON.stringify(forStorage(d)))
179
+ } catch {
180
+ /* optional */
181
+ }
182
+ }
183
+
184
+ const sections = useMemo(() => {
185
+ const root = reqSchema ? unwrap(reqSchema, defs) : undefined
186
+ const out: Record<'path' | 'query' | 'headers', Documented[]> = {
187
+ path: [],
188
+ query: [],
189
+ headers: [],
190
+ }
191
+ for (const s of ['path', 'query', 'headers'] as const) {
192
+ const sec = root?.properties?.[s]
193
+ if (!sec) continue
194
+ out[s] = propsOf(sec, defs).map((p) => ({
195
+ name: p.name,
196
+ doc: withNote(fieldDoc(reqSchema, defs, [p.name], [s]), opNotes.notes, s, p.name),
197
+ repeat: unwrap(p.schema, defs).type === 'array',
198
+ }))
199
+ }
200
+ return out
201
+ }, [reqSchema, defs, opNotes.notes])
202
+
203
+ const placeholders = useMemo(() => {
204
+ if (!draft) return []
205
+ try {
206
+ const parts = requestParts(draft, encoding)
207
+ return [...collectPlaceholders([parts.path, parts.query, parts.headers, parts.data])]
208
+ .filter(
209
+ (n) => !n.startsWith('faker.') && !n.startsWith('secrets.') && !n.startsWith('session.'),
210
+ )
211
+ .map((n) => n.split('.')[0] as string)
212
+ .filter((n, i, a) => a.indexOf(n) === i)
213
+ } catch {
214
+ return []
215
+ }
216
+ }, [draft, encoding])
217
+
218
+ // placeholders nothing supplies: not in config or the environment's files. They stay listed while
219
+ // you type, so the field you are filling does not disappear under you.
220
+ const needed = known ? placeholders.filter((n) => !known.includes(n)) : []
221
+ const unfilled = needed.filter((n) => (vars[n] ?? '') === '')
222
+
223
+ const play = async (forceStatus?: number) => {
224
+ if (!draft) return
225
+ setError(undefined)
226
+ setRunning(true)
227
+ try {
228
+ const def = saved
229
+ ? saved.def
230
+ : await engine.operationRequest(key, version.value, requestParts(draft, encoding))
231
+ setResult(
232
+ await engine.run(def, {
233
+ env: env.value,
234
+ version: version.value,
235
+ mockStatus: env.value === 'mock' ? (forceStatus ?? forced) : undefined,
236
+ locale: locale.value,
237
+ seed: seed.value,
238
+ vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => v !== '')),
239
+ fileDir: base?.dir,
240
+ }),
241
+ )
242
+ } catch (e) {
243
+ setError(e instanceof Error ? e.message : String(e))
244
+ } finally {
245
+ setRunning(false)
246
+ }
247
+ }
248
+
249
+ const saveExample = async (name: string) => {
250
+ if (!draft) return
251
+ const parts = requestParts(draft, encoding)
252
+ const file = slug(name)
253
+ const doc: Example = {
254
+ name: file,
255
+ title: name,
256
+ source: 'project',
257
+ path: parts.path,
258
+ query: parts.query,
259
+ headers: parts.headers,
260
+ body: typeof parts.data === 'object' && parts.data instanceof Blob ? undefined : parts.data,
261
+ }
262
+ const path = `${registry.project.requestsDir}/${service}/${op.id}/${file}.example.json`
263
+ // set before writing: the watcher reloads the page as soon as the file lands
264
+ const saved: SavedExample = { key, path, name: file }
265
+ try {
266
+ sessionStorage.setItem(SAVED_KEY, JSON.stringify(saved))
267
+ } catch {
268
+ /* the confirmation below still shows when there is no reload */
269
+ }
270
+ await saveFile(path, `${JSON.stringify(doc, null, 2)}\n`)
271
+ setNotice(saved)
272
+ setNaming(undefined)
273
+ }
274
+
275
+ const step = result?.steps.find((s) => s.response) ?? result?.steps[0]
276
+ const missing = error?.match(/Missing variable "\{\{([^}.]+)/)?.[1]
277
+ const hasParams =
278
+ !!draft &&
279
+ (draft.path.length > 0 ||
280
+ draft.query.length > 0 ||
281
+ sections.path.length > 0 ||
282
+ sections.query.length > 0)
283
+ const hasHeaders = !!draft && (draft.headers.length > 0 || sections.headers.length > 0)
284
+ const hasBody =
285
+ !!draft &&
286
+ encoding !== 'none' &&
287
+ (draft.body !== '' || draft.fields.length > 0 || !!reqSchema?.properties?.data)
288
+ const active =
289
+ tab ?? (hasParams ? 'params' : hasBody ? 'body' : hasHeaders ? 'headers' : 'params')
290
+ const count = (rows: { enabled: boolean }[]) => rows.filter((r) => r.enabled).length
291
+ const nQuery = draft ? count(draft.query) : 0
292
+ const nPath = draft ? count(draft.path) : 0
293
+ const url = draft ? previewUrl(op.path, draft.path, draft.query, baseUrl) : op.path
294
+
295
+ useEffect(() => {
296
+ const send = (e: KeyboardEvent) => {
297
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
298
+ e.preventDefault()
299
+ play()
300
+ }
301
+ }
302
+ addEventListener('keydown', send)
303
+ return () => removeEventListener('keydown', send)
304
+ })
305
+
306
+ return (
307
+ <div class="request-panel" data-op={key}>
308
+ <div class="rq">
309
+ <div class="rq-bar">
310
+ <span class={`method ${op.method}`}>{op.method}</span>
311
+ <div class="rq-url" title={url}>
312
+ <code>
313
+ {url
314
+ .split(/(\{\{[^}]*\}\}|\{[^}]+\})/)
315
+ .map((part) => (/^\{/.test(part) ? <mark>{part}</mark> : part))}
316
+ </code>
317
+ </div>
318
+ {(step?.response || error || step?.error) && (
319
+ <button
320
+ type="button"
321
+ class={`result-chip ${step?.response ? statusClass(step.response.status) : 's5'}`}
322
+ title="Open the response"
323
+ onClick={() => setTab('response')}
324
+ >
325
+ {step?.response
326
+ ? `${step.response.status} ${statusName(step.response.status)} · ${step.response.durationMs} ms`
327
+ : 'Failed'}
328
+ </button>
329
+ )}
330
+ <button
331
+ type="button"
332
+ class="btn primary send"
333
+ title="Send (⌘/Ctrl + Enter)"
334
+ aria-label="Send"
335
+ disabled={running || !draft}
336
+ onClick={() => play()}
337
+ >
338
+ {running ? '…' : <PaperPlane />}
339
+ </button>
340
+ </div>
341
+
342
+ {requestNote && (
343
+ <details class="step-about" key={saved?.file}>
344
+ <summary>About this request</summary>
345
+ <div class="doc-custom" dangerouslySetInnerHTML={{ __html: requestNote }} />
346
+ </details>
347
+ )}
348
+
349
+ {needed.length > 0 && (
350
+ <fieldset
351
+ class={`needs-vars ${unfilled.length === 0 ? 'ok' : ''}`}
352
+ aria-label="Values this request needs"
353
+ >
354
+ <span class="muted">{unfilled.length > 0 ? 'Needs a value for' : 'Values for'}</span>
355
+ {needed.map((n) => (
356
+ <label class="needs-var">
357
+ <code>{n}</code>
358
+ <input
359
+ type="text"
360
+ aria-label={n}
361
+ placeholder="value"
362
+ value={vars[n] ?? ''}
363
+ onInput={(e) => setVars({ ...vars, [n]: (e.target as HTMLInputElement).value })}
364
+ />
365
+ </label>
366
+ ))}
367
+ </fieldset>
368
+ )}
369
+
370
+ {secured && <SecretsBar services={[service]} />}
371
+
372
+ <div class="rq-meta">
373
+ <div class="rq-example">
374
+ <label class="row">
375
+ <span class="muted">Example</span>
376
+ <select value={sel} onChange={(e) => setSel((e.target as HTMLSelectElement).value)}>
377
+ {examples.map((e) => (
378
+ <option value={`ex:${e.name}`}>
379
+ {e.name}
380
+ {e.source === 'project' ? ' · yours' : ''}
381
+ </option>
382
+ ))}
383
+ {variants.map((v) => (
384
+ <option value={`saved:${v.file}`}>{v.name} · saved request</option>
385
+ ))}
386
+ </select>
387
+ </label>
388
+ <EnvBadge />
389
+ </div>
390
+ {modified && <span class="edited">Edited</span>}
391
+ <span class="spacer" />
392
+ {modified && (
393
+ <button type="button" class="link" onClick={() => baseDraft && edit(baseDraft)}>
394
+ ↺ Reset
395
+ </button>
396
+ )}
397
+ {!isStatic && !saved && modified && naming === undefined && (
398
+ <button type="button" class="link" onClick={() => setNaming('')}>
399
+ Save as example…
400
+ </button>
401
+ )}
402
+ {env.value === 'mock' && op.statuses.length > 0 && (
403
+ <label class="rq-status">
404
+ <span class="muted">Respond with</span>
405
+ <select
406
+ aria-label="Mock response"
407
+ value={forced === undefined ? '' : String(forced)}
408
+ onChange={(e) => {
409
+ const v = (e.target as HTMLSelectElement).value
410
+ const s = v === '' ? undefined : Number(v)
411
+ setForced(s)
412
+ play(s)
413
+ }}
414
+ >
415
+ <option value="">Default</option>
416
+ {op.statuses.map((s) => (
417
+ <option value={String(s)}>
418
+ {s} {statusName(s)}
419
+ </option>
420
+ ))}
421
+ </select>
422
+ </label>
423
+ )}
424
+ </div>
425
+
426
+ {notice && (
427
+ <p class="saved-note" role="status">
428
+ <span>
429
+ ✓ Saved to <code>{notice.path}</code>. It is a normal project file: commit it to share
430
+ this example with your team.
431
+ </span>
432
+ <button
433
+ type="button"
434
+ class="icon"
435
+ aria-label="Dismiss"
436
+ onClick={() => setNotice(undefined)}
437
+ >
438
+ ×
439
+ </button>
440
+ </p>
441
+ )}
442
+
443
+ {naming !== undefined && (
444
+ <form
445
+ class="row naming"
446
+ onSubmit={(e) => {
447
+ e.preventDefault()
448
+ if (naming.trim()) saveExample(naming.trim()).catch((err) => setError(String(err)))
449
+ }}
450
+ >
451
+ <input
452
+ autofocus
453
+ placeholder="Name this example"
454
+ aria-label="Example name"
455
+ value={naming}
456
+ onInput={(e) => setNaming((e.target as HTMLInputElement).value)}
457
+ />
458
+ <button type="submit" class="btn">
459
+ Save
460
+ </button>
461
+ <button type="button" class="link" onClick={() => setNaming(undefined)}>
462
+ Cancel
463
+ </button>
464
+ </form>
465
+ )}
466
+
467
+ {saved && (
468
+ <p class="muted lock">
469
+ A saved request: its own values, checks and mock responses apply, and it is read-only
470
+ here.
471
+ </p>
472
+ )}
473
+
474
+ <div class="rq-tabs" role="tablist">
475
+ {(
476
+ [
477
+ ['params', 'Params', nQuery + nPath],
478
+ ['headers', 'Headers', draft ? count(draft.headers) : 0],
479
+ ['body', 'Body', 0],
480
+ ['vars', 'Variables', placeholders.length],
481
+ ] as const
482
+ )
483
+ .filter(([id]) =>
484
+ id === 'params'
485
+ ? hasParams
486
+ : id === 'headers'
487
+ ? hasHeaders
488
+ : id === 'body'
489
+ ? hasBody
490
+ : placeholders.length > 0,
491
+ )
492
+ .map(([id, label, n]) => (
493
+ <button
494
+ type="button"
495
+ role="tab"
496
+ aria-selected={active === id}
497
+ class={`rq-tab ${active === id ? 'on' : ''}`}
498
+ onClick={() => setTab(id)}
499
+ >
500
+ {label}
501
+ {n > 0 && <span class="count">{n}</span>}
502
+ {id === 'body' && <span class="chip">{encoding}</span>}
503
+ </button>
504
+ ))}
505
+ <span class="tab-gap" />
506
+ <button
507
+ type="button"
508
+ role="tab"
509
+ aria-selected={active === 'request'}
510
+ class={`rq-tab ${active === 'request' ? 'on' : ''}`}
511
+ onClick={() => setTab('request')}
512
+ >
513
+ Request
514
+ </button>
515
+ <button
516
+ type="button"
517
+ role="tab"
518
+ aria-selected={active === 'response'}
519
+ class={`rq-tab ${active === 'response' ? 'on' : ''}`}
520
+ onClick={() => setTab('response')}
521
+ >
522
+ Response
523
+ {step?.response && (
524
+ <span class={`state-dot ${statusClass(step.response.status)}`}>
525
+ {step.response.status}
526
+ </span>
527
+ )}
528
+ {!step?.response && (error || step?.error) && <span class="state-dot s5">!</span>}
529
+ </button>
530
+ </div>
531
+
532
+ <div class="rq-pane">
533
+ {draft && (
534
+ <>
535
+ <div class="tab-pane" hidden={active !== 'params'}>
536
+ {(draft.query.length > 0 || sections.query.length > 0) && (
537
+ <ParamGrid
538
+ title="Query"
539
+ rows={draft.query}
540
+ documented={sections.query}
541
+ onChange={(query) => edit({ ...draft, query })}
542
+ />
543
+ )}
544
+ {(draft.path.length > 0 || sections.path.length > 0) && (
545
+ <ParamGrid
546
+ title="Path"
547
+ rows={draft.path}
548
+ documented={sections.path}
549
+ fixed
550
+ onChange={(path) => edit({ ...draft, path })}
551
+ />
552
+ )}
553
+ </div>
554
+ <div class="tab-pane" hidden={active !== 'headers'}>
555
+ {hasHeaders && (
556
+ <ParamGrid
557
+ title="Headers"
558
+ rows={draft.headers}
559
+ documented={sections.headers}
560
+ onChange={(headers) => edit({ ...draft, headers })}
561
+ />
562
+ )}
563
+ </div>
564
+ <div class="tab-pane" hidden={active !== 'body'}>
565
+ {hasBody && (
566
+ <Body
567
+ encoding={encoding}
568
+ draft={draft}
569
+ onChange={edit}
570
+ schema={reqSchema}
571
+ defs={defs}
572
+ files={meta?.files}
573
+ notes={opNotes.notes}
574
+ />
575
+ )}
576
+ </div>
577
+ <div class="tab-pane" hidden={active !== 'vars'}>
578
+ {placeholders.length > 0 && (
579
+ <ParamGrid
580
+ title="Variables"
581
+ rows={placeholders.map((n) => ({
582
+ key: n,
583
+ value: vars[n] ?? '',
584
+ enabled: true,
585
+ }))}
586
+ fixed
587
+ bad={missing}
588
+ placeholder="from the request's init, config or earlier steps"
589
+ onChange={(rows) =>
590
+ setVars(Object.fromEntries(rows.map((r) => [r.key, r.value])))
591
+ }
592
+ />
593
+ )}
594
+ </div>
595
+ </>
596
+ )}
597
+ <div class="tab-pane" hidden={active !== 'request'}>
598
+ {draft && (
599
+ <DraftCodeSample
600
+ service={service}
601
+ method={op.method}
602
+ path={op.path}
603
+ draft={draft}
604
+ encoding={encoding}
605
+ vars={vars}
606
+ secured={secured}
607
+ sent={
608
+ step?.request ? (
609
+ <RequestView
610
+ request={step.request}
611
+ docs={{ schema: reqSchema, defs, notes: opNotes.notes }}
612
+ />
613
+ ) : undefined
614
+ }
615
+ />
616
+ )}
617
+ </div>
618
+ <div class="tab-pane" hidden={active !== 'response'}>
619
+ <ResponseView step={step} error={error} schemas={respSchemas} defs={defs} />
620
+ </div>
621
+ </div>
622
+ </div>
623
+ </div>
624
+ )
625
+ }