FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(web): show nested Zod errors in FormMessage by nkabardin · Pull Request #16317 · langfuse/langfuse · GitHub

Merged
155 changes: 155 additions & 0 deletions web/src/components/ui/form.clienttest.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useForm } from "react-hook-form";

import {
Form,
FormField,
FormItem,
FormMessage,
hasArrayLevelFieldError,
} from "@/src/components/ui/form";

type Values = {
categories: Array<{ value: string }>;
};

function FormMessageProbe() {
const form = useForm<Values>({
defaultValues: { categories: [{ value: "" }, { value: "" }] },
});

// RHF FieldPath omits Zod nested keys like `.root` / `.config`.
const setNestedError = (name: string, message: string) => {
form.setError(name as never, { message });
};

return (
<Form {...form}>
<FormField
control={form.control}
name="categories"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
onClick={() => {
form.setError("categories.0.value", {
message: "Category cannot be empty",
});
}}
>
item-only
</button>
<button
type="button"
onClick={() => {
form.setError("categories", {
message: "Categories must be unique",
});
}}
>
array-only
</button>
<button
type="button"
onClick={() => {
form.setError("categories.0.value", {
message: "Category cannot be empty",
});
setNestedError("categories.root", "Add at least two categories");
}}
>
item-and-root
</button>
<button
type="button"
onClick={() => {
setNestedError("categories.config", "Expected string");
}}
>
union
</button>
<button
type="button"
onClick={() => {
form.setError("categories", { message: "" });
}}
>
empty-message
</button>
</Form>
);
}

describe("FormMessage", () => {
it("renders an item-only nested message on the parent field", () => {
render(<FormMessageProbe />);

fireEvent.click(screen.getByRole("button", { name: "item-only" }));

expect(screen.getByText("Category cannot be empty")).toBeInTheDocument();
});

it("renders an array-level message", () => {
render(<FormMessageProbe />);

fireEvent.click(screen.getByRole("button", { name: "array-only" }));

expect(screen.getByText("Categories must be unique")).toBeInTheDocument();
});

it("renders the array-level root message when item errors also exist", () => {
render(<FormMessageProbe />);

fireEvent.click(screen.getByRole("button", { name: "item-and-root" }));

expect(screen.getByText("Add at least two categories")).toBeInTheDocument();
});

it("renders a nested union-path message", () => {
render(<FormMessageProbe />);

fireEvent.click(screen.getByRole("button", { name: "union" }));

expect(screen.getByText("Expected string")).toBeInTheDocument();
});

it("renders nothing for an empty message object", () => {
render(<FormMessageProbe />);

fireEvent.click(screen.getByRole("button", { name: "empty-message" }));

expect(screen.queryByRole("paragraph")).not.toBeInTheDocument();
});
});

describe("hasArrayLevelFieldError", () => {
it("is false for item-only nested errors", () => {
expect(
hasArrayLevelFieldError({
0: { value: { message: "Enter a category value" } },
}),
).toBe(false);
});

it("is true for a string message on the field", () => {
expect(hasArrayLevelFieldError({ message: "Add at least two" })).toBe(true);
});

it("is true for root.message even when item errors also exist", () => {
expect(
hasArrayLevelFieldError({
root: { message: "Add at least two" },
0: { value: { message: "Enter a category value" } },
}),
).toBe(true);
});

it("is false for an empty message object", () => {
expect(hasArrayLevelFieldError({ message: "" })).toBe(false);
});
});
50 changes: 46 additions & 4 deletions web/src/components/ui/form.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,56 @@ const FormDescription = React.forwardRef<
});
FormDescription.displayName = "FormDescription";

/**
* True when the error is on the array/object itself (`message` or `root`),
* not only on a nested item. Use this to gate a parent FormMessage that sits
* next to per-item FormMessages, so item errors are not rendered twice.
*/
function hasArrayLevelFieldError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const record = error as Record<string, unknown>;
if (typeof record.message === "string" && record.message.length > 0) {
return true;
}
const root = record.root;
if (!root || typeof root !== "object") return false;
const rootMessage = (root as { message?: unknown }).message;
return typeof rootMessage === "string" && rootMessage.length > 0;
}

