@specbook/ui 0.2.0 → 0.4.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 (71) hide show
  1. package/dist-viewer/assets/Host-DsO9W5X8.js +41 -0
  2. package/dist-viewer/assets/index-D59fkVA9.js +1 -0
  3. package/dist-viewer/assets/main-i06Egg5h.js +14 -0
  4. package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-CLHBtIzR.js} +2 -2
  5. package/dist-viewer/assets/panel-l1xvY4kI.css +1 -0
  6. package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-nV09I7Wr.js} +1 -1
  7. package/dist-viewer/assets/registry-b_jzfqy0.js +2 -0
  8. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  9. package/dist-viewer/index.html +4 -4
  10. package/dist-viewer/specbook.js +1 -1
  11. package/package.json +6 -5
  12. package/src/app.tsx +67 -12
  13. package/src/branding.ts +12 -4
  14. package/src/components/Catalog.tsx +128 -51
  15. package/src/components/EnvBadge.tsx +21 -3
  16. package/src/components/EnvironmentMenu.tsx +237 -127
  17. package/src/components/Fields.tsx +18 -1
  18. package/src/components/NewEnvironmentDialog.tsx +118 -0
  19. package/src/components/Palette.tsx +7 -2
  20. package/src/custom-environments.ts +63 -0
  21. package/src/embed/panel.tsx +286 -44
  22. package/src/embed/styles.ts +2 -0
  23. package/src/embed/target.ts +82 -0
  24. package/src/embed/theme.ts +75 -0
  25. package/src/embed.ts +44 -15
  26. package/src/engine.ts +274 -32
  27. package/src/environment-list.ts +16 -0
  28. package/src/main.tsx +7 -2
  29. package/src/nav.ts +12 -1
  30. package/src/oauth.ts +9 -8
  31. package/src/panel/Body.tsx +27 -4
  32. package/src/panel/Checks.tsx +79 -0
  33. package/src/panel/CodeSample.tsx +16 -9
  34. package/src/panel/Combo.tsx +52 -13
  35. package/src/panel/ControlsGrid.tsx +54 -9
  36. package/src/panel/ErrorBoundary.tsx +36 -0
  37. package/src/panel/Host.tsx +14 -2
  38. package/src/panel/OAuthSignIn.tsx +6 -2
  39. package/src/panel/OperationDocs.tsx +1 -1
  40. package/src/panel/Panel.tsx +43 -10
  41. package/src/panel/ParamGrid.tsx +15 -8
  42. package/src/panel/RequestPanel.tsx +102 -39
  43. package/src/panel/RequestView.tsx +2 -1
  44. package/src/panel/ResponseView.tsx +25 -14
  45. package/src/panel/SecretsBar.tsx +10 -9
  46. package/src/panel/SequenceDocs.tsx +7 -6
  47. package/src/panel/SequencePanel.tsx +124 -32
  48. package/src/panel/announce.ts +17 -0
  49. package/src/panel/icons.tsx +9 -0
  50. package/src/panel/short.ts +24 -0
  51. package/src/routing.ts +0 -9
  52. package/src/runtime.ts +19 -1
  53. package/src/secrets.ts +4 -2
  54. package/src/storage.ts +58 -0
  55. package/src/store.ts +8 -0
  56. package/src/styles/app.css +82 -6
  57. package/src/styles/embed.css +116 -0
  58. package/src/styles/panel.css +235 -4
  59. package/src/viewer/embed.ts +9 -2
  60. package/src/viewer/manifests.ts +2 -0
  61. package/src/viewer/mount.ts +2 -0
  62. package/src/viewer/protocol.ts +6 -0
  63. package/src/viewer/registry.ts +48 -10
  64. package/src/viewer/worker.ts +9 -1
  65. package/src/virtual.d.ts +8 -0
  66. package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
  67. package/dist-viewer/assets/index-ez9c20v3.js +0 -1
  68. package/dist-viewer/assets/main-38QGGpNT.js +0 -14
  69. package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
  70. package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
  71. package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
