react-form-action 2.5.2 → 2.6.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 +543 -543
- package/dist/client.d.mts +28 -23
- package/dist/client.mjs +1 -1
- package/dist/index.d.mts +12 -12
- package/dist/index.mjs +1 -1
- package/package.json +76 -74
package/README.md
CHANGED
|
@@ -1,543 +1,543 @@
|
|
|
1
|
-
# react-form-action
|
|
2
|
-
|
|
3
|
-
End-to-end typesafe success, error & validation state control for Next.js form actions.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
**Action Creator**
|
|
8
|
-
|
|
9
|
-
- ✅ Provides envelope objects with `"initial" | "invalid" | "success" | "failure"` response types.
|
|
10
|
-
- ✅ Define generic payload for each of the response type.
|
|
11
|
-
- ✅ Bind action arguments.
|
|
12
|
-
|
|
13
|
-
**tRPC-like Form Action builder**
|
|
14
|
-
|
|
15
|
-
- ✅ Define payload schema with the `.input(zodSchema)` to validate the `formData`
|
|
16
|
-
- ✅ Reuse business logic with the `.use(middleware)` method.
|
|
17
|
-
- ✅ Define bindable arguments with the `.args([])` method.
|
|
18
|
-
- ✅ Reuse error handling with the `.error(handler)`.
|
|
19
|
-
|
|
20
|
-
**React Context access with the `<Action action={myFormAction} />` component**
|
|
21
|
-
|
|
22
|
-
- ✅ The `useActionState()` accessible via the `useActionContext()` hook.
|
|
23
|
-
- ✅ Computes progress flags like `isInvalid`, `isSuccess` based on the envelope type.
|
|
24
|
-
|
|
25
|
-
**Context-bound `<Form />` component**
|
|
26
|
-
|
|
27
|
-
- ✅ Reads the `action` from the `<Action />` context.
|
|
28
|
-
- ✅ Opt-out from the default form reset after action submit.
|
|
29
|
-
|
|
30
|
-
## Install
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
npm i react-form-action zod-form-data
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
<a aria-label="NPM version" href="https://www.npmjs.com/package/react-form-action">
|
|
37
|
-
<img alt="NPM Version" src="https://img.shields.io/npm/v/react-form-action?style=for-the-badge&labelColor=24292e">
|
|
38
|
-
</a>
|
|
39
|
-
|
|
40
|
-
## Getting Started
|
|
41
|
-
|
|
42
|
-
#### 1️⃣ Create a Server Action
|
|
43
|
-
|
|
44
|
-
```tsx
|
|
45
|
-
// app/subscribe/action.ts
|
|
46
|
-
"use server";
|
|
47
|
-
|
|
48
|
-
import { formAction } from "react-form-action";
|
|
49
|
-
import { z } from "zod";
|
|
50
|
-
|
|
51
|
-
export const subscribeAction = formAction
|
|
52
|
-
.input(z.object({ email: z.string().email() }))
|
|
53
|
-
.run(async ({ input }) => {
|
|
54
|
-
return input.email;
|
|
55
|
-
});
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
#### 2️⃣ Create a Client Form Component
|
|
59
|
-
|
|
60
|
-
```tsx
|
|
61
|
-
// app/subscribe/form.tsx
|
|
62
|
-
"use client";
|
|
63
|
-
|
|
64
|
-
import {
|
|
65
|
-
Form,
|
|
66
|
-
Pending,
|
|
67
|
-
createComponents,
|
|
68
|
-
useActionContext,
|
|
69
|
-
} from "react-form-action/client";
|
|
70
|
-
|
|
71
|
-
import { subscribeAction } from "./action";
|
|
72
|
-
|
|
73
|
-
const { FieldError, Success } = createComponents(subscribeAction);
|
|
74
|
-
|
|
75
|
-
export function SubscribeForm() {
|
|
76
|
-
const { isPending, isFailure, error, data } =
|
|
77
|
-
useActionContext(subscribeAction);
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<Form>
|
|
81
|
-
<Success>
|
|
82
|
-
<p>✅ Email {data} was registered.</p>
|
|
83
|
-
</Success>
|
|
84
|
-
{/*💡 The FieldError "name" prop supports autocompletion */}
|
|
85
|
-
<FieldError name="email">
|
|
86
|
-
{({ name, error }) => (
|
|
87
|
-
<input className={error ? "invalid" : ""} name={name} />
|
|
88
|
-
)}
|
|
89
|
-
</FieldError>
|
|
90
|
-
<button type="submit" disabled={isPending}>
|
|
91
|
-
{isPending ? "🌀 Submitting..." : "Submit"}
|
|
92
|
-
</button>
|
|
93
|
-
<Pending>Please wait...</Pending>
|
|
94
|
-
</Form>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
#### 3️⃣ Provide the `<Action>` context on a Page
|
|
100
|
-
|
|
101
|
-
```tsx
|
|
102
|
-
// app/subscribe/page.tsx
|
|
103
|
-
|
|
104
|
-
import { Action } from "react-form-action/client";
|
|
105
|
-
|
|
106
|
-
import { subscribeAction } from "./action";
|
|
107
|
-
import { SubscribeForm } from "./form";
|
|
108
|
-
|
|
109
|
-
export default function Page() {
|
|
110
|
-
return (
|
|
111
|
-
<Action action={subscribeAction} initialData="">
|
|
112
|
-
<SubscribeForm />
|
|
113
|
-
</Action>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
## Usage
|
|
119
|
-
|
|
120
|
-
### `formAction` builder
|
|
121
|
-
|
|
122
|
-
The [`zod-form-data`](https://www.npmjs.com/package/zod-form-data) powered action builder.
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
// app/actions/auth.ts
|
|
126
|
-
"use server";
|
|
127
|
-
|
|
128
|
-
import { formAction } from "react-form-action";
|
|
129
|
-
import { z } from "zod";
|
|
130
|
-
import { cookies } from "next/headers";
|
|
131
|
-
|
|
132
|
-
const i18nMiddleware = async () => {
|
|
133
|
-
const { t } = await useTranslation("auth", cookies().get("i18n")?.value);
|
|
134
|
-
// will be added to context
|
|
135
|
-
return { t };
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const authAction = formAction
|
|
139
|
-
.use(i18nMiddleware)
|
|
140
|
-
.use(async ({ ctx: { t } }) =>
|
|
141
|
-
console.log("🎉 context enhanced by previous middlewares 🎉", t)
|
|
142
|
-
)
|
|
143
|
-
.error(async ({ error }) => {
|
|
144
|
-
if (error instanceof DbError) {
|
|
145
|
-
return error.custom.code;
|
|
146
|
-
} else {
|
|
147
|
-
// unknown error
|
|
148
|
-
// default Next.js error handling (error.js boundary)
|
|
149
|
-
throw error;
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
export const signIn = authAction
|
|
154
|
-
.input(z.object({ email: z.string().email() }))
|
|
155
|
-
// 🎉 extend the previous input (only without refinements and transforms)
|
|
156
|
-
.input(z.object({ password: z.string() }))
|
|
157
|
-
.run(async ({ ctx: { t }, input: { email, password } }) => {
|
|
158
|
-
// Type inferred: {email: string, password: string}
|
|
159
|
-
|
|
160
|
-
await db.signIn({ email, password });
|
|
161
|
-
|
|
162
|
-
return t("verificationEmail.success");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
export const signUp = authAction
|
|
166
|
-
.input(
|
|
167
|
-
z
|
|
168
|
-
.object({
|
|
169
|
-
email: z.string().email(),
|
|
170
|
-
password: z.string(),
|
|
171
|
-
confirm: z.string(),
|
|
172
|
-
})
|
|
173
|
-
.refine((data) => data.password === data.confirm, {
|
|
174
|
-
message: "Passwords don't match",
|
|
175
|
-
path: ["confirm"],
|
|
176
|
-
})
|
|
177
|
-
) // if using refinement, only one input call is permited, as schema with ZodEffects is not extendable.
|
|
178
|
-
.run(async ({ ctx: { t }, input: { email, password } }) => {
|
|
179
|
-
// 🎉 passwords match!
|
|
180
|
-
|
|
181
|
-
const tokenData = await db.signUp({ email, password });
|
|
182
|
-
|
|
183
|
-
if (!tokenData) {
|
|
184
|
-
return t("signUp.emailVerificationRequired");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return t("singUp.success");
|
|
188
|
-
});
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
#### Args binding
|
|
192
|
-
|
|
193
|
-
The `formAction` builder supports action arguments binding:
|
|
194
|
-
|
|
195
|
-
```ts
|
|
196
|
-
// app/update-user/[userId]/action.tsx
|
|
197
|
-
import { formAction } from "react-form-action";
|
|
198
|
-
|
|
199
|
-
export const updateUser = formAction
|
|
200
|
-
.args([z.string().uuid()])
|
|
201
|
-
.run(async ({ args: [userId] }) => {
|
|
202
|
-
return userId;
|
|
203
|
-
// ^? string
|
|
204
|
-
});
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
```tsx
|
|
208
|
-
// app/update-user/[userId]/page.tsx
|
|
209
|
-
import { Action } from "react-form-action/client";
|
|
210
|
-
|
|
211
|
-
import { updateUser } from "./action";
|
|
212
|
-
import { UpdateUserForm } from "./form";
|
|
213
|
-
|
|
214
|
-
export default function Page({
|
|
215
|
-
params,
|
|
216
|
-
}: {
|
|
217
|
-
params: Promise<{ userId: string }>;
|
|
218
|
-
}) {
|
|
219
|
-
const { userId } = await params;
|
|
220
|
-
|
|
221
|
-
const action = updateUser.bind(null, userId);
|
|
222
|
-
|
|
223
|
-
return (
|
|
224
|
-
<Action action={action} initialData="">
|
|
225
|
-
<SubscribeForm />
|
|
226
|
-
</Action>
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
### Action Creator
|
|
232
|
-
|
|
233
|
-
Low-level action creator, which provides the `success`, `failure` and `invalid` envelope constructors. With the `createFormAction` you must handle the native `FormData` by yourself.
|
|
234
|
-
|
|
235
|
-
```ts
|
|
236
|
-
"use server";
|
|
237
|
-
|
|
238
|
-
import { createFormAction } from "react-form-action";
|
|
239
|
-
import { z } from "zod";
|
|
240
|
-
|
|
241
|
-
// Define custom serializable error & success data types
|
|
242
|
-
type ErrorData = {
|
|
243
|
-
message: string;
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
type SuccessData = {
|
|
247
|
-
message: string;
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
type ValiationError = {
|
|
251
|
-
name?: string;
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const updateUserSchema = z.object({ name: z.string() });
|
|
255
|
-
|
|
256
|
-
export const updateUser = createFormAction<
|
|
257
|
-
SuccessData,
|
|
258
|
-
ErrorData,
|
|
259
|
-
ValiationError
|
|
260
|
-
>(({ success, failure, invalid }) =>
|
|
261
|
-
// success and failure helper functions create wrappers for success & error data respectively
|
|
262
|
-
async (prevState, formData) => {
|
|
263
|
-
if (prevState.type === "initial") {
|
|
264
|
-
// use the initialData passed to <Form /> here
|
|
265
|
-
// prevState.data === "foobar"
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
const { name } = updateUserSchema.parse({
|
|
270
|
-
name: formData.get("name"),
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
const user = await updateCurrentUser(name);
|
|
274
|
-
|
|
275
|
-
if (user) {
|
|
276
|
-
// {type: "success", data: "Your profile has been updated.", error: null, validationError: null}
|
|
277
|
-
return success({
|
|
278
|
-
message: "Your profile has been updated.",
|
|
279
|
-
});
|
|
280
|
-
} else {
|
|
281
|
-
// {type: "error", data: null, error: { message: "No current user." }, validationError: null}
|
|
282
|
-
return failure({ message: "No current user." });
|
|
283
|
-
}
|
|
284
|
-
} catch (error) {
|
|
285
|
-
if (error instanceof ZodError) {
|
|
286
|
-
// {type: "invalid", data: null, error: null, validationError: {name: "Invalid input"}}
|
|
287
|
-
return invalid({
|
|
288
|
-
name: error.issues[0]?.message ?? "Validation error",
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return failure({ message: "Failed to update user." });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
);
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
The action creator supports arguments binding:
|
|
299
|
-
|
|
300
|
-
```ts
|
|
301
|
-
export const updateUser = createFormAction(
|
|
302
|
-
(
|
|
303
|
-
{ success, failure, invalid },
|
|
304
|
-
userId: string /* Here you can specify multiple arguments */
|
|
305
|
-
) =>
|
|
306
|
-
async (prevState, formData) => {
|
|
307
|
-
try {
|
|
308
|
-
const { name } = updateUserSchema.parse({
|
|
309
|
-
name: formData.get("name"),
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
const user = await db.users.findById(userId);
|
|
313
|
-
|
|
314
|
-
if (!user) {
|
|
315
|
-
return failure({ message: "No such user." });
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const updated = await user.update({ name });
|
|
319
|
-
|
|
320
|
-
if (updated) {
|
|
321
|
-
return success({
|
|
322
|
-
message: "User has been updated.",
|
|
323
|
-
});
|
|
324
|
-
} else {
|
|
325
|
-
return failure({ message: "Failed to update." });
|
|
326
|
-
}
|
|
327
|
-
} catch (error) {
|
|
328
|
-
// handle error
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
// call bind as usuall, the "123" becomes the "userId"
|
|
334
|
-
updateUser.bind(null, "123");
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
### Action Context
|
|
338
|
-
|
|
339
|
-
The `<Action>` components enables you to access your `action`'s state with the `useActionContext()` hook:
|
|
340
|
-
|
|
341
|
-
```tsx
|
|
342
|
-
// 👉 Define standalone client form component (e.g. /app/auth/signup/SignUpForm.tsx)
|
|
343
|
-
"use client";
|
|
344
|
-
|
|
345
|
-
import { Action, Form, useActionContext } from "react-form-action/client";
|
|
346
|
-
import type { PropsWithChildren } from "react";
|
|
347
|
-
|
|
348
|
-
import { signupAction } from "./action";
|
|
349
|
-
|
|
350
|
-
function Pending({ children }: PropsWithChildren) {
|
|
351
|
-
// read any state from the ActionContext:
|
|
352
|
-
const {
|
|
353
|
-
error,
|
|
354
|
-
data,
|
|
355
|
-
validationError,
|
|
356
|
-
isPending,
|
|
357
|
-
isFailure,
|
|
358
|
-
isInvalid,
|
|
359
|
-
isSuccess,
|
|
360
|
-
isInitial,
|
|
361
|
-
} = useActionContext();
|
|
362
|
-
|
|
363
|
-
return isPending && children;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// 💡 render this form on your RSC page (/app/auth/signup/page.tsx)
|
|
367
|
-
export function SignupForm() {
|
|
368
|
-
return (
|
|
369
|
-
<Action action={signupAction}>
|
|
370
|
-
<Form>
|
|
371
|
-
<input name="email" />
|
|
372
|
-
<input name="password" />
|
|
373
|
-
</Form>
|
|
374
|
-
{/* 🎆 Read the pending state outside the <Form> */}
|
|
375
|
-
<Pending>
|
|
376
|
-
{/* This renders only when the action is pending. 😎 */}
|
|
377
|
-
<p>Please wait...</p>
|
|
378
|
-
</Pending>
|
|
379
|
-
</Action>
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
### `<Form>` Component
|
|
385
|
-
|
|
386
|
-
The `<form>` submits the action in `onSubmit` handler to [prevent automatic form reset](https://github.com/facebook/react/issues/29034).
|
|
387
|
-
Pass `autoReset` prop to use the `action` prop instead and keep the default reset.
|
|
388
|
-
|
|
389
|
-
```tsx
|
|
390
|
-
"use client";
|
|
391
|
-
|
|
392
|
-
import { Action, Form } from "react-form-action/client";
|
|
393
|
-
|
|
394
|
-
import { updateUser } from "./action";
|
|
395
|
-
|
|
396
|
-
export function UpdateUserForm() {
|
|
397
|
-
return (
|
|
398
|
-
<Action action={updateUser}>
|
|
399
|
-
<Form autoReset>{/* ... */}</Form>
|
|
400
|
-
</Action>
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
### Context Bound Components `createComponents()`
|
|
406
|
-
|
|
407
|
-
Use the `createComponents(action)` helper to create components which use the ActionContext and have types bound to the action type.
|
|
408
|
-
|
|
409
|
-
#### `<FielError>` Component
|
|
410
|
-
|
|
411
|
-
```tsx
|
|
412
|
-
"use client";
|
|
413
|
-
|
|
414
|
-
// ⚠️ createComponents is usable only in "use client" components
|
|
415
|
-
import { Form, createComponents } from "react-form-action/client";
|
|
416
|
-
|
|
417
|
-
import { authAction } from "./actions";
|
|
418
|
-
|
|
419
|
-
export const signUpAction = authAction
|
|
420
|
-
.input(
|
|
421
|
-
z
|
|
422
|
-
.object({
|
|
423
|
-
user: z.object({
|
|
424
|
-
email: z.string().email(),
|
|
425
|
-
name: z.string(),
|
|
426
|
-
}),
|
|
427
|
-
password: z.string().min(8),
|
|
428
|
-
confirm: z.string(),
|
|
429
|
-
})
|
|
430
|
-
.refine((data) => data.password === data.confirm, {
|
|
431
|
-
message: "Passwords don't match",
|
|
432
|
-
})
|
|
433
|
-
)
|
|
434
|
-
.run(async ({ ctx, input }) => {
|
|
435
|
-
return null;
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// 🌟 The FieldError is now bound do the signUpAction input schema which allows autocompletion for its "name" prop
|
|
439
|
-
// ⚠️ Usable only with actions created with the formAction builder
|
|
440
|
-
const { FieldError } = createComponents(signUpAction);
|
|
441
|
-
|
|
442
|
-
export function SignUpForm() {
|
|
443
|
-
return (
|
|
444
|
-
<Action action={signUpAction} initialData={null}>
|
|
445
|
-
<Form>
|
|
446
|
-
{/* 1️⃣ When the "name" prop is an empty string, the top-level error will be rendered e.g.:
|
|
447
|
-
"Passwords don't match" */}
|
|
448
|
-
<FieldError name="" />
|
|
449
|
-
{/* 2️⃣ Access fields by their name: */}
|
|
450
|
-
<FieldError name="password" />
|
|
451
|
-
<FieldError name="confirm" />
|
|
452
|
-
{/* 3️⃣ Access nested fields by dot access notation: */}
|
|
453
|
-
<FieldError name="user.email" />
|
|
454
|
-
</Form>
|
|
455
|
-
</Action>
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
#### `<Success>`
|
|
461
|
-
|
|
462
|
-
#### When children are JSX
|
|
463
|
-
|
|
464
|
-
```tsx
|
|
465
|
-
import { Action, createComponents } from "react-form-action/client";
|
|
466
|
-
|
|
467
|
-
const { Success } = createComponents(signUpAction);
|
|
468
|
-
|
|
469
|
-
function MyForm() {
|
|
470
|
-
return (
|
|
471
|
-
<Action action={signUpAction}>
|
|
472
|
-
<Success>
|
|
473
|
-
{/* 👉 The message will render only after the action has succeeded */}
|
|
474
|
-
<p>You've been signed up!</p>
|
|
475
|
-
</Success>
|
|
476
|
-
</Action>
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
#### When children is a render prop
|
|
482
|
-
|
|
483
|
-
```tsx
|
|
484
|
-
import { createComponents } from "react-form-action/client";
|
|
485
|
-
|
|
486
|
-
const { Success } = createComponents(signUpAction);
|
|
487
|
-
|
|
488
|
-
function Label({children}: PropsWithChildren) {
|
|
489
|
-
return (
|
|
490
|
-
<Success>
|
|
491
|
-
{({ isSuccess, data }) => (
|
|
492
|
-
{/* 👉 With a render prop, the children are always mounted, regardles of the isSuccess flag */}
|
|
493
|
-
<label className={isSuccess ? "green" : ""}>
|
|
494
|
-
{children}
|
|
495
|
-
</label>
|
|
496
|
-
)}
|
|
497
|
-
</Success>
|
|
498
|
-
);
|
|
499
|
-
};
|
|
500
|
-
```
|
|
501
|
-
|
|
502
|
-
### `<Pending>`
|
|
503
|
-
|
|
504
|
-
Render children when the action is pending:
|
|
505
|
-
|
|
506
|
-
#### When children are JSX
|
|
507
|
-
|
|
508
|
-
```tsx
|
|
509
|
-
import { Action, Pending } from "react-form-action/client";
|
|
510
|
-
|
|
511
|
-
import { Spinner } from "./components";
|
|
512
|
-
|
|
513
|
-
function MyForm() {
|
|
514
|
-
return (
|
|
515
|
-
<Action action={action}>
|
|
516
|
-
{/* 👉 Unlike the React.useFormStatus() hook, we don't need here the <form> element at all. */}
|
|
517
|
-
<Pending>
|
|
518
|
-
{/* 👉 The spinner will UNMOUNT when the action is NOT pending */}
|
|
519
|
-
<Spinner />
|
|
520
|
-
</Pending>
|
|
521
|
-
</Action>
|
|
522
|
-
);
|
|
523
|
-
}
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
#### When children is a render prop
|
|
527
|
-
|
|
528
|
-
```tsx
|
|
529
|
-
import { Pending } from "react-form-action/client";
|
|
530
|
-
|
|
531
|
-
function SubmitButton() {
|
|
532
|
-
return (
|
|
533
|
-
<Pending>
|
|
534
|
-
{({ isPending }) => (
|
|
535
|
-
{/* 👉 With a render prop, the children are always mounted, regardles of the isPending flag */}
|
|
536
|
-
<button type="submit" disabled={isPending}>
|
|
537
|
-
{isPending ? "Submitting..." : "Submit"}
|
|
538
|
-
</button>
|
|
539
|
-
)}
|
|
540
|
-
</Pending>
|
|
541
|
-
);
|
|
542
|
-
};
|
|
543
|
-
```
|
|
1
|
+
# react-form-action
|
|
2
|
+
|
|
3
|
+
End-to-end typesafe success, error & validation state control for Next.js form actions.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
**Action Creator**
|
|
8
|
+
|
|
9
|
+
- ✅ Provides envelope objects with `"initial" | "invalid" | "success" | "failure"` response types.
|
|
10
|
+
- ✅ Define generic payload for each of the response type.
|
|
11
|
+
- ✅ Bind action arguments.
|
|
12
|
+
|
|
13
|
+
**tRPC-like Form Action builder**
|
|
14
|
+
|
|
15
|
+
- ✅ Define payload schema with the `.input(zodSchema)` to validate the `formData`
|
|
16
|
+
- ✅ Reuse business logic with the `.use(middleware)` method.
|
|
17
|
+
- ✅ Define bindable arguments with the `.args([])` method.
|
|
18
|
+
- ✅ Reuse error handling with the `.error(handler)`.
|
|
19
|
+
|
|
20
|
+
**React Context access with the `<Action action={myFormAction} />` component**
|
|
21
|
+
|
|
22
|
+
- ✅ The `useActionState()` accessible via the `useActionContext()` hook.
|
|
23
|
+
- ✅ Computes progress flags like `isInvalid`, `isSuccess` based on the envelope type.
|
|
24
|
+
|
|
25
|
+
**Context-bound `<Form />` component**
|
|
26
|
+
|
|
27
|
+
- ✅ Reads the `action` from the `<Action />` context.
|
|
28
|
+
- ✅ Opt-out from the default form reset after action submit.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
npm i react-form-action zod-form-data
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
<a aria-label="NPM version" href="https://www.npmjs.com/package/react-form-action">
|
|
37
|
+
<img alt="NPM Version" src="https://img.shields.io/npm/v/react-form-action?style=for-the-badge&labelColor=24292e">
|
|
38
|
+
</a>
|
|
39
|
+
|
|
40
|
+
## Getting Started
|
|
41
|
+
|
|
42
|
+
#### 1️⃣ Create a Server Action
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
// app/subscribe/action.ts
|
|
46
|
+
"use server";
|
|
47
|
+
|
|
48
|
+
import { formAction } from "react-form-action";
|
|
49
|
+
import { z } from "zod";
|
|
50
|
+
|
|
51
|
+
export const subscribeAction = formAction
|
|
52
|
+
.input(z.object({ email: z.string().email() }))
|
|
53
|
+
.run(async ({ input }) => {
|
|
54
|
+
return input.email;
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### 2️⃣ Create a Client Form Component
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// app/subscribe/form.tsx
|
|
62
|
+
"use client";
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
Form,
|
|
66
|
+
Pending,
|
|
67
|
+
createComponents,
|
|
68
|
+
useActionContext,
|
|
69
|
+
} from "react-form-action/client";
|
|
70
|
+
|
|
71
|
+
import { subscribeAction } from "./action";
|
|
72
|
+
|
|
73
|
+
const { FieldError, Success } = createComponents(subscribeAction);
|
|
74
|
+
|
|
75
|
+
export function SubscribeForm() {
|
|
76
|
+
const { isPending, isFailure, error, data } =
|
|
77
|
+
useActionContext(subscribeAction);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Form>
|
|
81
|
+
<Success>
|
|
82
|
+
<p>✅ Email {data} was registered.</p>
|
|
83
|
+
</Success>
|
|
84
|
+
{/*💡 The FieldError "name" prop supports autocompletion */}
|
|
85
|
+
<FieldError name="email">
|
|
86
|
+
{({ name, error }) => (
|
|
87
|
+
<input className={error ? "invalid" : ""} name={name} />
|
|
88
|
+
)}
|
|
89
|
+
</FieldError>
|
|
90
|
+
<button type="submit" disabled={isPending}>
|
|
91
|
+
{isPending ? "🌀 Submitting..." : "Submit"}
|
|
92
|
+
</button>
|
|
93
|
+
<Pending>Please wait...</Pending>
|
|
94
|
+
</Form>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### 3️⃣ Provide the `<Action>` context on a Page
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// app/subscribe/page.tsx
|
|
103
|
+
|
|
104
|
+
import { Action } from "react-form-action/client";
|
|
105
|
+
|
|
106
|
+
import { subscribeAction } from "./action";
|
|
107
|
+
import { SubscribeForm } from "./form";
|
|
108
|
+
|
|
109
|
+
export default function Page() {
|
|
110
|
+
return (
|
|
111
|
+
<Action action={subscribeAction} initialData="">
|
|
112
|
+
<SubscribeForm />
|
|
113
|
+
</Action>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Usage
|
|
119
|
+
|
|
120
|
+
### `formAction` builder
|
|
121
|
+
|
|
122
|
+
The [`zod-form-data`](https://www.npmjs.com/package/zod-form-data) powered action builder.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// app/actions/auth.ts
|
|
126
|
+
"use server";
|
|
127
|
+
|
|
128
|
+
import { formAction } from "react-form-action";
|
|
129
|
+
import { z } from "zod";
|
|
130
|
+
import { cookies } from "next/headers";
|
|
131
|
+
|
|
132
|
+
const i18nMiddleware = async () => {
|
|
133
|
+
const { t } = await useTranslation("auth", cookies().get("i18n")?.value);
|
|
134
|
+
// will be added to context
|
|
135
|
+
return { t };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const authAction = formAction
|
|
139
|
+
.use(i18nMiddleware)
|
|
140
|
+
.use(async ({ ctx: { t } }) =>
|
|
141
|
+
console.log("🎉 context enhanced by previous middlewares 🎉", t)
|
|
142
|
+
)
|
|
143
|
+
.error(async ({ error }) => {
|
|
144
|
+
if (error instanceof DbError) {
|
|
145
|
+
return error.custom.code;
|
|
146
|
+
} else {
|
|
147
|
+
// unknown error
|
|
148
|
+
// default Next.js error handling (error.js boundary)
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const signIn = authAction
|
|
154
|
+
.input(z.object({ email: z.string().email() }))
|
|
155
|
+
// 🎉 extend the previous input (only without refinements and transforms)
|
|
156
|
+
.input(z.object({ password: z.string() }))
|
|
157
|
+
.run(async ({ ctx: { t }, input: { email, password } }) => {
|
|
158
|
+
// Type inferred: {email: string, password: string}
|
|
159
|
+
|
|
160
|
+
await db.signIn({ email, password });
|
|
161
|
+
|
|
162
|
+
return t("verificationEmail.success");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export const signUp = authAction
|
|
166
|
+
.input(
|
|
167
|
+
z
|
|
168
|
+
.object({
|
|
169
|
+
email: z.string().email(),
|
|
170
|
+
password: z.string(),
|
|
171
|
+
confirm: z.string(),
|
|
172
|
+
})
|
|
173
|
+
.refine((data) => data.password === data.confirm, {
|
|
174
|
+
message: "Passwords don't match",
|
|
175
|
+
path: ["confirm"],
|
|
176
|
+
})
|
|
177
|
+
) // if using refinement, only one input call is permited, as schema with ZodEffects is not extendable.
|
|
178
|
+
.run(async ({ ctx: { t }, input: { email, password } }) => {
|
|
179
|
+
// 🎉 passwords match!
|
|
180
|
+
|
|
181
|
+
const tokenData = await db.signUp({ email, password });
|
|
182
|
+
|
|
183
|
+
if (!tokenData) {
|
|
184
|
+
return t("signUp.emailVerificationRequired");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return t("singUp.success");
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
#### Args binding
|
|
192
|
+
|
|
193
|
+
The `formAction` builder supports action arguments binding:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// app/update-user/[userId]/action.tsx
|
|
197
|
+
import { formAction } from "react-form-action";
|
|
198
|
+
|
|
199
|
+
export const updateUser = formAction
|
|
200
|
+
.args([z.string().uuid()])
|
|
201
|
+
.run(async ({ args: [userId] }) => {
|
|
202
|
+
return userId;
|
|
203
|
+
// ^? string
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
// app/update-user/[userId]/page.tsx
|
|
209
|
+
import { Action } from "react-form-action/client";
|
|
210
|
+
|
|
211
|
+
import { updateUser } from "./action";
|
|
212
|
+
import { UpdateUserForm } from "./form";
|
|
213
|
+
|
|
214
|
+
export default function Page({
|
|
215
|
+
params,
|
|
216
|
+
}: {
|
|
217
|
+
params: Promise<{ userId: string }>;
|
|
218
|
+
}) {
|
|
219
|
+
const { userId } = await params;
|
|
220
|
+
|
|
221
|
+
const action = updateUser.bind(null, userId);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<Action action={action} initialData="">
|
|
225
|
+
<SubscribeForm />
|
|
226
|
+
</Action>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Action Creator
|
|
232
|
+
|
|
233
|
+
Low-level action creator, which provides the `success`, `failure` and `invalid` envelope constructors. With the `createFormAction` you must handle the native `FormData` by yourself.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
"use server";
|
|
237
|
+
|
|
238
|
+
import { createFormAction } from "react-form-action";
|
|
239
|
+
import { z } from "zod";
|
|
240
|
+
|
|
241
|
+
// Define custom serializable error & success data types
|
|
242
|
+
type ErrorData = {
|
|
243
|
+
message: string;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
type SuccessData = {
|
|
247
|
+
message: string;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
type ValiationError = {
|
|
251
|
+
name?: string;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const updateUserSchema = z.object({ name: z.string() });
|
|
255
|
+
|
|
256
|
+
export const updateUser = createFormAction<
|
|
257
|
+
SuccessData,
|
|
258
|
+
ErrorData,
|
|
259
|
+
ValiationError
|
|
260
|
+
>(({ success, failure, invalid }) =>
|
|
261
|
+
// success and failure helper functions create wrappers for success & error data respectively
|
|
262
|
+
async (prevState, formData) => {
|
|
263
|
+
if (prevState.type === "initial") {
|
|
264
|
+
// use the initialData passed to <Form /> here
|
|
265
|
+
// prevState.data === "foobar"
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const { name } = updateUserSchema.parse({
|
|
270
|
+
name: formData.get("name"),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const user = await updateCurrentUser(name);
|
|
274
|
+
|
|
275
|
+
if (user) {
|
|
276
|
+
// {type: "success", data: "Your profile has been updated.", error: null, validationError: null}
|
|
277
|
+
return success({
|
|
278
|
+
message: "Your profile has been updated.",
|
|
279
|
+
});
|
|
280
|
+
} else {
|
|
281
|
+
// {type: "error", data: null, error: { message: "No current user." }, validationError: null}
|
|
282
|
+
return failure({ message: "No current user." });
|
|
283
|
+
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (error instanceof ZodError) {
|
|
286
|
+
// {type: "invalid", data: null, error: null, validationError: {name: "Invalid input"}}
|
|
287
|
+
return invalid({
|
|
288
|
+
name: error.issues[0]?.message ?? "Validation error",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return failure({ message: "Failed to update user." });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The action creator supports arguments binding:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
export const updateUser = createFormAction(
|
|
302
|
+
(
|
|
303
|
+
{ success, failure, invalid },
|
|
304
|
+
userId: string /* Here you can specify multiple arguments */
|
|
305
|
+
) =>
|
|
306
|
+
async (prevState, formData) => {
|
|
307
|
+
try {
|
|
308
|
+
const { name } = updateUserSchema.parse({
|
|
309
|
+
name: formData.get("name"),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const user = await db.users.findById(userId);
|
|
313
|
+
|
|
314
|
+
if (!user) {
|
|
315
|
+
return failure({ message: "No such user." });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const updated = await user.update({ name });
|
|
319
|
+
|
|
320
|
+
if (updated) {
|
|
321
|
+
return success({
|
|
322
|
+
message: "User has been updated.",
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
return failure({ message: "Failed to update." });
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
// handle error
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// call bind as usuall, the "123" becomes the "userId"
|
|
334
|
+
updateUser.bind(null, "123");
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Action Context
|
|
338
|
+
|
|
339
|
+
The `<Action>` components enables you to access your `action`'s state with the `useActionContext()` hook:
|
|
340
|
+
|
|
341
|
+
```tsx
|
|
342
|
+
// 👉 Define standalone client form component (e.g. /app/auth/signup/SignUpForm.tsx)
|
|
343
|
+
"use client";
|
|
344
|
+
|
|
345
|
+
import { Action, Form, useActionContext } from "react-form-action/client";
|
|
346
|
+
import type { PropsWithChildren } from "react";
|
|
347
|
+
|
|
348
|
+
import { signupAction } from "./action";
|
|
349
|
+
|
|
350
|
+
function Pending({ children }: PropsWithChildren) {
|
|
351
|
+
// read any state from the ActionContext:
|
|
352
|
+
const {
|
|
353
|
+
error,
|
|
354
|
+
data,
|
|
355
|
+
validationError,
|
|
356
|
+
isPending,
|
|
357
|
+
isFailure,
|
|
358
|
+
isInvalid,
|
|
359
|
+
isSuccess,
|
|
360
|
+
isInitial,
|
|
361
|
+
} = useActionContext();
|
|
362
|
+
|
|
363
|
+
return isPending && children;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 💡 render this form on your RSC page (/app/auth/signup/page.tsx)
|
|
367
|
+
export function SignupForm() {
|
|
368
|
+
return (
|
|
369
|
+
<Action action={signupAction}>
|
|
370
|
+
<Form>
|
|
371
|
+
<input name="email" />
|
|
372
|
+
<input name="password" />
|
|
373
|
+
</Form>
|
|
374
|
+
{/* 🎆 Read the pending state outside the <Form> */}
|
|
375
|
+
<Pending>
|
|
376
|
+
{/* This renders only when the action is pending. 😎 */}
|
|
377
|
+
<p>Please wait...</p>
|
|
378
|
+
</Pending>
|
|
379
|
+
</Action>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### `<Form>` Component
|
|
385
|
+
|
|
386
|
+
The `<form>` submits the action in `onSubmit` handler to [prevent automatic form reset](https://github.com/facebook/react/issues/29034).
|
|
387
|
+
Pass `autoReset` prop to use the `action` prop instead and keep the default reset.
|
|
388
|
+
|
|
389
|
+
```tsx
|
|
390
|
+
"use client";
|
|
391
|
+
|
|
392
|
+
import { Action, Form } from "react-form-action/client";
|
|
393
|
+
|
|
394
|
+
import { updateUser } from "./action";
|
|
395
|
+
|
|
396
|
+
export function UpdateUserForm() {
|
|
397
|
+
return (
|
|
398
|
+
<Action action={updateUser}>
|
|
399
|
+
<Form autoReset>{/* ... */}</Form>
|
|
400
|
+
</Action>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Context Bound Components `createComponents()`
|
|
406
|
+
|
|
407
|
+
Use the `createComponents(action)` helper to create components which use the ActionContext and have types bound to the action type.
|
|
408
|
+
|
|
409
|
+
#### `<FielError>` Component
|
|
410
|
+
|
|
411
|
+
```tsx
|
|
412
|
+
"use client";
|
|
413
|
+
|
|
414
|
+
// ⚠️ createComponents is usable only in "use client" components
|
|
415
|
+
import { Form, createComponents } from "react-form-action/client";
|
|
416
|
+
|
|
417
|
+
import { authAction } from "./actions";
|
|
418
|
+
|
|
419
|
+
export const signUpAction = authAction
|
|
420
|
+
.input(
|
|
421
|
+
z
|
|
422
|
+
.object({
|
|
423
|
+
user: z.object({
|
|
424
|
+
email: z.string().email(),
|
|
425
|
+
name: z.string(),
|
|
426
|
+
}),
|
|
427
|
+
password: z.string().min(8),
|
|
428
|
+
confirm: z.string(),
|
|
429
|
+
})
|
|
430
|
+
.refine((data) => data.password === data.confirm, {
|
|
431
|
+
message: "Passwords don't match",
|
|
432
|
+
})
|
|
433
|
+
)
|
|
434
|
+
.run(async ({ ctx, input }) => {
|
|
435
|
+
return null;
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// 🌟 The FieldError is now bound do the signUpAction input schema which allows autocompletion for its "name" prop
|
|
439
|
+
// ⚠️ Usable only with actions created with the formAction builder
|
|
440
|
+
const { FieldError } = createComponents(signUpAction);
|
|
441
|
+
|
|
442
|
+
export function SignUpForm() {
|
|
443
|
+
return (
|
|
444
|
+
<Action action={signUpAction} initialData={null}>
|
|
445
|
+
<Form>
|
|
446
|
+
{/* 1️⃣ When the "name" prop is an empty string, the top-level error will be rendered e.g.:
|
|
447
|
+
"Passwords don't match" */}
|
|
448
|
+
<FieldError name="" />
|
|
449
|
+
{/* 2️⃣ Access fields by their name: */}
|
|
450
|
+
<FieldError name="password" />
|
|
451
|
+
<FieldError name="confirm" />
|
|
452
|
+
{/* 3️⃣ Access nested fields by dot access notation: */}
|
|
453
|
+
<FieldError name="user.email" />
|
|
454
|
+
</Form>
|
|
455
|
+
</Action>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
#### `<Success>`
|
|
461
|
+
|
|
462
|
+
#### When children are JSX
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
import { Action, createComponents } from "react-form-action/client";
|
|
466
|
+
|
|
467
|
+
const { Success } = createComponents(signUpAction);
|
|
468
|
+
|
|
469
|
+
function MyForm() {
|
|
470
|
+
return (
|
|
471
|
+
<Action action={signUpAction}>
|
|
472
|
+
<Success>
|
|
473
|
+
{/* 👉 The message will render only after the action has succeeded */}
|
|
474
|
+
<p>You've been signed up!</p>
|
|
475
|
+
</Success>
|
|
476
|
+
</Action>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
#### When children is a render prop
|
|
482
|
+
|
|
483
|
+
```tsx
|
|
484
|
+
import { createComponents } from "react-form-action/client";
|
|
485
|
+
|
|
486
|
+
const { Success } = createComponents(signUpAction);
|
|
487
|
+
|
|
488
|
+
function Label({children}: PropsWithChildren) {
|
|
489
|
+
return (
|
|
490
|
+
<Success>
|
|
491
|
+
{({ isSuccess, data }) => (
|
|
492
|
+
{/* 👉 With a render prop, the children are always mounted, regardles of the isSuccess flag */}
|
|
493
|
+
<label className={isSuccess ? "green" : ""}>
|
|
494
|
+
{children}
|
|
495
|
+
</label>
|
|
496
|
+
)}
|
|
497
|
+
</Success>
|
|
498
|
+
);
|
|
499
|
+
};
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### `<Pending>`
|
|
503
|
+
|
|
504
|
+
Render children when the action is pending:
|
|
505
|
+
|
|
506
|
+
#### When children are JSX
|
|
507
|
+
|
|
508
|
+
```tsx
|
|
509
|
+
import { Action, Pending } from "react-form-action/client";
|
|
510
|
+
|
|
511
|
+
import { Spinner } from "./components";
|
|
512
|
+
|
|
513
|
+
function MyForm() {
|
|
514
|
+
return (
|
|
515
|
+
<Action action={action}>
|
|
516
|
+
{/* 👉 Unlike the React.useFormStatus() hook, we don't need here the <form> element at all. */}
|
|
517
|
+
<Pending>
|
|
518
|
+
{/* 👉 The spinner will UNMOUNT when the action is NOT pending */}
|
|
519
|
+
<Spinner />
|
|
520
|
+
</Pending>
|
|
521
|
+
</Action>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
#### When children is a render prop
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
import { Pending } from "react-form-action/client";
|
|
530
|
+
|
|
531
|
+
function SubmitButton() {
|
|
532
|
+
return (
|
|
533
|
+
<Pending>
|
|
534
|
+
{({ isPending }) => (
|
|
535
|
+
{/* 👉 With a render prop, the children are always mounted, regardles of the isPending flag */}
|
|
536
|
+
<button type="submit" disabled={isPending}>
|
|
537
|
+
{isPending ? "Submitting..." : "Submit"}
|
|
538
|
+
</button>
|
|
539
|
+
)}
|
|
540
|
+
</Pending>
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
```
|