/**
* Zod 4 + react-hook-form nest array-level errors on `root` and item errors
* on numeric keys. `error.message` is then undefined, so FormMessage used to
* render nothing even though the field was invalid.
*/
function getFieldErrorMessage(error: unknown): string | undefined {
if (!error || typeof error !== "object") return undefined;
const record = error as Record<string, unknown>;
if (typeof record.message === "string" && record.message.length > 0) {
return record.message;
}
const root = record.root;
if (root && typeof root === "object") {
const rootMessage = getFieldErrorMessage(root);
if (rootMessage) return rootMessage;
}
for (const key of Object.keys(record)) {
// `ref` is RHF metadata. `type` is metadata when it is a string, but a
// nested object when a union/discriminated path is named `type`.
if (key === "ref" || key === "message" || key === "root") continue;
if (key === "type" && typeof record[key] !== "object") continue;
const found = getFieldErrorMessage(record[key]);
if (found) return found;
}
return undefined;
}
Comment thread
cursor[bot] marked this conversation as resolved.

const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body =
typeof error?.message === "string" && error.message.length > 0
? error.message
: children;
const body = getFieldErrorMessage(error) ?? children;

if (!body) {
return null;
Expand Down Expand Up @@ -176,4 +217,5 @@ export {
FormDescription,
FormMessage,
FormField,
hasArrayLevelFieldError,
};
17 changes: 5 additions & 12 deletions web/src/features/evals/components/template-form.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
FormItem,
FormLabel,
FormMessage,
hasArrayLevelFieldError,
} from "@/src/components/ui/form";
import { api, reportTrpcErrorWithoutToast } from "@/src/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
Expand Down Expand Up @@ -319,13 +320,9 @@ const InnerEvalTemplateForm = (props: {
const isCategoricalOutput = scoreDataType === ScoreDataTypeEnum.CATEGORICAL;
const isBooleanOutput = scoreDataType === ScoreDataTypeEnum.BOOLEAN;
const shouldAllowMultipleMatches = form.watch("shouldAllowMultipleMatches");
const categoriesError = form.formState.errors.categories;
const categoriesErrorMessage =
typeof categoriesError?.message === "string"
? categoriesError.message
: typeof categoriesError?.root?.message === "string"
? categoriesError.root.message
: undefined;
const hasCategoriesArrayError = hasArrayLevelFieldError(
form.formState.errors.categories,
);

const applyDefaultOutputDefinitionCopy = (params: {
scoreDataType:
Expand Down Expand Up @@ -916,11 +913,7 @@ const InnerEvalTemplateForm = (props: {
</FormItem>
)}
/>
{categoriesErrorMessage ? (
<p className="text-destructive text-sm font-bold">
{categoriesErrorMessage}
</p>
) : null}
{hasCategoriesArrayError ? <FormMessage /> : null}
</FormItem>
)}
/>
Comment thread
cursor[bot] marked this conversation as resolved.
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
FormField,
FormItem,
FormMessage,
hasArrayLevelFieldError,
} from "@/src/components/ui/form";
import { useFieldArray, type UseFormReturn } from "react-hook-form";
import { Input } from "@/src/components/ui/input";
Expand Down Expand Up @@ -109,6 +110,9 @@ export const VariableMappingCard = ({
control: form.control,
name: "mapping",
});
const hasMappingArrayError = hasArrayLevelFieldError(
form.formState.errors.mapping,
);

const syncStatus = useVariableMappingSync({
templateVars: evalTemplate?.vars,
Expand Down Expand Up @@ -727,7 +731,7 @@ export const VariableMappingCard = ({
))}
</div>
</div>
<FormMessage />
{hasMappingArrayError ? <FormMessage /> : null}
</>
)}
/>
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ export function WidgetForm({
const chartTypeError: string | undefined = formErrors.chart?.type?.message;
const metricsError: string | undefined =
formErrors.metrics?.message ??
formErrors.metrics?.root?.message ??
formErrors.metrics?.[0]?.measure?.message ??
formErrors.metrics?.[0]?.aggregation?.message;
const dimensionsError: string | undefined = formErrors.dimensions?.message;
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,17 @@ describe("deriveSaveReason", () => {
).toBe("bad aggregation");
});

it("prefers metrics.root over a nested item message", () => {
expect(
deriveSaveReason({
metrics: {
root: { message: "add a metric" },
0: { measure: { message: "bad measure" } },
},
}),
).toBe("add a metric");
});

it("falls back to the first message anywhere for an unlisted path", () => {
expect(
deriveSaveReason({
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ export function deriveSaveReason(
const chartTypeError: string | undefined = errors.chart?.type?.message;
const metricsError: string | undefined =
errors.metrics?.message ??
errors.metrics?.root?.message ??
errors.metrics?.[0]?.measure?.message ??
errors.metrics?.[0]?.aggregation?.message;
const dimensionsError: string | undefined = errors.dimensions?.message;
Expand Down
Loading

Back | FazBrowse Home | New Git URL