diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 8574872d..5ade85ae 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -82,6 +82,94 @@ describe("schemaToForm", () => { }); }); + it("uses enumNames as single-select labels while keeping raw values", () => { + const form = schemaToForm( + { + properties: { + pet: { + type: "string", + enum: ["pet-1", "pet-2"], + enumNames: ["Cats", "Dogs"], + }, + }, + }, + "titledEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + type: "select", + options: [ + { label: "Cats", value: "pet-1" }, + { label: "Dogs", value: "pet-2" }, + ], + }); + }); + + it("falls back to raw single-select labels when enumNames length mismatches", () => { + const form = schemaToForm( + { + properties: { + pet: { + type: "string", + enum: ["pet-1", "pet-2"], + // Only one name for two values — a wrong-length zip would + // mislabel, so the raw values are used as labels. + enumNames: ["Cats"], + }, + }, + }, + "mismatchedEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + options: [ + { label: "pet-1", value: "pet-1" }, + { label: "pet-2", value: "pet-2" }, + ], + }); + }); + + it("uses items.enumNames as array-of-enum labels while keeping raw values", () => { + const form = schemaToForm( + { + properties: { + pets: { + type: "array", + enum: ["pet-1", "pet-2"], + items: { enum: ["pet-1", "pet-2"], enumNames: ["Cats", "Dogs"] }, + }, + }, + }, + "titledArrayEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + type: "select", + options: [ + { label: "Cats", value: "pet-1" }, + { label: "Dogs", value: "pet-2" }, + ], + }); + }); + + it("falls back to raw array-of-enum labels when items.enumNames length mismatches", () => { + const form = schemaToForm( + { + properties: { + pets: { + type: "array", + enum: ["pet-1", "pet-2"], + items: { enum: ["pet-1", "pet-2"], enumNames: ["Cats"] }, + }, + }, + }, + "mismatchedArrayEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + options: [ + { label: "pet-1", value: "pet-1" }, + { label: "pet-2", value: "pet-2" }, + ], + }); + }); + it("carries a JSON Schema default through as the field's initialValue", () => { const form = schemaToForm( { properties: { greeting: { type: "string", default: "hi" } } }, diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index c85bbff9..fae70066 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -9,12 +9,31 @@ interface JsonSchemaProperty { type?: string; title?: string; enum?: unknown[]; - items?: { enum?: unknown[] }; + /** Non-standard legacy support: titles for enum values */ + enumNames?: string[]; + items?: { enum?: unknown[]; enumNames?: string[] }; minimum?: number; maximum?: number; default?: unknown; } +/** + * Build ink-form select options from enum values, using their non-standard + * `enumNames` titles as labels when present and length-matched. Falls back to + * the stringified value as the label otherwise, since a wrong-length zip would + * mislabel options — worse than showing raw values. + */ +function toSelectOptions( + values: unknown[], + names: string[] | undefined, +): { label: string; value: string }[] { + const useNames = names !== undefined && names.length === values.length; + return values.map((val, index) => ({ + label: useNames ? names[index] : String(val), + value: String(val), + })); +} + /** Minimal JSON Schema object shape (properties + required) */ interface JsonSchemaObject { properties?: Record; @@ -58,20 +77,17 @@ export function schemaToForm( field = { type: "select", ...baseField, - options: property.items.enum.map((val: unknown) => ({ - label: String(val), - value: String(val), - })), + options: toSelectOptions( + property.items.enum, + property.items.enumNames, + ), } as FormField; } else { // Single select field = { type: "select", ...baseField, - options: property.enum.map((val: unknown) => ({ - label: String(val), - value: String(val), - })), + options: toSelectOptions(property.enum, property.enumNames), } as FormField; } } else { diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 9d383594..c534fb29 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -109,6 +109,83 @@ describe("SchemaForm", () => { expect(onChange).toHaveBeenCalledWith({ format: "csv" }); }); + it("uses enumNames for string-enum option labels while submitting the raw value", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + pet: { + type: "string", + title: "Pet", + enum: ["pet-1", "pet-2", "pet-3"], + enumNames: ["Cats", "Dogs", "Birds"], + }, + }, + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: "Pet" })); + // The option shows the enumNames label... + const option = await screen.findByRole("option", { + name: "Dogs", + hidden: true, + }); + await user.click(option); + // ...but the value persisted is the raw enum value. + expect(onChange).toHaveBeenCalledWith({ pet: "pet-2" }); + }); + + it("preselects a default string-enum value showing its enumNames label", () => { + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + pet: { + type: "string", + title: "Pet", + enum: ["pet-1", "pet-2", "pet-3"], + enumNames: ["Cats", "Dogs", "Birds"], + default: "pet-1", + }, + }, + }; + renderWithMantine( + , + ); + // Default pet-1 preselects and shows its human-readable label. + expect(screen.getByDisplayValue("Cats")).toBeInTheDocument(); + }); + + it("falls back to raw string-enum values when enumNames length mismatches", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + pet: { + type: "string", + title: "Pet", + enum: ["pet-1", "pet-2", "pet-3"], + // Only two names for three values — a wrong-length zip would + // mislabel, so the raw values are shown instead. + enumNames: ["Cats", "Dogs"], + }, + }, + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: "Pet" })); + const option = await screen.findByRole("option", { + name: "pet-2", + hidden: true, + }); + await user.click(option); + expect(onChange).toHaveBeenCalledWith({ pet: "pet-2" }); + }); + it("clears a string field via its Clear button", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index b7f75021..fd199904 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -25,6 +25,22 @@ function serializeJson(value: unknown): string { return JSON.stringify(value, null, 2); } +/** + * Pair enum values with their non-standard `enumNames` titles into Mantine + * `{ value, label }[]` option data. Falls back to bare enum values when + * `enumNames` is absent or its length does not match `enum`, since a wrong-length + * zip would mislabel options — worse than showing the raw values. + */ +function toEnumData( + values: string[], + names: string[] | undefined, +): string[] | { value: string; label: string }[] { + if (names && names.length === values.length) { + return values.map((value, index) => ({ value, label: names[index] })); + } + return values; +} + export interface SchemaFormProps { schema: InspectorFormSchema; values: Record; @@ -77,7 +93,7 @@ export function SchemaForm({ description={description} withAsterisk={isRequired} disabled={disabled} - data={fieldSchema.enum} + data={toEnumData(fieldSchema.enum, fieldSchema.enumNames)} value={(rawValue as string) ?? null} onChange={(val) => handleFieldChange(fieldName, val)} /> @@ -167,15 +183,10 @@ export function SchemaForm({ // array of enum values (multi-select) if (fieldSchema.type === "array" && fieldSchema.items?.enum) { - const itemEnum = fieldSchema.items.enum; - const itemEnumNames = fieldSchema.items.enumNames; - const data = - itemEnumNames && itemEnumNames.length === itemEnum.length - ? itemEnum.map((value, index) => ({ - value, - label: itemEnumNames[index], - })) - : itemEnum; + const data = toEnumData( + fieldSchema.items.enum, + fieldSchema.items.enumNames, + ); return ( pet-1 … Reptiles=>pet-5`; +array-of-enum `sizes` → `Small=>s | Medium=>m | Large=>l`; a mismatched-length +`enumNames` (2 names, 3 values) falls back to raw `a=>a | b=>b | c=>c`. The web +side of this fix is covered by `SchemaForm.test.tsx`. + +--- + # Server Settings OAuth field disambiguation under EMA (#1692) — proof screenshots The **Server Settings → OAuth Settings** fields, rendered from the diff --git a/pr-screenshots/tui-enumnames-after-cats.png b/pr-screenshots/tui-enumnames-after-cats.png new file mode 100644 index 00000000..37d970be Binary files /dev/null and b/pr-screenshots/tui-enumnames-after-cats.png differ diff --git a/pr-screenshots/tui-enumnames-before-raw.png b/pr-screenshots/tui-enumnames-before-raw.png new file mode 100644 index 00000000..7cb6c55f Binary files /dev/null and b/pr-screenshots/tui-enumnames-before-raw.png differ