Skip to content

Commit 8232fe6

Browse files
authored
Merge pull request #186 from code-forge-io/feat/issue-sweep
feat: defaultSubmitHandler, submitRawValues, resetOnSuccess and 5 issue fixes
2 parents 081a7bf + a79961e commit 8232fe6

5 files changed

Lines changed: 395 additions & 27 deletions

File tree

‎.changeset/issue-sweep-minor.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"remix-hook-form": minor
3+
---
4+
5+
New options and fixes for `useRemixForm`:
6+
7+
- New `defaultSubmitHandler` on the hook return. It is the handler that `useRemixForm` uses when `submitHandlers.onValid` is not set. Call it in a custom `onValid` to submit to the action in the default way. The form action, method and encType still apply (#148).
8+
- New `submitRawValues` option. When it is `true`, the default submit handler sends `getValues()` and not the resolver output, so transforms (for example a zod `.transform()`) do not run on the client and again on the server (#172).
9+
- New `resetOnSuccess` option. When it is `true`, the form is reset after a submission if the action returns no `errors` (#157).
10+
- The return types of `useRemixForm` and `useRemixFormContext` now come from `UseFormReturn`. Members that new react-hook-form versions add (for example `getErrors` and `resetDefaultValues`) are typed, and `<RemixFormProvider {...form}>` compiles again with react-hook-form 7.82 and later (#185). `UseRemixFormReturn["handleSubmit"]` now has only the event signature that the hook uses.
11+
- A form with an input named `action`, `method` or `enctype` now submits correctly (#139).
12+
- A router `basename` with a trailing slash (`/app/`) no longer makes the form action a relative path (#175).
13+
- An array or `FileList` with one file now parses back as an array on the server (#178).

‎src/hook/index.test.tsx‎

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
waitFor,
88
} from "@testing-library/react";
99
import React from "react";
10+
import type { Control, UseFormReturn } from "react-hook-form";
1011
import { type Navigation, useFetcher } from "react-router";
1112
import { RemixFormProvider, useRemixForm, useRemixFormContext } from "./index";
1213

@@ -23,7 +24,9 @@ const useNavigationMock = vi.hoisted(() =>
2324
})),
2425
);
2526

26-
const useHrefMock = vi.hoisted(() => vi.fn());
27+
const useHrefMock = vi.hoisted(() =>
28+
vi.fn<(to: string) => string | undefined>(() => "/"),
29+
);
2730

2831
vi.mock("react-router", () => ({
2932
useSubmit: () => submitMock,
@@ -451,6 +454,243 @@ describe("useRemixForm", () => {
451454
expect(booleanFieldProps.defaultChecked).toBe(false);
452455
expect(booleanFieldProps.defaultValue).toBe(undefined);
453456
});
457+
458+
it("types the return value with every react-hook-form member (#185, #116)", () => {
459+
type Values = { name: string };
460+
const { result } = renderHook(() => useRemixForm<Values>({}));
461+
// Fails to compile when the return type misses a member of UseFormReturn
462+
const members: Omit<
463+
UseFormReturn<Values>,
464+
"handleSubmit" | "reset" | "register"
465+
> = result.current;
466+
const control: Control<Values> = result.current.control;
467+
expect(members.getValues).toBeInstanceOf(Function);
468+
expect(control).toBe(result.current.control);
469+
});
470+
471+
it("keeps the leading slash of the action when the basename ends with a slash (#175)", async () => {
472+
submitMock.mockReset();
473+
useActionDataMock.mockReturnValue(undefined);
474+
useHrefMock.mockImplementation(() => "/my-basename/");
475+
vi.spyOn(window, "location", "get").mockReturnValueOnce({
476+
origin: "http://example.com",
477+
// biome-ignore lint/suspicious/noExplicitAny: partial location mock
478+
} as any);
479+
480+
const { result } = renderHook(() =>
481+
useRemixForm({
482+
resolver: () => ({ values: {}, errors: {} }),
483+
}),
484+
);
485+
486+
act(() => {
487+
result.current.handleSubmit({
488+
currentTarget: {
489+
action: "http://example.com/my-basename/basename-test-submit",
490+
},
491+
// biome-ignore lint/suspicious/noExplicitAny: partial event mock
492+
} as any);
493+
});
494+
await waitFor(() => {
495+
expect(submitMock).toHaveBeenCalledWith(expect.any(FormData), {
496+
method: "post",
497+
action: "/basename-test-submit",
498+
});
499+
});
500+
useHrefMock.mockImplementation(() => "/");
501+
});
502+
503+
it("submits when the form has inputs named action, method or enctype (#139)", async () => {
504+
submitMock.mockReset();
505+
useActionDataMock.mockReturnValue(undefined);
506+
const form = document.createElement("form");
507+
// In a browser, an input replaces the form property with the same name
508+
for (const name of ["action", "method", "enctype"]) {
509+
const input = document.createElement("input");
510+
input.name = name;
511+
form.appendChild(input);
512+
Object.defineProperty(form, name, { value: input });
513+
}
514+
// The HTMLFormElement getters still return the real values
515+
const getters = [
516+
vi
517+
.spyOn(HTMLFormElement.prototype, "action", "get")
518+
.mockReturnValue(`${window.location.origin}/submit`),
519+
vi
520+
.spyOn(HTMLFormElement.prototype, "method", "get")
521+
.mockReturnValue("post"),
522+
vi
523+
.spyOn(HTMLFormElement.prototype, "enctype", "get")
524+
.mockReturnValue("multipart/form-data"),
525+
];
526+
527+
const { result } = renderHook(() =>
528+
useRemixForm({
529+
resolver: () => ({ values: {}, errors: {} }),
530+
}),
531+
);
532+
533+
act(() => {
534+
// biome-ignore lint/suspicious/noExplicitAny: partial event mock
535+
result.current.handleSubmit({ currentTarget: form } as any);
536+
});
537+
await waitFor(() => {
538+
expect(submitMock).toHaveBeenCalledWith(expect.any(FormData), {
539+
method: "post",
540+
action: "/submit",
541+
encType: "multipart/form-data",
542+
});
543+
});
544+
for (const getter of getters) getter.mockRestore();
545+
});
546+
547+
it("lets clearErrors remove errors returned by the server (#12)", async () => {
548+
const serverData = {
549+
errors: {
550+
root: { message: "Server error" },
551+
name: { message: "Name is taken" },
552+
},
553+
};
554+
useActionDataMock.mockReturnValue(serverData);
555+
556+
const { result, rerender } = renderHook(() =>
557+
useRemixForm<{ name: string }>({ defaultValues: { name: "" } }),
558+
);
559+
await waitFor(() => {
560+
expect(result.current.formState.errors.root?.message).toBe(
561+
"Server error",
562+
);
563+
});
564+
565+
act(() => {
566+
result.current.clearErrors("root");
567+
result.current.clearErrors("name");
568+
});
569+
rerender();
570+
571+
expect(result.current.formState.errors.root).toBeUndefined();
572+
expect(result.current.formState.errors.name).toBeUndefined();
573+
useActionDataMock.mockReturnValue(undefined);
574+
});
575+
576+
it("exposes the default submit handler so onValid can wrap it (#148)", async () => {
577+
submitMock.mockReset();
578+
useActionDataMock.mockReturnValue(undefined);
579+
580+
const { result } = renderHook(() => {
581+
const form = useRemixForm<{ name: string; extra?: string }>({
582+
resolver: () => ({ values: { name: "John" }, errors: {} }),
583+
submitHandlers: {
584+
onValid: (data) => {
585+
form.defaultSubmitHandler({ ...data, extra: "from onValid" });
586+
},
587+
},
588+
});
589+
return form;
590+
});
591+
592+
act(() => {
593+
result.current.handleSubmit({
594+
currentTarget: {
595+
action: `${window.location.origin}/wrapped`,
596+
method: "post",
597+
},
598+
// biome-ignore lint/suspicious/noExplicitAny: partial event mock
599+
} as any);
600+
});
601+
await waitFor(() => expect(submitMock).toHaveBeenCalledTimes(1));
602+
603+
const [formData, options] = submitMock.mock.calls[0];
604+
expect(formData.get("name")).toBe(JSON.stringify("John"));
605+
expect(formData.get("extra")).toBe(JSON.stringify("from onValid"));
606+
// The form action and method still apply without passing them through
607+
expect(options).toEqual({ method: "post", action: "/wrapped" });
608+
});
609+
610+
it("submits the raw field values when submitRawValues is true (#172)", async () => {
611+
submitMock.mockReset();
612+
useActionDataMock.mockReturnValue(undefined);
613+
614+
const { result } = renderHook(() =>
615+
// biome-ignore lint/suspicious/noExplicitAny: default context type
616+
useRemixForm<{ date: string }, any, { date: Date }>({
617+
defaultValues: { date: "2025-01-01" },
618+
resolver: (values) => ({
619+
values: { date: new Date(values.date) },
620+
errors: {},
621+
}),
622+
submitRawValues: true,
623+
}),
624+
);
625+
626+
act(() => {
627+
// biome-ignore lint/suspicious/noExplicitAny: partial event mock
628+
result.current.handleSubmit({} as any);
629+
});
630+
await waitFor(() => expect(submitMock).toHaveBeenCalledTimes(1));
631+
632+
expect(submitMock.mock.calls[0][0].get("date")).toBe(
633+
JSON.stringify("2025-01-01"),
634+
);
635+
});
636+
637+
describe("resetOnSuccess (#157)", () => {
638+
const submitAndFinish = async (actionData: unknown) => {
639+
submitMock.mockReset();
640+
useActionDataMock.mockReturnValue(undefined);
641+
useNavigationMock.mockReturnValue({
642+
state: "idle",
643+
formData: undefined,
644+
json: undefined,
645+
});
646+
647+
const hook = renderHook(() =>
648+
useRemixForm<{ name: string }>({
649+
defaultValues: { name: "default" },
650+
resolver: (values) => ({ values, errors: {} }),
651+
resetOnSuccess: true,
652+
}),
653+
);
654+
act(() => hook.result.current.setValue("name", "changed"));
655+
act(() => {
656+
// biome-ignore lint/suspicious/noExplicitAny: partial event mock
657+
hook.result.current.handleSubmit({} as any);
658+
});
659+
await waitFor(() => expect(submitMock).toHaveBeenCalledTimes(1));
660+
661+
useNavigationMock.mockReturnValue({
662+
state: "submitting",
663+
formData: new FormData(),
664+
json: undefined,
665+
});
666+
hook.rerender();
667+
expect(hook.result.current.getValues("name")).toBe("changed");
668+
669+
useActionDataMock.mockReturnValue(actionData);
670+
useNavigationMock.mockReturnValue({
671+
state: "idle",
672+
formData: undefined,
673+
json: undefined,
674+
});
675+
hook.rerender();
676+
await act(async () => {});
677+
return hook;
678+
};
679+
680+
it("resets the form when the action returns no errors", async () => {
681+
const { result } = await submitAndFinish({ ok: true });
682+
expect(result.current.getValues("name")).toBe("default");
683+
useActionDataMock.mockReturnValue(undefined);
684+
});
685+
686+
it("keeps the values when the action returns errors", async () => {
687+
const { result } = await submitAndFinish({
688+
errors: { name: { message: "Name is taken" } },
689+
});
690+
expect(result.current.getValues("name")).toBe("changed");
691+
useActionDataMock.mockReturnValue(undefined);
692+
});
693+
});
454694
});
455695

456696
afterEach(cleanup);

0 commit comments

Comments
 (0)