@specbook/ui 0.0.0-stage → 0.1.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/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
- package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
- package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
- package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
- package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
- package/dist-viewer/assets/worker-Dw3Ps3hk.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 +60 -3
- package/src/app.tsx +363 -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 +46 -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 +114 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +432 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +16 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +237 -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/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +193 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +625 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +156 -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 +10 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1897 -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 +78 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +285 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +51 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +154 -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 +171 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthProfile,
|
|
3
|
+
type Encoding,
|
|
4
|
+
type RenderedRequest,
|
|
5
|
+
render,
|
|
6
|
+
type SnippetInput,
|
|
7
|
+
type SnippetLang,
|
|
8
|
+
snippetLangs,
|
|
9
|
+
toSnippet,
|
|
10
|
+
} from '@specbook/core'
|
|
11
|
+
import type { ComponentChildren } from 'preact'
|
|
12
|
+
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
|
13
|
+
import { engine } from '../runtime'
|
|
14
|
+
import { version } from '../store'
|
|
15
|
+
import { type Draft, requestParts } from './draft'
|
|
16
|
+
|
|
17
|
+
const LANG_KEY = 'specbook:code-lang'
|
|
18
|
+
|
|
19
|
+
function savedLang(): SnippetLang {
|
|
20
|
+
try {
|
|
21
|
+
const v = localStorage.getItem(LANG_KEY)
|
|
22
|
+
if (v === 'curl' || v === 'fetch') return v
|
|
23
|
+
} catch {
|
|
24
|
+
/* optional */
|
|
25
|
+
}
|
|
26
|
+
return 'curl'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Applied = { headers?: Record<string, string>; query?: Record<string, string> } | undefined
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
service: string
|
|
33
|
+
/** auth profile to apply: its name, `service` for the service's default, or nothing */
|
|
34
|
+
profile?: string
|
|
35
|
+
/** shown as a hint under the sample */
|
|
36
|
+
secured?: boolean
|
|
37
|
+
/** builds the request; keep it stable (useCallback) so values from faker do not change per render */
|
|
38
|
+
make: (base: string, applied: Applied) => SnippetInput
|
|
39
|
+
/** the request as it actually went out, offered as a third view once there is one */
|
|
40
|
+
sent?: ComponentChildren
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** cURL / Node fetch tabs with a copy button, for a request described by `make`. */
|
|
44
|
+
export function CodeSample({ service, profile, secured, make, sent }: Props) {
|
|
45
|
+
const [view, setView] = useState<SnippetLang | 'sent'>(savedLang)
|
|
46
|
+
const lang: SnippetLang = view === 'sent' ? 'curl' : view
|
|
47
|
+
const [copied, setCopied] = useState(false)
|
|
48
|
+
const [base, setBase] = useState<string>()
|
|
49
|
+
const [applied, setApplied] = useState<Applied>()
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let live = true
|
|
53
|
+
engine.config(version.value).then((c) => {
|
|
54
|
+
if (!live) return
|
|
55
|
+
const svc = c.services?.[service]
|
|
56
|
+
const urls = svc?.baseUrl ?? {}
|
|
57
|
+
setBase(urls.live ?? Object.values(urls)[0])
|
|
58
|
+
const name = profile === 'service' ? svc?.auth : profile
|
|
59
|
+
setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
|
|
60
|
+
})
|
|
61
|
+
return () => {
|
|
62
|
+
live = false
|
|
63
|
+
}
|
|
64
|
+
}, [service, profile])
|
|
65
|
+
|
|
66
|
+
const built = useMemo<{ text?: string; error?: string }>(() => {
|
|
67
|
+
try {
|
|
68
|
+
return { text: toSnippet(lang, make(base ?? 'https://api.example.com', applied)) }
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { error: e instanceof Error ? e.message : String(e) }
|
|
71
|
+
}
|
|
72
|
+
}, [make, lang, base, applied])
|
|
73
|
+
|
|
74
|
+
const pick = (l: SnippetLang | 'sent') => {
|
|
75
|
+
setView(l)
|
|
76
|
+
if (l === 'sent') return
|
|
77
|
+
try {
|
|
78
|
+
localStorage.setItem(LANG_KEY, l)
|
|
79
|
+
} catch {
|
|
80
|
+
/* optional */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const copy = async () => {
|
|
85
|
+
if (!built.text) return
|
|
86
|
+
try {
|
|
87
|
+
await navigator.clipboard.writeText(built.text)
|
|
88
|
+
setCopied(true)
|
|
89
|
+
setTimeout(() => setCopied(false), 1500)
|
|
90
|
+
} catch {
|
|
91
|
+
/* clipboard blocked: the text is selectable */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div class="code-sample">
|
|
97
|
+
<div class="code-head">
|
|
98
|
+
<fieldset class="seg" aria-label="Code sample">
|
|
99
|
+
{snippetLangs.map((l) => (
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
aria-pressed={view === l.id}
|
|
103
|
+
class={`seg-btn ${view === l.id ? 'on' : ''}`}
|
|
104
|
+
onClick={() => pick(l.id)}
|
|
105
|
+
>
|
|
106
|
+
{l.label}
|
|
107
|
+
</button>
|
|
108
|
+
))}
|
|
109
|
+
{sent !== undefined && (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-pressed={view === 'sent'}
|
|
113
|
+
class={`seg-btn ${view === 'sent' ? 'on' : ''}`}
|
|
114
|
+
onClick={() => pick('sent')}
|
|
115
|
+
>
|
|
116
|
+
Sent
|
|
117
|
+
</button>
|
|
118
|
+
)}
|
|
119
|
+
</fieldset>
|
|
120
|
+
<span class="spacer" />
|
|
121
|
+
{view !== 'sent' && (
|
|
122
|
+
<button type="button" class="btn small" onClick={copy} disabled={!built.text}>
|
|
123
|
+
{copied ? 'Copied' : 'Copy'}
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
{view === 'sent' ? (
|
|
128
|
+
sent
|
|
129
|
+
) : built.error ? (
|
|
130
|
+
<p class="muted">{built.error}</p>
|
|
131
|
+
) : (
|
|
132
|
+
<pre class="code-block" part="code">
|
|
133
|
+
<code>{built.text}</code>
|
|
134
|
+
</pre>
|
|
135
|
+
)}
|
|
136
|
+
{view !== 'sent' && secured && (
|
|
137
|
+
<p class="muted small">Secrets are read from environment variables; set them first.</p>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface DraftProps {
|
|
144
|
+
service: string
|
|
145
|
+
method: string
|
|
146
|
+
/** the operation's path template, e.g. /pets/{petId} */
|
|
147
|
+
path: string
|
|
148
|
+
draft: Draft
|
|
149
|
+
encoding: Encoding
|
|
150
|
+
vars: Record<string, string>
|
|
151
|
+
secured?: boolean
|
|
152
|
+
sent?: ComponentChildren
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The sample for the request as currently edited in the panel. */
|
|
156
|
+
export function DraftCodeSample({
|
|
157
|
+
service,
|
|
158
|
+
method,
|
|
159
|
+
path,
|
|
160
|
+
draft,
|
|
161
|
+
encoding,
|
|
162
|
+
vars,
|
|
163
|
+
secured,
|
|
164
|
+
sent,
|
|
165
|
+
}: DraftProps) {
|
|
166
|
+
const make = useCallback(
|
|
167
|
+
(base: string, applied: Applied): SnippetInput => {
|
|
168
|
+
const parts = requestParts(draft, encoding)
|
|
169
|
+
const raw = {
|
|
170
|
+
path: parts.path ?? {},
|
|
171
|
+
query: parts.query ?? {},
|
|
172
|
+
headers: parts.headers ?? {},
|
|
173
|
+
data: parts.data,
|
|
174
|
+
}
|
|
175
|
+
let r = raw
|
|
176
|
+
try {
|
|
177
|
+
r = render(raw, vars)
|
|
178
|
+
} catch {
|
|
179
|
+
/* a variable is still unset: show the placeholders as they are */
|
|
180
|
+
}
|
|
181
|
+
const url = path.replace(/\{([^}]+)\}/g, (m, n: string) => {
|
|
182
|
+
const v = (r.path as Record<string, unknown>)[n]
|
|
183
|
+
return v === undefined || v === '' ? m : encodeURIComponent(String(v))
|
|
184
|
+
})
|
|
185
|
+
return {
|
|
186
|
+
method,
|
|
187
|
+
url: `${base.replace(/\/+$/, '')}${url}`,
|
|
188
|
+
query: { ...(applied?.query ?? {}), ...(r.query as Record<string, string | string[]>) },
|
|
189
|
+
headers: { ...(applied?.headers ?? {}), ...(r.headers as Record<string, string>) },
|
|
190
|
+
body: r.data,
|
|
191
|
+
encoding,
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
[draft, encoding, vars, method, path],
|
|
195
|
+
)
|
|
196
|
+
return (
|
|
197
|
+
<CodeSample
|
|
198
|
+
service={service}
|
|
199
|
+
profile={secured ? 'service' : undefined}
|
|
200
|
+
secured={secured}
|
|
201
|
+
make={make}
|
|
202
|
+
sent={sent}
|
|
203
|
+
/>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The sample for a request a sequence step recorded (placeholders for secrets and session values kept). */
|
|
208
|
+
export function StepCodeSample({
|
|
209
|
+
request,
|
|
210
|
+
encoding,
|
|
211
|
+
sent,
|
|
212
|
+
}: {
|
|
213
|
+
request: RenderedRequest
|
|
214
|
+
encoding: Encoding
|
|
215
|
+
sent?: ComponentChildren
|
|
216
|
+
}) {
|
|
217
|
+
const make = useCallback(
|
|
218
|
+
(base: string, applied: Applied): SnippetInput => ({
|
|
219
|
+
method: request.method,
|
|
220
|
+
url: `${base.replace(/\/+$/, '')}${request.url}`,
|
|
221
|
+
query: { ...(applied?.query ?? {}), ...request.query },
|
|
222
|
+
headers: { ...(applied?.headers ?? {}), ...request.headers },
|
|
223
|
+
body: request.body,
|
|
224
|
+
encoding,
|
|
225
|
+
}),
|
|
226
|
+
[request, encoding],
|
|
227
|
+
)
|
|
228
|
+
return (
|
|
229
|
+
<CodeSample
|
|
230
|
+
service={request.service}
|
|
231
|
+
profile={request.profile}
|
|
232
|
+
secured={!!request.profile}
|
|
233
|
+
make={make}
|
|
234
|
+
sent={sent}
|
|
235
|
+
/>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
@@ -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
|
+
}
|