@specbook/ui 0.0.0-stage → 0.2.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/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/index-ez9c20v3.js +1 -0
- package/dist-viewer/assets/main-38QGGpNT.js +14 -0
- package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
- package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/worker-ClGb0tE1.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 +61 -3
- package/src/app.tsx +368 -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 +48 -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 +115 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +574 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +24 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +238 -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/OAuthSignIn.tsx +163 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +197 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +627 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +189 -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 +12 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1939 -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 +83 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +289 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +54 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +8 -0
- package/src/viewer/registry.ts +163 -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 +176 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,627 @@
|
|
|
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
|
+
// what you sent a request to see is the response, so show it (the tab stays one click away)
|
|
247
|
+
setTab('response')
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const saveExample = async (name: string) => {
|
|
252
|
+
if (!draft) return
|
|
253
|
+
const parts = requestParts(draft, encoding)
|
|
254
|
+
const file = slug(name)
|
|
255
|
+
const doc: Example = {
|
|
256
|
+
name: file,
|
|
257
|
+
title: name,
|
|
258
|
+
source: 'project',
|
|
259
|
+
path: parts.path,
|
|
260
|
+
query: parts.query,
|
|
261
|
+
headers: parts.headers,
|
|
262
|
+
body: typeof parts.data === 'object' && parts.data instanceof Blob ? undefined : parts.data,
|
|
263
|
+
}
|
|
264
|
+
const path = `${registry.project.requestsDir}/${service}/${op.id}/${file}.example.json`
|
|
265
|
+
// set before writing: the watcher reloads the page as soon as the file lands
|
|
266
|
+
const saved: SavedExample = { key, path, name: file }
|
|
267
|
+
try {
|
|
268
|
+
sessionStorage.setItem(SAVED_KEY, JSON.stringify(saved))
|
|
269
|
+
} catch {
|
|
270
|
+
/* the confirmation below still shows when there is no reload */
|
|
271
|
+
}
|
|
272
|
+
await saveFile(path, `${JSON.stringify(doc, null, 2)}\n`)
|
|
273
|
+
setNotice(saved)
|
|
274
|
+
setNaming(undefined)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const step = result?.steps.find((s) => s.response) ?? result?.steps[0]
|
|
278
|
+
const missing = error?.match(/Missing variable "\{\{([^}.]+)/)?.[1]
|
|
279
|
+
const hasParams =
|
|
280
|
+
!!draft &&
|
|
281
|
+
(draft.path.length > 0 ||
|
|
282
|
+
draft.query.length > 0 ||
|
|
283
|
+
sections.path.length > 0 ||
|
|
284
|
+
sections.query.length > 0)
|
|
285
|
+
const hasHeaders = !!draft && (draft.headers.length > 0 || sections.headers.length > 0)
|
|
286
|
+
const hasBody =
|
|
287
|
+
!!draft &&
|
|
288
|
+
encoding !== 'none' &&
|
|
289
|
+
(draft.body !== '' || draft.fields.length > 0 || !!reqSchema?.properties?.data)
|
|
290
|
+
const active =
|
|
291
|
+
tab ?? (hasParams ? 'params' : hasBody ? 'body' : hasHeaders ? 'headers' : 'params')
|
|
292
|
+
const count = (rows: { enabled: boolean }[]) => rows.filter((r) => r.enabled).length
|
|
293
|
+
const nQuery = draft ? count(draft.query) : 0
|
|
294
|
+
const nPath = draft ? count(draft.path) : 0
|
|
295
|
+
const url = draft ? previewUrl(op.path, draft.path, draft.query, baseUrl) : op.path
|
|
296
|
+
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
const send = (e: KeyboardEvent) => {
|
|
299
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
300
|
+
e.preventDefault()
|
|
301
|
+
play()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
addEventListener('keydown', send)
|
|
305
|
+
return () => removeEventListener('keydown', send)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<div class="request-panel" data-op={key}>
|
|
310
|
+
<div class="rq">
|
|
311
|
+
<div class="rq-bar">
|
|
312
|
+
<span class={`method ${op.method}`}>{op.method}</span>
|
|
313
|
+
<div class="rq-url" title={url}>
|
|
314
|
+
<code>
|
|
315
|
+
{url
|
|
316
|
+
.split(/(\{\{[^}]*\}\}|\{[^}]+\})/)
|
|
317
|
+
.map((part) => (/^\{/.test(part) ? <mark>{part}</mark> : part))}
|
|
318
|
+
</code>
|
|
319
|
+
</div>
|
|
320
|
+
{(step?.response || error || step?.error) && (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
class={`result-chip ${step?.response ? statusClass(step.response.status) : 's5'}`}
|
|
324
|
+
title="Open the response"
|
|
325
|
+
onClick={() => setTab('response')}
|
|
326
|
+
>
|
|
327
|
+
{step?.response
|
|
328
|
+
? `${step.response.status} ${statusName(step.response.status)} · ${step.response.durationMs} ms`
|
|
329
|
+
: 'Failed'}
|
|
330
|
+
</button>
|
|
331
|
+
)}
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
class="btn primary send"
|
|
335
|
+
title="Send (⌘/Ctrl + Enter)"
|
|
336
|
+
aria-label="Send"
|
|
337
|
+
disabled={running || !draft}
|
|
338
|
+
onClick={() => play()}
|
|
339
|
+
>
|
|
340
|
+
{running ? '…' : <PaperPlane />}
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
{requestNote && (
|
|
345
|
+
<details class="step-about" key={saved?.file}>
|
|
346
|
+
<summary>About this request</summary>
|
|
347
|
+
<div class="doc-custom" dangerouslySetInnerHTML={{ __html: requestNote }} />
|
|
348
|
+
</details>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{needed.length > 0 && (
|
|
352
|
+
<fieldset
|
|
353
|
+
class={`needs-vars ${unfilled.length === 0 ? 'ok' : ''}`}
|
|
354
|
+
aria-label="Values this request needs"
|
|
355
|
+
>
|
|
356
|
+
<span class="muted">{unfilled.length > 0 ? 'Needs a value for' : 'Values for'}</span>
|
|
357
|
+
{needed.map((n) => (
|
|
358
|
+
<label class="needs-var">
|
|
359
|
+
<code>{n}</code>
|
|
360
|
+
<input
|
|
361
|
+
type="text"
|
|
362
|
+
aria-label={n}
|
|
363
|
+
placeholder="value"
|
|
364
|
+
value={vars[n] ?? ''}
|
|
365
|
+
onInput={(e) => setVars({ ...vars, [n]: (e.target as HTMLInputElement).value })}
|
|
366
|
+
/>
|
|
367
|
+
</label>
|
|
368
|
+
))}
|
|
369
|
+
</fieldset>
|
|
370
|
+
)}
|
|
371
|
+
|
|
372
|
+
{secured && <SecretsBar services={[service]} />}
|
|
373
|
+
|
|
374
|
+
<div class="rq-meta">
|
|
375
|
+
<div class="rq-example">
|
|
376
|
+
<label class="row">
|
|
377
|
+
<span class="muted">Example</span>
|
|
378
|
+
<select value={sel} onChange={(e) => setSel((e.target as HTMLSelectElement).value)}>
|
|
379
|
+
{examples.map((e) => (
|
|
380
|
+
<option value={`ex:${e.name}`}>
|
|
381
|
+
{e.name}
|
|
382
|
+
{e.source === 'project' ? ' · yours' : ''}
|
|
383
|
+
</option>
|
|
384
|
+
))}
|
|
385
|
+
{variants.map((v) => (
|
|
386
|
+
<option value={`saved:${v.file}`}>{v.name} · saved request</option>
|
|
387
|
+
))}
|
|
388
|
+
</select>
|
|
389
|
+
</label>
|
|
390
|
+
<EnvBadge />
|
|
391
|
+
</div>
|
|
392
|
+
{modified && <span class="edited">Edited</span>}
|
|
393
|
+
<span class="spacer" />
|
|
394
|
+
{modified && (
|
|
395
|
+
<button type="button" class="link" onClick={() => baseDraft && edit(baseDraft)}>
|
|
396
|
+
↺ Reset
|
|
397
|
+
</button>
|
|
398
|
+
)}
|
|
399
|
+
{!isStatic && !saved && modified && naming === undefined && (
|
|
400
|
+
<button type="button" class="link" onClick={() => setNaming('')}>
|
|
401
|
+
Save as example…
|
|
402
|
+
</button>
|
|
403
|
+
)}
|
|
404
|
+
{env.value === 'mock' && op.statuses.length > 0 && (
|
|
405
|
+
<label class="rq-status">
|
|
406
|
+
<span class="muted">Respond with</span>
|
|
407
|
+
<select
|
|
408
|
+
aria-label="Mock response"
|
|
409
|
+
value={forced === undefined ? '' : String(forced)}
|
|
410
|
+
onChange={(e) => {
|
|
411
|
+
const v = (e.target as HTMLSelectElement).value
|
|
412
|
+
const s = v === '' ? undefined : Number(v)
|
|
413
|
+
setForced(s)
|
|
414
|
+
play(s)
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
<option value="">Default</option>
|
|
418
|
+
{op.statuses.map((s) => (
|
|
419
|
+
<option value={String(s)}>
|
|
420
|
+
{s} {statusName(s)}
|
|
421
|
+
</option>
|
|
422
|
+
))}
|
|
423
|
+
</select>
|
|
424
|
+
</label>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
{notice && (
|
|
429
|
+
<p class="saved-note" role="status">
|
|
430
|
+
<span>
|
|
431
|
+
✓ Saved to <code>{notice.path}</code>. It is a normal project file: commit it to share
|
|
432
|
+
this example with your team.
|
|
433
|
+
</span>
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
class="icon"
|
|
437
|
+
aria-label="Dismiss"
|
|
438
|
+
onClick={() => setNotice(undefined)}
|
|
439
|
+
>
|
|
440
|
+
×
|
|
441
|
+
</button>
|
|
442
|
+
</p>
|
|
443
|
+
)}
|
|
444
|
+
|
|
445
|
+
{naming !== undefined && (
|
|
446
|
+
<form
|
|
447
|
+
class="row naming"
|
|
448
|
+
onSubmit={(e) => {
|
|
449
|
+
e.preventDefault()
|
|
450
|
+
if (naming.trim()) saveExample(naming.trim()).catch((err) => setError(String(err)))
|
|
451
|
+
}}
|
|
452
|
+
>
|
|
453
|
+
<input
|
|
454
|
+
autofocus
|
|
455
|
+
placeholder="Name this example"
|
|
456
|
+
aria-label="Example name"
|
|
457
|
+
value={naming}
|
|
458
|
+
onInput={(e) => setNaming((e.target as HTMLInputElement).value)}
|
|
459
|
+
/>
|
|
460
|
+
<button type="submit" class="btn">
|
|
461
|
+
Save
|
|
462
|
+
</button>
|
|
463
|
+
<button type="button" class="link" onClick={() => setNaming(undefined)}>
|
|
464
|
+
Cancel
|
|
465
|
+
</button>
|
|
466
|
+
</form>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{saved && (
|
|
470
|
+
<p class="muted lock">
|
|
471
|
+
A saved request: its own values, checks and mock responses apply, and it is read-only
|
|
472
|
+
here.
|
|
473
|
+
</p>
|
|
474
|
+
)}
|
|
475
|
+
|
|
476
|
+
<div class="rq-tabs" role="tablist">
|
|
477
|
+
{(
|
|
478
|
+
[
|
|
479
|
+
['params', 'Params', nQuery + nPath],
|
|
480
|
+
['headers', 'Headers', draft ? count(draft.headers) : 0],
|
|
481
|
+
['body', 'Body', 0],
|
|
482
|
+
['vars', 'Variables', placeholders.length],
|
|
483
|
+
] as const
|
|
484
|
+
)
|
|
485
|
+
.filter(([id]) =>
|
|
486
|
+
id === 'params'
|
|
487
|
+
? hasParams
|
|
488
|
+
: id === 'headers'
|
|
489
|
+
? hasHeaders
|
|
490
|
+
: id === 'body'
|
|
491
|
+
? hasBody
|
|
492
|
+
: placeholders.length > 0,
|
|
493
|
+
)
|
|
494
|
+
.map(([id, label, n]) => (
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
role="tab"
|
|
498
|
+
aria-selected={active === id}
|
|
499
|
+
class={`rq-tab ${active === id ? 'on' : ''}`}
|
|
500
|
+
onClick={() => setTab(id)}
|
|
501
|
+
>
|
|
502
|
+
{label}
|
|
503
|
+
{n > 0 && <span class="count">{n}</span>}
|
|
504
|
+
{id === 'body' && <span class="chip">{encoding}</span>}
|
|
505
|
+
</button>
|
|
506
|
+
))}
|
|
507
|
+
<span class="tab-gap" />
|
|
508
|
+
<button
|
|
509
|
+
type="button"
|
|
510
|
+
role="tab"
|
|
511
|
+
aria-selected={active === 'request'}
|
|
512
|
+
class={`rq-tab ${active === 'request' ? 'on' : ''}`}
|
|
513
|
+
onClick={() => setTab('request')}
|
|
514
|
+
>
|
|
515
|
+
Request
|
|
516
|
+
</button>
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
role="tab"
|
|
520
|
+
aria-selected={active === 'response'}
|
|
521
|
+
class={`rq-tab ${active === 'response' ? 'on' : ''}`}
|
|
522
|
+
onClick={() => setTab('response')}
|
|
523
|
+
>
|
|
524
|
+
Response
|
|
525
|
+
{step?.response && (
|
|
526
|
+
<span class={`state-dot ${statusClass(step.response.status)}`}>
|
|
527
|
+
{step.response.status}
|
|
528
|
+
</span>
|
|
529
|
+
)}
|
|
530
|
+
{!step?.response && (error || step?.error) && <span class="state-dot s5">!</span>}
|
|
531
|
+
</button>
|
|
532
|
+
</div>
|
|
533
|
+
|
|
534
|
+
<div class="rq-pane">
|
|
535
|
+
{draft && (
|
|
536
|
+
<>
|
|
537
|
+
<div class="tab-pane" hidden={active !== 'params'}>
|
|
538
|
+
{(draft.query.length > 0 || sections.query.length > 0) && (
|
|
539
|
+
<ParamGrid
|
|
540
|
+
title="Query"
|
|
541
|
+
rows={draft.query}
|
|
542
|
+
documented={sections.query}
|
|
543
|
+
onChange={(query) => edit({ ...draft, query })}
|
|
544
|
+
/>
|
|
545
|
+
)}
|
|
546
|
+
{(draft.path.length > 0 || sections.path.length > 0) && (
|
|
547
|
+
<ParamGrid
|
|
548
|
+
title="Path"
|
|
549
|
+
rows={draft.path}
|
|
550
|
+
documented={sections.path}
|
|
551
|
+
fixed
|
|
552
|
+
onChange={(path) => edit({ ...draft, path })}
|
|
553
|
+
/>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
<div class="tab-pane" hidden={active !== 'headers'}>
|
|
557
|
+
{hasHeaders && (
|
|
558
|
+
<ParamGrid
|
|
559
|
+
title="Headers"
|
|
560
|
+
rows={draft.headers}
|
|
561
|
+
documented={sections.headers}
|
|
562
|
+
onChange={(headers) => edit({ ...draft, headers })}
|
|
563
|
+
/>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
<div class="tab-pane" hidden={active !== 'body'}>
|
|
567
|
+
{hasBody && (
|
|
568
|
+
<Body
|
|
569
|
+
encoding={encoding}
|
|
570
|
+
draft={draft}
|
|
571
|
+
onChange={edit}
|
|
572
|
+
schema={reqSchema}
|
|
573
|
+
defs={defs}
|
|
574
|
+
files={meta?.files}
|
|
575
|
+
notes={opNotes.notes}
|
|
576
|
+
/>
|
|
577
|
+
)}
|
|
578
|
+
</div>
|
|
579
|
+
<div class="tab-pane" hidden={active !== 'vars'}>
|
|
580
|
+
{placeholders.length > 0 && (
|
|
581
|
+
<ParamGrid
|
|
582
|
+
title="Variables"
|
|
583
|
+
rows={placeholders.map((n) => ({
|
|
584
|
+
key: n,
|
|
585
|
+
value: vars[n] ?? '',
|
|
586
|
+
enabled: true,
|
|
587
|
+
}))}
|
|
588
|
+
fixed
|
|
589
|
+
bad={missing}
|
|
590
|
+
placeholder="from the request's init, config or earlier steps"
|
|
591
|
+
onChange={(rows) =>
|
|
592
|
+
setVars(Object.fromEntries(rows.map((r) => [r.key, r.value])))
|
|
593
|
+
}
|
|
594
|
+
/>
|
|
595
|
+
)}
|
|
596
|
+
</div>
|
|
597
|
+
</>
|
|
598
|
+
)}
|
|
599
|
+
<div class="tab-pane" hidden={active !== 'request'}>
|
|
600
|
+
{draft && (
|
|
601
|
+
<DraftCodeSample
|
|
602
|
+
service={service}
|
|
603
|
+
method={op.method}
|
|
604
|
+
path={op.path}
|
|
605
|
+
draft={draft}
|
|
606
|
+
encoding={encoding}
|
|
607
|
+
vars={vars}
|
|
608
|
+
secured={secured}
|
|
609
|
+
sent={
|
|
610
|
+
step?.request ? (
|
|
611
|
+
<RequestView
|
|
612
|
+
request={step.request}
|
|
613
|
+
docs={{ schema: reqSchema, defs, notes: opNotes.notes }}
|
|
614
|
+
/>
|
|
615
|
+
) : undefined
|
|
616
|
+
}
|
|
617
|
+
/>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
<div class="tab-pane" hidden={active !== 'response'}>
|
|
621
|
+
<ResponseView step={step} error={error} schemas={respSchemas} defs={defs} />
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
)
|
|
627
|
+
}
|