@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,124 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
value: string
|
|
5
|
+
options: string[]
|
|
6
|
+
onChange: (value: string) => void
|
|
7
|
+
label: string
|
|
8
|
+
placeholder?: string
|
|
9
|
+
/** forwarded so the grid can move between rows with Enter and the arrow keys */
|
|
10
|
+
onKeyDown?: (e: KeyboardEvent) => void
|
|
11
|
+
cell?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A text input with the spec's allowed values as a dropdown; any other value can still be typed. */
|
|
15
|
+
export function Combo({ value, options, onChange, label, placeholder, onKeyDown, cell }: Props) {
|
|
16
|
+
const [open, setOpen] = useState(false)
|
|
17
|
+
const [at, setAt] = useState(-1)
|
|
18
|
+
const [pos, setPos] = useState<Record<string, string>>()
|
|
19
|
+
const box = useRef<HTMLDivElement>(null)
|
|
20
|
+
|
|
21
|
+
/** The list is `fixed` so the grid's rounded, clipped table and the scrolling pane can't cut it off. */
|
|
22
|
+
const place = () => {
|
|
23
|
+
const r = box.current?.getBoundingClientRect()
|
|
24
|
+
if (!r) return
|
|
25
|
+
const below = innerHeight - r.bottom - 8
|
|
26
|
+
const flip = below < 160 && r.top > below
|
|
27
|
+
// strings with units: Preact 11 sets style properties as given
|
|
28
|
+
setPos({
|
|
29
|
+
left: `${r.left}px`,
|
|
30
|
+
width: `${Math.max(r.width, 160)}px`,
|
|
31
|
+
maxHeight: `${Math.min(240, (flip ? r.top : below) - 4)}px`,
|
|
32
|
+
...(flip ? { bottom: `${innerHeight - r.top + 2}px` } : { top: `${r.bottom + 2}px` }),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!open) return
|
|
38
|
+
place()
|
|
39
|
+
const close = (e: Event) => {
|
|
40
|
+
if (!(e.target instanceof Node && box.current?.contains(e.target))) setOpen(false)
|
|
41
|
+
}
|
|
42
|
+
addEventListener('scroll', close, true)
|
|
43
|
+
addEventListener('resize', close)
|
|
44
|
+
return () => {
|
|
45
|
+
removeEventListener('scroll', close, true)
|
|
46
|
+
removeEventListener('resize', close)
|
|
47
|
+
}
|
|
48
|
+
}, [open])
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) return
|
|
52
|
+
const away = (e: MouseEvent) => {
|
|
53
|
+
if (!e.composedPath().includes(box.current as Node)) setOpen(false)
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener('mousedown', away, true)
|
|
56
|
+
return () => document.removeEventListener('mousedown', away, true)
|
|
57
|
+
}, [open])
|
|
58
|
+
|
|
59
|
+
const pick = (v: string) => {
|
|
60
|
+
onChange(v)
|
|
61
|
+
setOpen(false)
|
|
62
|
+
setAt(-1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div class="combo" ref={box}>
|
|
67
|
+
<input
|
|
68
|
+
class="cell"
|
|
69
|
+
data-cell={cell}
|
|
70
|
+
value={value}
|
|
71
|
+
placeholder={placeholder}
|
|
72
|
+
aria-label={label}
|
|
73
|
+
aria-expanded={open}
|
|
74
|
+
role="combobox"
|
|
75
|
+
autocomplete="off"
|
|
76
|
+
onFocus={() => setOpen(true)}
|
|
77
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
78
|
+
onKeyDown={(e) => {
|
|
79
|
+
if (open && e.key === 'ArrowDown') {
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
setAt((at + 1) % options.length)
|
|
82
|
+
} else if (open && e.key === 'ArrowUp') {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
setAt((at - 1 + options.length) % options.length)
|
|
85
|
+
} else if (open && e.key === 'Enter' && at >= 0) {
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
pick(options[at] as string)
|
|
88
|
+
} else if (e.key === 'Escape' && open) {
|
|
89
|
+
e.stopPropagation()
|
|
90
|
+
setOpen(false)
|
|
91
|
+
} else {
|
|
92
|
+
if (e.key === 'Enter' || e.key === 'Tab') setOpen(false)
|
|
93
|
+
onKeyDown?.(e)
|
|
94
|
+
}
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class="combo-toggle"
|
|
100
|
+
tabIndex={-1}
|
|
101
|
+
aria-label={`Show ${label} options`}
|
|
102
|
+
onClick={() => setOpen(!open)}
|
|
103
|
+
>
|
|
104
|
+
▾
|
|
105
|
+
</button>
|
|
106
|
+
{open && (
|
|
107
|
+
<div class="combo-list" role="listbox" style={pos}>
|
|
108
|
+
{options.map((o, i) => (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
role="option"
|
|
112
|
+
aria-selected={o === value}
|
|
113
|
+
class={`${o === value ? 'on' : ''} ${i === at ? 'at' : ''}`}
|
|
114
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
115
|
+
onClick={() => pick(o)}
|
|
116
|
+
>
|
|
117
|
+
{o}
|
|
118
|
+
</button>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks'
|
|
2
|
+
import { defaultFor, type FieldSpec } from '../components/Fields'
|
|
3
|
+
import { Combo } from './Combo'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
title: string
|
|
7
|
+
specs: Record<string, FieldSpec>
|
|
8
|
+
values: Record<string, unknown>
|
|
9
|
+
onChange: (values: Record<string, unknown>) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b)
|
|
13
|
+
|
|
14
|
+
function hint(spec: FieldSpec): string {
|
|
15
|
+
const bits: string[] = [spec.kind]
|
|
16
|
+
if (spec.min !== undefined || spec.max !== undefined)
|
|
17
|
+
bits.push(`${spec.min ?? ''}–${spec.max ?? ''}`)
|
|
18
|
+
return bits.join(' · ')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One editable cell, by control kind. Nested objects and arrays are edited as JSON. */
|
|
22
|
+
function ValueCell({
|
|
23
|
+
name,
|
|
24
|
+
spec,
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
}: {
|
|
28
|
+
name: string
|
|
29
|
+
spec: FieldSpec
|
|
30
|
+
value: unknown
|
|
31
|
+
onChange: (v: unknown) => void
|
|
32
|
+
}) {
|
|
33
|
+
const [raw, setRaw] = useState<string>()
|
|
34
|
+
switch (spec.kind) {
|
|
35
|
+
case 'number':
|
|
36
|
+
return (
|
|
37
|
+
<input
|
|
38
|
+
class="cell"
|
|
39
|
+
type="number"
|
|
40
|
+
aria-label={name}
|
|
41
|
+
value={String(value ?? '')}
|
|
42
|
+
min={spec.min}
|
|
43
|
+
max={spec.max}
|
|
44
|
+
onInput={(e) => {
|
|
45
|
+
const v = (e.target as HTMLInputElement).value
|
|
46
|
+
onChange(v === '' ? undefined : Number(v))
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
case 'boolean':
|
|
51
|
+
return (
|
|
52
|
+
<Combo
|
|
53
|
+
label={name}
|
|
54
|
+
value={String(Boolean(value))}
|
|
55
|
+
options={['true', 'false']}
|
|
56
|
+
onChange={(v) => (v === 'true' || v === 'false' ? onChange(v === 'true') : undefined)}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
case 'date':
|
|
60
|
+
return (
|
|
61
|
+
<input
|
|
62
|
+
class="cell"
|
|
63
|
+
type="date"
|
|
64
|
+
aria-label={name}
|
|
65
|
+
value={String(value ?? '')}
|
|
66
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
case 'select': {
|
|
70
|
+
const options = spec.options ?? []
|
|
71
|
+
const current = options.find((o) => same(o.value, value))
|
|
72
|
+
return (
|
|
73
|
+
<Combo
|
|
74
|
+
label={name}
|
|
75
|
+
value={current?.label ?? String(value ?? '')}
|
|
76
|
+
options={options.map((o) => o.label)}
|
|
77
|
+
onChange={(label) => {
|
|
78
|
+
const hit = options.find((o) => o.label === label)
|
|
79
|
+
if (hit) onChange(hit.value)
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
case 'multiselect': {
|
|
85
|
+
const chosen = (value as unknown[]) ?? []
|
|
86
|
+
return (
|
|
87
|
+
<div class="multi-cell">
|
|
88
|
+
{(spec.options ?? []).map((o) => (
|
|
89
|
+
<label>
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
checked={chosen.some((c) => same(c, o.value))}
|
|
93
|
+
onChange={(e) =>
|
|
94
|
+
onChange(
|
|
95
|
+
(e.target as HTMLInputElement).checked
|
|
96
|
+
? [...chosen, o.value]
|
|
97
|
+
: chosen.filter((c) => !same(c, o.value)),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
{o.label}
|
|
102
|
+
</label>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
case 'object':
|
|
108
|
+
case 'array':
|
|
109
|
+
return (
|
|
110
|
+
<input
|
|
111
|
+
class="cell mono"
|
|
112
|
+
aria-label={name}
|
|
113
|
+
value={raw ?? JSON.stringify(value ?? defaultFor(spec))}
|
|
114
|
+
onInput={(e) => {
|
|
115
|
+
const text = (e.target as HTMLInputElement).value
|
|
116
|
+
setRaw(text)
|
|
117
|
+
try {
|
|
118
|
+
onChange(JSON.parse(text))
|
|
119
|
+
} catch {
|
|
120
|
+
/* keep typing; the last valid value stays */
|
|
121
|
+
}
|
|
122
|
+
}}
|
|
123
|
+
onBlur={() => setRaw(undefined)}
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
default:
|
|
127
|
+
return (
|
|
128
|
+
<input
|
|
129
|
+
class="cell"
|
|
130
|
+
aria-label={name}
|
|
131
|
+
value={String(value ?? '')}
|
|
132
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A sequence's controls (params, or the choices of an input step) as the same spreadsheet the request
|
|
140
|
+
* parameters use: name, value, description.
|
|
141
|
+
*/
|
|
142
|
+
export function ControlsGrid({ title, specs, values, onChange }: Props) {
|
|
143
|
+
const names = Object.keys(specs)
|
|
144
|
+
if (names.length === 0) return null
|
|
145
|
+
return (
|
|
146
|
+
<section class="grid controls-grid" aria-label={title}>
|
|
147
|
+
<h3>{title}</h3>
|
|
148
|
+
<table>
|
|
149
|
+
<colgroup>
|
|
150
|
+
<col class="c-key" />
|
|
151
|
+
<col class="c-val" />
|
|
152
|
+
<col />
|
|
153
|
+
</colgroup>
|
|
154
|
+
<thead>
|
|
155
|
+
<tr>
|
|
156
|
+
<th class="g-key">Name</th>
|
|
157
|
+
<th class="g-val">Value</th>
|
|
158
|
+
<th class="g-doc">Description</th>
|
|
159
|
+
</tr>
|
|
160
|
+
</thead>
|
|
161
|
+
<tbody>
|
|
162
|
+
{names.map((name) => {
|
|
163
|
+
const spec = specs[name] as FieldSpec
|
|
164
|
+
return (
|
|
165
|
+
<tr key={name}>
|
|
166
|
+
<td class="g-key">
|
|
167
|
+
<span class="key">
|
|
168
|
+
<code>{spec.label ?? name}</code>
|
|
169
|
+
</span>
|
|
170
|
+
</td>
|
|
171
|
+
<td class="g-val">
|
|
172
|
+
<ValueCell
|
|
173
|
+
name={name}
|
|
174
|
+
spec={spec}
|
|
175
|
+
value={values[name]}
|
|
176
|
+
onChange={(v) => onChange({ ...values, [name]: v })}
|
|
177
|
+
/>
|
|
178
|
+
</td>
|
|
179
|
+
<td class="g-doc" title={spec.description}>
|
|
180
|
+
<span class="chip">{hint(spec)}</span> {spec.description}
|
|
181
|
+
</td>
|
|
182
|
+
</tr>
|
|
183
|
+
)
|
|
184
|
+
})}
|
|
185
|
+
</tbody>
|
|
186
|
+
</table>
|
|
187
|
+
</section>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FieldDoc as Doc } from '../fielddoc'
|
|
2
|
+
import { markdownToHtml } from '../markdown'
|
|
3
|
+
|
|
4
|
+
/** The documentation for the field the cursor is on, shown below the editor instead of beside each line. */
|
|
5
|
+
export function FieldDoc({ doc, hint }: { doc?: Doc; hint?: string }) {
|
|
6
|
+
if (!doc)
|
|
7
|
+
return (
|
|
8
|
+
<div class="field-doc empty" part="field-doc" aria-live="polite">
|
|
9
|
+
{hint ?? 'Put the cursor on a field to see what the API says about it.'}
|
|
10
|
+
</div>
|
|
11
|
+
)
|
|
12
|
+
return (
|
|
13
|
+
<div class="field-doc" part="field-doc" aria-live="polite">
|
|
14
|
+
<div class="field-doc-head">
|
|
15
|
+
<code>{doc.path}</code>
|
|
16
|
+
<span class="chip">{doc.type}</span>
|
|
17
|
+
{doc.required && <span class="chip req">required</span>}
|
|
18
|
+
{doc.deprecated && <span class="chip warn">deprecated</span>}
|
|
19
|
+
</div>
|
|
20
|
+
{doc.description ? (
|
|
21
|
+
<div
|
|
22
|
+
class="field-doc-text"
|
|
23
|
+
dangerouslySetInnerHTML={{ __html: markdownToHtml(doc.description) }}
|
|
24
|
+
/>
|
|
25
|
+
) : doc.note ? null : (
|
|
26
|
+
<p class="muted">
|
|
27
|
+
The spec has no description for this field.
|
|
28
|
+
{doc.parent && <> It is part of: {doc.parent}</>}
|
|
29
|
+
</p>
|
|
30
|
+
)}
|
|
31
|
+
{doc.note && <div class="field-note" dangerouslySetInnerHTML={{ __html: doc.note }} />}
|
|
32
|
+
{doc.enum && (
|
|
33
|
+
<p class="field-doc-enum">
|
|
34
|
+
{doc.enum.slice(0, 12).map((v) => (
|
|
35
|
+
<code>{v}</code>
|
|
36
|
+
))}
|
|
37
|
+
{doc.enum.length > 12 && <span class="muted">+{doc.enum.length - 12} more</span>}
|
|
38
|
+
</p>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { inlineSummary } from '../markdown'
|
|
2
|
+
import { type Defs, describe, type JsonSchema, kindOf, propsOf, unwrap } from '../schema'
|
|
3
|
+
import { type FieldSection, noteKey } from './fieldnotes-split'
|
|
4
|
+
|
|
5
|
+
interface Row {
|
|
6
|
+
name: string
|
|
7
|
+
depth: number
|
|
8
|
+
type: string
|
|
9
|
+
required: boolean
|
|
10
|
+
description?: string
|
|
11
|
+
enum?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_ROWS = 60
|
|
15
|
+
|
|
16
|
+
function typeOf(u: JsonSchema, defs: Defs): string {
|
|
17
|
+
const kind = kindOf(u)
|
|
18
|
+
if (kind === 'array') return `${kindOf(unwrap(u.items, defs))}[]`
|
|
19
|
+
return u.format ? `${kind} · ${u.format}` : kind
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function flatten(schema: JsonSchema | undefined, defs: Defs, depth = 0, prefix = ''): Row[] {
|
|
23
|
+
if (!schema || depth > 2) return []
|
|
24
|
+
const rows: Row[] = []
|
|
25
|
+
for (const p of propsOf(schema, defs)) {
|
|
26
|
+
const u = unwrap(p.schema, defs)
|
|
27
|
+
const name = prefix ? `${prefix}.${p.name}` : p.name
|
|
28
|
+
rows.push({
|
|
29
|
+
name,
|
|
30
|
+
depth,
|
|
31
|
+
type: typeOf(u, defs),
|
|
32
|
+
required: p.required,
|
|
33
|
+
description: describe(u),
|
|
34
|
+
enum: Array.isArray(u.enum) && u.enum.length <= 10 ? u.enum.map(String) : undefined,
|
|
35
|
+
})
|
|
36
|
+
const nested = kindOf(u) === 'array' ? unwrap(u.items, defs) : u
|
|
37
|
+
if (kindOf(nested) === 'object') rows.push(...flatten(nested, defs, depth + 1, name))
|
|
38
|
+
}
|
|
39
|
+
return rows
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function FieldTable({
|
|
43
|
+
schema,
|
|
44
|
+
defs,
|
|
45
|
+
section = 'data',
|
|
46
|
+
notes,
|
|
47
|
+
}: {
|
|
48
|
+
schema?: JsonSchema
|
|
49
|
+
defs: Defs
|
|
50
|
+
/** where these fields sit in the request, for looking up the project's notes */
|
|
51
|
+
section?: FieldSection
|
|
52
|
+
notes?: Map<string, string>
|
|
53
|
+
}) {
|
|
54
|
+
const rows = flatten(schema, defs)
|
|
55
|
+
if (rows.length === 0) return null
|
|
56
|
+
return (
|
|
57
|
+
<table class="fields">
|
|
58
|
+
<thead>
|
|
59
|
+
<tr>
|
|
60
|
+
<th>Field</th>
|
|
61
|
+
<th>Type</th>
|
|
62
|
+
<th>Description</th>
|
|
63
|
+
</tr>
|
|
64
|
+
</thead>
|
|
65
|
+
<tbody>
|
|
66
|
+
{rows.slice(0, MAX_ROWS).map((r) => (
|
|
67
|
+
<tr>
|
|
68
|
+
<td style={{ paddingLeft: `${r.depth * 16 + 8}px` }}>
|
|
69
|
+
<code>{r.name.split('.').pop()}</code>
|
|
70
|
+
{r.required && (
|
|
71
|
+
<span class="req" title="required">
|
|
72
|
+
*
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</td>
|
|
76
|
+
<td>
|
|
77
|
+
<span class="chip">{r.type}</span>
|
|
78
|
+
</td>
|
|
79
|
+
<td>
|
|
80
|
+
<span
|
|
81
|
+
class="doc-inline wrap"
|
|
82
|
+
dangerouslySetInnerHTML={{ __html: inlineSummary(r.description, 220) }}
|
|
83
|
+
/>
|
|
84
|
+
{notes?.get(noteKey(section, r.name)) && (
|
|
85
|
+
<div
|
|
86
|
+
class="field-note"
|
|
87
|
+
dangerouslySetInnerHTML={{
|
|
88
|
+
__html: notes.get(noteKey(section, r.name)) as string,
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
{r.enum && (
|
|
93
|
+
<span class="field-doc-enum">
|
|
94
|
+
{' '}
|
|
95
|
+
{r.enum.map((v) => (
|
|
96
|
+
<code>{v}</code>
|
|
97
|
+
))}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</td>
|
|
101
|
+
</tr>
|
|
102
|
+
))}
|
|
103
|
+
{rows.length > MAX_ROWS && (
|
|
104
|
+
<tr>
|
|
105
|
+
<td colSpan={3} class="muted">
|
|
106
|
+
+{rows.length - MAX_ROWS} more fields in the spec
|
|
107
|
+
</td>
|
|
108
|
+
</tr>
|
|
109
|
+
)}
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { SequenceInfo, Variant } from '../engine'
|
|
2
|
+
import type { PanelTab } from '../nav'
|
|
3
|
+
import { OperationDocs } from './OperationDocs'
|
|
4
|
+
import { Panel } from './Panel'
|
|
5
|
+
import { type OpInfo, RequestPanel } from './RequestPanel'
|
|
6
|
+
import { SequenceDocs } from './SequenceDocs'
|
|
7
|
+
import { SequencePanel } from './SequencePanel'
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
onClose: () => void
|
|
11
|
+
/** dims the page and traps focus; the standalone site docks the panel beside its list instead */
|
|
12
|
+
modal?: boolean
|
|
13
|
+
tab?: PanelTab
|
|
14
|
+
/** switch tab, optionally opening a named example */
|
|
15
|
+
onNavigate: (to: { tab: PanelTab; example?: string }) => void
|
|
16
|
+
onPrev?: () => void
|
|
17
|
+
onNext?: () => void
|
|
18
|
+
} & (
|
|
19
|
+
| { kind: 'op'; service: string; op: OpInfo; variants: Variant[]; example: string }
|
|
20
|
+
| { kind: 'seq'; info: SequenceInfo }
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Lazy entry for everything the side panel needs, so the catalog carries no editor code. */
|
|
24
|
+
export function Host(p: Props) {
|
|
25
|
+
const tab = p.tab ?? 'try'
|
|
26
|
+
const tabs = (
|
|
27
|
+
<div class="rq-tabs panel-tabs" role="tablist">
|
|
28
|
+
{(
|
|
29
|
+
[
|
|
30
|
+
['try', p.kind === 'seq' ? 'Run' : 'Try'],
|
|
31
|
+
['docs', 'Docs'],
|
|
32
|
+
] as const
|
|
33
|
+
).map(([id, label]) => (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
role="tab"
|
|
37
|
+
aria-selected={tab === id}
|
|
38
|
+
class={`rq-tab ${tab === id ? 'on' : ''}`}
|
|
39
|
+
onClick={() => p.onNavigate({ tab: id })}
|
|
40
|
+
>
|
|
41
|
+
{label}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if (p.kind === 'seq')
|
|
48
|
+
return (
|
|
49
|
+
<Panel
|
|
50
|
+
title={p.info.seq.meta.title}
|
|
51
|
+
subtitle="Sequence"
|
|
52
|
+
flush
|
|
53
|
+
modal={p.modal}
|
|
54
|
+
onPrev={p.onPrev}
|
|
55
|
+
onNext={p.onNext}
|
|
56
|
+
onClose={p.onClose}
|
|
57
|
+
>
|
|
58
|
+
<div class="panel-split">
|
|
59
|
+
{tabs}
|
|
60
|
+
<div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
|
|
61
|
+
{tab === 'docs' ? <SequenceDocs info={p.info} /> : <SequencePanel info={p.info} />}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</Panel>
|
|
65
|
+
)
|
|
66
|
+
return (
|
|
67
|
+
<Panel
|
|
68
|
+
title={p.op.summary?.replace(/\.$/, '') || p.op.path}
|
|
69
|
+
subtitle={`${p.op.method} ${p.op.path}`}
|
|
70
|
+
flush
|
|
71
|
+
modal={p.modal}
|
|
72
|
+
onPrev={p.onPrev}
|
|
73
|
+
onNext={p.onNext}
|
|
74
|
+
onClose={p.onClose}
|
|
75
|
+
>
|
|
76
|
+
<div class="panel-split">
|
|
77
|
+
{tabs}
|
|
78
|
+
<div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
|
|
79
|
+
{tab === 'docs' ? (
|
|
80
|
+
<OperationDocs
|
|
81
|
+
key={`${p.service}/${p.op.id}`}
|
|
82
|
+
service={p.service}
|
|
83
|
+
op={p.op}
|
|
84
|
+
variants={p.variants}
|
|
85
|
+
onTry={(example) => p.onNavigate({ tab: 'try', example })}
|
|
86
|
+
/>
|
|
87
|
+
) : (
|
|
88
|
+
<RequestPanel
|
|
89
|
+
key={`${p.service}/${p.op.id}`}
|
|
90
|
+
service={p.service}
|
|
91
|
+
op={p.op}
|
|
92
|
+
variants={p.variants}
|
|
93
|
+
initial={p.example}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</Panel>
|
|
99
|
+
)
|
|
100
|
+
}
|