@jxsuite/studio 0.37.1 → 1.0.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/dist/studio.js +77654 -76269
- package/dist/studio.js.map +128 -119
- package/package.json +44 -44
- package/src/account-status.ts +39 -0
- package/src/browse/browse.ts +10 -14
- package/src/canvas/iframe-host.ts +1 -1
- package/src/editor/context-menu.ts +1 -1
- package/src/editor/repeater-scope.ts +8 -13
- package/src/files/files.ts +3 -0
- package/src/format/format-host.ts +63 -1
- package/src/new-project/add-repo-modal.ts +183 -0
- package/src/new-project/new-project-modal.ts +22 -3
- package/src/page-params.ts +34 -8
- package/src/panels/ai-chat/chat-markdown.ts +2 -2
- package/src/panels/ai-panel.ts +61 -4
- package/src/panels/data-grid.ts +619 -0
- package/src/panels/signals-panel.ts +102 -437
- package/src/panels/statusbar.ts +1 -1
- package/src/panels/welcome-screen.ts +50 -0
- package/src/platform-errors.ts +30 -0
- package/src/platforms/cloud.ts +172 -89
- package/src/platforms/devserver.ts +172 -0
- package/src/services/context-resolver.ts +73 -0
- package/src/services/data-service.ts +155 -0
- package/src/services/monaco-setup.ts +75 -26
- package/src/settings/contributed-section.ts +406 -0
- package/src/settings/extension-sections.ts +145 -0
- package/src/settings/schema-field-ui.ts +4 -2
- package/src/settings/settings-modal.ts +101 -42
- package/src/site-context.ts +10 -1
- package/src/studio.ts +24 -0
- package/src/tabs/transact.ts +1 -1
- package/src/types.ts +120 -1
- package/src/ui/form-controls.ts +322 -0
- package/src/ui/progress-modal.ts +2 -2
- package/src/ui/schema-form.ts +524 -0
- package/src/utils/studio-utils.ts +4 -3
- package/src/settings/content-types-editor.ts +0 -599
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Schema-form — reusable JSON-Schema → form rendering engine, extracted from the signals panel.
|
|
4
|
+
*
|
|
5
|
+
* Maps schema property types to Spectrum controls (enum → picker, boolean → checkbox,
|
|
6
|
+
* number/integer → number-field, `json-schema` format → multiline JSON editor, array-of-objects →
|
|
7
|
+
* multi-row inline form, other array/object → JSON text field, default → textfield). Hosts commit
|
|
8
|
+
* edits through a single `onChange(patch)` callback; dynamic enum choices and `$ref` bindings
|
|
9
|
+
* resolve through a {@link SchemaFormContext}. Custom controls register by name via
|
|
10
|
+
* {@link registerFormControl} and are consulted first for `ui` overrides.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { html, nothing } from "lit-html";
|
|
14
|
+
import { ifDefined } from "lit-html/directives/if-defined.js";
|
|
15
|
+
import { live } from "lit-html/directives/live.js";
|
|
16
|
+
import { styleMap } from "lit-html/directives/style-map.js";
|
|
17
|
+
import { isRef } from "@jxsuite/schema/guards";
|
|
18
|
+
import { renderFieldRow } from "./field-row";
|
|
19
|
+
import type { TemplateResult } from "lit-html";
|
|
20
|
+
|
|
21
|
+
/** A (possibly nested) JSON Schema node, covering both object and property level keys. */
|
|
22
|
+
export interface JsonSchema {
|
|
23
|
+
type?: string;
|
|
24
|
+
properties?: Record<string, JsonSchema>;
|
|
25
|
+
required?: string[];
|
|
26
|
+
description?: string;
|
|
27
|
+
enum?: unknown;
|
|
28
|
+
default?: unknown;
|
|
29
|
+
format?: string;
|
|
30
|
+
minimum?: number;
|
|
31
|
+
maximum?: number;
|
|
32
|
+
examples?: string[];
|
|
33
|
+
name?: string;
|
|
34
|
+
items?: JsonSchema;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Host-provided context threaded to every control. */
|
|
38
|
+
export interface SchemaFormContext {
|
|
39
|
+
/** Resolve a `#/$context/…` pointer (or legacy sentinel) against the host's project config. */
|
|
40
|
+
resolvePointer: (pointer: string, scope?: Record<string, unknown>) => unknown;
|
|
41
|
+
/** Route params available for `$ref` bindings (e.g. from the document path). */
|
|
42
|
+
params?: string[] | undefined;
|
|
43
|
+
/** Unique prefix for per-field ephemeral UI state (e.g. the binding control's custom mode). */
|
|
44
|
+
fieldKeyPrefix?: string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Commit hook for the "secret" control: stores the VALUE in the platform's secret store (never
|
|
47
|
+
* project.json) and returns the derived env-var NAME, which the control persists to the field
|
|
48
|
+
* instead of the value.
|
|
49
|
+
*/
|
|
50
|
+
commitSecret?: ((key: string, value: string) => string | Promise<string>) | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Arguments passed to a registered form control. */
|
|
54
|
+
export interface SchemaFormControlArgs {
|
|
55
|
+
key: string;
|
|
56
|
+
schema: JsonSchema;
|
|
57
|
+
value: unknown;
|
|
58
|
+
onChange: (next: unknown) => void;
|
|
59
|
+
ctx: SchemaFormContext;
|
|
60
|
+
rerender?: (() => void) | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type SchemaFormControl = (args: SchemaFormControlArgs) => TemplateResult;
|
|
64
|
+
|
|
65
|
+
/** Options for {@link renderForm}. */
|
|
66
|
+
export interface RenderFormOptions {
|
|
67
|
+
onChange: (patch: Record<string, unknown>) => void;
|
|
68
|
+
context?: SchemaFormContext | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Per-field overrides from `$studio.settings.entry.ui`: a registered control name, and/or an
|
|
71
|
+
* `enum` source (choice list or `{ "$ref": "#/$context/<pointer>" }`) layered over the field
|
|
72
|
+
* schema — fragments stay valid JSON Schema while descriptors add dynamic choices.
|
|
73
|
+
*/
|
|
74
|
+
ui?: Record<string, { control?: string; enum?: unknown }> | undefined;
|
|
75
|
+
rerender?: (() => void) | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Control registry ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const controlRegistry = new Map<string, SchemaFormControl>();
|
|
81
|
+
|
|
82
|
+
/** Register (or replace) a named form control. */
|
|
83
|
+
export function registerFormControl(name: string, control: SchemaFormControl): void {
|
|
84
|
+
controlRegistry.set(name, control);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Look up a registered form control by name. */
|
|
88
|
+
export function getFormControl(name: string): SchemaFormControl | undefined {
|
|
89
|
+
return controlRegistry.get(name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Inert context used when a host renders a form without one. */
|
|
93
|
+
const NULL_CONTEXT: SchemaFormContext = {
|
|
94
|
+
resolvePointer: () => {
|
|
95
|
+
// Nothing to resolve against without a host context
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ─── Enum resolution ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a schema enum definition to concrete choices. Plain arrays pass through; `$ref` objects
|
|
103
|
+
* and sentinel strings resolve through the context's pointer resolver, applying object →
|
|
104
|
+
* `Object.keys` and string[] → itself.
|
|
105
|
+
*
|
|
106
|
+
* @param {unknown} enumDef
|
|
107
|
+
* @param {SchemaFormContext | undefined} ctx
|
|
108
|
+
* @param {Record<string, unknown>} [scope] - Scope for `{@param}` substitution (the form value)
|
|
109
|
+
* @returns {string[] | undefined}
|
|
110
|
+
*/
|
|
111
|
+
export function resolveFormEnum(
|
|
112
|
+
enumDef: unknown,
|
|
113
|
+
ctx?: SchemaFormContext,
|
|
114
|
+
scope?: Record<string, unknown>,
|
|
115
|
+
): string[] | undefined {
|
|
116
|
+
if (Array.isArray(enumDef)) {
|
|
117
|
+
return enumDef as string[];
|
|
118
|
+
}
|
|
119
|
+
let pointer: string | undefined;
|
|
120
|
+
if (enumDef && typeof enumDef === "object") {
|
|
121
|
+
const ref = (enumDef as Record<string, unknown>).$ref;
|
|
122
|
+
if (typeof ref === "string") {
|
|
123
|
+
pointer = ref;
|
|
124
|
+
}
|
|
125
|
+
} else if (typeof enumDef === "string") {
|
|
126
|
+
// Legacy sentinel strings (e.g. "$contentTypes") resolve through the host's pointer resolver
|
|
127
|
+
pointer = enumDef;
|
|
128
|
+
}
|
|
129
|
+
if (pointer === undefined || !ctx) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const resolved = ctx.resolvePointer(pointer, scope);
|
|
133
|
+
if (Array.isArray(resolved)) {
|
|
134
|
+
return resolved.map(String);
|
|
135
|
+
}
|
|
136
|
+
if (resolved && typeof resolved === "object") {
|
|
137
|
+
return Object.keys(resolved);
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Field helpers ───────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/** Parse a numeric field value, returning NaN for blank input (so callers can treat it as unset). */
|
|
145
|
+
export function parseNumericField(raw: string, integer: boolean): number {
|
|
146
|
+
if (raw.trim() === "") {
|
|
147
|
+
return Number.NaN;
|
|
148
|
+
}
|
|
149
|
+
return integer ? Math.trunc(Number(raw)) : Number(raw);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Plain textfield editing a `{ $ref }` value directly — the fallback when no binding control. */
|
|
153
|
+
function refTextField(key: string, refVal: string, onChange: (next: unknown) => void) {
|
|
154
|
+
return html`<sp-textfield
|
|
155
|
+
size="s"
|
|
156
|
+
label=${key}
|
|
157
|
+
placeholder=${key}
|
|
158
|
+
.value=${live(refVal)}
|
|
159
|
+
@change=${(e: Event) => {
|
|
160
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
161
|
+
onChange(v ? { $ref: v } : undefined);
|
|
162
|
+
}}
|
|
163
|
+
></sp-textfield>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Render a single inline field within an array-of-objects row. Dispatches by schema type: enum →
|
|
168
|
+
* picker, boolean → switch, number → number-field, else → textfield.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} key
|
|
171
|
+
* @param {JsonSchema} schema
|
|
172
|
+
* @param {unknown} value
|
|
173
|
+
* @param {(val: unknown) => void} onChange
|
|
174
|
+
* @param {SchemaFormContext | undefined} ctx
|
|
175
|
+
* @param {Record<string, unknown>} [scope] - Scope for dependent enum refs (the parent form value)
|
|
176
|
+
*/
|
|
177
|
+
export function renderInlineField(
|
|
178
|
+
key: string,
|
|
179
|
+
schema: JsonSchema,
|
|
180
|
+
value: unknown,
|
|
181
|
+
onChange: (val: unknown) => void,
|
|
182
|
+
ctx?: SchemaFormContext,
|
|
183
|
+
scope?: Record<string, unknown>,
|
|
184
|
+
) {
|
|
185
|
+
if (isRef(value)) {
|
|
186
|
+
return refTextField(key, value.$ref, onChange);
|
|
187
|
+
}
|
|
188
|
+
const enumValues = resolveFormEnum(schema.enum, ctx, scope);
|
|
189
|
+
|
|
190
|
+
if (enumValues) {
|
|
191
|
+
return html`<sp-picker
|
|
192
|
+
size="s"
|
|
193
|
+
label=${key}
|
|
194
|
+
value=${value !== undefined ? String(value) : "__none__"}
|
|
195
|
+
@change=${(e: Event) =>
|
|
196
|
+
onChange(
|
|
197
|
+
(e.target as HTMLInputElement).value === "__none__"
|
|
198
|
+
? undefined
|
|
199
|
+
: (e.target as HTMLInputElement).value,
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
<sp-menu-item value="__none__">—</sp-menu-item>
|
|
203
|
+
${enumValues.map((v: string) => html`<sp-menu-item value=${v}>${v}</sp-menu-item>`)}
|
|
204
|
+
</sp-picker>`;
|
|
205
|
+
}
|
|
206
|
+
if (schema.type === "boolean") {
|
|
207
|
+
return html`<sp-switch
|
|
208
|
+
size="s"
|
|
209
|
+
?checked=${Boolean(value)}
|
|
210
|
+
@change=${(e: Event) => onChange((e.target as HTMLInputElement).checked)}
|
|
211
|
+
>${key}</sp-switch
|
|
212
|
+
>`;
|
|
213
|
+
}
|
|
214
|
+
if (schema.type === "integer" || schema.type === "number") {
|
|
215
|
+
return html`<sp-number-field
|
|
216
|
+
size="s"
|
|
217
|
+
label=${key}
|
|
218
|
+
.value=${value !== undefined ? value : nothing}
|
|
219
|
+
step=${schema.type === "integer" ? "1" : nothing}
|
|
220
|
+
@change=${(e: Event) => {
|
|
221
|
+
const parsed = parseNumericField(
|
|
222
|
+
(e.target as HTMLInputElement).value,
|
|
223
|
+
schema.type === "integer",
|
|
224
|
+
);
|
|
225
|
+
onChange(Number.isNaN(parsed) ? undefined : parsed);
|
|
226
|
+
}}
|
|
227
|
+
></sp-number-field>`;
|
|
228
|
+
}
|
|
229
|
+
return html`<sp-textfield
|
|
230
|
+
size="s"
|
|
231
|
+
label=${key}
|
|
232
|
+
placeholder=${key}
|
|
233
|
+
.value=${value ?? ""}
|
|
234
|
+
@input=${(e: Event) => onChange((e.target as HTMLInputElement).value || undefined)}
|
|
235
|
+
></sp-textfield>`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Render a debounced multiline JSON text field for array/object schema properties. */
|
|
239
|
+
function renderJsonTextField(currentValue: unknown, ps: JsonSchema, commit: (v: unknown) => void) {
|
|
240
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
241
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
242
|
+
return html`<sp-textfield
|
|
243
|
+
multiline
|
|
244
|
+
size="s"
|
|
245
|
+
style="min-height:40px"
|
|
246
|
+
.value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
|
|
247
|
+
placeholder=${ps.default !== undefined ? JSON.stringify(ps.default) : nothing}
|
|
248
|
+
@input=${(e: Event) => {
|
|
249
|
+
clearTimeout(debounce);
|
|
250
|
+
debounce = setTimeout(() => {
|
|
251
|
+
try {
|
|
252
|
+
commit(JSON.parse((e.target as HTMLInputElement).value) as unknown);
|
|
253
|
+
} catch {}
|
|
254
|
+
}, 500);
|
|
255
|
+
}}
|
|
256
|
+
></sp-textfield>`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Per-property control dispatch ───────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/** Render the widget for one schema property, honoring registered-control overrides. */
|
|
262
|
+
function renderPropertyControl(
|
|
263
|
+
prop: string,
|
|
264
|
+
ps: JsonSchema,
|
|
265
|
+
value: Record<string, unknown>,
|
|
266
|
+
required: Set<string>,
|
|
267
|
+
opts: RenderFormOptions,
|
|
268
|
+
ctx: SchemaFormContext,
|
|
269
|
+
): TemplateResult {
|
|
270
|
+
const currentValue = value[prop];
|
|
271
|
+
const commit = (next: unknown) => opts.onChange({ [prop]: next });
|
|
272
|
+
const controlArgs: SchemaFormControlArgs = {
|
|
273
|
+
ctx,
|
|
274
|
+
key: prop,
|
|
275
|
+
onChange: commit,
|
|
276
|
+
rerender: opts.rerender,
|
|
277
|
+
schema: ps,
|
|
278
|
+
value: currentValue,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Explicit ui override → consult the control registry first
|
|
282
|
+
const overrideName = opts.ui?.[prop]?.control;
|
|
283
|
+
if (overrideName) {
|
|
284
|
+
const custom = controlRegistry.get(overrideName);
|
|
285
|
+
if (custom) {
|
|
286
|
+
return custom(controlArgs);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (
|
|
291
|
+
isRef(currentValue) &&
|
|
292
|
+
ps.format !== "json-schema" &&
|
|
293
|
+
ps.type !== "object" &&
|
|
294
|
+
ps.type !== "array"
|
|
295
|
+
) {
|
|
296
|
+
const binding = controlRegistry.get("binding");
|
|
297
|
+
if (binding) {
|
|
298
|
+
return binding(controlArgs);
|
|
299
|
+
}
|
|
300
|
+
return refTextField(prop, currentValue.$ref, commit);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const enumValues = resolveFormEnum(opts.ui?.[prop]?.enum ?? ps.enum, ctx, value);
|
|
304
|
+
if (enumValues) {
|
|
305
|
+
return html`
|
|
306
|
+
<sp-picker
|
|
307
|
+
size="s"
|
|
308
|
+
value=${currentValue !== undefined
|
|
309
|
+
? String(currentValue)
|
|
310
|
+
: ps.default !== undefined
|
|
311
|
+
? String(ps.default)
|
|
312
|
+
: "__none__"}
|
|
313
|
+
@change=${(e: Event) =>
|
|
314
|
+
commit(
|
|
315
|
+
(e.target as HTMLInputElement).value === "__none__"
|
|
316
|
+
? undefined
|
|
317
|
+
: (e.target as HTMLInputElement).value,
|
|
318
|
+
)}
|
|
319
|
+
>
|
|
320
|
+
${!required.has(prop) ? html`<sp-menu-item value="__none__">—</sp-menu-item>` : nothing}
|
|
321
|
+
${enumValues.map((val: string) => html`<sp-menu-item value=${val}>${val}</sp-menu-item>`)}
|
|
322
|
+
</sp-picker>
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
if (ps.type === "boolean") {
|
|
326
|
+
return html`<sp-checkbox
|
|
327
|
+
?checked=${currentValue ?? ps.default ?? false}
|
|
328
|
+
@change=${(e: Event) => commit((e.target as HTMLInputElement).checked)}
|
|
329
|
+
></sp-checkbox>`;
|
|
330
|
+
}
|
|
331
|
+
if (ps.type === "integer" || ps.type === "number") {
|
|
332
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
333
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
334
|
+
return html`<sp-number-field
|
|
335
|
+
size="s"
|
|
336
|
+
min=${ifDefined(ps.minimum)}
|
|
337
|
+
max=${ifDefined(ps.maximum)}
|
|
338
|
+
step=${ps.type === "integer" ? "1" : nothing}
|
|
339
|
+
.value=${currentValue !== undefined ? currentValue : nothing}
|
|
340
|
+
placeholder=${ps.default != null ? String(ps.default) : nothing}
|
|
341
|
+
@change=${(e: Event) => {
|
|
342
|
+
clearTimeout(debounce);
|
|
343
|
+
debounce = setTimeout(() => {
|
|
344
|
+
const parsed = parseNumericField(
|
|
345
|
+
(e.target as HTMLInputElement).value,
|
|
346
|
+
ps.type === "integer",
|
|
347
|
+
);
|
|
348
|
+
commit(Number.isNaN(parsed) ? undefined : parsed);
|
|
349
|
+
}, 400);
|
|
350
|
+
}}
|
|
351
|
+
></sp-number-field>`;
|
|
352
|
+
}
|
|
353
|
+
if (ps.format === "json-schema") {
|
|
354
|
+
const hasValue =
|
|
355
|
+
currentValue && typeof currentValue === "object" && Object.keys(currentValue).length > 0;
|
|
356
|
+
const cv = currentValue as Record<string, unknown>;
|
|
357
|
+
const isSchemaRef = hasValue && cv.$ref;
|
|
358
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
359
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
360
|
+
return html`
|
|
361
|
+
<div class="schema-param-editor">
|
|
362
|
+
${hasValue && !isSchemaRef && cv.properties
|
|
363
|
+
? html`
|
|
364
|
+
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px">
|
|
365
|
+
${Object.entries(cv.properties as Record<string, Record<string, unknown>>).map(
|
|
366
|
+
([k, v]) => html`
|
|
367
|
+
<span
|
|
368
|
+
style="background:var(--bg);padding:1px 6px;border-radius:var(--radius);font-size:10px;color:var(--fg-dim)"
|
|
369
|
+
>${k}: ${v.type ?? "any"}</span
|
|
370
|
+
>
|
|
371
|
+
`,
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
`
|
|
375
|
+
: nothing}
|
|
376
|
+
<sp-textfield
|
|
377
|
+
multiline
|
|
378
|
+
size="s"
|
|
379
|
+
style=${styleMap({
|
|
380
|
+
fontFamily: "monospace",
|
|
381
|
+
fontSize: "11px",
|
|
382
|
+
minHeight: hasValue ? "80px" : "40px",
|
|
383
|
+
})}
|
|
384
|
+
.value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
|
|
385
|
+
placeholder=${ps.description ?? "JSON Schema defining the data shape…"}
|
|
386
|
+
@input=${(e: Event) => {
|
|
387
|
+
clearTimeout(debounce);
|
|
388
|
+
debounce = setTimeout(() => {
|
|
389
|
+
try {
|
|
390
|
+
commit(JSON.parse((e.target as HTMLInputElement).value) as unknown);
|
|
391
|
+
} catch {}
|
|
392
|
+
}, 500);
|
|
393
|
+
}}
|
|
394
|
+
></sp-textfield>
|
|
395
|
+
</div>
|
|
396
|
+
`;
|
|
397
|
+
}
|
|
398
|
+
if (ps.type === "array" && ps.items?.type === "object" && ps.items?.properties) {
|
|
399
|
+
// Array of objects with defined schema → multi-row inline form
|
|
400
|
+
const rows: Record<string, unknown>[] = Array.isArray(currentValue)
|
|
401
|
+
? (currentValue as Record<string, unknown>[])
|
|
402
|
+
: [];
|
|
403
|
+
const itemProps = ps.items.properties;
|
|
404
|
+
return html`
|
|
405
|
+
<div class="array-object-field">
|
|
406
|
+
${rows.map(
|
|
407
|
+
(row: Record<string, unknown>, idx: number) => html`
|
|
408
|
+
<div
|
|
409
|
+
class="array-object-row"
|
|
410
|
+
style="display:flex;gap:4px;align-items:center;margin-bottom:4px"
|
|
411
|
+
>
|
|
412
|
+
${Object.entries(itemProps).map(([propKey, propSchema]) =>
|
|
413
|
+
renderInlineField(
|
|
414
|
+
propKey,
|
|
415
|
+
propSchema,
|
|
416
|
+
row[propKey],
|
|
417
|
+
(val) => {
|
|
418
|
+
const updated = [...rows];
|
|
419
|
+
updated[idx] = { ...updated[idx], [propKey]: val };
|
|
420
|
+
commit(updated);
|
|
421
|
+
},
|
|
422
|
+
ctx,
|
|
423
|
+
value,
|
|
424
|
+
),
|
|
425
|
+
)}
|
|
426
|
+
<sp-action-button
|
|
427
|
+
quiet
|
|
428
|
+
size="s"
|
|
429
|
+
@click=${() => {
|
|
430
|
+
const updated = rows.filter((_: unknown, i: number) => i !== idx);
|
|
431
|
+
commit(updated.length > 0 ? updated : undefined);
|
|
432
|
+
opts.rerender?.();
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
436
|
+
</sp-action-button>
|
|
437
|
+
</div>
|
|
438
|
+
`,
|
|
439
|
+
)}
|
|
440
|
+
<sp-action-button
|
|
441
|
+
quiet
|
|
442
|
+
size="s"
|
|
443
|
+
@click=${(e: Event) => {
|
|
444
|
+
e.stopPropagation();
|
|
445
|
+
const newRow: Record<string, unknown> = {};
|
|
446
|
+
for (const [k, v] of Object.entries(itemProps)) {
|
|
447
|
+
if (v.default !== undefined) {
|
|
448
|
+
newRow[k] = v.default;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
commit([...rows, newRow]);
|
|
452
|
+
opts.rerender?.();
|
|
453
|
+
}}
|
|
454
|
+
>+ Add</sp-action-button
|
|
455
|
+
>
|
|
456
|
+
</div>
|
|
457
|
+
`;
|
|
458
|
+
}
|
|
459
|
+
if (ps.type === "array" || ps.type === "object") {
|
|
460
|
+
return renderJsonTextField(currentValue, ps, commit) as TemplateResult;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
464
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
465
|
+
const params = ctx.params ?? [];
|
|
466
|
+
const ph = ps.default !== undefined ? String(ps.default) : (ps.examples?.[0] ?? "");
|
|
467
|
+
return html`<div style="display:flex;gap:4px;align-items:center">
|
|
468
|
+
<sp-textfield
|
|
469
|
+
size="s"
|
|
470
|
+
style="flex:1"
|
|
471
|
+
.value=${currentValue ?? ""}
|
|
472
|
+
placeholder=${ph || nothing}
|
|
473
|
+
title=${ps.description || nothing}
|
|
474
|
+
@input=${(e: Event) => {
|
|
475
|
+
clearTimeout(debounce);
|
|
476
|
+
debounce = setTimeout(() => commit((e.target as HTMLInputElement).value || undefined), 400);
|
|
477
|
+
}}
|
|
478
|
+
></sp-textfield>
|
|
479
|
+
${params.length > 0
|
|
480
|
+
? html`<sp-action-button
|
|
481
|
+
quiet
|
|
482
|
+
size="s"
|
|
483
|
+
title="Bind to route param"
|
|
484
|
+
@click=${() => {
|
|
485
|
+
commit({ $ref: `#/$params/${params[0]}` });
|
|
486
|
+
opts.rerender?.();
|
|
487
|
+
}}
|
|
488
|
+
><sp-icon-link slot="icon"></sp-icon-link
|
|
489
|
+
></sp-action-button>`
|
|
490
|
+
: nothing}
|
|
491
|
+
</div>`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Form rendering ──────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Render form field rows for a JSON Schema's `properties`, committing edits through `opts.onChange`
|
|
498
|
+
* as single-key patches (`undefined` values mean "unset the key").
|
|
499
|
+
*
|
|
500
|
+
* @param {JsonSchema} schema
|
|
501
|
+
* @param {Record<string, unknown>} value - The record being edited
|
|
502
|
+
* @param {RenderFormOptions} opts
|
|
503
|
+
* @returns {TemplateResult}
|
|
504
|
+
*/
|
|
505
|
+
export function renderForm(
|
|
506
|
+
schema: JsonSchema,
|
|
507
|
+
value: Record<string, unknown>,
|
|
508
|
+
opts: RenderFormOptions,
|
|
509
|
+
): TemplateResult {
|
|
510
|
+
const required = new Set(schema.required);
|
|
511
|
+
const ctx = opts.context ?? NULL_CONTEXT;
|
|
512
|
+
|
|
513
|
+
const propertyFields = Object.entries(schema.properties ?? {}).map(([prop, ps]) => {
|
|
514
|
+
const labelText = prop + (required.has(prop) ? " *" : "");
|
|
515
|
+
return renderFieldRow({
|
|
516
|
+
hasValue: false,
|
|
517
|
+
label: labelText,
|
|
518
|
+
prop: ps.name || prop,
|
|
519
|
+
widget: renderPropertyControl(prop, ps, value, required, opts, ctx),
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
return html`${propertyFields}`;
|
|
524
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { defaultContentFormat, formatByName } from "../format/format-host";
|
|
8
|
-
import type {
|
|
8
|
+
import type { ContentSectionEntry } from "../types";
|
|
9
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* CamelCase → kebab-case for inline style attributes
|
|
@@ -165,14 +166,14 @@ export function findContentTypeSchema(
|
|
|
165
166
|
documentPath: string | null,
|
|
166
167
|
projectConfig: ProjectConfig | null | undefined,
|
|
167
168
|
) {
|
|
168
|
-
if (!documentPath || !projectConfig?.
|
|
169
|
+
if (!documentPath || !projectConfig?.content) {
|
|
169
170
|
return null;
|
|
170
171
|
}
|
|
171
172
|
// Content-type `source` prefixes are always forward-slash. The desktop platform can hand us
|
|
172
173
|
// OS-native backslash paths on Windows, so normalize before prefix matching.
|
|
173
174
|
const docPath = documentPath.replaceAll("\\", "/");
|
|
174
175
|
for (const [name, def] of Object.entries(
|
|
175
|
-
projectConfig.
|
|
176
|
+
projectConfig.content as Record<string, ContentSectionEntry>,
|
|
176
177
|
)) {
|
|
177
178
|
if (!def.source || !def.schema) {
|
|
178
179
|
continue;
|