@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,324 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { brand } from '../branding'
|
|
3
|
+
import { catalogFilter, catalogService, type Entry, go, route, search } from '../nav'
|
|
4
|
+
import { Lock } from '../panel/icons'
|
|
5
|
+
import { ModeHint } from './ModeHint'
|
|
6
|
+
import { PoweredBy } from './PoweredBy'
|
|
7
|
+
|
|
8
|
+
export interface ServiceInfo {
|
|
9
|
+
id: string
|
|
10
|
+
title?: string
|
|
11
|
+
version?: string
|
|
12
|
+
ops: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Groups with more operations than this start collapsed unless something is being searched. */
|
|
16
|
+
const OPEN_LIMIT = 60
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FLOW_GROUP = 'Default'
|
|
19
|
+
|
|
20
|
+
function isActive(e: Entry): boolean {
|
|
21
|
+
const r = route.value
|
|
22
|
+
return (
|
|
23
|
+
(e.route.kind === 'op' && r.kind === 'op' && e.route.key === r.key) ||
|
|
24
|
+
(e.route.kind === 'seq' && r.kind === 'seq' && e.route.id === r.id)
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function OpRow({ e }: { e: Entry }) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class={`op-row ${isActive(e) ? 'active' : ''}`}
|
|
33
|
+
onClick={() => go(e.route)}
|
|
34
|
+
title={e.path ? `${e.title}\n${e.path}` : e.title}
|
|
35
|
+
>
|
|
36
|
+
<span class={`method ${e.method}`}>{e.method}</span>
|
|
37
|
+
<span class="op-title">
|
|
38
|
+
{e.title}
|
|
39
|
+
{e.secured && (
|
|
40
|
+
<span class="op-lock" title="Needs credentials">
|
|
41
|
+
<Lock />
|
|
42
|
+
</span>
|
|
43
|
+
)}
|
|
44
|
+
</span>
|
|
45
|
+
<span class="op-path">{e.path}</span>
|
|
46
|
+
</button>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Shared by the list and the overview: which entries the current pill and filter select. */
|
|
51
|
+
function useCatalog(entries: Entry[], services: ServiceInfo[]) {
|
|
52
|
+
const q = catalogFilter.value.trim()
|
|
53
|
+
const single = services.length <= 1
|
|
54
|
+
const svc = single ? (services[0]?.id ?? '') : catalogService.value
|
|
55
|
+
const ops = useMemo(() => entries.filter((e) => e.kind === 'op'), [entries])
|
|
56
|
+
const flows = useMemo(() => entries.filter((e) => e.kind === 'seq'), [entries])
|
|
57
|
+
const shownOps = useMemo(() => {
|
|
58
|
+
const scoped = svc ? ops.filter((e) => e.service === svc) : ops
|
|
59
|
+
return q ? search(scoped, q, 500) : scoped
|
|
60
|
+
}, [ops, svc, q])
|
|
61
|
+
const shownFlows = useMemo(() => {
|
|
62
|
+
const scoped = svc && !single ? flows.filter((e) => e.tags?.includes(svc)) : flows
|
|
63
|
+
return q ? search(scoped, q, 100) : scoped
|
|
64
|
+
}, [flows, svc, q, single])
|
|
65
|
+
return { q, single, svc, shownOps, shownFlows }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The left column: pills, filter and grouped rows. It never changes width, so opening the panel moves nothing. */
|
|
69
|
+
export function CatalogList({ entries, services }: { entries: Entry[]; services: ServiceInfo[] }) {
|
|
70
|
+
const filter = useRef<HTMLInputElement>(null)
|
|
71
|
+
const list = useRef<HTMLDivElement>(null)
|
|
72
|
+
// what the user chose per group; groups they haven't touched follow the default
|
|
73
|
+
const [chosen, setChosen] = useState<Map<string, boolean>>(new Map())
|
|
74
|
+
const { q, single, svc, shownOps, shownFlows } = useCatalog(entries, services)
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const key = (e: KeyboardEvent) => {
|
|
78
|
+
const el = e.target as HTMLElement
|
|
79
|
+
if (e.key === '/' && !/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) {
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
filter.current?.focus()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
addEventListener('keydown', key)
|
|
85
|
+
return () => removeEventListener('keydown', key)
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
// arrow keys walk the rows; going up from the first one returns to the filter
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const el = list.current
|
|
91
|
+
if (!el) return
|
|
92
|
+
const move = (e: KeyboardEvent) => {
|
|
93
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
|
|
94
|
+
const rows = [...el.querySelectorAll<HTMLElement>('.op-row, .flow-row')]
|
|
95
|
+
const at = rows.indexOf(document.activeElement as HTMLElement)
|
|
96
|
+
if (at < 0) return
|
|
97
|
+
const next = e.key === 'ArrowDown' ? at + 1 : at - 1
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
if (next >= 0 && next < rows.length) rows[next]?.focus()
|
|
100
|
+
else if (next < 0) filter.current?.focus()
|
|
101
|
+
}
|
|
102
|
+
el.addEventListener('keydown', move)
|
|
103
|
+
return () => el.removeEventListener('keydown', move)
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
const groups = useMemo(() => {
|
|
107
|
+
const m = new Map<string, Entry[]>()
|
|
108
|
+
for (const e of shownOps) {
|
|
109
|
+
const tag = e.tag ?? 'default'
|
|
110
|
+
const name = svc || single ? tag : `${e.service} · ${tag}`
|
|
111
|
+
m.set(name, [...(m.get(name) ?? []), e])
|
|
112
|
+
}
|
|
113
|
+
return [...m.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
114
|
+
}, [shownOps, svc, single])
|
|
115
|
+
|
|
116
|
+
// "Default" first, then the rest alphabetically
|
|
117
|
+
const flowGroups = useMemo(() => {
|
|
118
|
+
const m = new Map<string, Entry[]>()
|
|
119
|
+
for (const e of shownFlows) {
|
|
120
|
+
const name = e.flowGroup?.trim() || DEFAULT_FLOW_GROUP
|
|
121
|
+
m.set(name, [...(m.get(name) ?? []), e])
|
|
122
|
+
}
|
|
123
|
+
const rank = (n: string) => (n === DEFAULT_FLOW_GROUP ? 0 : 1)
|
|
124
|
+
return [...m.entries()].sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
|
125
|
+
}, [shownFlows])
|
|
126
|
+
|
|
127
|
+
const crowded = shownOps.length > OPEN_LIMIT && !q
|
|
128
|
+
const isOpen = (name: string, rows: Entry[]) =>
|
|
129
|
+
chosen.get(name) ?? (!crowded || rows.some(isActive))
|
|
130
|
+
const toggle = (name: string, rows: Entry[]) => {
|
|
131
|
+
const n = new Map(chosen)
|
|
132
|
+
n.set(name, !isOpen(name, rows))
|
|
133
|
+
setChosen(n)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div class="catalog" ref={list}>
|
|
138
|
+
<div class="cat-top">
|
|
139
|
+
{!single && (
|
|
140
|
+
<div class="pills" role="tablist" aria-label="APIs">
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
role="tab"
|
|
144
|
+
aria-selected={svc === ''}
|
|
145
|
+
class={`pill ${svc === '' ? 'on' : ''}`}
|
|
146
|
+
onClick={() => (catalogService.value = '')}
|
|
147
|
+
>
|
|
148
|
+
All
|
|
149
|
+
</button>
|
|
150
|
+
{services.map((s) => (
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
role="tab"
|
|
154
|
+
aria-selected={svc === s.id}
|
|
155
|
+
class={`pill ${svc === s.id ? 'on' : ''}`}
|
|
156
|
+
onClick={() => (catalogService.value = s.id)}
|
|
157
|
+
>
|
|
158
|
+
{s.id} <span class="count">{s.ops}</span>
|
|
159
|
+
</button>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
<div class="cat-filter-wrap">
|
|
164
|
+
<input
|
|
165
|
+
ref={filter}
|
|
166
|
+
type="text"
|
|
167
|
+
class="cat-filter"
|
|
168
|
+
placeholder={svc ? `Filter ${svc}` : 'Filter operations and flows'}
|
|
169
|
+
aria-label="Filter"
|
|
170
|
+
value={catalogFilter.value}
|
|
171
|
+
onInput={(e) => (catalogFilter.value = (e.target as HTMLInputElement).value)}
|
|
172
|
+
onKeyDown={(e) => {
|
|
173
|
+
if (e.key === 'ArrowDown') {
|
|
174
|
+
e.preventDefault()
|
|
175
|
+
list.current?.querySelector<HTMLElement>('.op-row, .flow-row')?.focus()
|
|
176
|
+
}
|
|
177
|
+
if (e.key === 'Escape') catalogFilter.value = ''
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
<kbd class="cat-hint" title="Focus the filter">
|
|
181
|
+
/
|
|
182
|
+
</kbd>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{flowGroups.length > 0 && (
|
|
187
|
+
<section class="flows" aria-label="Flows">
|
|
188
|
+
<div class="flows-title">
|
|
189
|
+
<span class="method flow">FLOW</span>
|
|
190
|
+
<span class="muted small">Multi-step sequences you can run end to end</span>
|
|
191
|
+
<span class="count">{shownFlows.length}</span>
|
|
192
|
+
</div>
|
|
193
|
+
{flowGroups.map(([name, rows]) => {
|
|
194
|
+
const key = `flows:${name}`
|
|
195
|
+
const solo = flowGroups.length === 1
|
|
196
|
+
const expanded = solo || (chosen.get(key) ?? true)
|
|
197
|
+
return (
|
|
198
|
+
<div class="flow-group" key={key}>
|
|
199
|
+
{!solo && (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
class="group-head flow-head"
|
|
203
|
+
aria-expanded={expanded}
|
|
204
|
+
onClick={() => {
|
|
205
|
+
const n = new Map(chosen)
|
|
206
|
+
n.set(key, !expanded)
|
|
207
|
+
setChosen(n)
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
<span class="caret">{expanded ? '▾' : '▸'}</span> {name}{' '}
|
|
211
|
+
<span class="count">{rows.length}</span>
|
|
212
|
+
</button>
|
|
213
|
+
)}
|
|
214
|
+
{expanded &&
|
|
215
|
+
rows.map((e) => (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
class={`op-row flow-row ${isActive(e) ? 'active' : ''}`}
|
|
219
|
+
onClick={() => go(e.route)}
|
|
220
|
+
>
|
|
221
|
+
<span class="flow-play" aria-hidden="true">
|
|
222
|
+
▶
|
|
223
|
+
</span>
|
|
224
|
+
<span class="op-title">{e.title}</span>
|
|
225
|
+
</button>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
})}
|
|
230
|
+
</section>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{groups.map(([name, rows]) => {
|
|
234
|
+
const expanded = isOpen(name, rows)
|
|
235
|
+
return (
|
|
236
|
+
<div class="op-group" key={name}>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
class="group-head"
|
|
240
|
+
aria-expanded={expanded}
|
|
241
|
+
onClick={() => toggle(name, rows)}
|
|
242
|
+
>
|
|
243
|
+
<span class="caret">{expanded ? '▾' : '▸'}</span> {name}{' '}
|
|
244
|
+
<span class="count">{rows.length}</span>
|
|
245
|
+
</button>
|
|
246
|
+
{expanded && rows.map((e) => <OpRow e={e} />)}
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
{groups.length === 0 && flowGroups.length === 0 && (
|
|
251
|
+
<p class="muted cat-empty">
|
|
252
|
+
{q ? `Nothing matches “${q}”.` : 'No operations.'}{' '}
|
|
253
|
+
{q && (
|
|
254
|
+
<button type="button" class="link" onClick={() => (catalogFilter.value = '')}>
|
|
255
|
+
Clear the filter
|
|
256
|
+
</button>
|
|
257
|
+
)}
|
|
258
|
+
</p>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The stage when nothing is open: the API's identity, since the list is the content. */
|
|
265
|
+
export function Overview({ services }: { services: ServiceInfo[] }) {
|
|
266
|
+
const b = brand.value
|
|
267
|
+
// wait for the project's branding: showing the fallback first and swapping it makes the page jump
|
|
268
|
+
if (!b.ready) return <div class="overview" aria-busy="true" />
|
|
269
|
+
const total = services.reduce((n, s) => n + s.ops, 0)
|
|
270
|
+
const single = services.length === 1 ? services[0] : undefined
|
|
271
|
+
const title =
|
|
272
|
+
b.title ??
|
|
273
|
+
b.name ??
|
|
274
|
+
(single
|
|
275
|
+
? (single.title ?? single.id)
|
|
276
|
+
: `${services.length} APIs · ${total.toLocaleString()} operations`)
|
|
277
|
+
const meta = single
|
|
278
|
+
? `${single.ops} ${single.ops === 1 ? 'operation' : 'operations'}${single.version ? ` · v${single.version}` : ''}`
|
|
279
|
+
: b.name || b.title
|
|
280
|
+
? `${services.length} APIs · ${total.toLocaleString()} operations`
|
|
281
|
+
: undefined
|
|
282
|
+
return (
|
|
283
|
+
<div class="overview">
|
|
284
|
+
{b.logo && (
|
|
285
|
+
<img
|
|
286
|
+
class="overview-logo"
|
|
287
|
+
src={b.logo}
|
|
288
|
+
alt=""
|
|
289
|
+
style={
|
|
290
|
+
b.logoBackground
|
|
291
|
+
? { background: b.logoBackground, padding: '10px 16px', borderRadius: '10px' }
|
|
292
|
+
: undefined
|
|
293
|
+
}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
{b.title && b.name && <p class="overview-name">{b.name}</p>}
|
|
297
|
+
<h1>{title}</h1>
|
|
298
|
+
{b.tagline && <p class="tagline">{b.tagline}</p>}
|
|
299
|
+
{meta && <p class="muted">{meta}</p>}
|
|
300
|
+
<p class="overview-hint">Choose an operation or flow on the left to read it and try it.</p>
|
|
301
|
+
<ModeHint />
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
class="btn primary overview-browse"
|
|
305
|
+
onClick={() => go({ kind: 'browse' })}
|
|
306
|
+
>
|
|
307
|
+
Browse operations and flows
|
|
308
|
+
</button>
|
|
309
|
+
{b.links.length > 0 && (
|
|
310
|
+
<p class="overview-links">
|
|
311
|
+
{b.links.map((l) => (
|
|
312
|
+
<a href={l.href} target="_blank" rel="noreferrer">
|
|
313
|
+
{l.label}
|
|
314
|
+
</a>
|
|
315
|
+
))}
|
|
316
|
+
</p>
|
|
317
|
+
)}
|
|
318
|
+
<p class="muted overview-keys">
|
|
319
|
+
<kbd>⌘</kbd> <kbd>K</kbd> jumps anywhere · <kbd>/</kbd> filters the list
|
|
320
|
+
</p>
|
|
321
|
+
<PoweredBy />
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { env, environmentNote, environmentRisk, environments } from '../store'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The environment a request will run in, and a way to change it. On a phone the header with the
|
|
5
|
+
* selector is behind a menu or under a panel, so the chip appears in the header and in each panel: it
|
|
6
|
+
* is a real control (a transparent native select over the label), so a tap opens the platform picker.
|
|
7
|
+
* Anything but `mock` is flagged, and production more strongly: an environment the config marks
|
|
8
|
+
* `risk: 'production'`, or by default one whose name contains "prod".
|
|
9
|
+
*/
|
|
10
|
+
export function EnvBadge() {
|
|
11
|
+
const name = env.value
|
|
12
|
+
const names = environments.value.includes(name)
|
|
13
|
+
? environments.value
|
|
14
|
+
: [name, ...environments.value]
|
|
15
|
+
const risk = environmentRisk.value[name]
|
|
16
|
+
const kind =
|
|
17
|
+
name === 'mock'
|
|
18
|
+
? 'mock'
|
|
19
|
+
: risk === 'production' || (risk !== 'sandbox' && /prod/i.test(name))
|
|
20
|
+
? 'prod'
|
|
21
|
+
: 'live'
|
|
22
|
+
const switchable = names.length > 1
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
class={`env-badge ${kind} ${switchable ? 'switch' : ''}`}
|
|
26
|
+
title={
|
|
27
|
+
name === 'mock'
|
|
28
|
+
? 'Environment: mock (no network)'
|
|
29
|
+
: `Environment: ${name}. Requests go to the real API.`
|
|
30
|
+
}
|
|
31
|
+
>
|
|
32
|
+
<span class="env-name">{name}</span>
|
|
33
|
+
{switchable && (
|
|
34
|
+
<select
|
|
35
|
+
aria-label="Environment"
|
|
36
|
+
value={name}
|
|
37
|
+
onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
|
|
38
|
+
>
|
|
39
|
+
{names.map((n) => (
|
|
40
|
+
<option value={n} title={environmentNote.value[n]}>
|
|
41
|
+
{n}
|
|
42
|
+
</option>
|
|
43
|
+
))}
|
|
44
|
+
</select>
|
|
45
|
+
)}
|
|
46
|
+
</span>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
2
|
+
import { engine, isStatic } from '../runtime'
|
|
3
|
+
import { env, version } from '../store'
|
|
4
|
+
|
|
5
|
+
type Source = 'session' | 'process' | 'environment' | 'shared' | undefined
|
|
6
|
+
type Scope = 'environment' | 'session' | 'shared'
|
|
7
|
+
|
|
8
|
+
interface SecretRow {
|
|
9
|
+
name: string
|
|
10
|
+
key: string
|
|
11
|
+
source: Source
|
|
12
|
+
}
|
|
13
|
+
interface EnvState {
|
|
14
|
+
vars: Record<string, string>
|
|
15
|
+
secrets: SecretRow[]
|
|
16
|
+
warnings: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SOURCE_LABEL: Record<NonNullable<Source>, string> = {
|
|
20
|
+
session: 'this session',
|
|
21
|
+
process: 'environment variable',
|
|
22
|
+
environment: 'environment file',
|
|
23
|
+
shared: 'shared file',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function post(path: string, body: object) {
|
|
27
|
+
const r = await fetch(path, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'content-type': 'application/json' },
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
})
|
|
32
|
+
if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Header control for what the selected environment needs: its variables (`<env>.env`, committed) and
|
|
37
|
+
* secrets (`<env>.secrets.env`, gitignored, or this session only). The badge counts secrets still missing.
|
|
38
|
+
*/
|
|
39
|
+
export function EnvironmentMenu() {
|
|
40
|
+
const [state, setState] = useState<EnvState>()
|
|
41
|
+
const [labels, setLabels] = useState<Record<string, string>>({})
|
|
42
|
+
const [open, setOpen] = useState(false)
|
|
43
|
+
const [scope, setScope] = useState<Scope>('environment')
|
|
44
|
+
const [draft, setDraft] = useState<Record<string, string>>({})
|
|
45
|
+
const [newVar, setNewVar] = useState({ name: '', value: '' })
|
|
46
|
+
const [error, setError] = useState<string>()
|
|
47
|
+
const name = env.value
|
|
48
|
+
|
|
49
|
+
const refresh = () =>
|
|
50
|
+
fetch(`/__specbook/environment?env=${encodeURIComponent(name)}`)
|
|
51
|
+
.then((r) => r.json() as Promise<EnvState>)
|
|
52
|
+
.then(setState, () => setState(undefined))
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (isStatic) return
|
|
56
|
+
refresh()
|
|
57
|
+
setScope(name === 'mock' ? 'session' : 'environment')
|
|
58
|
+
setDraft({})
|
|
59
|
+
}, [name])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
engine.config(version.value).then((cfg) => {
|
|
63
|
+
const out: Record<string, string> = {}
|
|
64
|
+
for (const [profile, p] of Object.entries(cfg.auth ?? {}))
|
|
65
|
+
for (const m of JSON.stringify(p.apply).matchAll(/secrets\.([\w$-]+)/g))
|
|
66
|
+
out[m[1] as string] = p.title ?? profile
|
|
67
|
+
setLabels(out)
|
|
68
|
+
})
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const missing = useMemo(
|
|
72
|
+
() => (name === 'mock' ? 0 : (state?.secrets.filter((s) => !s.source).length ?? 0)),
|
|
73
|
+
[state, name],
|
|
74
|
+
)
|
|
75
|
+
if (isStatic || !state) return null
|
|
76
|
+
const hasAnything =
|
|
77
|
+
state.secrets.length > 0 || Object.keys(state.vars).length > 0 || name !== 'mock'
|
|
78
|
+
if (!hasAnything) return null
|
|
79
|
+
|
|
80
|
+
const run = async (fn: () => Promise<void>) => {
|
|
81
|
+
setError(undefined)
|
|
82
|
+
try {
|
|
83
|
+
await fn()
|
|
84
|
+
await refresh()
|
|
85
|
+
} catch (e) {
|
|
86
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const saveSecret = (secret: string, value: string) =>
|
|
90
|
+
run(async () => {
|
|
91
|
+
await post('/__specbook/secret', {
|
|
92
|
+
name: secret,
|
|
93
|
+
value,
|
|
94
|
+
scope,
|
|
95
|
+
env: scope === 'shared' ? undefined : name,
|
|
96
|
+
})
|
|
97
|
+
setDraft((d) => ({ ...d, [secret]: '' }))
|
|
98
|
+
})
|
|
99
|
+
const saveVar = (varName: string, value: string | null) =>
|
|
100
|
+
run(() => post('/__specbook/var', { env: name, name: varName, value }))
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div class="envmenu">
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
class="btn envmenu-btn"
|
|
107
|
+
aria-expanded={open}
|
|
108
|
+
title={`Variables and credentials for ${name}`}
|
|
109
|
+
onClick={() => setOpen(!open)}
|
|
110
|
+
>
|
|
111
|
+
<span aria-hidden="true">⚙︎</span> Environment settings
|
|
112
|
+
{missing > 0 && <span class="badge-missing">{missing}</span>}
|
|
113
|
+
</button>
|
|
114
|
+
|
|
115
|
+
{open && (
|
|
116
|
+
<div class="envmenu-pop" role="dialog" aria-label={`Settings for ${name}`}>
|
|
117
|
+
<header class="envmenu-head">
|
|
118
|
+
<strong>{name}</strong>
|
|
119
|
+
<span class="muted small">
|
|
120
|
+
{name === 'mock' ? 'mocks need no credentials' : 'values apply to this environment'}
|
|
121
|
+
</span>
|
|
122
|
+
<span class="spacer" />
|
|
123
|
+
<button type="button" class="icon" aria-label="Close" onClick={() => setOpen(false)}>
|
|
124
|
+
×
|
|
125
|
+
</button>
|
|
126
|
+
</header>
|
|
127
|
+
|
|
128
|
+
{state.warnings.map((w) => (
|
|
129
|
+
<p class="envmenu-warn">{w}</p>
|
|
130
|
+
))}
|
|
131
|
+
{error && <p class="envmenu-warn">{error}</p>}
|
|
132
|
+
|
|
133
|
+
<section>
|
|
134
|
+
<h3>Variables</h3>
|
|
135
|
+
<p class="muted small">
|
|
136
|
+
Shared with the team in <code>.specbook/environments/{name}.env</code>.
|
|
137
|
+
</p>
|
|
138
|
+
{Object.entries(state.vars).map(([k, v]) => (
|
|
139
|
+
<div class="envrow">
|
|
140
|
+
<code>{k}</code>
|
|
141
|
+
<input
|
|
142
|
+
aria-label={`Value of ${k}`}
|
|
143
|
+
value={draft[`var:${k}`] ?? v}
|
|
144
|
+
onInput={(e) =>
|
|
145
|
+
setDraft({ ...draft, [`var:${k}`]: (e.target as HTMLInputElement).value })
|
|
146
|
+
}
|
|
147
|
+
onBlur={() => {
|
|
148
|
+
const next = draft[`var:${k}`]
|
|
149
|
+
if (next !== undefined && next !== v) saveVar(k, next)
|
|
150
|
+
}}
|
|
151
|
+
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|
152
|
+
/>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
class="icon"
|
|
156
|
+
aria-label={`Remove ${k}`}
|
|
157
|
+
onClick={() => saveVar(k, null)}
|
|
158
|
+
>
|
|
159
|
+
×
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
))}
|
|
163
|
+
<form
|
|
164
|
+
class="envrow"
|
|
165
|
+
onSubmit={(e) => {
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
if (!newVar.name) return
|
|
168
|
+
saveVar(newVar.name, newVar.value).then(() => setNewVar({ name: '', value: '' }))
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<input
|
|
172
|
+
placeholder="name"
|
|
173
|
+
aria-label="New variable name"
|
|
174
|
+
value={newVar.name}
|
|
175
|
+
onInput={(e) =>
|
|
176
|
+
setNewVar({ ...newVar, name: (e.target as HTMLInputElement).value })
|
|
177
|
+
}
|
|
178
|
+
/>
|
|
179
|
+
<input
|
|
180
|
+
placeholder="value"
|
|
181
|
+
aria-label="New variable value"
|
|
182
|
+
value={newVar.value}
|
|
183
|
+
onInput={(e) =>
|
|
184
|
+
setNewVar({ ...newVar, value: (e.target as HTMLInputElement).value })
|
|
185
|
+
}
|
|
186
|
+
/>
|
|
187
|
+
<button type="submit" class="btn" disabled={!newVar.name}>
|
|
188
|
+
Add
|
|
189
|
+
</button>
|
|
190
|
+
</form>
|
|
191
|
+
</section>
|
|
192
|
+
|
|
193
|
+
<section>
|
|
194
|
+
<h3>Secrets</h3>
|
|
195
|
+
{state.secrets.length === 0 ? (
|
|
196
|
+
<p class="muted small">
|
|
197
|
+
None declared. Add <code>secrets: {'{ NAME: {} }'}</code> to the config.
|
|
198
|
+
</p>
|
|
199
|
+
) : (
|
|
200
|
+
<>
|
|
201
|
+
<label class="envrow scope">
|
|
202
|
+
<span class="muted small">Save to</span>
|
|
203
|
+
<select
|
|
204
|
+
aria-label="Where to save secrets"
|
|
205
|
+
value={scope}
|
|
206
|
+
onChange={(e) => setScope((e.target as HTMLSelectElement).value as Scope)}
|
|
207
|
+
>
|
|
208
|
+
<option value="environment">{name}.secrets.env (remembered here)</option>
|
|
209
|
+
<option value="session">this session only</option>
|
|
210
|
+
<option value="shared">all environments (secrets.local.env)</option>
|
|
211
|
+
</select>
|
|
212
|
+
</label>
|
|
213
|
+
{state.secrets.map((s) => (
|
|
214
|
+
<form
|
|
215
|
+
class="secretrow"
|
|
216
|
+
onSubmit={(e) => {
|
|
217
|
+
e.preventDefault()
|
|
218
|
+
saveSecret(s.name, draft[s.name] ?? '')
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<div class="secretrow-head">
|
|
222
|
+
<span>{labels[s.name] ?? s.name}</span>
|
|
223
|
+
<code class="muted small">{s.key}</code>
|
|
224
|
+
<span class={`src ${s.source ? 'set' : 'missing'}`}>
|
|
225
|
+
{s.source ? SOURCE_LABEL[s.source] : 'missing'}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="envrow">
|
|
229
|
+
<input
|
|
230
|
+
type="password"
|
|
231
|
+
autocomplete="off"
|
|
232
|
+
aria-label={`Value of ${s.name}`}
|
|
233
|
+
placeholder={s.source ? '•••••• (type to replace)' : 'paste a value'}
|
|
234
|
+
value={draft[s.name] ?? ''}
|
|
235
|
+
onInput={(e) =>
|
|
236
|
+
setDraft({ ...draft, [s.name]: (e.target as HTMLInputElement).value })
|
|
237
|
+
}
|
|
238
|
+
/>
|
|
239
|
+
<button type="submit" class="btn" disabled={!draft[s.name]}>
|
|
240
|
+
Save
|
|
241
|
+
</button>
|
|
242
|
+
{s.source && s.source !== 'process' && (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
class="link"
|
|
246
|
+
onClick={() => saveSecret(s.name, '')}
|
|
247
|
+
title="Remove the value from the selected place"
|
|
248
|
+
>
|
|
249
|
+
Clear
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</form>
|
|
254
|
+
))}
|
|
255
|
+
</>
|
|
256
|
+
)}
|
|
257
|
+
</section>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
)
|
|
262
|
+
}
|