fix: show raw any-map entries in config UI
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user