diff --git a/README.md b/README.md
index 23ef58c..41645ce 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,29 @@
# react-form-action
-End-to-end typesafe success, error & validation state control for Next.js 14 form actions.
+End-to-end typesafe success, error & validation state control for Next.js form actions.
## Features
**Action Creator**
-- ✅ Provides `"invalid" | "success" | "failure"` response objects.
+- ✅ Provides envelope objects with `"initial" | "invalid" | "success" | "failure"` response types.
- ✅ Define generic payload for each of the response type.
-**Form Action builder**
+**tRPC-like Form Action builder**
-- ✅ tRPC-like builder API for `.input(zodSchema)` & context `.use(middleware)`
-- ✅ Parses `formData` with [`zod-form-data`](https://www.npmjs.com/package/zod-form-data)
+- ✅ Define payload schema with the `.input(zodSchema)` to validate the `formData`
+- ✅ Reuse business logic with the `.use(middleware)` method.
+- ✅ Reuse error handling with the `.error(handler)`.
-**Stateful `
`**
+**React Context access with the ` ` component**
-- ✅ `` component reads the action's response.
-- ✅ Computes progress meta-state like `isInvalid`, `isSuccess` and more.
+- ✅ The `useActionState()` accessible via the `useActionContext()` hook.
+- ✅ Computes progress flags like `isInvalid`, `isSuccess` based on the envelope type.
+
+**Context-bound `` component**
+
+- ✅ Reads the `action` from the ` ` context.
+- ✅ Opt-out from the default form reset after action submit.
## Install
@@ -29,10 +35,95 @@ npm i react-form-action zod-form-data
+## Getting Started
+
+#### 1️⃣ Create a Server Action
+
+```tsx
+// app/subscibe/action.ts
+"use server";
+
+import { formAction } from "react-form-action";
+import { z } from "zod";
+
+export const subscribeAction = formAction
+ .input(z.object({ email: z.string().email() }))
+ .run(async ({ input }) => {
+ return input.email;
+ });
+```
+
+#### 2️⃣ Create a Client Form Component
+
+```tsx
+// app/subscibe/SubscribeForm.tsx
+"use client";
+
+import {
+ Action,
+ Form,
+ Pending,
+ useActionContext,
+} from "react-form-action/client";
+
+import { subscribeAction } from "./action";
+
+const { FieldError } = createComponents(subscribeAction);
+
+export function SubscribeForm() {
+ return (
+
+
+
+
+ Please wait...
+
+ );
+}
+
+function SuccessMessage() {
+ // Pass action to cast the type of the "data" value
+ const { isSuccess, data } = useActionContext(subscribeAction);
+
+ return isSuccess && Email {data} was registered.
;
+}
+
+function SubmitButton() {
+ // no need for action when reading a generic value
+ const { isPending } = useActionContext();
+
+ return (
+
+ {isPending ? "Submitting..." : "Submit"}
+
+ );
+}
+```
+
+#### 3️⃣ Render the form on a Page
+
+```tsx
+// app/subscibe/page.tsx
+
+import { SubscribeForm } from "./SubscribeForm";
+
+function SuccessMessage() {}
+
+export function Page() {
+ return ;
+}
+```
+
## Usage
### `formAction` builder
+The [`zod-form-data`](https://www.npmjs.com/package/zod-form-data) powered action builder.
+
```ts
// app/actions/auth.ts
"use server";
@@ -50,7 +141,7 @@ const i18nMiddleware = async () => {
const authAction = formAction
.use(i18nMiddleware)
.use(async ({ ctx: { t } }) =>
- console.log("🎉 context enhanced by previous middlewares 🎉", t),
+ console.log("🎉 context enhanced by previous middlewares 🎉", t)
)
.error(({ error }) => {
if (error instanceof DbError) {
@@ -85,7 +176,7 @@ export const signUp = authAction
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ["confirm"],
- }),
+ })
) // if using refinement, only one input call is permited, as schema with ZodEffects is not extendable.
.run(async ({ ctx: { t }, input: { email, password } }) => {
// 🎉 passwords match!
@@ -100,7 +191,9 @@ export const signUp = authAction
});
```
-### Server Action (usable in Next.js)
+### Action Creator
+
+Low-level action creator, which provides the `success`, `failure` and `invalid` envelope constructors. With the `createFormAction` you must handle the native `FormData` by yourself.
```ts
"use server";
@@ -161,59 +254,91 @@ export const updateUser = createFormAction<
return failure({ message: "Failed to update user." });
}
- },
+ }
);
```
+### Action Context
+
+The `` components enables you to access your `action`'s state with the `useActionContext()` hook:
+
+```tsx
+// 👉 Define standalone client form component (e.g. /app/auth/signup/SignUpForm.tsx)
+"use client";
+
+import { Action, Form, useActionContext } from "react-form-action/client";
+import type { PropsWithChildren } from "react";
+
+import { signupAction } from "./action";
+
+function Pending({ children }: PropsWithChildren) {
+ // read any state from the ActionContext:
+ const {
+ error,
+ data,
+ validationError,
+ isPending,
+ isFailure,
+ isInvalid,
+ isSuccess,
+ isInitial,
+ } = useActionContext();
+
+ return isPending && children;
+}
+
+// 💡 render this form on your RSC page (/app/auth/signup/page.tsx)
+export function SignupForm() {
+ return (
+
+
+ {/* 🎆 Read the pending state outside the
+ );
+}
+```
+
### `
+
+
+
);
}
```
-### `` Component
+### Context Bound Components `createComponents()`
+
+Use the `createComponents(action)` helper to create components which use the ActionContext and have types bound to the action type.
-Actions created with `formAction` builder will have the `validationError` of [`ZodFormattedError`](https://zod.dev/ERROR_HANDLING?id=formatting-errors) type.
-To easily access the nested error, use the helper `` component:
+### `` Component
```tsx
"use client";
-import { Form, ZodFieldError } from "react-form-action/client";
+
+// ⚠️ createComponents is usable only in "use client" components
+import { Form, createComponents } from "react-form-action/client";
+
+import { authAction } from "./actions";
export const signUp = authAction
.input(
@@ -231,51 +356,47 @@ export const signUp = authAction
})
)
.run(async ({ ctx, input }) => {
- // implementation
+ return null;
});
+// 🌟 The FieldError is now bound do the signUp input schema which allows autocompletion for its "name" prop
+// ⚠️ Usable only with actions created with the formAction builder
+const { FieldError } = createComponents(signUp);
+
export function SignUpForm() {
return (
-
+
+
+
);
}
```
-### Bonus ``
+### ``
-The `useFormStatus` hook data exposed via render prop:
+Render children when the action is pending:
```tsx
-import { FormStatus } from "react-form-action/client";
+import { Action, Pending } from "react-form-action/client";
-// alleviates the need to create a separate component using the useFormStatus hook
+import { Spinner } from "./components";
return function MyForm() {
return (
-
+
+ {/* 👉 Unlike the React.useFormStatus() hook, we don't need here the
);
};
```
diff --git a/package-lock.json b/package-lock.json
index 625e629..a6d85cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "react-form-action",
- "version": "1.2.3",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "react-form-action",
- "version": "1.2.3",
+ "version": "2.0.0",
"license": "MIT",
"devDependencies": {
"@testing-library/dom": "^10.4.0",
diff --git a/package.json b/package.json
index f4a4bb8..b21e6cd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-form-action",
- "version": "1.2.3",
+ "version": "2.0.0",
"description": "State management helpers for the react form actions.",
"repository": {
"type": "git",
diff --git a/src/Action.test.tsx b/src/Action.test.tsx
new file mode 100644
index 0000000..48f9458
--- /dev/null
+++ b/src/Action.test.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { describe, test, expect } from "vitest";
+import { userEvent } from "@testing-library/user-event";
+import { act, render, screen } from "@testing-library/react";
+import { z } from "zod";
+import { Action } from "./Action";
+import { formAction } from "./formAction";
+import { ActionForm } from "./ActionForm";
+
+import { createComponents } from "./createComponents";
+
+describe("Action", () => {
+ test("it enables form to consume action via context", async () => {
+ const subscribeAction = formAction
+ .input(
+ z.object({
+ email: z.string().email(),
+ })
+ )
+ .run(async () => {
+ return null;
+ });
+
+ const { FieldError } = createComponents(subscribeAction);
+
+ function SubscribeForm() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ render( );
+
+ await act(() => userEvent.type(screen.getByTestId("email"), "fake"));
+ await act(() => userEvent.click(screen.getByTestId("submit")));
+
+ expect(
+ screen.getByText("Invalid email")
+ // @ts-expect-error
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/Action.tsx b/src/Action.tsx
new file mode 100644
index 0000000..996a886
--- /dev/null
+++ b/src/Action.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import React, { type PropsWithChildren } from "react";
+import { createContext, use } from "react";
+import type {
+ ActionState,
+ InitialState,
+ InvalidState,
+ SuccessState,
+ FailureState,
+ FormAction,
+} from "./createFormAction";
+import { initial } from "./createFormAction";
+import { useActionState } from "react";
+
+export type ActionProps = PropsWithChildren<{
+ action: FormAction;
+ initialData: Data;
+ permalink?: string;
+}>;
+
+type ActionStatusFlags<
+ T extends ActionState["type"] | unknown = unknown,
+> = {
+ isInitial: T extends "initial" ? true : false;
+ isInvalid: T extends "invalid" ? true : false;
+ isFailure: T extends "failure" ? true : false;
+ isSuccess: T extends "success" ? true : false;
+};
+
+export type ActionContextState<
+ T extends ActionState,
+> = T &
+ ActionStatusFlags & {
+ /**
+ * The dispatch function returned from React.useActionState()
+ */
+ action: (payload: FormData) => void;
+ isPending: boolean;
+ };
+
+const neverMetaState: ActionStatusFlags = {
+ isInitial: false,
+ isInvalid: false,
+ isFailure: false,
+ isSuccess: false,
+};
+
+/**
+ * NOTE: ActionContextState> would not allow discriminate the union inside of the ActionState.
+ */
+type SpreadActionContext<
+ Data = unknown,
+ Error = unknown,
+ ValidationError = unknown,
+> =
+ | ActionContextState>
+ | ActionContextState>
+ | ActionContextState>
+ | ActionContextState>;
+
+/**
+ * A context exposing the form action state.
+ */
+const ActionContext = createContext(null);
+
+/**
+ * A hook to consume the form action state from the context.
+ */
+export function useActionContext(
+ action?: FormAction
+) {
+ const ctx = use(ActionContext);
+
+ if (!ctx) {
+ throw new Error(
+ "ActionContext must be initialized before use. Is your useActionContext hook wrapped with an Component?"
+ );
+ }
+
+ // Generics shouldn't be used for explicit casts, I know
+ return ctx as SpreadActionContext;
+}
+
+export function Action({
+ children,
+ action: formAction,
+ initialData,
+ permalink,
+}: ActionProps) {
+ const [state, action, isPending] = useActionState(
+ formAction,
+ initial(initialData),
+ permalink
+ );
+
+ const metaState =
+ state.type === "initial"
+ ? {
+ ...state,
+ ...neverMetaState,
+ action,
+ isPending,
+ isInitial: true as const,
+ }
+ : state.type === "invalid"
+ ? {
+ ...state,
+ ...neverMetaState,
+ action,
+ isPending,
+ isInvalid: true as const,
+ }
+ : state.type === "failure"
+ ? {
+ ...state,
+ ...neverMetaState,
+ action,
+ isPending,
+ isFailure: true as const,
+ }
+ : {
+ ...state,
+ ...neverMetaState,
+ action,
+ isPending,
+ isSuccess: true as const,
+ };
+
+ return {children} ;
+}
diff --git a/src/Form.test-d.ts b/src/ActionContext.test-d.ts
similarity index 57%
rename from src/Form.test-d.ts
rename to src/ActionContext.test-d.ts
index fc37a12..6acdc19 100644
--- a/src/Form.test-d.ts
+++ b/src/ActionContext.test-d.ts
@@ -1,39 +1,18 @@
import { expectTypeOf, describe, test } from "vitest";
-import { z } from "zod";
-import { formAction } from "./formAction";
-import type { FormProps } from "./Form";
+import { useActionContext } from "./Action";
-describe("FormProps", () => {
- test("children props have mutually exclusive progress flags", () => {
- const signUpSchema = z.object({
- email: z.string().email(),
- password: z.string(),
- });
-
- const signUpAction = formAction
- .input(signUpSchema)
- .error(() => {
- const dbError = {
- type: "ProtocolError",
- message: "Email already exists",
- };
- return dbError;
- })
- .run(async () => {
- return "We've sent you and email";
- });
-
- function fakeFormRender(props: FormProps ) {
- return props.children;
- }
-
- const children = fakeFormRender({
- action: signUpAction,
- initialData: "Please sign up",
- children: () => null,
- });
+describe("useActionContext", () => {
+ test("context value has mutually exclusive progress flags", () => {
+ const ctx = useActionContext<
+ string,
+ { type: string; message: string },
+ {
+ email?: { _errors: string[] };
+ password?: { _errors: string[] };
+ }
+ >();
- expectTypeOf().parameter(0).toMatchTypeOf<
+ expectTypeOf().toMatchTypeOf<
| {
isPending: boolean;
type: "initial";
diff --git a/src/ActionForm.tsx b/src/ActionForm.tsx
new file mode 100644
index 0000000..f818f7e
--- /dev/null
+++ b/src/ActionForm.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import React, { startTransition } from "react";
+import type { FormHTMLAttributes, FormEvent } from "react";
+
+import { useActionContext } from "./Action";
+
+export type ActionFormProps = Omit<
+ FormHTMLAttributes,
+ "action"
+> & {
+ /**
+ * Opt-in into automatic form reset by using the form "action" prop.
+ * By default, the onSubmit with a custom transition which opts-out of the implicit form reset.
+ * See for more. https://github.com/facebook/react/issues/29034
+ * @default false
+ */
+ autoReset?: boolean;
+};
+
+export function ActionForm({ autoReset = false, ...props }: ActionFormProps) {
+ const { action } = useActionContext();
+
+ const submitStrategy = autoReset
+ ? { action }
+ : {
+ onSubmit: (event: FormEvent) => {
+ event.preventDefault();
+ const form = event.currentTarget;
+ startTransition(() => {
+ action(new FormData(form));
+ });
+ },
+ };
+
+ return ;
+}
diff --git a/src/Form.test.tsx b/src/Form.test.tsx
deleted file mode 100644
index 049aede..0000000
--- a/src/Form.test.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from "react";
-import { describe, test, expect } from "vitest";
-import { userEvent } from "@testing-library/user-event";
-import { act, render, screen } from "@testing-library/react";
-import { z } from "zod";
-import { Form } from "./Form";
-import { formAction } from "./formAction";
-import { ZodFieldError } from "./ZodFieldError";
-
-describe("Form", () => {
- test("it integrates with ZodFieldError", async () => {
- const signUp = formAction
- .input(
- z
- .object({
- user: z.object({
- email: z.string().email(),
- }),
- password: z.string().min(8),
- confirm: z.string(),
- })
- .refine((data) => data.password === data.confirm, {
- message: "Passwords don't match",
- }),
- )
- .run(async () => {
- // implementation
- return "success";
- });
-
- function SignUpForm() {
- return (
-
- );
- }
-
- render( );
-
- const email = screen.getByTestId("email");
- await act(() => userEvent.type(email, "fake"));
-
- const pass = screen.getByTestId("pass");
- await act(() => userEvent.type(pass, "short"));
-
- const submit = screen.getByTestId("submit");
- await act(() => userEvent.click(submit));
-
- // @ts-expect-error
- expect(screen.getByText("Passwords don't match")).toBeInTheDocument();
- expect(
- screen.getByText("Invalid email"),
- // @ts-expect-error
- ).toBeInTheDocument();
- expect(
- screen.getByText("String must contain at least 8 character(s)"),
- // @ts-expect-error
- ).toBeInTheDocument();
- });
-});
diff --git a/src/Form.tsx b/src/Form.tsx
deleted file mode 100644
index b0c46d2..0000000
--- a/src/Form.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-"use client";
-
-import React, { useTransition } from "react";
-import type { FormHTMLAttributes, FormEvent } from "react";
-import type { RenderProp } from "react-render-prop-type";
-import type {
- FormState,
- InitialState,
- InvalidState,
- FailureState,
- SuccessState,
-} from "./createFormAction";
-import { useActionState } from "react";
-
-export type FormStateProps = {
- action: (
- state: FormState,
- payload: Payload,
- ) => Promise>;
- initialData: Data;
- permalink?: string;
- /**
- * Opt-in into automatic form reset by using the form "action" prop.
- * By default, the onSubmit with a custom transition which opts-out of the implicit form reset.
- * See for more. https://github.com/facebook/react/issues/29034
- * @default false
- */
- autoReset?: boolean;
-};
-
-export function initial(data: Data): InitialState {
- return { type: "initial", data, error: null, validationError: null };
-}
-
-type FormStatusFlags<
- T extends FormState["type"] | unknown = unknown,
-> = {
- isInitial: T extends "initial" ? true : false;
- isInvalid: T extends "invalid" ? true : false;
- isFailure: T extends "failure" ? true : false;
- isSuccess: T extends "success" ? true : false;
-};
-
-export type FormMetaState> = T &
- FormStatusFlags & {
- isPending: boolean;
- };
-
-export type FormProps = Omit<
- FormHTMLAttributes,
- "action" | "children"
-> &
- FormStateProps &
- RenderProp<
- | FormMetaState>
- | FormMetaState>
- | FormMetaState>
- | FormMetaState>
- >;
-
-const neverMetaState: FormStatusFlags = {
- isInitial: false,
- isInvalid: false,
- isFailure: false,
- isSuccess: false,
-};
-
-export function Form({
- children,
- action,
- initialData,
- permalink,
- autoReset = false,
- ...props
-}: FormProps) {
- const [state, formAction, isPending] = useActionState(
- action,
- initial(initialData),
- permalink,
- );
- const [, startTransition] = useTransition();
-
- const metaState =
- state.type === "initial"
- ? { ...state, ...neverMetaState, isPending, isInitial: true as const }
- : state.type === "invalid"
- ? { ...state, ...neverMetaState, isPending, isInvalid: true as const }
- : state.type === "failure"
- ? { ...state, ...neverMetaState, isPending, isFailure: true as const }
- : {
- ...state,
- ...neverMetaState,
- isPending,
- isSuccess: true as const,
- };
-
- const submitStrategy = autoReset
- ? { action: formAction }
- : {
- onSubmit: (event: FormEvent) => {
- event.preventDefault();
- const form = event.currentTarget;
- startTransition(() => {
- formAction(new FormData(form));
- });
- },
- };
-
- return (
-
- );
-}
diff --git a/src/Pending.test.tsx b/src/Pending.test.tsx
new file mode 100644
index 0000000..11fce1e
--- /dev/null
+++ b/src/Pending.test.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { describe, test, expect } from "vitest";
+import { userEvent } from "@testing-library/user-event";
+import { act, render, screen } from "@testing-library/react";
+import { z } from "zod";
+
+import { Action } from "./Action";
+import { formAction } from "./formAction";
+import { Pending } from "./Pending";
+import { ActionForm } from "./ActionForm";
+
+describe("Pending", () => {
+ test("it renders children when the action is pending", async () => {
+ const signUp = formAction
+ .input(
+ z.object({
+ email: z.string().email(),
+ })
+ )
+ .run(async () => {
+ await new Promise(() => {
+ /* never resolve */
+ });
+ return null;
+ });
+
+ function SignUpForm() {
+ return (
+
+
+
+
+
+ Please wait...
+
+
+
+ );
+ }
+
+ render( );
+
+ const email = screen.getByTestId("email");
+ await act(() => userEvent.type(email, "form@action.com"));
+
+ const submit = screen.getByTestId("submit");
+ await act(() => userEvent.click(submit));
+
+ // @ts-expect-error
+ expect(screen.getByText("Please wait...")).toBeInTheDocument();
+ });
+});
diff --git a/src/Pending.tsx b/src/Pending.tsx
new file mode 100644
index 0000000..ac8e98c
--- /dev/null
+++ b/src/Pending.tsx
@@ -0,0 +1,11 @@
+import { type PropsWithChildren } from "react";
+import { useActionContext } from "./Action";
+
+/**
+ * Conditionally renders the children, when the form action is in the "pending" state.
+ */
+export function Pending({ children }: PropsWithChildren) {
+ const { isPending } = useActionContext();
+
+ return isPending && children;
+}
diff --git a/src/client.ts b/src/client.ts
index fb907c8..abfc88e 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,4 +1,8 @@
"use client";
-export * from "./Form";
+
+export * from "./Action";
+export * from "./ActionForm";
+export * from "./createComponents";
export * from "./FormStatus";
+export * from "./Pending";
export * from "./ZodFieldError";
diff --git a/src/createComponents.tsx b/src/createComponents.tsx
new file mode 100644
index 0000000..f4cdafe
--- /dev/null
+++ b/src/createComponents.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import React, { type PropsWithChildren } from "react";
+import { type ZodFormattedError } from "zod";
+import type { FormAction } from "./createFormAction";
+import { useActionContext } from "./Action";
+import { InferZodErrorPaths, ZodFieldError } from "./ZodFieldError";
+
+/**
+ * Creates a typed components for actions created with the formAction builder.
+ */
+export function createComponents<
+ Data,
+ Error,
+ ValidationError extends ZodFormattedError,
+>(action: FormAction) {
+ function FieldError({
+ name,
+ }: PropsWithChildren<{ name?: "" | InferZodErrorPaths }>) {
+ const { isInvalid, validationError } = useActionContext(action);
+
+ return isInvalid && ;
+ }
+
+ return {
+ FieldError,
+ };
+}
diff --git a/src/createFormAction.ts b/src/createFormAction.ts
index 2f99aea..f351cc5 100644
--- a/src/createFormAction.ts
+++ b/src/createFormAction.ts
@@ -26,7 +26,7 @@ export type SuccessState = {
validationError: null;
};
-export type FormState> =
+export type ActionState> =
| InitialState
| InvalidState
| FailureState
@@ -38,9 +38,13 @@ export type FormAction<
ValidationError = Record,
Payload = FormData,
> = (
- state: FormState,
- payload: Payload,
-) => Promise>;
+ state: ActionState,
+ payload: Payload
+) => Promise>;
+
+export function initial(data: Data): InitialState {
+ return { type: "initial", data, error: null, validationError: null };
+}
export function createFormAction<
Data,
@@ -52,9 +56,9 @@ export function createFormAction<
success: (data: Data) => SuccessState;
failure: (error: Error) => FailureState;
invalid: (
- validationError: ValidationError,
+ validationError: ValidationError
) => InvalidState;
- }) => FormAction,
+ }) => FormAction
) {
function success(data: Data): SuccessState {
return { type: "success", data, error: null, validationError: null };
@@ -63,11 +67,11 @@ export function createFormAction<
return { type: "failure", data: null, error, validationError: null };
}
function invalid(
- validationError: ValidationError,
+ validationError: ValidationError
): InvalidState {
return { type: "invalid", data: null, error: null, validationError };
}
- return (state: FormState, payload: Payload) =>
+ return (state: ActionState, payload: Payload) =>
formAction({ success, failure, invalid })(state, payload);
}
diff --git a/src/formAction.test.ts b/src/formAction.test.ts
index c7961eb..e5400a1 100644
--- a/src/formAction.test.ts
+++ b/src/formAction.test.ts
@@ -1,14 +1,7 @@
import { it, describe, vi, expect } from "vitest";
-import {
- AnyZodObject,
- ZodEffects,
- ZodObject,
- ZodType,
- ZodTypeAny,
- z,
-} from "zod";
+import { z } from "zod";
import { formAction } from "./formAction";
-import { initial } from "./Form";
+import { initial } from "./createFormAction";
import { zfd } from "zod-form-data";
describe("formAction", () => {
@@ -16,7 +9,7 @@ describe("formAction", () => {
const result = await formAction.run(async () => 42)(
// @ts-expect-error undefined is ok, we don't use initial state
undefined,
- undefined,
+ undefined
);
expect(result).toHaveProperty("type", "success");
@@ -30,7 +23,7 @@ describe("formAction", () => {
});
await expect(() =>
- action(initial(null), new FormData()),
+ action(initial(null), new FormData())
).rejects.toThrowError();
});
@@ -58,7 +51,7 @@ describe("formAction", () => {
});
const failedWithNumber = await throwsNumber(
initial(null),
- new FormData(),
+ new FormData()
);
expect(failedWithNumber).toHaveProperty("type", "failure");
expect(failedWithNumber).toHaveProperty("error", "unknown");
@@ -98,7 +91,7 @@ describe("formAction", () => {
await formAction.run(handler)(
// @ts-expect-error undefined is ok, we don't use initial state
undefined,
- formData,
+ formData
);
expect(handler).toBeCalledWith({ ctx: { formData } });
@@ -124,7 +117,7 @@ describe("formAction", () => {
const action = formAction.input(
z.object({
allright: zfd.checkbox(),
- }),
+ })
);
describe("formData parsing", () => {
@@ -132,7 +125,7 @@ describe("formAction", () => {
.input(
z.object({
user: z.object({ name: z.string().min(3) }),
- }),
+ })
)
.run(async ({ input }) => input);
@@ -144,7 +137,7 @@ describe("formAction", () => {
const result = await nestedObjectInput(
// @ts-expect-error undefined is ok
undefined,
- formData,
+ formData
);
expect(result).toHaveProperty("type", "success");
@@ -164,7 +157,7 @@ describe("formAction", () => {
const result = await nestedObjectInput(
// @ts-expect-error undefined is ok
undefined,
- formData,
+ formData
);
expect(result).toHaveProperty("type", "invalid");
@@ -197,11 +190,11 @@ describe("formAction", () => {
const result = await foo.run(
async ({ input: { age, allright } }) =>
- `You are ${age} y.o. and feeling ${allright ? "ok" : "ko"}`,
+ `You are ${age} y.o. and feeling ${allright ? "ok" : "ko"}`
)(
// @ts-expect-error undefined is ok
undefined,
- formData,
+ formData
);
expect(result).toHaveProperty("type", "success");
@@ -230,7 +223,7 @@ describe("formAction", () => {
.run(async ({ input }) => {})(
// @ts-expect-error
undefined,
- formData,
+ formData
);
expect(result).toHaveProperty("validationError", {
diff --git a/src/formAction.ts b/src/formAction.ts
index 2481316..ed0255b 100644
--- a/src/formAction.ts
+++ b/src/formAction.ts
@@ -13,7 +13,7 @@ import {
FailureState,
InvalidState,
SuccessState,
- FormState,
+ ActionState,
} from "./createFormAction";
import { zfd } from "zod-form-data";
@@ -65,7 +65,7 @@ type FormActionBuilder<
? "Your input contains effect which prevents merging it with the previous inputs."
: T
: T
- : "The schema output must be an object.",
+ : "The schema output must be an object."
) => FormActionBuilder<
Schema extends ZodObject
? // merging won't work for effects, but that is sanitized in input parameter
@@ -83,10 +83,10 @@ type FormActionBuilder<
*/
run: Schema extends ZodTypeAny
? (
- action: SchemaAction,
+ action: SchemaAction
) => (
- state: FormState>,
- payload: FormData,
+ state: ActionState>,
+ payload: FormData
) => Promise<
Flatten<
| InvalidState>
@@ -95,10 +95,10 @@ type FormActionBuilder<
>
>
: (
- action: Action,
+ action: Action
) => (
- state: FormState,
- payload: FormData,
+ state: ActionState,
+ payload: FormData
) => Promise | SuccessState>>;
/**
* A chainable context enhancing helper.
@@ -106,7 +106,7 @@ type FormActionBuilder<
* @returns FormActionBuilder
*/
use: >(
- middleware: ({ ctx }: { ctx: Context }) => Promise,
+ middleware: ({ ctx }: { ctx: Context }) => Promise
) => FormActionBuilder;
/**
* A chainable error handler to handle errors thrown while running the action passed to .run().
@@ -115,7 +115,7 @@ type FormActionBuilder<
* @returns FormActionBuilder
*/
error: (
- processError: (params: { error: unknown; ctx: Context }) => Err,
+ processError: (params: { error: unknown; ctx: Context }) => Err
) => FormActionBuilder;
};
@@ -127,7 +127,7 @@ function formActionBuilder<
>(
schema: Schema,
middleware: MiddlewareFn[] = [],
- processError?: (params: { error: unknown; ctx: Context }) => Err,
+ processError?: (params: { error: unknown; ctx: Context }) => Err
): FormActionBuilder {
async function createContext(formData: FormData) {
let ctx = { formData } as Context;
@@ -174,7 +174,7 @@ function formActionBuilder<
if (!result.success) {
return invalid(
- result.error.format() as z.inferFormattedError,
+ result.error.format() as z.inferFormattedError
);
}
@@ -190,7 +190,7 @@ function formActionBuilder<
throw error;
}
};
- },
+ }
);
};
@@ -200,15 +200,15 @@ function formActionBuilder<
return formActionBuilder(
newInput,
middleware,
- processError,
+ processError
);
} else if (schema._def.effect) {
throw new Error(
- "Previous input is not augmentable because it contains an effect.",
+ "Previous input is not augmentable because it contains an effect."
);
} else if (newInput._def.effect) {
throw new Error(
- "Your input contains effect which prevents merging it with the previous inputs.",
+ "Your input contains effect which prevents merging it with the previous inputs."
);
} else if (schema._def.typeName === ZodFirstPartyTypeKind.ZodObject) {
// @ts-ignore
@@ -216,30 +216,30 @@ function formActionBuilder<
return formActionBuilder(
merged,
- middleware,
+ middleware
);
} else {
throw Error(
- "Merging inputs works only for object schemas without effects.",
+ "Merging inputs works only for object schemas without effects."
);
}
},
use>(
- newMiddleware: ({ ctx }: { ctx: Context }) => Promise,
+ newMiddleware: ({ ctx }: { ctx: Context }) => Promise
) {
return formActionBuilder(
schema,
[...middleware, newMiddleware],
- processError,
+ processError
);
},
error(
- processError: (params: { error: unknown; ctx: Context }) => Err,
+ processError: (params: { error: unknown; ctx: Context }) => Err
) {
return formActionBuilder(
schema,
middleware,
- processError,
+ processError
);
},
run: schema === emptyInput ? run : runSchema,