@typeonce/effect-machine-devtools 0.23.0 → 0.24.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/README.md +10 -4
- package/dist/DevServer.d.ts +2 -1
- package/dist/DevServer.d.ts.map +1 -1
- package/dist/DevServer.js.map +1 -1
- package/dist/DevToolsProtocol.d.ts +731 -17
- package/dist/DevToolsProtocol.d.ts.map +1 -1
- package/dist/DevToolsProtocol.js +191 -1
- package/dist/DevToolsProtocol.js.map +1 -1
- package/dist/MachineDocument.d.ts +78 -2
- package/dist/MachineDocument.d.ts.map +1 -1
- package/dist/MachineDocument.js +33 -1
- package/dist/MachineDocument.js.map +1 -1
- package/dist/MachineRegistry.d.ts +36 -6
- package/dist/MachineRegistry.d.ts.map +1 -1
- package/dist/ProjectInspector.d.ts +2 -0
- package/dist/ProjectInspector.d.ts.map +1 -1
- package/dist/ProjectInspector.js.map +1 -1
- package/dist/client/assets/index-B49Tjawc.js +14 -0
- package/dist/client/assets/index-BKH0cOG2.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/internal/devServer.d.ts +2 -1
- package/dist/internal/devServer.d.ts.map +1 -1
- package/dist/internal/devServer.js +68 -7
- package/dist/internal/devServer.js.map +1 -1
- package/dist/internal/evaluationWorker.d.ts.map +1 -1
- package/dist/internal/evaluationWorker.js +279 -4
- package/dist/internal/evaluationWorker.js.map +1 -1
- package/dist/internal/machineDocument.d.ts.map +1 -1
- package/dist/internal/machineDocument.js +63 -2
- package/dist/internal/machineDocument.js.map +1 -1
- package/dist/internal/projectInspector.d.ts.map +1 -1
- package/dist/internal/projectInspector.js +26 -10
- package/dist/internal/projectInspector.js.map +1 -1
- package/package.json +2 -2
- package/src/DevServer.ts +6 -2
- package/src/DevToolsProtocol.ts +286 -1
- package/src/MachineDocument.ts +54 -1
- package/src/ProjectInspector.ts +5 -0
- package/src/internal/browser/input-form.ts +669 -0
- package/src/internal/browser/planner-example.ts +143 -0
- package/src/internal/browser/simulation-client.ts +20 -0
- package/src/internal/browser/styles.css +323 -3
- package/src/internal/browser/visualizer-app.ts +395 -34
- package/src/internal/browser/visualizer.ts +1 -1
- package/src/internal/devServer.ts +91 -8
- package/src/internal/evaluationWorker.ts +390 -8
- package/src/internal/machineDocument.ts +75 -2
- package/src/internal/projectInspector.ts +58 -15
- package/dist/client/assets/index-BGOhE3Ng.css +0 -1
- package/dist/client/assets/index-DiqGhsGR.js +0 -14
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import type { InputIssue } from "../../DevToolsProtocol.js"
|
|
2
|
+
import type { InputSchema } from "../../MachineDocument.js"
|
|
3
|
+
|
|
4
|
+
type JsonPrimitive = string | number | boolean | null
|
|
5
|
+
|
|
6
|
+
export type InputField =
|
|
7
|
+
| {
|
|
8
|
+
readonly _tag: "String"
|
|
9
|
+
readonly title: string | undefined
|
|
10
|
+
readonly description: string | undefined
|
|
11
|
+
readonly defaultValue: string | undefined
|
|
12
|
+
readonly format: string | undefined
|
|
13
|
+
readonly minLength: number | undefined
|
|
14
|
+
readonly maxLength: number | undefined
|
|
15
|
+
readonly pattern: string | undefined
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
readonly _tag: "Number"
|
|
19
|
+
readonly title: string | undefined
|
|
20
|
+
readonly description: string | undefined
|
|
21
|
+
readonly defaultValue: number | undefined
|
|
22
|
+
readonly integer: boolean
|
|
23
|
+
readonly minimum: number | undefined
|
|
24
|
+
readonly maximum: number | undefined
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
readonly _tag: "Boolean"
|
|
28
|
+
readonly title: string | undefined
|
|
29
|
+
readonly description: string | undefined
|
|
30
|
+
readonly defaultValue: boolean | undefined
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly _tag: "Enum"
|
|
34
|
+
readonly title: string | undefined
|
|
35
|
+
readonly description: string | undefined
|
|
36
|
+
readonly values: ReadonlyArray<JsonPrimitive>
|
|
37
|
+
readonly defaultValue: JsonPrimitive | undefined
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly _tag: "Literal"
|
|
41
|
+
readonly title: string | undefined
|
|
42
|
+
readonly description: string | undefined
|
|
43
|
+
readonly value: JsonPrimitive
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly _tag: "Object"
|
|
47
|
+
readonly title: string | undefined
|
|
48
|
+
readonly description: string | undefined
|
|
49
|
+
readonly fields: ReadonlyArray<{
|
|
50
|
+
readonly key: string
|
|
51
|
+
readonly required: boolean
|
|
52
|
+
readonly field: InputField
|
|
53
|
+
}>
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
readonly _tag: "Array"
|
|
57
|
+
readonly title: string | undefined
|
|
58
|
+
readonly description: string | undefined
|
|
59
|
+
readonly item: InputField
|
|
60
|
+
readonly minItems: number
|
|
61
|
+
readonly maxItems: number | undefined
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
readonly _tag: "Union"
|
|
65
|
+
readonly title: string | undefined
|
|
66
|
+
readonly description: string | undefined
|
|
67
|
+
readonly alternatives: ReadonlyArray<InputField>
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
readonly _tag: "Unsupported"
|
|
71
|
+
readonly title: string | undefined
|
|
72
|
+
readonly description: string | undefined
|
|
73
|
+
readonly reason: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
77
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
78
|
+
|
|
79
|
+
const stringValue = (value: unknown): string | undefined => typeof value === "string" ? value : undefined
|
|
80
|
+
|
|
81
|
+
const numberValue = (value: unknown): number | undefined =>
|
|
82
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined
|
|
83
|
+
|
|
84
|
+
const primitiveValue = (value: unknown): JsonPrimitive | undefined =>
|
|
85
|
+
value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
|
86
|
+
? value
|
|
87
|
+
: undefined
|
|
88
|
+
|
|
89
|
+
const resolveReference = (
|
|
90
|
+
value: unknown,
|
|
91
|
+
definitions: Readonly<Record<string, unknown>>,
|
|
92
|
+
seen: ReadonlySet<string> = new Set()
|
|
93
|
+
): unknown => {
|
|
94
|
+
if (!isRecord(value) || typeof value.$ref !== "string" || !value.$ref.startsWith("#/$defs/")) return value
|
|
95
|
+
const name = decodeURIComponent(value.$ref.slice("#/$defs/".length))
|
|
96
|
+
if (seen.has(name)) return undefined
|
|
97
|
+
const next = definitions[name]
|
|
98
|
+
return resolveReference(next, definitions, new Set([...seen, name]))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const annotations = (schema: Record<string, unknown>) => ({
|
|
102
|
+
title: stringValue(schema.title),
|
|
103
|
+
description: stringValue(schema.description)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const mergeAllOf = (
|
|
107
|
+
schema: Record<string, unknown>,
|
|
108
|
+
definitions: Readonly<Record<string, unknown>>
|
|
109
|
+
): Record<string, unknown> => {
|
|
110
|
+
if (!Array.isArray(schema.allOf)) return schema
|
|
111
|
+
const { allOf, ...base } = schema
|
|
112
|
+
return Object.assign(
|
|
113
|
+
base,
|
|
114
|
+
...allOf
|
|
115
|
+
.map((part) => resolveReference(part, definitions))
|
|
116
|
+
.filter(isRecord)
|
|
117
|
+
.map((part) => mergeAllOf(part, definitions))
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const effectNumberAlternative = (
|
|
122
|
+
alternatives: ReadonlyArray<unknown>,
|
|
123
|
+
definitions: Readonly<Record<string, unknown>>
|
|
124
|
+
): Record<string, unknown> | undefined => {
|
|
125
|
+
if (alternatives.length !== 2) return undefined
|
|
126
|
+
const resolved = alternatives.map((alternative) => resolveReference(alternative, definitions))
|
|
127
|
+
const number = resolved.find((alternative) => isRecord(alternative) && alternative.type === "number")
|
|
128
|
+
const encodedNonFinite = resolved.find((alternative) => {
|
|
129
|
+
if (!isRecord(alternative) || alternative.type !== "string" || !Array.isArray(alternative.enum)) return false
|
|
130
|
+
const enumValues: ReadonlyArray<unknown> = alternative.enum
|
|
131
|
+
return enumValues.length === 3 &&
|
|
132
|
+
["Infinity", "-Infinity", "NaN"].every((value) => enumValues.includes(value))
|
|
133
|
+
})
|
|
134
|
+
return isRecord(number) && encodedNonFinite !== undefined ? number : undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const project = (
|
|
138
|
+
value: unknown,
|
|
139
|
+
definitions: Readonly<Record<string, unknown>>
|
|
140
|
+
): InputField => {
|
|
141
|
+
const referenced = resolveReference(value, definitions)
|
|
142
|
+
if (!isRecord(referenced)) {
|
|
143
|
+
return {
|
|
144
|
+
_tag: "Unsupported",
|
|
145
|
+
title: undefined,
|
|
146
|
+
description: undefined,
|
|
147
|
+
reason: "This input schema cannot be represented as fields."
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const resolved = mergeAllOf(referenced, definitions)
|
|
151
|
+
const common = annotations(resolved)
|
|
152
|
+
const alternatives = Array.isArray(resolved.oneOf)
|
|
153
|
+
? resolved.oneOf
|
|
154
|
+
: Array.isArray(resolved.anyOf)
|
|
155
|
+
? resolved.anyOf
|
|
156
|
+
: undefined
|
|
157
|
+
if (alternatives !== undefined) {
|
|
158
|
+
const effectNumber = effectNumberAlternative(alternatives, definitions)
|
|
159
|
+
if (effectNumber !== undefined) {
|
|
160
|
+
return {
|
|
161
|
+
_tag: "Number",
|
|
162
|
+
...common,
|
|
163
|
+
defaultValue: numberValue(resolved.default ?? effectNumber.default),
|
|
164
|
+
integer: false,
|
|
165
|
+
minimum: numberValue(resolved.minimum ?? effectNumber.minimum),
|
|
166
|
+
maximum: numberValue(resolved.maximum ?? effectNumber.maximum)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
_tag: "Union",
|
|
171
|
+
...common,
|
|
172
|
+
alternatives: alternatives.map((alternative) => project(alternative, definitions))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const constant = primitiveValue(resolved.const)
|
|
176
|
+
if (constant !== undefined || resolved.const === null) {
|
|
177
|
+
return { _tag: "Literal", ...common, value: constant ?? null }
|
|
178
|
+
}
|
|
179
|
+
if (Array.isArray(resolved.enum)) {
|
|
180
|
+
const values = resolved.enum.map(primitiveValue).filter((item): item is JsonPrimitive => item !== undefined)
|
|
181
|
+
if (values.length > 0) {
|
|
182
|
+
return {
|
|
183
|
+
_tag: "Enum",
|
|
184
|
+
...common,
|
|
185
|
+
values,
|
|
186
|
+
defaultValue: primitiveValue(resolved.default)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
switch (resolved.type) {
|
|
191
|
+
case "string":
|
|
192
|
+
return {
|
|
193
|
+
_tag: "String",
|
|
194
|
+
...common,
|
|
195
|
+
defaultValue: stringValue(resolved.default),
|
|
196
|
+
format: stringValue(resolved.format),
|
|
197
|
+
minLength: numberValue(resolved.minLength),
|
|
198
|
+
maxLength: numberValue(resolved.maxLength),
|
|
199
|
+
pattern: stringValue(resolved.pattern)
|
|
200
|
+
}
|
|
201
|
+
case "integer":
|
|
202
|
+
case "number":
|
|
203
|
+
return {
|
|
204
|
+
_tag: "Number",
|
|
205
|
+
...common,
|
|
206
|
+
defaultValue: numberValue(resolved.default),
|
|
207
|
+
integer: resolved.type === "integer",
|
|
208
|
+
minimum: numberValue(resolved.minimum),
|
|
209
|
+
maximum: numberValue(resolved.maximum)
|
|
210
|
+
}
|
|
211
|
+
case "boolean":
|
|
212
|
+
return {
|
|
213
|
+
_tag: "Boolean",
|
|
214
|
+
...common,
|
|
215
|
+
defaultValue: typeof resolved.default === "boolean" ? resolved.default : undefined
|
|
216
|
+
}
|
|
217
|
+
case "null":
|
|
218
|
+
return { _tag: "Literal", ...common, value: null }
|
|
219
|
+
case "object": {
|
|
220
|
+
if (!isRecord(resolved.properties)) {
|
|
221
|
+
return { _tag: "Object", ...common, fields: [] }
|
|
222
|
+
}
|
|
223
|
+
const required = new Set(
|
|
224
|
+
Array.isArray(resolved.required)
|
|
225
|
+
? resolved.required.filter((item): item is string => typeof item === "string")
|
|
226
|
+
: []
|
|
227
|
+
)
|
|
228
|
+
return {
|
|
229
|
+
_tag: "Object",
|
|
230
|
+
...common,
|
|
231
|
+
fields: Object.entries(resolved.properties).map(([key, child]) => ({
|
|
232
|
+
key,
|
|
233
|
+
required: required.has(key),
|
|
234
|
+
field: project(child, definitions)
|
|
235
|
+
}))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
case "array":
|
|
239
|
+
return {
|
|
240
|
+
_tag: "Array",
|
|
241
|
+
...common,
|
|
242
|
+
item: project(resolved.items, definitions),
|
|
243
|
+
minItems: numberValue(resolved.minItems) ?? 0,
|
|
244
|
+
maxItems: numberValue(resolved.maxItems)
|
|
245
|
+
}
|
|
246
|
+
default:
|
|
247
|
+
return {
|
|
248
|
+
_tag: "Unsupported",
|
|
249
|
+
...common,
|
|
250
|
+
reason: "This input schema has no concrete JSON shape to render."
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const projectInputSchema = (document: InputSchema): InputField => project(document.schema, document.definitions)
|
|
256
|
+
|
|
257
|
+
export interface InputFormResult {
|
|
258
|
+
readonly ok: boolean
|
|
259
|
+
readonly value?: unknown
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface InputForm {
|
|
263
|
+
readonly element: HTMLFormElement
|
|
264
|
+
readonly hasFields: boolean
|
|
265
|
+
readonly supported: boolean
|
|
266
|
+
readonly read: () => InputFormResult
|
|
267
|
+
readonly clearIssues: () => void
|
|
268
|
+
readonly setIssues: (issues: ReadonlyArray<InputIssue>) => void
|
|
269
|
+
readonly setPending: (pending: boolean) => void
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface Control {
|
|
273
|
+
readonly element: HTMLElement
|
|
274
|
+
readonly interactive: boolean
|
|
275
|
+
readonly supported: boolean
|
|
276
|
+
readonly read: () => unknown
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface RenderContext {
|
|
280
|
+
readonly issueTargets: Map<string, HTMLElement>
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let nextControlId = 0
|
|
284
|
+
|
|
285
|
+
const element = <Tag extends keyof HTMLElementTagNameMap>(
|
|
286
|
+
tag: Tag,
|
|
287
|
+
className?: string,
|
|
288
|
+
text?: string
|
|
289
|
+
): HTMLElementTagNameMap[Tag] => {
|
|
290
|
+
const node = document.createElement(tag)
|
|
291
|
+
if (className !== undefined) node.className = className
|
|
292
|
+
if (text !== undefined) node.textContent = text
|
|
293
|
+
return node
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const valueKey = (value: JsonPrimitive): string => JSON.stringify(value)
|
|
297
|
+
|
|
298
|
+
const labelText = (field: InputField, fallback: string): string => field.title ?? fallback
|
|
299
|
+
|
|
300
|
+
const description = (field: InputField): HTMLElement | undefined =>
|
|
301
|
+
field.description === undefined ? undefined : element("p", "input-description", field.description)
|
|
302
|
+
|
|
303
|
+
const fieldType = (field: InputField): string => {
|
|
304
|
+
switch (field._tag) {
|
|
305
|
+
case "String":
|
|
306
|
+
return field.format ?? "string"
|
|
307
|
+
case "Number":
|
|
308
|
+
return field.integer ? "integer" : "number"
|
|
309
|
+
case "Boolean":
|
|
310
|
+
return "boolean"
|
|
311
|
+
case "Enum":
|
|
312
|
+
return "enum"
|
|
313
|
+
case "Literal":
|
|
314
|
+
return "literal"
|
|
315
|
+
case "Object":
|
|
316
|
+
return "object"
|
|
317
|
+
case "Array":
|
|
318
|
+
return `${fieldType(field.item)}[]`
|
|
319
|
+
case "Union":
|
|
320
|
+
return "union"
|
|
321
|
+
case "Unsupported":
|
|
322
|
+
return "unsupported"
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const fieldConstraints = (field: InputField): ReadonlyArray<string> => {
|
|
327
|
+
switch (field._tag) {
|
|
328
|
+
case "String":
|
|
329
|
+
return [
|
|
330
|
+
field.minLength === undefined ? undefined : `min ${field.minLength} characters`,
|
|
331
|
+
field.maxLength === undefined ? undefined : `max ${field.maxLength} characters`,
|
|
332
|
+
field.pattern === undefined ? undefined : `pattern ${field.pattern}`,
|
|
333
|
+
field.defaultValue === undefined ? undefined : `default ${field.defaultValue}`
|
|
334
|
+
].filter((value): value is string => value !== undefined)
|
|
335
|
+
case "Number":
|
|
336
|
+
return [
|
|
337
|
+
field.minimum === undefined ? undefined : `min ${field.minimum}`,
|
|
338
|
+
field.maximum === undefined ? undefined : `max ${field.maximum}`,
|
|
339
|
+
field.defaultValue === undefined ? undefined : `default ${field.defaultValue}`
|
|
340
|
+
].filter((value): value is string => value !== undefined)
|
|
341
|
+
case "Enum":
|
|
342
|
+
return [
|
|
343
|
+
`${field.values.length} options`,
|
|
344
|
+
field.defaultValue === undefined ? undefined : `default ${String(field.defaultValue)}`
|
|
345
|
+
].filter((value): value is string => value !== undefined)
|
|
346
|
+
case "Literal":
|
|
347
|
+
return [`fixed ${String(field.value)}`]
|
|
348
|
+
case "Object":
|
|
349
|
+
return [`${field.fields.length} fields`]
|
|
350
|
+
case "Array":
|
|
351
|
+
return [
|
|
352
|
+
field.minItems === 0 ? undefined : `min ${field.minItems} items`,
|
|
353
|
+
field.maxItems === undefined ? undefined : `max ${field.maxItems} items`
|
|
354
|
+
].filter((value): value is string => value !== undefined)
|
|
355
|
+
case "Union":
|
|
356
|
+
return [`${field.alternatives.length} variants`]
|
|
357
|
+
case "Boolean":
|
|
358
|
+
case "Unsupported":
|
|
359
|
+
return []
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const renderControl = (
|
|
364
|
+
field: InputField,
|
|
365
|
+
name: string,
|
|
366
|
+
context: RenderContext,
|
|
367
|
+
path: ReadonlyArray<string | number>
|
|
368
|
+
): Control => {
|
|
369
|
+
switch (field._tag) {
|
|
370
|
+
case "String": {
|
|
371
|
+
const input = element("input", "input-control")
|
|
372
|
+
input.type = field.format === "date-time"
|
|
373
|
+
? "datetime-local"
|
|
374
|
+
: field.format === "date"
|
|
375
|
+
? "date"
|
|
376
|
+
: field.format === "email"
|
|
377
|
+
? "email"
|
|
378
|
+
: field.format === "uri"
|
|
379
|
+
? "url"
|
|
380
|
+
: "text"
|
|
381
|
+
input.name = name
|
|
382
|
+
input.value = field.defaultValue ?? ""
|
|
383
|
+
if (field.minLength !== undefined) input.minLength = field.minLength
|
|
384
|
+
if (field.maxLength !== undefined) input.maxLength = field.maxLength
|
|
385
|
+
if (field.pattern !== undefined) input.pattern = field.pattern
|
|
386
|
+
return { element: input, interactive: true, supported: true, read: () => input.value }
|
|
387
|
+
}
|
|
388
|
+
case "Number": {
|
|
389
|
+
const input = element("input", "input-control")
|
|
390
|
+
input.type = "number"
|
|
391
|
+
input.name = name
|
|
392
|
+
input.step = field.integer ? "1" : "any"
|
|
393
|
+
if (field.defaultValue !== undefined) input.value = String(field.defaultValue)
|
|
394
|
+
if (field.minimum !== undefined) input.min = String(field.minimum)
|
|
395
|
+
if (field.maximum !== undefined) input.max = String(field.maximum)
|
|
396
|
+
return {
|
|
397
|
+
element: input,
|
|
398
|
+
interactive: true,
|
|
399
|
+
supported: true,
|
|
400
|
+
read: () => input.value === "" ? undefined : Number(input.value)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
case "Boolean": {
|
|
404
|
+
const wrapper = element("label", "boolean-control")
|
|
405
|
+
const input = element("input")
|
|
406
|
+
input.type = "checkbox"
|
|
407
|
+
input.name = name
|
|
408
|
+
input.checked = field.defaultValue ?? false
|
|
409
|
+
wrapper.append(input, element("span", undefined, "Enabled"))
|
|
410
|
+
return { element: wrapper, interactive: true, supported: true, read: () => input.checked }
|
|
411
|
+
}
|
|
412
|
+
case "Enum": {
|
|
413
|
+
const select = element("select", "input-control")
|
|
414
|
+
select.name = name
|
|
415
|
+
field.values.forEach((value) => {
|
|
416
|
+
const option = element("option", undefined, String(value))
|
|
417
|
+
option.value = valueKey(value)
|
|
418
|
+
option.selected = Object.is(value, field.defaultValue)
|
|
419
|
+
select.append(option)
|
|
420
|
+
})
|
|
421
|
+
return {
|
|
422
|
+
element: select,
|
|
423
|
+
interactive: true,
|
|
424
|
+
supported: true,
|
|
425
|
+
read: () => JSON.parse(select.value) as JsonPrimitive
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
case "Literal": {
|
|
429
|
+
const output = element("output", "literal-control", String(field.value))
|
|
430
|
+
return { element: output, interactive: false, supported: true, read: () => field.value }
|
|
431
|
+
}
|
|
432
|
+
case "Object": {
|
|
433
|
+
const group = element("fieldset", "input-object")
|
|
434
|
+
const legend = element("legend", undefined, labelText(field, name))
|
|
435
|
+
group.append(legend)
|
|
436
|
+
const controls: Array<{
|
|
437
|
+
readonly key: string
|
|
438
|
+
readonly included: HTMLInputElement | undefined
|
|
439
|
+
readonly control: Control
|
|
440
|
+
}> = []
|
|
441
|
+
for (const property of field.fields) {
|
|
442
|
+
const row = element("div", "input-field")
|
|
443
|
+
const heading = element("div", "input-field-heading")
|
|
444
|
+
const label = element("label", "input-label", labelText(property.field, property.key))
|
|
445
|
+
const identity = element("div", "input-field-identity")
|
|
446
|
+
identity.append(label, element("span", "input-field-type", fieldType(property.field)))
|
|
447
|
+
const required = property.required ? element("span", "input-required", "required") : undefined
|
|
448
|
+
let included: HTMLInputElement | undefined
|
|
449
|
+
const propertyPath = [...path, property.key]
|
|
450
|
+
const control = renderControl(property.field, `${name}.${property.key}`, context, propertyPath)
|
|
451
|
+
const labelled = control.element instanceof HTMLInputElement || control.element instanceof HTMLSelectElement
|
|
452
|
+
? control.element
|
|
453
|
+
: control.element.querySelector<HTMLInputElement | HTMLSelectElement>(":scope > input, :scope > select")
|
|
454
|
+
if (labelled !== null) {
|
|
455
|
+
const id = `machine-input-${nextControlId++}`
|
|
456
|
+
labelled.id = id
|
|
457
|
+
label.htmlFor = id
|
|
458
|
+
}
|
|
459
|
+
if (property.required) {
|
|
460
|
+
if (control.element instanceof HTMLSelectElement) {
|
|
461
|
+
control.element.required = true
|
|
462
|
+
} else if (control.element instanceof HTMLInputElement) {
|
|
463
|
+
control.element.required = property.field._tag === "Number" ||
|
|
464
|
+
(property.field._tag === "String" &&
|
|
465
|
+
(property.field.format !== undefined || (property.field.minLength ?? 0) > 0))
|
|
466
|
+
}
|
|
467
|
+
heading.append(identity, required!)
|
|
468
|
+
} else {
|
|
469
|
+
const optional = element("label", "input-optional")
|
|
470
|
+
included = element("input")
|
|
471
|
+
included.type = "checkbox"
|
|
472
|
+
optional.append(included, element("span", undefined, "include"))
|
|
473
|
+
heading.append(identity, optional)
|
|
474
|
+
control.element.toggleAttribute("inert", true)
|
|
475
|
+
control.element.classList.add("is-disabled")
|
|
476
|
+
control.element.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(
|
|
477
|
+
"input, select, button"
|
|
478
|
+
).forEach((item) => item.disabled = true)
|
|
479
|
+
if (
|
|
480
|
+
control.element instanceof HTMLInputElement ||
|
|
481
|
+
control.element instanceof HTMLSelectElement ||
|
|
482
|
+
control.element instanceof HTMLButtonElement
|
|
483
|
+
) control.element.disabled = true
|
|
484
|
+
included.addEventListener("change", () => {
|
|
485
|
+
control.element.toggleAttribute("inert", !included!.checked)
|
|
486
|
+
control.element.classList.toggle("is-disabled", !included!.checked)
|
|
487
|
+
control.element.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(
|
|
488
|
+
"input, select, button"
|
|
489
|
+
).forEach((item) => item.disabled = !included!.checked)
|
|
490
|
+
if (
|
|
491
|
+
control.element instanceof HTMLInputElement ||
|
|
492
|
+
control.element instanceof HTMLSelectElement ||
|
|
493
|
+
control.element instanceof HTMLButtonElement
|
|
494
|
+
) control.element.disabled = !included!.checked
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
const issues = element("div", "input-errors")
|
|
498
|
+
issues.hidden = true
|
|
499
|
+
context.issueTargets.set(JSON.stringify(propertyPath), issues)
|
|
500
|
+
row.append(heading, control.element)
|
|
501
|
+
const constraints = fieldConstraints(property.field)
|
|
502
|
+
if (constraints.length > 0) {
|
|
503
|
+
const metadata = element("div", "input-constraints")
|
|
504
|
+
constraints.forEach((constraint) => metadata.append(element("span", undefined, constraint)))
|
|
505
|
+
row.append(metadata)
|
|
506
|
+
}
|
|
507
|
+
const details = description(property.field)
|
|
508
|
+
if (details !== undefined) row.append(details)
|
|
509
|
+
row.append(issues)
|
|
510
|
+
group.append(row)
|
|
511
|
+
controls.push({ key: property.key, included, control })
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
element: group,
|
|
515
|
+
interactive: controls.some(({ control }) => control.interactive),
|
|
516
|
+
supported: controls.every(({ control }) => control.supported),
|
|
517
|
+
read: () =>
|
|
518
|
+
Object.fromEntries(
|
|
519
|
+
controls
|
|
520
|
+
.filter(({ included }) => included === undefined || included.checked)
|
|
521
|
+
.map(({ key, control }) => [key, control.read()])
|
|
522
|
+
.filter((entry) => entry[1] !== undefined)
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
case "Array": {
|
|
527
|
+
const group = element("fieldset", "input-array")
|
|
528
|
+
group.append(element("legend", undefined, labelText(field, name)))
|
|
529
|
+
const items = element("div", "input-array-items")
|
|
530
|
+
const controls: Array<{
|
|
531
|
+
readonly row: HTMLElement
|
|
532
|
+
readonly control: Control
|
|
533
|
+
readonly remove: HTMLButtonElement
|
|
534
|
+
}> = []
|
|
535
|
+
const add = element("button", "input-array-add", "Add item")
|
|
536
|
+
add.type = "button"
|
|
537
|
+
const refreshActions = (): void => {
|
|
538
|
+
add.disabled = field.maxItems !== undefined && controls.length >= field.maxItems
|
|
539
|
+
controls.forEach(({ remove }) => remove.disabled = controls.length <= field.minItems)
|
|
540
|
+
}
|
|
541
|
+
const addItem = (): void => {
|
|
542
|
+
if (field.maxItems !== undefined && controls.length >= field.maxItems) return
|
|
543
|
+
const row = element("div", "input-array-item")
|
|
544
|
+
const control = renderControl(field.item, `${name}.${controls.length}`, context, [...path, controls.length])
|
|
545
|
+
const remove = element("button", "input-array-remove", "Remove")
|
|
546
|
+
remove.type = "button"
|
|
547
|
+
remove.addEventListener("click", () => {
|
|
548
|
+
row.remove()
|
|
549
|
+
const index = controls.findIndex((item) => item.row === row)
|
|
550
|
+
if (index >= 0) controls.splice(index, 1)
|
|
551
|
+
refreshActions()
|
|
552
|
+
})
|
|
553
|
+
row.append(control.element, remove)
|
|
554
|
+
items.append(row)
|
|
555
|
+
controls.push({ row, control, remove })
|
|
556
|
+
refreshActions()
|
|
557
|
+
}
|
|
558
|
+
for (let index = 0; index < field.minItems; index++) addItem()
|
|
559
|
+
add.addEventListener("click", addItem)
|
|
560
|
+
group.append(items, add)
|
|
561
|
+
return {
|
|
562
|
+
element: group,
|
|
563
|
+
interactive: true,
|
|
564
|
+
supported: controls.every(({ control }) => control.supported) && field.item._tag !== "Unsupported",
|
|
565
|
+
read: () => controls.map(({ control }) => control.read())
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
case "Union": {
|
|
569
|
+
const group = element("fieldset", "input-union")
|
|
570
|
+
group.append(element("legend", undefined, labelText(field, name)))
|
|
571
|
+
const select = element("select", "input-control")
|
|
572
|
+
const body = element("div", "input-union-body")
|
|
573
|
+
let selected = 0
|
|
574
|
+
const controls = field.alternatives.map((alternative, index) => {
|
|
575
|
+
const control = renderControl(alternative, `${name}.${index}`, context, path)
|
|
576
|
+
const option = element("option", undefined, labelText(alternative, `Option ${index + 1}`))
|
|
577
|
+
option.value = String(index)
|
|
578
|
+
select.append(option)
|
|
579
|
+
return control
|
|
580
|
+
})
|
|
581
|
+
const update = (): void => {
|
|
582
|
+
selected = Number(select.value)
|
|
583
|
+
body.replaceChildren(controls[selected]?.element ?? element("div"))
|
|
584
|
+
}
|
|
585
|
+
select.addEventListener("change", update)
|
|
586
|
+
group.append(select, body)
|
|
587
|
+
update()
|
|
588
|
+
return {
|
|
589
|
+
element: group,
|
|
590
|
+
interactive: true,
|
|
591
|
+
supported: controls.every(({ supported }) => supported),
|
|
592
|
+
read: () => controls[selected]?.read()
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
case "Unsupported": {
|
|
596
|
+
const message = element("p", "input-unsupported", field.reason)
|
|
597
|
+
return { element: message, interactive: false, supported: false, read: () => undefined }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export const renderInputForm = (
|
|
603
|
+
schema: InputSchema,
|
|
604
|
+
options: {
|
|
605
|
+
readonly name: string
|
|
606
|
+
readonly fixed?: Readonly<Record<string, JsonPrimitive>>
|
|
607
|
+
readonly omit?: ReadonlyArray<string>
|
|
608
|
+
}
|
|
609
|
+
): InputForm => {
|
|
610
|
+
const form = element("form", "schema-form")
|
|
611
|
+
const formIssues = element("div", "input-errors input-errors-form")
|
|
612
|
+
formIssues.hidden = true
|
|
613
|
+
const context: RenderContext = { issueTargets: new Map() }
|
|
614
|
+
const projected = projectInputSchema(schema)
|
|
615
|
+
const visible = projected._tag === "Object" && options.omit !== undefined
|
|
616
|
+
? { ...projected, fields: projected.fields.filter(({ key }) => !options.omit!.includes(key)) }
|
|
617
|
+
: projected
|
|
618
|
+
const control = renderControl(visible, options.name, context, [])
|
|
619
|
+
form.append(formIssues, control.element)
|
|
620
|
+
const clearIssues = (): void => {
|
|
621
|
+
formIssues.replaceChildren()
|
|
622
|
+
formIssues.hidden = true
|
|
623
|
+
context.issueTargets.forEach((target) => {
|
|
624
|
+
target.replaceChildren()
|
|
625
|
+
target.hidden = true
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
const setIssues = (issues: ReadonlyArray<InputIssue>): void => {
|
|
629
|
+
clearIssues()
|
|
630
|
+
const grouped = new Map<HTMLElement, Array<string>>()
|
|
631
|
+
for (const issue of issues) {
|
|
632
|
+
let target: HTMLElement = formIssues
|
|
633
|
+
for (let length = issue.path.length; length > 0; length--) {
|
|
634
|
+
const candidate = context.issueTargets.get(JSON.stringify(issue.path.slice(0, length)))
|
|
635
|
+
if (candidate !== undefined) {
|
|
636
|
+
target = candidate
|
|
637
|
+
break
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const messages = grouped.get(target) ?? []
|
|
641
|
+
if (!messages.includes(issue.message)) messages.push(issue.message)
|
|
642
|
+
grouped.set(target, messages)
|
|
643
|
+
}
|
|
644
|
+
grouped.forEach((messages, target) => {
|
|
645
|
+
messages.forEach((message) => target.append(element("div", undefined, message)))
|
|
646
|
+
target.hidden = false
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
element: form,
|
|
651
|
+
hasFields: control.interactive,
|
|
652
|
+
supported: control.supported,
|
|
653
|
+
read: () => {
|
|
654
|
+
clearIssues()
|
|
655
|
+
if (!form.reportValidity() || !control.supported) return { ok: false }
|
|
656
|
+
const value = control.read()
|
|
657
|
+
return {
|
|
658
|
+
ok: true,
|
|
659
|
+
value: isRecord(value) && options.fixed !== undefined ? { ...value, ...options.fixed } : value
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
clearIssues,
|
|
663
|
+
setIssues,
|
|
664
|
+
setPending: (pending) => {
|
|
665
|
+
form.toggleAttribute("inert", pending)
|
|
666
|
+
form.setAttribute("aria-busy", String(pending))
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|