Files
openclaw/ui/src/components/config-form.node.ts
Peter Steinberger f81f9d8570 chore: enforce max-lines suppression ratchet (#107315)
* ci: enforce max-lines suppression ratchet

* chore: prune stale max-lines suppression

* fix: close max-lines ratchet enforcement gaps

* fix: harden max-lines ratchet checks

* fix(ci): satisfy max-lines ratchet checks

* style: format max-lines declarations

* fix(ci): match oxlint suppression grammar

* test: isolate max-lines git fixtures

* chore: prune resolved max-lines debt

* test: skip newline path fixture on Windows

* fix: harden max-lines suppression ratchet

* chore: refresh max-lines baseline

* fix: close max-lines ratchet bypasses

* fix: derive ratchet base from PR merge tree

* fix: support older Git in staged ratchet

* fix: align max-lines declarations and baseline

* chore: refresh max-lines baseline for current main

* fix: exclude generated wizard locales from max-lines

* chore: prune resolved max-lines debt
2026-07-14 09:27:02 -07:00

1164 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Control UI view renders config form screen content.
import { html, nothing, type TemplateResult } from "lit";
import type { ConfigUiHints } from "../api/types.ts";
import { icons } from "../components/icons.ts";
import "../components/tooltip.ts";
import { t } from "../i18n/index.ts";
import { formatUnknownText } from "../lib/format.ts";
import {
hasConfigSearchCriteria as hasSearchCriteria,
matchesNodeSearch,
matchesNodeSelf,
resolveConfigFieldMeta as resolveFieldMeta,
type ConfigSearchCriteria,
} from "./config-form.search.ts";
import {
defaultValue,
hasSensitiveConfigData,
hintForPath,
pathKey,
REDACTED_PLACEHOLDER,
schemaType,
type JsonSchema,
} from "./config-form.shared.ts";
import {
renderSettingsEmpty,
renderSettingsSegmented,
renderSettingsToggle,
renderSettingsToggleRow,
} from "./settings-ui.ts";
const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]);
function isAnySchema(schema: JsonSchema): boolean {
const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key));
return keys.length === 0;
}
function jsonValue(value: unknown): string {
if (value === undefined) {
return "";
}
try {
return JSON.stringify(value, null, 2) ?? "";
} catch {
return "";
}
}
function formatComparablePrimitive(value: unknown): string | null {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
return null;
}
function matchesComparablePrimitiveValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) {
return true;
}
const leftComparable = formatComparablePrimitive(left);
const rightComparable = formatComparablePrimitive(right);
return leftComparable !== null && leftComparable === rightComparable;
}
function isSecretRefObject(value: unknown): value is {
source: string;
id: string;
provider?: string;
} {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const candidate = value as Record<string, unknown>;
if (typeof candidate.source !== "string" || typeof candidate.id !== "string") {
return false;
}
return candidate.provider === undefined || typeof candidate.provider === "string";
}
type SensitiveRenderParams = {
path: Array<string | number>;
value: unknown;
hints: ConfigUiHints;
revealSensitive: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
};
type SensitiveRenderState = {
isSensitive: boolean;
isRedacted: boolean;
isRevealed: boolean;
canReveal: boolean;
};
function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState {
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
const isRevealed =
isSensitive &&
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
return {
isSensitive,
isRedacted: isSensitive && !isRevealed,
isRevealed,
canReveal: isSensitive,
};
}
function renderSensitiveToggleButton(params: {
path: Array<string | number>;
state: SensitiveRenderState;
disabled: boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
}): TemplateResult | typeof nothing {
const { state } = params;
if (!state.isSensitive || !params.onToggleSensitivePath) {
return nothing;
}
const label = state.canReveal
? state.isRevealed
? t("configForm.hideValue")
: t("configForm.revealValue")
: t("configForm.disableStreamToReveal");
return html`
<openclaw-tooltip .content=${label}>
<button
type="button"
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
style="width:28px;height:28px;padding:0;"
aria-label=${label}
aria-pressed=${state.isRevealed}
?disabled=${params.disabled || !state.canReveal}
@click=${() => params.onToggleSensitivePath?.(params.path)}
>
${state.isRevealed ? icons.eye : icons.eyeOff}
</button>
</openclaw-tooltip>
`;
}
function renderTags(tags: string[]): TemplateResult | typeof nothing {
if (tags.length === 0) {
return nothing;
}
return html`
<div class="cfg-tags">${tags.map((tag) => html`<span class="cfg-tag">${tag}</span>`)}</div>
`;
}
type FieldRowParams = {
label: unknown;
help?: unknown;
tags: string[];
showLabel: boolean;
control: TemplateResult | typeof nothing;
stacked?: boolean;
error?: unknown;
};
/** One settings-row: title/help/tags left, exactly one control cluster right. */
function renderFieldRow(params: FieldRowParams): TemplateResult {
const hasText =
params.showLabel || Boolean(params.help) || params.tags.length > 0 || Boolean(params.error);
// Control-only rows (array/map item values) stack so the control gets full width.
const stacked = params.stacked || !hasText;
const className = stacked ? "settings-row settings-row--stacked" : "settings-row";
return html`
<div class=${className}>
${hasText
? html`
<div class="settings-row__text">
${params.showLabel
? html`<span class="settings-row__title">${params.label}</span>`
: nothing}
${params.help
? html`<span class="settings-row__desc">${params.help}</span>`
: nothing}
${renderTags(params.tags)}
${params.error
? html`<span class="cfg-field__error">${params.error}</span>`
: nothing}
</div>
`
: nothing}
${params.control !== nothing
? html`<div class="settings-row__control">${params.control}</div>`
: nothing}
</div>
`;
}
function renderSegmentedControl(params: {
options: unknown[];
resolvedValue: unknown;
disabled: boolean;
ariaLabel: string;
onSelect: (value: unknown) => void;
}): TemplateResult {
const selectedIndex = params.options.findIndex((option) =>
matchesComparablePrimitiveValue(option, params.resolvedValue),
);
return renderSettingsSegmented({
value: selectedIndex < 0 ? "" : String(selectedIndex),
options: params.options.map((option, index) => ({
value: String(index),
label: formatUnknownText(option),
})),
disabled: params.disabled,
ariaLabel: params.ariaLabel,
onChange: (index) => {
const option = params.options[Number(index)];
if (option !== undefined) {
params.onSelect(option);
}
},
});
}
export function renderNode(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): 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;
}
// Handle anyOf/oneOf unions
if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.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 = (v: JsonSchema): unknown => {
if (v.const !== undefined) {
return v.const;
}
if (v.enum && v.enum.length === 1) {
return v.enum[0];
}
return undefined;
};
const literals = nonNull.map(extractLiteral);
const allLiterals = literals.every((v) => v !== undefined);
if (allLiterals && literals.length > 0 && literals.length <= 5) {
// Use segmented control for small sets
const resolvedValue = value ?? schema.default;
return renderFieldRow({
label,
help,
tags,
showLabel,
control: renderSegmentedControl({
options: literals,
resolvedValue,
disabled,
ariaLabel: label,
onSelect: (literal) => onPatch(path, literal),
}),
});
}
if (allLiterals && literals.length > 5) {
// Use dropdown for larger sets
return renderSelect({ ...params, options: literals, value: value ?? schema.default });
}
// Handle mixed primitive types
const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean));
const normalizedTypes = new Set(
[...primitiveTypes].map((v) => (v === "integer" ? "number" : v)),
);
if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v 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({
schema,
value,
path,
hints,
disabled,
showLabel,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
onToggleSensitivePath: params.onToggleSensitivePath,
onPatch,
});
}
// Enum - use segmented for small, dropdown for large
if (schema.enum) {
const options = schema.enum;
if (options.length <= 5) {
const resolvedValue = value ?? schema.default;
return renderFieldRow({
label,
help,
tags,
showLabel,
control: renderSegmentedControl({
options,
resolvedValue,
disabled,
ariaLabel: label,
onSelect: (option) => onPatch(path, option),
}),
});
}
return renderSelect({ ...params, options, value: value ?? schema.default });
}
// Object type - collapsible section
if (type === "object") {
return renderObject(params);
}
// Array type
if (type === "array") {
return renderArray(params);
}
// 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 ? html`${help ?? nothing}${renderTags(tags)}` : undefined;
return renderSettingsToggleRow({
title: label,
description,
checked: displayValue,
disabled,
onChange,
});
}
// Number/Integer
if (type === "number" || type === "integer") {
return renderNumberInput(params);
}
// String
if (type === "string") {
return renderTextInput({ ...params, inputType: "text" });
}
// Fallback
return renderFieldRow({
label,
tags: [],
showLabel: true,
control: nothing,
error: t("configForm.unsupportedType", { type: String(type) }),
});
}
function renderTextInput(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
inputType: "text" | "number";
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const isStructuredValue =
value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value);
const isStructuredSecretRef = isSecretRefObject(value);
const rawAvailable = params.rawAvailable ?? true;
const effectiveRedacted = sensitiveState.isRedacted || isStructuredSecretRef;
const placeholder = effectiveRedacted
? isStructuredSecretRef
? rawAvailable
? t("configForm.structuredSecretRaw")
: t("configForm.structuredSecretFile")
: REDACTED_PLACEHOLDER
: (hint?.placeholder ??
(schema.default !== undefined
? t("configForm.defaultValue", { value: formatUnknownText(schema.default) })
: ""));
const displayValue = effectiveRedacted
? ""
: isStructuredValue
? jsonValue(value)
: (value ?? "");
const effectiveInputType = sensitiveState.isSensitive && !effectiveRedacted ? "text" : inputType;
const control = html`
<input
type=${effectiveInputType}
class="settings-input${effectiveRedacted ? " cfg-redacted" : ""}"
placeholder=${placeholder}
.value=${formatUnknownText(displayValue)}
?disabled=${disabled}
?readonly=${effectiveRedacted}
@click=${() => {
if (sensitiveState.isRedacted && !isStructuredSecretRef && params.onToggleSensitivePath) {
params.onToggleSensitivePath(path);
}
}}
@input=${(e: Event) => {
if (effectiveRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
if (inputType === "number") {
if (raw.trim() === "") {
onPatch(path, undefined);
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
return;
}
onPatch(path, raw);
}}
@change=${(e: Event) => {
if (inputType === "number" || effectiveRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim());
}}
/>
${isStructuredSecretRef
? nothing
: renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${schema.default !== undefined
? html`
<openclaw-tooltip .content=${t("configForm.resetToDefault")}>
<button
type="button"
class="btn btn--icon"
style="width:28px;height:28px;padding:0;"
aria-label=${t("configForm.resetToDefault")}
?disabled=${disabled || effectiveRedacted}
@click=${() => onPatch(path, schema.default)}
>
</button>
</openclaw-tooltip>
`
: nothing}
`;
return renderFieldRow({ label, help, tags, showLabel, control });
}
function renderNumberInput(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const displayValue = value ?? schema.default ?? "";
// Touch devices and some browsers hide native number spinners; keep explicit
// one-step adjust buttons so single-step edits stay possible without typing.
const step = (delta: number) => {
if (disabled) {
return;
}
const current = Number(displayValue);
const base = Number.isFinite(current) ? current : 0;
onPatch(path, base + delta);
};
const control = html`
<button
type="button"
class="btn btn--sm btn--icon"
aria-label=${`${label}: -1`}
?disabled=${disabled}
@click=${() => step(-1)}
>
</button>
<input
type="number"
class="settings-input"
aria-label=${label}
.value=${formatUnknownText(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
<button
type="button"
class="btn btn--sm btn--icon"
aria-label=${`${label}: +1`}
?disabled=${disabled}
@click=${() => step(1)}
>
+
</button>
`;
return renderFieldRow({ label, help, tags, showLabel, control });
}
function renderSelect(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
options: unknown[];
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, options, onPatch } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
);
const unset = "__unset__";
const control = html`
<select
class="settings-select"
?disabled=${disabled}
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
onPatch(path, val === unset ? undefined : options[Number(val)]);
}}
>
<option value=${unset} ?selected=${currentIndex < 0}>${t("configForm.select")}</option>
${options.map(
(opt, idx) =>
html` <option value=${String(idx)} ?selected=${idx === currentIndex}>
${String(opt)}
</option>`,
)}
</select>
`;
return renderFieldRow({ label, help, tags, showLabel, control });
}
function renderJsonTextareaControl(params: {
path: Array<string | number>;
fallback: string;
rows: number;
sensitiveState: SensitiveRenderState;
disabled: boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { path, fallback, sensitiveState, disabled, onPatch } = params;
return html`
<textarea
class="settings-input${sensitiveState.isRedacted ? " cfg-redacted" : ""}"
placeholder=${sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : t("configForm.jsonValue")}
rows=${params.rows}
.value=${sensitiveState.isRedacted ? "" : fallback}
?disabled=${disabled}
?readonly=${sensitiveState.isRedacted}
@click=${() => {
if (sensitiveState.isRedacted && params.onToggleSensitivePath) {
params.onToggleSensitivePath(path);
}
}}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(path, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
`;
}
function renderJsonTextarea(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const fallback = jsonValue(value);
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
return renderFieldRow({
label,
help,
tags,
showLabel,
stacked: true,
control: renderJsonTextareaControl({
path,
fallback,
rows: 3,
sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
onPatch,
}),
});
}
function renderObject(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
rawAvailable,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
searchCriteria && hasSearchCriteria(searchCriteria)
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
: false;
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
const fallback = value ?? schema.default;
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
// Sort by hint order
const sorted = entries.toSorted((a, b) => {
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
if (orderA !== orderB) {
return orderA - orderB;
}
return a[0].localeCompare(b[0]);
});
const reserved = new Set(Object.keys(props));
const additional = schema.additionalProperties;
const allowExtra = Boolean(additional) && typeof additional === "object";
const fields = html`
${sorted.map(([propKey, node]) =>
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
rawAvailable,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
}),
)}
${allowExtra
? renderMapField({
schema: additional,
value: obj,
path,
hints,
rawAvailable,
unsupported,
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
: nothing}
`;
// Top-level objects and label-less contexts emit rows directly into the
// surrounding settings-group so row dividers stay sibling-driven.
if (path.length === 1 || !showLabel) {
return html`${fields}`;
}
// Nested objects get collapsible treatment as an indented sub-block.
return html`
<details class="cfg-object cfg-block" ?open=${path.length <= 2}>
<summary class="settings-row cfg-object__summary">
<div class="settings-row__text">
<span class="settings-row__title">${label}</span>
${help ? html`<span class="settings-row__desc">${help}</span>` : nothing}
${renderTags(tags)}
</div>
<span class="settings-row__chevron cfg-object__chevron">${icons.chevronDown}</span>
</summary>
<div class="settings-subrows">${fields}</div>
</details>
`;
}
function renderArray(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
rawAvailable,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
searchCriteria && hasSearchCriteria(searchCriteria)
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
: false;
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
return renderFieldRow({
label,
tags: [],
showLabel: true,
control: nothing,
error: t("configForm.unsupportedArray"),
});
}
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
return html`
<div class="cfg-block cfg-array">
<div class="settings-row">
<div class="settings-row__text">
${showLabel ? html`<span class="settings-row__title">${label}</span>` : nothing}
${help ? html`<span class="settings-row__desc">${help}</span>` : nothing}
${renderTags(tags)}
</div>
<div class="settings-row__control">
<span class="settings-row__value"
>${t(arr.length === 1 ? "configForm.itemCountOne" : "configForm.itemCount", {
count: String(arr.length),
})}</span
>
<button
type="button"
class="btn btn--sm"
?disabled=${disabled}
@click=${() => {
const next = [...arr, defaultValue(itemsSchema)];
onPatch(path, next);
}}
>
${t("configForm.add")}
</button>
</div>
</div>
${arr.length === 0
? renderSettingsEmpty(t("configForm.noItems"))
: html`
<div class="settings-subrows">
${arr.map(
(item, idx) => html`
<div class="settings-row">
<div class="settings-row__text">
<span class="settings-row__title">#${idx + 1}</span>
</div>
<div class="settings-row__control">
<openclaw-tooltip .content=${t("configForm.removeItem")}>
<button
type="button"
class="btn btn--icon"
style="width:28px;height:28px;padding:0;"
aria-label=${t("configForm.removeItem")}
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
</openclaw-tooltip>
</div>
</div>
${renderNode({
schema: itemsSchema,
value: item,
path: [...path, idx],
hints,
rawAvailable,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
`,
)}
</div>
`}
</div>
`;
}
function renderMapField(params: {
schema: JsonSchema;
value: Record<string, unknown>;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
reservedKeys: Set<string>;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
schema,
value,
path,
hints,
rawAvailable,
unsupported,
disabled,
reservedKeys,
onPatch,
searchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
const visibleEntries =
searchCriteria && hasSearchCriteria(searchCriteria)
? entries.filter(([key, entryValue]) =>
matchesNodeSearch({
schema,
value: entryValue,
path: [...path, key],
hints,
criteria: searchCriteria,
}),
)
: entries;
return html`
<div class="cfg-block cfg-map">
<div class="settings-row">
<div class="settings-row__text">
<span class="settings-row__title">${t("configForm.customEntries")}</span>
</div>
<div class="settings-row__control">
<button
type="button"
class="btn btn--sm"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
let index = 1;
let key = `custom-${index}`;
while (key in next) {
index += 1;
key = `custom-${index}`;
}
next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next);
}}
>
${t("configForm.addEntry")}
</button>
</div>
</div>
${visibleEntries.length === 0
? renderSettingsEmpty(t("configForm.noCustomEntries"))
: html`
<div class="settings-subrows">
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
const sensitiveState = getSensitiveRenderState({
path: valuePath,
value: entryValue,
hints,
revealSensitive: revealSensitive ?? false,
isSensitivePathRevealed,
});
return html`
<div class="settings-row">
<div class="settings-row__text">
<input
type="text"
class="settings-input"
placeholder=${t("configForm.key")}
aria-label=${t("configForm.key")}
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {
return;
}
const next = { ...value };
if (nextKey in next) {
return;
}
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
</div>
<div class="settings-row__control">
<openclaw-tooltip .content=${t("configForm.removeEntry")}>
<button
type="button"
class="btn btn--icon"
style="width:28px;height:28px;padding:0;"
aria-label=${t("configForm.removeEntry")}
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</openclaw-tooltip>
</div>
</div>
${anySchema
? renderFieldRow({
label: key,
tags: [],
showLabel: false,
stacked: true,
control: renderJsonTextareaControl({
path: valuePath,
fallback,
rows: 2,
sensitiveState,
disabled,
onToggleSensitivePath,
onPatch,
}),
})
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
rawAvailable,
unsupported,
disabled,
searchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
`;
})}
</div>
`}
</div>
`;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */