From 3171781d58dae65df9d0ddf553883a493aa58c1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 08:24:16 +0000 Subject: [PATCH] fix: show raw any-map entries in config UI --- ui/src/ui/config-form.browser.test.ts | 22 +++ ui/src/ui/views/config-form.analyze.ts | 177 ++++++++++++++++++------- ui/src/ui/views/config-form.node.ts | 92 +++++++++++-- 3 files changed, 228 insertions(+), 63 deletions(-) diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index f6014d29d..cb40b9908 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -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", diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts index 4153fe058..5e72ffff1 100644 --- a/ui/src/ui/views/config-form.analyze.ts +++ b/ui/src/ui/views/config-form.analyze.ts @@ -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: [""] }; @@ -16,15 +35,14 @@ function normalizeSchemaNode( schema: JsonSchema, path: Array, ): ConfigSchemaAnalysis { - const unsupportedPaths: string[] = []; + const unsupported = new Set(); const normalized: JsonSchema = { ...schema }; const pathLabel = pathKey(path) || ""; 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 = {}; 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, ): ConfigSchemaAnalysis | null { - const union = schema.anyOf ?? schema.oneOf ?? schema.allOf ?? []; - const pathLabel = pathKey(path) || ""; - 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; +} diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index f2563827c..ca45cf3be 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -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` + + `; + } + if (type === "object") { const obj = (value ?? {}) as Record; 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`
No entries yet.
` : entries.map(([key, entryValue]) => { const valuePath = [...path, key]; + const fallback = jsonValue(entryValue); return html`
- ${renderNode({ - schema, - value: entryValue, - path: valuePath, - hints, - unsupported, - disabled, - showLabel: false, - onPatch, - })} + ${anySchema + ? html`` + : renderNode({ + schema, + value: entryValue, + path: valuePath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })}