fix: show raw any-map entries in config UI
This commit is contained in:
parent
35ddd8db5e
commit
3171781d58
@ -253,6 +253,28 @@ describe("config form renderer", () => {
|
|||||||
expect(analysis.unsupportedPaths).not.toContain("note");
|
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", () => {
|
it("flags additionalProperties true", () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
|
|||||||
@ -5,6 +5,25 @@ export type ConfigSchemaAnalysis = {
|
|||||||
unsupportedPaths: string[];
|
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 {
|
export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
|
||||||
if (!raw || typeof raw !== "object") {
|
if (!raw || typeof raw !== "object") {
|
||||||
return { schema: null, unsupportedPaths: ["<root>"] };
|
return { schema: null, unsupportedPaths: ["<root>"] };
|
||||||
@ -16,15 +35,14 @@ function normalizeSchemaNode(
|
|||||||
schema: JsonSchema,
|
schema: JsonSchema,
|
||||||
path: Array<string | number>,
|
path: Array<string | number>,
|
||||||
): ConfigSchemaAnalysis {
|
): ConfigSchemaAnalysis {
|
||||||
const unsupportedPaths: string[] = [];
|
const unsupported = new Set<string>();
|
||||||
const normalized: JsonSchema = { ...schema };
|
const normalized: JsonSchema = { ...schema };
|
||||||
const pathLabel = pathKey(path) || "<root>";
|
const pathLabel = pathKey(path) || "<root>";
|
||||||
|
|
||||||
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
||||||
const union = normalizeUnion(schema, path);
|
const union = normalizeUnion(schema, path);
|
||||||
if (union) return union;
|
if (union) return union;
|
||||||
unsupportedPaths.push(pathLabel);
|
return { schema, unsupportedPaths: [pathLabel] };
|
||||||
return { schema, unsupportedPaths };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nullable = Array.isArray(schema.type) && schema.type.includes("null");
|
const nullable = Array.isArray(schema.type) && schema.type.includes("null");
|
||||||
@ -32,9 +50,13 @@ function normalizeSchemaNode(
|
|||||||
schemaType(schema) ??
|
schemaType(schema) ??
|
||||||
(schema.properties || schema.additionalProperties ? "object" : undefined);
|
(schema.properties || schema.additionalProperties ? "object" : undefined);
|
||||||
normalized.type = type ?? schema.type;
|
normalized.type = type ?? schema.type;
|
||||||
|
normalized.nullable = nullable || schema.nullable;
|
||||||
|
|
||||||
if (nullable && !normalized.nullable) {
|
if (normalized.enum) {
|
||||||
normalized.nullable = true;
|
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") {
|
if (type === "object") {
|
||||||
@ -42,80 +64,133 @@ function normalizeSchemaNode(
|
|||||||
const normalizedProps: Record<string, JsonSchema> = {};
|
const normalizedProps: Record<string, JsonSchema> = {};
|
||||||
for (const [key, value] of Object.entries(properties)) {
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
const res = normalizeSchemaNode(value, [...path, key]);
|
const res = normalizeSchemaNode(value, [...path, key]);
|
||||||
normalizedProps[key] = res.schema ?? value;
|
if (res.schema) normalizedProps[key] = res.schema;
|
||||||
unsupportedPaths.push(...res.unsupportedPaths);
|
for (const entry of res.unsupportedPaths) unsupported.add(entry);
|
||||||
}
|
}
|
||||||
normalized.properties = normalizedProps;
|
normalized.properties = normalizedProps;
|
||||||
|
|
||||||
if (
|
if (schema.additionalProperties === true) {
|
||||||
|
unsupported.add(pathLabel);
|
||||||
|
} else if (schema.additionalProperties === false) {
|
||||||
|
normalized.additionalProperties = false;
|
||||||
|
} else if (
|
||||||
schema.additionalProperties &&
|
schema.additionalProperties &&
|
||||||
typeof schema.additionalProperties === "object"
|
typeof schema.additionalProperties === "object"
|
||||||
) {
|
) {
|
||||||
const res = normalizeSchemaNode(
|
if (!isAnySchema(schema.additionalProperties as JsonSchema)) {
|
||||||
schema.additionalProperties as JsonSchema,
|
const res = normalizeSchemaNode(
|
||||||
[...path, "*"],
|
schema.additionalProperties as JsonSchema,
|
||||||
);
|
[...path, "*"],
|
||||||
normalized.additionalProperties =
|
);
|
||||||
res.schema ?? schema.additionalProperties;
|
normalized.additionalProperties =
|
||||||
unsupportedPaths.push(...res.unsupportedPaths);
|
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)) {
|
return {
|
||||||
const res = normalizeSchemaNode(schema.items, [...path, 0]);
|
schema: normalized,
|
||||||
normalized.items = res.schema ?? schema.items;
|
unsupportedPaths: Array.from(unsupported),
|
||||||
unsupportedPaths.push(...res.unsupportedPaths);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return { schema: normalized, unsupportedPaths };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUnion(
|
function normalizeUnion(
|
||||||
schema: JsonSchema,
|
schema: JsonSchema,
|
||||||
path: Array<string | number>,
|
path: Array<string | number>,
|
||||||
): ConfigSchemaAnalysis | null {
|
): ConfigSchemaAnalysis | null {
|
||||||
const union = schema.anyOf ?? schema.oneOf ?? schema.allOf ?? [];
|
if (schema.allOf) return null;
|
||||||
const pathLabel = pathKey(path) || "<root>";
|
const union = schema.anyOf ?? schema.oneOf;
|
||||||
if (union.length === 0) return null;
|
if (!union) return null;
|
||||||
|
|
||||||
const nonNull = union.filter(
|
const literals: unknown[] = [];
|
||||||
(v) =>
|
const remaining: JsonSchema[] = [];
|
||||||
!(
|
let nullable = false;
|
||||||
v.type === "null" ||
|
|
||||||
(Array.isArray(v.type) && v.type.includes("null"))
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (nonNull.length === 1) {
|
for (const entry of union) {
|
||||||
const res = normalizeSchemaNode(nonNull[0], path);
|
if (!entry || typeof entry !== "object") return null;
|
||||||
return {
|
if (Array.isArray(entry.enum)) {
|
||||||
schema: { ...(res.schema ?? nonNull[0]), nullable: true },
|
const { enumValues, nullable: enumNullable } = normalizeEnum(entry.enum);
|
||||||
unsupportedPaths: res.unsupportedPaths,
|
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
|
if (literals.length > 0 && remaining.length === 0) {
|
||||||
.map((v) => {
|
const unique: unknown[] = [];
|
||||||
if (v.const !== undefined) return v.const;
|
for (const value of literals) {
|
||||||
if (v.enum && v.enum.length === 1) return v.enum[0];
|
if (!unique.some((existing) => Object.is(existing, value))) {
|
||||||
return undefined;
|
unique.push(value);
|
||||||
})
|
}
|
||||||
.filter((v) => v !== undefined);
|
}
|
||||||
|
|
||||||
if (literals.length === nonNull.length) {
|
|
||||||
return {
|
return {
|
||||||
schema: {
|
schema: {
|
||||||
...schema,
|
...schema,
|
||||||
|
enum: unique,
|
||||||
|
nullable,
|
||||||
anyOf: undefined,
|
anyOf: undefined,
|
||||||
oneOf: undefined,
|
oneOf: undefined,
|
||||||
allOf: undefined,
|
allOf: undefined,
|
||||||
type: "string",
|
|
||||||
enum: literals as unknown[],
|
|
||||||
},
|
},
|
||||||
unsupportedPaths: [],
|
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,
|
type JsonSchema,
|
||||||
} from "./config-form.shared";
|
} 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: {
|
export function renderNode(params: {
|
||||||
schema: JsonSchema;
|
schema: JsonSchema;
|
||||||
value: unknown;
|
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") {
|
if (type === "object") {
|
||||||
const obj = (value ?? {}) as Record<string, unknown>;
|
const obj = (value ?? {}) as Record<string, unknown>;
|
||||||
const props = schema.properties ?? {};
|
const props = schema.properties ?? {};
|
||||||
@ -262,6 +306,7 @@ function renderMapField(params: {
|
|||||||
}): TemplateResult {
|
}): TemplateResult {
|
||||||
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } =
|
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } =
|
||||||
params;
|
params;
|
||||||
|
const anySchema = isAnySchema(schema);
|
||||||
const entries = Object.entries(value ?? {}).filter(
|
const entries = Object.entries(value ?? {}).filter(
|
||||||
([key]) => !reservedKeys.has(key),
|
([key]) => !reservedKeys.has(key),
|
||||||
);
|
);
|
||||||
@ -280,7 +325,7 @@ function renderMapField(params: {
|
|||||||
index += 1;
|
index += 1;
|
||||||
key = `new-${index}`;
|
key = `new-${index}`;
|
||||||
}
|
}
|
||||||
next[key] = defaultValue(schema);
|
next[key] = anySchema ? {} : defaultValue(schema);
|
||||||
onPatch(path, next);
|
onPatch(path, next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -291,6 +336,7 @@ function renderMapField(params: {
|
|||||||
? html`<div class="muted">No entries yet.</div>`
|
? html`<div class="muted">No entries yet.</div>`
|
||||||
: entries.map(([key, entryValue]) => {
|
: entries.map(([key, entryValue]) => {
|
||||||
const valuePath = [...path, key];
|
const valuePath = [...path, key];
|
||||||
|
const fallback = jsonValue(entryValue);
|
||||||
return html`<div class="array-item" style="gap: 8px;">
|
return html`<div class="array-item" style="gap: 8px;">
|
||||||
<input
|
<input
|
||||||
class="mono"
|
class="mono"
|
||||||
@ -308,16 +354,39 @@ function renderMapField(params: {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style="flex: 1;">
|
<div style="flex: 1;">
|
||||||
${renderNode({
|
${anySchema
|
||||||
schema,
|
? html`<label class="field" style="margin: 0;">
|
||||||
value: entryValue,
|
<div class="muted">JSON value</div>
|
||||||
path: valuePath,
|
<textarea
|
||||||
hints,
|
class="mono"
|
||||||
unsupported,
|
rows="5"
|
||||||
disabled,
|
.value=${fallback}
|
||||||
showLabel: false,
|
?disabled=${disabled}
|
||||||
onPatch,
|
@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>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn danger"
|
class="btn danger"
|
||||||
@ -335,4 +404,3 @@ function renderMapField(params: {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user