@specbook/ui 0.2.0 → 0.3.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/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-DfwluJy4.js} +2 -2
- package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-9eYU7Gz6.js} +1 -1
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +6 -5
- package/src/app.tsx +65 -12
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +21 -3
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +286 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +274 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +7 -2
- package/src/nav.ts +12 -1
- package/src/oauth.ts +9 -8
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -9
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +6 -2
- package/src/panel/Panel.tsx +43 -10
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +91 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +10 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +8 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +233 -0
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +6 -0
- package/src/viewer/registry.ts +46 -10
- package/src/viewer/worker.ts +9 -1
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
- package/dist-viewer/assets/index-ez9c20v3.js +0 -1
- package/dist-viewer/assets/main-38QGGpNT.js +0 -14
- package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
- package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
- package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
2
|
+
import type { EnvironmentView, VariableRow } from '../engine'
|
|
3
|
+
import { refreshEnvironments } from '../environment-list'
|
|
2
4
|
import { engine, isStatic } from '../runtime'
|
|
3
|
-
import { env, version } from '../store'
|
|
5
|
+
import { env, envSettingsOpen, envVarsVersion, version } from '../store'
|
|
4
6
|
|
|
5
7
|
type Source = 'session' | 'process' | 'environment' | 'shared' | undefined
|
|
6
8
|
type Scope = 'environment' | 'session' | 'shared'
|
|
@@ -23,6 +25,13 @@ const SOURCE_LABEL: Record<NonNullable<Source>, string> = {
|
|
|
23
25
|
shared: 'shared file',
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
const VARIABLE_LABEL: Record<VariableRow['source'], string> = {
|
|
29
|
+
default: 'default',
|
|
30
|
+
config: 'config',
|
|
31
|
+
file: 'file',
|
|
32
|
+
browser: 'this browser',
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
async function post(path: string, body: object) {
|
|
27
36
|
const r = await fetch(path, {
|
|
28
37
|
method: 'POST',
|
|
@@ -32,32 +41,59 @@ async function post(path: string, body: object) {
|
|
|
32
41
|
if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
/** What a variable is for in this environment: the calls and sign-ins that read it. */
|
|
45
|
+
function usesOf(row: VariableRow, view: EnvironmentView): string[] {
|
|
46
|
+
const u = view.uses[row.name]
|
|
47
|
+
if (!u) return []
|
|
48
|
+
const out: string[] = []
|
|
49
|
+
for (const service of u.services) {
|
|
50
|
+
const a = view.addresses.find((x) => x.service === service)
|
|
51
|
+
out.push(a?.url ? `${service} calls go to ${a.url}` : `${service} calls use this address`)
|
|
52
|
+
}
|
|
53
|
+
for (const p of u.profiles) out.push(`${p.profile} sign-in reads this (${p.field})`)
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
/**
|
|
36
|
-
* Header control for what the selected environment needs: its variables (
|
|
37
|
-
*
|
|
58
|
+
* Header control for what the selected environment needs: its variables (defaults from the config, and
|
|
59
|
+
* the environment's own in `<env>.env`, committed, or in this browser) and secrets (`<env>.secrets.env`,
|
|
60
|
+
* gitignored, or this session only). The badge counts secrets still missing.
|
|
38
61
|
*/
|
|
39
62
|
export function EnvironmentMenu() {
|
|
40
63
|
const [state, setState] = useState<EnvState>()
|
|
64
|
+
const [view, setView] = useState<EnvironmentView>()
|
|
41
65
|
const [labels, setLabels] = useState<Record<string, string>>({})
|
|
42
|
-
const
|
|
66
|
+
const open = envSettingsOpen.value
|
|
67
|
+
const setOpen = (o: boolean) => (envSettingsOpen.value = o)
|
|
43
68
|
const [scope, setScope] = useState<Scope>('environment')
|
|
44
69
|
const [draft, setDraft] = useState<Record<string, string>>({})
|
|
45
70
|
const [newVar, setNewVar] = useState({ name: '', value: '' })
|
|
46
71
|
const [error, setError] = useState<string>()
|
|
47
72
|
const name = env.value
|
|
48
73
|
|
|
74
|
+
// the dev server's view of secrets (and committed-secret warnings); a static page has no files
|
|
49
75
|
const refresh = () =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
isStatic
|
|
77
|
+
? Promise.resolve()
|
|
78
|
+
: fetch(`/__specbook/environment?env=${encodeURIComponent(name)}`)
|
|
79
|
+
.then((r) => r.json() as Promise<EnvState>)
|
|
80
|
+
.then(setState, () => setState(undefined))
|
|
53
81
|
|
|
54
82
|
useEffect(() => {
|
|
55
|
-
if (isStatic) return
|
|
56
83
|
refresh()
|
|
57
84
|
setScope(name === 'mock' ? 'session' : 'environment')
|
|
58
85
|
setDraft({})
|
|
59
86
|
}, [name])
|
|
60
87
|
|
|
88
|
+
// the variables as the settings show them: defaults and overrides, and where each address goes
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
let on = true
|
|
91
|
+
engine.environmentView(name, version.value).then((v) => on && setView(v))
|
|
92
|
+
return () => {
|
|
93
|
+
on = false
|
|
94
|
+
}
|
|
95
|
+
}, [name, version.value, envVarsVersion.value])
|
|
96
|
+
|
|
61
97
|
useEffect(() => {
|
|
62
98
|
engine.config(version.value).then((cfg) => {
|
|
63
99
|
const out: Record<string, string> = {}
|
|
@@ -72,9 +108,10 @@ export function EnvironmentMenu() {
|
|
|
72
108
|
() => (name === 'mock' ? 0 : (state?.secrets.filter((s) => !s.source).length ?? 0)),
|
|
73
109
|
[state, name],
|
|
74
110
|
)
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
if (!view) return null
|
|
112
|
+
if (!isStatic && !state) return null
|
|
113
|
+
const secrets = state?.secrets ?? []
|
|
114
|
+
const hasAnything = secrets.length > 0 || view.rows.length > 0 || name !== 'mock'
|
|
78
115
|
if (!hasAnything) return null
|
|
79
116
|
|
|
80
117
|
const run = async (fn: () => Promise<void>) => {
|
|
@@ -96,8 +133,27 @@ export function EnvironmentMenu() {
|
|
|
96
133
|
})
|
|
97
134
|
setDraft((d) => ({ ...d, [secret]: '' }))
|
|
98
135
|
})
|
|
136
|
+
// a variable change moves addresses and sign-in origins: what is derived from them is redone
|
|
99
137
|
const saveVar = (varName: string, value: string | null) =>
|
|
100
|
-
run(() =>
|
|
138
|
+
run(async () => {
|
|
139
|
+
await engine.setVariable(name, varName, value === '' ? null : value)
|
|
140
|
+
envVarsVersion.value += 1
|
|
141
|
+
})
|
|
142
|
+
const removeEnvironment = () =>
|
|
143
|
+
run(async () => {
|
|
144
|
+
engine.deleteEnvironment(name)
|
|
145
|
+
env.value = 'mock'
|
|
146
|
+
await refreshEnvironments()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const warnings = [
|
|
150
|
+
...(state?.warnings ?? []),
|
|
151
|
+
...(name === 'mock' ? [] : view.addresses.filter((a) => a.error).map((a) => a.error as string)),
|
|
152
|
+
]
|
|
153
|
+
const ordered = [...view.rows].sort(
|
|
154
|
+
(a, b) =>
|
|
155
|
+
Number(!!view.uses[b.name]) - Number(!!view.uses[a.name]) || a.name.localeCompare(b.name),
|
|
156
|
+
)
|
|
101
157
|
|
|
102
158
|
return (
|
|
103
159
|
<div class="envmenu">
|
|
@@ -106,9 +162,12 @@ export function EnvironmentMenu() {
|
|
|
106
162
|
class="btn envmenu-btn"
|
|
107
163
|
aria-expanded={open}
|
|
108
164
|
title={`Variables and credentials for ${name}`}
|
|
165
|
+
aria-label={
|
|
166
|
+
missing > 0 ? `Environment settings, ${missing} missing` : 'Environment settings'
|
|
167
|
+
}
|
|
109
168
|
onClick={() => setOpen(!open)}
|
|
110
169
|
>
|
|
111
|
-
<span aria-hidden="true">⚙︎</span> Environment settings
|
|
170
|
+
<span aria-hidden="true">⚙︎</span> <span class="envmenu-label">Environment settings</span>
|
|
112
171
|
{missing > 0 && <span class="badge-missing">{missing}</span>}
|
|
113
172
|
</button>
|
|
114
173
|
|
|
@@ -125,7 +184,7 @@ export function EnvironmentMenu() {
|
|
|
125
184
|
</button>
|
|
126
185
|
</header>
|
|
127
186
|
|
|
128
|
-
{
|
|
187
|
+
{warnings.map((w) => (
|
|
129
188
|
<p class="envmenu-warn">{w}</p>
|
|
130
189
|
))}
|
|
131
190
|
{error && <p class="envmenu-warn">{error}</p>}
|
|
@@ -133,128 +192,179 @@ export function EnvironmentMenu() {
|
|
|
133
192
|
<section>
|
|
134
193
|
<h3>Variables</h3>
|
|
135
194
|
<p class="muted small">
|
|
136
|
-
|
|
195
|
+
{isStatic ? (
|
|
196
|
+
'Kept in this browser; the defaults come from the spec or site.'
|
|
197
|
+
) : (
|
|
198
|
+
<>
|
|
199
|
+
Shared with the team in <code>.specbook/environments/{name}.env</code>. Defaults
|
|
200
|
+
come from specbook.config.ts.
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
137
203
|
</p>
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
204
|
+
{ordered.map((row) => {
|
|
205
|
+
const own = row.source === 'file' || row.source === 'browser'
|
|
206
|
+
const key = `var:${row.name}`
|
|
207
|
+
return (
|
|
208
|
+
<div key={row.name}>
|
|
209
|
+
<div class="envrow">
|
|
210
|
+
<code>{row.name}</code>
|
|
211
|
+
<input
|
|
212
|
+
aria-label={`Value of ${row.name}`}
|
|
213
|
+
value={draft[key] ?? row.value}
|
|
214
|
+
disabled={!row.editable}
|
|
215
|
+
onInput={(e) =>
|
|
216
|
+
setDraft({ ...draft, [key]: (e.target as HTMLInputElement).value })
|
|
217
|
+
}
|
|
218
|
+
onBlur={() => {
|
|
219
|
+
const next = draft[key]
|
|
220
|
+
if (next !== undefined && next !== row.value) saveVar(row.name, next)
|
|
221
|
+
}}
|
|
222
|
+
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|
223
|
+
/>
|
|
224
|
+
<span class={`src ${own ? 'set' : 'inherited'}`}>
|
|
225
|
+
{VARIABLE_LABEL[row.source]}
|
|
226
|
+
</span>
|
|
227
|
+
{own && row.editable && (
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
class="icon"
|
|
231
|
+
aria-label={
|
|
232
|
+
row.fallback !== undefined ? `Reset ${row.name}` : `Remove ${row.name}`
|
|
233
|
+
}
|
|
234
|
+
title={
|
|
235
|
+
row.fallback !== undefined
|
|
236
|
+
? `Back to ${row.fallback}`
|
|
237
|
+
: 'Remove this variable'
|
|
238
|
+
}
|
|
239
|
+
onClick={() => saveVar(row.name, null)}
|
|
240
|
+
>
|
|
241
|
+
×
|
|
242
|
+
</button>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
{usesOf(row, view).map((line) => (
|
|
246
|
+
<p class="muted small envuse">{line}</p>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
})}
|
|
251
|
+
{name !== 'mock' &&
|
|
252
|
+
view.addresses
|
|
253
|
+
.filter(
|
|
254
|
+
(a) =>
|
|
255
|
+
a.url && !Object.values(view.uses).some((u) => u.services.includes(a.service)),
|
|
256
|
+
)
|
|
257
|
+
.map((a) => (
|
|
258
|
+
<p class="muted small envuse">
|
|
259
|
+
{a.service} calls go to {a.url}
|
|
260
|
+
</p>
|
|
261
|
+
))}
|
|
262
|
+
{name !== 'mock' && (
|
|
263
|
+
<form
|
|
264
|
+
class="envrow"
|
|
265
|
+
onSubmit={(e) => {
|
|
266
|
+
e.preventDefault()
|
|
267
|
+
if (!newVar.name) return
|
|
268
|
+
saveVar(newVar.name, newVar.value).then(() => setNewVar({ name: '', value: '' }))
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<input
|
|
272
|
+
placeholder="name"
|
|
273
|
+
aria-label="New variable name"
|
|
274
|
+
value={newVar.name}
|
|
275
|
+
onInput={(e) =>
|
|
276
|
+
setNewVar({ ...newVar, name: (e.target as HTMLInputElement).value })
|
|
277
|
+
}
|
|
278
|
+
/>
|
|
141
279
|
<input
|
|
142
|
-
|
|
143
|
-
|
|
280
|
+
placeholder="value"
|
|
281
|
+
aria-label="New variable value"
|
|
282
|
+
value={newVar.value}
|
|
144
283
|
onInput={(e) =>
|
|
145
|
-
|
|
284
|
+
setNewVar({ ...newVar, value: (e.target as HTMLInputElement).value })
|
|
146
285
|
}
|
|
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
286
|
/>
|
|
153
|
-
<button
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
287
|
+
<button type="submit" class="btn" disabled={!newVar.name}>
|
|
288
|
+
Add
|
|
289
|
+
</button>
|
|
290
|
+
</form>
|
|
291
|
+
)}
|
|
292
|
+
{view.removable && (
|
|
293
|
+
<p>
|
|
294
|
+
<button type="button" class="link" onClick={removeEnvironment}>
|
|
295
|
+
Delete environment
|
|
160
296
|
</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
297
|
</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
298
|
)}
|
|
257
299
|
</section>
|
|
300
|
+
|
|
301
|
+
{!isStatic && (
|
|
302
|
+
<section>
|
|
303
|
+
<h3>Secrets</h3>
|
|
304
|
+
{secrets.length === 0 ? (
|
|
305
|
+
<p class="muted small">
|
|
306
|
+
None declared. Add <code>secrets: {'{ NAME: {} }'}</code> to the config.
|
|
307
|
+
</p>
|
|
308
|
+
) : (
|
|
309
|
+
<>
|
|
310
|
+
<label class="envrow scope">
|
|
311
|
+
<span class="muted small">Save to</span>
|
|
312
|
+
<select
|
|
313
|
+
aria-label="Where to save secrets"
|
|
314
|
+
value={scope}
|
|
315
|
+
onChange={(e) => setScope((e.target as HTMLSelectElement).value as Scope)}
|
|
316
|
+
>
|
|
317
|
+
<option value="environment">{name}.secrets.env (remembered here)</option>
|
|
318
|
+
<option value="session">this session only</option>
|
|
319
|
+
<option value="shared">all environments (secrets.local.env)</option>
|
|
320
|
+
</select>
|
|
321
|
+
</label>
|
|
322
|
+
{secrets.map((s) => (
|
|
323
|
+
<form
|
|
324
|
+
class="secretrow"
|
|
325
|
+
onSubmit={(e) => {
|
|
326
|
+
e.preventDefault()
|
|
327
|
+
saveSecret(s.name, draft[s.name] ?? '')
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
<div class="secretrow-head">
|
|
331
|
+
<span>{labels[s.name] ?? s.name}</span>
|
|
332
|
+
<code class="muted small">{s.key}</code>
|
|
333
|
+
<span class={`src ${s.source ? 'set' : 'missing'}`}>
|
|
334
|
+
{s.source ? SOURCE_LABEL[s.source] : 'missing'}
|
|
335
|
+
</span>
|
|
336
|
+
</div>
|
|
337
|
+
<div class="envrow">
|
|
338
|
+
<input
|
|
339
|
+
type="password"
|
|
340
|
+
autocomplete="off"
|
|
341
|
+
aria-label={`Value of ${s.name}`}
|
|
342
|
+
placeholder={s.source ? '•••••• (type to replace)' : 'paste a value'}
|
|
343
|
+
value={draft[s.name] ?? ''}
|
|
344
|
+
onInput={(e) =>
|
|
345
|
+
setDraft({ ...draft, [s.name]: (e.target as HTMLInputElement).value })
|
|
346
|
+
}
|
|
347
|
+
/>
|
|
348
|
+
<button type="submit" class="btn" disabled={!draft[s.name]}>
|
|
349
|
+
Save
|
|
350
|
+
</button>
|
|
351
|
+
{s.source && s.source !== 'process' && (
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
class="link"
|
|
355
|
+
onClick={() => saveSecret(s.name, '')}
|
|
356
|
+
title="Remove the value from the selected place"
|
|
357
|
+
>
|
|
358
|
+
Clear
|
|
359
|
+
</button>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
</form>
|
|
363
|
+
))}
|
|
364
|
+
</>
|
|
365
|
+
)}
|
|
366
|
+
</section>
|
|
367
|
+
)}
|
|
258
368
|
</div>
|
|
259
369
|
)}
|
|
260
370
|
</div>
|
|
@@ -7,6 +7,8 @@ export interface FieldSpec {
|
|
|
7
7
|
label?: string
|
|
8
8
|
description?: string
|
|
9
9
|
default?: unknown
|
|
10
|
+
/** makes a fresh starting value (a faker value): the control offers to generate another */
|
|
11
|
+
generate?: () => unknown
|
|
10
12
|
options?: { label: string; value: unknown }[]
|
|
11
13
|
shape?: Record<string, FieldSpec>
|
|
12
14
|
item?: FieldSpec
|
|
@@ -50,11 +52,26 @@ function baseSpec(ctl: any, o: any): FieldSpec {
|
|
|
50
52
|
max: o.max,
|
|
51
53
|
}
|
|
52
54
|
default:
|
|
53
|
-
return
|
|
55
|
+
return typeof o.default === 'function'
|
|
56
|
+
? { kind: ctl.kind, generate: o.default as () => unknown, min: o.min, max: o.max }
|
|
57
|
+
: { kind: ctl.kind, default: o.default, min: o.min, max: o.max }
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** A fresh generated value for a field, or its plain default when it has no generator or the generator cannot run. */
|
|
62
|
+
export function generateFor(spec: FieldSpec): unknown {
|
|
63
|
+
if (spec.generate) {
|
|
64
|
+
try {
|
|
65
|
+
return spec.generate()
|
|
66
|
+
} catch {
|
|
67
|
+
/* faker is not ready: fall through to the plain default */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return defaultFor({ ...spec, generate: undefined })
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
export function defaultFor(spec: FieldSpec): unknown {
|
|
74
|
+
if (spec.generate) return generateFor(spec)
|
|
58
75
|
switch (spec.kind) {
|
|
59
76
|
case 'select':
|
|
60
77
|
return spec.options?.[0]?.value
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { refreshEnvironments } from '../environment-list'
|
|
3
|
+
import { engine } from '../runtime'
|
|
4
|
+
import { env, environments, envSettingsOpen, newEnvironmentOpen, version } from '../store'
|
|
5
|
+
|
|
6
|
+
const NAME = /^[A-Za-z0-9_-]+$/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Makes an environment as a copy of another one's variables: its own address, its own sign-in provider, the
|
|
10
|
+
* same secrets as nothing (those are asked for again). In a project it becomes `.specbook/environments/<name>.env`;
|
|
11
|
+
* on a page without files it is kept in this browser.
|
|
12
|
+
*/
|
|
13
|
+
export function NewEnvironmentDialog() {
|
|
14
|
+
return newEnvironmentOpen.value ? <Dialog /> : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// mounted only while open, so each opening starts fresh: empty name, a copy of what is being looked at
|
|
18
|
+
function Dialog() {
|
|
19
|
+
const [name, setName] = useState('')
|
|
20
|
+
const [from, setFrom] = useState(() =>
|
|
21
|
+
// unless that is mock (nothing of its own to copy)
|
|
22
|
+
env.value === 'mock' ? (environments.value.find((e) => e !== 'mock') ?? 'mock') : env.value,
|
|
23
|
+
)
|
|
24
|
+
const [error, setError] = useState<string>()
|
|
25
|
+
const [busy, setBusy] = useState(false)
|
|
26
|
+
const input = useRef<HTMLInputElement>(null)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
input.current?.focus()
|
|
30
|
+
const onKey = (e: KeyboardEvent) => {
|
|
31
|
+
if (e.key === 'Escape') newEnvironmentOpen.value = false
|
|
32
|
+
}
|
|
33
|
+
addEventListener('keydown', onKey)
|
|
34
|
+
return () => removeEventListener('keydown', onKey)
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const close = () => (newEnvironmentOpen.value = false)
|
|
38
|
+
const taken = environments.value.includes(name)
|
|
39
|
+
const problem =
|
|
40
|
+
name === ''
|
|
41
|
+
? undefined
|
|
42
|
+
: !NAME.test(name)
|
|
43
|
+
? 'Use letters, digits, "-" and "_"'
|
|
44
|
+
: name === 'mock' || taken
|
|
45
|
+
? `"${name}" already exists`
|
|
46
|
+
: undefined
|
|
47
|
+
|
|
48
|
+
const create = async () => {
|
|
49
|
+
setBusy(true)
|
|
50
|
+
setError(undefined)
|
|
51
|
+
try {
|
|
52
|
+
await engine.createEnvironment(name, from, version.value)
|
|
53
|
+
await refreshEnvironments()
|
|
54
|
+
env.value = name
|
|
55
|
+
newEnvironmentOpen.value = false
|
|
56
|
+
envSettingsOpen.value = true
|
|
57
|
+
} catch (e) {
|
|
58
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div class="palette-backdrop newenv-backdrop">
|
|
66
|
+
<button type="button" class="palette-dismiss" aria-label="Close" onClick={close} />
|
|
67
|
+
<div class="palette newenv" role="dialog" aria-label="New environment">
|
|
68
|
+
<form
|
|
69
|
+
onSubmit={(e) => {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
if (name && !problem && !busy) create()
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<h2>New environment</h2>
|
|
75
|
+
<label>
|
|
76
|
+
<span class="muted small">Name</span>
|
|
77
|
+
<input
|
|
78
|
+
ref={input}
|
|
79
|
+
type="text"
|
|
80
|
+
aria-label="Environment name"
|
|
81
|
+
placeholder="staging"
|
|
82
|
+
value={name}
|
|
83
|
+
onInput={(e) => setName((e.target as HTMLInputElement).value.trim())}
|
|
84
|
+
/>
|
|
85
|
+
</label>
|
|
86
|
+
<label>
|
|
87
|
+
<span class="muted small">Duplicate of</span>
|
|
88
|
+
<select
|
|
89
|
+
aria-label="Duplicate of"
|
|
90
|
+
value={from}
|
|
91
|
+
onChange={(e) => setFrom((e.target as HTMLSelectElement).value)}
|
|
92
|
+
>
|
|
93
|
+
{environments.value
|
|
94
|
+
.filter((e) => e !== 'mock')
|
|
95
|
+
.map((e) => (
|
|
96
|
+
<option value={e}>{e}</option>
|
|
97
|
+
))}
|
|
98
|
+
</select>
|
|
99
|
+
</label>
|
|
100
|
+
<p class="muted small">
|
|
101
|
+
It starts with {from}'s variables. Change its address in Variables; secrets are asked
|
|
102
|
+
for again.
|
|
103
|
+
</p>
|
|
104
|
+
{(problem || error) && <p class="envmenu-warn">{problem ?? error}</p>}
|
|
105
|
+
<div class="envrow">
|
|
106
|
+
<span class="spacer" />
|
|
107
|
+
<button type="button" class="btn" onClick={close}>
|
|
108
|
+
Cancel
|
|
109
|
+
</button>
|
|
110
|
+
<button type="submit" class="btn primary" disabled={!name || !!problem || busy}>
|
|
111
|
+
Create
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</form>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { type Entry, go, paletteOpen, search } from '../nav'
|
|
3
|
+
import { isMinimal } from '../runtime'
|
|
3
4
|
|
|
4
5
|
export function Palette({ entries }: { entries: Entry[] }) {
|
|
5
6
|
const [q, setQ] = useState('')
|
|
@@ -47,7 +48,9 @@ export function Palette({ entries }: { entries: Entry[] }) {
|
|
|
47
48
|
<input
|
|
48
49
|
ref={input}
|
|
49
50
|
type="text"
|
|
50
|
-
placeholder=
|
|
51
|
+
placeholder={
|
|
52
|
+
isMinimal ? 'Jump to a flow or request…' : 'Jump to a sequence, request or operation…'
|
|
53
|
+
}
|
|
51
54
|
value={q}
|
|
52
55
|
onInput={(e) => {
|
|
53
56
|
setQ((e.target as HTMLInputElement).value)
|
|
@@ -73,7 +76,9 @@ export function Palette({ entries }: { entries: Entry[] }) {
|
|
|
73
76
|
onClick={() => choose(e)}
|
|
74
77
|
>
|
|
75
78
|
<span class="p-title">{e.title}</span>
|
|
76
|
-
<span class="muted">
|
|
79
|
+
<span class="muted">
|
|
80
|
+
{e.kind === 'seq' ? 'Sequence' : e.kind === 'req' ? 'Request' : e.group}
|
|
81
|
+
</span>
|
|
77
82
|
</button>
|
|
78
83
|
</li>
|
|
79
84
|
))}
|