@@ -1,6 +1,10 @@
1
1
  import type { ComponentChildren } from 'preact'
2
- import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'
2
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks'
3
3
  import { env, environmentNote, environments } from '../store'
4
+ import { announcement } from './announce'
5
+ import { ErrorBoundary } from './ErrorBoundary'
6
+
7
+ let panelIds = 0
4
8
 
5
9
  interface Props {
6
10
  title: string
@@ -9,6 +13,8 @@ interface Props {
9
13
  flush?: boolean
10
14
  /** dims the page and traps focus (embeds); the standalone site leaves its list usable beside the panel */
11
15
  modal?: boolean
16
+ /** `popover`: a centered dialog on a desktop and a bottom sheet on a phone; `side` is the app's sheet from the right */
17
+ variant?: 'side' | 'popover'
12
18
  onPrev?: () => void
13
19
  onNext?: () => void
14
20
  onClose: () => void
@@ -24,12 +30,22 @@ export function Panel({
24
30
  subtitle,
25
31
  flush,
26
32
  modal = true,
33
+ variant = 'side',
27
34
  onPrev,
28
35
  onNext,
29
36
  onClose,
30
37
  children,
31
38
  }: Props) {
32
39
  const aside = useRef<HTMLElement>(null)
40
+ const titleId = useMemo(() => `sb-panel-title-${++panelIds}`, [])
41
+
42
+ // what was announced for one run is not read out again when the next panel opens
43
+ useEffect(
44
+ () => () => {
45
+ announcement.value = ''
46
+ },
47
+ [],
48
+ )
33
49
 
34
50
  useEffect(() => {
35
51
  const root = aside.current?.getRootNode() as Document | ShadowRoot
@@ -63,14 +79,23 @@ export function Panel({
63
79
  const top = vv?.offsetTop ?? 0
64
80
  const height = vv?.height ?? innerHeight
65
81
  const at = field.getBoundingClientRect().top
66
- const want = top + height * 0.2
67
- if (at > top + height * 0.4 || at < top) box.scrollTop += at - want
82
+ // a dropdown field needs its list visible too, and picking from it needs no keyboard: it is moved
83
+ // only when it is down where the keyboard would be, and not as far
84
+ const dropdown = field.getAttribute('role') === 'combobox'
85
+ const want = top + height * (dropdown ? 0.3 : 0.2)
86
+ if (at > top + height * (dropdown ? 0.55 : 0.4) || at < top) box.scrollTop += at - want
68
87
  }
69
88
  const focusin = (e: Event) => {
70
89
  const field = e.target as HTMLElement
71
90
  if (!phone.matches || !field.matches?.(FIELD)) return
72
91
  const box = scroller(field)
73
- if (box && padded?.box !== box) {
92
+ // a dropdown field high enough on the screen is not moved, so it needs no room below (which would
93
+ // only show a scrollbar)
94
+ const vv = typeof visualViewport === 'undefined' ? undefined : visualViewport
95
+ const low =
96
+ field.getBoundingClientRect().top >
97
+ (vv?.offsetTop ?? 0) + (vv?.height ?? innerHeight) * 0.55
98
+ if (box && padded?.box !== box && (field.getAttribute('role') !== 'combobox' || low)) {
74
99
  if (padded) padded.box.style.paddingBottom = padded.was
75
100
  padded = { box, was: box.style.paddingBottom }
76
101
  box.style.paddingBottom = '70vh'
@@ -121,15 +146,18 @@ export function Panel({
121
146
  first.focus()
122
147
  }
123
148
  }
124
- addEventListener('keydown', key)
125
- return () => removeEventListener('keydown', key)
149
+ // a dialog over someone else's page listens on itself (focus is trapped in it), not on the window; the app's
150
+ // docked panel keeps its window-wide Escape
151
+ const target: EventTarget = modal && aside.current ? aside.current : window
152
+ target.addEventListener('keydown', key as EventListener)
153
+ return () => target.removeEventListener('keydown', key as EventListener)
126
154
  }, [onClose, modal])
127
155
 
128
156
  // a dialog when it dims the page (embeds), a complementary region when docked beside the list
129
157
  const attrs = {
130
- class: `panel ${modal ? '' : 'docked'}`,
158
+ class: `panel ${modal ? '' : 'docked'} ${variant === 'popover' ? 'popover' : ''}`,
131
159
  part: 'panel',
132
- 'aria-label': title,
160
+ 'aria-labelledby': titleId,
133
161
  'aria-modal': modal ? true : undefined,
134
162
  role: (modal ? 'dialog' : 'complementary') as 'dialog',
135
163
  tabIndex: -1,
@@ -143,7 +171,7 @@ export function Panel({
143
171
  <section {...attrs} ref={aside}>
144
172
  <header class="panel-head" part="header">
145
173
  <div class="panel-title">
146
- <h2>{title}</h2>
174
+ <h2 id={titleId}>{title}</h2>
147
175
  {subtitle && <div class="muted small">{subtitle}</div>}
148
176
  </div>
149
177
  <span class="spacer" />
@@ -190,7 +218,12 @@ export function Panel({
190
218
  ×
191
219
  </button>
192
220
  </header>
193
- <div class={`panel-body ${flush ? 'flush' : ''}`}>{children}</div>
221
+ <div class={`panel-body ${flush ? 'flush' : ''}`}>
222
+ <ErrorBoundary>{children}</ErrorBoundary>
223
+ </div>
224
+ <div class="visually-hidden" role="status" aria-live="polite" aria-atomic="true">
225
+ {announcement.value}
226
+ </div>
194
227
  </section>
195
228
  </>
196
229
  )
@@ -2,6 +2,7 @@ import { signal } from '@preact/signals'
2
2
  import { useEffect, useRef } from 'preact/hooks'
3
3
  import type { FieldDoc } from '../fielddoc'
4
4
  import { inlineSummary, plainText } from '../markdown'
5
+ import { kv } from '../storage'
5
6
  import { Combo } from './Combo'
6
7
  import type { FieldRow } from './draft'
7
8
 
@@ -28,6 +29,8 @@ interface Props {
28
29
  placeholder?: string
29
30
  /** the grid sits under a tab that already names it */
30
31
  bare?: boolean
32
+ /** values are shown, not edited (a saved request carries its own) */
33
+ readOnly?: boolean
31
34
  }
32
35
 
33
36
  type Shown = FieldRow & { virtual?: true }
@@ -41,7 +44,7 @@ const cols = signal(loadCols())
41
44
 
42
45
  function loadCols(): { key: number; val: number } {
43
46
  try {
44
- const c = JSON.parse(localStorage.getItem(COLS_KEY) ?? 'null')
47
+ const c = JSON.parse(kv.local.getItem(COLS_KEY) ?? 'null')
45
48
  if (c && c.key >= MIN_COL && c.val >= MIN_COL && c.key + c.val <= 100 - MIN_COL) return c
46
49
  } catch {
47
50
  /* fall through */
@@ -55,8 +58,8 @@ function setCols(next: { key: number; val: number } | undefined) {
55
58
  const val = Math.min(100 - key - MIN_COL, Math.max(MIN_COL, c.val))
56
59
  cols.value = { key: Math.round(key * 10) / 10, val: Math.round(val * 10) / 10 }
57
60
  try {
58
- if (next) localStorage.setItem(COLS_KEY, JSON.stringify(cols.value))
59
- else localStorage.removeItem(COLS_KEY)
61
+ if (next) kv.local.setItem(COLS_KEY, JSON.stringify(cols.value))
62
+ else kv.local.removeItem(COLS_KEY)
60
63
  } catch {
61
64
  /* optional */
62
65
  }
@@ -167,6 +170,7 @@ export function ParamGrid({
167
170
  bad,
168
171
  placeholder,
169
172
  bare,
173
+ readOnly,
170
174
  }: Props) {
171
175
  const root = useRef<HTMLElement>(null)
172
176
  const table = useRef<HTMLTableElement>(null)
@@ -252,7 +256,7 @@ export function ParamGrid({
252
256
  {all.map((r, i) => {
253
257
  const d = docOf(r.key)
254
258
  const isFile = info(r.key)?.file
255
- const locked = fixed || r.virtual || info(r.key)
259
+ const locked = fixed || readOnly || r.virtual || info(r.key)
256
260
  return (
257
261
  <tr class={`${r.enabled ? '' : 'off'} ${bad === r.key ? 'bad' : ''}`} key={i}>
258
262
  <td class="g-check">
@@ -260,7 +264,7 @@ export function ParamGrid({
260
264
  type="checkbox"
261
265
  aria-label={`Send ${r.key}`}
262
266
  checked={r.enabled}
263
- disabled={fixed}
267
+ disabled={fixed || readOnly}
264
268
  onChange={(e) => patch(i, { enabled: (e.target as HTMLInputElement).checked })}
265
269
  />
266
270
  </td>
@@ -292,6 +296,7 @@ export function ParamGrid({
292
296
  <label class="file-cell">
293
297
  <input
294
298
  type="file"
299
+ disabled={readOnly}
295
300
  aria-label={`File for ${r.key}`}
296
301
  onChange={(e) => {
297
302
  const file = (e.target as HTMLInputElement).files?.[0]
@@ -306,6 +311,7 @@ export function ParamGrid({
306
311
  options={d.enum.map(String)}
307
312
  label={r.key}
308
313
  cell={`${i}:${VALUE}`}
314
+ readOnly={readOnly}
309
315
  placeholder="value"
310
316
  onKeyDown={keys(i, VALUE)}
311
317
  onChange={(value) => patch(i, { value, enabled: true })}
@@ -315,6 +321,7 @@ export function ParamGrid({
315
321
  class="cell"
316
322
  data-cell={`${i}:${VALUE}`}
317
323
  value={r.value}
324
+ readOnly={readOnly}
318
325
  placeholder={placeholder ?? 'value'}
319
326
  aria-label={`Value of ${r.key}`}
320
327
  onInput={(e) =>
@@ -336,7 +343,7 @@ export function ParamGrid({
336
343
  )}
337
344
  </td>
338
345
  <td class="g-act">
339
- {!fixed && r.key && (
346
+ {!fixed && !readOnly && r.key && (
340
347
  <button
341
348
  type="button"
342
349
  class="icon"
@@ -347,7 +354,7 @@ export function ParamGrid({
347
354
  <Icon d="M5.5 5.5h7v7h-7zM3.5 10.5v-7h7" />
348
355
  </button>
349
356
  )}
350
- {!fixed && !r.virtual && (
357
+ {!fixed && !readOnly && !r.virtual && (
351
358
  <button
352
359
  type="button"
353
360
  class="icon"
@@ -362,7 +369,7 @@ export function ParamGrid({
362
369
  </tr>
363
370
  )
364
371
  })}
365
- {!fixed && (
372
+ {!fixed && !readOnly && (
366
373
  <tr class="ghost" key={ghost}>
367
374
  <td class="g-check" />
368
375
  <td class="g-key">
@@ -1,17 +1,25 @@
1
- import { collectPlaceholders, type Encoding, type Example, type RunResult } from '@specbook/core'
2
- import { useEffect, useMemo, useState } from 'preact/hooks'
1
+ import {
2
+ collectPlaceholders,
3
+ type Encoding,
4
+ type Example,
5
+ type RunResult,
6
+ rootName,
7
+ } from '@specbook/core'
8
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
9
  import { EnvBadge } from '../components/EnvBadge'
4
10
  import type { Variant } from '../engine'
5
11
  import { fieldDoc, withNote } from '../fielddoc'
6
12
  import { engine, isStatic, registry, saveFile } from '../runtime'
7
13
  import { type Defs, type JsonSchema, propsOf, unwrap } from '../schema'
8
14
  import { statusClass, statusName } from '../status'
9
- import { env, locale, seed, version } from '../store'
15
+ import { kv } from '../storage'
16
+ import { env, envSettingsOpen, envVarsVersion, features, locale, seed, version } from '../store'
17
+ import { announcement, describeRun } from './announce'
10
18
  import { Body } from './Body'
11
19
  import { DraftCodeSample } from './CodeSample'
12
20
  import { type Draft, draftFrom, forStorage, previewUrl, requestParts } from './draft'
13
21
  import { useOperationNotes } from './fieldnotes'
14
- import { PaperPlane } from './icons'
22
+ import { PaperPlane, StopSquare } from './icons'
15
23
  import { type Documented, ParamGrid } from './ParamGrid'
16
24
  import { RequestView } from './RequestView'
17
25
  import { ResponseView } from './ResponseView'
@@ -33,6 +41,8 @@ interface Props {
33
41
  variants: Variant[]
34
42
  /** example name to open, '' for the first */
35
43
  initial: string
44
+ /** run once as soon as the request is ready (an embed that asks for it, never against production) */
45
+ autoRun?: boolean
36
46
  }
37
47
 
38
48
  const storeKey = (key: string, sel: string) => `specbook:draft:${key}:${sel}`
@@ -44,7 +54,7 @@ const slug = (s: string) =>
44
54
 
45
55
  function readDraft(key: string): Draft | undefined {
46
56
  try {
47
- const raw = localStorage.getItem(key)
57
+ const raw = kv.local.getItem(key)
48
58
  return raw ? (JSON.parse(raw) as Draft) : undefined
49
59
  } catch {
50
60
  return undefined
@@ -64,21 +74,28 @@ interface SavedExample {
64
74
 
65
75
  function takeSaved(key: string): SavedExample | undefined {
66
76
  try {
67
- const raw = sessionStorage.getItem(SAVED_KEY)
77
+ const raw = kv.session.getItem(SAVED_KEY)
68
78
  const saved = raw ? (JSON.parse(raw) as SavedExample) : undefined
69
79
  if (saved?.key !== key) return undefined
70
- sessionStorage.removeItem(SAVED_KEY)
80
+ kv.session.removeItem(SAVED_KEY)
71
81
  return saved
72
82
  } catch {
73
83
  return undefined
74
84
  }
75
85
  }
76
86
 
77
- export function RequestPanel({ service, op, variants, initial }: Props) {
87
+ export function RequestPanel({ service, op, variants, initial, autoRun }: Props) {
78
88
  const key = `${service}/${op.id}`
89
+ const panelRoot = useRef<HTMLDivElement>(null)
90
+ const autoRan = useRef(false)
91
+ /** ends the request that is out: a person pressing Stop, so a request that never answers is not the end of the panel */
92
+ const stopper = useRef<AbortController | undefined>(undefined)
79
93
  const examples = engine.examplesOf(key)
80
94
  const [notice, setNotice] = useState<SavedExample | undefined>(() => takeSaved(key))
81
95
  const [sel, setSel] = useState(() => {
96
+ // a link to a saved request ("saved:<file>") opens on it
97
+ if (initial?.startsWith('saved:') && variants.some((v) => `saved:${v.file}` === initial))
98
+ return initial
82
99
  const named = examples.find((e) => e.name === (notice?.name ?? initial))
83
100
  return named
84
101
  ? `ex:${named.name}`
@@ -110,11 +127,27 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
110
127
  const [baseUrl, setBaseUrl] = useState('')
111
128
  const [known, setKnown] = useState<string[]>()
112
129
 
130
+ const [addressError, setAddressError] = useState<string>()
113
131
  useEffect(() => {
132
+ let on = true
133
+ setAddressError(undefined)
134
+ // mock has no address; elsewhere it is whatever this environment's variables make it
135
+ if (env.value === 'mock') {
136
+ setBaseUrl('')
137
+ return
138
+ }
114
139
  engine
115
- .config(version.value)
116
- .then((c) => setBaseUrl(c.services?.[service]?.baseUrl?.[env.value] ?? ''))
117
- }, [service, env.value])
140
+ .baseUrlOf(service, env.value, version.value)
141
+ .then((url) => on && setBaseUrl(url ?? ''))
142
+ .catch((e: unknown) => {
143
+ if (!on) return
144
+ setBaseUrl('')
145
+ setAddressError(e instanceof Error ? e.message : String(e))
146
+ })
147
+ return () => {
148
+ on = false
149
+ }
150
+ }, [service, env.value, version.value, envVarsVersion.value])
118
151
 
119
152
  useEffect(() => {
120
153
  let live = true
@@ -174,8 +207,8 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
174
207
  try {
175
208
  const k = storeKey(key, sel)
176
209
  if (baseDraft && JSON.stringify(forStorage(d)) === JSON.stringify(forStorage(baseDraft)))
177
- localStorage.removeItem(k)
178
- else localStorage.setItem(k, JSON.stringify(forStorage(d)))
210
+ kv.local.removeItem(k)
211
+ else kv.local.setItem(k, JSON.stringify(forStorage(d)))
179
212
  } catch {
180
213
  /* optional */
181
214
  }
@@ -208,7 +241,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
208
241
  .filter(
209
242
  (n) => !n.startsWith('faker.') && !n.startsWith('secrets.') && !n.startsWith('session.'),
210
243
  )
211
- .map((n) => n.split('.')[0] as string)
244
+ .map(rootName)
212
245
  .filter((n, i, a) => a.indexOf(n) === i)
213
246
  } catch {
214
247
  return []
@@ -217,37 +250,49 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
217
250
 
218
251
  // placeholders nothing supplies: not in config or the environment's files. They stay listed while
219
252
  // you type, so the field you are filling does not disappear under you.
220
- const needed = known ? placeholders.filter((n) => !known.includes(n)) : []
253
+ // a saved request brings the values of its own `init`: they are what a run uses, so nothing is missing
254
+ const own = Object.keys(saved?.def.input.init ?? {})
255
+ const needed = known ? placeholders.filter((n) => !known.includes(n) && !own.includes(n)) : []
221
256
  const unfilled = needed.filter((n) => (vars[n] ?? '') === '')
222
257
 
223
258
  const play = async (forceStatus?: number) => {
224
- if (!draft) return
259
+ if (!draft || stopper.current) return
225
260
  setError(undefined)
226
261
  setRunning(true)
262
+ const stop = new AbortController()
263
+ stopper.current = stop
227
264
  try {
228
265
  const def = saved
229
266
  ? saved.def
230
267
  : await engine.operationRequest(key, version.value, requestParts(draft, encoding))
231
- setResult(
232
- await engine.run(def, {
233
- env: env.value,
234
- version: version.value,
235
- mockStatus: env.value === 'mock' ? (forceStatus ?? forced) : undefined,
236
- locale: locale.value,
237
- seed: seed.value,
238
- vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => v !== '')),
239
- fileDir: base?.dir,
240
- }),
241
- )
268
+ const ran = await engine.run(def, {
269
+ env: env.value,
270
+ version: version.value,
271
+ mockStatus: env.value === 'mock' ? (forceStatus ?? forced) : undefined,
272
+ locale: locale.value,
273
+ seed: seed.value,
274
+ vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => v !== '')),
275
+ fileDir: base?.dir,
276
+ signal: stop.signal,
277
+ })
278
+ setResult(ran)
279
+ announcement.value = describeRun(ran)
242
280
  } catch (e) {
243
281
  setError(e instanceof Error ? e.message : String(e))
244
282
  } finally {
283
+ stopper.current = undefined
245
284
  setRunning(false)
246
285
  // what you sent a request to see is the response, so show it (the tab stays one click away)
247
286
  setTab('response')
248
287
  }
249
288
  }
250
289
 
290
+ useEffect(() => {
291
+ if (!autoRun || !draft || autoRan.current) return
292
+ autoRan.current = true
293
+ void play()
294
+ }, [draft])
295
+
251
296
  const saveExample = async (name: string) => {
252
297
  if (!draft) return
253
298
  const parts = requestParts(draft, encoding)
@@ -265,7 +310,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
265
310
  // set before writing: the watcher reloads the page as soon as the file lands
266
311
  const saved: SavedExample = { key, path, name: file }
267
312
  try {
268
- sessionStorage.setItem(SAVED_KEY, JSON.stringify(saved))
313
+ kv.session.setItem(SAVED_KEY, JSON.stringify(saved))
269
314
  } catch {
270
315
  /* the confirmation below still shows when there is no reload */
271
316
  }
@@ -301,12 +346,14 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
301
346
  play()
302
347
  }
303
348
  }
304
- addEventListener('keydown', send)
305
- return () => removeEventListener('keydown', send)
349
+ // on the page's own root (a shadow root in an embed), not the window, so a host page's keys are left alone
350
+ const root: EventTarget = panelRoot.current?.getRootNode() ?? window
351
+ root.addEventListener('keydown', send as EventListener)
352
+ return () => root.removeEventListener('keydown', send as EventListener)
306
353
  })
307
354
 
308
355
  return (
309
- <div class="request-panel" data-op={key}>
356
+ <div class="request-panel" data-op={key} ref={panelRoot}>
310
357
  <div class="rq">
311
358
  <div class="rq-bar">
312
359
  <span class={`method ${op.method}`}>{op.method}</span>
@@ -332,15 +379,26 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
332
379
  <button
333
380
  type="button"
334
381
  class="btn primary send"
335
- title="Send (⌘/Ctrl + Enter)"
336
- aria-label="Send"
337
- disabled={running || !draft}
338
- onClick={() => play()}
382
+ title={running ? 'Stop' : 'Send (⌘/Ctrl + Enter)'}
383
+ aria-label={running ? 'Stop' : 'Send'}
384
+ disabled={!running && !draft}
385
+ onClick={() => (running ? stopper.current?.abort() : play())}
339
386
  >
340
- {running ? '…' : <PaperPlane />}
387
+ {running ? <StopSquare /> : <PaperPlane />}
341
388
  </button>
342
389
  </div>
343
390
 
391
+ {addressError && (
392
+ <p class="envmenu-warn" role="alert">
393
+ {addressError}.{' '}
394
+ {features.value.environmentSettings && (
395
+ <button type="button" class="link" onClick={() => (envSettingsOpen.value = true)}>
396
+ Open Environment settings
397
+ </button>
398
+ )}
399
+ </p>
400
+ )}
401
+
344
402
  {requestNote && (
345
403
  <details class="step-about" key={saved?.file}>
346
404
  <summary>About this request</summary>
@@ -383,7 +441,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
383
441
  </option>
384
442
  ))}
385
443
  {variants.map((v) => (
386
- <option value={`saved:${v.file}`}>{v.name} · saved request</option>
444
+ <option value={`saved:${v.file}`}>{v.name} · request</option>
387
445
  ))}
388
446
  </select>
389
447
  </label>
@@ -468,8 +526,9 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
468
526
 
469
527
  {saved && (
470
528
  <p class="muted lock">
471
- A saved request: its own values, checks and mock responses apply, and it is read-only
472
- here.
529
+ A request from your project: it runs with its own values and checks. Edit its file to
530
+ change it.
531
+ {env.value === 'mock' && saved.def.mockHook ? ' Its mock hook shapes the answer.' : ''}
473
532
  </p>
474
533
  )}
475
534
 
@@ -540,6 +599,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
540
599
  title="Query"
541
600
  rows={draft.query}
542
601
  documented={sections.query}
602
+ readOnly={!!saved}
543
603
  onChange={(query) => edit({ ...draft, query })}
544
604
  />
545
605
  )}
@@ -549,6 +609,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
549
609
  rows={draft.path}
550
610
  documented={sections.path}
551
611
  fixed
612
+ readOnly={!!saved}
552
613
  onChange={(path) => edit({ ...draft, path })}
553
614
  />
554
615
  )}
@@ -559,6 +620,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
559
620
  title="Headers"
560
621
  rows={draft.headers}
561
622
  documented={sections.headers}
623
+ readOnly={!!saved}
562
624
  onChange={(headers) => edit({ ...draft, headers })}
563
625
  />
564
626
  )}
@@ -573,6 +635,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
573
635
  defs={defs}
574
636
  files={meta?.files}
575
637
  notes={opNotes.notes}
638
+ readOnly={!!saved}
576
639
  />
577
640
  )}
578
641
  </div>
@@ -4,8 +4,9 @@ import { fieldDoc, withNote } from '../fielddoc'
4
4
  import { type Defs, type JsonSchema, linePaths, pathString } from '../schema'
5
5
  import { BodyEditor } from './BodyEditor'
6
6
  import { FieldDoc } from './FieldDoc'
7
+ import { show } from './short'
7
8
 
8
- const pretty = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))
9
+ const pretty = (v: unknown) => (typeof v === 'string' ? v : show(v, 2))
9
10
 
10
11
  /** Repeated keys for arrays, as the transport sends them. */
11
12
  export function queryString(query: Record<string, string | string[]>) {
@@ -1,12 +1,14 @@
1
1
  import type { StepResult } from '@specbook/core'
2
- import { useMemo, useState } from 'preact/hooks'
2
+ import { useEffect, useMemo, useState } from 'preact/hooks'
3
3
  import { fieldDoc } from '../fielddoc'
4
4
  import { type Defs, type JsonSchema, linePaths, validate } from '../schema'
5
5
  import { statusClass, statusName } from '../status'
6
6
  import { BodyEditor } from './BodyEditor'
7
+ import { Checks } from './Checks'
7
8
  import { FieldDoc } from './FieldDoc'
8
9
  import { PaperPlane } from './icons'
9
10
  import { queryString } from './RequestView'
11
+ import { show } from './short'
10
12
 
11
13
  interface Props {
12
14
  step?: StepResult
@@ -17,7 +19,7 @@ interface Props {
17
19
  hideRequest?: boolean
18
20
  }
19
21
 
20
- const pretty = (v: unknown) => JSON.stringify(v, null, 2)
22
+ const pretty = (v: unknown) => show(v, 2)
21
23
  const size = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`)
22
24
 
23
25
  export function ResponseView({ step, error, schemas, defs, hideRequest }: Props) {
@@ -25,10 +27,14 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
25
27
  const [line, setLine] = useState(0)
26
28
  const response = step?.response
27
29
  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
- )
30
+ const issues = useMemo(() => {
31
+ try {
32
+ return response && schema ? validate(response.body, schema, defs, { reportExtra: true }) : []
33
+ } catch {
34
+ // an answer the schema check cannot make sense of is shown as it came, without the check
35
+ return []
36
+ }
37
+ }, [response, schema, defs])
32
38
  const text =
33
39
  response === undefined || response.body === undefined || response.body === ''
34
40
  ? ''
@@ -42,6 +48,10 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
42
48
  }, [paths, line, schema, defs])
43
49
  const checks = step?.assertions ?? []
44
50
  const failed = checks.filter((a) => !a.pass).length
51
+ // a run that failed a check opens on it: that is what the person came to see
52
+ useEffect(() => {
53
+ if (step && failed > 0) setTab('checks')
54
+ }, [step])
45
55
  const headers = Object.entries(response?.headers ?? {})
46
56
  const empty = !response && !error && !step?.error
47
57
 
@@ -112,7 +122,14 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
112
122
  >
113
123
  {label}
114
124
  {n > 0 && (
115
- <span class={`count ${id === 'checks' && failed ? 'bad' : ''}`}>{n}</span>
125
+ <span
126
+ class={`count ${id === 'checks' && failed ? 'bad' : ''}`}
127
+ title={
128
+ id === 'checks' && failed ? `${failed} of ${n} checks failed` : undefined
129
+ }
130
+ >
131
+ {id === 'checks' && failed ? failed : n}
132
+ </span>
116
133
  )}
117
134
  </button>
118
135
  ))}
@@ -165,13 +182,7 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
165
182
  {checks.length === 0 ? (
166
183
  <p class="muted">This request has no checks.</p>
167
184
  ) : (
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>
185
+ <Checks assertions={checks} />
175
186
  )}
176
187
  </div>
177
188
  </div>