Files
openclaw/ui/src/components/config-form.node.ts
2026-07-31 16:06:36 +08:00

263 lines
7.8 KiB
TypeScript

// Control UI view dispatches config form schema node rendering.
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../i18n/index.ts";
import {
shouldStageStructuredDraft,
structuredDraftInitialValue,
type ConfigFormStructuredDraftProps,
} from "./config-form-structured-draft.ts";
import { renderArray, renderObject } from "./config-form.node.collection.ts";
import { renderJsonTextarea } from "./config-form.node.json.ts";
import { renderNumberInput, renderSelect, renderTextInput } from "./config-form.node.scalar.ts";
import {
renderFieldRow,
isAnySchema,
renderRestoreDefaultButton,
renderSchemaDefaultDescription,
renderSegmentedControl,
renderTags,
type ConfigNodeRenderParams,
} from "./config-form.node.shared.ts";
import {
hasConfigSearchCriteria as hasSearchCriteria,
matchesNodeSearch,
resolveConfigFieldMeta as resolveFieldMeta,
} from "./config-form.search.ts";
import { configFieldId, pathKey, schemaType } from "./config-form.shared.ts";
import { renderSettingsToggle, renderSettingsToggleRow } from "./settings-ui.ts";
export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const key = pathKey(path);
const criteria = params.searchCriteria;
if (unsupported.has(key)) {
return renderFieldRow({
label,
tags: [],
showLabel: true,
control: nothing,
error: t("configForm.unsupportedNode"),
});
}
if (
criteria &&
hasSearchCriteria(criteria) &&
!matchesNodeSearch({ schema, value, path, hints, criteria })
) {
return nothing;
}
const structuredDraftValue = structuredDraftInitialValue(params);
if (shouldStageStructuredDraft(params, structuredDraftValue)) {
const props: ConfigFormStructuredDraftProps = {
identity: configFieldId(path, "structured-draft"),
sourceIdentity: params.sourceIdentity ?? value,
initialValue: structuredDraftValue,
params,
renderNode,
};
return html`
<openclaw-config-form-structured-draft
class="cfg-structured-draft"
.props=${props}
></openclaw-config-form-structured-draft>
`;
}
// Handle anyOf/oneOf unions
if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(variant) =>
!(
variant.type === "null" ||
(Array.isArray(variant.type) && variant.type.includes("null"))
),
);
if (nonNull.length === 1) {
const selectedSchema = nonNull[0];
return selectedSchema ? renderNode({ ...params, schema: selectedSchema }) : nothing;
}
// Check if it's a set of literal values (enum-like)
const extractLiteral = (variant: (typeof nonNull)[number]): unknown => {
if (variant.const !== undefined) {
return variant.const;
}
if (variant.enum && variant.enum.length === 1) {
return variant.enum[0];
}
return undefined;
};
const literals = nonNull.map(extractLiteral);
const allLiterals = literals.every((literal) => literal !== undefined);
if (allLiterals && literals.length > 0 && literals.length <= 5) {
// Use segmented control for small sets
const resolvedValue = value !== undefined ? value : schema.default;
return renderFieldRow({
label,
help,
defaultDescription: renderSchemaDefaultDescription(schema, value),
tags,
showLabel,
control: html`
${renderSegmentedControl({
options: literals,
resolvedValue,
disabled,
ariaLabel: label,
onSelect: (literal) => onPatch(path, literal),
})}
${renderRestoreDefaultButton(params)}
`,
});
}
if (allLiterals && literals.length > 5) {
// Use dropdown for larger sets
return renderSelect({ ...params, options: literals });
}
// Handle mixed primitive types
const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean));
const normalizedTypes = new Set(
[...primitiveTypes].map((variantType) =>
variantType === "integer" ? "number" : variantType,
),
);
if (
[...normalizedTypes].every((variantType) =>
["string", "number", "boolean"].includes(variantType as string),
)
) {
const hasString = normalizedTypes.has("string");
const hasNumber = normalizedTypes.has("number");
const hasBoolean = normalizedTypes.has("boolean");
if (hasBoolean && normalizedTypes.size === 1) {
return renderNode({
...params,
schema: { ...schema, type: "boolean", anyOf: undefined, oneOf: undefined },
});
}
if (hasString || hasNumber) {
return renderTextInput({
...params,
inputType: hasNumber && !hasString ? "number" : "text",
});
}
}
// Complex union (e.g. array | object) — render as JSON textarea
return renderJsonTextarea(params);
}
// Enum - use segmented for small, dropdown for large
if (schema.enum) {
const options = schema.enum;
if (options.length <= 5) {
const resolvedValue = value !== undefined ? value : schema.default;
return renderFieldRow({
label,
help,
defaultDescription: renderSchemaDefaultDescription(schema, value),
tags,
showLabel,
control: html`
${renderSegmentedControl({
options,
resolvedValue,
disabled,
ariaLabel: label,
onSelect: (option) => onPatch(path, option),
})}
${renderRestoreDefaultButton(params)}
`,
});
}
return renderSelect({ ...params, options });
}
// Object type - collapsible section
if (type === "object") {
return renderObject(params, renderNode);
}
// Array type
if (type === "array") {
return renderArray(params, renderNode);
}
// Boolean - toggle row
if (type === "boolean") {
const displayValue =
typeof value === "boolean"
? value
: typeof schema.default === "boolean"
? schema.default
: false;
const onChange = (checked: boolean) => onPatch(path, checked);
if (!showLabel) {
// Control-only contexts (array items, map values) have no visible title,
// so the switch keeps its accessible name from the field label.
return renderFieldRow({
label,
help,
tags,
showLabel,
control: renderSettingsToggle({
checked: displayValue,
disabled,
ariaLabel: label,
onChange,
}),
});
}
const description =
help || tags.length > 0 || schema.default !== undefined
? html`
${help ?? nothing} ${help && schema.default !== undefined ? html`<br />` : nothing}
${renderSchemaDefaultDescription(schema, value)}${renderTags(tags)}
`
: undefined;
return renderSettingsToggleRow({
title: label,
description,
checked: displayValue,
disabled,
onChange,
actions: renderRestoreDefaultButton(params),
});
}
// Number/Integer
if (type === "number" || type === "integer") {
return renderNumberInput(params);
}
// String
if (type === "string") {
return renderTextInput({ ...params, inputType: "text" });
}
if (isAnySchema(schema)) {
return renderJsonTextarea(params);
}
// Fallback
return renderFieldRow({
label,
tags: [],
showLabel: true,
control: nothing,
error: t("configForm.unsupportedType", { type: String(type) }),
});
}