react-form-action 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -44
- package/dist/client.d.mts +8 -6
- package/dist/client.mjs +1 -1
- package/dist/createFormAction-0c8f36ce.d.ts +32 -0
- package/dist/index.d.mts +55 -19
- package/dist/index.mjs +1 -1
- package/package.json +13 -8
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# react-form-action
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
End-to-end typesafe success, error & validation state control for Next.js 14 form actions.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -10,57 +10,122 @@ npm i react-form-action
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
+
### `formAction` builder
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// app/actions/auth.ts
|
|
17
|
+
"use server";
|
|
18
|
+
|
|
19
|
+
import { formAction } from "react-form-action";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { cookies } from "next/headers";
|
|
22
|
+
|
|
23
|
+
const emailSchema = z.object({ email: z.string().email() });
|
|
24
|
+
|
|
25
|
+
const i18nMiddleware = async () => {
|
|
26
|
+
const { t } = await useTranslation("auth", cookies().get("i18n")?.value);
|
|
27
|
+
// will be added to context
|
|
28
|
+
return { t };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const authAction = formAction(emailSchema)
|
|
32
|
+
.use(i18nMiddleware)
|
|
33
|
+
.use(async ({ ctx: { t } }) =>
|
|
34
|
+
console.log("🎉 context enhanced by previous middlewares 🎉", t)
|
|
35
|
+
)
|
|
36
|
+
.error((error) => {
|
|
37
|
+
if (error instanceof DbError) {
|
|
38
|
+
return error.custom.code;
|
|
39
|
+
} else {
|
|
40
|
+
// unknown error
|
|
41
|
+
// default Next.js error handling (error.js boundary)
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const resendVerifyEmailAction = authAction.run(
|
|
47
|
+
async ({ ctx: { t }, input: { email } }) => {
|
|
48
|
+
// do custom work
|
|
49
|
+
await db.resendVerificationEmail({ email });
|
|
50
|
+
|
|
51
|
+
// return translated success message
|
|
52
|
+
return t("verificationEmail.success");
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const sendResetPasswordEmail = authAction.run(
|
|
57
|
+
async ({ ctx: { t }, input: { email } }) => {
|
|
58
|
+
// do custom work
|
|
59
|
+
await db.emailPasswordSendPasswordResetEmail({ email });
|
|
60
|
+
|
|
61
|
+
// return translated success message
|
|
62
|
+
return t("resetPasswordEmail.success");
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
```
|
|
66
|
+
|
|
13
67
|
### Server Action (usable in Next.js)
|
|
14
68
|
|
|
15
69
|
```ts
|
|
16
|
-
// actions/updateUser.ts
|
|
17
70
|
"use server";
|
|
71
|
+
|
|
18
72
|
import { createFormAction } from "react-form-action";
|
|
73
|
+
import { z } from "zod";
|
|
19
74
|
|
|
20
75
|
// Define custom serializable error & success data types
|
|
21
76
|
type ErrorData = {
|
|
22
77
|
message: string;
|
|
23
|
-
validation?: boolean;
|
|
24
78
|
};
|
|
25
79
|
|
|
26
|
-
type SuccessData =
|
|
80
|
+
type SuccessData = {
|
|
81
|
+
message: string;
|
|
82
|
+
};
|
|
27
83
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
async (prevState, formData) => {
|
|
32
|
-
if (prevState.type === "initial") {
|
|
33
|
-
// use the initialData passed to <Form /> here
|
|
34
|
-
// prevState.data === "foobar"
|
|
35
|
-
}
|
|
84
|
+
type ValiationError = {
|
|
85
|
+
name?: string;
|
|
86
|
+
};
|
|
36
87
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
88
|
+
const updateUserSchema = z.object({ name: z.string() });
|
|
89
|
+
|
|
90
|
+
export const updateUser = createFormAction<
|
|
91
|
+
SuccessData,
|
|
92
|
+
ErrorData,
|
|
93
|
+
ValiationError
|
|
94
|
+
>(({ success, failure, invalid }) =>
|
|
95
|
+
// success and failure helper functions create wrappers for success & error data respectively
|
|
96
|
+
async (prevState, formData) => {
|
|
97
|
+
if (prevState.type === "initial") {
|
|
98
|
+
// use the initialData passed to <Form /> here
|
|
99
|
+
// prevState.data === "foobar"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const { name } = updateUserSchema.parse({
|
|
104
|
+
name: formData.get("name"),
|
|
105
|
+
});
|
|
41
106
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return failure({ message: "Failed to update user." });
|
|
107
|
+
const user = await updateCurrentUser(name);
|
|
108
|
+
|
|
109
|
+
if (user) {
|
|
110
|
+
// {type: "success", data: "Your profile has been updated.", error: null, validationError: null}
|
|
111
|
+
return success({
|
|
112
|
+
message: "Your profile has been updated.",
|
|
113
|
+
});
|
|
114
|
+
} else {
|
|
115
|
+
// {type: "error", data: null, error: { message: "No current user." }, validationError: null}
|
|
116
|
+
return failure({ message: "No current user." });
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof ZodError) {
|
|
120
|
+
// {type: "invalid", data: null, error: null, validationError: {name: "Invalid input"}}
|
|
121
|
+
return invalid({
|
|
122
|
+
name: error.issues[0]?.message ?? "Validation error",
|
|
123
|
+
});
|
|
62
124
|
}
|
|
125
|
+
|
|
126
|
+
return failure({ message: "Failed to update user." });
|
|
63
127
|
}
|
|
128
|
+
}
|
|
64
129
|
);
|
|
65
130
|
```
|
|
66
131
|
|
|
@@ -76,18 +141,23 @@ import { updateUser } from "@/actions";
|
|
|
76
141
|
export function UpdateUserForm() {
|
|
77
142
|
return (
|
|
78
143
|
<Form action={updateUser} initialData="foobar">
|
|
79
|
-
{({
|
|
144
|
+
{({
|
|
145
|
+
error,
|
|
146
|
+
data,
|
|
147
|
+
validationError,
|
|
148
|
+
isPending,
|
|
149
|
+
isFailure,
|
|
150
|
+
isInvalid,
|
|
151
|
+
isSuccess,
|
|
152
|
+
isInitial,
|
|
153
|
+
}) => (
|
|
80
154
|
<>
|
|
81
155
|
{/* safely access the data or error by checking the mutually exclusive boolean flags: */}
|
|
82
156
|
{isSuccess && <p className="success-message">{data}</p>}
|
|
83
|
-
|
|
84
|
-
{isFailure && !error.validation && (
|
|
85
|
-
<p className="error-message">{error.message}</p>
|
|
86
|
-
)}
|
|
87
|
-
|
|
157
|
+
{isFailure && <p className="error-message">{error.message}</p>}
|
|
88
158
|
<input type="text" name="name" disabled={isPending} />
|
|
89
|
-
{
|
|
90
|
-
<span className="input-error">{
|
|
159
|
+
{isInvalid && (
|
|
160
|
+
<span className="input-error">{validationError.name}</span>
|
|
91
161
|
)}
|
|
92
162
|
|
|
93
163
|
<button disabled={isPending}>
|
package/dist/client.d.mts
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import React__default, { FormHTMLAttributes } from 'react';
|
|
3
3
|
import { RenderProp } from 'react-render-prop-type';
|
|
4
|
-
import { InitialState, FailureState, SuccessState, FormState } from './
|
|
4
|
+
import { I as InitialState, a as InvalidState, b as FailureState, S as SuccessState, F as FormState } from './createFormAction-0c8f36ce.js';
|
|
5
5
|
import { useFormStatus } from 'react-dom';
|
|
6
6
|
|
|
7
|
-
type FormStateProps<Data, Error, Payload> = {
|
|
8
|
-
action: (state: FormState<Data, Error>, payload: Payload) => Promise<FormState<Data, Error>>;
|
|
7
|
+
type FormStateProps<Data, ValidationError, Error, Payload> = {
|
|
8
|
+
action: (state: FormState<Data, ValidationError, Error>, payload: Payload) => Promise<FormState<Data, ValidationError, Error>>;
|
|
9
9
|
initialData: Data;
|
|
10
10
|
permalink?: string;
|
|
11
11
|
};
|
|
12
|
-
|
|
12
|
+
declare function initial<Data>(data: Data): InitialState<Data>;
|
|
13
|
+
type FormMetaState<T extends FormState<unknown, unknown, unknown>> = T & {
|
|
13
14
|
isPending: boolean;
|
|
14
15
|
isInitial: T["type"] extends "initial" ? true : false;
|
|
15
16
|
isFailure: T["type"] extends "failure" ? true : false;
|
|
16
17
|
isSuccess: T["type"] extends "success" ? true : false;
|
|
18
|
+
isInvalid: T["type"] extends "invalid" ? true : false;
|
|
17
19
|
};
|
|
18
|
-
declare function Form<Data, Error>({ children, action, initialData, permalink, ...props }: Omit<FormHTMLAttributes<HTMLFormElement>, "action" | "children"> & FormStateProps<Data, Error, FormData> & RenderProp<FormMetaState<InitialState<Data>> | FormMetaState<FailureState<Error>> | FormMetaState<SuccessState<Data>>>): React__default.JSX.Element;
|
|
20
|
+
declare function Form<Data, ValidationError, Error>({ children, action, initialData, permalink, ...props }: Omit<FormHTMLAttributes<HTMLFormElement>, "action" | "children"> & FormStateProps<Data, ValidationError, Error, FormData> & RenderProp<FormMetaState<InitialState<Data>> | FormMetaState<InvalidState<Error>> | FormMetaState<FailureState<Error>> | FormMetaState<SuccessState<Data>>>): React__default.JSX.Element;
|
|
19
21
|
|
|
20
22
|
declare function FormStatus({ children, }: RenderProp<ReturnType<typeof useFormStatus>>): React.ReactNode;
|
|
21
23
|
|
|
22
|
-
export { Form, FormStatus };
|
|
24
|
+
export { Form, FormStatus, initial };
|
package/dist/client.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import a from"react";import{useFormState as
|
|
1
|
+
"use client";import a from"react";import{useFormState as u}from"react-dom";import{useFormStatus as p}from"react-dom";function e({children:t}){return t(p())}function S(t){return{type:"initial",data:t,error:null,validationError:null}}function E({children:t,action:o,initialData:i,permalink:n,...s}){let[r,m]=u(o,S(i),n);return a.createElement("form",{action:m,...s},a.createElement(e,null,({pending:l})=>t({...r,isPending:l,isInitial:r.type==="initial",isInvalid:r.type==="invalid",isFailure:r.type==="failure",isSuccess:r.type==="success"})))}export{E as Form,e as FormStatus,S as initial};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
type InitialState<T> = {
|
|
2
|
+
type: "initial";
|
|
3
|
+
data: T;
|
|
4
|
+
error: null;
|
|
5
|
+
validationError: null;
|
|
6
|
+
};
|
|
7
|
+
type InvalidState<T> = {
|
|
8
|
+
type: "invalid";
|
|
9
|
+
data: null;
|
|
10
|
+
error: null;
|
|
11
|
+
validationError: T;
|
|
12
|
+
};
|
|
13
|
+
type FailureState<T> = {
|
|
14
|
+
type: "failure";
|
|
15
|
+
data: null;
|
|
16
|
+
error: T;
|
|
17
|
+
validationError: null;
|
|
18
|
+
};
|
|
19
|
+
type SuccessState<T> = {
|
|
20
|
+
type: "success";
|
|
21
|
+
data: T;
|
|
22
|
+
error: null;
|
|
23
|
+
validationError: null;
|
|
24
|
+
};
|
|
25
|
+
type FormState<Data, Error, ValidationError = Record<string, never>> = InitialState<Data> | InvalidState<ValidationError> | FailureState<Error> | SuccessState<Data>;
|
|
26
|
+
declare function createFormAction<Data, Error = Data, ValidationError = Record<string, never>, Payload = FormData>(formAction: (params: {
|
|
27
|
+
success: (data: Data) => SuccessState<Data>;
|
|
28
|
+
failure: (error: Error) => FailureState<Error>;
|
|
29
|
+
invalid: (validationError: ValidationError) => InvalidState<ValidationError>;
|
|
30
|
+
}) => (state: FormState<Data, Error, ValidationError>, payload: Payload) => Promise<FormState<Data, Error, ValidationError>>): (state: FormState<Data, Error, ValidationError>, payload: Payload) => Promise<FormState<Data, Error, ValidationError>>;
|
|
31
|
+
|
|
32
|
+
export { FormState as F, InitialState as I, SuccessState as S, InvalidState as a, FailureState as b, createFormAction as c };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,22 +1,58 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { F as FormState } from './createFormAction-0c8f36ce.js';
|
|
2
|
+
export { b as FailureState, I as InitialState, a as InvalidState, S as SuccessState, c as createFormAction } from './createFormAction-0c8f36ce.js';
|
|
3
|
+
import { ZodSchema, ZodUndefined, z } from 'zod';
|
|
4
|
+
|
|
5
|
+
type MiddlewareFn<Context, NewContext extends Record<string, unknown> = Record<string, unknown>> = ({ ctx }: {
|
|
6
|
+
ctx: Context;
|
|
7
|
+
}) => Promise<NewContext>;
|
|
8
|
+
type InitialContext = Record<"formData", FormData>;
|
|
9
|
+
/**
|
|
10
|
+
* Action without schema has no input.
|
|
11
|
+
*/
|
|
12
|
+
type Action<Data, Context> = (params: {
|
|
13
|
+
ctx: Context;
|
|
14
|
+
}) => Promise<Data>;
|
|
15
|
+
/**
|
|
16
|
+
* Action with schema will get parsed formData as the input.
|
|
17
|
+
*/
|
|
18
|
+
type SchemaAction<Data, Context, Schema extends ZodSchema> = (params: {
|
|
19
|
+
ctx: Context;
|
|
20
|
+
input: z.infer<Schema>;
|
|
21
|
+
}) => Promise<Data>;
|
|
22
|
+
type FormActionBuilder<Schema extends ZodSchema = ZodUndefined, Err = Error, Context = InitialContext> = {
|
|
23
|
+
/**
|
|
24
|
+
* A finishing call on the builder completing your action.
|
|
25
|
+
* @param action async function to execute. It will receive parsed formData as input, when the builder was initialized with a schema.
|
|
26
|
+
* @returns runnable server action exportable from a module.
|
|
27
|
+
*/
|
|
28
|
+
run: Schema extends undefined ? <Data>(action: Action<Data, Context>) => (state: FormState<Data, Err>, payload: FormData) => Promise<Data> : <Data>(action: SchemaAction<Data, Context, Schema>) => (state: FormState<Data, Err, ZodErrorMessages<Schema>>, payload: FormData) => Promise<Data>;
|
|
29
|
+
/**
|
|
30
|
+
* A chainable context enhancing helper.
|
|
31
|
+
* @param middleware A function which receives the current context, and returns an object which will be added to the context.
|
|
32
|
+
* @returns FormActionBuilder
|
|
33
|
+
*/
|
|
34
|
+
use: <NewContext extends Record<string, unknown>>(middleware: ({ ctx }: {
|
|
35
|
+
ctx: Context;
|
|
36
|
+
}) => Promise<NewContext>) => FormActionBuilder<Schema, Err, Context & NewContext>;
|
|
37
|
+
/**
|
|
38
|
+
* A chainable error handler to handle errors thrown while running the action passed to .run().
|
|
39
|
+
* It does not receive errors thrown from the context/middleware.
|
|
40
|
+
* @param processError A function which receives action's errors.
|
|
41
|
+
* @returns FormActionBuilder
|
|
42
|
+
*/
|
|
43
|
+
error: <Err>(processError: (error: unknown) => Err) => FormActionBuilder<Schema, Err, Context>;
|
|
10
44
|
};
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
45
|
+
declare function formAction<Schema extends ZodSchema = ZodUndefined, Err = Error, Context = InitialContext, NewContext extends Record<string, unknown> = Record<string, unknown>>(schema?: Schema,
|
|
46
|
+
/**
|
|
47
|
+
* @private
|
|
48
|
+
*/
|
|
49
|
+
middleware?: MiddlewareFn<Context, NewContext>[],
|
|
50
|
+
/**
|
|
51
|
+
* @private
|
|
52
|
+
*/
|
|
53
|
+
processError?: (error: unknown) => Err): FormActionBuilder<Schema, Err, Context>;
|
|
54
|
+
type ZodErrorMessages<Schema extends ZodSchema> = {
|
|
55
|
+
[k in keyof z.infer<Schema>]?: string;
|
|
15
56
|
};
|
|
16
|
-
type FormState<Data, Error> = InitialState<Data> | FailureState<Error> | SuccessState<Data>;
|
|
17
|
-
declare function createFormAction<Data, Error = Data, Payload = FormData>(formAction: (params: {
|
|
18
|
-
success: (data: Data) => SuccessState<Data>;
|
|
19
|
-
failure: (data: Error) => FailureState<Error>;
|
|
20
|
-
}) => (state: FormState<Data, Error>, payload: Payload) => Promise<FormState<Data, Error>>): (state: FormState<Data, Error>, payload: Payload) => Promise<FormState<Data, Error>>;
|
|
21
57
|
|
|
22
|
-
export {
|
|
58
|
+
export { FormState, formAction };
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
function
|
|
1
|
+
function x(r){function n(a){return{type:"success",data:a,error:null,validationError:null}}function e(a){return{type:"failure",data:null,error:a,validationError:null}}function i(a){return{type:"invalid",data:null,error:null,validationError:a}}return(a,m)=>r({success:n,failure:e,invalid:i})(a,m)}function C(r,n=[],e){async function i(t){let o={formData:t};for(let c of n){let d=await c({ctx:o});o={...o,...d}}return o}return{run:r?t=>x(({success:o,failure:c,invalid:d})=>async(S,s)=>{let l=await i(s),u=r.safeParse(s);if(!u.success)return d(D(u.error));let p=u.data;try{return o(await t({input:p,ctx:l}))}catch(E){if(e)return c(e(E));throw E}}):t=>x(({success:o,failure:c})=>async(d,S)=>{let s=await i(S);try{return o(await t({ctx:s}))}catch(l){if(e)return c(e(l));throw l}}),use(t){return C(r,[...n,t])},error(t){return C(r,n,t)}}}var D=r=>r.errors.reduce((n,{message:e,path:i})=>({...n,[i[0]]:e}),{});export{x as createFormAction,C as formAction};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-form-action",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "State management helpers for the react form actions.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"/dist"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"build": "tsup"
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"test": "vitest"
|
|
16
17
|
},
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
|
@@ -30,18 +31,19 @@
|
|
|
30
31
|
},
|
|
31
32
|
"keywords": [
|
|
32
33
|
"react",
|
|
33
|
-
"form",
|
|
34
|
-
"action",
|
|
34
|
+
"form-action",
|
|
35
|
+
"react-form-action",
|
|
35
36
|
"next",
|
|
37
|
+
"next-form-action",
|
|
36
38
|
"nextjs",
|
|
37
|
-
"server"
|
|
38
|
-
"action"
|
|
39
|
+
"server-action"
|
|
39
40
|
],
|
|
40
41
|
"author": "Miroslav Petrik",
|
|
41
42
|
"license": "MIT",
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"react": ">=18.2",
|
|
44
|
-
"react-dom": ">=18.2"
|
|
45
|
+
"react-dom": ">=18.2",
|
|
46
|
+
"zod": "^3"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
47
49
|
"@types/react": "^18.2.37",
|
|
@@ -49,6 +51,9 @@
|
|
|
49
51
|
"next": "^14.0.2",
|
|
50
52
|
"react-render-prop-type": "^0.1.0",
|
|
51
53
|
"tsup": "^7.2.0",
|
|
52
|
-
"typescript": "^5.2.2"
|
|
54
|
+
"typescript": "^5.2.2",
|
|
55
|
+
"vitest": "^1.2.2",
|
|
56
|
+
"zod": "^3.22.4",
|
|
57
|
+
"zod-form-data": "^2.0.2"
|
|
53
58
|
}
|
|
54
59
|
}
|