@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,108 @@
|
|
|
1
|
+
import type { RenderedRequest } from '@specbook/core'
|
|
2
|
+
import { useMemo, useState } from 'preact/hooks'
|
|
3
|
+
import { fieldDoc, withNote } from '../fielddoc'
|
|
4
|
+
import { type Defs, type JsonSchema, linePaths, pathString } from '../schema'
|
|
5
|
+
import { BodyEditor } from './BodyEditor'
|
|
6
|
+
import { FieldDoc } from './FieldDoc'
|
|
7
|
+
|
|
8
|
+
const pretty = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))
|
|
9
|
+
|
|
10
|
+
/** Repeated keys for arrays, as the transport sends them. */
|
|
11
|
+
export function queryString(query: Record<string, string | string[]>) {
|
|
12
|
+
const p = new URLSearchParams()
|
|
13
|
+
for (const [k, v] of Object.entries(query))
|
|
14
|
+
for (const x of Array.isArray(v) ? v : [v]) p.append(k, x)
|
|
15
|
+
const s = p.toString()
|
|
16
|
+
return s ? `?${s}` : ''
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the spec says about the fields: the request schema (`{ path, query, headers, data }`). */
|
|
20
|
+
export interface RequestDocs {
|
|
21
|
+
schema?: JsonSchema
|
|
22
|
+
defs?: Defs
|
|
23
|
+
notes?: Map<string, string>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The body as sent, with the documentation of the field the cursor is on below it. */
|
|
27
|
+
function SentBody({ body, docs }: { body: unknown; docs?: RequestDocs }) {
|
|
28
|
+
const [line, setLine] = useState(0)
|
|
29
|
+
const text = pretty(body)
|
|
30
|
+
const documented = !!docs?.schema && typeof body === 'object' && body !== null
|
|
31
|
+
const paths = useMemo(() => (documented ? linePaths(text) : []), [documented, text])
|
|
32
|
+
const doc = useMemo(() => {
|
|
33
|
+
const p = paths[line]
|
|
34
|
+
return p && docs
|
|
35
|
+
? withNote(fieldDoc(docs.schema, docs.defs, p, ['data']), docs.notes, 'data', pathString(p))
|
|
36
|
+
: undefined
|
|
37
|
+
}, [paths, line, docs])
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<BodyEditor
|
|
41
|
+
label="Body as sent"
|
|
42
|
+
value={text}
|
|
43
|
+
readOnly
|
|
44
|
+
language={typeof body === 'string' ? 'text' : 'json'}
|
|
45
|
+
onLine={setLine}
|
|
46
|
+
/>
|
|
47
|
+
{documented && <FieldDoc doc={doc} />}
|
|
48
|
+
</>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The request exactly as it went out: method, URL, headers and body after placeholders were filled. */
|
|
53
|
+
export function RequestView({
|
|
54
|
+
request,
|
|
55
|
+
hideBar,
|
|
56
|
+
docs,
|
|
57
|
+
}: {
|
|
58
|
+
request?: RenderedRequest
|
|
59
|
+
/** the surrounding view already shows the method and URL */
|
|
60
|
+
hideBar?: boolean
|
|
61
|
+
/** when given, a field's description is shown as the cursor moves through the body */
|
|
62
|
+
docs?: RequestDocs
|
|
63
|
+
}) {
|
|
64
|
+
if (!request)
|
|
65
|
+
return (
|
|
66
|
+
<div class="rs-empty">
|
|
67
|
+
<strong>Nothing sent yet</strong>
|
|
68
|
+
<span class="muted">The request, with every value filled in, appears here after Send.</span>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
const headers = Object.entries(request.headers)
|
|
72
|
+
return (
|
|
73
|
+
<div class="sent-view">
|
|
74
|
+
{!hideBar && (
|
|
75
|
+
<div class="rq-bar">
|
|
76
|
+
<span class={`method ${request.method}`}>{request.method}</span>
|
|
77
|
+
<div class="rq-url" title={request.url}>
|
|
78
|
+
<code>
|
|
79
|
+
{request.url}
|
|
80
|
+
{queryString(request.query)}
|
|
81
|
+
</code>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
<h3>Headers</h3>
|
|
86
|
+
{headers.length === 0 ? (
|
|
87
|
+
<p class="muted">No headers.</p>
|
|
88
|
+
) : (
|
|
89
|
+
<table class="headers">
|
|
90
|
+
<tbody>
|
|
91
|
+
{headers.map(([k, v]) => (
|
|
92
|
+
<tr>
|
|
93
|
+
<th>{k}</th>
|
|
94
|
+
<td>{v}</td>
|
|
95
|
+
</tr>
|
|
96
|
+
))}
|
|
97
|
+
</tbody>
|
|
98
|
+
</table>
|
|
99
|
+
)}
|
|
100
|
+
{request.body !== undefined && (
|
|
101
|
+
<>
|
|
102
|
+
<h3>Body</h3>
|
|
103
|
+
<SentBody body={request.body} docs={docs} />
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { StepResult } from '@specbook/core'
|
|
2
|
+
import { useMemo, useState } from 'preact/hooks'
|
|
3
|
+
import { fieldDoc } from '../fielddoc'
|
|
4
|
+
import { type Defs, type JsonSchema, linePaths, validate } from '../schema'
|
|
5
|
+
import { statusClass, statusName } from '../status'
|
|
6
|
+
import { BodyEditor } from './BodyEditor'
|
|
7
|
+
import { FieldDoc } from './FieldDoc'
|
|
8
|
+
import { PaperPlane } from './icons'
|
|
9
|
+
import { queryString } from './RequestView'
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
step?: StepResult
|
|
13
|
+
error?: string
|
|
14
|
+
schemas?: Record<string, JsonSchema>
|
|
15
|
+
defs?: Defs
|
|
16
|
+
/** the request is shown elsewhere */
|
|
17
|
+
hideRequest?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const pretty = (v: unknown) => JSON.stringify(v, null, 2)
|
|
21
|
+
const size = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`)
|
|
22
|
+
|
|
23
|
+
export function ResponseView({ step, error, schemas, defs, hideRequest }: Props) {
|
|
24
|
+
const [tab, setTab] = useState<'body' | 'headers' | 'checks'>('body')
|
|
25
|
+
const [line, setLine] = useState(0)
|
|
26
|
+
const response = step?.response
|
|
27
|
+
const schema = response ? (schemas?.[String(response.status)] ?? schemas?.default) : undefined
|
|
28
|
+
const issues = useMemo(
|
|
29
|
+
() => (response && schema ? validate(response.body, schema, defs, { reportExtra: true }) : []),
|
|
30
|
+
[response, schema, defs],
|
|
31
|
+
)
|
|
32
|
+
const text =
|
|
33
|
+
response === undefined || response.body === undefined || response.body === ''
|
|
34
|
+
? ''
|
|
35
|
+
: typeof response.body === 'string'
|
|
36
|
+
? response.body
|
|
37
|
+
: pretty(response.body)
|
|
38
|
+
const paths = useMemo(() => linePaths(text), [text])
|
|
39
|
+
const doc = useMemo(() => {
|
|
40
|
+
const p = paths[line]
|
|
41
|
+
return p ? fieldDoc(schema, defs, p) : undefined
|
|
42
|
+
}, [paths, line, schema, defs])
|
|
43
|
+
const checks = step?.assertions ?? []
|
|
44
|
+
const failed = checks.filter((a) => !a.pass).length
|
|
45
|
+
const headers = Object.entries(response?.headers ?? {})
|
|
46
|
+
const empty = !response && !error && !step?.error
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<section class="response" aria-label="Response">
|
|
50
|
+
<div class="rs-head">
|
|
51
|
+
{response && (
|
|
52
|
+
<>
|
|
53
|
+
<span class={`state-badge ${statusClass(response.status)}`}>
|
|
54
|
+
{response.status} {statusName(response.status)}
|
|
55
|
+
</span>
|
|
56
|
+
<span class="rs-stat">{response.durationMs} ms</span>
|
|
57
|
+
<span class="rs-stat">{size(new Blob([text]).size)}</span>
|
|
58
|
+
</>
|
|
59
|
+
)}
|
|
60
|
+
<span class="spacer" />
|
|
61
|
+
{response && (
|
|
62
|
+
<span class={`conf ${schema ? (issues.length ? 'warn' : 'ok') : 'none'}`}>
|
|
63
|
+
{schema
|
|
64
|
+
? issues.length
|
|
65
|
+
? `⚠ ${issues.length} difference${issues.length > 1 ? 's' : ''} from spec`
|
|
66
|
+
: '✓ Matches spec'
|
|
67
|
+
: 'No schema for this response'}
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{step?.request && !hideRequest && (
|
|
73
|
+
<p class="sent">
|
|
74
|
+
<span class={`method ${step.request.method}`}>{step.request.method}</span>{' '}
|
|
75
|
+
<code>
|
|
76
|
+
{step.request.url}
|
|
77
|
+
{queryString(step.request.query)}
|
|
78
|
+
</code>
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
{error && <p class="error-text">{error}</p>}
|
|
82
|
+
{step?.error && <p class="error-text">{step.error}</p>}
|
|
83
|
+
{empty && (
|
|
84
|
+
<div class="rs-empty">
|
|
85
|
+
<strong>No response yet</strong>
|
|
86
|
+
<span class="muted">
|
|
87
|
+
Press{' '}
|
|
88
|
+
<kbd class="icon-key">
|
|
89
|
+
<PaperPlane size={12} />
|
|
90
|
+
</kbd>{' '}
|
|
91
|
+
or <kbd>⌘</kbd> <kbd>↵</kbd>
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{response && (
|
|
97
|
+
<>
|
|
98
|
+
<div class="rq-tabs" role="tablist">
|
|
99
|
+
{(
|
|
100
|
+
[
|
|
101
|
+
['body', 'Body', 0],
|
|
102
|
+
['headers', 'Headers', headers.length],
|
|
103
|
+
['checks', 'Checks', checks.length],
|
|
104
|
+
] as const
|
|
105
|
+
).map(([id, label, n]) => (
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
role="tab"
|
|
109
|
+
aria-selected={tab === id}
|
|
110
|
+
class={`rq-tab ${tab === id ? 'on' : ''}`}
|
|
111
|
+
onClick={() => setTab(id)}
|
|
112
|
+
>
|
|
113
|
+
{label}
|
|
114
|
+
{n > 0 && (
|
|
115
|
+
<span class={`count ${id === 'checks' && failed ? 'bad' : ''}`}>{n}</span>
|
|
116
|
+
)}
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
<div class="rs-pane">
|
|
121
|
+
<div hidden={tab !== 'body'}>
|
|
122
|
+
{text === '' ? (
|
|
123
|
+
<p class="muted">No content.</p>
|
|
124
|
+
) : (
|
|
125
|
+
<BodyEditor
|
|
126
|
+
label="Response body"
|
|
127
|
+
value={text}
|
|
128
|
+
readOnly
|
|
129
|
+
language={typeof response.body === 'string' ? 'text' : 'json'}
|
|
130
|
+
onLine={setLine}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
{text !== '' && <FieldDoc doc={doc} />}
|
|
134
|
+
{issues.length > 0 && (
|
|
135
|
+
<details class="issues">
|
|
136
|
+
<summary>What differs</summary>
|
|
137
|
+
<ul>
|
|
138
|
+
{issues.map((i) => (
|
|
139
|
+
<li>
|
|
140
|
+
<code>{i.path || '(root)'}</code> {i.message}
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
</details>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
<div hidden={tab !== 'headers'}>
|
|
148
|
+
<table class="headers">
|
|
149
|
+
<tbody>
|
|
150
|
+
{headers.map(([k, v]) => (
|
|
151
|
+
<tr>
|
|
152
|
+
<th>{k}</th>
|
|
153
|
+
<td>{v}</td>
|
|
154
|
+
</tr>
|
|
155
|
+
))}
|
|
156
|
+
{headers.length === 0 && (
|
|
157
|
+
<tr>
|
|
158
|
+
<td class="muted">No headers recorded</td>
|
|
159
|
+
</tr>
|
|
160
|
+
)}
|
|
161
|
+
</tbody>
|
|
162
|
+
</table>
|
|
163
|
+
</div>
|
|
164
|
+
<div hidden={tab !== 'checks'}>
|
|
165
|
+
{checks.length === 0 ? (
|
|
166
|
+
<p class="muted">This request has no checks.</p>
|
|
167
|
+
) : (
|
|
168
|
+
<ul class="checks">
|
|
169
|
+
{checks.map((a) => (
|
|
170
|
+
<li class={a.pass ? 'ok' : 'bad'}>
|
|
171
|
+
{a.pass ? '✓' : '✗'} {a.label}
|
|
172
|
+
</li>
|
|
173
|
+
))}
|
|
174
|
+
</ul>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</>
|
|
179
|
+
)}
|
|
180
|
+
</section>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
2
|
+
import { absoluteBase } from '../routing'
|
|
3
|
+
import { engine } from '../runtime'
|
|
4
|
+
import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
|
|
5
|
+
import { env, version } from '../store'
|
|
6
|
+
|
|
7
|
+
interface Need {
|
|
8
|
+
service: string
|
|
9
|
+
origin: string
|
|
10
|
+
title: string
|
|
11
|
+
names: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SOURCE: Record<string, string> = {
|
|
15
|
+
session: 'this session',
|
|
16
|
+
process: 'environment variable',
|
|
17
|
+
environment: 'environment file',
|
|
18
|
+
shared: 'shared file',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Credentials, asked for where they are needed: the request or flow about to run. A value typed here
|
|
23
|
+
* goes into the request, bound to the API's own origin, and stays in this tab. Where the dev server
|
|
24
|
+
* already has one (an environment variable or a secrets file) it is listed as available and only needs
|
|
25
|
+
* a value here to override it; a name nothing supplies is what the bar asks for.
|
|
26
|
+
*/
|
|
27
|
+
export function SecretsBar({
|
|
28
|
+
services,
|
|
29
|
+
enabled = true,
|
|
30
|
+
supplied,
|
|
31
|
+
}: {
|
|
32
|
+
services?: string[]
|
|
33
|
+
/** false hides the bar (the caller has its own way to ask) */
|
|
34
|
+
enabled?: boolean
|
|
35
|
+
/** names the executor already holds, by source; read from the engine unless given */
|
|
36
|
+
supplied?: Record<string, string>
|
|
37
|
+
}) {
|
|
38
|
+
const [needs, setNeeds] = useState<Need[]>([])
|
|
39
|
+
const [asked, setManaged] = useState<Record<string, string>>({})
|
|
40
|
+
const managed = supplied ?? asked
|
|
41
|
+
const [, bump] = useState(0)
|
|
42
|
+
// once nothing is missing the prompt folds to one small chip; a tap opens it again
|
|
43
|
+
const [open, setOpen] = useState(false)
|
|
44
|
+
const live = enabled && env.value !== 'mock'
|
|
45
|
+
const wanted = services?.join('\n') ?? '*'
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!live) {
|
|
49
|
+
setNeeds([])
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
let on = true
|
|
53
|
+
if (!supplied) engine.managedSecrets(env.value).then((m) => on && setManaged(m))
|
|
54
|
+
engine.config(version.value).then((cfg) => {
|
|
55
|
+
if (!on) return
|
|
56
|
+
const out: Need[] = []
|
|
57
|
+
for (const [service, s] of Object.entries(cfg.services ?? {})) {
|
|
58
|
+
if (services && !services.includes(service)) continue
|
|
59
|
+
const profile = s.auth ? cfg.auth?.[s.auth] : undefined
|
|
60
|
+
const base = s.baseUrl?.[env.value]
|
|
61
|
+
const origin = originOf(base ? absoluteBase(base, globalThis.location?.href) : undefined)
|
|
62
|
+
if (!profile || !origin) continue
|
|
63
|
+
const names = [
|
|
64
|
+
...new Set(
|
|
65
|
+
[...JSON.stringify(profile.apply).matchAll(SECRET_PLACEHOLDER)].map(
|
|
66
|
+
(m) => m[1] as string,
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
]
|
|
70
|
+
if (names.length) out.push({ service, origin, title: profile.title ?? s.auth ?? '', names })
|
|
71
|
+
}
|
|
72
|
+
setNeeds(out)
|
|
73
|
+
})
|
|
74
|
+
return () => {
|
|
75
|
+
on = false
|
|
76
|
+
}
|
|
77
|
+
}, [live, env.value, wanted, version.value])
|
|
78
|
+
|
|
79
|
+
if (!live || needs.length === 0) return null
|
|
80
|
+
const have = (n: Need, name: string) =>
|
|
81
|
+
secretStore.get(n.origin, name) !== undefined || managed[name] !== undefined
|
|
82
|
+
const missing = needs.some((n) => n.names.some((x) => !have(n, x)))
|
|
83
|
+
if (!missing && !open)
|
|
84
|
+
return (
|
|
85
|
+
<div class="secrets-folded">
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
class="secrets-chip"
|
|
89
|
+
aria-expanded="false"
|
|
90
|
+
title="Credentials are available. Tap to change or clear the ones set in this tab."
|
|
91
|
+
onClick={() => setOpen(true)}
|
|
92
|
+
>
|
|
93
|
+
<span aria-hidden="true">🔑</span> {needs.map((n) => n.title || n.service).join(' · ')} ✓
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
return (
|
|
98
|
+
<fieldset class={`needs-vars ${missing ? '' : 'ok'}`} aria-label="Credentials for this request">
|
|
99
|
+
{needs.map((n) => (
|
|
100
|
+
<>
|
|
101
|
+
<span class="muted" title={`Sent from this browser to ${n.origin}`}>
|
|
102
|
+
{n.service} · {n.title}
|
|
103
|
+
</span>
|
|
104
|
+
{n.names.map((name) => {
|
|
105
|
+
const has = secretStore.get(n.origin, name) !== undefined
|
|
106
|
+
const from = managed[name]
|
|
107
|
+
return (
|
|
108
|
+
<span class="needs-var">
|
|
109
|
+
<code>{name}</code>
|
|
110
|
+
{has ? (
|
|
111
|
+
<>
|
|
112
|
+
<span class="muted">✓ set</span>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
class="link"
|
|
116
|
+
onClick={() => {
|
|
117
|
+
secretStore.remove(n.origin, name)
|
|
118
|
+
bump((x) => x + 1)
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
Clear
|
|
122
|
+
</button>
|
|
123
|
+
</>
|
|
124
|
+
) : (
|
|
125
|
+
<>
|
|
126
|
+
{from && <span class="muted">from {SOURCE[from] ?? from}</span>}
|
|
127
|
+
<input
|
|
128
|
+
type="password"
|
|
129
|
+
autocomplete="off"
|
|
130
|
+
aria-label={`${name} for ${n.service}`}
|
|
131
|
+
placeholder={from ? 'use another value' : 'paste value'}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
const v = (e.target as HTMLInputElement).value
|
|
134
|
+
if (v === '') return
|
|
135
|
+
secretStore.set(n.origin, name, v)
|
|
136
|
+
bump((x) => x + 1)
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</span>
|
|
142
|
+
)
|
|
143
|
+
})}
|
|
144
|
+
</>
|
|
145
|
+
))}
|
|
146
|
+
<span class="muted small secrets-note">
|
|
147
|
+
Kept in this tab only; sent from your browser to the API.
|
|
148
|
+
</span>
|
|
149
|
+
{!missing && (
|
|
150
|
+
<button type="button" class="link" onClick={() => setOpen(false)}>
|
|
151
|
+
Hide
|
|
152
|
+
</button>
|
|
153
|
+
)}
|
|
154
|
+
</fieldset>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useMemo } from 'preact/hooks'
|
|
2
|
+
import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
|
|
3
|
+
import type { SequenceInfo } from '../engine'
|
|
4
|
+
import { go } from '../nav'
|
|
5
|
+
import { useSequenceNotes } from './stepnotes'
|
|
6
|
+
|
|
7
|
+
const KIND: Record<string, string> = {
|
|
8
|
+
step: 'request',
|
|
9
|
+
each: 'for each item',
|
|
10
|
+
input: 'you choose',
|
|
11
|
+
check: 'check',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const show = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v))
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a sequence does, as documentation: its description (markdown), an optional markdown page from
|
|
18
|
+
* `docs/*.md` with `sequence: <id>` frontmatter, the controls it takes and the operation behind each step.
|
|
19
|
+
*/
|
|
20
|
+
export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
21
|
+
const { seq } = info
|
|
22
|
+
const { intro, notes } = useSequenceNotes(info)
|
|
23
|
+
|
|
24
|
+
const params = useMemo(
|
|
25
|
+
() =>
|
|
26
|
+
Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([name, c]) => {
|
|
27
|
+
const spec: FieldSpec = specFromControl(c)
|
|
28
|
+
return { name, spec, initial: defaultFor(spec) }
|
|
29
|
+
}),
|
|
30
|
+
[seq],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<article class="doc" data-seq={seq.meta.id}>
|
|
35
|
+
{(seq.meta.tags?.length ?? 0) > 0 && (
|
|
36
|
+
<p class="doc-tags">
|
|
37
|
+
{seq.meta.tags?.map((t) => (
|
|
38
|
+
<span class="chip">{t}</span>
|
|
39
|
+
))}
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
|
|
43
|
+
{intro && <div class="doc-custom" dangerouslySetInnerHTML={{ __html: intro }} />}
|
|
44
|
+
|
|
45
|
+
{params.length > 0 && (
|
|
46
|
+
<section>
|
|
47
|
+
<h2>Controls</h2>
|
|
48
|
+
<table class="fields">
|
|
49
|
+
<thead>
|
|
50
|
+
<tr>
|
|
51
|
+
<th>Name</th>
|
|
52
|
+
<th>Type</th>
|
|
53
|
+
<th>Default</th>
|
|
54
|
+
<th>Description</th>
|
|
55
|
+
</tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody>
|
|
58
|
+
{params.map((p) => (
|
|
59
|
+
<tr>
|
|
60
|
+
<td>
|
|
61
|
+
<code>{p.name}</code>
|
|
62
|
+
</td>
|
|
63
|
+
<td>
|
|
64
|
+
<span class="chip">{p.spec.kind}</span>
|
|
65
|
+
</td>
|
|
66
|
+
<td>
|
|
67
|
+
<code>{show(p.initial)}</code>
|
|
68
|
+
</td>
|
|
69
|
+
<td>{p.spec.description}</td>
|
|
70
|
+
</tr>
|
|
71
|
+
))}
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</section>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<section>
|
|
78
|
+
<h2>Steps</h2>
|
|
79
|
+
<table class="fields">
|
|
80
|
+
<thead>
|
|
81
|
+
<tr>
|
|
82
|
+
<th>#</th>
|
|
83
|
+
<th>Step</th>
|
|
84
|
+
<th>What it does</th>
|
|
85
|
+
</tr>
|
|
86
|
+
</thead>
|
|
87
|
+
<tbody>
|
|
88
|
+
{seq.nodes.map((n, i) => {
|
|
89
|
+
const def = n.kind === 'step' && typeof n.def !== 'function' ? n.def : undefined
|
|
90
|
+
const op = def?.op
|
|
91
|
+
const note = notes.get(n.name)
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<tr class={note ? 'has-note' : ''}>
|
|
95
|
+
<td>{i + 1}</td>
|
|
96
|
+
<td>{n.name}</td>
|
|
97
|
+
<td>
|
|
98
|
+
{op ? (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
class="link"
|
|
102
|
+
title="Open this operation"
|
|
103
|
+
onClick={() => go({ kind: 'op', key: `${op.service}/${op.id}` })}
|
|
104
|
+
>
|
|
105
|
+
<span class={`method ${op.method}`}>{op.method}</span>{' '}
|
|
106
|
+
<code>{op.path}</code>
|
|
107
|
+
</button>
|
|
108
|
+
) : (
|
|
109
|
+
<span class="muted">{KIND[n.kind] ?? n.kind}</span>
|
|
110
|
+
)}
|
|
111
|
+
</td>
|
|
112
|
+
</tr>
|
|
113
|
+
{note && (
|
|
114
|
+
<tr class="step-note-row">
|
|
115
|
+
<td />
|
|
116
|
+
<td colSpan={2}>
|
|
117
|
+
<div
|
|
118
|
+
class="doc-custom step-note"
|
|
119
|
+
dangerouslySetInnerHTML={{ __html: note }}
|
|
120
|
+
/>
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
)}
|
|
124
|
+
</>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</tbody>
|
|
128
|
+
</table>
|
|
129
|
+
</section>
|
|
130
|
+
</article>
|
|
131
|
+
)
|
|
132
|
+
}
|