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:
parent
402d7b204d
commit
8c41dcd383
7 changed files with 240 additions and 19 deletions
|
|
@ -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" } } },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,32 @@
|
|||
# TUI enum forms honor `enumNames` (#1691) — proof screenshots
|
||||
|
||||
The TUI tool-test form (`schemaToForm` → `ink-form`) rendered against a legacy
|
||||
titled enum: `enum: [pet-1 … pet-5]` paired with the non-standard
|
||||
`enumNames: [Cats … Reptiles]`, `default: "pet-1"`. Captured non-interactively
|
||||
(a standalone render of the real `ink-form` built by the real `schemaToForm`;
|
||||
the Ink TUI can't take live keystrokes in a headless pty).
|
||||
|
||||

|
||||
|
||||
**With the fix:** the required **Favorite Pet** field resolves the default
|
||||
`pet-1` to its human title **"Cats"**. The submitted value stays the raw
|
||||
`pet-1` — only the display label changes. (`Plain Enum`, which has no
|
||||
`enumNames`, is unaffected.)
|
||||
|
||||

|
||||
|
||||
**Before the fix** (same schema with `enumNames` stripped, reproducing the old
|
||||
behavior): the field shows the opaque wire value **"pet-1"** with no indication
|
||||
it means "Cats".
|
||||
|
||||
Ground-truth options from the real `schemaToForm` confirm the label↔value
|
||||
pairing and the length-guarded fallbacks: `pet` → `Cats=>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
|
||||
|
|
|
|||
BIN
pr-screenshots/tui-enumnames-after-cats.png
Normal file
BIN
pr-screenshots/tui-enumnames-after-cats.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
pr-screenshots/tui-enumnames-before-raw.png
Normal file
BIN
pr-screenshots/tui-enumnames-before-raw.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Loading…
Reference in a new issue