@specbook/ui 0.1.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.
Files changed (75) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  3. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  4. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  5. package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
  6. package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
  7. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  8. package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
  9. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +7 -5
  13. package/src/app.tsx +71 -13
  14. package/src/branding.ts +12 -4
  15. package/src/components/Catalog.tsx +128 -51
  16. package/src/components/EnvBadge.tsx +24 -4
  17. package/src/components/EnvironmentMenu.tsx +237 -127
  18. package/src/components/Fields.tsx +18 -1
  19. package/src/components/NewEnvironmentDialog.tsx +118 -0
  20. package/src/components/Palette.tsx +7 -2
  21. package/src/custom-environments.ts +63 -0
  22. package/src/embed/panel.tsx +287 -44
  23. package/src/embed/styles.ts +2 -0
  24. package/src/embed/target.ts +82 -0
  25. package/src/embed/theme.ts +75 -0
  26. package/src/embed.ts +44 -15
  27. package/src/engine.ts +416 -32
  28. package/src/environment-list.ts +16 -0
  29. package/src/main.tsx +14 -1
  30. package/src/nav.ts +12 -1
  31. package/src/oauth-callback.ts +87 -0
  32. package/src/oauth.ts +195 -0
  33. package/src/panel/Body.tsx +27 -4
  34. package/src/panel/Checks.tsx +79 -0
  35. package/src/panel/CodeSample.tsx +16 -8
  36. package/src/panel/Combo.tsx +52 -13
  37. package/src/panel/ControlsGrid.tsx +54 -9
  38. package/src/panel/ErrorBoundary.tsx +36 -0
  39. package/src/panel/Host.tsx +14 -2
  40. package/src/panel/OAuthSignIn.tsx +167 -0
  41. package/src/panel/Panel.tsx +50 -13
  42. package/src/panel/ParamGrid.tsx +15 -8
  43. package/src/panel/RequestPanel.tsx +93 -36
  44. package/src/panel/RequestView.tsx +2 -1
  45. package/src/panel/ResponseView.tsx +25 -14
  46. package/src/panel/SecretsBar.tsx +43 -9
  47. package/src/panel/SequenceDocs.tsx +7 -6
  48. package/src/panel/SequencePanel.tsx +124 -32
  49. package/src/panel/announce.ts +17 -0
  50. package/src/panel/icons.tsx +9 -0
  51. package/src/panel/short.ts +24 -0
  52. package/src/routing.ts +0 -9
  53. package/src/runtime.ts +19 -1
  54. package/src/secrets.ts +4 -2
  55. package/src/storage.ts +58 -0
  56. package/src/store.ts +10 -0
  57. package/src/styles/app.css +69 -6
  58. package/src/styles/embed.css +116 -0
  59. package/src/styles/panel.css +277 -2
  60. package/src/viewer/embed.ts +5 -0
  61. package/src/viewer/main.tsx +5 -1
  62. package/src/viewer/manifests.ts +2 -0
  63. package/src/viewer/protocol.ts +9 -0
  64. package/src/viewer/registry-data.ts +4 -0
  65. package/src/viewer/registry.ts +48 -3
  66. package/src/viewer/worker.ts +20 -7
  67. package/src/virtual.d.ts +8 -0
  68. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  69. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  70. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  71. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  72. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  73. package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
  74. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
  75. package/dist-viewer/assets/worker-Dw3Ps3hk.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 (`<env>.env`, committed) and
37
- * secrets (`<env>.secrets.env`, gitignored, or this session only). The badge counts secrets still missing.
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 [open, setOpen] = useState(false)
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
- fetch(`/__specbook/environment?env=${encodeURIComponent(name)}`)
51
- .then((r) => r.json() as Promise<EnvState>)
52
- .then(setState, () => setState(undefined))
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 (isStatic || !state) return null
76
- const hasAnything =
77
- state.secrets.length > 0 || Object.keys(state.vars).length > 0 || name !== 'mock'
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(() => post('/__specbook/var', { env: name, name: varName, value }))
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
- {state.warnings.map((w) => (
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
- Shared with the team in <code>.specbook/environments/{name}.env</code>.
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
- {Object.entries(state.vars).map(([k, v]) => (
139
- <div class="envrow">
140
- <code>{k}</code>
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
- aria-label={`Value of ${k}`}
143
- value={draft[`var:${k}`] ?? v}
280
+ placeholder="value"
281
+ aria-label="New variable value"
282
+ value={newVar.value}
144
283
  onInput={(e) =>
145
- setDraft({ ...draft, [`var:${k}`]: (e.target as HTMLInputElement).value })
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
- type="button"
155
- class="icon"
156
- aria-label={`Remove ${k}`}
157
- onClick={() => saveVar(k, null)}
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 { kind: ctl.kind, default: o.default, min: o.min, max: o.max }
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="Jump to a sequence or operation…"
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">{e.kind === 'seq' ? 'Sequence' : e.group}</span>
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
  ))}