mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
feat(config-builder): add typed field renderer for phase 3
This commit is contained in:
@@ -12,14 +12,18 @@ Use the same front-end stack as the existing OpenClaw web UI (`ui/`):
|
||||
|
||||
## Current status
|
||||
|
||||
Phase 0, Phase 1, and Phase 2 are in place:
|
||||
Phase 0, Phase 1, Phase 2, and Phase 3 are in place:
|
||||
|
||||
- app boots with Vite + Lit
|
||||
- `OpenClawSchema.toJSONSchema()` runs in browser bundle
|
||||
- `buildConfigSchema()` UI hints load in browser bundle
|
||||
- Explorer scaffold renders grouped sections + field metadata
|
||||
- Primitive field editing writes sparse config state by dot-path
|
||||
- Draft state persists to localStorage
|
||||
- Sparse draft state persists to localStorage and renders to JSON5 preview
|
||||
- Typed field renderer covers:
|
||||
- strings, numbers, integers, booleans, enums
|
||||
- primitive arrays with add/remove
|
||||
- record-like objects (key/value editor)
|
||||
- JSON fallback editor for complex array/object shapes
|
||||
- Live JSON5 preview supports copy/download/reset
|
||||
|
||||
To run locally:
|
||||
|
||||
@@ -22,5 +22,17 @@ describe("buildExplorerSnapshot", () => {
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path.includes("*"));
|
||||
expect(wildcardField?.editable).toBe(false);
|
||||
|
||||
const arrayField = snapshot.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === "tools.alsoAllow");
|
||||
expect(arrayField?.kind).toBe("array");
|
||||
expect(arrayField?.itemKind).toBe("string");
|
||||
|
||||
const recordField = snapshot.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === "diagnostics.otel.headers");
|
||||
expect(recordField?.kind).toBe("object");
|
||||
expect(recordField?.recordValueKind).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,10 @@ export type ExplorerField = {
|
||||
advanced: boolean;
|
||||
kind: FieldKind;
|
||||
enumValues: string[];
|
||||
itemKind: FieldKind | null;
|
||||
itemEnumValues: string[];
|
||||
recordValueKind: FieldKind | null;
|
||||
recordEnumValues: string[];
|
||||
hasDefault: boolean;
|
||||
editable: boolean;
|
||||
};
|
||||
@@ -202,11 +206,32 @@ function resolveType(node: JsonSchemaNode | null): FieldKind {
|
||||
}
|
||||
}
|
||||
|
||||
function firstArrayItemNode(node: JsonSchemaNode | null): JsonSchemaNode | null {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(node.items)) {
|
||||
return asObjectNode(node.items[0] ?? null);
|
||||
}
|
||||
return asObjectNode(node.items);
|
||||
}
|
||||
|
||||
function recordValueNode(node: JsonSchemaNode | null): JsonSchemaNode | null {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
const properties = node.properties ?? {};
|
||||
if (Object.keys(properties).length > 0) {
|
||||
return null;
|
||||
}
|
||||
return asObjectNode(node.additionalProperties);
|
||||
}
|
||||
|
||||
function isEditable(path: string, kind: FieldKind): boolean {
|
||||
if (path.includes("*") || path.includes("[]")) {
|
||||
return false;
|
||||
}
|
||||
return kind === "string" || kind === "number" || kind === "integer" || kind === "boolean" || kind === "enum";
|
||||
return kind !== "unknown";
|
||||
}
|
||||
|
||||
function toEnumValues(values: unknown[] | undefined): string[] {
|
||||
@@ -282,6 +307,10 @@ export function buildExplorerSnapshot(): ExplorerSnapshot {
|
||||
|
||||
const schemaNode = resolveSchemaNode(schemaRoot ?? {}, path);
|
||||
const kind = resolveType(schemaNode);
|
||||
const arrayItemNode = kind === "array" ? firstArrayItemNode(schemaNode) : null;
|
||||
const itemKind = arrayItemNode ? resolveType(arrayItemNode) : null;
|
||||
const recordNode = kind === "object" ? recordValueNode(schemaNode) : null;
|
||||
const recordValueKind = recordNode ? resolveType(recordNode) : null;
|
||||
|
||||
target.fields.push({
|
||||
path,
|
||||
@@ -291,6 +320,10 @@ export function buildExplorerSnapshot(): ExplorerSnapshot {
|
||||
advanced: Boolean(hint.advanced),
|
||||
kind,
|
||||
enumValues: toEnumValues(schemaNode?.enum),
|
||||
itemKind,
|
||||
itemEnumValues: toEnumValues(arrayItemNode?.enum),
|
||||
recordValueKind,
|
||||
recordEnumValues: toEnumValues(recordNode?.enum),
|
||||
hasDefault: schemaNode?.default !== undefined,
|
||||
editable: isEditable(path, kind),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ExplorerSection,
|
||||
type ExplorerSnapshot,
|
||||
} from "../lib/schema-spike.ts";
|
||||
import { renderFieldEditor } from "./components/field-renderer.ts";
|
||||
|
||||
type AppState =
|
||||
| { status: "loading" }
|
||||
@@ -58,6 +59,7 @@ class ConfigBuilderApp extends LitElement {
|
||||
private config: ConfigDraft = {};
|
||||
private selectedSectionId: string | null = null;
|
||||
private searchQuery = "";
|
||||
private fieldErrors: Record<string, string> = {};
|
||||
private copyState: CopyState = "idle";
|
||||
private copyResetTimer: number | null = null;
|
||||
|
||||
@@ -107,56 +109,38 @@ class ConfigBuilderApp extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private setFieldError(path: string, message: string): void {
|
||||
this.fieldErrors = {
|
||||
...this.fieldErrors,
|
||||
[path]: message,
|
||||
};
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private clearFieldError(path: string): void {
|
||||
if (!(path in this.fieldErrors)) {
|
||||
return;
|
||||
}
|
||||
const next = { ...this.fieldErrors };
|
||||
delete next[path];
|
||||
this.fieldErrors = next;
|
||||
}
|
||||
|
||||
private clearField(path: string): void {
|
||||
this.clearFieldError(path);
|
||||
this.saveConfig(clearFieldValue(this.config, path));
|
||||
}
|
||||
|
||||
private setField(path: string, value: unknown): void {
|
||||
this.clearFieldError(path);
|
||||
this.saveConfig(setFieldValue(this.config, path, value));
|
||||
}
|
||||
|
||||
private resetAllFields(): void {
|
||||
this.fieldErrors = {};
|
||||
this.saveConfig(resetDraft());
|
||||
}
|
||||
|
||||
private parseAndSetField(field: ExplorerField, raw: string): void {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
this.clearField(field.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.kind === "number" || field.kind === "integer") {
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return;
|
||||
}
|
||||
this.setField(field.path, field.kind === "integer" ? Math.trunc(parsed) : parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.kind === "boolean") {
|
||||
if (trimmed === "true") {
|
||||
this.setField(field.path, true);
|
||||
return;
|
||||
}
|
||||
if (trimmed === "false") {
|
||||
this.setField(field.path, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.kind === "enum") {
|
||||
if (field.enumValues.includes(trimmed)) {
|
||||
this.setField(field.path, trimmed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.setField(field.path, raw);
|
||||
}
|
||||
|
||||
private async copyPreview(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -245,9 +229,9 @@ class ConfigBuilderApp extends LitElement {
|
||||
<div class="config-sidebar__header">
|
||||
<div>
|
||||
<div class="config-sidebar__title">Config Builder</div>
|
||||
<div class="builder-subtitle">Explorer + JSON5 preview</div>
|
||||
<div class="builder-subtitle">Typed field renderer + JSON5 preview</div>
|
||||
</div>
|
||||
<span class="pill pill--sm pill--ok">phase 2</span>
|
||||
<span class="pill pill--sm pill--ok">phase 3</span>
|
||||
</div>
|
||||
|
||||
${this.renderSearch()}
|
||||
@@ -287,76 +271,10 @@ class ConfigBuilderApp extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFieldControl(field: ExplorerField, value: unknown) {
|
||||
if (!field.editable) {
|
||||
return html`<div class="cfg-field__help">Phase 2 edits only primitive concrete paths.</div>`;
|
||||
}
|
||||
|
||||
if (field.kind === "boolean") {
|
||||
return html`
|
||||
<label class="cfg-toggle-row builder-toggle-row">
|
||||
<span class="cfg-field__help">Toggle to set this boolean value.</span>
|
||||
<div class="cfg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${value === true}
|
||||
@change=${(event: Event) =>
|
||||
this.setField(field.path, (event.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span class="cfg-toggle__track"></span>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (field.kind === "enum") {
|
||||
const selectValue = typeof value === "string" ? value : "";
|
||||
return html`
|
||||
<select
|
||||
class="cfg-select"
|
||||
.value=${selectValue}
|
||||
@change=${(event: Event) => {
|
||||
const next = (event.target as HTMLSelectElement).value;
|
||||
if (!next) {
|
||||
this.clearField(field.path);
|
||||
return;
|
||||
}
|
||||
this.setField(field.path, next);
|
||||
}}
|
||||
>
|
||||
<option value="">(unset)</option>
|
||||
${field.enumValues.map((option) => html`<option value=${option}>${option}</option>`)}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
if (field.kind === "number" || field.kind === "integer") {
|
||||
const textValue = typeof value === "number" ? String(value) : "";
|
||||
return html`
|
||||
<input
|
||||
class="cfg-input"
|
||||
type="number"
|
||||
.value=${textValue}
|
||||
@input=${(event: Event) =>
|
||||
this.parseAndSetField(field, (event.target as HTMLInputElement).value)}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
const textValue = typeof value === "string" ? value : "";
|
||||
return html`
|
||||
<input
|
||||
class="cfg-input"
|
||||
type=${field.sensitive ? "password" : "text"}
|
||||
.value=${textValue}
|
||||
@input=${(event: Event) => this.parseAndSetField(field, (event.target as HTMLInputElement).value)}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderField(field: ExplorerField) {
|
||||
const value = getFieldValue(this.config, field.path);
|
||||
const hasValue = value !== undefined;
|
||||
const error = this.fieldErrors[field.path] ?? null;
|
||||
|
||||
return html`
|
||||
<div class="cfg-field builder-field ${hasValue ? "builder-field--set" : ""}">
|
||||
@@ -367,6 +285,12 @@ class ConfigBuilderApp extends LitElement {
|
||||
${field.sensitive ? html`<span class="pill pill--sm pill--danger">sensitive</span>` : nothing}
|
||||
${field.advanced ? html`<span class="pill pill--sm">advanced</span>` : nothing}
|
||||
<span class="pill pill--sm mono">${field.kind}</span>
|
||||
${field.kind === "array" && field.itemKind
|
||||
? html`<span class="pill pill--sm mono">item:${field.itemKind}</span>`
|
||||
: nothing}
|
||||
${field.kind === "object" && field.recordValueKind
|
||||
? html`<span class="pill pill--sm mono">value:${field.recordValueKind}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,7 +298,17 @@ class ConfigBuilderApp extends LitElement {
|
||||
|
||||
${field.help ? html`<div class="cfg-field__help">${field.help}</div>` : nothing}
|
||||
|
||||
<div class="builder-field__controls">${this.renderFieldControl(field, value)}</div>
|
||||
<div class="builder-field__controls">
|
||||
${renderFieldEditor({
|
||||
field,
|
||||
value,
|
||||
onSet: (nextValue: unknown) => this.setField(field.path, nextValue),
|
||||
onClear: () => this.clearField(field.path),
|
||||
onValidationError: (message: string) => this.setFieldError(field.path, message),
|
||||
})}
|
||||
</div>
|
||||
|
||||
${error ? html`<div class="cfg-field__error">${error}</div>` : nothing}
|
||||
|
||||
<div class="builder-field__actions">
|
||||
<button class="btn btn--sm" @click=${() => this.clearField(field.path)}>Clear</button>
|
||||
@@ -464,7 +398,7 @@ class ConfigBuilderApp extends LitElement {
|
||||
<main class="config-main">
|
||||
<div class="config-actions">
|
||||
<div class="config-actions__left">
|
||||
<span class="config-status">Phase 2: sparse state + live JSON5 preview</span>
|
||||
<span class="config-status">Phase 3: typed field renderer + live JSON5 preview</span>
|
||||
</div>
|
||||
<div class="config-actions__right">
|
||||
<span class="pill pill--sm">sections: ${snapshot.sectionCount}</span>
|
||||
|
||||
469
apps/config-builder/src/ui/components/field-renderer.ts
Normal file
469
apps/config-builder/src/ui/components/field-renderer.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { ExplorerField, FieldKind } from "../../lib/schema-spike.ts";
|
||||
|
||||
type FieldRendererParams = {
|
||||
field: ExplorerField;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onClear: () => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
};
|
||||
|
||||
function defaultValueForKind(kind: FieldKind): unknown {
|
||||
if (kind === "boolean") {
|
||||
return false;
|
||||
}
|
||||
if (kind === "number" || kind === "integer") {
|
||||
return 0;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseScalar(kind: FieldKind, raw: string): unknown {
|
||||
if (kind === "number") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error("Enter a valid number.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (kind === "integer") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error("Enter a valid integer.");
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
if (kind === "boolean") {
|
||||
if (raw === "true") {
|
||||
return true;
|
||||
}
|
||||
if (raw === "false") {
|
||||
return false;
|
||||
}
|
||||
throw new Error("Use true or false.");
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? "{}";
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function renderScalarControl(params: {
|
||||
field: ExplorerField;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onClear: () => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
}): TemplateResult {
|
||||
const { field, value, onSet, onClear, onValidationError } = params;
|
||||
|
||||
if (field.kind === "boolean") {
|
||||
return html`
|
||||
<label class="cfg-toggle-row builder-toggle-row">
|
||||
<span class="cfg-field__help">Toggle value</span>
|
||||
<div class="cfg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${value === true}
|
||||
@change=${(event: Event) => onSet((event.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span class="cfg-toggle__track"></span>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (field.kind === "enum") {
|
||||
const selected = typeof value === "string" ? value : "";
|
||||
if (field.enumValues.length > 0 && field.enumValues.length <= 4) {
|
||||
return html`
|
||||
<div class="cfg-segmented">
|
||||
${field.enumValues.map(
|
||||
(entry) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-segmented__btn ${entry === selected ? "active" : ""}"
|
||||
@click=${() => onSet(entry)}
|
||||
>
|
||||
${entry}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
<button type="button" class="cfg-segmented__btn ${selected ? "" : "active"}" @click=${onClear}>
|
||||
unset
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<select
|
||||
class="cfg-select"
|
||||
.value=${selected}
|
||||
@change=${(event: Event) => {
|
||||
const next = (event.target as HTMLSelectElement).value;
|
||||
if (!next) {
|
||||
onClear();
|
||||
return;
|
||||
}
|
||||
onSet(next);
|
||||
}}
|
||||
>
|
||||
<option value="">(unset)</option>
|
||||
${field.enumValues.map((entry) => html`<option value=${entry}>${entry}</option>`)}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
const inputType = field.kind === "number" || field.kind === "integer" ? "number" : "text";
|
||||
const inputValue = value == null ? "" : String(value);
|
||||
|
||||
return html`
|
||||
<input
|
||||
class="cfg-input"
|
||||
type=${field.sensitive ? "password" : inputType}
|
||||
.value=${inputValue}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
if (raw.trim() === "") {
|
||||
onClear();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onSet(parseScalar(field.kind, raw));
|
||||
} catch (error) {
|
||||
onValidationError?.(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPrimitiveArray(params: {
|
||||
field: ExplorerField;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
}): TemplateResult {
|
||||
const { field, value, onSet, onValidationError } = params;
|
||||
const list = asArray(value);
|
||||
const itemKind = field.itemKind;
|
||||
const itemEnum = field.itemEnumValues;
|
||||
|
||||
if (!itemKind || itemKind === "unknown" || itemKind === "object" || itemKind === "array") {
|
||||
return renderJsonControl({ field, value, onSet, onValidationError });
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="cfg-array">
|
||||
<div class="cfg-array__header">
|
||||
<span class="cfg-array__label">Items</span>
|
||||
<span class="cfg-array__count">${list.length} item${list.length === 1 ? "" : "s"}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-array__add"
|
||||
@click=${() => onSet([...list, defaultValueForKind(itemKind)])}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${list.length === 0
|
||||
? html`<div class="cfg-array__empty">No items yet.</div>`
|
||||
: html`
|
||||
<div class="cfg-array__items">
|
||||
${list.map((item, index) =>
|
||||
html`
|
||||
<div class="cfg-array__item">
|
||||
<div class="cfg-array__item-header">
|
||||
<span class="cfg-array__item-index">#${index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-array__item-remove"
|
||||
title="Remove item"
|
||||
@click=${() => {
|
||||
const next = [...list];
|
||||
next.splice(index, 1);
|
||||
onSet(next);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="cfg-array__item-content">
|
||||
${itemKind === "boolean"
|
||||
? html`
|
||||
<label class="cfg-toggle-row builder-toggle-row">
|
||||
<span class="cfg-field__help">boolean</span>
|
||||
<div class="cfg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${item === true}
|
||||
@change=${(event: Event) => {
|
||||
const next = [...list];
|
||||
next[index] = (event.target as HTMLInputElement).checked;
|
||||
onSet(next);
|
||||
}}
|
||||
/>
|
||||
<span class="cfg-toggle__track"></span>
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
: itemKind === "enum" && itemEnum.length > 0
|
||||
? html`
|
||||
<select
|
||||
class="cfg-select"
|
||||
.value=${String(item ?? "")}
|
||||
@change=${(event: Event) => {
|
||||
const next = [...list];
|
||||
next[index] = (event.target as HTMLSelectElement).value;
|
||||
onSet(next);
|
||||
}}
|
||||
>
|
||||
${itemEnum.map(
|
||||
(entry) => html`<option value=${entry}>${entry}</option>`,
|
||||
)}
|
||||
</select>
|
||||
`
|
||||
: html`
|
||||
<input
|
||||
class="cfg-input"
|
||||
type=${itemKind === "number" || itemKind === "integer" ? "number" : "text"}
|
||||
.value=${item == null ? "" : String(item)}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const next = [...list];
|
||||
if (raw.trim() === "") {
|
||||
next[index] = defaultValueForKind(itemKind);
|
||||
onSet(next);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
next[index] = parseScalar(itemKind, raw);
|
||||
onSet(next);
|
||||
} catch (error) {
|
||||
onValidationError?.(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecordObject(params: {
|
||||
field: ExplorerField;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
}): TemplateResult {
|
||||
const { field, value, onSet, onValidationError } = params;
|
||||
const record = asObject(value);
|
||||
const entries = Object.entries(record);
|
||||
const valueKind = field.recordValueKind;
|
||||
|
||||
if (!valueKind || valueKind === "unknown" || valueKind === "object" || valueKind === "array") {
|
||||
return renderJsonControl(params);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="cfg-map">
|
||||
<div class="cfg-map__header">
|
||||
<span class="cfg-map__label">Entries</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__add"
|
||||
@click=${() => {
|
||||
const next = { ...record };
|
||||
let index = 1;
|
||||
let key = `key-${index}`;
|
||||
while (key in next) {
|
||||
index += 1;
|
||||
key = `key-${index}`;
|
||||
}
|
||||
next[key] = defaultValueForKind(valueKind);
|
||||
onSet(next);
|
||||
}}
|
||||
>
|
||||
Add Entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${entries.length === 0
|
||||
? html`<div class="cfg-map__empty">No entries yet.</div>`
|
||||
: html`
|
||||
<div class="cfg-map__items">
|
||||
${entries.map(([key, entryValue]) =>
|
||||
html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-key">
|
||||
<input
|
||||
type="text"
|
||||
class="cfg-input cfg-input--sm"
|
||||
.value=${key}
|
||||
@change=${(event: Event) => {
|
||||
const nextKey = (event.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key || nextKey in record) {
|
||||
return;
|
||||
}
|
||||
const next = { ...record };
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onSet(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cfg-map__item-value">
|
||||
${valueKind === "boolean"
|
||||
? html`
|
||||
<label class="cfg-toggle-row builder-toggle-row">
|
||||
<span class="cfg-field__help">boolean</span>
|
||||
<div class="cfg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${entryValue === true}
|
||||
@change=${(event: Event) => {
|
||||
const next = { ...record };
|
||||
next[key] = (event.target as HTMLInputElement).checked;
|
||||
onSet(next);
|
||||
}}
|
||||
/>
|
||||
<span class="cfg-toggle__track"></span>
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
: html`
|
||||
<input
|
||||
class="cfg-input cfg-input--sm"
|
||||
type=${valueKind === "number" || valueKind === "integer" ? "number" : "text"}
|
||||
.value=${entryValue == null ? "" : String(entryValue)}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const next = { ...record };
|
||||
try {
|
||||
next[key] = raw.trim() === "" ? defaultValueForKind(valueKind) : parseScalar(valueKind, raw);
|
||||
onSet(next);
|
||||
} catch (error) {
|
||||
onValidationError?.(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__item-remove"
|
||||
title="Remove entry"
|
||||
@click=${() => {
|
||||
const next = { ...record };
|
||||
delete next[key];
|
||||
onSet(next);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderJsonControl(params: {
|
||||
field: ExplorerField;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
}): TemplateResult {
|
||||
const { field, value, onSet, onValidationError } = params;
|
||||
return html`
|
||||
<label class="cfg-field">
|
||||
<span class="cfg-field__help">Edit as JSON (${field.kind})</span>
|
||||
<textarea
|
||||
class="cfg-textarea"
|
||||
rows="4"
|
||||
.value=${jsonValue(value ?? (field.kind === "array" ? [] : {}))}
|
||||
@change=${(event: Event) => {
|
||||
const raw = (event.target as HTMLTextAreaElement).value.trim();
|
||||
if (!raw) {
|
||||
onSet(field.kind === "array" ? [] : {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onSet(JSON.parse(raw));
|
||||
} catch {
|
||||
onValidationError?.("Invalid JSON value.");
|
||||
(event.target as HTMLTextAreaElement).value = jsonValue(
|
||||
value ?? (field.kind === "array" ? [] : {}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderFieldEditor(params: FieldRendererParams): TemplateResult | typeof nothing {
|
||||
const { field, value, onSet, onClear, onValidationError } = params;
|
||||
|
||||
if (!field.editable) {
|
||||
return html`<div class="cfg-field__help">Read-only in this phase.</div>`;
|
||||
}
|
||||
|
||||
if (
|
||||
field.kind === "string" ||
|
||||
field.kind === "number" ||
|
||||
field.kind === "integer" ||
|
||||
field.kind === "boolean" ||
|
||||
field.kind === "enum"
|
||||
) {
|
||||
return renderScalarControl({ field, value, onSet, onClear, onValidationError });
|
||||
}
|
||||
|
||||
if (field.kind === "array") {
|
||||
return renderPrimitiveArray({ field, value, onSet, onValidationError });
|
||||
}
|
||||
|
||||
if (field.kind === "object") {
|
||||
return renderRecordObject({ field, value, onSet, onValidationError });
|
||||
}
|
||||
|
||||
return html`<div class="cfg-field__help">Unsupported schema node.</div>`;
|
||||
}
|
||||
Reference in New Issue
Block a user