mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 09:01:40 +00:00
fix(ui): preserve schema-backed settings edits (#116282)
* fix(ui): harden schema-backed settings controls Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com> * test(ui): match config secret label in Chromium * chore(ui): remove release-owned changelog entry * test(agents): mock incremental registry persistence --------- Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>
This commit is contained in:
81
ui/src/components/config-form-array-candidates.ts
Normal file
81
ui/src/components/config-form-array-candidates.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
arrayConstraintCandidates,
|
||||
configValuesEqual,
|
||||
defaultValue,
|
||||
isSupportedConfigValueValid,
|
||||
MAX_AUTO_ARRAY_DEFAULT_ITEMS,
|
||||
NO_SAFE_DEFAULT,
|
||||
} from "./config-form.constraints.ts";
|
||||
import type { JsonSchema } from "./config-form.shared.ts";
|
||||
|
||||
type ArrayAddCandidates = {
|
||||
atomicCandidate: unknown[] | undefined;
|
||||
autoCandidate: unknown[] | undefined;
|
||||
};
|
||||
|
||||
function extendsArrayValue(value: readonly unknown[], candidate: readonly unknown[]): boolean {
|
||||
return (
|
||||
candidate.length > value.length &&
|
||||
value.every((entry, index) => configValuesEqual(entry, candidate[index]))
|
||||
);
|
||||
}
|
||||
|
||||
export function arrayAddCandidates(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown[];
|
||||
minimumItems: number;
|
||||
maximumItems: number | undefined;
|
||||
uniqueItems: boolean;
|
||||
isUnset: boolean;
|
||||
isRequired: boolean;
|
||||
itemSchemaAt: (index: number) => JsonSchema;
|
||||
}): ArrayAddCandidates {
|
||||
const {
|
||||
schema,
|
||||
value,
|
||||
minimumItems,
|
||||
maximumItems,
|
||||
uniqueItems,
|
||||
isUnset,
|
||||
isRequired,
|
||||
itemSchemaAt,
|
||||
} = params;
|
||||
const requiredAppendCount = Math.max(1, minimumItems - value.length);
|
||||
const autoAppendCount =
|
||||
requiredAppendCount > MAX_AUTO_ARRAY_DEFAULT_ITEMS ? 1 : requiredAppendCount;
|
||||
const generatedItems: unknown[] = [];
|
||||
for (let offset = 0; offset < autoAppendCount; offset += 1) {
|
||||
const generatedDefault = defaultValue(itemSchemaAt(value.length + offset));
|
||||
if (generatedDefault === NO_SAFE_DEFAULT) {
|
||||
generatedItems.length = 0;
|
||||
break;
|
||||
}
|
||||
generatedItems.push(generatedDefault);
|
||||
}
|
||||
const generatedCandidate =
|
||||
generatedItems.length === autoAppendCount ? [...value, ...generatedItems] : undefined;
|
||||
const autoCandidate =
|
||||
generatedCandidate !== undefined &&
|
||||
!uniqueItems &&
|
||||
(maximumItems === undefined || generatedCandidate.length <= maximumItems) &&
|
||||
(generatedCandidate.length < minimumItems ||
|
||||
isSupportedConfigValueValid(schema, generatedCandidate))
|
||||
? generatedCandidate
|
||||
: undefined;
|
||||
|
||||
const currentValueValid = isSupportedConfigValueValid(schema, value);
|
||||
const constrainedCandidate = arrayConstraintCandidates(schema).find(
|
||||
(candidate) =>
|
||||
isSupportedConfigValueValid(schema, candidate) &&
|
||||
(isUnset || !currentValueValid || extendsArrayValue(value, candidate)),
|
||||
);
|
||||
const wholeArrayDefault =
|
||||
constrainedCandidate ??
|
||||
(isUnset && isRequired && maximumItems === 0 && isSupportedConfigValueValid(schema, [])
|
||||
? []
|
||||
: undefined);
|
||||
const atomicCandidate = Array.isArray(wholeArrayDefault)
|
||||
? structuredClone(wholeArrayDefault)
|
||||
: undefined;
|
||||
return { atomicCandidate, autoCandidate };
|
||||
}
|
||||
32
ui/src/components/config-form-array-identity.test.ts
Normal file
32
ui/src/components/config-form-array-identity.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendArrayRowIdentities,
|
||||
discardArrayRowIdentities,
|
||||
preserveArrayRowIdentities,
|
||||
rowIdentitiesForArray,
|
||||
} from "./config-form-array-identity.ts";
|
||||
|
||||
describe("config form array row identity", () => {
|
||||
it("keeps appended primitive identities unique after an equal row is removed", () => {
|
||||
const initial = ["same", "same"];
|
||||
const initialIdentities = rowIdentitiesForArray(initial);
|
||||
const afterRemoval = ["same"];
|
||||
preserveArrayRowIdentities(afterRemoval, initialIdentities.slice(1));
|
||||
|
||||
const afterAppend = ["same", "same"];
|
||||
appendArrayRowIdentities(afterAppend, rowIdentitiesForArray(afterRemoval), 1);
|
||||
const appendedIdentities = rowIdentitiesForArray(afterAppend);
|
||||
|
||||
expect(new Set(appendedIdentities).size).toBe(appendedIdentities.length);
|
||||
expect(appendedIdentities[0]).toBe(initialIdentities[1]);
|
||||
});
|
||||
|
||||
it("removes row metadata from rejected candidate arrays", () => {
|
||||
const candidate = ["same"];
|
||||
const rejectedIdentity = Symbol("rejected-row");
|
||||
preserveArrayRowIdentities(candidate, [rejectedIdentity]);
|
||||
discardArrayRowIdentities(candidate);
|
||||
|
||||
expect(rowIdentitiesForArray(candidate)[0]).not.toBe(rejectedIdentity);
|
||||
});
|
||||
});
|
||||
53
ui/src/components/config-form-array-identity.ts
Normal file
53
ui/src/components/config-form-array-identity.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Control UI helpers preserve repeated-row draft ownership across array edits.
|
||||
const arrayRowIdentities = new WeakMap<unknown[], readonly unknown[]>();
|
||||
|
||||
function primitiveRowIdentity(value: unknown, occurrence: number): string {
|
||||
const normalized =
|
||||
typeof value === "number" && Object.is(value, -0)
|
||||
? "-0"
|
||||
: typeof value === "number" && Number.isNaN(value)
|
||||
? "NaN"
|
||||
: String(value);
|
||||
return `${typeof value}:${normalized}:${occurrence}`;
|
||||
}
|
||||
|
||||
export function rowIdentitiesForArray(value: unknown[]): readonly unknown[] {
|
||||
const existing = arrayRowIdentities.get(value);
|
||||
if (existing?.length === value.length) {
|
||||
return existing;
|
||||
}
|
||||
const occurrences = new Map<string, number>();
|
||||
const created = value.map((entry) => {
|
||||
if (entry && typeof entry === "object") {
|
||||
return entry;
|
||||
}
|
||||
const base = primitiveRowIdentity(entry, 0);
|
||||
const occurrence = occurrences.get(base) ?? 0;
|
||||
occurrences.set(base, occurrence + 1);
|
||||
return primitiveRowIdentity(entry, occurrence);
|
||||
});
|
||||
arrayRowIdentities.set(value, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function preserveArrayRowIdentities(
|
||||
nextValue: unknown[],
|
||||
identities: readonly unknown[],
|
||||
): void {
|
||||
arrayRowIdentities.set(nextValue, identities);
|
||||
}
|
||||
|
||||
export function discardArrayRowIdentities(value: unknown[]): void {
|
||||
arrayRowIdentities.delete(value);
|
||||
}
|
||||
|
||||
export function appendArrayRowIdentities(
|
||||
nextValue: unknown[],
|
||||
identities: readonly unknown[],
|
||||
count: number,
|
||||
): void {
|
||||
// Appended rows need fresh tokens even when their values equal preserved rows.
|
||||
// Re-deriving occurrence labels can duplicate a survivor's token after removal.
|
||||
const appended = Array.from({ length: count }, () => Symbol("array-row"));
|
||||
preserveArrayRowIdentities(nextValue, [...identities, ...appended]);
|
||||
}
|
||||
944
ui/src/components/config-form-array-integrity.browser.test.ts
Normal file
944
ui/src/components/config-form-array-integrity.browser.test.ts
Normal file
@@ -0,0 +1,944 @@
|
||||
// Control UI tests cover array draft recovery and repeated-item constraints.
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConfigFormCollectionDraft } from "./config-form-collection-draft.ts";
|
||||
import { renderArray } from "./config-form.node.collection.ts";
|
||||
import type { JsonSchema } from "./config-form.shared.ts";
|
||||
import { renderNode } from "./config-form.ts";
|
||||
|
||||
function expectElement<T extends Element>(element: T | null | undefined, label: string): T {
|
||||
expect(element instanceof Element, label).toBe(true);
|
||||
if (!(element instanceof Element)) {
|
||||
throw new Error(`missing ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("config form array integrity", () => {
|
||||
it("applies safe whole-array composition candidates atomically", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
allOf: [{ const: ["a", "b"] }],
|
||||
},
|
||||
value: undefined,
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"whole-array composition add",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]);
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
enum: [[], ["a", "b"]],
|
||||
},
|
||||
value: [],
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"valid constrained array extension",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]);
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string", default: "item" },
|
||||
default: ["a", "b"],
|
||||
},
|
||||
value: [],
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"explicit empty array add",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], ["item"]);
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
const: ["a", "b"],
|
||||
},
|
||||
value: [],
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"explicit invalid constrained array add",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]);
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
const: [],
|
||||
maxItems: 0,
|
||||
},
|
||||
value: undefined,
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
const emptyConstAdd = expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"empty const array add",
|
||||
);
|
||||
expect(emptyConstAdd.disabled).toBe(false);
|
||||
emptyConstAdd.click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], []);
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
maxItems: 0,
|
||||
},
|
||||
value: undefined,
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
const requiredEmptyAdd = expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"required empty-only array add",
|
||||
);
|
||||
expect(requiredEmptyAdd.disabled).toBe(false);
|
||||
requiredEmptyAdd.click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], []);
|
||||
});
|
||||
|
||||
it("keeps large and unique array minimums incrementally editable", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const renderSchema = (schema: JsonSchema) => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema,
|
||||
value: undefined,
|
||||
path: ["codes"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderSchema({
|
||||
type: "array",
|
||||
minItems: 101,
|
||||
items: { type: "string" },
|
||||
});
|
||||
const largeMinimumAdd = expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"large minimum array add",
|
||||
);
|
||||
expect(largeMinimumAdd.disabled).toBe(false);
|
||||
largeMinimumAdd.click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["codes"], [""]);
|
||||
|
||||
onPatch.mockClear();
|
||||
renderSchema({
|
||||
type: "array",
|
||||
minItems: 2,
|
||||
uniqueItems: true,
|
||||
items: { type: "string" },
|
||||
});
|
||||
const draftHost = expectElement(
|
||||
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"unique array draft",
|
||||
);
|
||||
await draftHost.updateComplete;
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"unique array add",
|
||||
).click();
|
||||
await draftHost.updateComplete;
|
||||
const draftValue = expectElement(
|
||||
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"unique array draft value",
|
||||
);
|
||||
draftValue.value = "first";
|
||||
draftValue.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draftHost.updateComplete;
|
||||
expectElement(
|
||||
Array.from(draftHost.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"unique array draft commit",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["codes"], ["first"]);
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
minItems: 2,
|
||||
uniqueItems: true,
|
||||
items: { type: "string" },
|
||||
},
|
||||
value: ["first"],
|
||||
path: ["codes"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
const duplicateDraft = expectElement(
|
||||
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"duplicate array draft",
|
||||
);
|
||||
await duplicateDraft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"duplicate array add",
|
||||
).click();
|
||||
await duplicateDraft.updateComplete;
|
||||
const duplicateValue = expectElement(
|
||||
duplicateDraft.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"duplicate array draft value",
|
||||
);
|
||||
duplicateValue.value = "first";
|
||||
duplicateValue.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await duplicateDraft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(duplicateDraft.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"duplicate array draft commit",
|
||||
).click();
|
||||
await duplicateDraft.updateComplete;
|
||||
expect(duplicateValue.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
minItems: 2,
|
||||
items: { type: "string" },
|
||||
allOf: [{ items: { type: "string", pattern: "^[0-9]+$" } }],
|
||||
},
|
||||
value: undefined,
|
||||
path: ["codes"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
const composedDraft = expectElement(
|
||||
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"composed item draft",
|
||||
);
|
||||
await composedDraft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"composed item add",
|
||||
).click();
|
||||
await composedDraft.updateComplete;
|
||||
const composedValue = expectElement(
|
||||
composedDraft.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"composed item draft value",
|
||||
);
|
||||
composedValue.value = "abc";
|
||||
composedValue.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await composedDraft.updateComplete;
|
||||
const composedCommit = expectElement(
|
||||
Array.from(composedDraft.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"composed item draft commit",
|
||||
);
|
||||
composedCommit.click();
|
||||
await composedDraft.updateComplete;
|
||||
expect(composedValue.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
|
||||
composedValue.value = "123";
|
||||
composedValue.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await composedDraft.updateComplete;
|
||||
composedCommit.click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["codes"], ["123"]);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("rejects existing array edits that violate uniqueItems", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: { type: "string" },
|
||||
},
|
||||
value: ["alpha", "beta"],
|
||||
path: ["codes"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
const inputs = Array.from(container.querySelectorAll<HTMLInputElement>(".cfg-array input"));
|
||||
expect(inputs).toHaveLength(2);
|
||||
const second = expectElement(inputs[1], "second unique array item");
|
||||
|
||||
second.value = "alpha";
|
||||
second.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
expect(second.value).toBe("beta");
|
||||
expect(second.getAttribute("aria-invalid")).toBe("false");
|
||||
|
||||
second.value = "gamma";
|
||||
second.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["codes"], ["alpha", "gamma"]);
|
||||
});
|
||||
|
||||
it("restores boolean rows when uniqueItems rejects a toggle", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: { type: "boolean" },
|
||||
},
|
||||
value: [false, true],
|
||||
path: ["flags"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
const switches = Array.from(
|
||||
container.querySelectorAll<HTMLElement & { checked: boolean }>("wa-switch.settings-toggle"),
|
||||
);
|
||||
expect(switches).toHaveLength(2);
|
||||
const second = expectElement(switches[1], "second unique boolean item");
|
||||
|
||||
second.checked = false;
|
||||
second.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
expect(second.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("allows invalid arrays to be repaired one item at a time", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "array",
|
||||
minItems: 4,
|
||||
maxItems: 4,
|
||||
uniqueItems: true,
|
||||
items: { type: "string" },
|
||||
};
|
||||
const renderValue = (value: string[]) => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema,
|
||||
value,
|
||||
path: ["codes"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue(["alpha", "alpha", "beta", "beta"]);
|
||||
const firstRepair = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>(".cfg-array input"),
|
||||
)[1];
|
||||
expect(firstRepair).toBeDefined();
|
||||
if (!firstRepair) {
|
||||
return;
|
||||
}
|
||||
firstRepair.value = "gamma";
|
||||
firstRepair.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["alpha", "gamma", "beta", "beta"]);
|
||||
|
||||
onPatch.mockClear();
|
||||
renderValue(["alpha", "gamma", "beta", "beta"]);
|
||||
const secondRepair = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>(".cfg-array input"),
|
||||
)[3];
|
||||
expect(secondRepair).toBeDefined();
|
||||
if (!secondRepair) {
|
||||
return;
|
||||
}
|
||||
secondRepair.value = "delta";
|
||||
secondRepair.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["alpha", "gamma", "beta", "delta"]);
|
||||
});
|
||||
|
||||
it("validates the resulting tuple before removing an item", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: [{ type: "string" }, { type: "number" }],
|
||||
additionalItems: false,
|
||||
},
|
||||
value: ["identifier", 3],
|
||||
path: ["tuple"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const removeButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button[aria-label='Remove item']"),
|
||||
);
|
||||
expect(removeButtons).toHaveLength(2);
|
||||
expect(removeButtons[0]?.disabled).toBe(true);
|
||||
expect(removeButtons[1]?.disabled).toBe(false);
|
||||
const addButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
);
|
||||
expect(addButton?.disabled).toBe(true);
|
||||
removeButtons[1]?.click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["tuple"], ["identifier"]);
|
||||
});
|
||||
|
||||
it("allows over-limit arrays to be repaired by repeated removal", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "array",
|
||||
maxItems: 2,
|
||||
items: { type: "string" },
|
||||
};
|
||||
const renderValue = (value: string[]) => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema,
|
||||
value,
|
||||
path: ["codes"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue(["one", "two", "three", "four"]);
|
||||
let removeButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button[aria-label='Remove item']"),
|
||||
);
|
||||
expect(removeButtons.every((button) => !button.disabled)).toBe(true);
|
||||
removeButtons[3]?.click();
|
||||
expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["one", "two", "three"]);
|
||||
|
||||
onPatch.mockClear();
|
||||
renderValue(["one", "two", "three"]);
|
||||
removeButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button[aria-label='Remove item']"),
|
||||
);
|
||||
expect(removeButtons.every((button) => !button.disabled)).toBe(true);
|
||||
removeButtons[2]?.click();
|
||||
expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["one", "two"]);
|
||||
});
|
||||
|
||||
it("restores JSON rows when a collection constraint rejects the edit", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
anyOf: [{ type: "object" }, { type: "array" }],
|
||||
},
|
||||
},
|
||||
value: [{ id: "first" }, { id: "second" }],
|
||||
path: ["entries"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const textareas = Array.from(container.querySelectorAll<HTMLTextAreaElement>("textarea"));
|
||||
expect(textareas).toHaveLength(2);
|
||||
const second = expectElement(textareas[1], "second JSON array item");
|
||||
second.value = '{"id":"first"}';
|
||||
second.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
second.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
expect(second.value).toContain('"second"');
|
||||
expect(second.getAttribute("aria-invalid")).toBe("false");
|
||||
});
|
||||
|
||||
it("preserves an unaffected JSON row draft when another row changes", () => {
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [{ type: "object" }, { type: "array" }],
|
||||
},
|
||||
};
|
||||
let currentValue: unknown[] = [{ id: "first" }, { id: "second" }];
|
||||
const renderValue = () => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema,
|
||||
value: currentValue,
|
||||
path: ["entries"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch: (_path, nextValue) => {
|
||||
currentValue = nextValue as unknown[];
|
||||
renderValue();
|
||||
},
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue();
|
||||
let textareas = Array.from(container.querySelectorAll<HTMLTextAreaElement>("textarea"));
|
||||
const first = expectElement(textareas[0], "first JSON array item");
|
||||
const second = expectElement(textareas[1], "second JSON array item");
|
||||
first.value = '{"id":';
|
||||
first.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(first.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
second.value = '{"id":"changed"}';
|
||||
second.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
second.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
textareas = Array.from(container.querySelectorAll<HTMLTextAreaElement>("textarea"));
|
||||
expect(textareas[0]?.value).toBe('{"id":');
|
||||
expect(textareas[0]?.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(currentValue).toEqual([{ id: "first" }, { id: "changed" }]);
|
||||
});
|
||||
|
||||
it("resets scalar and JSON drafts when the first equal primitive row is removed", () => {
|
||||
const scalarContainer = document.createElement("div");
|
||||
let scalarValue: unknown[] = ["same", "same"];
|
||||
const renderScalarValue = () => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 2 },
|
||||
},
|
||||
value: scalarValue,
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch: (_path, nextValue) => {
|
||||
scalarValue = nextValue as unknown[];
|
||||
renderScalarValue();
|
||||
},
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
scalarContainer,
|
||||
);
|
||||
};
|
||||
renderScalarValue();
|
||||
const scalarInput = expectElement(
|
||||
scalarContainer.querySelector<HTMLInputElement>("input"),
|
||||
"first equal scalar row",
|
||||
);
|
||||
scalarInput.value = "x";
|
||||
scalarInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(scalarInput.getAttribute("aria-invalid")).toBe("true");
|
||||
expectElement(
|
||||
scalarContainer.querySelector<HTMLButtonElement>("button[aria-label='Remove item']"),
|
||||
"first equal scalar row remove",
|
||||
).click();
|
||||
const remainingScalar = expectElement(
|
||||
scalarContainer.querySelector<HTMLInputElement>("input"),
|
||||
"remaining equal scalar row",
|
||||
);
|
||||
expect(remainingScalar).toBe(scalarInput);
|
||||
expect(remainingScalar.value).toBe("same");
|
||||
expect(remainingScalar.getAttribute("aria-invalid")).toBe("false");
|
||||
|
||||
const jsonContainer = document.createElement("div");
|
||||
let jsonValue: unknown[] = [true, true];
|
||||
const renderJsonValue = () => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: { type: "array", items: {} },
|
||||
value: jsonValue,
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch: (_path, nextValue) => {
|
||||
jsonValue = nextValue as unknown[];
|
||||
renderJsonValue();
|
||||
},
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
jsonContainer,
|
||||
);
|
||||
};
|
||||
renderJsonValue();
|
||||
const jsonDraft = expectElement(
|
||||
jsonContainer.querySelector<HTMLTextAreaElement>("textarea"),
|
||||
"first equal JSON row",
|
||||
);
|
||||
jsonDraft.value = "{";
|
||||
jsonDraft.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(jsonDraft.getAttribute("aria-invalid")).toBe("true");
|
||||
expectElement(
|
||||
jsonContainer.querySelector<HTMLButtonElement>("button[aria-label='Remove item']"),
|
||||
"first equal JSON row remove",
|
||||
).click();
|
||||
const remainingJson = expectElement(
|
||||
jsonContainer.querySelector<HTMLTextAreaElement>("textarea"),
|
||||
"remaining equal JSON row",
|
||||
);
|
||||
expect(remainingJson).toBe(jsonDraft);
|
||||
expect(remainingJson.value).toBe("true");
|
||||
expect(remainingJson.getAttribute("aria-invalid")).toBe("false");
|
||||
});
|
||||
|
||||
it("adds null through nullable scalar collection drafts", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: { type: ["string", "null"] },
|
||||
},
|
||||
value: [""],
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
const draftHost = expectElement(
|
||||
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"nullable scalar collection draft",
|
||||
);
|
||||
expectElement(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"nullable scalar array add",
|
||||
).click();
|
||||
await draftHost.updateComplete;
|
||||
const nullToggle = expectElement(
|
||||
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-null]"),
|
||||
"nullable scalar null toggle",
|
||||
);
|
||||
nullToggle.checked = true;
|
||||
nullToggle.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await draftHost.updateComplete;
|
||||
expect(
|
||||
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-value]")?.disabled,
|
||||
).toBe(true);
|
||||
expectElement(
|
||||
Array.from(draftHost.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"nullable scalar null commit",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], ["", null]);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("propagates rejected edits through nested constrained arrays", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
value: [["alpha"], ["beta"]],
|
||||
path: ["groups"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const inputs = Array.from(container.querySelectorAll<HTMLInputElement>("input"));
|
||||
expect(inputs).toHaveLength(2);
|
||||
const second = expectElement(inputs[1], "second nested array item");
|
||||
second.value = "alpha";
|
||||
second.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
expect(second.value).toBe("beta");
|
||||
expect(second.getAttribute("aria-invalid")).toBe("false");
|
||||
});
|
||||
|
||||
it("resets a reused object row after array removal", () => {
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 2 },
|
||||
},
|
||||
},
|
||||
};
|
||||
let currentValue: unknown[] = [{ name: "same" }, { name: "same" }];
|
||||
const renderValue = () => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema,
|
||||
value: currentValue,
|
||||
path: ["entries"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch: (_path, nextValue) => {
|
||||
currentValue = nextValue as unknown[];
|
||||
renderValue();
|
||||
},
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue();
|
||||
const firstInput = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input"),
|
||||
"first object row input",
|
||||
);
|
||||
firstInput.value = "x";
|
||||
firstInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(firstInput.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
const firstRemove = expectElement(
|
||||
container.querySelector<HTMLButtonElement>("button[aria-label='Remove item']"),
|
||||
"first object row remove",
|
||||
);
|
||||
firstRemove.click();
|
||||
|
||||
const shiftedInput = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input"),
|
||||
"shifted object row input",
|
||||
);
|
||||
expect(shiftedInput.value).toBe("same");
|
||||
expect(shiftedInput.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(currentValue).toEqual([{ name: "same" }]);
|
||||
});
|
||||
|
||||
it("renders open tuple tail items as unconstrained JSON values", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: [{ type: "string" }],
|
||||
},
|
||||
value: ["head", { enabled: true }],
|
||||
path: ["tuple"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const tail = expectElement(
|
||||
container.querySelector<HTMLTextAreaElement>("textarea"),
|
||||
"open tuple tail JSON value",
|
||||
);
|
||||
tail.value = '{"enabled":false}';
|
||||
tail.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
tail.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["tuple"], ["head", { enabled: false }]);
|
||||
|
||||
const addButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
);
|
||||
expect(addButton?.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
268
ui/src/components/config-form-collection-draft.ts
Normal file
268
ui/src/components/config-form-collection-draft.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { html, nothing, type PropertyValues } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
import { configValuesEqual, isSupportedConfigValueValid } from "./config-form.constraints.ts";
|
||||
import { schemaType, type JsonSchema } from "./config-form.shared.ts";
|
||||
|
||||
export type ConfigFormCollectionDraftProps = {
|
||||
schema: JsonSchema;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
identity: string;
|
||||
sourceIdentity: unknown;
|
||||
existingKeys?: readonly string[];
|
||||
existingValues?: readonly unknown[];
|
||||
validateValue?: (value: unknown) => boolean;
|
||||
};
|
||||
|
||||
export type ConfigFormCollectionDraftCommit = {
|
||||
key?: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export class ConfigFormCollectionDraft extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) props?: ConfigFormCollectionDraftProps;
|
||||
|
||||
@state() private draftOpen = false;
|
||||
@state() private draftKey = "";
|
||||
@state() private draftValue = "";
|
||||
@state() private draftIsNull = false;
|
||||
@state() private error = "";
|
||||
@state() private invalidTarget: "key" | "value" | null = null;
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
const previous = changedProperties.get("props") as ConfigFormCollectionDraftProps | undefined;
|
||||
const next = this.props;
|
||||
if (
|
||||
previous &&
|
||||
(!next ||
|
||||
previous.identity !== next.identity ||
|
||||
!Object.is(previous.sourceIdentity, next.sourceIdentity))
|
||||
) {
|
||||
this.closeDraft();
|
||||
}
|
||||
}
|
||||
|
||||
openDraft(): void {
|
||||
if (this.props?.disabled) {
|
||||
return;
|
||||
}
|
||||
this.draftOpen = true;
|
||||
void this.updateComplete.then(() => {
|
||||
this.querySelector<HTMLElement>("[data-collection-draft-value]")?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.error = "";
|
||||
this.invalidTarget = null;
|
||||
}
|
||||
|
||||
private closeDraft(): void {
|
||||
this.draftOpen = false;
|
||||
this.draftKey = "";
|
||||
this.draftValue = "";
|
||||
this.draftIsNull = false;
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
private fail(target: "key" | "value", message: string): void {
|
||||
this.invalidTarget = target;
|
||||
this.error = message;
|
||||
void this.updateComplete.then(() => {
|
||||
this.querySelector<HTMLElement>(
|
||||
target === "key" ? "[data-collection-draft-key]" : "[data-collection-draft-value]",
|
||||
)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private parseValue(
|
||||
schema: JsonSchema,
|
||||
): { ok: true; value: unknown } | { ok: false; message: string } {
|
||||
if (this.draftIsNull) {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
const valueType = schemaType(schema);
|
||||
if (valueType === "string") {
|
||||
return { ok: true, value: this.draftValue };
|
||||
}
|
||||
if (valueType === "number" || valueType === "integer") {
|
||||
const value = Number(this.draftValue);
|
||||
return this.draftValue.trim() && Number.isFinite(value)
|
||||
? { ok: true, value }
|
||||
: { ok: false, message: t("configForm.invalidNumber") };
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(this.draftValue) };
|
||||
} catch {
|
||||
return { ok: false, message: t("configForm.invalidJson") };
|
||||
}
|
||||
}
|
||||
|
||||
private commit(): void {
|
||||
const props = this.props;
|
||||
if (!props || props.disabled) {
|
||||
return;
|
||||
}
|
||||
const parsed = this.parseValue(props.schema);
|
||||
if (!parsed.ok) {
|
||||
this.fail("value", parsed.message);
|
||||
return;
|
||||
}
|
||||
if (!isSupportedConfigValueValid(props.schema, parsed.value)) {
|
||||
this.fail(
|
||||
"value",
|
||||
["number", "integer"].includes(schemaType(props.schema) ?? "")
|
||||
? t("configForm.invalidNumber")
|
||||
: t("configForm.invalidString"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (props.existingValues?.some((value) => configValuesEqual(value, parsed.value))) {
|
||||
this.fail("value", t("configForm.invalidString"));
|
||||
return;
|
||||
}
|
||||
if (props.validateValue && !props.validateValue(parsed.value)) {
|
||||
this.fail("value", t("configForm.invalidString"));
|
||||
return;
|
||||
}
|
||||
const key = this.draftKey.trim();
|
||||
if (props.existingKeys && (!key || props.existingKeys.includes(key))) {
|
||||
this.fail("key", t("configForm.invalidString"));
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = this.dispatchEvent(
|
||||
new CustomEvent<ConfigFormCollectionDraftCommit>("config-collection-draft-commit", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
detail: {
|
||||
...(props.existingKeys ? { key } : {}),
|
||||
value: parsed.value,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (accepted) {
|
||||
this.closeDraft();
|
||||
} else {
|
||||
this.fail("value", t("configForm.invalidString"));
|
||||
}
|
||||
}
|
||||
|
||||
protected override updated(): void {
|
||||
const keyInput = this.querySelector<HTMLInputElement>("[data-collection-draft-key]");
|
||||
const valueInput = this.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||
"[data-collection-draft-value]",
|
||||
);
|
||||
keyInput?.setCustomValidity(this.invalidTarget === "key" ? this.error : "");
|
||||
valueInput?.setCustomValidity(this.invalidTarget === "value" ? this.error : "");
|
||||
}
|
||||
|
||||
override render() {
|
||||
const props = this.props;
|
||||
if (!props || !this.draftOpen || props.disabled) {
|
||||
return nothing;
|
||||
}
|
||||
const valueType = schemaType(props.schema);
|
||||
const canUseNull = isSupportedConfigValueValid(props.schema, null);
|
||||
const usesTextInput =
|
||||
valueType === "string" || valueType === "number" || valueType === "integer";
|
||||
const errorId = `${this.id}-error`;
|
||||
const valueLabel = `${t("configForm.add")}: ${props.label}`;
|
||||
const valueControl = usesTextInput
|
||||
? html`
|
||||
<input
|
||||
data-collection-draft-value
|
||||
type=${valueType === "string" ? "text" : "number"}
|
||||
class="settings-input"
|
||||
aria-label=${valueLabel}
|
||||
aria-describedby=${errorId}
|
||||
aria-invalid=${this.invalidTarget === "value" ? "true" : "false"}
|
||||
.value=${this.draftValue}
|
||||
?disabled=${this.draftIsNull}
|
||||
@input=${(event: Event) => {
|
||||
this.draftValue = (event.currentTarget as HTMLInputElement).value;
|
||||
this.clearError();
|
||||
}}
|
||||
/>
|
||||
`
|
||||
: html`
|
||||
<textarea
|
||||
data-collection-draft-value
|
||||
class="settings-input"
|
||||
aria-label=${valueLabel}
|
||||
aria-describedby=${errorId}
|
||||
aria-invalid=${this.invalidTarget === "value" ? "true" : "false"}
|
||||
placeholder=${t("configForm.jsonValue")}
|
||||
rows="2"
|
||||
.value=${this.draftValue}
|
||||
?disabled=${this.draftIsNull}
|
||||
@input=${(event: Event) => {
|
||||
this.draftValue = (event.currentTarget as HTMLTextAreaElement).value;
|
||||
this.clearError();
|
||||
}}
|
||||
></textarea>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="settings-row settings-row--stacked cfg-collection-draft">
|
||||
<div class="settings-row__control">
|
||||
<div class="cfg-collection-draft__controls">
|
||||
${props.existingKeys
|
||||
? html`
|
||||
<input
|
||||
data-collection-draft-key
|
||||
type="text"
|
||||
class="settings-input"
|
||||
aria-label=${t("configForm.key")}
|
||||
aria-describedby=${errorId}
|
||||
aria-invalid=${this.invalidTarget === "key" ? "true" : "false"}
|
||||
placeholder=${t("configForm.key")}
|
||||
.value=${this.draftKey}
|
||||
@input=${(event: Event) => {
|
||||
this.draftKey = (event.currentTarget as HTMLInputElement).value;
|
||||
this.clearError();
|
||||
}}
|
||||
/>
|
||||
`
|
||||
: nothing}
|
||||
${canUseNull
|
||||
? html`
|
||||
<label class="field checkbox">
|
||||
<input
|
||||
data-collection-draft-null
|
||||
type="checkbox"
|
||||
.checked=${this.draftIsNull}
|
||||
@change=${(event: Event) => {
|
||||
this.draftIsNull = (event.currentTarget as HTMLInputElement).checked;
|
||||
this.clearError();
|
||||
}}
|
||||
/>
|
||||
<span>${t("configForm.nullValue")}</span>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
${valueControl}
|
||||
<span id=${errorId} class="cfg-field__error" role="alert" ?hidden=${!this.error}
|
||||
>${this.error}</span
|
||||
>
|
||||
<div class="cfg-collection-draft__actions">
|
||||
<button type="button" class="btn btn--sm" @click=${() => this.commit()}>
|
||||
${props.existingKeys ? t("configForm.addEntry") : t("configForm.add")}
|
||||
</button>
|
||||
<button type="button" class="btn btn--sm" @click=${() => this.closeDraft()}>
|
||||
${t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-config-form-collection-draft")) {
|
||||
customElements.define("openclaw-config-form-collection-draft", ConfigFormCollectionDraft);
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
// Control UI tests cover schema composition that changes field requiredness.
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isSupportedConfigValueValid } from "./config-form.constraints.ts";
|
||||
import { renderObject } from "./config-form.node.collection.ts";
|
||||
import { analyzeConfigSchema, renderConfigForm, renderNode } from "./config-form.ts";
|
||||
|
||||
describe("config form composition integrity", () => {
|
||||
it("keeps mixed union and allOf compositions fail-closed", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: {
|
||||
type: "string",
|
||||
anyOf: [{ const: "a" }, { const: "b" }],
|
||||
allOf: [{ const: "a" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(analysis.unsupportedPaths).toEqual(["mixed"]);
|
||||
expect(analysis.schema?.properties?.mixed).toMatchObject({
|
||||
anyOf: [{ const: "a" }, { const: "b" }],
|
||||
allOf: [{ const: "a" }],
|
||||
});
|
||||
|
||||
const unsupportedUnion = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: {
|
||||
type: "string",
|
||||
anyOf: [{ const: "a" }, { const: "b" }],
|
||||
not: { const: "b" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(unsupportedUnion.unsupportedPaths).toEqual(["mixed"]);
|
||||
});
|
||||
|
||||
it("marks required-only object branches as form-unsafe", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
closed: {
|
||||
type: "object",
|
||||
allOf: [{ required: ["token"] }],
|
||||
},
|
||||
open: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
allOf: [{ required: ["token"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(analysis.unsupportedPaths).toEqual(["closed"]);
|
||||
});
|
||||
|
||||
it("marks branch-scoped additional-properties schemas as form-unsafe", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
conflicting: {
|
||||
type: "object",
|
||||
allOf: [
|
||||
{ properties: { count: { type: "integer" } } },
|
||||
{ additionalProperties: { type: "string" } },
|
||||
],
|
||||
},
|
||||
representable: {
|
||||
type: "object",
|
||||
allOf: [
|
||||
{
|
||||
properties: { count: { type: "integer" } },
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(analysis.unsupportedPaths).toEqual(["conflicting"]);
|
||||
});
|
||||
|
||||
it("keeps annotations harmless and items-only schemas form-unsafe", () => {
|
||||
const annotated = analyzeConfigSchema({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://example.test/config",
|
||||
type: "object",
|
||||
examples: [{}],
|
||||
deprecated: false,
|
||||
readOnly: false,
|
||||
writeOnly: false,
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(annotated.unsupportedPaths).toEqual([]);
|
||||
|
||||
const itemsOnly = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(itemsOnly.unsupportedPaths).toEqual(["value"]);
|
||||
expect(itemsOnly.schema?.properties?.value?.type).toBeUndefined();
|
||||
|
||||
const composedArrayItems = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
codes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
allOf: [{ items: { pattern: "^[0-9]+$" } }],
|
||||
},
|
||||
nestedCodes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
allOf: [{ allOf: [{ minItems: 1 }] }],
|
||||
},
|
||||
nullableCodes: {
|
||||
type: ["array", "null"],
|
||||
items: { type: "string" },
|
||||
allOf: [{ allOf: [{ minItems: 1 }] }],
|
||||
},
|
||||
nullOnlyCodes: {
|
||||
type: ["array", "null"],
|
||||
items: { type: "string" },
|
||||
allOf: [{ type: ["null"] }],
|
||||
},
|
||||
nestedConflict: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
allOf: [{ allOf: [{ type: "object", properties: {} }] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(composedArrayItems.unsupportedPaths).toEqual([
|
||||
"nullableCodes",
|
||||
"nullOnlyCodes",
|
||||
"nestedConflict",
|
||||
]);
|
||||
expect(composedArrayItems.schema?.properties?.codes?.allOf?.[0]?.type).toBe("array");
|
||||
const nullableCodes = composedArrayItems.schema?.properties?.nullableCodes;
|
||||
expect(nullableCodes?.allOf?.[0]?.nullable).toBe(true);
|
||||
expect(isSupportedConfigValueValid(nullableCodes ?? {}, null)).toBe(true);
|
||||
const nullOnlyCodes = composedArrayItems.schema?.properties?.nullOnlyCodes;
|
||||
expect(nullOnlyCodes?.allOf?.[0]?.type).toEqual(["null"]);
|
||||
expect(isSupportedConfigValueValid(nullOnlyCodes ?? {}, null)).toBe(true);
|
||||
expect(isSupportedConfigValueValid(nullOnlyCodes ?? {}, ["123"])).toBe(false);
|
||||
const nestedConflict = composedArrayItems.schema?.properties?.nestedConflict;
|
||||
expect(isSupportedConfigValueValid(nestedConflict ?? {}, ["123"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not clear fields required through allOf", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer" },
|
||||
},
|
||||
allOf: [
|
||||
{
|
||||
required: ["count", "mode"],
|
||||
properties: {
|
||||
count: { minimum: 2 },
|
||||
mode: { type: "string", enum: ["a", "b", "c", "d", "e", "f"] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([]);
|
||||
expect(analysis.schema).not.toBeNull();
|
||||
if (!analysis.schema) {
|
||||
return;
|
||||
}
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { settings: { count: 3, mode: "a" } },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>("input[aria-label='Count']");
|
||||
expect(input).not.toBeNull();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
|
||||
input.value = "1";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
|
||||
input.value = "2";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(onPatch).toHaveBeenCalledWith(["settings", "count"], 2);
|
||||
|
||||
onPatch.mockClear();
|
||||
const select = container.querySelector<HTMLSelectElement>("select[aria-label='Mode']");
|
||||
expect(select).not.toBeNull();
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
expect(select.querySelector<HTMLOptionElement>("option[value='__unset__']")?.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
select.value = "__unset__";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(select.value).toBe("0");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not clear required JSON-backed fields", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
payload: {
|
||||
anyOf: [{ type: "object" }, { type: "array" }],
|
||||
},
|
||||
},
|
||||
allOf: [{ required: ["payload"] }],
|
||||
},
|
||||
value: { payload: { enabled: true } },
|
||||
path: ["settings"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
expect(textarea).not.toBeNull();
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.value = "";
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(textarea.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
|
||||
textarea.value = "123";
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(textarea.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves heterogeneous tuple schemas through analysis and rendering", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuple: {
|
||||
type: "array",
|
||||
items: [{ type: "string" }, { type: "integer", minimum: 2 }],
|
||||
additionalItems: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([]);
|
||||
const tupleSchema = analysis.schema?.properties?.tuple;
|
||||
expect(
|
||||
Array.isArray(tupleSchema?.items) ? tupleSchema.items.map((item) => item.type) : [],
|
||||
).toEqual(["string", "integer"]);
|
||||
if (!analysis.schema) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { tuple: ["head", 2] },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const inputs = Array.from(container.querySelectorAll<HTMLInputElement>(".cfg-array input"));
|
||||
expect(inputs.map((input) => input.type)).toEqual(["text", "number"]);
|
||||
const add = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
);
|
||||
expect(add?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects child edits that violate composed object constraints", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string" },
|
||||
},
|
||||
allOf: [{ const: { mode: "safe" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([]);
|
||||
if (!analysis.schema) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { settings: { mode: "safe" } },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const mode = container.querySelector<HTMLInputElement>("input[aria-label='Mode']");
|
||||
expect(mode).not.toBeNull();
|
||||
if (!mode) {
|
||||
return;
|
||||
}
|
||||
mode.value = "fast";
|
||||
mode.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(mode.value).toBe("safe");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders and enforces additional properties composed through allOf", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
aliases: {
|
||||
type: "object",
|
||||
allOf: [
|
||||
{
|
||||
additionalProperties: { type: "string", minLength: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([]);
|
||||
if (!analysis.schema) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { aliases: { custom: "ok" } },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).some(
|
||||
(button) => button.textContent?.trim() === "Add Entry",
|
||||
),
|
||||
).toBe(true);
|
||||
const custom = container.querySelector<HTMLInputElement>("input[aria-label='Custom']");
|
||||
expect(custom).not.toBeNull();
|
||||
if (!custom) {
|
||||
return;
|
||||
}
|
||||
custom.value = "x";
|
||||
custom.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(custom.value).toBe("x");
|
||||
expect(custom.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets additionalProperties false dominate composed map policies", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
aliases: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
allOf: [{ additionalProperties: false }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([]);
|
||||
if (!analysis.schema) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { aliases: {} },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).some(
|
||||
(button) => button.textContent?.trim() === "Add Entry",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("infers nested and compatible allOf types while rejecting impossible intersections", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: {
|
||||
allOf: [{ allOf: [{ type: "string", minLength: 2 }] }],
|
||||
},
|
||||
numeric: {
|
||||
allOf: [{ type: "number" }, { type: "integer", minimum: 2 }],
|
||||
},
|
||||
impossible: {
|
||||
allOf: [{ type: "string" }, { type: "number" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual(["impossible"]);
|
||||
expect(analysis.schema?.properties?.nested?.type).toBe("string");
|
||||
expect(analysis.schema?.properties?.numeric?.type).toBe("integer");
|
||||
if (!analysis.schema) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { nested: "ok", numeric: 2, impossible: "raw-only" },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const nested = container.querySelector<HTMLInputElement>("input[aria-label='Nested']");
|
||||
const numeric = container.querySelector<HTMLInputElement>("input[aria-label='Numeric']");
|
||||
expect(nested?.type).toBe("text");
|
||||
expect(numeric?.type).toBe("number");
|
||||
if (!nested) {
|
||||
return;
|
||||
}
|
||||
nested.value = "x";
|
||||
nested.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(nested.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks ambiguous non-null type arrays as form-unsafe", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
numberFirst: { type: ["number", "string"] },
|
||||
stringFirst: { type: ["string", "number"] },
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual(["numberFirst", "stringFirst"]);
|
||||
});
|
||||
|
||||
it("marks allOf branches with unenforced constraint keywords as form-unsafe", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
strictObject: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
allOf: [{ minProperties: 1 }],
|
||||
},
|
||||
strictArray: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
allOf: [{ contains: { const: "required" } }],
|
||||
},
|
||||
typedObject: {
|
||||
allOf: [{ type: "object", minProperties: 1 }],
|
||||
},
|
||||
nestedConstraint: {
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
child: { minProperties: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
outerConstraint: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
contains: { const: "required" },
|
||||
allOf: [{ maxItems: 3 }],
|
||||
},
|
||||
plainConstraint: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
contains: { const: "required" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([
|
||||
"strictObject",
|
||||
"strictArray",
|
||||
"typedObject",
|
||||
"nestedConstraint.child",
|
||||
"outerConstraint",
|
||||
"plainConstraint",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks incompatible effective allOf child schemas as form-unsafe", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string" },
|
||||
},
|
||||
allOf: [
|
||||
{
|
||||
properties: {
|
||||
mode: { type: "number" },
|
||||
constraintOnly: { const: "safe" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
mixedItems: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
allOf: [{ items: { type: "number" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([
|
||||
"settings.mode",
|
||||
"settings.constraintOnly",
|
||||
"mixedItems",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves nullability inherited through allOf", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
inherited: {
|
||||
allOf: [{ type: ["string", "null"] }],
|
||||
},
|
||||
excludedByOuterType: {
|
||||
type: "string",
|
||||
allOf: [{ type: ["string", "null"] }],
|
||||
},
|
||||
unionTypeExcludesNull: {
|
||||
type: "string",
|
||||
anyOf: [{ const: "fixed" }, { const: null }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual(["inherited"]);
|
||||
expect(analysis.schema?.properties?.inherited).toMatchObject({
|
||||
type: "string",
|
||||
nullable: true,
|
||||
});
|
||||
expect(analysis.schema?.properties?.excludedByOuterType).toMatchObject({
|
||||
type: "string",
|
||||
nullable: false,
|
||||
});
|
||||
expect(analysis.schema?.properties?.unionTypeExcludesNull).toMatchObject({
|
||||
nullable: false,
|
||||
enumIncludesNull: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves whether nullable enums actually include null", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
excludesNull: {
|
||||
type: ["string", "null"],
|
||||
enum: ["fixed"],
|
||||
},
|
||||
includesNull: {
|
||||
type: ["string", "null"],
|
||||
enum: ["fixed", null],
|
||||
},
|
||||
typeExcludesNull: {
|
||||
type: "string",
|
||||
enum: ["fixed", null],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual([]);
|
||||
expect(analysis.schema?.properties?.excludesNull?.enumIncludesNull).toBe(false);
|
||||
expect(analysis.schema?.properties?.includesNull?.enumIncludesNull).toBe(true);
|
||||
expect(analysis.schema?.properties?.typeExcludesNull).toMatchObject({
|
||||
nullable: false,
|
||||
enumIncludesNull: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps type-less allOf fields unsafe and closed empty tuples repairable", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
unknown: { allOf: [{ minLength: 2 }] },
|
||||
empty: {
|
||||
type: "array",
|
||||
items: [],
|
||||
additionalItems: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(analysis.unsupportedPaths).toEqual(["unknown"]);
|
||||
if (!analysis.schema) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { unknown: "raw-only", empty: ["invalid"] },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const add = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
);
|
||||
expect(add?.disabled).toBe(true);
|
||||
const remove = container.querySelector<HTMLButtonElement>("button[aria-label='Remove item']");
|
||||
expect(remove?.disabled).toBe(false);
|
||||
remove?.click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["empty"], []);
|
||||
});
|
||||
});
|
||||
37
ui/src/components/config-form-copy-on-write.test.ts
Normal file
37
ui/src/components/config-form-copy-on-write.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copyWithPathPatch } from "./config-form-copy-on-write.ts";
|
||||
|
||||
describe("config form copy-on-write patching", () => {
|
||||
it("patches special own keys without changing object prototypes", () => {
|
||||
const current = JSON.parse(
|
||||
'{"constructor":{"value":"before"},"prototype":{"value":"before"},"__proto__":{"value":"before"},"untouched":{"stable":true}}',
|
||||
) as Record<string, unknown>;
|
||||
|
||||
for (const key of ["constructor", "prototype", "__proto__"]) {
|
||||
const result = copyWithPathPatch(current, [key, "value"], "after");
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
continue;
|
||||
}
|
||||
const patched = result.value as Record<string, Record<string, unknown>>;
|
||||
expect(Object.hasOwn(patched, key)).toBe(true);
|
||||
expect(patched[key]?.value).toBe("after");
|
||||
expect(Object.getPrototypeOf(patched)).toBe(Object.prototype);
|
||||
expect(patched.untouched).toBe(current.untouched);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates an own __proto__ data property safely", () => {
|
||||
const result = copyWithPathPatch({}, ["__proto__", "value"], "safe");
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
const patched = result.value as Record<string, Record<string, unknown>>;
|
||||
expect(Object.hasOwn(patched, "__proto__")).toBe(true);
|
||||
expect(Object.getOwnPropertyDescriptor(patched, "__proto__")?.value).toEqual({
|
||||
value: "safe",
|
||||
});
|
||||
expect(Object.getPrototypeOf(patched)).toBe(Object.prototype);
|
||||
});
|
||||
});
|
||||
84
ui/src/components/config-form-copy-on-write.ts
Normal file
84
ui/src/components/config-form-copy-on-write.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
const INVALID_PATH_PATCH = Symbol("invalid-path-patch");
|
||||
|
||||
type PathPatchResult =
|
||||
| { ok: true; value: unknown }
|
||||
| { ok: false; value: typeof INVALID_PATH_PATCH };
|
||||
|
||||
function patchPathValue(
|
||||
current: unknown,
|
||||
path: Array<string | number>,
|
||||
index: number,
|
||||
replacement: unknown,
|
||||
): PathPatchResult {
|
||||
const segment = path[index];
|
||||
if (segment === undefined) {
|
||||
return { ok: false, value: INVALID_PATH_PATCH };
|
||||
}
|
||||
const last = index === path.length - 1;
|
||||
|
||||
if (typeof segment === "number") {
|
||||
if (current != null && !Array.isArray(current)) {
|
||||
return { ok: false, value: INVALID_PATH_PATCH };
|
||||
}
|
||||
const next = Array.isArray(current) ? [...current] : [];
|
||||
if (last) {
|
||||
if (replacement === undefined) {
|
||||
next.splice(segment, 1);
|
||||
} else {
|
||||
next[segment] = replacement;
|
||||
}
|
||||
return { ok: true, value: next };
|
||||
}
|
||||
const child = patchPathValue(next[segment], path, index + 1, replacement);
|
||||
if (!child.ok) {
|
||||
return child;
|
||||
}
|
||||
next[segment] = child.value;
|
||||
return { ok: true, value: next };
|
||||
}
|
||||
|
||||
if (current != null && (typeof current !== "object" || Array.isArray(current))) {
|
||||
return { ok: false, value: INVALID_PATH_PATCH };
|
||||
}
|
||||
const next = current ? { ...(current as Record<string, unknown>) } : {};
|
||||
if (last) {
|
||||
if (replacement === undefined) {
|
||||
delete next[segment];
|
||||
} else {
|
||||
Object.defineProperty(next, segment, {
|
||||
value: replacement,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return { ok: true, value: next };
|
||||
}
|
||||
const child = patchPathValue(
|
||||
Object.hasOwn(next, segment) ? next[segment] : undefined,
|
||||
path,
|
||||
index + 1,
|
||||
replacement,
|
||||
);
|
||||
if (!child.ok) {
|
||||
return child;
|
||||
}
|
||||
Object.defineProperty(next, segment, {
|
||||
value: child.value,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return { ok: true, value: next };
|
||||
}
|
||||
|
||||
export function copyWithPathPatch(
|
||||
current: unknown,
|
||||
path: Array<string | number>,
|
||||
replacement: unknown,
|
||||
): PathPatchResult {
|
||||
if (path.length === 0) {
|
||||
return { ok: true, value: replacement };
|
||||
}
|
||||
return patchPathValue(current, path, 0, replacement);
|
||||
}
|
||||
1019
ui/src/components/config-form-integrity.browser.test.ts
Normal file
1019
ui/src/components/config-form-integrity.browser.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
92
ui/src/components/config-form-map-integrity.browser.test.ts
Normal file
92
ui/src/components/config-form-map-integrity.browser.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConfigFormCollectionDraft } from "./config-form-collection-draft.ts";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./config-form.ts";
|
||||
|
||||
function expectElement<T extends Element>(element: T | null | undefined, label: string): T {
|
||||
expect(element instanceof Element, label).toBe(true);
|
||||
if (!(element instanceof Element)) {
|
||||
throw new Error(`missing ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("config form map integrity", () => {
|
||||
it("retains unset map drafts until the collection source changes", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
aliases: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "string",
|
||||
pattern: "^[0-9]+$",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const renderValue = (aliases: Record<string, unknown> | undefined) => {
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: aliases === undefined ? {} : { aliases },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch: () => {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue(undefined);
|
||||
const map = expectElement(container.querySelector<HTMLElement>(".cfg-map"), "unset map");
|
||||
const draftHost = expectElement(
|
||||
map.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"unset map draft host",
|
||||
);
|
||||
expectElement(
|
||||
Array.from(map.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add Entry",
|
||||
),
|
||||
"unset map add button",
|
||||
).click();
|
||||
await draftHost.updateComplete;
|
||||
const key = expectElement(
|
||||
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-key]"),
|
||||
"unset map draft key",
|
||||
);
|
||||
const value = expectElement(
|
||||
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"unset map draft value",
|
||||
);
|
||||
key.value = "primary";
|
||||
key.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
value.value = "123";
|
||||
value.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draftHost.updateComplete;
|
||||
|
||||
renderValue(undefined);
|
||||
await draftHost.updateComplete;
|
||||
expect(
|
||||
expectElement(
|
||||
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-key]"),
|
||||
"preserved unset map draft key",
|
||||
).value,
|
||||
).toBe("primary");
|
||||
expect(
|
||||
expectElement(
|
||||
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"preserved unset map draft value",
|
||||
).value,
|
||||
).toBe("123");
|
||||
|
||||
renderValue({});
|
||||
await draftHost.updateComplete;
|
||||
expect(draftHost.querySelector(".cfg-collection-draft")).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderArray } from "./config-form.node.collection.ts";
|
||||
import { renderNode } from "./config-form.ts";
|
||||
|
||||
function expectElement<T extends Element>(element: T | null | undefined, label: string): T {
|
||||
expect(element instanceof Element, label).toBe(true);
|
||||
if (!(element instanceof Element)) {
|
||||
throw new Error(`missing ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("config form nested array integrity", () => {
|
||||
it("preserves an unrelated JSON draft when a sibling field changes", () => {
|
||||
const container = document.createElement("div");
|
||||
let currentValue: unknown[] = [{ name: "before", payload: { enabled: true } }];
|
||||
const renderValue = () => {
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
payload: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
value: currentValue,
|
||||
path: ["entries"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch: (_path, nextValue) => {
|
||||
currentValue = nextValue as unknown[];
|
||||
renderValue();
|
||||
},
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue();
|
||||
const payload = expectElement(
|
||||
container.querySelector<HTMLTextAreaElement>("textarea"),
|
||||
"payload JSON draft",
|
||||
);
|
||||
payload.value = "{";
|
||||
payload.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(payload.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
const name = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input[aria-label='Name']"),
|
||||
"name input",
|
||||
);
|
||||
name.value = "after";
|
||||
name.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
name.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
const currentPayload = expectElement(
|
||||
container.querySelector<HTMLTextAreaElement>("textarea"),
|
||||
"preserved payload JSON draft",
|
||||
);
|
||||
expect(currentPayload).toBe(payload);
|
||||
expect(currentPayload.value).toBe("{");
|
||||
expect(currentPayload.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(currentValue).toEqual([{ name: "after", payload: { enabled: true } }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
// Control UI tests cover nested config edit rejection and draft preservation.
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConfigFormCollectionDraft } from "./config-form-collection-draft.ts";
|
||||
import { renderArray, renderObject } from "./config-form.node.collection.ts";
|
||||
import { renderNode } from "./config-form.ts";
|
||||
|
||||
type ConfigFormStructuredDraftElement = HTMLElement & {
|
||||
updateComplete: Promise<unknown>;
|
||||
};
|
||||
|
||||
function expectElement<T extends Element>(element: T | null | undefined, label: string): T {
|
||||
expect(element instanceof Element, label).toBe(true);
|
||||
if (!(element instanceof Element)) {
|
||||
throw new Error(`missing ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("config form rejection integrity", () => {
|
||||
it("preserves an invalid property draft when a sibling edit rerenders the object", () => {
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 2 },
|
||||
mode: { type: "string", enum: ["a", "b", "c", "d", "e", "f"] },
|
||||
},
|
||||
};
|
||||
let currentValue = { name: "valid", mode: "a" };
|
||||
const renderValue = () => {
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema,
|
||||
value: currentValue,
|
||||
path: ["settings"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
sourceIdentity: currentValue,
|
||||
controlIdentity: currentValue,
|
||||
onPatch: (path, nextValue) => {
|
||||
const key = path.at(-1);
|
||||
if (key !== "name" && key !== "mode") {
|
||||
return false;
|
||||
}
|
||||
currentValue = { ...currentValue, [key]: nextValue };
|
||||
renderValue();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue();
|
||||
const name = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input[aria-label='Name']"),
|
||||
"name input",
|
||||
);
|
||||
name.value = "x";
|
||||
name.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(name.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
const mode = expectElement(
|
||||
container.querySelector<HTMLSelectElement>("select[aria-label='Mode']"),
|
||||
"mode select",
|
||||
);
|
||||
mode.value = "1";
|
||||
mode.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
const rerenderedName = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input[aria-label='Name']"),
|
||||
"rerendered name input",
|
||||
);
|
||||
expect(rerenderedName.value).toBe("x");
|
||||
expect(rerenderedName.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(currentValue).toEqual({ name: "valid", mode: "b" });
|
||||
});
|
||||
|
||||
it("keeps a nested collection draft open when its parent rejects the commit", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "string", pattern: "^[a-z]+$" },
|
||||
},
|
||||
},
|
||||
value: [["alpha"], []],
|
||||
path: ["groups"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const arrays = Array.from(container.querySelectorAll<HTMLElement>(".cfg-array"));
|
||||
const secondGroup = expectElement(arrays[2], "second nested array");
|
||||
const draft = expectElement(
|
||||
secondGroup.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"second nested array draft",
|
||||
);
|
||||
await draft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(secondGroup.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"second nested array add",
|
||||
).click();
|
||||
await draft.updateComplete;
|
||||
const value = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"second nested array draft value",
|
||||
);
|
||||
value.value = "alpha";
|
||||
value.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(draft.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"second nested array commit",
|
||||
).click();
|
||||
await draft.updateComplete;
|
||||
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
expect(draft.querySelector(".cfg-collection-draft")).not.toBeNull();
|
||||
const retainedValue = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"retained nested array draft value",
|
||||
);
|
||||
expect(retainedValue.value).toBe("alpha");
|
||||
expect(retainedValue.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(draft.querySelector<HTMLElement>("[role='alert']")?.hidden).toBe(false);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("opens an array draft when a parent rejects the automatic default", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
value: [[""], []],
|
||||
path: ["groups"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const arrays = Array.from(container.querySelectorAll<HTMLElement>(".cfg-array"));
|
||||
const secondGroup = expectElement(arrays[2], "second auto-default array");
|
||||
const draft = expectElement(
|
||||
secondGroup.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"second auto-default array draft",
|
||||
);
|
||||
await draft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(secondGroup.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"second auto-default array add",
|
||||
).click();
|
||||
await draft.updateComplete;
|
||||
expect(draft.querySelector(".cfg-collection-draft")).not.toBeNull();
|
||||
|
||||
const value = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
||||
"array fallback draft value",
|
||||
);
|
||||
value.value = "x";
|
||||
value.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(draft.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"array fallback draft commit",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["groups"], [[""], ["x"]]);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("opens a map draft when a parent rejects the automatic entry", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "object" },
|
||||
},
|
||||
},
|
||||
value: [{ "custom-1": {} }, {}],
|
||||
path: ["entries"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const maps = Array.from(container.querySelectorAll<HTMLElement>(".cfg-map"));
|
||||
const secondMap = expectElement(maps[1], "second auto-default map");
|
||||
const draft = expectElement(
|
||||
secondMap.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
||||
"second auto-default map draft",
|
||||
);
|
||||
await draft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(secondMap.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add Entry",
|
||||
),
|
||||
"second auto-default map add",
|
||||
).click();
|
||||
await draft.updateComplete;
|
||||
expect(draft.querySelector(".cfg-collection-draft")).not.toBeNull();
|
||||
|
||||
const key = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("[data-collection-draft-key]"),
|
||||
"map fallback draft key",
|
||||
);
|
||||
const value = expectElement(
|
||||
draft.querySelector<HTMLTextAreaElement>("[data-collection-draft-value]"),
|
||||
"map fallback draft value",
|
||||
);
|
||||
key.value = "custom-2";
|
||||
key.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
value.value = "{}";
|
||||
value.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draft.updateComplete;
|
||||
expectElement(
|
||||
Array.from(draft.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add Entry",
|
||||
),
|
||||
"map fallback draft commit",
|
||||
).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["entries"], [{ "custom-1": {} }, { "custom-2": {} }]);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("restores a map key when a constrained parent rejects the rename", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
},
|
||||
value: [{ a: "1" }, { b: "1" }],
|
||||
path: ["entries"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const key = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input[aria-label='Key: b']"),
|
||||
"second map key",
|
||||
);
|
||||
key.value = "a";
|
||||
key.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
expect(key.value).toBe("b");
|
||||
});
|
||||
|
||||
it("commits an optional object only after all required children are valid", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
let currentValue: Record<string, unknown> = {};
|
||||
const onPatch = vi.fn((path: Array<string | number>, value: unknown) => {
|
||||
currentValue = { ...currentValue, connection: value };
|
||||
renderValue();
|
||||
return true;
|
||||
});
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
connection: {
|
||||
type: "object",
|
||||
required: ["host", "port"],
|
||||
properties: {
|
||||
host: { type: "string", minLength: 1 },
|
||||
port: { type: "integer", minimum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderValue = () => {
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema,
|
||||
value: currentValue,
|
||||
path: ["settings"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue();
|
||||
const draft = expectElement(
|
||||
container.querySelector<ConfigFormStructuredDraftElement>(
|
||||
"openclaw-config-form-structured-draft",
|
||||
),
|
||||
"optional object draft",
|
||||
);
|
||||
await draft.updateComplete;
|
||||
const host = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Host']"),
|
||||
"optional host",
|
||||
);
|
||||
host.value = "gateway.local";
|
||||
host.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draft.updateComplete;
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
|
||||
renderValue();
|
||||
await draft.updateComplete;
|
||||
expect(
|
||||
expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Host']"),
|
||||
"preserved optional host",
|
||||
).value,
|
||||
).toBe("gateway.local");
|
||||
|
||||
const port = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Port']"),
|
||||
"optional port",
|
||||
);
|
||||
port.value = "18789";
|
||||
port.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
expect(onPatch).toHaveBeenCalledTimes(1);
|
||||
expect(onPatch).toHaveBeenCalledWith(["settings", "connection"], {
|
||||
host: "gateway.local",
|
||||
port: 18789,
|
||||
});
|
||||
expect(currentValue).toEqual({
|
||||
connection: { host: "gateway.local", port: 18789 },
|
||||
});
|
||||
expect(container.querySelector("openclaw-config-form-structured-draft")).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("retains a complete optional object draft when its atomic commit is rejected", async () => {
|
||||
const onPatch = vi.fn(() => false);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
connection: {
|
||||
type: "object",
|
||||
required: ["host", "port"],
|
||||
properties: {
|
||||
host: { type: "string", minLength: 1 },
|
||||
port: { type: "integer", minimum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderValue = () => {
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema,
|
||||
value: {},
|
||||
path: ["settings"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue();
|
||||
const draft = expectElement(
|
||||
container.querySelector<ConfigFormStructuredDraftElement>(
|
||||
"openclaw-config-form-structured-draft",
|
||||
),
|
||||
"rejected optional object draft",
|
||||
);
|
||||
await draft.updateComplete;
|
||||
const host = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Host']"),
|
||||
"rejected optional host",
|
||||
);
|
||||
host.value = "gateway.local";
|
||||
host.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draft.updateComplete;
|
||||
const port = expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Port']"),
|
||||
"rejected optional port",
|
||||
);
|
||||
port.value = "18789";
|
||||
port.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await draft.updateComplete;
|
||||
|
||||
expect(onPatch).toHaveBeenCalledTimes(1);
|
||||
expect(onPatch).toHaveBeenCalledWith(["settings", "connection"], {
|
||||
host: "gateway.local",
|
||||
port: 18789,
|
||||
});
|
||||
expect(
|
||||
expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Host']"),
|
||||
"retained rejected host",
|
||||
).value,
|
||||
).toBe("gateway.local");
|
||||
expect(
|
||||
expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Port']"),
|
||||
"retained rejected port",
|
||||
).value,
|
||||
).toBe("18789");
|
||||
expect(draft.querySelector<HTMLElement>("[role='alert']")?.textContent).toContain(
|
||||
"draft is still here",
|
||||
);
|
||||
|
||||
renderValue();
|
||||
await draft.updateComplete;
|
||||
expect(
|
||||
expectElement(
|
||||
draft.querySelector<HTMLInputElement>("input[aria-label='Host']"),
|
||||
"rerendered rejected host",
|
||||
).value,
|
||||
).toBe("gateway.local");
|
||||
expect(draft.querySelector<HTMLElement>("[role='alert']")?.textContent).toContain(
|
||||
"draft is still here",
|
||||
);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("constructs an optional large-minimum array without leaking partial values", async () => {
|
||||
const onPatch = vi.fn((_path: Array<string | number>, _value: unknown) => false);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
codes: {
|
||||
type: "array",
|
||||
minItems: 101,
|
||||
maxItems: 101,
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderValue = () => {
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema,
|
||||
value: {},
|
||||
path: ["settings"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
const add = (draft: ConfigFormStructuredDraftElement) =>
|
||||
expectElement(
|
||||
Array.from(draft.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
),
|
||||
"large-minimum array add",
|
||||
);
|
||||
|
||||
renderValue();
|
||||
const draft = expectElement(
|
||||
container.querySelector<ConfigFormStructuredDraftElement>(
|
||||
"openclaw-config-form-structured-draft",
|
||||
),
|
||||
"large-minimum array draft",
|
||||
);
|
||||
await draft.updateComplete;
|
||||
add(draft).click();
|
||||
await draft.updateComplete;
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
expect(draft.textContent).toContain("1 item");
|
||||
|
||||
renderValue();
|
||||
await draft.updateComplete;
|
||||
expect(draft.textContent).toContain("1 item");
|
||||
|
||||
add(draft).click();
|
||||
await draft.updateComplete;
|
||||
expect(onPatch).toHaveBeenCalledTimes(1);
|
||||
const [path, value] = onPatch.mock.calls[0] ?? [];
|
||||
expect(path).toEqual(["settings", "codes"]);
|
||||
expect(value).toEqual(Array.from({ length: 101 }, () => ""));
|
||||
expect(draft.textContent).toContain("101 items");
|
||||
expect(draft.querySelector<HTMLElement>("[role='alert']")?.textContent).toContain(
|
||||
"draft is still here",
|
||||
);
|
||||
|
||||
renderValue();
|
||||
await draft.updateComplete;
|
||||
expect(draft.textContent).toContain("101 items");
|
||||
expect(draft.querySelector<HTMLElement>("[role='alert']")?.textContent).toContain(
|
||||
"draft is still here",
|
||||
);
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
148
ui/src/components/config-form-scalar-integrity.browser.test.ts
Normal file
148
ui/src/components/config-form-scalar-integrity.browser.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Control UI tests cover scalar identity and nullable enum behavior.
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderNumberInput, renderSelect } from "./config-form.node.scalar.ts";
|
||||
|
||||
function expectElement<T extends Element>(element: T | null | undefined, label: string): T {
|
||||
expect(element instanceof Element, label).toBe(true);
|
||||
if (!(element instanceof Element)) {
|
||||
throw new Error(`missing ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("config form scalar integrity", () => {
|
||||
it("keeps repeated number input identity arguments aligned", () => {
|
||||
const container = document.createElement("div");
|
||||
const renderValue = (controlIdentity: number[]) => {
|
||||
render(
|
||||
renderNumberInput({
|
||||
schema: { type: "integer" },
|
||||
value: 2,
|
||||
path: ["values", 0],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
sourceIdentity: 2,
|
||||
controlIdentity,
|
||||
onPatch: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue([2]);
|
||||
const input = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input[type='number']"),
|
||||
"repeated number input",
|
||||
);
|
||||
renderValue([2, 4]);
|
||||
expect(container.querySelector("input[type='number']")).toBe(input);
|
||||
expect(input.value).toBe("2");
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
});
|
||||
|
||||
it("allows required nullable enums to select their null member", () => {
|
||||
const container = document.createElement("div");
|
||||
const nullablePatch = vi.fn();
|
||||
render(
|
||||
renderSelect({
|
||||
schema: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
enumIncludesNull: true,
|
||||
},
|
||||
value: "fixed",
|
||||
path: ["nullableMode"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
options: ["fixed", "other"],
|
||||
onPatch: nullablePatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const nullableSelect = expectElement(
|
||||
container.querySelector<HTMLSelectElement>("select"),
|
||||
"required nullable enum",
|
||||
);
|
||||
const nullOption = expectElement(
|
||||
nullableSelect.querySelector<HTMLOptionElement>("option[value='__null__']"),
|
||||
"nullable enum null option",
|
||||
);
|
||||
expect(nullOption.disabled).toBe(false);
|
||||
expect(
|
||||
nullableSelect.querySelector<HTMLOptionElement>("option[value='__unset__']")?.disabled,
|
||||
).toBe(true);
|
||||
nullableSelect.value = "__null__";
|
||||
nullableSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(nullablePatch).toHaveBeenCalledWith(["nullableMode"], null);
|
||||
|
||||
const requiredPatch = vi.fn();
|
||||
render(
|
||||
renderSelect({
|
||||
schema: { type: "string" },
|
||||
value: "fixed",
|
||||
path: ["requiredMode"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
options: ["fixed", "other"],
|
||||
onPatch: requiredPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const requiredSelect = expectElement(
|
||||
container.querySelector<HTMLSelectElement>("select"),
|
||||
"required non-null enum",
|
||||
);
|
||||
expect(
|
||||
requiredSelect.querySelector<HTMLOptionElement>("option[value='__unset__']")?.disabled,
|
||||
).toBe(true);
|
||||
requiredSelect.value = "__unset__";
|
||||
requiredSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(requiredSelect.value).toBe("0");
|
||||
expect(requiredPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps optional nullable enum unset distinct from explicit null", () => {
|
||||
const container = document.createElement("div");
|
||||
const onPatch = vi.fn();
|
||||
const renderValue = (value: unknown) => {
|
||||
render(
|
||||
renderSelect({
|
||||
schema: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
enumIncludesNull: true,
|
||||
},
|
||||
value,
|
||||
path: ["mode"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
options: ["fixed", "other"],
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderValue(null);
|
||||
const select = expectElement(
|
||||
container.querySelector<HTMLSelectElement>("select"),
|
||||
"optional nullable enum",
|
||||
);
|
||||
expect(select.value).toBe("__null__");
|
||||
select.value = "__unset__";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenLastCalledWith(["mode"], undefined);
|
||||
|
||||
renderValue("fixed");
|
||||
select.value = "__null__";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenLastCalledWith(["mode"], null);
|
||||
});
|
||||
});
|
||||
152
ui/src/components/config-form-structured-draft.ts
Normal file
152
ui/src/components/config-form-structured-draft.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { html, nothing, type PropertyValues } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
import { copyWithPathPatch } from "./config-form-copy-on-write.ts";
|
||||
import { isSupportedConfigValueValid } from "./config-form.constraints.ts";
|
||||
import type { ConfigNodeRenderer, ConfigNodeRenderParams } from "./config-form.node.shared.ts";
|
||||
import { configFieldId, schemaType } from "./config-form.shared.ts";
|
||||
|
||||
export type ConfigFormStructuredDraftProps = {
|
||||
identity: string;
|
||||
sourceIdentity: unknown;
|
||||
initialValue: Record<string, unknown> | unknown[];
|
||||
params: ConfigNodeRenderParams;
|
||||
renderNode: ConfigNodeRenderer;
|
||||
};
|
||||
|
||||
function cloneDraftValue(value: Record<string, unknown> | unknown[]) {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
export function structuredDraftInitialValue(
|
||||
params: ConfigNodeRenderParams,
|
||||
): Record<string, unknown> | unknown[] | undefined {
|
||||
const type = schemaType(params.schema);
|
||||
if (type !== "object" && type !== "array") {
|
||||
return undefined;
|
||||
}
|
||||
const schemaDefault = params.schema.default;
|
||||
if (
|
||||
(type === "object" &&
|
||||
schemaDefault &&
|
||||
typeof schemaDefault === "object" &&
|
||||
!Array.isArray(schemaDefault)) ||
|
||||
(type === "array" && Array.isArray(schemaDefault))
|
||||
) {
|
||||
return cloneDraftValue(schemaDefault as Record<string, unknown> | unknown[]);
|
||||
}
|
||||
return type === "object" ? {} : [];
|
||||
}
|
||||
|
||||
export function shouldStageStructuredDraft(
|
||||
params: ConfigNodeRenderParams,
|
||||
initialValue: Record<string, unknown> | unknown[] | undefined,
|
||||
): initialValue is Record<string, unknown> | unknown[] {
|
||||
return (
|
||||
initialValue !== undefined &&
|
||||
params.value === undefined &&
|
||||
params.isRequired !== true &&
|
||||
params.structuredDraftOwner !== true &&
|
||||
!isSupportedConfigValueValid(params.schema, initialValue)
|
||||
);
|
||||
}
|
||||
|
||||
class ConfigFormStructuredDraft extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) props?: ConfigFormStructuredDraftProps;
|
||||
|
||||
@state() private draftValue: Record<string, unknown> | unknown[] | undefined;
|
||||
@state() private error = "";
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (!changedProperties.has("props")) {
|
||||
return;
|
||||
}
|
||||
const previous = changedProperties.get("props") as ConfigFormStructuredDraftProps | undefined;
|
||||
const next = this.props;
|
||||
if (
|
||||
next &&
|
||||
(!previous ||
|
||||
previous.identity !== next.identity ||
|
||||
!Object.is(previous.sourceIdentity, next.sourceIdentity))
|
||||
) {
|
||||
this.draftValue = cloneDraftValue(next.initialValue);
|
||||
this.error = "";
|
||||
}
|
||||
}
|
||||
|
||||
private patchDraft(path: Array<string | number>, value: unknown): boolean {
|
||||
const props = this.props;
|
||||
const current = this.draftValue;
|
||||
if (!props || !current) {
|
||||
return false;
|
||||
}
|
||||
const rootPath = props.params.path;
|
||||
if (
|
||||
path.length < rootPath.length ||
|
||||
!rootPath.every((segment, index) => segment === path[index])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const relativePath = path.slice(rootPath.length);
|
||||
const patched =
|
||||
relativePath.length === 0
|
||||
? { ok: true as const, value }
|
||||
: copyWithPathPatch(current, relativePath, value);
|
||||
if (!patched.ok) {
|
||||
return false;
|
||||
}
|
||||
const candidate = patched.value;
|
||||
const type = schemaType(props.params.schema);
|
||||
if (
|
||||
(type === "object" &&
|
||||
(!candidate || typeof candidate !== "object" || Array.isArray(candidate))) ||
|
||||
(type === "array" && !Array.isArray(candidate))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.draftValue = candidate as Record<string, unknown> | unknown[];
|
||||
this.error = "";
|
||||
if (!isSupportedConfigValueValid(props.params.schema, candidate)) {
|
||||
return true;
|
||||
}
|
||||
if (props.params.onPatch(rootPath, candidate) !== false) {
|
||||
return true;
|
||||
}
|
||||
this.error = t("configForm.draftRejected");
|
||||
return false;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const props = this.props;
|
||||
const draftValue = this.draftValue;
|
||||
if (!props || !draftValue) {
|
||||
return nothing;
|
||||
}
|
||||
const errorId = configFieldId(props.params.path, "structured-draft-error");
|
||||
return html`
|
||||
${props.renderNode({
|
||||
...props.params,
|
||||
value: draftValue,
|
||||
sourceIdentity: draftValue,
|
||||
controlIdentity: draftValue,
|
||||
structuredDraftOwner: true,
|
||||
onPatch: (path, value) => this.patchDraft(path, value),
|
||||
})}
|
||||
${this.error
|
||||
? html`
|
||||
<div class="settings-row settings-row--stacked cfg-structured-draft__error">
|
||||
<div class="settings-row__control">
|
||||
<span id=${errorId} class="cfg-field__error" role="alert">${this.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-config-form-structured-draft")) {
|
||||
customElements.define("openclaw-config-form-structured-draft", ConfigFormStructuredDraft);
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
// Control UI view renders config form.analyze screen content.
|
||||
import {
|
||||
arrayItemSchema,
|
||||
objectAdditionalPropertiesSchema,
|
||||
objectPropertyKeys,
|
||||
objectPropertySchema,
|
||||
requiredPropertyKeys,
|
||||
} from "./config-form.constraints.ts";
|
||||
import { pathKey, schemaType, type JsonSchema } from "./config-form.shared.ts";
|
||||
|
||||
export type ConfigSchemaAnalysis = {
|
||||
@@ -6,7 +13,49 @@ export type ConfigSchemaAnalysis = {
|
||||
unsupportedPaths: string[];
|
||||
};
|
||||
|
||||
const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]);
|
||||
const META_KEYS = new Set([
|
||||
"$id",
|
||||
"$schema",
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"deprecated",
|
||||
"nullable",
|
||||
"enumIncludesNull",
|
||||
"examples",
|
||||
"readOnly",
|
||||
"tags",
|
||||
"writeOnly",
|
||||
"x-tags",
|
||||
]);
|
||||
const SUPPORTED_CONSTRAINT_ONLY_KEYS = new Set([
|
||||
...META_KEYS,
|
||||
"const",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
]);
|
||||
const SUPPORTED_FORM_SCHEMA_KEYS = new Set([
|
||||
...SUPPORTED_CONSTRAINT_ONLY_KEYS,
|
||||
"type",
|
||||
"properties",
|
||||
"items",
|
||||
"additionalItems",
|
||||
"enum",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"allOf",
|
||||
]);
|
||||
const RENDERABLE_UNION_TYPES = new Set([
|
||||
"string",
|
||||
"number",
|
||||
@@ -37,6 +86,147 @@ function uniqueValues(values: unknown[]): unknown[] {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function inferredSchemaTypes(schema: JsonSchema, seen = new Set<JsonSchema>()): Set<string> {
|
||||
if (seen.has(schema)) {
|
||||
return new Set();
|
||||
}
|
||||
seen.add(schema);
|
||||
const types = new Set<string>();
|
||||
const declared = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
||||
for (const type of declared) {
|
||||
if (type !== "null") {
|
||||
types.add(type);
|
||||
}
|
||||
}
|
||||
if (types.size === 0) {
|
||||
if (schema.properties || schema.additionalProperties) {
|
||||
types.add("object");
|
||||
}
|
||||
}
|
||||
for (const entry of schema.allOf ?? []) {
|
||||
for (const type of inferredSchemaTypes(entry, seen)) {
|
||||
types.add(type);
|
||||
}
|
||||
}
|
||||
seen.delete(schema);
|
||||
return types;
|
||||
}
|
||||
|
||||
function intersectedSchemaType(types: ReadonlySet<string>): string | undefined {
|
||||
if (types.size === 1) {
|
||||
return types.values().next().value;
|
||||
}
|
||||
if (types.size > 1 && [...types].every((type) => type === "number" || type === "integer")) {
|
||||
return types.has("integer") ? "integer" : "number";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasIncompatibleTypes(types: ReadonlySet<string>): boolean {
|
||||
return types.size > 1 && intersectedSchemaType(types) === undefined;
|
||||
}
|
||||
|
||||
function inferredSchemaType(schema: JsonSchema): string | undefined {
|
||||
return intersectedSchemaType(inferredSchemaTypes(schema));
|
||||
}
|
||||
|
||||
function shouldNormalizeAllOfBranch(schema: JsonSchema): boolean {
|
||||
return Boolean(
|
||||
inferredSchemaType(schema) ||
|
||||
schema.items ||
|
||||
schema.enum ||
|
||||
schema.anyOf ||
|
||||
schema.oneOf ||
|
||||
schema.allOf,
|
||||
);
|
||||
}
|
||||
|
||||
function hasOnlySupportedConstraintKeywords(schema: JsonSchema): boolean {
|
||||
return Object.keys(schema).every((key) => SUPPORTED_CONSTRAINT_ONLY_KEYS.has(key));
|
||||
}
|
||||
|
||||
function hasOnlySupportedFormKeywords(schema: JsonSchema): boolean {
|
||||
return Object.keys(schema).every((key) => SUPPORTED_FORM_SCHEMA_KEYS.has(key));
|
||||
}
|
||||
|
||||
function schemaAllowsNull(schema: JsonSchema, seen = new Set<JsonSchema>()): boolean {
|
||||
if (seen.has(schema)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(schema);
|
||||
const declaredTypes = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
||||
let allowsNull =
|
||||
schema.nullable === true || declaredTypes.length === 0 || declaredTypes.includes("null");
|
||||
if (schema.const !== undefined) {
|
||||
allowsNull = allowsNull && schema.const === null;
|
||||
}
|
||||
if (schema.enum) {
|
||||
allowsNull = allowsNull && schema.enum.some((entry) => entry === null);
|
||||
}
|
||||
if (schema.allOf) {
|
||||
allowsNull = allowsNull && schema.allOf.every((entry) => schemaAllowsNull(entry, seen));
|
||||
}
|
||||
if (schema.anyOf) {
|
||||
allowsNull = allowsNull && schema.anyOf.some((entry) => schemaAllowsNull(entry, seen));
|
||||
}
|
||||
if (schema.oneOf) {
|
||||
allowsNull =
|
||||
allowsNull && schema.oneOf.filter((entry) => schemaAllowsNull(entry, seen)).length === 1;
|
||||
}
|
||||
seen.delete(schema);
|
||||
return allowsNull;
|
||||
}
|
||||
|
||||
function effectiveArrayItemIndexes(schema: JsonSchema): number[] {
|
||||
const pending = [schema];
|
||||
const seen = new Set<JsonSchema>();
|
||||
let maxTupleLength = 0;
|
||||
let hasRepeatedItems = false;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current || seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
if (Array.isArray(current.items)) {
|
||||
maxTupleLength = Math.max(maxTupleLength, current.items.length);
|
||||
} else if (current.items) {
|
||||
hasRepeatedItems = true;
|
||||
}
|
||||
pending.push(...(current.allOf ?? []));
|
||||
}
|
||||
const count = Math.max(maxTupleLength, hasRepeatedItems ? 1 : 0);
|
||||
return Array.from({ length: count }, (_, index) => index);
|
||||
}
|
||||
|
||||
function hasUnrepresentableComposedAdditionalProperties(schema: JsonSchema): boolean {
|
||||
const schemas: JsonSchema[] = [];
|
||||
const pending = [schema];
|
||||
const seen = new Set<JsonSchema>();
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current || seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
schemas.push(current);
|
||||
pending.push(...(current.allOf ?? []));
|
||||
}
|
||||
if (schemas.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
const propertyKeys = new Set(schemas.flatMap((entry) => Object.keys(entry.properties ?? {})));
|
||||
return schemas.some((entry) => {
|
||||
const policy = entry.additionalProperties;
|
||||
return (
|
||||
Boolean(policy) &&
|
||||
typeof policy === "object" &&
|
||||
Object.keys(policy).length > 0 &&
|
||||
[...propertyKeys].some((key) => !Object.hasOwn(entry.properties ?? {}, key))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { schema: null, unsupportedPaths: ["<root>"] };
|
||||
@@ -47,41 +237,121 @@ export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
|
||||
function normalizeSchemaNode(
|
||||
schema: JsonSchema,
|
||||
path: Array<string | number>,
|
||||
compositionBranch = false,
|
||||
inheritedCompositionType?: string,
|
||||
inheritedCompositionAllowsNull?: boolean,
|
||||
): ConfigSchemaAnalysis {
|
||||
const unsupported = new Set<string>();
|
||||
const normalized: JsonSchema = { ...schema };
|
||||
const pathLabel = pathKey(path) || "<root>";
|
||||
|
||||
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
||||
if (!hasOnlySupportedFormKeywords(schema)) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const union = normalizeUnion(schema, path);
|
||||
if (union) {
|
||||
return union;
|
||||
return {
|
||||
schema: union.schema,
|
||||
unsupportedPaths: Array.from(new Set([...unsupported, ...union.unsupportedPaths])),
|
||||
};
|
||||
}
|
||||
return { schema, unsupportedPaths: [pathLabel] };
|
||||
}
|
||||
|
||||
const nullable = Array.isArray(schema.type) && schema.type.includes("null");
|
||||
const type =
|
||||
schemaType(schema) ?? (schema.properties || schema.additionalProperties ? "object" : undefined);
|
||||
const declaredTypes = Array.isArray(schema.type)
|
||||
? schema.type.filter((entry) => entry !== "null")
|
||||
: [];
|
||||
const inferredTypes = inferredSchemaTypes(schema);
|
||||
const inheritedCompositionOnly =
|
||||
compositionBranch &&
|
||||
Boolean(inheritedCompositionType) &&
|
||||
schema.type === undefined &&
|
||||
inferredTypes.size === 0;
|
||||
if (inheritedCompositionOnly && inheritedCompositionType) {
|
||||
inferredTypes.add(inheritedCompositionType);
|
||||
}
|
||||
if (
|
||||
compositionBranch &&
|
||||
inheritedCompositionType &&
|
||||
inferredTypes.size > 0 &&
|
||||
hasIncompatibleTypes(new Set([...inferredTypes, inheritedCompositionType]))
|
||||
) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
if (new Set(declaredTypes).size > 1 || hasIncompatibleTypes(inferredTypes)) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
const type = intersectedSchemaType(inferredTypes);
|
||||
const allowsNull =
|
||||
schemaAllowsNull(schema) &&
|
||||
(inheritedCompositionAllowsNull === undefined || inheritedCompositionAllowsNull);
|
||||
|
||||
if (schema.allOf) {
|
||||
const normalizedAllOf: JsonSchema[] = [];
|
||||
for (const entry of schema.allOf) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
unsupported.add(pathLabel);
|
||||
continue;
|
||||
}
|
||||
if (!shouldNormalizeAllOfBranch(entry)) {
|
||||
normalizedAllOf.push(entry);
|
||||
if (!hasOnlySupportedConstraintKeywords(entry)) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const result = normalizeSchemaNode(entry, path, true, type, allowsNull);
|
||||
normalizedAllOf.push(result.schema ?? entry);
|
||||
for (const unsupportedPath of result.unsupportedPaths) {
|
||||
unsupported.add(unsupportedPath);
|
||||
}
|
||||
}
|
||||
normalized.allOf = normalizedAllOf;
|
||||
}
|
||||
|
||||
normalized.type = type ?? schema.type;
|
||||
normalized.nullable = nullable || schema.nullable;
|
||||
normalized.nullable = allowsNull;
|
||||
const hasLocalObjectStructure =
|
||||
schema.properties !== undefined || schema.additionalProperties !== undefined;
|
||||
const hasLocalArrayStructure = schema.items !== undefined || schema.additionalItems !== undefined;
|
||||
|
||||
if (normalized.enum) {
|
||||
const { enumValues, nullable: enumNullable } = normalizeEnum(normalized.enum);
|
||||
normalized.enum = enumValues;
|
||||
if (enumNullable) {
|
||||
normalized.nullable = true;
|
||||
}
|
||||
normalized.enumIncludesNull = enumNullable && allowsNull;
|
||||
if (enumValues.length === 0) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
}
|
||||
if (schema.allOf && allowsNull && !normalized.enumIncludesNull) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
|
||||
if (type === "object") {
|
||||
if (type === "object" && (!inheritedCompositionOnly || hasLocalObjectStructure)) {
|
||||
const properties = schema.properties ?? {};
|
||||
const propertyKeys = new Set(objectPropertyKeys(schema));
|
||||
const additionalProperties = objectAdditionalPropertiesSchema(schema);
|
||||
if (
|
||||
[...requiredPropertyKeys(schema)].some((key) => !propertyKeys.has(key)) &&
|
||||
!additionalProperties
|
||||
) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
if (hasUnrepresentableComposedAdditionalProperties(schema)) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
const normalizedProps: Record<string, JsonSchema> = {};
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
const res = normalizeSchemaNode(value, [...path, key]);
|
||||
if (compositionBranch && !shouldNormalizeAllOfBranch(value)) {
|
||||
normalizedProps[key] = value;
|
||||
if (!hasOnlySupportedConstraintKeywords(value)) {
|
||||
unsupported.add(pathKey([...path, key]) || "<root>");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const res = normalizeSchemaNode(value, [...path, key], compositionBranch);
|
||||
if (res.schema) {
|
||||
normalizedProps[key] = res.schema;
|
||||
}
|
||||
@@ -91,6 +361,19 @@ function normalizeSchemaNode(
|
||||
}
|
||||
normalized.properties = normalizedProps;
|
||||
|
||||
if (schema.allOf) {
|
||||
for (const key of objectPropertyKeys(schema)) {
|
||||
const effectiveSchema = objectPropertySchema(schema, key);
|
||||
if (!effectiveSchema) {
|
||||
continue;
|
||||
}
|
||||
const result = normalizeSchemaNode(effectiveSchema, [...path, key]);
|
||||
for (const unsupportedPath of result.unsupportedPaths) {
|
||||
unsupported.add(unsupportedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.additionalProperties === true) {
|
||||
// Treat `true` as an untyped map schema so dynamic object keys can still be edited.
|
||||
normalized.additionalProperties = {};
|
||||
@@ -98,30 +381,96 @@ function normalizeSchemaNode(
|
||||
normalized.additionalProperties = false;
|
||||
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
if (!isAnySchema(schema.additionalProperties)) {
|
||||
const res = normalizeSchemaNode(schema.additionalProperties, [...path, "*"]);
|
||||
const res = normalizeSchemaNode(
|
||||
schema.additionalProperties,
|
||||
[...path, "*"],
|
||||
compositionBranch,
|
||||
);
|
||||
normalized.additionalProperties = res.schema ?? schema.additionalProperties;
|
||||
if (res.unsupportedPaths.length > 0) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type === "array") {
|
||||
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
||||
if (!itemsSchema) {
|
||||
} else if (type === "array" && (!inheritedCompositionOnly || hasLocalArrayStructure)) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
const normalizedItems: JsonSchema[] = [];
|
||||
for (let index = 0; index < schema.items.length; index += 1) {
|
||||
const itemSchema = schema.items[index];
|
||||
if (!itemSchema) {
|
||||
unsupported.add(pathLabel);
|
||||
continue;
|
||||
}
|
||||
if (compositionBranch && !shouldNormalizeAllOfBranch(itemSchema)) {
|
||||
normalizedItems.push(itemSchema);
|
||||
if (!hasOnlySupportedConstraintKeywords(itemSchema)) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const result = normalizeSchemaNode(itemSchema, [...path, index], compositionBranch);
|
||||
normalizedItems.push(result.schema ?? itemSchema);
|
||||
for (const unsupportedPath of result.unsupportedPaths) {
|
||||
unsupported.add(unsupportedPath);
|
||||
}
|
||||
}
|
||||
normalized.items = normalizedItems;
|
||||
if (schema.additionalItems && typeof schema.additionalItems === "object") {
|
||||
if (compositionBranch && !shouldNormalizeAllOfBranch(schema.additionalItems)) {
|
||||
normalized.additionalItems = schema.additionalItems;
|
||||
if (!hasOnlySupportedConstraintKeywords(schema.additionalItems)) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
} else {
|
||||
const result = normalizeSchemaNode(
|
||||
schema.additionalItems,
|
||||
[...path, "*"],
|
||||
compositionBranch,
|
||||
);
|
||||
normalized.additionalItems = result.schema ?? schema.additionalItems;
|
||||
for (const unsupportedPath of result.unsupportedPaths) {
|
||||
unsupported.add(unsupportedPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized.additionalItems = schema.additionalItems;
|
||||
}
|
||||
} else if (!schema.items) {
|
||||
unsupported.add(pathLabel);
|
||||
} else {
|
||||
const res = normalizeSchemaNode(itemsSchema, [...path, "*"]);
|
||||
normalized.items = res.schema ?? itemsSchema;
|
||||
if (res.unsupportedPaths.length > 0) {
|
||||
unsupported.add(pathLabel);
|
||||
if (compositionBranch && !shouldNormalizeAllOfBranch(schema.items)) {
|
||||
normalized.items = schema.items;
|
||||
if (!hasOnlySupportedConstraintKeywords(schema.items)) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
} else {
|
||||
const res = normalizeSchemaNode(schema.items, [...path, "*"], compositionBranch);
|
||||
normalized.items = res.schema ?? schema.items;
|
||||
if (res.unsupportedPaths.length > 0) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (schema.allOf) {
|
||||
for (const index of effectiveArrayItemIndexes(schema)) {
|
||||
const effectiveSchema = arrayItemSchema(schema, index);
|
||||
if (!effectiveSchema) {
|
||||
continue;
|
||||
}
|
||||
const result = normalizeSchemaNode(effectiveSchema, [...path, index]);
|
||||
for (const unsupportedPath of result.unsupportedPaths) {
|
||||
unsupported.add(unsupportedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
!(inheritedCompositionOnly && (type === "object" || type === "array")) &&
|
||||
type !== "string" &&
|
||||
type !== "number" &&
|
||||
type !== "integer" &&
|
||||
type !== "boolean" &&
|
||||
!normalized.enum
|
||||
!normalized.enum &&
|
||||
!(compositionBranch && schema.allOf)
|
||||
) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
@@ -193,6 +542,8 @@ function normalizeUnion(
|
||||
schema: JsonSchema,
|
||||
path: Array<string | number>,
|
||||
): ConfigSchemaAnalysis | null {
|
||||
// Union normalization replaces the composition keywords, so mixed allOf schemas
|
||||
// must stay unsupported instead of silently losing sibling restrictions.
|
||||
if (schema.allOf) {
|
||||
return null;
|
||||
}
|
||||
@@ -231,6 +582,7 @@ function normalizeUnion(
|
||||
}
|
||||
remaining.push(entry);
|
||||
}
|
||||
nullable = nullable && schemaAllowsNull(schema);
|
||||
|
||||
// Config secrets accept either a raw key string or a structured secret ref object.
|
||||
// The form only supports editing the string path for now.
|
||||
@@ -245,6 +597,7 @@ function normalizeUnion(
|
||||
...schema,
|
||||
enum: uniqueValues(literals),
|
||||
nullable,
|
||||
enumIncludesNull: nullable,
|
||||
anyOf: undefined,
|
||||
oneOf: undefined,
|
||||
allOf: undefined,
|
||||
|
||||
456
ui/src/components/config-form.constraints.test.ts
Normal file
456
ui/src/components/config-form.constraints.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
arrayInputConstraints,
|
||||
canApplyArrayCandidate,
|
||||
coerceConfigFormNumberString,
|
||||
configValuesEqual,
|
||||
defaultValue,
|
||||
isSupportedConfigValueValid,
|
||||
NO_SAFE_DEFAULT,
|
||||
normalizeNumericValue,
|
||||
numericInputConstraints,
|
||||
objectPropertyKeys,
|
||||
objectPropertySchema,
|
||||
requiredPropertyKeys,
|
||||
} from "./config-form.constraints.ts";
|
||||
import type { JsonSchema } from "./config-form.shared.ts";
|
||||
import { decimalRational } from "./config-form.validation.ts";
|
||||
|
||||
describe("config form schema constraints", () => {
|
||||
it("coerces only decimal and scientific config number spellings", () => {
|
||||
expect(coerceConfigFormNumberString("42.5", false)).toBe(42.5);
|
||||
expect(coerceConfigFormNumberString(".5e2", false)).toBe(50);
|
||||
expect(coerceConfigFormNumberString("-2.5E-3", false)).toBe(-0.0025);
|
||||
expect(coerceConfigFormNumberString("1e5", true)).toBe(100_000);
|
||||
expect(coerceConfigFormNumberString("", false)).toBeUndefined();
|
||||
|
||||
for (const spelling of [
|
||||
"0x10",
|
||||
"0b1010",
|
||||
"0o17",
|
||||
"+5",
|
||||
"1_000",
|
||||
"Infinity",
|
||||
"NaN",
|
||||
"1e",
|
||||
"e5",
|
||||
]) {
|
||||
expect(coerceConfigFormNumberString(spelling, false)).toBe(spelling);
|
||||
}
|
||||
expect(coerceConfigFormNumberString("42.5", true)).toBe("42.5");
|
||||
});
|
||||
|
||||
it("rejects non-finite decimal rationals and schema multiples", () => {
|
||||
expect(decimalRational(Number.NaN)).toBeUndefined();
|
||||
expect(decimalRational(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
expect(numericInputConstraints({ type: "number", multipleOf: Number.NaN }).step).toBe("any");
|
||||
expect(
|
||||
numericInputConstraints({ type: "integer", multipleOf: Number.POSITIVE_INFINITY }).step,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("aligns native numeric bounds to JSON Schema multiples", () => {
|
||||
const schema = {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 9,
|
||||
multipleOf: 2,
|
||||
};
|
||||
expect(numericInputConstraints(schema)).toMatchObject({ min: 2, max: 8, step: 2 });
|
||||
expect(normalizeNumericValue(9, schema)).toBe(8);
|
||||
expect(defaultValue(schema)).toBe(2);
|
||||
});
|
||||
|
||||
it("does not let large-number tolerance cross a whole step", () => {
|
||||
const schema = {
|
||||
type: "number",
|
||||
minimum: 10_000_000_000_000_000,
|
||||
multipleOf: 3,
|
||||
};
|
||||
expect(numericInputConstraints(schema).min).toBe(10_000_000_000_000_002);
|
||||
expect(defaultValue(schema)).toBe(10_000_000_000_000_002);
|
||||
expect(
|
||||
isSupportedConfigValueValid({ type: "number", multipleOf: 3 }, 10_000_000_000_000_000),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSupportedConfigValueValid({ type: "number", multipleOf: 3 }, 10_000_000_000_000_002),
|
||||
).toBe(true);
|
||||
|
||||
const decimalSchema = {
|
||||
type: "number",
|
||||
minimum: 10_000_000_000_000_002,
|
||||
multipleOf: 10,
|
||||
};
|
||||
expect(numericInputConstraints(decimalSchema).min).toBe(10_000_000_000_000_010);
|
||||
expect(defaultValue(decimalSchema)).toBe(10_000_000_000_000_010);
|
||||
expect(
|
||||
isSupportedConfigValueValid({ type: "number", multipleOf: 10 }, 10_000_000_000_000_002),
|
||||
).toBe(false);
|
||||
expect(isSupportedConfigValueValid({ type: "number", multipleOf: 0.1 }, 0.3)).toBe(true);
|
||||
expect(isSupportedConfigValueValid({ type: "number", multipleOf: 0.1 }, 0.2 + 0.1)).toBe(false);
|
||||
expect(
|
||||
numericInputConstraints({ type: "number", minimum: -10, maximum: -10, multipleOf: 3 }),
|
||||
).toMatchObject({ min: -9, max: -12 });
|
||||
expect(defaultValue({ type: "number", minimum: -10, maximum: -10, multipleOf: 3 })).toBe(
|
||||
NO_SAFE_DEFAULT,
|
||||
);
|
||||
});
|
||||
|
||||
it("converts exclusive integer bounds into reachable native bounds", () => {
|
||||
const schema = {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 2,
|
||||
exclusiveMaximum: 8,
|
||||
};
|
||||
expect(numericInputConstraints(schema)).toMatchObject({
|
||||
min: 3,
|
||||
max: 7,
|
||||
exclusiveMin: 2,
|
||||
exclusiveMax: 8,
|
||||
step: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves decimal step precision", () => {
|
||||
const schema = {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 0.3,
|
||||
multipleOf: 0.1,
|
||||
};
|
||||
expect(normalizeNumericValue(0.2 + 0.1, schema)).toBe(0.3);
|
||||
expect(
|
||||
normalizeNumericValue(3 * 1.5e-7, {
|
||||
type: "number",
|
||||
maximum: 6e-7,
|
||||
multipleOf: 1.5e-7,
|
||||
}),
|
||||
).toBe(4.5e-7);
|
||||
});
|
||||
|
||||
it("derives integer-compatible steps from fractional multiples", () => {
|
||||
expect(numericInputConstraints({ type: "integer", multipleOf: 0.5 }).step).toBe(1);
|
||||
expect(numericInputConstraints({ type: "integer", multipleOf: 2.5 }).step).toBe(5);
|
||||
expect(normalizeNumericValue(3, { type: "integer", multipleOf: 2.5 })).toBe(5);
|
||||
});
|
||||
|
||||
it("derives valid defaults for exclusive unconstrained bounds", () => {
|
||||
const schema = {
|
||||
type: "number",
|
||||
exclusiveMinimum: 0,
|
||||
};
|
||||
expect(numericInputConstraints(schema)).toMatchObject({
|
||||
min: 0,
|
||||
exclusiveMin: 0,
|
||||
step: "any",
|
||||
});
|
||||
expect(defaultValue(schema)).toBe(1);
|
||||
});
|
||||
|
||||
it("normalizes one-sided exclusive bounds without jumping across the range", () => {
|
||||
const belowMaximum = normalizeNumericValue(10, {
|
||||
type: "number",
|
||||
exclusiveMaximum: 10,
|
||||
});
|
||||
const aboveMinimum = normalizeNumericValue(10, {
|
||||
type: "number",
|
||||
exclusiveMinimum: 10,
|
||||
});
|
||||
|
||||
expect(belowMaximum).toBeLessThan(10);
|
||||
expect(belowMaximum).toBeGreaterThan(9);
|
||||
expect(aboveMinimum).toBeGreaterThan(10);
|
||||
expect(aboveMinimum).toBeLessThan(11);
|
||||
});
|
||||
|
||||
it("derives numeric controls from intersected allOf constraints", () => {
|
||||
const schema = {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
allOf: [{ maximum: 13 }, { multipleOf: 2 }, { multipleOf: 3 }],
|
||||
};
|
||||
expect(numericInputConstraints(schema)).toMatchObject({ min: 6, max: 12, step: 6 });
|
||||
expect(normalizeNumericValue(7, schema)).toBe(6);
|
||||
expect(
|
||||
numericInputConstraints({
|
||||
type: "number",
|
||||
allOf: [{ type: ["integer", "number"] }],
|
||||
}).step,
|
||||
).toBe("any");
|
||||
expect(
|
||||
normalizeNumericValue(1.5, {
|
||||
type: "number",
|
||||
allOf: [{ type: ["integer", "number"] }],
|
||||
}),
|
||||
).toBe(1.5);
|
||||
});
|
||||
|
||||
it("derives array controls from intersected allOf constraints", () => {
|
||||
expect(
|
||||
arrayInputConstraints({
|
||||
type: "array",
|
||||
allOf: [
|
||||
{ minItems: 1, maxItems: 4 },
|
||||
{ minItems: 2, maxItems: 3, uniqueItems: true },
|
||||
],
|
||||
}),
|
||||
).toEqual({ minItems: 2, maxItems: 3, uniqueItems: true });
|
||||
expect(
|
||||
arrayInputConstraints({
|
||||
type: "array",
|
||||
items: [{ type: "string" }, { type: "number" }],
|
||||
additionalItems: false,
|
||||
}),
|
||||
).toEqual({ minItems: 0, maxItems: 2, uniqueItems: false });
|
||||
|
||||
let deeplyNested: JsonSchema = { maxItems: 0 };
|
||||
for (let depth = 0; depth < 40; depth += 1) {
|
||||
deeplyNested = { allOf: [deeplyNested] };
|
||||
}
|
||||
expect(
|
||||
arrayInputConstraints({
|
||||
type: "array",
|
||||
allOf: [deeplyNested],
|
||||
}).maxItems,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("allows removals that move arrays toward whole-array constraints", () => {
|
||||
const schema = {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
const: ["a"],
|
||||
} satisfies JsonSchema;
|
||||
|
||||
expect(canApplyArrayCandidate(schema, ["a", "x", "y"], ["a", "x"], false, false)).toBe(true);
|
||||
expect(canApplyArrayCandidate(schema, ["a", "x"], ["a"], false, false)).toBe(true);
|
||||
expect(canApplyArrayCandidate(schema, ["a", "x", "y"], ["x", "y"], false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("validates and compares deeply nested config values without depth cutoffs", () => {
|
||||
let deeplyComposed: JsonSchema = { type: "string", minLength: 2 };
|
||||
for (let depth = 0; depth < 40; depth += 1) {
|
||||
deeplyComposed = { allOf: [deeplyComposed] };
|
||||
}
|
||||
expect(isSupportedConfigValueValid(deeplyComposed, "ok")).toBe(true);
|
||||
expect(isSupportedConfigValueValid(deeplyComposed, "x")).toBe(false);
|
||||
expect(isSupportedConfigValueValid({ type: "string", nullable: true }, null)).toBe(true);
|
||||
expect(
|
||||
isSupportedConfigValueValid({ type: "string", nullable: true, const: "fixed" }, null),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSupportedConfigValueValid(
|
||||
{ nullable: true, allOf: [{ type: "string", const: "fixed" }] },
|
||||
null,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isSupportedConfigValueValid({ nullable: true, enum: ["fixed"] }, null)).toBe(false);
|
||||
expect(
|
||||
isSupportedConfigValueValid(
|
||||
{ nullable: true, enum: ["fixed"], enumIncludesNull: true },
|
||||
null,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(defaultValue({ type: "string", nullable: true, default: null })).toBeNull();
|
||||
|
||||
let deeplyNested: Record<string, unknown> = { value: "same" };
|
||||
for (let depth = 0; depth < 40; depth += 1) {
|
||||
deeplyNested = { child: deeplyNested };
|
||||
}
|
||||
const duplicate = structuredClone(deeplyNested);
|
||||
expect(configValuesEqual(deeplyNested, duplicate)).toBe(true);
|
||||
expect(
|
||||
isSupportedConfigValueValid(
|
||||
{
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
},
|
||||
[deeplyNested, duplicate],
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const selfCycle: Record<string, unknown> = {};
|
||||
selfCycle.next = selfCycle;
|
||||
const first: Record<string, unknown> = {};
|
||||
const second: Record<string, unknown> = {};
|
||||
first.next = second;
|
||||
second.next = first;
|
||||
expect(configValuesEqual(selfCycle, selfCycle)).toBe(false);
|
||||
expect(configValuesEqual(selfCycle, first)).toBe(false);
|
||||
});
|
||||
|
||||
it("collects required object properties from nested allOf schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
required: ["direct"],
|
||||
properties: { count: { type: "integer" } },
|
||||
allOf: [
|
||||
{
|
||||
required: ["composed"],
|
||||
properties: { count: { minimum: 2 } },
|
||||
allOf: [{ required: ["nested"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(requiredPropertyKeys(schema)).toEqual(new Set(["direct", "composed", "nested"]));
|
||||
const countSchema = objectPropertySchema(schema, "count");
|
||||
expect(countSchema).toBeDefined();
|
||||
expect(countSchema && isSupportedConfigValueValid(countSchema, 1)).toBe(false);
|
||||
expect(countSchema && isSupportedConfigValueValid(countSchema, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects branch-local additional-properties scopes for composed properties", () => {
|
||||
const forbidden = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
allOf: [{ properties: { mode: { type: "string" } } }],
|
||||
} satisfies JsonSchema;
|
||||
expect(objectPropertyKeys(forbidden)).toEqual([]);
|
||||
expect(objectPropertySchema(forbidden, "mode")).toBeUndefined();
|
||||
|
||||
const declared = {
|
||||
type: "object",
|
||||
properties: { mode: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
allOf: [{ properties: { mode: { minLength: 2 } } }],
|
||||
} satisfies JsonSchema;
|
||||
expect(objectPropertyKeys(declared)).toEqual(["mode"]);
|
||||
expect(objectPropertySchema(declared, "mode")).toMatchObject({
|
||||
type: "string",
|
||||
allOf: [{ minLength: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("derives only defaults that the schema can prove", () => {
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "object",
|
||||
required: ["mode", "weights"],
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["safe", "fast"] },
|
||||
weights: {
|
||||
type: "array",
|
||||
minItems: 2,
|
||||
items: { type: "integer", minimum: 2 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ mode: "safe", weights: [2, 2] });
|
||||
expect(defaultValue({ type: "string", minLength: 3 })).toBe("xxx");
|
||||
expect(defaultValue({ type: "string", minLength: 3, pattern: "^[0-9]+$" })).toBe(
|
||||
NO_SAFE_DEFAULT,
|
||||
);
|
||||
expect(defaultValue({ type: "string", enum: ["x", ""], pattern: "^$" })).toBe("");
|
||||
expect(defaultValue({ type: "integer", enum: [1, 4], minimum: 2, multipleOf: 2 })).toBe(4);
|
||||
expect(defaultValue({ type: "string", enum: ["x"], pattern: "^$" })).toBe(NO_SAFE_DEFAULT);
|
||||
expect(defaultValue({ type: "null" })).toBeNull();
|
||||
expect(defaultValue({ type: "string", allOf: [{ minLength: 3 }] })).toBe(NO_SAFE_DEFAULT);
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
allOf: [{ const: ["a", "b"] }],
|
||||
}),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(defaultValue({ type: "integer", default: 3, multipleOf: 2 })).toBe(NO_SAFE_DEFAULT);
|
||||
expect(defaultValue({ type: "string", minLength: 1_000_000_000 })).toBe(NO_SAFE_DEFAULT);
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "array",
|
||||
minItems: 3,
|
||||
items: [{ type: "string" }, { type: "integer", minimum: 2 }],
|
||||
additionalItems: { type: "boolean" },
|
||||
}),
|
||||
).toEqual(["", 2, false]);
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "array",
|
||||
minItems: 3,
|
||||
items: [{ type: "string" }, { type: "integer" }],
|
||||
additionalItems: false,
|
||||
}),
|
||||
).toBe(NO_SAFE_DEFAULT);
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "array",
|
||||
minItems: 2,
|
||||
uniqueItems: true,
|
||||
items: { type: "string" },
|
||||
}),
|
||||
).toBe(NO_SAFE_DEFAULT);
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "array",
|
||||
minItems: 101,
|
||||
items: { type: "string" },
|
||||
}),
|
||||
).toBe(NO_SAFE_DEFAULT);
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "array",
|
||||
minItems: 101,
|
||||
items: [{ type: "string" }],
|
||||
additionalItems: { type: "string" },
|
||||
}),
|
||||
).toBe(NO_SAFE_DEFAULT);
|
||||
expect(
|
||||
isSupportedConfigValueValid(
|
||||
{
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{ constructor: "inherited-name" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
defaultValue({
|
||||
type: "object",
|
||||
required: ["constructor"],
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
).toBe(NO_SAFE_DEFAULT);
|
||||
});
|
||||
|
||||
it("clones mutable schema defaults and repeated derived defaults", () => {
|
||||
const expectIndependentCandidates = (
|
||||
schema: JsonSchema,
|
||||
source: { nested: { enabled: boolean } },
|
||||
) => {
|
||||
const first = defaultValue(schema) as typeof source;
|
||||
const second = defaultValue(schema) as typeof source;
|
||||
|
||||
expect(first).not.toBe(source);
|
||||
expect(second).not.toBe(first);
|
||||
first.nested.enabled = false;
|
||||
expect(second.nested.enabled).toBe(true);
|
||||
expect(source.nested.enabled).toBe(true);
|
||||
};
|
||||
const explicitDefault = { nested: { enabled: true } };
|
||||
expectIndependentCandidates({ type: "object", default: explicitDefault }, explicitDefault);
|
||||
const constant = { nested: { enabled: true } };
|
||||
expectIndependentCandidates({ type: "object", const: constant }, constant);
|
||||
const enumerated = { nested: { enabled: true } };
|
||||
expectIndependentCandidates({ type: "object", enum: [enumerated] }, enumerated);
|
||||
|
||||
const repeated = defaultValue({
|
||||
type: "array",
|
||||
minItems: 2,
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["tags"],
|
||||
properties: {
|
||||
tags: { type: "array", default: ["primary"] },
|
||||
},
|
||||
},
|
||||
}) as Array<{ tags: string[] }>;
|
||||
|
||||
expect(repeated).toHaveLength(2);
|
||||
expect(repeated[0]).not.toBe(repeated[1]);
|
||||
expect(repeated[0]?.tags).not.toBe(repeated[1]?.tags);
|
||||
repeated[0]?.tags.push("changed");
|
||||
expect(repeated[1]?.tags).toEqual(["primary"]);
|
||||
});
|
||||
});
|
||||
728
ui/src/components/config-form.constraints.ts
Normal file
728
ui/src/components/config-form.constraints.ts
Normal file
@@ -0,0 +1,728 @@
|
||||
// Control UI helpers derive native constraints and safe initial values from config schemas.
|
||||
import { schemaType, type JsonSchema } from "./config-form.shared.ts";
|
||||
import {
|
||||
configValuesEqual,
|
||||
decimalRational,
|
||||
isSupportedConfigValueValid,
|
||||
ownPropertySchema,
|
||||
} from "./config-form.validation.ts";
|
||||
|
||||
export { configValuesEqual, isSupportedConfigValueValid } from "./config-form.validation.ts";
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
const CONFIG_FORM_DECIMAL_NUMBER_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
|
||||
|
||||
export function coerceConfigFormNumberString(
|
||||
value: string,
|
||||
integer: boolean,
|
||||
): number | undefined | string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (!CONFIG_FORM_DECIMAL_NUMBER_RE.test(trimmed)) {
|
||||
return value;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed) || (integer && !Number.isInteger(parsed))) {
|
||||
return value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function decimalPlaces(value: number): number {
|
||||
const text = String(value).toLowerCase();
|
||||
const [coefficient = "", exponentText] = text.split("e");
|
||||
const fractionLength = coefficient.split(".")[1]?.length ?? 0;
|
||||
const exponent = Number(exponentText ?? 0);
|
||||
return Math.max(0, fractionLength - exponent);
|
||||
}
|
||||
|
||||
function normalizePrecision(value: number, step: number | undefined): number {
|
||||
if (!step) {
|
||||
return value;
|
||||
}
|
||||
const places = decimalPlaces(step);
|
||||
return places <= 100 ? Number(value.toFixed(places)) : value;
|
||||
}
|
||||
|
||||
function greatestCommonDivisor(left: bigint, right: bigint): bigint {
|
||||
let a = left < 0n ? -left : left;
|
||||
let b = right < 0n ? -right : right;
|
||||
while (b !== 0n) {
|
||||
const remainder = a % b;
|
||||
a = b;
|
||||
b = remainder;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function leastCommonMultiple(left: bigint, right: bigint): bigint {
|
||||
return (left / greatestCommonDivisor(left, right)) * right;
|
||||
}
|
||||
|
||||
function integerCompatibleStep(multipleOf: number): number {
|
||||
const [coefficient = "", exponentText] = String(multipleOf).toLowerCase().split("e");
|
||||
const [whole = "0", fraction = ""] = coefficient.split(".");
|
||||
const exponent = Number(exponentText ?? 0);
|
||||
const digits = BigInt(`${whole}${fraction}`);
|
||||
const denominatorExponent = fraction.length - exponent;
|
||||
const numerator = denominatorExponent < 0 ? digits * 10n ** BigInt(-denominatorExponent) : digits;
|
||||
const denominator = denominatorExponent > 0 ? 10n ** BigInt(denominatorExponent) : 1n;
|
||||
const divisor = greatestCommonDivisor(numerator, denominator);
|
||||
const step = Number(numerator / divisor);
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
function alignToStep(value: number, step: number, direction: "ceil" | "floor" | "round"): number {
|
||||
const valueRational = decimalRational(value);
|
||||
const stepRational = decimalRational(step);
|
||||
if (!valueRational || !stepRational || stepRational.numerator === 0n) {
|
||||
return value;
|
||||
}
|
||||
const dividend = valueRational.numerator * stepRational.denominator;
|
||||
const divisor = valueRational.denominator * stepRational.numerator;
|
||||
const truncated = dividend / divisor;
|
||||
const remainder = dividend % divisor;
|
||||
const floor = remainder < 0n ? truncated - 1n : truncated;
|
||||
const aligned =
|
||||
direction === "floor"
|
||||
? floor
|
||||
: direction === "ceil"
|
||||
? remainder === 0n
|
||||
? truncated
|
||||
: remainder > 0n
|
||||
? truncated + 1n
|
||||
: truncated
|
||||
: (dividend - floor * divisor) * 2n < divisor
|
||||
? floor
|
||||
: floor + 1n;
|
||||
return normalizePrecision(Number(aligned) * step, step);
|
||||
}
|
||||
|
||||
type NumericInputConstraints = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
exclusiveMin?: number;
|
||||
exclusiveMax?: number;
|
||||
step: number | "any";
|
||||
};
|
||||
|
||||
type ArrayInputConstraints = {
|
||||
minItems: number;
|
||||
maxItems?: number;
|
||||
uniqueItems: boolean;
|
||||
};
|
||||
|
||||
type EffectiveNumericBound = {
|
||||
value?: number;
|
||||
exclusive: boolean;
|
||||
};
|
||||
|
||||
function collectAllOfSchemas(schema: JsonSchema): JsonSchema[] {
|
||||
const result: JsonSchema[] = [];
|
||||
const pending = [schema];
|
||||
const seen = new Set<JsonSchema>();
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current || seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
result.push(current);
|
||||
for (let index = (current.allOf?.length ?? 0) - 1; index >= 0; index -= 1) {
|
||||
const entry = current.allOf?.[index];
|
||||
if (entry) {
|
||||
pending.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function effectiveNumericBound(
|
||||
schemas: JsonSchema[],
|
||||
direction: "lower" | "upper",
|
||||
): EffectiveNumericBound {
|
||||
let value: number | undefined;
|
||||
let exclusive = false;
|
||||
for (const schema of schemas) {
|
||||
const inclusiveCandidate = finiteNumber(
|
||||
direction === "lower" ? schema.minimum : schema.maximum,
|
||||
);
|
||||
const exclusiveCandidate = finiteNumber(
|
||||
direction === "lower" ? schema.exclusiveMinimum : schema.exclusiveMaximum,
|
||||
);
|
||||
for (const [candidate, candidateExclusive] of [
|
||||
[inclusiveCandidate, false],
|
||||
[exclusiveCandidate, true],
|
||||
] as const) {
|
||||
if (
|
||||
candidate !== undefined &&
|
||||
(value === undefined ||
|
||||
(direction === "lower" ? candidate > value : candidate < value) ||
|
||||
(candidate === value && candidateExclusive && !exclusive))
|
||||
) {
|
||||
value = candidate;
|
||||
exclusive = candidateExclusive;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { value, exclusive };
|
||||
}
|
||||
|
||||
function combinedMultipleOf(schemas: JsonSchema[]): number | undefined {
|
||||
let numerator: bigint | undefined;
|
||||
let denominator: bigint | undefined;
|
||||
for (const schema of schemas) {
|
||||
const multipleOf = finiteNumber(schema.multipleOf);
|
||||
if (multipleOf === undefined || multipleOf <= 0) {
|
||||
continue;
|
||||
}
|
||||
const rational = decimalRational(multipleOf);
|
||||
if (!rational) {
|
||||
continue;
|
||||
}
|
||||
const divisor = greatestCommonDivisor(rational.numerator, rational.denominator);
|
||||
const nextNumerator = rational.numerator / divisor;
|
||||
const nextDenominator = rational.denominator / divisor;
|
||||
numerator =
|
||||
numerator === undefined ? nextNumerator : leastCommonMultiple(numerator, nextNumerator);
|
||||
denominator =
|
||||
denominator === undefined
|
||||
? nextDenominator
|
||||
: greatestCommonDivisor(denominator, nextDenominator);
|
||||
}
|
||||
if (numerator === undefined || denominator === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const combined = Number(numerator) / Number(denominator);
|
||||
return Number.isFinite(combined) && combined > 0 ? combined : undefined;
|
||||
}
|
||||
|
||||
export function arrayInputConstraints(schema: JsonSchema): ArrayInputConstraints {
|
||||
const schemas = collectAllOfSchemas(schema);
|
||||
let minItems = 0;
|
||||
let maxItems: number | undefined;
|
||||
let uniqueItems = false;
|
||||
for (const entry of schemas) {
|
||||
if (
|
||||
Number.isSafeInteger(entry.minItems) &&
|
||||
entry.minItems !== undefined &&
|
||||
entry.minItems >= 0
|
||||
) {
|
||||
minItems = Math.max(minItems, entry.minItems);
|
||||
}
|
||||
if (
|
||||
Number.isSafeInteger(entry.maxItems) &&
|
||||
entry.maxItems !== undefined &&
|
||||
entry.maxItems >= 0
|
||||
) {
|
||||
maxItems = maxItems === undefined ? entry.maxItems : Math.min(maxItems, entry.maxItems);
|
||||
}
|
||||
if (Array.isArray(entry.items) && entry.additionalItems === false) {
|
||||
maxItems = Math.min(maxItems ?? Number.POSITIVE_INFINITY, entry.items.length);
|
||||
}
|
||||
uniqueItems ||= entry.uniqueItems === true;
|
||||
}
|
||||
return { minItems, maxItems, uniqueItems };
|
||||
}
|
||||
|
||||
export function requiredPropertyKeys(schema: JsonSchema): Set<string> {
|
||||
return new Set(collectAllOfSchemas(schema).flatMap((entry) => entry.required ?? []));
|
||||
}
|
||||
|
||||
export function objectPropertyKeys(schema: JsonSchema): string[] {
|
||||
const schemas = collectAllOfSchemas(schema);
|
||||
const keys = new Set<string>();
|
||||
for (const entry of schemas) {
|
||||
for (const key of Object.keys(entry.properties ?? {})) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys].filter((key) =>
|
||||
schemas.every(
|
||||
(entry) =>
|
||||
ownPropertySchema(entry, key) !== undefined || entry.additionalProperties !== false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function combinedSchema(candidates: JsonSchema[]): JsonSchema | undefined {
|
||||
const base = candidates.find((candidate) => schemaType(candidate) !== undefined) ?? candidates[0];
|
||||
return !base || candidates.length === 1
|
||||
? base
|
||||
: {
|
||||
...base,
|
||||
allOf: [...(base.allOf ?? []), ...candidates.filter((candidate) => candidate !== base)],
|
||||
};
|
||||
}
|
||||
|
||||
export function objectAdditionalPropertiesSchema(
|
||||
schema: JsonSchema,
|
||||
): JsonSchema | false | undefined {
|
||||
const policies = collectAllOfSchemas(schema)
|
||||
.map((entry) => entry.additionalProperties)
|
||||
.filter((policy) => policy !== undefined);
|
||||
if (policies.some((policy) => policy === false)) {
|
||||
return false;
|
||||
}
|
||||
const schemas = policies.filter(
|
||||
(policy): policy is JsonSchema => Boolean(policy) && typeof policy === "object",
|
||||
);
|
||||
if (schemas.length > 0) {
|
||||
return combinedSchema(schemas);
|
||||
}
|
||||
return policies.some((policy) => policy === true) ? {} : undefined;
|
||||
}
|
||||
|
||||
function objectRepairIssueCount(schema: JsonSchema, value: Record<string, unknown>): number {
|
||||
let issues = isSupportedConfigValueValid(schema, value) ? 0 : 1;
|
||||
const knownKeys = new Set(objectPropertyKeys(schema));
|
||||
for (const key of requiredPropertyKeys(schema)) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
const additionalProperties = objectAdditionalPropertiesSchema(schema);
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
const propertySchema = objectPropertySchema(schema, key);
|
||||
if (propertySchema) {
|
||||
if (!isSupportedConfigValueValid(propertySchema, entryValue)) {
|
||||
issues += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!knownKeys.has(key) &&
|
||||
(additionalProperties === false ||
|
||||
additionalProperties === undefined ||
|
||||
!isSupportedConfigValueValid(additionalProperties, entryValue))
|
||||
) {
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function canApplyObjectCandidate(
|
||||
schema: JsonSchema,
|
||||
current: Record<string, unknown>,
|
||||
candidate: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (isSupportedConfigValueValid(schema, candidate)) {
|
||||
return true;
|
||||
}
|
||||
if (isSupportedConfigValueValid(schema, current)) {
|
||||
return false;
|
||||
}
|
||||
return objectRepairIssueCount(schema, candidate) <= objectRepairIssueCount(schema, current);
|
||||
}
|
||||
|
||||
export function objectPropertySchema(schema: JsonSchema, key: string): JsonSchema | undefined {
|
||||
if (!objectPropertyKeys(schema).includes(key)) {
|
||||
return undefined;
|
||||
}
|
||||
return combinedSchema(
|
||||
collectAllOfSchemas(schema)
|
||||
.map((entry) => ownPropertySchema(entry, key))
|
||||
.filter((property): property is JsonSchema => property !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function arrayItemSchema(schema: JsonSchema, index: number): JsonSchema | undefined {
|
||||
const candidates: JsonSchema[] = [];
|
||||
for (const entry of collectAllOfSchemas(schema)) {
|
||||
if (Array.isArray(entry.items)) {
|
||||
const item =
|
||||
entry.items[index] ??
|
||||
(entry.additionalItems && typeof entry.additionalItems === "object"
|
||||
? entry.additionalItems
|
||||
: undefined);
|
||||
if (item) {
|
||||
candidates.push(item);
|
||||
}
|
||||
} else if (entry.items) {
|
||||
candidates.push(entry.items);
|
||||
}
|
||||
}
|
||||
return combinedSchema(candidates);
|
||||
}
|
||||
|
||||
export function arrayConstraintCandidates(
|
||||
schema: JsonSchema,
|
||||
seen = new Set<JsonSchema>(),
|
||||
): unknown[][] {
|
||||
if (seen.has(schema)) {
|
||||
return [];
|
||||
}
|
||||
seen.add(schema);
|
||||
const candidates: unknown[][] = [];
|
||||
if (Array.isArray(schema.const)) {
|
||||
candidates.push(schema.const);
|
||||
}
|
||||
for (const entry of schema.enum ?? []) {
|
||||
if (Array.isArray(entry)) {
|
||||
candidates.push(entry);
|
||||
}
|
||||
}
|
||||
for (const entry of [...(schema.allOf ?? []), ...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]) {
|
||||
candidates.push(...arrayConstraintCandidates(entry, seen));
|
||||
}
|
||||
seen.delete(schema);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function arrayCandidateDistance(value: readonly unknown[], candidate: readonly unknown[]): number {
|
||||
let distance = Math.abs(value.length - candidate.length);
|
||||
const sharedLength = Math.min(value.length, candidate.length);
|
||||
for (let index = 0; index < sharedLength; index += 1) {
|
||||
if (!configValuesEqual(value[index], candidate[index])) {
|
||||
distance += 1;
|
||||
}
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
function arrayRepairIssueCount(
|
||||
schema: JsonSchema,
|
||||
value: readonly unknown[],
|
||||
uniqueItems: boolean,
|
||||
): number {
|
||||
const { minItems, maxItems } = arrayInputConstraints(schema);
|
||||
let issues = Math.max(0, minItems - value.length);
|
||||
const constrainedDistances = arrayConstraintCandidates(schema)
|
||||
.filter((candidate) => isSupportedConfigValueValid(schema, candidate))
|
||||
.map((candidate) => arrayCandidateDistance(value, candidate));
|
||||
if (constrainedDistances.length > 0) {
|
||||
issues += Math.min(...constrainedDistances);
|
||||
}
|
||||
if (maxItems !== undefined) {
|
||||
issues += Math.max(0, value.length - maxItems);
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const itemSchema = arrayItemSchema(schema, index);
|
||||
if (itemSchema && !isSupportedConfigValueValid(itemSchema, value[index])) {
|
||||
issues += 1;
|
||||
}
|
||||
if (
|
||||
uniqueItems &&
|
||||
value.slice(index + 1).some((candidate) => configValuesEqual(value[index], candidate))
|
||||
) {
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function canApplyArrayCandidate(
|
||||
schema: JsonSchema,
|
||||
current: readonly unknown[],
|
||||
candidate: readonly unknown[],
|
||||
uniqueItems: boolean,
|
||||
allowEqualRepair: boolean,
|
||||
): boolean {
|
||||
if (isSupportedConfigValueValid(schema, candidate)) {
|
||||
return true;
|
||||
}
|
||||
if (isSupportedConfigValueValid(schema, current)) {
|
||||
return false;
|
||||
}
|
||||
const currentIssues = arrayRepairIssueCount(schema, current, uniqueItems);
|
||||
const candidateIssues = arrayRepairIssueCount(schema, candidate, uniqueItems);
|
||||
return allowEqualRepair ? candidateIssues <= currentIssues : candidateIssues < currentIssues;
|
||||
}
|
||||
|
||||
export function numericInputConstraints(schema: JsonSchema): NumericInputConstraints {
|
||||
const schemas = collectAllOfSchemas(schema);
|
||||
const numericTypes = new Set(
|
||||
schemas.flatMap((entry) => {
|
||||
const declaredTypes = Array.isArray(entry.type) ? entry.type : entry.type ? [entry.type] : [];
|
||||
return declaredTypes.includes("number")
|
||||
? ["number"]
|
||||
: declaredTypes.includes("integer")
|
||||
? ["integer"]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const type = numericTypes.has("integer")
|
||||
? "integer"
|
||||
: numericTypes.has("number")
|
||||
? "number"
|
||||
: schemaType(schema);
|
||||
const multipleOf = combinedMultipleOf(schemas);
|
||||
const numericStep =
|
||||
type === "integer"
|
||||
? multipleOf && multipleOf > 0
|
||||
? integerCompatibleStep(multipleOf)
|
||||
: 1
|
||||
: multipleOf && multipleOf > 0
|
||||
? multipleOf
|
||||
: undefined;
|
||||
const lowerBound = effectiveNumericBound(schemas, "lower");
|
||||
const upperBound = effectiveNumericBound(schemas, "upper");
|
||||
const rawMinimum = lowerBound.exclusive ? undefined : lowerBound.value;
|
||||
const rawMaximum = upperBound.exclusive ? undefined : upperBound.value;
|
||||
const exclusiveMinimum = lowerBound.exclusive ? lowerBound.value : undefined;
|
||||
const exclusiveMaximum = upperBound.exclusive ? upperBound.value : undefined;
|
||||
|
||||
let min = rawMinimum ?? exclusiveMinimum;
|
||||
let max = rawMaximum ?? exclusiveMaximum;
|
||||
if (numericStep) {
|
||||
if (min !== undefined) {
|
||||
min = alignToStep(min, numericStep, "ceil");
|
||||
}
|
||||
if (max !== undefined) {
|
||||
max = alignToStep(max, numericStep, "floor");
|
||||
}
|
||||
if (exclusiveMinimum !== undefined) {
|
||||
const aligned = alignToStep(exclusiveMinimum, numericStep, "ceil");
|
||||
const exclusiveAligned =
|
||||
aligned <= exclusiveMinimum
|
||||
? normalizePrecision(aligned + numericStep, numericStep)
|
||||
: aligned;
|
||||
min = min === undefined ? exclusiveAligned : Math.max(min, exclusiveAligned);
|
||||
}
|
||||
if (exclusiveMaximum !== undefined) {
|
||||
const aligned = alignToStep(exclusiveMaximum, numericStep, "floor");
|
||||
const exclusiveAligned =
|
||||
aligned >= exclusiveMaximum
|
||||
? normalizePrecision(aligned - numericStep, numericStep)
|
||||
: aligned;
|
||||
max = max === undefined ? exclusiveAligned : Math.min(max, exclusiveAligned);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
exclusiveMin: exclusiveMinimum,
|
||||
exclusiveMax: exclusiveMaximum,
|
||||
step: numericStep ?? "any",
|
||||
};
|
||||
}
|
||||
|
||||
function nextRepresentable(value: number, direction: 1 | -1): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value === 0) {
|
||||
return direction > 0 ? Number.MIN_VALUE : -Number.MIN_VALUE;
|
||||
}
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new DataView(buffer);
|
||||
view.setFloat64(0, value);
|
||||
const bits = view.getBigUint64(0);
|
||||
const nextBits = value > 0 === direction > 0 ? bits + 1n : bits - 1n;
|
||||
view.setBigUint64(0, nextBits);
|
||||
return view.getFloat64(0);
|
||||
}
|
||||
|
||||
function nextUsableNumber(value: number, direction: 1 | -1, opposite?: number): number {
|
||||
if (opposite !== undefined && Number.isFinite(opposite)) {
|
||||
const midpoint = value + (opposite - value) / 2;
|
||||
if ((direction > 0 && midpoint > value) || (direction < 0 && midpoint < value)) {
|
||||
return midpoint;
|
||||
}
|
||||
}
|
||||
const offset = Math.max(1, Math.abs(value));
|
||||
const candidate = value + direction * offset;
|
||||
if (Number.isFinite(candidate) && candidate !== value) {
|
||||
return candidate;
|
||||
}
|
||||
return nextRepresentable(value, direction);
|
||||
}
|
||||
|
||||
export function normalizeNumericValue(value: number, schema: JsonSchema): number {
|
||||
const constraints = numericInputConstraints(schema);
|
||||
let normalized = value;
|
||||
if (typeof constraints.step === "number") {
|
||||
normalized = alignToStep(normalized, constraints.step, "round");
|
||||
}
|
||||
if (constraints.min !== undefined) {
|
||||
normalized = Math.max(constraints.min, normalized);
|
||||
}
|
||||
if (constraints.max !== undefined) {
|
||||
normalized = Math.min(constraints.max, normalized);
|
||||
}
|
||||
if (constraints.exclusiveMin !== undefined && normalized <= constraints.exclusiveMin) {
|
||||
normalized =
|
||||
typeof constraints.step === "number"
|
||||
? nextRepresentable(constraints.exclusiveMin, 1)
|
||||
: nextRepresentable(constraints.exclusiveMin, 1);
|
||||
}
|
||||
if (constraints.exclusiveMax !== undefined && normalized >= constraints.exclusiveMax) {
|
||||
normalized =
|
||||
typeof constraints.step === "number"
|
||||
? nextRepresentable(constraints.exclusiveMax, -1)
|
||||
: nextRepresentable(constraints.exclusiveMax, -1);
|
||||
}
|
||||
return normalizePrecision(
|
||||
normalized,
|
||||
typeof constraints.step === "number" ? constraints.step : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export const NO_SAFE_DEFAULT = Symbol("no-safe-config-default");
|
||||
|
||||
const MAX_AUTO_STRING_DEFAULT_LENGTH = 4096;
|
||||
export const MAX_AUTO_ARRAY_DEFAULT_ITEMS = 100;
|
||||
|
||||
function defaultNumericValue(schema: JsonSchema): number {
|
||||
const constraints = numericInputConstraints(schema);
|
||||
if (constraints.step === "any") {
|
||||
if (constraints.exclusiveMin !== undefined && constraints.exclusiveMin >= 0) {
|
||||
return nextUsableNumber(constraints.exclusiveMin, 1, constraints.max);
|
||||
}
|
||||
if (constraints.exclusiveMax !== undefined && constraints.exclusiveMax <= 0) {
|
||||
return nextUsableNumber(constraints.exclusiveMax, -1, constraints.min);
|
||||
}
|
||||
}
|
||||
return normalizeNumericValue(0, schema);
|
||||
}
|
||||
|
||||
function defaultStringValue(schema: JsonSchema): string | typeof NO_SAFE_DEFAULT {
|
||||
const minLength = Math.max(0, schema.minLength ?? 0);
|
||||
const maxLength = schema.maxLength ?? Math.max(minLength, 0);
|
||||
if (
|
||||
!Number.isSafeInteger(minLength) ||
|
||||
minLength > MAX_AUTO_STRING_DEFAULT_LENGTH ||
|
||||
maxLength < minLength
|
||||
) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
if (schema.pattern) {
|
||||
try {
|
||||
return minLength === 0 && new RegExp(schema.pattern, "u").test("") ? "" : NO_SAFE_DEFAULT;
|
||||
} catch {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
}
|
||||
if (minLength === 0) {
|
||||
return "";
|
||||
}
|
||||
return "x".repeat(minLength).slice(0, maxLength);
|
||||
}
|
||||
|
||||
function validatedDefaultCandidate(schema: JsonSchema, candidate: unknown): unknown {
|
||||
if (candidate === NO_SAFE_DEFAULT || !isSupportedConfigValueValid(schema, candidate)) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return candidate;
|
||||
}
|
||||
try {
|
||||
return structuredClone(candidate);
|
||||
} catch {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultValue(schema?: JsonSchema, depth = 0): unknown {
|
||||
if (!schema) {
|
||||
return "";
|
||||
}
|
||||
if (schema.default !== undefined) {
|
||||
return validatedDefaultCandidate(schema, schema.default);
|
||||
}
|
||||
if (schema.const !== undefined) {
|
||||
return validatedDefaultCandidate(schema, schema.const);
|
||||
}
|
||||
if (schema.enum && schema.enum.length > 0) {
|
||||
for (const candidate of schema.enum) {
|
||||
const validated = validatedDefaultCandidate(schema, candidate);
|
||||
if (validated !== NO_SAFE_DEFAULT) {
|
||||
return validated;
|
||||
}
|
||||
}
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
if (depth >= 32) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
for (const entry of schema.allOf ?? []) {
|
||||
const candidate = defaultValue(entry, depth + 1);
|
||||
const validated = validatedDefaultCandidate(schema, candidate);
|
||||
if (validated !== NO_SAFE_DEFAULT) {
|
||||
return validated;
|
||||
}
|
||||
}
|
||||
const type = schemaType(schema);
|
||||
switch (type) {
|
||||
case "object": {
|
||||
const value: Record<string, unknown> = {};
|
||||
for (const key of schema.required ?? []) {
|
||||
const propertySchema = ownPropertySchema(schema, key);
|
||||
if (!propertySchema) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
const propertyDefault = defaultValue(propertySchema, depth + 1);
|
||||
if (propertyDefault === NO_SAFE_DEFAULT) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
value[key] = propertyDefault;
|
||||
}
|
||||
return validatedDefaultCandidate(schema, value);
|
||||
}
|
||||
case "array": {
|
||||
const itemCount = Math.max(0, schema.minItems ?? 0);
|
||||
if (!Number.isSafeInteger(itemCount) || itemCount > MAX_AUTO_ARRAY_DEFAULT_ITEMS) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
if (itemCount === 0) {
|
||||
return validatedDefaultCandidate(schema, []);
|
||||
}
|
||||
if (Array.isArray(schema.items)) {
|
||||
const value: unknown[] = [];
|
||||
for (let index = 0; index < itemCount; index += 1) {
|
||||
const itemSchema =
|
||||
schema.items[index] ??
|
||||
(schema.additionalItems && typeof schema.additionalItems === "object"
|
||||
? schema.additionalItems
|
||||
: undefined);
|
||||
if (!itemSchema) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
const itemDefault = defaultValue(itemSchema, depth + 1);
|
||||
if (itemDefault === NO_SAFE_DEFAULT) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
value.push(itemDefault);
|
||||
}
|
||||
return validatedDefaultCandidate(schema, value);
|
||||
}
|
||||
const itemsSchema = schema.items;
|
||||
if (!itemsSchema) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
const value: unknown[] = [];
|
||||
for (let index = 0; index < itemCount; index += 1) {
|
||||
const itemDefault = defaultValue(itemsSchema, depth + 1);
|
||||
if (itemDefault === NO_SAFE_DEFAULT) {
|
||||
return NO_SAFE_DEFAULT;
|
||||
}
|
||||
value.push(itemDefault);
|
||||
}
|
||||
return validatedDefaultCandidate(schema, value);
|
||||
}
|
||||
case "boolean":
|
||||
return validatedDefaultCandidate(schema, false);
|
||||
case "number":
|
||||
case "integer": {
|
||||
const value = defaultNumericValue(schema);
|
||||
return validatedDefaultCandidate(schema, value);
|
||||
}
|
||||
case "string":
|
||||
return validatedDefaultCandidate(schema, defaultStringValue(schema));
|
||||
case "null":
|
||||
return validatedDefaultCandidate(schema, null);
|
||||
default:
|
||||
return validatedDefaultCandidate(schema, "");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,34 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { icons } from "../components/icons.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { removePathValue, setPathValue } from "../lib/config-form-utils.ts";
|
||||
import { arrayAddCandidates } from "./config-form-array-candidates.ts";
|
||||
import {
|
||||
appendArrayRowIdentities,
|
||||
discardArrayRowIdentities,
|
||||
preserveArrayRowIdentities,
|
||||
rowIdentitiesForArray,
|
||||
} from "./config-form-array-identity.ts";
|
||||
import {
|
||||
ConfigFormCollectionDraft,
|
||||
type ConfigFormCollectionDraftCommit,
|
||||
type ConfigFormCollectionDraftProps,
|
||||
} from "./config-form-collection-draft.ts";
|
||||
import { copyWithPathPatch } from "./config-form-copy-on-write.ts";
|
||||
import {
|
||||
arrayInputConstraints,
|
||||
arrayItemSchema,
|
||||
canApplyArrayCandidate,
|
||||
canApplyObjectCandidate,
|
||||
configValuesEqual,
|
||||
defaultValue,
|
||||
isSupportedConfigValueValid,
|
||||
NO_SAFE_DEFAULT,
|
||||
objectAdditionalPropertiesSchema,
|
||||
objectPropertyKeys,
|
||||
objectPropertySchema,
|
||||
requiredPropertyKeys,
|
||||
} from "./config-form.constraints.ts";
|
||||
import {
|
||||
getSensitiveRenderState,
|
||||
isAnySchema,
|
||||
@@ -18,13 +46,26 @@ import {
|
||||
matchesNodeSelf,
|
||||
resolveConfigFieldMeta as resolveFieldMeta,
|
||||
} from "./config-form.search.ts";
|
||||
import { defaultValue, hintForPath } from "./config-form.shared.ts";
|
||||
import { configFieldId, hintForPath, type JsonSchema } from "./config-form.shared.ts";
|
||||
import { renderSettingsEmpty } from "./settings-ui.ts";
|
||||
|
||||
const UNSET_ARRAY_SOURCE_IDENTITY = Symbol("unset-array-source");
|
||||
const UNSET_MAP_SOURCE_IDENTITY = Symbol("unset-map-source");
|
||||
|
||||
function openCollectionDraft(event: Event, draftId: string): void {
|
||||
const block = (event.currentTarget as HTMLElement).closest(".cfg-block");
|
||||
const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId);
|
||||
const openDraft = (draft as Partial<ConfigFormCollectionDraft> | undefined)?.openDraft;
|
||||
if (typeof openDraft === "function") {
|
||||
openDraft.call(draft);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderJsonTextarea(params: ConfigNodeRenderParams): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const helpId = showLabel && help ? configFieldId(path, "description") : undefined;
|
||||
const fallback = jsonValue(value);
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path,
|
||||
@@ -37,15 +78,22 @@ export function renderJsonTextarea(params: ConfigNodeRenderParams): TemplateResu
|
||||
return renderFieldRow({
|
||||
label,
|
||||
help,
|
||||
helpId,
|
||||
tags,
|
||||
showLabel,
|
||||
stacked: true,
|
||||
control: renderJsonTextareaControl({
|
||||
schema,
|
||||
path,
|
||||
ariaLabel: label,
|
||||
descriptionId: helpId,
|
||||
sourceValue: params.sourceIdentity ?? value,
|
||||
rowIdentity: params.rowIdentity,
|
||||
fallback,
|
||||
rows: 3,
|
||||
sensitiveState,
|
||||
disabled,
|
||||
isRequired: params.isRequired,
|
||||
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||
onPatch,
|
||||
}),
|
||||
@@ -79,12 +127,17 @@ export function renderObject(
|
||||
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
|
||||
|
||||
const fallback = value ?? schema.default;
|
||||
const objectSourceIdentity = fallback === undefined ? UNSET_MAP_SOURCE_IDENTITY : fallback;
|
||||
const objectValue =
|
||||
fallback && typeof fallback === "object" && !Array.isArray(fallback)
|
||||
? (fallback as Record<string, unknown>)
|
||||
: {};
|
||||
const properties = schema.properties ?? {};
|
||||
const entries = Object.entries(properties);
|
||||
const entries = objectPropertyKeys(schema)
|
||||
.map((key) => [key, objectPropertySchema(schema, key)] as const)
|
||||
.filter((entry): entry is readonly [string, ConfigNodeRenderParams["schema"]] =>
|
||||
Boolean(entry[1]),
|
||||
);
|
||||
const requiredKeys = requiredPropertyKeys(schema);
|
||||
|
||||
// Sort by hint order
|
||||
const sorted = entries.toSorted((left, right) => {
|
||||
@@ -96,13 +149,44 @@ export function renderObject(
|
||||
return left[0].localeCompare(right[0]);
|
||||
});
|
||||
|
||||
const reservedKeys = new Set(Object.keys(properties));
|
||||
const additionalProperties = schema.additionalProperties;
|
||||
const reservedKeys = new Set(entries.map(([key]) => key));
|
||||
const additionalProperties = objectAdditionalPropertiesSchema(schema);
|
||||
const allowExtra = Boolean(additionalProperties) && typeof additionalProperties === "object";
|
||||
const patchObjectChild = (childPath: Array<string | number>, childValue: unknown) => {
|
||||
if (
|
||||
childPath.length < path.length ||
|
||||
!path.every((segment, index) => segment === childPath[index])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let candidate: Record<string, unknown>;
|
||||
const relativePath = childPath.slice(path.length);
|
||||
if (relativePath.length === 0) {
|
||||
if (!childValue || typeof childValue !== "object" || Array.isArray(childValue)) {
|
||||
return false;
|
||||
}
|
||||
candidate = childValue as Record<string, unknown>;
|
||||
} else {
|
||||
try {
|
||||
candidate = structuredClone(objectValue);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (childValue === undefined) {
|
||||
removePathValue(candidate, relativePath);
|
||||
} else {
|
||||
setPathValue(candidate, relativePath, childValue);
|
||||
}
|
||||
}
|
||||
if (!canApplyObjectCandidate(schema, objectValue, candidate)) {
|
||||
return false;
|
||||
}
|
||||
return onPatch(childPath, childValue) !== false;
|
||||
};
|
||||
|
||||
const fields = html`
|
||||
${sorted.map(([propertyKey, node]) =>
|
||||
renderNode({
|
||||
${sorted.map(([propertyKey, node]) => {
|
||||
return renderNode({
|
||||
schema: node,
|
||||
value: objectValue[propertyKey],
|
||||
path: [...path, propertyKey],
|
||||
@@ -110,21 +194,27 @@ export function renderObject(
|
||||
rawAvailable,
|
||||
unsupported,
|
||||
disabled,
|
||||
isRequired: requiredKeys.has(propertyKey),
|
||||
sourceIdentity: objectValue[propertyKey],
|
||||
controlIdentity: params.controlIdentity ?? objectValue,
|
||||
rowIdentity: params.rowIdentity,
|
||||
searchCriteria: childSearchCriteria,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
}),
|
||||
)}
|
||||
onPatch: patchObjectChild,
|
||||
});
|
||||
})}
|
||||
${allowExtra
|
||||
? renderMapField(
|
||||
{
|
||||
...params,
|
||||
schema: additionalProperties,
|
||||
value: objectValue,
|
||||
sourceIdentity: objectSourceIdentity,
|
||||
reservedKeys,
|
||||
searchCriteria: childSearchCriteria,
|
||||
onPatch: patchObjectChild,
|
||||
},
|
||||
renderNode,
|
||||
)
|
||||
@@ -179,7 +269,8 @@ export function renderArray(
|
||||
: false;
|
||||
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
|
||||
|
||||
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
||||
const tupleItems = Array.isArray(schema.items) ? schema.items : undefined;
|
||||
const itemsSchema = Array.isArray(schema.items) ? (schema.items[0] ?? {}) : schema.items;
|
||||
if (!itemsSchema) {
|
||||
return renderFieldRow({
|
||||
label,
|
||||
@@ -195,6 +286,84 @@ export function renderArray(
|
||||
: Array.isArray(schema.default)
|
||||
? schema.default
|
||||
: [];
|
||||
const arraySourceIdentity = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(schema.default)
|
||||
? schema.default
|
||||
: UNSET_ARRAY_SOURCE_IDENTITY;
|
||||
const rowIdentities = rowIdentitiesForArray(arrayValue);
|
||||
const {
|
||||
minItems: minimumItems,
|
||||
maxItems: maximumItems,
|
||||
uniqueItems,
|
||||
} = arrayInputConstraints(schema);
|
||||
const itemSchemaAt = (index: number): JsonSchema =>
|
||||
arrayItemSchema(schema, index) ?? (tupleItems ? {} : itemsSchema);
|
||||
const { atomicCandidate, autoCandidate } = arrayAddCandidates({
|
||||
schema,
|
||||
value: arrayValue,
|
||||
minimumItems,
|
||||
maximumItems,
|
||||
uniqueItems,
|
||||
isUnset: value === undefined,
|
||||
isRequired: params.isRequired ?? false,
|
||||
itemSchemaAt,
|
||||
});
|
||||
const canAppend = maximumItems === undefined || arrayValue.length < maximumItems;
|
||||
const requiresDraft = atomicCandidate === undefined && autoCandidate === undefined;
|
||||
const nextItemSchema = itemSchemaAt(arrayValue.length);
|
||||
const draftId = configFieldId(path, "array-draft");
|
||||
const draftProps: ConfigFormCollectionDraftProps = {
|
||||
schema: nextItemSchema,
|
||||
label,
|
||||
disabled: disabled || !canAppend,
|
||||
identity: draftId,
|
||||
sourceIdentity: arraySourceIdentity,
|
||||
existingValues: uniqueItems ? arrayValue : undefined,
|
||||
validateValue: (candidate) => {
|
||||
const nextValue = [...arrayValue, candidate];
|
||||
return (
|
||||
(maximumItems === undefined || nextValue.length <= maximumItems) &&
|
||||
(nextValue.length < minimumItems || isSupportedConfigValueValid(schema, nextValue))
|
||||
);
|
||||
},
|
||||
};
|
||||
const patchArrayItem = (childPath: Array<string | number>, childValue: unknown) => {
|
||||
if (
|
||||
childPath.length <= path.length ||
|
||||
!path.every((segment, index) => segment === childPath[index])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const relativePath = childPath.slice(path.length);
|
||||
const itemIndex = relativePath[0];
|
||||
if (typeof itemIndex !== "number" || itemIndex < 0 || itemIndex >= arrayValue.length) {
|
||||
return false;
|
||||
}
|
||||
const nextValue = [...arrayValue];
|
||||
const itemPath = relativePath.slice(1);
|
||||
if (itemPath.length === 0) {
|
||||
if (childValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
nextValue[itemIndex] = childValue;
|
||||
} else {
|
||||
const nextItem = copyWithPathPatch(arrayValue[itemIndex], itemPath, childValue);
|
||||
if (!nextItem.ok) {
|
||||
return false;
|
||||
}
|
||||
nextValue[itemIndex] = nextItem.value;
|
||||
}
|
||||
if (canApplyArrayCandidate(schema, arrayValue, nextValue, uniqueItems, true)) {
|
||||
preserveArrayRowIdentities(nextValue, rowIdentities);
|
||||
const accepted = onPatch(path, nextValue) !== false;
|
||||
if (!accepted) {
|
||||
discardArrayRowIdentities(nextValue);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="cfg-block cfg-array">
|
||||
@@ -213,13 +382,57 @@ export function renderArray(
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, [...arrayValue, defaultValue(itemsSchema)])}
|
||||
aria-controls=${draftId}
|
||||
?disabled=${disabled || (!canAppend && atomicCandidate === undefined)}
|
||||
@click=${(event: Event) => {
|
||||
if (atomicCandidate) {
|
||||
if (onPatch(path, atomicCandidate) === false) {
|
||||
openCollectionDraft(event, draftId);
|
||||
}
|
||||
} else if (requiresDraft) {
|
||||
openCollectionDraft(event, draftId);
|
||||
} else if (autoCandidate) {
|
||||
appendArrayRowIdentities(
|
||||
autoCandidate,
|
||||
rowIdentities,
|
||||
autoCandidate.length - arrayValue.length,
|
||||
);
|
||||
if (onPatch(path, autoCandidate) === false) {
|
||||
discardArrayRowIdentities(autoCandidate);
|
||||
openCollectionDraft(event, draftId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
${t("configForm.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<openclaw-config-form-collection-draft
|
||||
id=${draftId}
|
||||
.props=${draftProps}
|
||||
@config-collection-draft-commit=${(event: CustomEvent<ConfigFormCollectionDraftCommit>) => {
|
||||
const nextValue = [...arrayValue, event.detail.value];
|
||||
const canApply =
|
||||
!(
|
||||
uniqueItems && arrayValue.some((item) => configValuesEqual(item, event.detail.value))
|
||||
) &&
|
||||
(maximumItems === undefined || arrayValue.length < maximumItems) &&
|
||||
isSupportedConfigValueValid(nextItemSchema, event.detail.value) &&
|
||||
(nextValue.length < minimumItems || isSupportedConfigValueValid(schema, nextValue));
|
||||
let accepted = false;
|
||||
if (canApply) {
|
||||
appendArrayRowIdentities(nextValue, rowIdentities, 1);
|
||||
accepted = onPatch(path, nextValue) !== false;
|
||||
if (!accepted) {
|
||||
discardArrayRowIdentities(nextValue);
|
||||
}
|
||||
}
|
||||
if (!accepted) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
></openclaw-config-form-collection-draft>
|
||||
${arrayValue.length === 0
|
||||
? renderSettingsEmpty(t("configForm.noItems"))
|
||||
: html`
|
||||
@@ -237,11 +450,34 @@ export function renderArray(
|
||||
class="btn btn--icon"
|
||||
style="width:28px;height:28px;padding:0;"
|
||||
aria-label=${t("configForm.removeItem")}
|
||||
?disabled=${disabled}
|
||||
?disabled=${disabled ||
|
||||
arrayValue.length <= minimumItems ||
|
||||
!canApplyArrayCandidate(
|
||||
schema,
|
||||
arrayValue,
|
||||
arrayValue.toSpliced(index, 1),
|
||||
uniqueItems,
|
||||
false,
|
||||
)}
|
||||
@click=${() => {
|
||||
const nextValue = [...arrayValue];
|
||||
nextValue.splice(index, 1);
|
||||
onPatch(path, nextValue);
|
||||
const nextValue = arrayValue.toSpliced(index, 1);
|
||||
if (
|
||||
canApplyArrayCandidate(
|
||||
schema,
|
||||
arrayValue,
|
||||
nextValue,
|
||||
uniqueItems,
|
||||
false,
|
||||
)
|
||||
) {
|
||||
preserveArrayRowIdentities(
|
||||
nextValue,
|
||||
rowIdentities.toSpliced(index, 1),
|
||||
);
|
||||
if (onPatch(path, nextValue) === false) {
|
||||
discardArrayRowIdentities(nextValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.trash}
|
||||
@@ -250,19 +486,23 @@ export function renderArray(
|
||||
</div>
|
||||
</div>
|
||||
${renderNode({
|
||||
schema: itemsSchema,
|
||||
schema: itemSchemaAt(index),
|
||||
value: item,
|
||||
path: [...path, index],
|
||||
hints,
|
||||
rawAvailable,
|
||||
unsupported,
|
||||
disabled,
|
||||
isRequired: true,
|
||||
sourceIdentity: item,
|
||||
controlIdentity: arrayValue,
|
||||
rowIdentity: rowIdentities[index],
|
||||
searchCriteria: childSearchCriteria,
|
||||
showLabel: false,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
onPatch: patchArrayItem,
|
||||
})}
|
||||
`,
|
||||
)}
|
||||
@@ -295,6 +535,16 @@ function renderMapField(
|
||||
onToggleSensitivePath,
|
||||
} = params;
|
||||
const anySchema = isAnySchema(schema);
|
||||
const entryDefault = anySchema ? {} : defaultValue(schema);
|
||||
const draftId = configFieldId(path, "map-draft");
|
||||
const draftProps: ConfigFormCollectionDraftProps = {
|
||||
schema,
|
||||
label: t("configForm.customEntries"),
|
||||
disabled,
|
||||
identity: draftId,
|
||||
sourceIdentity: params.sourceIdentity ?? value,
|
||||
existingKeys: [...new Set([...Object.keys(value), ...reservedKeys])],
|
||||
};
|
||||
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
|
||||
const visibleEntries =
|
||||
searchCriteria && hasSearchCriteria(searchCriteria)
|
||||
@@ -319,8 +569,13 @@ function renderMapField(
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
aria-controls=${draftId}
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
@click=${(event: Event) => {
|
||||
if (entryDefault === NO_SAFE_DEFAULT) {
|
||||
openCollectionDraft(event, draftId);
|
||||
return;
|
||||
}
|
||||
const nextValue = { ...value };
|
||||
let index = 1;
|
||||
let key = `custom-${index}`;
|
||||
@@ -328,8 +583,10 @@ function renderMapField(
|
||||
index += 1;
|
||||
key = `custom-${index}`;
|
||||
}
|
||||
nextValue[key] = anySchema ? {} : defaultValue(schema);
|
||||
onPatch(path, nextValue);
|
||||
nextValue[key] = entryDefault;
|
||||
if (onPatch(path, nextValue) === false) {
|
||||
openCollectionDraft(event, draftId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${t("configForm.addEntry")}
|
||||
@@ -337,6 +594,21 @@ function renderMapField(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<openclaw-config-form-collection-draft
|
||||
id=${draftId}
|
||||
.props=${draftProps}
|
||||
@config-collection-draft-commit=${(event: CustomEvent<ConfigFormCollectionDraftCommit>) => {
|
||||
const key = event.detail.key;
|
||||
if (
|
||||
!key ||
|
||||
Object.hasOwn(value, key) ||
|
||||
reservedKeys.has(key) ||
|
||||
onPatch(path, { ...value, [key]: event.detail.value }) === false
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
></openclaw-config-form-collection-draft>
|
||||
${visibleEntries.length === 0
|
||||
? renderSettingsEmpty(t("configForm.noCustomEntries"))
|
||||
: html`
|
||||
@@ -358,21 +630,26 @@ function renderMapField(
|
||||
type="text"
|
||||
class="settings-input"
|
||||
placeholder=${t("configForm.key")}
|
||||
aria-label=${t("configForm.key")}
|
||||
aria-label=${`${t("configForm.key")}: ${key}`}
|
||||
.value=${key}
|
||||
?disabled=${disabled}
|
||||
@change=${(event: Event) => {
|
||||
const nextKey = (event.target as HTMLInputElement).value.trim();
|
||||
const target = event.target as HTMLInputElement;
|
||||
const nextKey = target.value.trim();
|
||||
if (!nextKey || nextKey === key) {
|
||||
target.value = key;
|
||||
return;
|
||||
}
|
||||
const nextValue = { ...value };
|
||||
if (nextKey in nextValue) {
|
||||
target.value = key;
|
||||
return;
|
||||
}
|
||||
nextValue[nextKey] = nextValue[key];
|
||||
delete nextValue[key];
|
||||
onPatch(path, nextValue);
|
||||
if (onPatch(path, nextValue) === false) {
|
||||
target.value = key;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -402,11 +679,16 @@ function renderMapField(
|
||||
showLabel: false,
|
||||
stacked: true,
|
||||
control: renderJsonTextareaControl({
|
||||
schema,
|
||||
path: valuePath,
|
||||
ariaLabel: `${key}: ${t("configForm.jsonValue")}`,
|
||||
sourceValue: entryValue,
|
||||
rowIdentity: params.rowIdentity,
|
||||
fallback,
|
||||
rows: 2,
|
||||
sensitiveState,
|
||||
disabled,
|
||||
isRequired: true,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
}),
|
||||
@@ -419,6 +701,10 @@ function renderMapField(
|
||||
rawAvailable,
|
||||
unsupported,
|
||||
disabled,
|
||||
isRequired: true,
|
||||
sourceIdentity: entryValue,
|
||||
controlIdentity: value,
|
||||
rowIdentity: params.rowIdentity,
|
||||
searchCriteria,
|
||||
showLabel: false,
|
||||
revealSensitive,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Control UI renderers for scalar config form nodes.
|
||||
import { formatInternationalPhoneNumberForDisplay } from "@openclaw/normalization-core/phone-presentation";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { i18n, t } from "../i18n/index.ts";
|
||||
import { formatUnknownText } from "../lib/format.ts";
|
||||
import {
|
||||
isSupportedConfigValueValid,
|
||||
normalizeNumericValue,
|
||||
numericInputConstraints,
|
||||
} from "./config-form.constraints.ts";
|
||||
import {
|
||||
getSensitiveRenderState,
|
||||
isSecretRefObject,
|
||||
@@ -13,7 +19,79 @@ import {
|
||||
type ConfigNodeRenderParams,
|
||||
} from "./config-form.node.shared.ts";
|
||||
import { resolveConfigFieldMeta as resolveFieldMeta } from "./config-form.search.ts";
|
||||
import { hintForPath, redactedPlaceholder } from "./config-form.shared.ts";
|
||||
import { configFieldId, hintForPath, redactedPlaceholder } from "./config-form.shared.ts";
|
||||
|
||||
const scalarInputState = new WeakMap<
|
||||
HTMLInputElement,
|
||||
{
|
||||
controlIdentity: unknown;
|
||||
sourceIdentity: unknown;
|
||||
rowIdentity: unknown;
|
||||
pathKey: string;
|
||||
presentationIdentity: string;
|
||||
renderedValue: string;
|
||||
}
|
||||
>();
|
||||
|
||||
function setControlValidity(target: HTMLInputElement, message: string): boolean {
|
||||
target.setCustomValidity(message);
|
||||
target.setAttribute("aria-invalid", String(Boolean(message)));
|
||||
return !message;
|
||||
}
|
||||
|
||||
function syncScalarInputIdentity(
|
||||
element: Element | undefined,
|
||||
controlIdentity: unknown,
|
||||
sourceIdentity: unknown,
|
||||
rowIdentity: unknown,
|
||||
pathKey: string,
|
||||
presentationIdentity: string,
|
||||
renderedValue: string,
|
||||
revalidate: (target: HTMLInputElement) => void,
|
||||
): void {
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const previous = scalarInputState.get(element);
|
||||
if (previous) {
|
||||
if (
|
||||
!Object.is(previous.sourceIdentity, sourceIdentity) ||
|
||||
!Object.is(previous.rowIdentity, rowIdentity) ||
|
||||
previous.pathKey !== pathKey ||
|
||||
previous.presentationIdentity !== presentationIdentity ||
|
||||
previous.renderedValue !== renderedValue
|
||||
) {
|
||||
element.value = renderedValue;
|
||||
setControlValidity(element, "");
|
||||
} else if (!Object.is(previous.controlIdentity, controlIdentity)) {
|
||||
revalidate(element);
|
||||
}
|
||||
}
|
||||
scalarInputState.set(element, {
|
||||
controlIdentity,
|
||||
sourceIdentity,
|
||||
rowIdentity,
|
||||
pathKey,
|
||||
presentationIdentity,
|
||||
renderedValue,
|
||||
});
|
||||
}
|
||||
|
||||
function stringConstraintMessage(value: string, schema: ConfigNodeRenderParams["schema"]): string {
|
||||
return isSupportedConfigValueValid(schema, value) ? "" : t("configForm.invalidString");
|
||||
}
|
||||
|
||||
function shouldClearOptionalEmpty(
|
||||
value: string,
|
||||
schema: ConfigNodeRenderParams["schema"],
|
||||
isRequired: boolean,
|
||||
): boolean {
|
||||
return value === "" && !isRequired && Boolean(stringConstraintMessage(value, schema));
|
||||
}
|
||||
|
||||
function numericConstraintMessage(value: number, schema: ConfigNodeRenderParams["schema"]): string {
|
||||
return isSupportedConfigValueValid(schema, value) ? "" : t("configForm.invalidNumber");
|
||||
}
|
||||
|
||||
export function renderTextInput(
|
||||
params: ConfigNodeRenderParams & { inputType: "text" | "number" },
|
||||
@@ -22,6 +100,7 @@ export function renderTextInput(
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const helpId = showLabel && help ? configFieldId(path, "description") : undefined;
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path,
|
||||
value,
|
||||
@@ -55,13 +134,67 @@ export function renderTextInput(
|
||||
isPhonePresentation && !effectiveRedacted && typeof value === "string"
|
||||
? formatInternationalPhoneNumberForDisplay(value, i18n.getLocale())
|
||||
: undefined;
|
||||
const controlIdentity = params.controlIdentity ?? params.sourceIdentity ?? value;
|
||||
const sourceIdentity = params.sourceIdentity ?? value;
|
||||
const controlPathKey = configFieldId(path, "scalar-identity");
|
||||
const renderedValue = formatUnknownText(displayValue);
|
||||
const presentationIdentity = [
|
||||
effectiveRedacted ? "redacted" : "visible",
|
||||
effectiveInputType,
|
||||
isPhonePresentation ? "phone" : "plain",
|
||||
isStructuredSecretRef ? (rawAvailable ? "secret-raw" : "secret-file") : "scalar",
|
||||
].join(":");
|
||||
const revalidate = (target: HTMLInputElement) => {
|
||||
if (effectiveRedacted) {
|
||||
setControlValidity(target, "");
|
||||
return;
|
||||
}
|
||||
if (inputType === "number") {
|
||||
const raw = target.value;
|
||||
setControlValidity(
|
||||
target,
|
||||
raw.trim() === ""
|
||||
? params.isRequired
|
||||
? t("configForm.invalidNumber")
|
||||
: ""
|
||||
: numericConstraintMessage(Number(raw), schema),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const raw = target.value;
|
||||
const optionalEmpty = shouldClearOptionalEmpty(raw, schema, params.isRequired === true);
|
||||
setControlValidity(target, optionalEmpty ? "" : stringConstraintMessage(raw, schema));
|
||||
};
|
||||
const commitScalarValue = (target: HTMLInputElement, candidate: unknown) => {
|
||||
if (onPatch(path, candidate) !== false) {
|
||||
return true;
|
||||
}
|
||||
target.value = renderedValue;
|
||||
revalidate(target);
|
||||
return false;
|
||||
};
|
||||
|
||||
const inputControl = html`
|
||||
<input
|
||||
${ref((element) =>
|
||||
syncScalarInputIdentity(
|
||||
element,
|
||||
controlIdentity,
|
||||
sourceIdentity,
|
||||
params.rowIdentity,
|
||||
controlPathKey,
|
||||
presentationIdentity,
|
||||
renderedValue,
|
||||
revalidate,
|
||||
),
|
||||
)}
|
||||
type=${effectiveInputType}
|
||||
class="settings-input${effectiveRedacted ? " cfg-redacted" : ""}"
|
||||
aria-label=${label}
|
||||
aria-describedby=${helpId ?? nothing}
|
||||
aria-invalid="false"
|
||||
placeholder=${placeholder}
|
||||
.value=${formatUnknownText(displayValue)}
|
||||
.value=${renderedValue}
|
||||
?disabled=${disabled}
|
||||
?readonly=${effectiveRedacted}
|
||||
@click=${() => {
|
||||
@@ -73,24 +206,58 @@ export function renderTextInput(
|
||||
if (effectiveRedacted) {
|
||||
return;
|
||||
}
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const target = event.target as HTMLInputElement;
|
||||
const raw = target.value;
|
||||
if (inputType === "number") {
|
||||
if (raw.trim() === "") {
|
||||
onPatch(path, undefined);
|
||||
if (params.isRequired) {
|
||||
setControlValidity(target, t("configForm.invalidNumber"));
|
||||
} else {
|
||||
setControlValidity(target, "");
|
||||
commitScalarValue(target, undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
|
||||
if (setControlValidity(target, numericConstraintMessage(parsed, schema))) {
|
||||
commitScalarValue(target, Number.isNaN(parsed) ? raw : parsed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onPatch(path, raw);
|
||||
if (shouldClearOptionalEmpty(raw, schema, params.isRequired === true)) {
|
||||
setControlValidity(target, "");
|
||||
commitScalarValue(target, undefined);
|
||||
} else if (setControlValidity(target, stringConstraintMessage(raw, schema))) {
|
||||
commitScalarValue(target, raw);
|
||||
}
|
||||
}}
|
||||
@change=${(event: Event) => {
|
||||
if (inputType === "number" || effectiveRedacted) {
|
||||
return;
|
||||
}
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
onPatch(path, raw.trim());
|
||||
const target = event.target as HTMLInputElement;
|
||||
const raw = target.value;
|
||||
const rawMessage = stringConstraintMessage(raw, schema);
|
||||
if (!rawMessage && !isPhonePresentation) {
|
||||
setControlValidity(target, "");
|
||||
commitScalarValue(target, raw);
|
||||
return;
|
||||
}
|
||||
const normalized = raw.trim();
|
||||
if (shouldClearOptionalEmpty(normalized, schema, params.isRequired === true)) {
|
||||
target.value = normalized;
|
||||
setControlValidity(target, "");
|
||||
commitScalarValue(target, undefined);
|
||||
return;
|
||||
}
|
||||
const normalizedMessage = stringConstraintMessage(normalized, schema);
|
||||
if (normalizedMessage) {
|
||||
setControlValidity(target, rawMessage);
|
||||
return;
|
||||
}
|
||||
target.value = normalized;
|
||||
setControlValidity(target, "");
|
||||
commitScalarValue(target, normalized);
|
||||
}}
|
||||
/>
|
||||
`;
|
||||
@@ -133,51 +300,128 @@ export function renderTextInput(
|
||||
: nothing}
|
||||
`;
|
||||
|
||||
return renderFieldRow({ label, help, tags, showLabel, control });
|
||||
return renderFieldRow({ label, help, helpId, tags, showLabel, control });
|
||||
}
|
||||
|
||||
export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResult {
|
||||
const { schema, value, path, hints, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const helpId = showLabel && help ? configFieldId(path, "description") : undefined;
|
||||
const displayValue = value ?? schema.default ?? "";
|
||||
const constraints = numericInputConstraints(schema);
|
||||
const numericStep = typeof constraints.step === "number" ? constraints.step : 1;
|
||||
const controlIdentity = params.controlIdentity ?? params.sourceIdentity ?? value;
|
||||
const sourceIdentity = params.sourceIdentity ?? value;
|
||||
const controlPathKey = configFieldId(path, "scalar-identity");
|
||||
const renderedValue = formatUnknownText(displayValue);
|
||||
const revalidate = (target: HTMLInputElement) => {
|
||||
const raw = target.value;
|
||||
setControlValidity(
|
||||
target,
|
||||
raw === ""
|
||||
? params.isRequired
|
||||
? t("configForm.invalidNumber")
|
||||
: ""
|
||||
: numericConstraintMessage(Number(raw), schema),
|
||||
);
|
||||
};
|
||||
const commitScalarValue = (target: HTMLInputElement, candidate: unknown) => {
|
||||
if (onPatch(path, candidate) !== false) {
|
||||
return true;
|
||||
}
|
||||
target.value = renderedValue;
|
||||
revalidate(target);
|
||||
return false;
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
// adjust buttons so schema-sized edits stay possible without typing.
|
||||
const step = (direction: -1 | 1) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const current = Number(displayValue);
|
||||
const base = Number.isFinite(current) ? current : 0;
|
||||
onPatch(path, base + delta);
|
||||
const base = Number.isFinite(current) ? current : normalizeNumericValue(0, schema);
|
||||
const candidate = normalizeNumericValue(base + direction * numericStep, schema);
|
||||
if (isSupportedConfigValueValid(schema, candidate)) {
|
||||
onPatch(path, candidate);
|
||||
}
|
||||
};
|
||||
const control = html`
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--icon"
|
||||
aria-label=${`${label}: -1`}
|
||||
aria-label=${`${label}: -${numericStep}`}
|
||||
?disabled=${disabled}
|
||||
@click=${() => step(-1)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
${ref((element) =>
|
||||
syncScalarInputIdentity(
|
||||
element,
|
||||
controlIdentity,
|
||||
sourceIdentity,
|
||||
params.rowIdentity,
|
||||
controlPathKey,
|
||||
"number",
|
||||
renderedValue,
|
||||
revalidate,
|
||||
),
|
||||
)}
|
||||
type="number"
|
||||
class="settings-input"
|
||||
aria-label=${label}
|
||||
.value=${formatUnknownText(displayValue)}
|
||||
aria-describedby=${helpId ?? nothing}
|
||||
aria-invalid="false"
|
||||
min=${constraints.min ?? nothing}
|
||||
max=${constraints.max ?? nothing}
|
||||
step=${constraints.step}
|
||||
.value=${renderedValue}
|
||||
?disabled=${disabled}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const target = event.target as HTMLInputElement;
|
||||
const raw = target.value;
|
||||
if (raw === "") {
|
||||
if (params.isRequired) {
|
||||
setControlValidity(target, t("configForm.invalidNumber"));
|
||||
} else {
|
||||
setControlValidity(target, "");
|
||||
commitScalarValue(target, undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const parsed = raw === "" ? undefined : Number(raw);
|
||||
onPatch(path, parsed);
|
||||
if (
|
||||
parsed !== undefined &&
|
||||
setControlValidity(target, numericConstraintMessage(parsed, schema))
|
||||
) {
|
||||
commitScalarValue(target, parsed);
|
||||
}
|
||||
}}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.value === "") {
|
||||
return;
|
||||
}
|
||||
const parsed = Number(target.value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setControlValidity(target, t("configForm.invalidNumber"));
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeNumericValue(parsed, schema);
|
||||
target.value = formatUnknownText(normalized);
|
||||
if (setControlValidity(target, numericConstraintMessage(normalized, schema))) {
|
||||
commitScalarValue(target, normalized);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--icon"
|
||||
aria-label=${`${label}: +1`}
|
||||
aria-label=${`${label}: +${numericStep}`}
|
||||
?disabled=${disabled}
|
||||
@click=${() => step(1)}
|
||||
>
|
||||
@@ -185,7 +429,7 @@ export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResul
|
||||
</button>
|
||||
`;
|
||||
|
||||
return renderFieldRow({ label, help, tags, showLabel, control });
|
||||
return renderFieldRow({ label, help, helpId, tags, showLabel, control });
|
||||
}
|
||||
|
||||
export function renderSelect(
|
||||
@@ -194,23 +438,56 @@ export function renderSelect(
|
||||
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 helpId = showLabel && help ? configFieldId(path, "description") : undefined;
|
||||
const resolvedValue = value !== undefined ? value : schema.default;
|
||||
const currentIndex = options.findIndex(
|
||||
(option) => option === resolvedValue || String(option) === String(resolvedValue),
|
||||
);
|
||||
const unset = "__unset__";
|
||||
const nullValue = "__null__";
|
||||
const canSelectNull = schema.nullable && schema.enumIncludesNull;
|
||||
const selectedValue =
|
||||
resolvedValue === null && canSelectNull
|
||||
? nullValue
|
||||
: currentIndex >= 0
|
||||
? String(currentIndex)
|
||||
: unset;
|
||||
|
||||
const control = html`
|
||||
<select
|
||||
class="settings-select"
|
||||
aria-label=${label}
|
||||
aria-describedby=${helpId ?? nothing}
|
||||
?disabled=${disabled}
|
||||
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
|
||||
.value=${selectedValue}
|
||||
@change=${(event: Event) => {
|
||||
const selectedValue = (event.target as HTMLSelectElement).value;
|
||||
onPatch(path, selectedValue === unset ? undefined : options[Number(selectedValue)]);
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const nextSelection = target.value;
|
||||
if (nextSelection === unset && params.isRequired) {
|
||||
target.value = selectedValue;
|
||||
return;
|
||||
}
|
||||
const candidate =
|
||||
nextSelection === unset
|
||||
? undefined
|
||||
: nextSelection === nullValue
|
||||
? null
|
||||
: options[Number(nextSelection)];
|
||||
if (onPatch(path, candidate) === false) {
|
||||
target.value = selectedValue;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value=${unset} ?selected=${currentIndex < 0}>${t("configForm.select")}</option>
|
||||
<option value=${unset} ?selected=${selectedValue === unset} ?disabled=${params.isRequired}>
|
||||
${t("configForm.select")}
|
||||
</option>
|
||||
${canSelectNull
|
||||
? html`
|
||||
<option value=${nullValue} ?selected=${selectedValue === nullValue}>
|
||||
${t("configForm.nullValue")}
|
||||
</option>
|
||||
`
|
||||
: nothing}
|
||||
${options.map(
|
||||
(option, index) => html`
|
||||
<option value=${String(index)} ?selected=${index === currentIndex}>
|
||||
@@ -221,5 +498,5 @@ export function renderSelect(
|
||||
</select>
|
||||
`;
|
||||
|
||||
return renderFieldRow({ label, help, tags, showLabel, control });
|
||||
return renderFieldRow({ label, help, helpId, tags, showLabel, control });
|
||||
}
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
// Control UI helpers shared by config form node renderers.
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
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 { isSupportedConfigValueValid } from "./config-form.constraints.ts";
|
||||
import type { ConfigSearchCriteria } from "./config-form.search.ts";
|
||||
import {
|
||||
configFieldId,
|
||||
hasSensitiveConfigData,
|
||||
redactedPlaceholder,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared.ts";
|
||||
import { renderSettingsSegmented } from "./settings-ui.ts";
|
||||
|
||||
const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]);
|
||||
const META_KEYS = new Set([
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"nullable",
|
||||
"enumIncludesNull",
|
||||
"tags",
|
||||
"x-tags",
|
||||
]);
|
||||
const jsonTextareaState = new WeakMap<
|
||||
HTMLTextAreaElement,
|
||||
{ sourceValue: unknown; rowIdentity: unknown; fallback: string; pathKey: string }
|
||||
>();
|
||||
|
||||
export type ConfigNodeRenderParams = {
|
||||
schema: JsonSchema;
|
||||
@@ -23,12 +38,17 @@ export type ConfigNodeRenderParams = {
|
||||
rawAvailable?: boolean;
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
isRequired?: boolean;
|
||||
sourceIdentity?: unknown;
|
||||
controlIdentity?: unknown;
|
||||
rowIdentity?: unknown;
|
||||
structuredDraftOwner?: 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;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => boolean | void;
|
||||
};
|
||||
|
||||
export type ConfigNodeRenderer = (
|
||||
@@ -171,6 +191,7 @@ export function renderTags(tags: string[]): TemplateResult | typeof nothing {
|
||||
export function renderFieldRow(params: {
|
||||
label: unknown;
|
||||
help?: unknown;
|
||||
helpId?: string;
|
||||
tags: string[];
|
||||
showLabel: boolean;
|
||||
control: TemplateResult | typeof nothing;
|
||||
@@ -194,10 +215,14 @@ export function renderFieldRow(params: {
|
||||
${params.showLabel
|
||||
? html`<span class="settings-row__title">${params.label}</span>`
|
||||
: nothing}
|
||||
${help ? html`<span class="settings-row__desc">${help}</span>` : nothing}
|
||||
${help
|
||||
? html`<span class="settings-row__desc" id=${params.helpId ?? nothing}
|
||||
>${help}</span
|
||||
>`
|
||||
: nothing}
|
||||
${renderTags(params.tags)}
|
||||
${params.error
|
||||
? html`<span class="cfg-field__error">${params.error}</span>`
|
||||
? html`<span class="cfg-field__error" role="alert">${params.error}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
@@ -237,21 +262,92 @@ export function renderSegmentedControl(params: {
|
||||
}
|
||||
|
||||
export function renderJsonTextareaControl(params: {
|
||||
schema: JsonSchema;
|
||||
path: Array<string | number>;
|
||||
ariaLabel: string;
|
||||
descriptionId?: string;
|
||||
sourceValue: unknown;
|
||||
rowIdentity?: unknown;
|
||||
fallback: string;
|
||||
rows: number;
|
||||
sensitiveState: SensitiveRenderState;
|
||||
disabled: boolean;
|
||||
isRequired?: boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => boolean | void;
|
||||
}): TemplateResult {
|
||||
const { path, fallback, sensitiveState, disabled, onPatch } = params;
|
||||
const errorId = configFieldId(path, "json-error");
|
||||
const describedBy = [params.descriptionId, errorId].filter(Boolean).join(" ");
|
||||
const setValidity = (target: HTMLTextAreaElement, message: string) => {
|
||||
const error = target
|
||||
.closest(".cfg-json-editor")
|
||||
?.querySelector<HTMLElement>(".cfg-field__error");
|
||||
target.setCustomValidity(message);
|
||||
target.setAttribute("aria-invalid", String(Boolean(message)));
|
||||
if (error) {
|
||||
error.hidden = !message;
|
||||
error.textContent = message;
|
||||
}
|
||||
};
|
||||
const updateValidity = (target: HTMLTextAreaElement) => {
|
||||
let message = "";
|
||||
const raw = target.value.trim();
|
||||
if (!raw && params.isRequired) {
|
||||
message = t("configForm.invalidJson");
|
||||
} else if (raw) {
|
||||
try {
|
||||
if (!isSupportedConfigValueValid(params.schema, JSON.parse(raw))) {
|
||||
message = t("configForm.invalidJson");
|
||||
}
|
||||
} catch {
|
||||
message = t("configForm.invalidJson");
|
||||
}
|
||||
}
|
||||
setValidity(target, message);
|
||||
return !message;
|
||||
};
|
||||
const renderedFallback = sensitiveState.isRedacted ? "" : fallback;
|
||||
const pathKey = JSON.stringify(path);
|
||||
const commitJsonValue = (target: HTMLTextAreaElement, candidate: unknown) => {
|
||||
if (onPatch(path, candidate) !== false) {
|
||||
return true;
|
||||
}
|
||||
target.value = renderedFallback;
|
||||
updateValidity(target);
|
||||
return false;
|
||||
};
|
||||
const textareaControl = html`
|
||||
<textarea
|
||||
${ref((element) => {
|
||||
if (!(element instanceof HTMLTextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
const previous = jsonTextareaState.get(element);
|
||||
if (
|
||||
previous &&
|
||||
(!Object.is(previous.sourceValue, params.sourceValue) ||
|
||||
!Object.is(previous.rowIdentity, params.rowIdentity) ||
|
||||
previous.fallback !== renderedFallback ||
|
||||
previous.pathKey !== pathKey)
|
||||
) {
|
||||
element.value = renderedFallback;
|
||||
setValidity(element, "");
|
||||
}
|
||||
jsonTextareaState.set(element, {
|
||||
sourceValue: params.sourceValue,
|
||||
rowIdentity: params.rowIdentity,
|
||||
fallback: renderedFallback,
|
||||
pathKey,
|
||||
});
|
||||
})}
|
||||
class="settings-input${sensitiveState.isRedacted ? " cfg-redacted" : ""}"
|
||||
aria-label=${params.ariaLabel}
|
||||
aria-describedby=${describedBy || nothing}
|
||||
aria-invalid="false"
|
||||
placeholder=${sensitiveState.isRedacted ? redactedPlaceholder() : t("configForm.jsonValue")}
|
||||
rows=${params.rows}
|
||||
.value=${sensitiveState.isRedacted ? "" : fallback}
|
||||
.value=${renderedFallback}
|
||||
?disabled=${disabled}
|
||||
?readonly=${sensitiveState.isRedacted}
|
||||
@click=${() => {
|
||||
@@ -259,31 +355,44 @@ export function renderJsonTextareaControl(params: {
|
||||
params.onToggleSensitivePath(path);
|
||||
}
|
||||
}}
|
||||
@input=${(event: Event) => {
|
||||
if (!sensitiveState.isRedacted) {
|
||||
updateValidity(event.target as HTMLTextAreaElement);
|
||||
}
|
||||
}}
|
||||
@change=${(event: Event) => {
|
||||
if (sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
if (!updateValidity(target)) {
|
||||
return;
|
||||
}
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onPatch(path, undefined);
|
||||
commitJsonValue(target, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(path, JSON.parse(raw));
|
||||
commitJsonValue(target, JSON.parse(raw));
|
||||
} catch {
|
||||
target.value = fallback;
|
||||
// Input validity is already surfaced inline; preserve the draft until it is valid JSON.
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
`;
|
||||
return wrapSensitiveControl(
|
||||
textareaControl,
|
||||
renderSensitiveToggleButton({
|
||||
path,
|
||||
state: sensitiveState,
|
||||
disabled,
|
||||
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||
}),
|
||||
);
|
||||
return html`
|
||||
<span class="cfg-json-editor">
|
||||
${wrapSensitiveControl(
|
||||
textareaControl,
|
||||
renderSensitiveToggleButton({
|
||||
path,
|
||||
state: sensitiveState,
|
||||
disabled,
|
||||
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||
}),
|
||||
)}
|
||||
<span id=${errorId} class="cfg-field__error" role="alert" hidden></span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// 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, renderJsonTextarea, renderObject } from "./config-form.node.collection.ts";
|
||||
import { renderNumberInput, renderSelect, renderTextInput } from "./config-form.node.scalar.ts";
|
||||
import {
|
||||
renderFieldRow,
|
||||
isAnySchema,
|
||||
renderSegmentedControl,
|
||||
renderTags,
|
||||
type ConfigNodeRenderParams,
|
||||
@@ -14,7 +20,7 @@ import {
|
||||
matchesNodeSearch,
|
||||
resolveConfigFieldMeta as resolveFieldMeta,
|
||||
} from "./config-form.search.ts";
|
||||
import { pathKey, schemaType } from "./config-form.shared.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 {
|
||||
@@ -41,6 +47,22 @@ export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typ
|
||||
) {
|
||||
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) {
|
||||
@@ -208,6 +230,10 @@ export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typ
|
||||
return renderTextInput({ ...params, inputType: "text" });
|
||||
}
|
||||
|
||||
if (isAnySchema(schema)) {
|
||||
return renderJsonTextarea(params);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return renderFieldRow({
|
||||
label,
|
||||
|
||||
@@ -67,7 +67,8 @@ export function resolveConfigFieldMeta(
|
||||
hints: ConfigUiHints,
|
||||
): ConfigFieldMeta {
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const fallbackSegment = path.findLast((segment) => typeof segment === "string") ?? path.at(-1);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(fallbackSegment));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const schemaTags = normalizeTags(schema["x-tags"] ?? schema.tags);
|
||||
const hintTags = normalizeTags(hint?.tags);
|
||||
|
||||
@@ -12,12 +12,23 @@ export type JsonSchema = {
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema | JsonSchema[];
|
||||
additionalItems?: JsonSchema | boolean;
|
||||
additionalProperties?: JsonSchema | boolean;
|
||||
enum?: unknown[];
|
||||
enumIncludesNull?: boolean;
|
||||
const?: unknown;
|
||||
default?: unknown;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
exclusiveMinimum?: number;
|
||||
exclusiveMaximum?: number;
|
||||
multipleOf?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
uniqueItems?: boolean;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
allOf?: JsonSchema[];
|
||||
@@ -34,29 +45,22 @@ export function schemaType(schema: JsonSchema): string | undefined {
|
||||
return schema.type;
|
||||
}
|
||||
|
||||
export function defaultValue(schema?: JsonSchema): unknown {
|
||||
if (!schema) {
|
||||
return "";
|
||||
}
|
||||
if (schema.default !== undefined) {
|
||||
return schema.default;
|
||||
}
|
||||
const type = schemaType(schema);
|
||||
switch (type) {
|
||||
case "object":
|
||||
return {};
|
||||
case "array":
|
||||
return [];
|
||||
case "boolean":
|
||||
return false;
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "string":
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
export function configFieldId(path: Array<string | number>, suffix: string): string {
|
||||
const key =
|
||||
path.length === 0
|
||||
? "root"
|
||||
: path
|
||||
.map((segment) => {
|
||||
const value = String(segment);
|
||||
let encoded = "";
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
encoded += value.charCodeAt(index).toString(16).padStart(4, "0");
|
||||
}
|
||||
const type = typeof segment === "number" ? "n" : "s";
|
||||
return `${type}${value.length}-${encoded}`;
|
||||
})
|
||||
.join("_");
|
||||
return `config-field-${key}-${suffix}`;
|
||||
}
|
||||
|
||||
export function pathKey(path: Array<string | number>): string {
|
||||
|
||||
259
ui/src/components/config-form.validation.ts
Normal file
259
ui/src/components/config-form.validation.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
// Control UI helpers validate schema-backed values without arbitrary depth cutoffs.
|
||||
import type { JsonSchema } from "./config-form.shared.ts";
|
||||
|
||||
type DecimalRational = {
|
||||
numerator: bigint;
|
||||
denominator: bigint;
|
||||
};
|
||||
|
||||
export function decimalRational(value: number): DecimalRational | undefined {
|
||||
if (!Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const [coefficientText = "", exponentText] = String(value).toLowerCase().split("e");
|
||||
const negative = coefficientText.startsWith("-");
|
||||
const coefficient = negative ? coefficientText.slice(1) : coefficientText;
|
||||
const [whole = "0", fraction = ""] = coefficient.split(".");
|
||||
const exponent = Number(exponentText ?? 0);
|
||||
const digits = BigInt(`${whole}${fraction}`);
|
||||
const fractionalPlaces = fraction.length - exponent;
|
||||
const numerator = fractionalPlaces < 0 ? digits * 10n ** BigInt(-fractionalPlaces) : digits;
|
||||
return {
|
||||
numerator: negative ? -numerator : numerator,
|
||||
denominator: fractionalPlaces > 0 ? 10n ** BigInt(fractionalPlaces) : 1n,
|
||||
};
|
||||
}
|
||||
|
||||
function isNumericMultiple(value: number, multipleOf: number): boolean {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(multipleOf) || multipleOf <= 0) {
|
||||
return false;
|
||||
}
|
||||
const valueRational = decimalRational(value);
|
||||
const multipleRational = decimalRational(multipleOf);
|
||||
if (!valueRational || !multipleRational) {
|
||||
return false;
|
||||
}
|
||||
const dividend = valueRational.numerator * multipleRational.denominator;
|
||||
const divisor = valueRational.denominator * multipleRational.numerator;
|
||||
return divisor !== 0n && dividend % divisor === 0n;
|
||||
}
|
||||
|
||||
function configValuesEqualInternal(left: unknown, right: unknown): boolean {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
if (!left || !right || typeof left !== "object" || typeof right !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return (
|
||||
Array.isArray(left) &&
|
||||
Array.isArray(right) &&
|
||||
left.length === right.length &&
|
||||
left.every((entry, index) => configValuesEqualInternal(entry, right[index]))
|
||||
);
|
||||
}
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const keys = Object.keys(leftRecord);
|
||||
return (
|
||||
keys.length === Object.keys(rightRecord).length &&
|
||||
keys.every(
|
||||
(key) =>
|
||||
Object.hasOwn(rightRecord, key) &&
|
||||
configValuesEqualInternal(leftRecord[key], rightRecord[key]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isAcyclicValue(
|
||||
value: unknown,
|
||||
active = new WeakSet<object>(),
|
||||
complete = new WeakSet<object>(),
|
||||
): boolean {
|
||||
if (!value || typeof value !== "object") {
|
||||
return true;
|
||||
}
|
||||
if (complete.has(value)) {
|
||||
return true;
|
||||
}
|
||||
if (active.has(value)) {
|
||||
return false;
|
||||
}
|
||||
active.add(value);
|
||||
const entries = Array.isArray(value) ? value : Object.values(value);
|
||||
const acyclic = entries.every((entry) => isAcyclicValue(entry, active, complete));
|
||||
active.delete(value);
|
||||
if (acyclic) {
|
||||
complete.add(value);
|
||||
}
|
||||
return acyclic;
|
||||
}
|
||||
|
||||
export function configValuesEqual(left: unknown, right: unknown): boolean {
|
||||
return isAcyclicValue(left) && isAcyclicValue(right) && configValuesEqualInternal(left, right);
|
||||
}
|
||||
|
||||
function matchesJsonSchemaType(type: string, value: unknown): boolean {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return typeof value === "string";
|
||||
case "number":
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
case "integer":
|
||||
return typeof value === "number" && Number.isInteger(value);
|
||||
case "boolean":
|
||||
return typeof value === "boolean";
|
||||
case "null":
|
||||
return value === null;
|
||||
case "array":
|
||||
return Array.isArray(value);
|
||||
case "object":
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ownPropertySchema(schema: JsonSchema, key: string): JsonSchema | undefined {
|
||||
const properties = schema.properties;
|
||||
return properties && Object.hasOwn(properties, key) ? properties[key] : undefined;
|
||||
}
|
||||
|
||||
function validateSupportedConfigValue(
|
||||
schema: JsonSchema,
|
||||
value: unknown,
|
||||
active: Map<JsonSchema, Set<unknown>>,
|
||||
): boolean {
|
||||
let activeValues = active.get(schema);
|
||||
if (activeValues?.has(value)) {
|
||||
return true;
|
||||
}
|
||||
if (!activeValues) {
|
||||
activeValues = new Set();
|
||||
active.set(schema, activeValues);
|
||||
}
|
||||
activeValues.add(value);
|
||||
try {
|
||||
if (
|
||||
(schema.allOf &&
|
||||
!schema.allOf.every((entry) => validateSupportedConfigValue(entry, value, active))) ||
|
||||
(schema.anyOf &&
|
||||
!schema.anyOf.some((entry) => validateSupportedConfigValue(entry, value, active))) ||
|
||||
(schema.oneOf &&
|
||||
schema.oneOf.filter((entry) => validateSupportedConfigValue(entry, value, active))
|
||||
.length !== 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (schema.const !== undefined && !configValuesEqual(schema.const, value)) {
|
||||
return false;
|
||||
}
|
||||
if (schema.enum && !schema.enum.some((entry) => configValuesEqual(entry, value))) {
|
||||
if (!(value === null && schema.nullable && schema.enumIncludesNull)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (value === null && schema.nullable) {
|
||||
return true;
|
||||
}
|
||||
const declaredTypes =
|
||||
typeof schema.type === "string"
|
||||
? [schema.type]
|
||||
: Array.isArray(schema.type)
|
||||
? schema.type
|
||||
: [];
|
||||
if (
|
||||
declaredTypes.length > 0 &&
|
||||
!declaredTypes.some((type) => matchesJsonSchemaType(type, value))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const length = Array.from(value).length;
|
||||
if (
|
||||
(schema.minLength !== undefined && length < schema.minLength) ||
|
||||
(schema.maxLength !== undefined && length > schema.maxLength)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (schema.pattern) {
|
||||
try {
|
||||
if (!new RegExp(schema.pattern, "u").test(value)) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
Number.isFinite(value) &&
|
||||
(schema.minimum === undefined || value >= schema.minimum) &&
|
||||
(schema.maximum === undefined || value <= schema.maximum) &&
|
||||
(schema.exclusiveMinimum === undefined || value > schema.exclusiveMinimum) &&
|
||||
(schema.exclusiveMaximum === undefined || value < schema.exclusiveMaximum) &&
|
||||
(schema.multipleOf === undefined || isNumericMultiple(value, schema.multipleOf))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (
|
||||
(schema.minItems !== undefined && value.length < schema.minItems) ||
|
||||
(schema.maxItems !== undefined && value.length > schema.maxItems) ||
|
||||
(schema.uniqueItems === true &&
|
||||
value.some((item, index) =>
|
||||
value.slice(index + 1).some((candidate) => configValuesEqual(item, candidate)),
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const items = schema.items;
|
||||
if (!Array.isArray(items)) {
|
||||
return items
|
||||
? value.every((item) => validateSupportedConfigValue(items, item, active))
|
||||
: true;
|
||||
}
|
||||
return value.every((item, index) => {
|
||||
const itemSchema = items[index];
|
||||
if (itemSchema) {
|
||||
return validateSupportedConfigValue(itemSchema, item, active);
|
||||
}
|
||||
return schema.additionalItems && typeof schema.additionalItems === "object"
|
||||
? validateSupportedConfigValue(schema.additionalItems, item, active)
|
||||
: schema.additionalItems !== false;
|
||||
});
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if ((schema.required ?? []).some((key) => !Object.hasOwn(record, key))) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(record).every(([key, entryValue]) => {
|
||||
const propertySchema = ownPropertySchema(schema, key);
|
||||
if (propertySchema) {
|
||||
return validateSupportedConfigValue(propertySchema, entryValue, active);
|
||||
}
|
||||
return schema.additionalProperties && typeof schema.additionalProperties === "object"
|
||||
? validateSupportedConfigValue(schema.additionalProperties, entryValue, active)
|
||||
: schema.additionalProperties !== false;
|
||||
});
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
return true;
|
||||
default:
|
||||
return value === null;
|
||||
}
|
||||
} finally {
|
||||
activeValues.delete(value);
|
||||
if (activeValues.size === 0) {
|
||||
active.delete(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupportedConfigValueValid(schema: JsonSchema, value: unknown): boolean {
|
||||
return validateSupportedConfigValue(schema, value, new Map());
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export function renderSettingsNavRow(
|
||||
* title is not associated with the input; prefer renderSettingsToggleRow. */
|
||||
export function renderSettingsToggle(props: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
onChange: (checked: boolean) => boolean | void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
}): TemplateResult {
|
||||
@@ -144,7 +144,10 @@ export function renderSettingsToggle(props: {
|
||||
.checked=${live(props.checked)}
|
||||
?disabled=${props.disabled ?? false}
|
||||
@change=${(event: Event) => {
|
||||
props.onChange((event.currentTarget as HTMLElement & { checked: boolean }).checked);
|
||||
const target = event.currentTarget as HTMLElement & { checked: boolean };
|
||||
if (props.onChange(target.checked) === false) {
|
||||
target.checked = props.checked;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="settings-control__sr-label">${props.ariaLabel}</span>
|
||||
@@ -159,7 +162,7 @@ export function renderSettingsToggleRow(props: {
|
||||
ariaLabel?: unknown;
|
||||
description?: unknown;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
onChange: (checked: boolean) => boolean | void;
|
||||
/** Runs synchronously during direct activation for effects gated on user activation. */
|
||||
onAct?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
@@ -205,7 +208,10 @@ export function renderSettingsToggleRow(props: {
|
||||
@click=${notifySwitchActivation}
|
||||
@keydown=${notifySwitchActivation}
|
||||
@change=${(event: Event) => {
|
||||
props.onChange((event.currentTarget as HTMLElement & { checked: boolean }).checked);
|
||||
const target = event.currentTarget as HTMLElement & { checked: boolean };
|
||||
if (props.onChange(target.checked) === false) {
|
||||
target.checked = props.checked;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="settings-control__sr-label">${props.ariaLabel ?? props.title}</span>
|
||||
|
||||
190
ui/src/e2e/config-form-integrity.e2e.test.ts
Normal file
190
ui/src/e2e/config-form-integrity.e2e.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// Control UI tests cover schema-backed form constraints, draft recovery, and accessible names.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
|
||||
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const proofVariant = process.env.OPENCLAW_UI_PROOF_VARIANT ?? "after";
|
||||
const uiProofArtifactDir = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"control-ui-e2e",
|
||||
"config-form-integrity",
|
||||
proofVariant,
|
||||
);
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
function configFormIntegrityMocks() {
|
||||
const config = {
|
||||
laboratory: {
|
||||
endpoint: "local-api",
|
||||
metadata: { mode: "safe" },
|
||||
retryBudget: 4,
|
||||
weights: [2],
|
||||
codes: [],
|
||||
},
|
||||
};
|
||||
return {
|
||||
"config.get": {
|
||||
appliedConfigHash: "config-form-integrity-e2e",
|
||||
config,
|
||||
configRevisionHash: "config-form-integrity-e2e",
|
||||
hash: "config-form-integrity-e2e",
|
||||
issues: [],
|
||||
raw: JSON.stringify(config),
|
||||
valid: true,
|
||||
},
|
||||
"config.schema": {
|
||||
generatedAt: "2026-07-29T00:00:00.000Z",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
laboratory: {
|
||||
type: "object",
|
||||
title: "Form Integrity",
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: "string",
|
||||
title: "Endpoint slug",
|
||||
description: "Lowercase letters and hyphens only.",
|
||||
minLength: 3,
|
||||
maxLength: 16,
|
||||
pattern: "[a-z-]+",
|
||||
},
|
||||
retryBudget: {
|
||||
type: "integer",
|
||||
title: "Retry budget",
|
||||
description: "Even values from two through eight.",
|
||||
minimum: 2,
|
||||
maximum: 8,
|
||||
multipleOf: 2,
|
||||
},
|
||||
weights: {
|
||||
type: "array",
|
||||
title: "Weights",
|
||||
items: { type: "integer", minimum: 2, maximum: 8, multipleOf: 2 },
|
||||
},
|
||||
codes: {
|
||||
type: "array",
|
||||
title: "Codes",
|
||||
items: {
|
||||
type: "string",
|
||||
minLength: 3,
|
||||
pattern: "^[0-9]+$",
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uiHints: {},
|
||||
version: "e2e",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI config form integrity mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(
|
||||
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
|
||||
);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("keeps invalid drafts visible and exposes schema constraints to the browser", async () => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 1000, width: 1440 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, { methodResponses: configFormIntegrityMocks() });
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}settings/advanced?section=laboratory`);
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
const endpoint = page.getByRole("textbox", { name: "Endpoint slug" });
|
||||
const retryBudget = page.getByRole("spinbutton", { name: "Retry budget" });
|
||||
const weights = page.locator(".cfg-array").filter({ hasText: "Weights" });
|
||||
const addWeight = weights.getByRole("button", { name: "Add" });
|
||||
await addWeight.click();
|
||||
|
||||
const metadataEditor = page.locator(".cfg-map textarea");
|
||||
await metadataEditor.fill('{"mode":');
|
||||
await metadataEditor.blur();
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
await page.locator("#config-section-panel").screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "01-invalid-json-draft.png"),
|
||||
});
|
||||
}
|
||||
|
||||
await expect.poll(() => endpoint.getAttribute("minlength")).toBeNull();
|
||||
await expect.poll(() => endpoint.getAttribute("maxlength")).toBeNull();
|
||||
await expect.poll(() => endpoint.getAttribute("pattern")).toBeNull();
|
||||
await expect.poll(() => endpoint.getAttribute("aria-describedby")).not.toBeNull();
|
||||
await expect.poll(() => retryBudget.getAttribute("min")).toBe("2");
|
||||
await expect.poll(() => retryBudget.getAttribute("max")).toBe("8");
|
||||
await expect.poll(() => retryBudget.getAttribute("step")).toBe("2");
|
||||
await expect
|
||||
.poll(() => page.locator(".cfg-array input[type='number']").last().inputValue())
|
||||
.toBe("2");
|
||||
await expect.poll(() => metadataEditor.inputValue()).toBe('{"mode":');
|
||||
await expect.poll(() => metadataEditor.getAttribute("aria-invalid")).toBe("true");
|
||||
await expect.poll(() => page.getByRole("alert").textContent()).toContain("valid JSON");
|
||||
|
||||
const codes = page.locator(".cfg-array").filter({ hasText: "Codes" });
|
||||
await codes.getByRole("button", { name: "Add" }).click();
|
||||
const codeDraft = codes.locator(".cfg-collection-draft");
|
||||
await expect.poll(() => codeDraft.isVisible()).toBe(true);
|
||||
const codeValue = codeDraft.getByRole("textbox", { name: "Add: Codes" });
|
||||
await codeValue.fill("abc");
|
||||
await codeDraft.getByRole("button", { name: "Add" }).click();
|
||||
await expect.poll(() => codeValue.getAttribute("aria-invalid")).toBe("true");
|
||||
await expect.poll(() => codes.locator("input[aria-label='Codes']").count()).toBe(0);
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await page.locator("#config-section-panel").screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "02-pattern-collection-draft.png"),
|
||||
});
|
||||
}
|
||||
|
||||
await codeValue.fill("123");
|
||||
await codeDraft.getByRole("button", { name: "Add" }).click();
|
||||
await expect
|
||||
.poll(() => codes.locator("input[aria-label='Codes']").last().inputValue())
|
||||
.toBe("123");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1086,7 +1086,12 @@ export const en: TranslationMap = {
|
||||
defaultValue: "Default: {value}",
|
||||
resetToDefault: "Reset to default",
|
||||
select: "Select...",
|
||||
nullValue: "null",
|
||||
jsonValue: "JSON value",
|
||||
invalidJson: "Enter valid JSON before leaving this field.",
|
||||
invalidString: "Enter a value that matches this setting's constraints.",
|
||||
invalidNumber: "Enter a value within the allowed range and step.",
|
||||
draftRejected: "This setting could not be saved. Your draft is still here.",
|
||||
unsupportedArray: "Unsupported array schema. Use Raw mode.",
|
||||
itemCountOne: "{count} item",
|
||||
itemCount: "{count} items",
|
||||
|
||||
@@ -166,7 +166,10 @@ describe("createRuntimeConfigCapability", () => {
|
||||
if (method === "config.get") {
|
||||
configGetCount += 1;
|
||||
return {
|
||||
config: configGetCount === 1 ? { count: 1, enabled: false, tags: [1], label: "ok" } : {},
|
||||
config:
|
||||
configGetCount === 1
|
||||
? { count: 1, composedCount: 2, enabled: false, tags: [1], label: "ok" }
|
||||
: {},
|
||||
hash: configGetCount === 1 ? "hash-1" : "hash-2",
|
||||
valid: true,
|
||||
issues: [],
|
||||
@@ -178,6 +181,7 @@ describe("createRuntimeConfigCapability", () => {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "number" },
|
||||
composedCount: { type: "number", allOf: [{ minimum: 2 }] },
|
||||
enabled: { type: "boolean" },
|
||||
tags: { type: "array", items: { type: "integer" } },
|
||||
label: { type: "string", minLength: 1 },
|
||||
@@ -195,6 +199,7 @@ describe("createRuntimeConfigCapability", () => {
|
||||
|
||||
await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]);
|
||||
runtimeConfig.patchForm(["count"], "42.5");
|
||||
runtimeConfig.patchForm(["composedCount"], "8.5");
|
||||
runtimeConfig.patchForm(["enabled"], "true");
|
||||
runtimeConfig.patchForm(["tags"], ["7", ""]);
|
||||
runtimeConfig.patchForm(["label"], "");
|
||||
@@ -204,7 +209,81 @@ describe("createRuntimeConfigCapability", () => {
|
||||
expect(submission?.params).toMatchObject({ baseHash: "hash-1" });
|
||||
const raw = (submission?.params as { raw?: unknown } | undefined)?.raw;
|
||||
expect(typeof raw).toBe("string");
|
||||
expect(JSON.parse(raw as string)).toEqual({ count: 42.5, enabled: true, tags: [7] });
|
||||
expect(JSON.parse(raw as string)).toEqual({
|
||||
count: 42.5,
|
||||
composedCount: 8.5,
|
||||
enabled: true,
|
||||
tags: [7],
|
||||
});
|
||||
runtimeConfig.dispose();
|
||||
});
|
||||
|
||||
it("submits only decimal numeric spellings as numbers", async () => {
|
||||
const submitted: Array<{ method: string; params: unknown }> = [];
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
config: {},
|
||||
hash: "hash-1",
|
||||
valid: true,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
if (method === "config.schema") {
|
||||
return {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hex: { type: "number" },
|
||||
binary: { type: "integer" },
|
||||
explicitPlus: { type: "number" },
|
||||
separator: { type: "number" },
|
||||
nonFinite: { type: "number" },
|
||||
scientific: { type: "number" },
|
||||
decimal: { type: "number" },
|
||||
fractionalInteger: { type: "integer" },
|
||||
unionRadix: { anyOf: [{ type: "integer" }, { type: "string" }] },
|
||||
unionScientific: { anyOf: [{ type: "integer" }, { type: "string" }] },
|
||||
},
|
||||
},
|
||||
uiHints: {},
|
||||
};
|
||||
}
|
||||
submitted.push({ method, params });
|
||||
return { hash: "hash-2" };
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const { gateway } = createGatewayHarness(client);
|
||||
const runtimeConfig = createRuntimeConfigCapability(gateway);
|
||||
|
||||
await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]);
|
||||
runtimeConfig.patchForm(["hex"], "0x10");
|
||||
runtimeConfig.patchForm(["binary"], "0b1010");
|
||||
runtimeConfig.patchForm(["explicitPlus"], "+5");
|
||||
runtimeConfig.patchForm(["separator"], "1_000");
|
||||
runtimeConfig.patchForm(["nonFinite"], "Infinity");
|
||||
runtimeConfig.patchForm(["scientific"], "-2.5E-3");
|
||||
runtimeConfig.patchForm(["decimal"], ".5");
|
||||
runtimeConfig.patchForm(["fractionalInteger"], "42.5");
|
||||
runtimeConfig.patchForm(["unionRadix"], "0o17");
|
||||
runtimeConfig.patchForm(["unionScientific"], "1e5");
|
||||
|
||||
await expect(runtimeConfig.save()).resolves.toBe(true);
|
||||
const submission = submitted.find((entry) => entry.method === "config.set");
|
||||
const raw = (submission?.params as { raw?: unknown } | undefined)?.raw;
|
||||
expect(typeof raw).toBe("string");
|
||||
expect(JSON.parse(raw as string)).toEqual({
|
||||
hex: "0x10",
|
||||
binary: "0b1010",
|
||||
explicitPlus: "+5",
|
||||
separator: "1_000",
|
||||
nonFinite: "Infinity",
|
||||
scientific: -0.0025,
|
||||
decimal: 0.5,
|
||||
fractionalInteger: "42.5",
|
||||
unionRadix: "0o17",
|
||||
unionScientific: 100_000,
|
||||
});
|
||||
runtimeConfig.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts";
|
||||
import type { ApplicationGatewayPhase } from "../../app/gateway.ts";
|
||||
import { coerceConfigFormNumberString } from "../../components/config-form.constraints.ts";
|
||||
import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { copyToClipboard } from "../clipboard.ts";
|
||||
@@ -386,21 +387,6 @@ function asJsonSchema(value: unknown): JsonSchema | null {
|
||||
return value as JsonSchema;
|
||||
}
|
||||
|
||||
function coerceNumberString(value: string, integer: boolean): number | undefined | string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return value;
|
||||
}
|
||||
if (integer && !Number.isInteger(parsed)) {
|
||||
return value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function coerceBooleanString(value: string): boolean | string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "true") {
|
||||
@@ -418,8 +404,9 @@ function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
|
||||
}
|
||||
|
||||
if (schema.allOf && schema.allOf.length > 0) {
|
||||
let next: unknown = value;
|
||||
for (const segment of schema.allOf) {
|
||||
const { allOf, ...baseSchema } = schema;
|
||||
let next: unknown = coerceFormValues(value, baseSchema);
|
||||
for (const segment of allOf) {
|
||||
next = coerceFormValues(next, segment);
|
||||
}
|
||||
return next;
|
||||
@@ -443,7 +430,7 @@ function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
|
||||
for (const variant of variants) {
|
||||
const variantType = schemaType(variant);
|
||||
if (variantType === "number" || variantType === "integer") {
|
||||
const coerced = coerceNumberString(value, variantType === "integer");
|
||||
const coerced = coerceConfigFormNumberString(value, variantType === "integer");
|
||||
if (coerced === undefined || typeof coerced === "number") {
|
||||
return coerced;
|
||||
}
|
||||
@@ -470,7 +457,7 @@ function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
|
||||
|
||||
if (type === "number" || type === "integer") {
|
||||
if (typeof value === "string") {
|
||||
const coerced = coerceNumberString(value, type === "integer");
|
||||
const coerced = coerceConfigFormNumberString(value, type === "integer");
|
||||
if (coerced === undefined || typeof coerced === "number") {
|
||||
return coerced;
|
||||
}
|
||||
|
||||
@@ -614,6 +614,53 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cfg-structured-draft {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.cfg-structured-draft__error .settings-row__control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cfg-json-editor {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cfg-json-editor > .settings-input,
|
||||
.cfg-json-editor > .settings-secret {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cfg-array .settings-row--stacked .settings-row__control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cfg-array .settings-row--stacked .settings-row__control > input[type="number"] {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cfg-collection-draft__controls {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cfg-collection-draft__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-content :is(input, textarea, select)[aria-invalid="true"] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Redacted click-to-reveal inputs/textareas. */
|
||||
.cfg-redacted {
|
||||
opacity: 0.7;
|
||||
|
||||
Reference in New Issue
Block a user