@firtoz/router-toolkit 5.1.0 → 5.3.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/package.json +1 -1
- package/src/formAction.ts +35 -19
- package/src/useDynamicSubmitter.tsx +49 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/router-toolkit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.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",
|
package/src/formAction.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Type-safe form action utility for React Router 7
|
|
3
3
|
*
|
|
4
|
-
* This module provides a wrapper for React Router actions that handles form data
|
|
5
|
-
* using Zod schemas and provides structured error handling with MaybeError.
|
|
4
|
+
* This module provides a wrapper for React Router actions that handles form data and JSON
|
|
5
|
+
* validation using Zod schemas and provides structured error handling with MaybeError.
|
|
6
|
+
*
|
|
7
|
+
* Supports both:
|
|
8
|
+
* - **JSON requests** (`Content-Type: application/json`) - parsed with `request.json()` and validated directly
|
|
9
|
+
* - **FormData requests** (`multipart/form-data` or `application/x-www-form-urlencoded`) - parsed with `request.formData()` and validated with zod-form-data
|
|
6
10
|
*
|
|
7
11
|
* ## Overview
|
|
8
12
|
*
|
|
@@ -56,17 +60,18 @@
|
|
|
56
60
|
* function LoginForm() {
|
|
57
61
|
* const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
|
|
58
62
|
*
|
|
59
|
-
* // Option 1: Submit as JSON (
|
|
63
|
+
* // Option 1: Submit as JSON (defaults to POST)
|
|
60
64
|
* const handleLoginJson = async () => {
|
|
61
|
-
* await submitter.submitJson(
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
+
* await submitter.submitJson({
|
|
66
|
+
* email: "user@example.com",
|
|
67
|
+
* password: "secret123",
|
|
68
|
+
* rememberMe: true,
|
|
69
|
+
* });
|
|
65
70
|
* };
|
|
66
71
|
*
|
|
67
|
-
* // Option 2: Use the Form component
|
|
72
|
+
* // Option 2: Use the Form component (defaults to POST)
|
|
68
73
|
* return (
|
|
69
|
-
* <submitter.Form
|
|
74
|
+
* <submitter.Form>
|
|
70
75
|
* <input name="email" type="email" placeholder="Email" />
|
|
71
76
|
* <input name="password" type="password" placeholder="Password" />
|
|
72
77
|
* <label>
|
|
@@ -229,13 +234,18 @@ export interface FormActionConfig<
|
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
/**
|
|
232
|
-
* Creates a type-safe form action handler that validates form data and provides structured error handling.
|
|
237
|
+
* Creates a type-safe form action handler that validates form data or JSON and provides structured error handling.
|
|
233
238
|
*
|
|
234
239
|
* This function wraps a React Router action to:
|
|
235
|
-
* 1.
|
|
236
|
-
* 2.
|
|
237
|
-
* 3.
|
|
238
|
-
* 4.
|
|
240
|
+
* 1. Detect content type (JSON vs FormData) from the request headers
|
|
241
|
+
* 2. Parse and validate the request body using a Zod schema
|
|
242
|
+
* 3. Call the provided handler with validated data
|
|
243
|
+
* 4. Return structured errors for validation failures, handler errors, or unknown errors
|
|
244
|
+
* 5. Preserve React Router Response objects (redirects, etc.) by re-throwing them
|
|
245
|
+
*
|
|
246
|
+
* **Content-Type handling:**
|
|
247
|
+
* - `application/json`: Uses `request.json()` and validates directly with the schema
|
|
248
|
+
* - `multipart/form-data` or `application/x-www-form-urlencoded`: Uses `request.formData()` and validates with zod-form-data
|
|
239
249
|
*
|
|
240
250
|
* @template TSchema - The Zod schema type for form validation
|
|
241
251
|
* @template TResult - The success result type from the handler (defaults to undefined)
|
|
@@ -302,19 +312,25 @@ export const formAction = <
|
|
|
302
312
|
args: ActionArgs,
|
|
303
313
|
): Promise<MaybeError<TResult, FormActionError<TError, TSchema>>> => {
|
|
304
314
|
try {
|
|
305
|
-
const
|
|
306
|
-
const
|
|
315
|
+
const contentType = args.request.headers.get("Content-Type");
|
|
316
|
+
const isJson = contentType?.includes("application/json") ?? false;
|
|
317
|
+
|
|
318
|
+
const parseResult = isJson
|
|
319
|
+
? await schema.safeParseAsync(await args.request.json())
|
|
320
|
+
: await zfd
|
|
321
|
+
.formData(schema)
|
|
322
|
+
.safeParseAsync(await args.request.formData());
|
|
307
323
|
|
|
308
|
-
if (!
|
|
324
|
+
if (!parseResult.success) {
|
|
309
325
|
return fail({
|
|
310
326
|
type: "validation" as const,
|
|
311
327
|
error: z.treeifyError<z.infer<TSchema>>(
|
|
312
|
-
|
|
328
|
+
parseResult.error as z.core.$ZodError<z.infer<TSchema>>,
|
|
313
329
|
),
|
|
314
330
|
});
|
|
315
331
|
}
|
|
316
332
|
|
|
317
|
-
const handlerResult = await handler(args,
|
|
333
|
+
const handlerResult = await handler(args, parseResult.data);
|
|
318
334
|
if (!handlerResult.success) {
|
|
319
335
|
return fail({
|
|
320
336
|
type: "handler" as const,
|
|
@@ -55,11 +55,13 @@
|
|
|
55
55
|
* // submitter.state is "idle" | "loading" | "submitting"
|
|
56
56
|
*
|
|
57
57
|
* // Option 1: Submit as JSON (recommended for programmatic submissions)
|
|
58
|
+
* // Defaults to POST if no options provided
|
|
58
59
|
* const handleSubmitJson = async () => {
|
|
59
|
-
* await submitter.submitJson(
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* await submitter.submitJson({
|
|
61
|
+
* title: "My Post",
|
|
62
|
+
* content: "Post content here",
|
|
63
|
+
* published: true,
|
|
64
|
+
* });
|
|
63
65
|
* };
|
|
64
66
|
*
|
|
65
67
|
* // Option 2: Submit with FormData or SubmitTarget
|
|
@@ -67,9 +69,9 @@
|
|
|
67
69
|
* await submitter.submit(formData, { method: "POST" });
|
|
68
70
|
* };
|
|
69
71
|
*
|
|
70
|
-
* // Option 3: Use the Form component
|
|
72
|
+
* // Option 3: Use the Form component (defaults to POST)
|
|
71
73
|
* return (
|
|
72
|
-
* <submitter.Form
|
|
74
|
+
* <submitter.Form>
|
|
73
75
|
* <input name="title" />
|
|
74
76
|
* <textarea name="content" />
|
|
75
77
|
* <button type="submit">Save</button>
|
|
@@ -98,7 +100,7 @@
|
|
|
98
100
|
* }, [submitter.data]);
|
|
99
101
|
*
|
|
100
102
|
* return (
|
|
101
|
-
* <submitter.Form
|
|
103
|
+
* <submitter.Form>
|
|
102
104
|
* <input name="email" type="email" />
|
|
103
105
|
* <input name="password" type="password" />
|
|
104
106
|
* <button disabled={submitter.state !== "idle"}>
|
|
@@ -160,6 +162,17 @@ type SubmitFunc<TModule extends RouteModule> = (
|
|
|
160
162
|
},
|
|
161
163
|
) => Promise<void>;
|
|
162
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Options for submitJson function.
|
|
167
|
+
* Method defaults to "POST" if not specified.
|
|
168
|
+
*/
|
|
169
|
+
type SubmitJsonOptions = Omit<
|
|
170
|
+
SubmitOptions,
|
|
171
|
+
"action" | "method" | "encType"
|
|
172
|
+
> & {
|
|
173
|
+
method?: Exclude<SubmitOptions["method"], "GET">;
|
|
174
|
+
};
|
|
175
|
+
|
|
163
176
|
/**
|
|
164
177
|
* Function type for submitting form data as JSON.
|
|
165
178
|
*
|
|
@@ -167,33 +180,44 @@ type SubmitFunc<TModule extends RouteModule> = (
|
|
|
167
180
|
* Automatically serializes the data as JSON. This is the recommended
|
|
168
181
|
* approach for programmatic form submissions.
|
|
169
182
|
*
|
|
183
|
+
* Options are optional and default to `{ method: "POST" }`.
|
|
184
|
+
*
|
|
170
185
|
* @example
|
|
171
186
|
* ```typescript
|
|
172
|
-
* // Submit a plain object - fully type-safe
|
|
173
|
-
* await submitter.submitJson(
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
187
|
+
* // Submit a plain object - fully type-safe (defaults to POST)
|
|
188
|
+
* await submitter.submitJson({
|
|
189
|
+
* email: "user@example.com",
|
|
190
|
+
* password: "secret123",
|
|
191
|
+
* rememberMe: true,
|
|
192
|
+
* });
|
|
193
|
+
*
|
|
194
|
+
* // Or specify a different method
|
|
195
|
+
* await submitter.submitJson(data, { method: "PUT" });
|
|
177
196
|
* ```
|
|
178
197
|
*/
|
|
179
198
|
type SubmitJsonFunc<TModule extends RouteModule> = (
|
|
180
199
|
data: z.infer<TModule["formSchema"]>,
|
|
181
|
-
options
|
|
182
|
-
method: Exclude<SubmitOptions["method"], "GET">;
|
|
183
|
-
},
|
|
200
|
+
options?: SubmitJsonOptions,
|
|
184
201
|
) => Promise<void>;
|
|
185
202
|
|
|
186
203
|
/**
|
|
187
204
|
* Form component type with pre-bound action URL.
|
|
188
205
|
*
|
|
189
206
|
* Renders a form element that automatically submits to the correct route.
|
|
207
|
+
* Method defaults to "POST" if not specified.
|
|
190
208
|
*
|
|
191
209
|
* @example
|
|
192
210
|
* ```typescript
|
|
193
|
-
*
|
|
211
|
+
* // Defaults to POST
|
|
212
|
+
* <submitter.Form>
|
|
194
213
|
* <input name="title" />
|
|
195
214
|
* <button type="submit">Submit</button>
|
|
196
215
|
* </submitter.Form>
|
|
216
|
+
*
|
|
217
|
+
* // Or specify a different method
|
|
218
|
+
* <submitter.Form method="PUT">
|
|
219
|
+
* ...
|
|
220
|
+
* </submitter.Form>
|
|
197
221
|
* ```
|
|
198
222
|
*/
|
|
199
223
|
type SubmitForm = (
|
|
@@ -201,7 +225,7 @@ type SubmitForm = (
|
|
|
201
225
|
FetcherFormProps & React.RefAttributes<HTMLFormElement>,
|
|
202
226
|
"action" | "method"
|
|
203
227
|
> & {
|
|
204
|
-
method
|
|
228
|
+
method?: Exclude<SubmitOptions["method"], "GET">;
|
|
205
229
|
},
|
|
206
230
|
) => React.ReactElement;
|
|
207
231
|
|
|
@@ -245,12 +269,12 @@ type SubmitForm = (
|
|
|
245
269
|
* { userId: "123" }
|
|
246
270
|
* );
|
|
247
271
|
*
|
|
248
|
-
* // Submit using submitJson (type-safe, no FormData needed)
|
|
272
|
+
* // Submit using submitJson (type-safe, no FormData needed, defaults to POST)
|
|
249
273
|
* await submitter.submitJson({
|
|
250
274
|
* displayName: "John Doe",
|
|
251
275
|
* email: "john@example.com",
|
|
252
276
|
* notifications: true,
|
|
253
|
-
* }
|
|
277
|
+
* });
|
|
254
278
|
*
|
|
255
279
|
* // Check the response
|
|
256
280
|
* if (submitter.data?.success) {
|
|
@@ -269,9 +293,9 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
|
269
293
|
> & {
|
|
270
294
|
/** Submit with FormData or SubmitTarget (schema type & SubmitTarget) */
|
|
271
295
|
submit: SubmitFunc<TInfo>;
|
|
272
|
-
/** Submit a plain object as JSON (schema type only,
|
|
296
|
+
/** Submit a plain object as JSON (schema type only, defaults to POST) */
|
|
273
297
|
submitJson: SubmitJsonFunc<TInfo>;
|
|
274
|
-
/** Pre-bound Form component with action URL already set */
|
|
298
|
+
/** Pre-bound Form component with action URL already set (defaults to POST) */
|
|
275
299
|
Form: SubmitForm;
|
|
276
300
|
} => {
|
|
277
301
|
const url = useMemo(() => {
|
|
@@ -295,9 +319,10 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
|
295
319
|
);
|
|
296
320
|
|
|
297
321
|
const submitJson: SubmitJsonFunc<TInfo> = useCallback(
|
|
298
|
-
(data, options) => {
|
|
322
|
+
(data, options = {}) => {
|
|
299
323
|
return fetcher.submit(data as SubmitTarget, {
|
|
300
324
|
...options,
|
|
325
|
+
method: options.method ?? "POST",
|
|
301
326
|
action: url,
|
|
302
327
|
encType: "application/json",
|
|
303
328
|
});
|
|
@@ -308,8 +333,8 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
|
308
333
|
const OriginalForm = fetcher.Form;
|
|
309
334
|
|
|
310
335
|
const Form: SubmitForm = useCallback(
|
|
311
|
-
(props) => {
|
|
312
|
-
return <OriginalForm action={url} {...props} />;
|
|
336
|
+
({ method = "POST", ...props }) => {
|
|
337
|
+
return <OriginalForm action={url} method={method} {...props} />;
|
|
313
338
|
},
|
|
314
339
|
[url, OriginalForm],
|
|
315
340
|
);
|