feat(config-builder): add typed field renderer for phase 3

This commit is contained in:
Sebastian
2026-02-09 20:57:30 -05:00
parent 04c741b3ec
commit d22b4c3769
5 changed files with 565 additions and 113 deletions

View File

@@ -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:

View File

@@ -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");
});
});

View File

@@ -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),
});

View File

@@ -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>

View 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>`;
}