Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/forms/components/FormErrors.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FormErrors errors={{ _form: "Form is invalid." }} />);
expect(screen.getByRole("alert").textContent).toBe("Form is invalid.");
});

it("renders nothing without a form-level error", () => {
const { container } = render(<FormErrors errors={{ email: "bad" }} />);
expect(container.innerHTML).toBe("");
});
});
19 changes: 19 additions & 0 deletions frontend/src/forms/components/FormErrors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type React from "react";
import { FORM_ERROR_KEY } from "../zodValidate";

export interface FormErrorsProps {
errors: Partial<Record<string, string>>;
}

/** Renders the form-level (`_form`) validation error, if any. */
const FormErrors: React.FC<FormErrorsProps> = ({ errors }) => {
const message = errors[FORM_ERROR_KEY];
if (!message) return null;
return (
<div className="form-error form-error-form" role="alert">
{message}
</div>
);
};

export default FormErrors;
3 changes: 3 additions & 0 deletions frontend/src/forms/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
24 changes: 0 additions & 24 deletions frontend/src/forms/schemas/settingsFormSchema.ts

This file was deleted.

17 changes: 17 additions & 0 deletions frontend/src/forms/useForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
});
16 changes: 8 additions & 8 deletions frontend/src/forms/useForm.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object> = ValidationSchema<T> | z.ZodSchema<T>;
Expand All @@ -10,11 +10,11 @@ export type SubmitStatus = "idle" | "loading" | "success" | "error";
function runSchemaValidation<T extends object>(
schema: FormSchema<T>,
values: T
): Partial<Record<keyof T, string>> {
): FormErrors<T> {
if ("safeParse" in schema && typeof schema.safeParse === "function") {
return zodValidate(schema as z.ZodSchema<T>, values);
}
return validate(schema as ValidationSchema<T>, values);
return validate(schema as ValidationSchema<T>, values) as FormErrors<T>;
}

/**
Expand All @@ -25,7 +25,7 @@ export function useForm<T extends object>(
schema: FormSchema<T>
) {
const [values, setValuesState] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [errors, setErrors] = useState<FormErrors<T>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<SubmitStatus>("idle");
Expand All @@ -46,12 +46,12 @@ export function useForm<T extends object>(

const filterTouchedErrors = useCallback(
(
allErrors: Partial<Record<keyof T, string>>,
allErrors: FormErrors<T>,
touchedMap: Partial<Record<keyof T, boolean>>
): Partial<Record<keyof T, string>> =>
): FormErrors<T> =>
Object.fromEntries(
Object.entries(allErrors).filter(([field]) => touchedMap[field as keyof T])
) as Partial<Record<keyof T, string>>,
Object.entries(allErrors).filter(([field]) => field === FORM_ERROR_KEY || touchedMap[field as keyof T])
) as FormErrors<T>,
[]
);

Expand Down
72 changes: 0 additions & 72 deletions frontend/src/forms/useZodForm.test.ts

This file was deleted.

34 changes: 34 additions & 0 deletions frontend/src/forms/zodValidate.test.ts
Original file line number Diff line number Diff line change
@@ -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<object>, ["a", "bb"]);
expect((errors as Record<string, string>)["0"]).toBe("Too short.");
});
});
23 changes: 16 additions & 7 deletions frontend/src/forms/zodValidate.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import { z } from "zod";

/** Reserved errors key for issues that are not tied to a single field (e.g. object-level refine). */
export const FORM_ERROR_KEY = "_form";

export type FormErrors<TValues extends object> = Partial<
Record<keyof TValues | typeof FORM_ERROR_KEY, string>
>;

/**
* Validates form values using a Zod schema.
* Returns a map of fieldName -> first error message string.
* Returns a map of fieldName -> first error message string. Issues with an
* empty path are reported under the reserved `_form` key.
*/
export function zodValidate<TValues extends object>(
schema: z.ZodSchema<TValues>,
values: TValues
): Partial<Record<keyof TValues, string>> {
): FormErrors<TValues> {
const result = schema.safeParse(values);
if (result.success) {
return {};
}

const errors: Partial<Record<keyof TValues, string>> = {};
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const fieldName = issue.path[0] as keyof TValues;
if (fieldName && !errors[fieldName]) {
errors[fieldName] = issue.message;
const first = issue.path[0];
const key = first === undefined ? FORM_ERROR_KEY : String(first);
if (!(key in errors)) {
errors[key] = issue.message;
}
}

return errors;
return errors as FormErrors<TValues>;
}

export default zodValidate;
17 changes: 16 additions & 1 deletion node_modules/.bin/commitlint

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions node_modules/.bin/commitlint.cmd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions node_modules/.bin/commitlint.ps1

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion node_modules/.bin/conc

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions node_modules/.bin/conc.cmd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading