@firtoz/router-toolkit 1.3.1 → 2.0.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 +222 -0
- package/package.json +4 -1
- package/src/formAction.ts +188 -0
- package/src/index.ts +1 -0
package/README.md
CHANGED
|
@@ -392,6 +392,228 @@ export default function NotificationForm() {
|
|
|
392
392
|
- `idle` → `loading`: Data fetching started (with `useDynamicFetcher`)
|
|
393
393
|
- `loading` → `idle`: Data fetching completed
|
|
394
394
|
|
|
395
|
+
## Form Action Utilities
|
|
396
|
+
|
|
397
|
+
### `formAction`
|
|
398
|
+
|
|
399
|
+
Type-safe form action wrapper that provides Zod validation and structured error handling for React Router actions. This utility integrates seamlessly with `useDynamicSubmitter` and the `formSchema` export pattern.
|
|
400
|
+
|
|
401
|
+
#### Features
|
|
402
|
+
|
|
403
|
+
- ✅ **Automatic form data validation** using Zod schemas
|
|
404
|
+
- 🛡️ **Type-safe error handling** with structured error types
|
|
405
|
+
- 🔄 **MaybeError integration** for consistent error patterns
|
|
406
|
+
- 🚀 **React Router compatibility** preserves redirects and responses
|
|
407
|
+
- 📝 **Full TypeScript support** with inferred types from schemas
|
|
408
|
+
|
|
409
|
+
#### Basic Usage
|
|
410
|
+
|
|
411
|
+
```tsx
|
|
412
|
+
// app/routes/register.tsx
|
|
413
|
+
import { z } from "zod/v4";
|
|
414
|
+
import { formAction, type RoutePath } from "@firtoz/router-toolkit";
|
|
415
|
+
import { success, fail } from "@firtoz/maybe-error";
|
|
416
|
+
|
|
417
|
+
// Export the schema for useDynamicSubmitter integration
|
|
418
|
+
export const formSchema = z.object({
|
|
419
|
+
email: z.string().email("Invalid email format"),
|
|
420
|
+
password: z.string().min(8, "Password must be at least 8 characters"),
|
|
421
|
+
confirmPassword: z.string(),
|
|
422
|
+
}).refine(data => data.password === data.confirmPassword, {
|
|
423
|
+
message: "Passwords don't match",
|
|
424
|
+
path: ["confirmPassword"],
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
export const action = formAction({
|
|
428
|
+
schema: formSchema,
|
|
429
|
+
handler: async (args, data) => {
|
|
430
|
+
// data is fully typed based on the schema
|
|
431
|
+
try {
|
|
432
|
+
const user = await createUser({
|
|
433
|
+
email: data.email,
|
|
434
|
+
password: data.password,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return success({
|
|
438
|
+
message: "Registration successful!",
|
|
439
|
+
userId: user.id,
|
|
440
|
+
});
|
|
441
|
+
} catch (error) {
|
|
442
|
+
return fail("Email already exists");
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
export const route: RoutePath<"/register"> = "/register";
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
#### Using with useDynamicSubmitter
|
|
451
|
+
|
|
452
|
+
The `formAction` utility works seamlessly with `useDynamicSubmitter` when you export a `formSchema`:
|
|
453
|
+
|
|
454
|
+
```tsx
|
|
455
|
+
// app/routes/register.tsx (component)
|
|
456
|
+
import { useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
457
|
+
|
|
458
|
+
export default function Register() {
|
|
459
|
+
const submitter = useDynamicSubmitter<typeof import("./register")>("/register");
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<submitter.Form method="post">
|
|
463
|
+
<input name="email" type="email" required />
|
|
464
|
+
<input name="password" type="password" required />
|
|
465
|
+
<input name="confirmPassword" type="password" required />
|
|
466
|
+
<button type="submit" disabled={submitter.state === "submitting"}>
|
|
467
|
+
{submitter.state === "submitting" ? "Registering..." : "Register"}
|
|
468
|
+
</button>
|
|
469
|
+
</submitter.Form>
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
#### Error Handling
|
|
475
|
+
|
|
476
|
+
The `formAction` utility returns structured errors that you can handle in your components:
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
export default function Register() {
|
|
480
|
+
const submitter = useDynamicSubmitter<typeof import("./register")>("/register");
|
|
481
|
+
|
|
482
|
+
if (submitter.data && !submitter.data.success) {
|
|
483
|
+
const error = submitter.data.error;
|
|
484
|
+
|
|
485
|
+
switch (error.type) {
|
|
486
|
+
case "validation":
|
|
487
|
+
// Handle Zod validation errors
|
|
488
|
+
console.log("Validation errors:", error.error);
|
|
489
|
+
break;
|
|
490
|
+
case "handler":
|
|
491
|
+
// Handle business logic errors
|
|
492
|
+
console.log("Handler error:", error.error);
|
|
493
|
+
break;
|
|
494
|
+
case "unknown":
|
|
495
|
+
// Handle unexpected errors
|
|
496
|
+
console.log("Unknown error occurred");
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Rest of component...
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
#### Error Types
|
|
506
|
+
|
|
507
|
+
The `formAction` utility returns three types of errors:
|
|
508
|
+
|
|
509
|
+
1. **Validation Errors** (`type: "validation"`)
|
|
510
|
+
- Occurs when form data doesn't match the Zod schema
|
|
511
|
+
- Contains detailed field-level validation errors from Zod
|
|
512
|
+
- The `error.error` field contains the result of `z.treeifyError()`
|
|
513
|
+
|
|
514
|
+
2. **Handler Errors** (`type: "handler"`)
|
|
515
|
+
- Occurs when your handler function returns a `fail()` result
|
|
516
|
+
- Contains the custom error you provided to `fail()`
|
|
517
|
+
- The `error.error` field contains your custom error value
|
|
518
|
+
|
|
519
|
+
3. **Unknown Errors** (`type: "unknown"`)
|
|
520
|
+
- Occurs when an unexpected exception is thrown
|
|
521
|
+
- Logs the error to console for debugging
|
|
522
|
+
- Does not expose the raw error to avoid information leakage
|
|
523
|
+
|
|
524
|
+
#### Advanced Features
|
|
525
|
+
|
|
526
|
+
**File Uploads**
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
const uploadSchema = z.object({
|
|
530
|
+
title: z.string().min(1),
|
|
531
|
+
file: z.instanceof(File),
|
|
532
|
+
description: z.string().optional(),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
export const action = formAction({
|
|
536
|
+
schema: uploadSchema,
|
|
537
|
+
handler: async (args, data) => {
|
|
538
|
+
const uploadResult = await uploadFile(data.file, {
|
|
539
|
+
title: data.title,
|
|
540
|
+
description: data.description,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
return success({ fileId: uploadResult.id });
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
**Complex Validation**
|
|
549
|
+
|
|
550
|
+
```tsx
|
|
551
|
+
const complexSchema = z.object({
|
|
552
|
+
user: z.object({
|
|
553
|
+
name: z.string().min(2),
|
|
554
|
+
age: z.coerce.number().min(18),
|
|
555
|
+
}),
|
|
556
|
+
preferences: z.object({
|
|
557
|
+
newsletter: z.boolean().default(false),
|
|
558
|
+
theme: z.enum(["light", "dark"]).default("light"),
|
|
559
|
+
}),
|
|
560
|
+
terms: z.literal("on", {
|
|
561
|
+
errorMap: () => ({ message: "You must accept the terms" })
|
|
562
|
+
}),
|
|
563
|
+
});
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
**Redirects and Responses**
|
|
567
|
+
|
|
568
|
+
React Router `Response` objects (like redirects) are automatically preserved:
|
|
569
|
+
|
|
570
|
+
```tsx
|
|
571
|
+
export const action = formAction({
|
|
572
|
+
schema: loginSchema,
|
|
573
|
+
handler: async (args, data) => {
|
|
574
|
+
const user = await authenticateUser(data.email, data.password);
|
|
575
|
+
|
|
576
|
+
if (user) {
|
|
577
|
+
// This redirect will be properly handled by React Router
|
|
578
|
+
throw redirect("/dashboard");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return fail("Invalid credentials");
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
#### Type Safety
|
|
587
|
+
|
|
588
|
+
The `formAction` utility provides full type safety:
|
|
589
|
+
|
|
590
|
+
- **Schema inference**: Form data is typed based on your Zod schema
|
|
591
|
+
- **Handler types**: Handler parameters are properly typed
|
|
592
|
+
- **Error types**: Error handling is type-safe with discriminated unions
|
|
593
|
+
- **Integration**: Works seamlessly with `useDynamicSubmitter` type inference
|
|
594
|
+
|
|
595
|
+
#### API Reference
|
|
596
|
+
|
|
597
|
+
```tsx
|
|
598
|
+
function formAction<
|
|
599
|
+
TSchema extends z.ZodTypeAny,
|
|
600
|
+
TResult = undefined,
|
|
601
|
+
TError = string,
|
|
602
|
+
ActionArgs extends ActionFunctionArgs = ActionFunctionArgs,
|
|
603
|
+
>(config: {
|
|
604
|
+
schema: TSchema;
|
|
605
|
+
handler: (
|
|
606
|
+
args: ActionArgs,
|
|
607
|
+
data: z.infer<TSchema>
|
|
608
|
+
) => Promise<MaybeError<TResult, TError>>;
|
|
609
|
+
}): (args: ActionArgs) => Promise<MaybeError<TResult, FormActionError<TError>>>;
|
|
610
|
+
|
|
611
|
+
type FormActionError<TError> =
|
|
612
|
+
| { type: "validation"; error: ReturnType<typeof z.treeifyError> }
|
|
613
|
+
| { type: "handler"; error: TError }
|
|
614
|
+
| { type: "unknown" };
|
|
615
|
+
```
|
|
616
|
+
|
|
395
617
|
## Type Utilities
|
|
396
618
|
|
|
397
619
|
### `RoutePath<T>`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/router-toolkit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -59,5 +59,8 @@
|
|
|
59
59
|
},
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"zod-form-data": "^3.0.0"
|
|
62
65
|
}
|
|
63
66
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Type-safe form action utility for React Router 7
|
|
3
|
+
*
|
|
4
|
+
* This module provides a wrapper for React Router actions that handles form data validation
|
|
5
|
+
* using Zod schemas and provides structured error handling with MaybeError.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { z } from "zod/v4";
|
|
10
|
+
* import { formAction } from "@firtoz/router-toolkit";
|
|
11
|
+
* import { success } from "@firtoz/maybe-error";
|
|
12
|
+
*
|
|
13
|
+
* const schema = z.object({
|
|
14
|
+
* email: z.string().email(),
|
|
15
|
+
* password: z.string().min(8),
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* export const action = formAction({
|
|
19
|
+
* schema,
|
|
20
|
+
* handler: async (args, data) => {
|
|
21
|
+
* // data is fully typed based on the schema
|
|
22
|
+
* const user = await authenticateUser(data.email, data.password);
|
|
23
|
+
* return success(user);
|
|
24
|
+
* },
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { fail, type MaybeError } from "@firtoz/maybe-error";
|
|
30
|
+
import type { ActionFunctionArgs } from "react-router";
|
|
31
|
+
import { z } from "zod/v4";
|
|
32
|
+
import { zfd } from "zod-form-data";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Error types that can be returned by formAction
|
|
36
|
+
*/
|
|
37
|
+
export type FormActionError<TError> =
|
|
38
|
+
| {
|
|
39
|
+
type: "validation";
|
|
40
|
+
error: ReturnType<typeof z.treeifyError<z.ZodTypeAny>>;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
type: "handler";
|
|
44
|
+
error: TError;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
type: "unknown";
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Configuration object for formAction
|
|
52
|
+
*
|
|
53
|
+
* @template TSchema - The Zod schema type for form validation
|
|
54
|
+
* @template TResult - The success result type from the handler
|
|
55
|
+
* @template TError - The error type that the handler can return
|
|
56
|
+
* @template ActionArgs - The action function arguments type (defaults to ActionFunctionArgs)
|
|
57
|
+
*/
|
|
58
|
+
export interface FormActionConfig<
|
|
59
|
+
TSchema extends z.ZodTypeAny,
|
|
60
|
+
TResult = undefined,
|
|
61
|
+
TError = string,
|
|
62
|
+
ActionArgs extends ActionFunctionArgs = ActionFunctionArgs,
|
|
63
|
+
> {
|
|
64
|
+
/**
|
|
65
|
+
* Zod schema to validate the form data against
|
|
66
|
+
*/
|
|
67
|
+
schema: TSchema;
|
|
68
|
+
/**
|
|
69
|
+
* Handler function that processes the validated form data
|
|
70
|
+
*
|
|
71
|
+
* @param args - The original action function arguments
|
|
72
|
+
* @param data - The validated form data (typed according to the schema)
|
|
73
|
+
* @returns A promise that resolves to a MaybeError with the result or error
|
|
74
|
+
*/
|
|
75
|
+
handler: (
|
|
76
|
+
args: ActionArgs,
|
|
77
|
+
data: z.infer<TSchema>,
|
|
78
|
+
) => Promise<MaybeError<TResult, TError>>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates a type-safe form action handler that validates form data and provides structured error handling.
|
|
83
|
+
*
|
|
84
|
+
* This function wraps a React Router action to:
|
|
85
|
+
* 1. Parse and validate form data using a Zod schema
|
|
86
|
+
* 2. Call the provided handler with validated data
|
|
87
|
+
* 3. Return structured errors for validation failures, handler errors, or unknown errors
|
|
88
|
+
* 4. Preserve React Router Response objects (redirects, etc.) by re-throwing them
|
|
89
|
+
*
|
|
90
|
+
* @template TSchema - The Zod schema type for form validation
|
|
91
|
+
* @template TResult - The success result type from the handler (defaults to undefined)
|
|
92
|
+
* @template TError - The error type that the handler can return (defaults to string)
|
|
93
|
+
* @template ActionArgs - The action function arguments type (defaults to ActionFunctionArgs)
|
|
94
|
+
*
|
|
95
|
+
* @param config - Configuration object containing schema and handler
|
|
96
|
+
* @returns An action function that can be used with React Router
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* import { z } from "zod/v4";
|
|
101
|
+
* import { formAction } from "@firtoz/router-toolkit";
|
|
102
|
+
* import { success, fail } from "@firtoz/maybe-error";
|
|
103
|
+
*
|
|
104
|
+
* const loginSchema = z.object({
|
|
105
|
+
* email: z.string().email("Invalid email format"),
|
|
106
|
+
* password: z.string().min(8, "Password must be at least 8 characters"),
|
|
107
|
+
* });
|
|
108
|
+
*
|
|
109
|
+
* export const action = formAction({
|
|
110
|
+
* schema: loginSchema,
|
|
111
|
+
* handler: async (args, data) => {
|
|
112
|
+
* try {
|
|
113
|
+
* const user = await authenticateUser(data.email, data.password);
|
|
114
|
+
* return success(user);
|
|
115
|
+
* } catch (error) {
|
|
116
|
+
* return fail("Invalid credentials");
|
|
117
|
+
* }
|
|
118
|
+
* },
|
|
119
|
+
* });
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```typescript
|
|
124
|
+
* // In your component, handle the different error types:
|
|
125
|
+
* const actionData = useActionData<typeof action>();
|
|
126
|
+
*
|
|
127
|
+
* if (actionData && !actionData.success) {
|
|
128
|
+
* switch (actionData.error.type) {
|
|
129
|
+
* case "validation":
|
|
130
|
+
* // Handle validation errors - actionData.error.error contains field-specific errors
|
|
131
|
+
* break;
|
|
132
|
+
* case "handler":
|
|
133
|
+
* // Handle business logic errors - actionData.error.error contains your custom error
|
|
134
|
+
* break;
|
|
135
|
+
* case "unknown":
|
|
136
|
+
* // Handle unexpected errors
|
|
137
|
+
* break;
|
|
138
|
+
* }
|
|
139
|
+
* }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export const formAction = <
|
|
143
|
+
TSchema extends z.ZodTypeAny,
|
|
144
|
+
TResult = undefined,
|
|
145
|
+
TError = string,
|
|
146
|
+
ActionArgs extends ActionFunctionArgs = ActionFunctionArgs,
|
|
147
|
+
>({
|
|
148
|
+
schema,
|
|
149
|
+
handler,
|
|
150
|
+
}: FormActionConfig<TSchema, TResult, TError, ActionArgs>) => {
|
|
151
|
+
return async (
|
|
152
|
+
args: ActionArgs,
|
|
153
|
+
): Promise<MaybeError<TResult, FormActionError<TError>>> => {
|
|
154
|
+
try {
|
|
155
|
+
const rawFormData = await args.request.formData();
|
|
156
|
+
const formData = await zfd.formData(schema).safeParseAsync(rawFormData);
|
|
157
|
+
|
|
158
|
+
if (!formData.success) {
|
|
159
|
+
return fail({
|
|
160
|
+
type: "validation" as const,
|
|
161
|
+
error: z.treeifyError<TSchema>(
|
|
162
|
+
formData.error as z.core.$ZodError<TSchema>,
|
|
163
|
+
),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const handlerResult = await handler(args, formData.data);
|
|
168
|
+
if (!handlerResult.success) {
|
|
169
|
+
return fail({
|
|
170
|
+
type: "handler" as const,
|
|
171
|
+
error: handlerResult.error,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return handlerResult;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// Re-throw Response objects (redirects, etc.) to preserve React Router behavior
|
|
178
|
+
if (error instanceof Response) {
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.error("Unexpected error in formAction:", error);
|
|
183
|
+
return fail({
|
|
184
|
+
type: "unknown" as const,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
};
|
package/src/index.ts
CHANGED