web+tui: honor enumNames in enum forms (#1750)

* web+tui: honor enumNames in enum forms (#1691)

Legacy titled enums (`enum` + non-standard `enumNames`) rendered their raw
wire values instead of the human-readable titles in both the web SchemaForm
and the TUI schemaToForm builder, so a user picking "pet-1" had no idea it
meant "Cats".

Web (SchemaForm.tsx): extract a `toEnumData` helper that zips `enum` with
`enumNames` into `{ value, label }[]` when the lengths match, falling back to
bare values otherwise. Use it in both the string-enum and array multi-select
branches (the latter previously inlined the same zip).

TUI (schemaToForm.ts): add `enumNames` / `items.enumNames` to the property
interface and a `toSelectOptions` helper used by the single-select and
array-of-enum branches, labeling by `enumNames[i]` when length-matched and
falling back to `String(val)`.

Both guard on the arrays being the same length: a wrong-length zip would
mislabel options, which is worse than showing raw values. Raw values are
always submitted; only the display label changes.

Closes #1691

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs(pr-screenshots): TUI enumNames before/after proof shots (#1691)

Before/after captures of the TUI tool-test form (schemaToForm -> ink-form)
against a legacy titled enum: with the fix the field resolves pet-1 -> "Cats";
before, it showed the raw "pet-1".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-23 18:21:53 -04:00 • committed by GitHub
commit 8c41dcd383
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 240 additions and 19 deletions

View file

@ -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" } } },

View file

@ -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<string, JsonSchemaProperty>;
@ -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 {

View file

@ -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(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
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(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
// 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(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
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();

View file

@ -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<string, unknown>;
@ -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 (
<MultiSelect
key={fieldName}