diff --git a/frontend/src/forms/components/FormErrors.test.tsx b/frontend/src/forms/components/FormErrors.test.tsx
new file mode 100644
index 000000000..81081abaa
--- /dev/null
+++ b/frontend/src/forms/components/FormErrors.test.tsx
@@ -0,0 +1,15 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import FormErrors from "./FormErrors";
+
+describe("FormErrors", () => {
+ it("renders the _form error as an alert", () => {
+ render();
+ expect(screen.getByRole("alert").textContent).toBe("Form is invalid.");
+ });
+
+ it("renders nothing without a form-level error", () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+});
diff --git a/frontend/src/forms/components/FormErrors.tsx b/frontend/src/forms/components/FormErrors.tsx
new file mode 100644
index 000000000..cb37f904f
--- /dev/null
+++ b/frontend/src/forms/components/FormErrors.tsx
@@ -0,0 +1,19 @@
+import type React from "react";
+import { FORM_ERROR_KEY } from "../zodValidate";
+
+export interface FormErrorsProps {
+ errors: Partial>;
+}
+
+/** Renders the form-level (`_form`) validation error, if any. */
+const FormErrors: React.FC = ({ errors }) => {
+ const message = errors[FORM_ERROR_KEY];
+ if (!message) return null;
+ return (
+
+ {message}
+
+ );
+};
+
+export default FormErrors;
diff --git a/frontend/src/forms/index.ts b/frontend/src/forms/index.ts
index bfafa0338..7d4be36eb 100644
--- a/frontend/src/forms/index.ts
+++ b/frontend/src/forms/index.ts
@@ -1,10 +1,13 @@
export { useForm } from "./useForm";
+export { FORM_ERROR_KEY } from "./zodValidate";
+export type { FormErrors as FormErrorMap } from "./zodValidate";
export { validate } from "./validate";
export type { ValidationRule, ValidationSchema } from "./validate";
export { default as FormField } from "./components/FormField";
export { default as FormSelect } from "./components/FormSelect";
export { default as FormTextarea } from "./components/FormTextarea";
+export { default as FormErrors } from "./components/FormErrors";
export { default as SubmitButton } from "./components/SubmitButton";
export type { FormFieldProps } from "./components/FormField";
diff --git a/frontend/src/forms/schemas/settingsFormSchema.ts b/frontend/src/forms/schemas/settingsFormSchema.ts
deleted file mode 100644
index 0fe5ea792..000000000
--- a/frontend/src/forms/schemas/settingsFormSchema.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { z } from "zod";
-import {
- SLIPPAGE_RANGE_ERROR,
- isValidSlippagePercent,
-} from "./slippageSchema";
-
-export const settingsFormSchema = z.object({
- username: z
- .string()
- .min(3, "Username must be at least 3 characters.")
- .max(30, "Username cannot exceed 30 characters.")
- .regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens."),
- email: z.string().email("Please enter a valid email address."),
- slippageTolerance: z
- .string()
- .refine((val) => isValidSlippagePercent(Number(val)), {
- message: SLIPPAGE_RANGE_ERROR,
- }),
- currency: z.enum(["USD", "EUR", "GBP", "XLM"], {
- error: () => ({ message: "Please select a supported currency." }),
- }),
-});
-
-export type SettingsFormValues = z.infer;
diff --git a/frontend/src/forms/useForm.test.tsx b/frontend/src/forms/useForm.test.tsx
index bea44aea4..831b76f4f 100644
--- a/frontend/src/forms/useForm.test.tsx
+++ b/frontend/src/forms/useForm.test.tsx
@@ -226,3 +226,20 @@ describe("useForm", () => {
expect(result.current.errors.amount).toBeUndefined();
});
});
+
+describe("useForm form-level zod errors", () => {
+ it("surfaces object-level refine errors under _form and fails validateAll", async () => {
+ const { z } = await import("zod");
+ const { renderHook, act } = await import("@testing-library/react");
+ const schema = z
+ .object({ a: z.string(), b: z.string() })
+ .refine((v) => v.a === v.b, { message: "A must equal B." });
+ const { result } = renderHook(() => useForm({ a: "x", b: "y" }, schema));
+ let ok = true;
+ act(() => {
+ ok = result.current.validateAll();
+ });
+ expect(ok).toBe(false);
+ expect(result.current.errors._form).toBe("A must equal B.");
+ });
+});
diff --git a/frontend/src/forms/useForm.ts b/frontend/src/forms/useForm.ts
index 6ac5aee84..6c146f9dc 100644
--- a/frontend/src/forms/useForm.ts
+++ b/frontend/src/forms/useForm.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, FocusEvent, FormEvent } from "react";
import { type ValidationSchema, validate } from "./validate";
-import { zodValidate } from "./zodValidate";
+import { FORM_ERROR_KEY, zodValidate, type FormErrors } from "./zodValidate";
import { z } from "zod";
export type FormSchema = ValidationSchema | z.ZodSchema;
@@ -10,11 +10,11 @@ export type SubmitStatus = "idle" | "loading" | "success" | "error";
function runSchemaValidation(
schema: FormSchema,
values: T
-): Partial> {
+): FormErrors {
if ("safeParse" in schema && typeof schema.safeParse === "function") {
return zodValidate(schema as z.ZodSchema, values);
}
- return validate(schema as ValidationSchema, values);
+ return validate(schema as ValidationSchema, values) as FormErrors;
}
/**
@@ -25,7 +25,7 @@ export function useForm(
schema: FormSchema
) {
const [values, setValuesState] = useState(initialValues);
- const [errors, setErrors] = useState>>({});
+ const [errors, setErrors] = useState>({});
const [touched, setTouched] = useState>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState("idle");
@@ -46,12 +46,12 @@ export function useForm(
const filterTouchedErrors = useCallback(
(
- allErrors: Partial>,
+ allErrors: FormErrors,
touchedMap: Partial>
- ): Partial> =>
+ ): FormErrors =>
Object.fromEntries(
- Object.entries(allErrors).filter(([field]) => touchedMap[field as keyof T])
- ) as Partial>,
+ Object.entries(allErrors).filter(([field]) => field === FORM_ERROR_KEY || touchedMap[field as keyof T])
+ ) as FormErrors,
[]
);
diff --git a/frontend/src/forms/useZodForm.test.ts b/frontend/src/forms/useZodForm.test.ts
deleted file mode 100644
index e6c574a80..000000000
--- a/frontend/src/forms/useZodForm.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { renderHook, act } from "@testing-library/react";
-import type { FormEvent } from "react";
-import { useForm } from "./useForm";
-import { settingsFormSchema } from "./schemas/settingsFormSchema";
-
-describe("Zod Form Validation & Submission States", () => {
- const initialValues = {
- username: "",
- email: "invalid-email",
- slippageTolerance: "55.0",
- currency: "USD" as const,
- };
-
- it("validates fields using Zod schema on validateAll", () => {
- const { result } = renderHook(() => useForm(initialValues, settingsFormSchema));
-
- let isValid = false;
- act(() => {
- isValid = result.current.validateAll();
- });
-
- expect(isValid).toBe(false);
- expect(result.current.errors.username).toBe("Username must be at least 3 characters.");
- expect(result.current.errors.email).toBe("Please enter a valid email address.");
- expect(result.current.errors.slippageTolerance).toBe("Slippage tolerance must be between 0.1% and 50%.");
- });
-
- it("clears field errors when valid data is entered", () => {
- const { result } = renderHook(() => useForm(initialValues, settingsFormSchema));
-
- act(() => {
- result.current.setValues({
- username: "valid_user",
- email: "user@example.com",
- slippageTolerance: "0.5",
- currency: "USD",
- });
- });
-
- let isValid = false;
- act(() => {
- isValid = result.current.validateAll();
- });
-
- expect(isValid).toBe(true);
- expect(result.current.errors).toEqual({});
- });
-
- it("tracks submission status (loading, success, error)", async () => {
- const validValues = {
- username: "valid_user",
- email: "user@example.com",
- slippageTolerance: "0.5",
- currency: "USD" as const,
- };
-
- const { result } = renderHook(() => useForm(validValues, settingsFormSchema));
-
- const onSubmitSuccess = vi.fn().mockResolvedValue(undefined);
-
- const fakeEvent = { preventDefault: vi.fn() } as unknown as FormEvent;
-
- await act(async () => {
- await result.current.handleSubmit(onSubmitSuccess)(fakeEvent);
- });
-
- expect(onSubmitSuccess).toHaveBeenCalledWith(validValues);
- expect(result.current.submitStatus).toBe("success");
- expect(result.current.isSubmitting).toBe(false);
- });
-});
diff --git a/frontend/src/forms/zodValidate.test.ts b/frontend/src/forms/zodValidate.test.ts
new file mode 100644
index 000000000..53ef321c0
--- /dev/null
+++ b/frontend/src/forms/zodValidate.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { z } from "zod";
+import { zodValidate, FORM_ERROR_KEY } from "./zodValidate";
+
+const schema = z
+ .object({
+ from: z.string().min(1, "From is required."),
+ to: z.string().min(1, "To is required."),
+ })
+ .refine((v) => v.from !== v.to, { message: "From and To must differ." });
+
+describe("zodValidate", () => {
+ it("returns no errors for valid values", () => {
+ expect(zodValidate(schema, { from: "a", to: "b" })).toEqual({});
+ });
+
+ it("reports object-level refine errors under _form", () => {
+ expect(zodValidate(schema, { from: "a", to: "a" })).toEqual({
+ [FORM_ERROR_KEY]: "From and To must differ.",
+ });
+ });
+
+ it("reports field errors by path[0] and keeps the first message", () => {
+ const errors = zodValidate(schema, { from: "", to: "b" });
+ expect(errors.from).toBe("From is required.");
+ expect(errors._form).toBeUndefined();
+ });
+
+ it("handles falsy index-0 path keys (array schemas)", () => {
+ const arr = z.array(z.string().min(2, "Too short."));
+ const errors = zodValidate(arr as unknown as z.ZodSchema