fix: show raw any-map entries in config UI

This commit is contained in:
Peter Steinberger
2026-01-15 08:24:16 +00:00
parent 35ddd8db5e
commit 3171781d58
3 changed files with 228 additions and 63 deletions

View File

@@ -253,6 +253,28 @@ describe("config form renderer", () => {
expect(analysis.unsupportedPaths).not.toContain("note");
});
it("ignores untyped additionalProperties schemas", () => {
const schema = {
type: "object",
properties: {
channels: {
type: "object",
properties: {
whatsapp: {
type: "object",
properties: {
enabled: { type: "boolean" },
},
},
},
additionalProperties: {},
},
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).not.toContain("channels");
});
it("flags additionalProperties true", () => {
const schema = {
type: "object",

View File

@@ -5,6 +5,25 @@ export type ConfigSchemaAnalysis = {
unsupportedPaths: string[];
};
const META_KEYS = new Set(["title", "description", "default", "nullable"]);
function isAnySchema(schema: JsonSchema): boolean {
const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key));
return keys.length === 0;
}
function normalizeEnum(values: unknown[]): { enumValues: unknown[]; nullable: boolean } {
const filtered = values.filter((value) => value != null);
const nullable = filtered.length !== values.length;
const enumValues: unknown[] = [];
for (const value of filtered) {
if (!enumValues.some((existing) => Object.is(existing, value))) {
enumValues.push(value);
}
}
return { enumValues, nullable };
}
export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
if (!raw || typeof raw !== "object") {
return { schema: null, unsupportedPaths: ["<root>"] };
@@ -16,15 +35,14 @@ function normalizeSchemaNode(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis {
const unsupportedPaths: string[] = [];
const unsupported = new Set<string>();
const normalized: JsonSchema = { ...schema };
const pathLabel = pathKey(path) || "<root>";
if (schema.anyOf || schema.oneOf || schema.allOf) {
const union = normalizeUnion(schema, path);
if (union) return union;
unsupportedPaths.push(pathLabel);
return { schema, unsupportedPaths };
return { schema, unsupportedPaths: [pathLabel] };
}
const nullable = Array.isArray(schema.type) && schema.type.includes("null");
@@ -32,9 +50,13 @@ function normalizeSchemaNode(
schemaType(schema) ??
(schema.properties || schema.additionalProperties ? "object" : undefined);
normalized.type = type ?? schema.type;
normalized.nullable = nullable || schema.nullable;
if (nullable && !normalized.nullable) {
normalized.nullable = true;
if (normalized.enum) {
const { enumValues, nullable: enumNullable } = normalizeEnum(normalized.enum);
normalized.enum = enumValues;
if (enumNullable) normalized.nullable = true;
if (enumValues.length === 0) unsupported.add(pathLabel);
}
if (type === "object") {
@@ -42,80 +64,133 @@ function normalizeSchemaNode(
const normalizedProps: Record<string, JsonSchema> = {};
for (const [key, value] of Object.entries(properties)) {
const res = normalizeSchemaNode(value, [...path, key]);
normalizedProps[key] = res.schema ?? value;
unsupportedPaths.push(...res.unsupportedPaths);
if (res.schema) normalizedProps[key] = res.schema;
for (const entry of res.unsupportedPaths) unsupported.add(entry);
}
normalized.properties = normalizedProps;
if (
if (schema.additionalProperties === true) {
unsupported.add(pathLabel);
} else if (schema.additionalProperties === false) {
normalized.additionalProperties = false;
} else if (
schema.additionalProperties &&
typeof schema.additionalProperties === "object"
) {
const res = normalizeSchemaNode(
schema.additionalProperties as JsonSchema,
[...path, "*"],
);
normalized.additionalProperties =
res.schema ?? schema.additionalProperties;
unsupportedPaths.push(...res.unsupportedPaths);
if (!isAnySchema(schema.additionalProperties as JsonSchema)) {
const res = normalizeSchemaNode(
schema.additionalProperties as JsonSchema,
[...path, "*"],
);
normalized.additionalProperties =
res.schema ?? (schema.additionalProperties as JsonSchema);
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) {
unsupported.add(pathLabel);
} else {
const res = normalizeSchemaNode(itemsSchema, [...path, "*"]);
normalized.items = res.schema ?? itemsSchema;
if (res.unsupportedPaths.length > 0) unsupported.add(pathLabel);
}
} else if (
type !== "string" &&
type !== "number" &&
type !== "integer" &&
type !== "boolean" &&
!normalized.enum
) {
unsupported.add(pathLabel);
}
if (type === "array" && schema.items && !Array.isArray(schema.items)) {
const res = normalizeSchemaNode(schema.items, [...path, 0]);
normalized.items = res.schema ?? schema.items;
unsupportedPaths.push(...res.unsupportedPaths);
}
return { schema: normalized, unsupportedPaths };
return {
schema: normalized,
unsupportedPaths: Array.from(unsupported),
};
}
function normalizeUnion(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis | null {
const union = schema.anyOf ?? schema.oneOf ?? schema.allOf ?? [];
const pathLabel = pathKey(path) || "<root>";
if (union.length === 0) return null;
if (schema.allOf) return null;
const union = schema.anyOf ?? schema.oneOf;
if (!union) return null;
const nonNull = union.filter(
(v) =>
!(
v.type === "null" ||
(Array.isArray(v.type) && v.type.includes("null"))
),
);
const literals: unknown[] = [];
const remaining: JsonSchema[] = [];
let nullable = false;
if (nonNull.length === 1) {
const res = normalizeSchemaNode(nonNull[0], path);
return {
schema: { ...(res.schema ?? nonNull[0]), nullable: true },
unsupportedPaths: res.unsupportedPaths,
};
for (const entry of union) {
if (!entry || typeof entry !== "object") return null;
if (Array.isArray(entry.enum)) {
const { enumValues, nullable: enumNullable } = normalizeEnum(entry.enum);
literals.push(...enumValues);
if (enumNullable) nullable = true;
continue;
}
if ("const" in entry) {
if (entry.const == null) {
nullable = true;
continue;
}
literals.push(entry.const);
continue;
}
if (schemaType(entry) === "null") {
nullable = true;
continue;
}
remaining.push(entry);
}
const literals = nonNull
.map((v) => {
if (v.const !== undefined) return v.const;
if (v.enum && v.enum.length === 1) return v.enum[0];
return undefined;
})
.filter((v) => v !== undefined);
if (literals.length === nonNull.length) {
if (literals.length > 0 && remaining.length === 0) {
const unique: unknown[] = [];
for (const value of literals) {
if (!unique.some((existing) => Object.is(existing, value))) {
unique.push(value);
}
}
return {
schema: {
...schema,
enum: unique,
nullable,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
type: "string",
enum: literals as unknown[],
},
unsupportedPaths: [],
};
}
return { schema, unsupportedPaths: [pathLabel] };
}
if (remaining.length === 1) {
const res = normalizeSchemaNode(remaining[0], path);
if (res.schema) {
res.schema.nullable = nullable || res.schema.nullable;
}
return res;
}
const primitiveTypes = ["string", "number", "integer", "boolean"];
if (
remaining.length > 0 &&
literals.length === 0 &&
remaining.every((entry) => entry.type && primitiveTypes.includes(String(entry.type)))
) {
return {
schema: {
...schema,
nullable,
},
unsupportedPaths: [],
};
}
return null;
}

View File

@@ -10,6 +10,22 @@ import {
type JsonSchema,
} from "./config-form.shared";
const META_KEYS = new Set(["title", "description", "default", "nullable"]);
function isAnySchema(schema: JsonSchema): boolean {
const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key));
return keys.length === 0;
}
function jsonValue(value: unknown): string {
if (value === undefined) return "";
try {
return JSON.stringify(value, null, 2) ?? "";
} catch {
return "";
}
}
export function renderNode(params: {
schema: JsonSchema;
value: unknown;
@@ -83,6 +99,34 @@ export function renderNode(params: {
}
}
if (schema.enum) {
const options = schema.enum;
const currentIndex = options.findIndex(
(opt) => opt === value || String(opt) === String(value),
);
const unset = "__unset__";
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
?disabled=${disabled}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === unset ? undefined : options[Number(idx)]);
}}
>
<option value=${unset}>Select…</option>
${options.map(
(opt, idx) =>
html`<option value=${String(idx)}>${String(opt)}</option>`,
)}
</select>
</label>
`;
}
if (type === "object") {
const obj = (value ?? {}) as Record<string, unknown>;
const props = schema.properties ?? {};
@@ -262,6 +306,7 @@ function renderMapField(params: {
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } =
params;
const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(
([key]) => !reservedKeys.has(key),
);
@@ -280,7 +325,7 @@ function renderMapField(params: {
index += 1;
key = `new-${index}`;
}
next[key] = defaultValue(schema);
next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next);
}}
>
@@ -291,6 +336,7 @@ function renderMapField(params: {
? html`<div class="muted">No entries yet.</div>`
: entries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
return html`<div class="array-item" style="gap: 8px;">
<input
class="mono"
@@ -308,16 +354,39 @@ function renderMapField(params: {
}}
/>
<div style="flex: 1;">
${renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
${anySchema
? html`<label class="field" style="margin: 0;">
<div class="muted">JSON value</div>
<textarea
class="mono"
rows="5"
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
</label>`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
</div>
<button
class="btn danger"
@@ -335,4 +404,3 @@ function renderMapField(params: {
</div>
`;
}