@firtoz/router-toolkit 7.0.1 → 8.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 +5 -1
- package/dist/ConcurrentSubmitterProvider.d.ts +41 -0
- package/dist/ConcurrentSubmitterProvider.js +3 -0
- package/dist/ConcurrentSubmitterProvider.js.map +1 -0
- package/dist/chunk-2RLEUOSR.js +3 -0
- package/dist/chunk-2RLEUOSR.js.map +1 -0
- package/dist/chunk-2W5QFQTE.js +3 -0
- package/dist/chunk-2W5QFQTE.js.map +1 -0
- package/dist/chunk-5MOCOBGV.js +45 -0
- package/dist/chunk-5MOCOBGV.js.map +1 -0
- package/dist/chunk-HX57TC2S.js +33 -0
- package/dist/chunk-HX57TC2S.js.map +1 -0
- package/dist/chunk-JJN6GBJL.js +55 -0
- package/dist/chunk-JJN6GBJL.js.map +1 -0
- package/dist/chunk-MKH4ZP5U.js +3 -0
- package/dist/chunk-MKH4ZP5U.js.map +1 -0
- package/dist/chunk-OQGTU34H.js +44 -0
- package/dist/chunk-OQGTU34H.js.map +1 -0
- package/dist/chunk-QZDEBX3D.js +71 -0
- package/dist/chunk-QZDEBX3D.js.map +1 -0
- package/dist/chunk-R6HEJ5E5.js +3 -0
- package/dist/chunk-R6HEJ5E5.js.map +1 -0
- package/dist/chunk-S4QCJFVR.js +3 -0
- package/dist/chunk-S4QCJFVR.js.map +1 -0
- package/dist/chunk-W5R7YYHR.js +16 -0
- package/dist/chunk-W5R7YYHR.js.map +1 -0
- package/dist/chunk-YYJDSKJG.js +3 -0
- package/dist/chunk-YYJDSKJG.js.map +1 -0
- package/dist/chunk-ZC6U3MIB.js +189 -0
- package/dist/chunk-ZC6U3MIB.js.map +1 -0
- package/dist/chunk-ZMRGBYFH.js +3 -0
- package/dist/chunk-ZMRGBYFH.js.map +1 -0
- package/dist/formAction.d.ts +290 -0
- package/dist/formAction.js +3 -0
- package/dist/formAction.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/types/Func.d.ts +3 -0
- package/dist/types/Func.js +3 -0
- package/dist/types/Func.js.map +1 -0
- package/dist/types/HrefArgs.d.ts +8 -0
- package/dist/types/HrefArgs.js +3 -0
- package/dist/types/HrefArgs.js.map +1 -0
- package/dist/types/RegisterPages.d.ts +11 -0
- package/dist/types/RegisterPages.js +3 -0
- package/dist/types/RegisterPages.js.map +1 -0
- package/dist/types/RoutePath.d.ts +6 -0
- package/dist/types/RoutePath.js +3 -0
- package/dist/types/RoutePath.js.map +1 -0
- package/dist/types/RouteWithActionModule.d.ts +18 -0
- package/dist/types/RouteWithActionModule.js +3 -0
- package/dist/types/RouteWithActionModule.js.map +1 -0
- package/dist/types/RouteWithLoaderModule.d.ts +10 -0
- package/dist/types/RouteWithLoaderModule.js +3 -0
- package/dist/types/RouteWithLoaderModule.js.map +1 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/index.js +9 -0
- package/dist/types/index.js.map +1 -0
- package/dist/useCachedFetch.d.ts +13 -0
- package/dist/useCachedFetch.js +3 -0
- package/dist/useCachedFetch.js.map +1 -0
- package/dist/useConcurrentSubmitter.d.ts +34 -0
- package/dist/useConcurrentSubmitter.js +4 -0
- package/dist/useConcurrentSubmitter.js.map +1 -0
- package/dist/useDynamicFetcher.d.ts +219 -0
- package/dist/useDynamicFetcher.js +3 -0
- package/dist/useDynamicFetcher.js.map +1 -0
- package/dist/useDynamicSubmitter.d.ts +256 -0
- package/dist/useDynamicSubmitter.js +3 -0
- package/dist/useDynamicSubmitter.js.map +1 -0
- package/dist/useFetcherStateChanged.d.ts +10 -0
- package/dist/useFetcherStateChanged.js +3 -0
- package/dist/useFetcherStateChanged.js.map +1 -0
- package/package.json +26 -17
- package/src/useConcurrentSubmitter.tsx +11 -14
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"RouteWithLoaderModule.js"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from '@firtoz/maybe-error';
|
|
2
|
+
export { Func } from './Func.js';
|
|
3
|
+
export { HrefArgs } from './HrefArgs.js';
|
|
4
|
+
export { RegisterPages } from './RegisterPages.js';
|
|
5
|
+
export { RoutePath } from './RoutePath.js';
|
|
6
|
+
export { ActionResult, RouteWithActionModule } from './RouteWithActionModule.js';
|
|
7
|
+
export { RouteWithLoaderModule } from './RouteWithLoaderModule.js';
|
|
8
|
+
import 'react-router';
|
|
9
|
+
import 'zod';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import '../chunk-2RLEUOSR.js';
|
|
2
|
+
import '../chunk-2W5QFQTE.js';
|
|
3
|
+
import '../chunk-YYJDSKJG.js';
|
|
4
|
+
import '../chunk-ZMRGBYFH.js';
|
|
5
|
+
import '../chunk-MKH4ZP5U.js';
|
|
6
|
+
import '../chunk-S4QCJFVR.js';
|
|
7
|
+
import '../chunk-R6HEJ5E5.js';
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useLoaderData } from 'react-router';
|
|
2
|
+
import { HrefArgs } from './types/HrefArgs.js';
|
|
3
|
+
import { RouteWithLoaderModule } from './types/RouteWithLoaderModule.js';
|
|
4
|
+
import './types/RegisterPages.js';
|
|
5
|
+
import './types/Func.js';
|
|
6
|
+
|
|
7
|
+
declare const useCachedFetch: <TInfo extends RouteWithLoaderModule>(path: TInfo["route"], ...args: TInfo["route"] extends "undefined" ? HrefArgs<"/"> : HrefArgs<TInfo["route"]>) => {
|
|
8
|
+
data: ReturnType<typeof useLoaderData<TInfo["loader"]>> | undefined;
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
error: Error | undefined;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { useCachedFetch };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"useCachedFetch.js"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { RegisterPages } from './types/RegisterPages.js';
|
|
3
|
+
import { RouteWithActionModule, ActionResult } from './types/RouteWithActionModule.js';
|
|
4
|
+
import { Operation, FormDataSubmittedData, SubmitJsonOptions, SubmitJsonResult, SubmitFormDataOptions } from './ConcurrentSubmitterProvider.js';
|
|
5
|
+
import 'react-router';
|
|
6
|
+
import './types/Func.js';
|
|
7
|
+
import 'react/jsx-runtime';
|
|
8
|
+
import 'react';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @fileoverview Typed hook for concurrent form submissions. Use within ConcurrentSubmitterProvider.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type RouteParams<R extends keyof RegisterPages> = RegisterPages[R]["params"];
|
|
15
|
+
/**
|
|
16
|
+
* True when the route has no dynamic segments (`params: {}` from React Router typegen).
|
|
17
|
+
* Uses `keyof … extends never` so real empty params stay distinct from the `RegisterPages`
|
|
18
|
+
* fallback (`AnyPages` → `params: Record<string, …>` has `keyof` = `string`, not `never`).
|
|
19
|
+
* That avoids mis-resolving `submitJson(path, data)` as the route-args overload (strings only).
|
|
20
|
+
*/
|
|
21
|
+
type HasNoParams<R extends keyof RegisterPages> = [
|
|
22
|
+
keyof RegisterPages[R]["params"]
|
|
23
|
+
] extends [never] ? true : false;
|
|
24
|
+
type HasOptionalParams<R extends keyof RegisterPages> = HasNoParams<R> extends true ? false : Partial<RouteParams<R>> extends RouteParams<R> ? true : false;
|
|
25
|
+
type SubmitJsonFn<TInfo extends RouteWithActionModule> = HasNoParams<TInfo["route"]> extends true ? (path: TInfo["route"], data: z.infer<TInfo["formSchema"]>, options?: SubmitJsonOptions) => SubmitJsonResult<ActionResult<TInfo>> : HasOptionalParams<TInfo["route"]> extends true ? (path: TInfo["route"], args: RouteParams<TInfo["route"]> | undefined, data: z.infer<TInfo["formSchema"]>, options?: SubmitJsonOptions) => SubmitJsonResult<ActionResult<TInfo>> : (path: TInfo["route"], args: RouteParams<TInfo["route"]>, data: z.infer<TInfo["formSchema"]>, options?: SubmitJsonOptions) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
26
|
+
type SubmitFormDataFn<TInfo extends RouteWithActionModule> = HasNoParams<TInfo["route"]> extends true ? (path: TInfo["route"], formData: FormData, submittedData?: FormDataSubmittedData, options?: SubmitFormDataOptions) => SubmitJsonResult<ActionResult<TInfo>> : HasOptionalParams<TInfo["route"]> extends true ? (path: TInfo["route"], args: RouteParams<TInfo["route"]> | undefined, formData: FormData, submittedData?: FormDataSubmittedData, options?: SubmitFormDataOptions) => SubmitJsonResult<ActionResult<TInfo>> : (path: TInfo["route"], args: RouteParams<TInfo["route"]>, formData: FormData, submittedData?: FormDataSubmittedData, options?: SubmitFormDataOptions) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
27
|
+
type UseConcurrentSubmitterReturn<TInfo extends RouteWithActionModule> = {
|
|
28
|
+
operations: Record<string, Operation<ActionResult<TInfo>, z.infer<TInfo["formSchema"]> | FormDataSubmittedData>>;
|
|
29
|
+
submitJson: SubmitJsonFn<TInfo>;
|
|
30
|
+
submitFormData: SubmitFormDataFn<TInfo>;
|
|
31
|
+
};
|
|
32
|
+
declare function useConcurrentSubmitter<TInfo extends RouteWithActionModule>(): UseConcurrentSubmitterReturn<TInfo>;
|
|
33
|
+
|
|
34
|
+
export { ActionResult, type UseConcurrentSubmitterReturn, useConcurrentSubmitter };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"useConcurrentSubmitter.js"}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { useFetcher } from 'react-router';
|
|
2
|
+
import { HrefArgs } from './types/HrefArgs.js';
|
|
3
|
+
import { RouteWithLoaderModule } from './types/RouteWithLoaderModule.js';
|
|
4
|
+
import './types/RegisterPages.js';
|
|
5
|
+
import './types/Func.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @fileoverview Type-safe dynamic data fetching hook for React Router 7
|
|
9
|
+
*
|
|
10
|
+
* This module provides a hook that creates a type-safe fetcher for loading data
|
|
11
|
+
* from dynamic routes with full TypeScript inference for the loader response and route params.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ### Route Setup (`app/routes/api.users.$userId.ts`)
|
|
15
|
+
*
|
|
16
|
+
* First, set up your route with the required exports:
|
|
17
|
+
*
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import type { RoutePath } from "@firtoz/router-toolkit";
|
|
20
|
+
*
|
|
21
|
+
* // Export the route path for type inference
|
|
22
|
+
* export const route: RoutePath<"/api/users/:userId"> = "/api/users/:userId";
|
|
23
|
+
*
|
|
24
|
+
* // Define the loader with a typed return value
|
|
25
|
+
* export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|
26
|
+
* const user = await db.users.findUnique({ where: { id: params.userId } });
|
|
27
|
+
* return {
|
|
28
|
+
* user: {
|
|
29
|
+
* id: user.id,
|
|
30
|
+
* email: user.email,
|
|
31
|
+
* displayName: user.displayName,
|
|
32
|
+
* createdAt: user.createdAt.toISOString(),
|
|
33
|
+
* },
|
|
34
|
+
* };
|
|
35
|
+
* };
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ### Using the hook in a component
|
|
40
|
+
*
|
|
41
|
+
* ```tsx
|
|
42
|
+
* import { useDynamicFetcher } from "@firtoz/router-toolkit";
|
|
43
|
+
* import { useEffect } from "react";
|
|
44
|
+
*
|
|
45
|
+
* function UserProfile({ userId }: { userId: string }) {
|
|
46
|
+
* // Type-safe fetcher with full inference
|
|
47
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.users.$userId")>(
|
|
48
|
+
* "/api/users/:userId",
|
|
49
|
+
* { userId }
|
|
50
|
+
* );
|
|
51
|
+
*
|
|
52
|
+
* // Load data on mount
|
|
53
|
+
* useEffect(() => {
|
|
54
|
+
* fetcher.load();
|
|
55
|
+
* }, [fetcher.load]);
|
|
56
|
+
*
|
|
57
|
+
* // fetcher.data is fully typed: { user: { id, email, displayName, createdAt } } | undefined
|
|
58
|
+
* if (fetcher.state === "loading") {
|
|
59
|
+
* return <div>Loading...</div>;
|
|
60
|
+
* }
|
|
61
|
+
*
|
|
62
|
+
* if (!fetcher.data) {
|
|
63
|
+
* return <div>No user found</div>;
|
|
64
|
+
* }
|
|
65
|
+
*
|
|
66
|
+
* return (
|
|
67
|
+
* <div>
|
|
68
|
+
* <h1>{fetcher.data.user.displayName}</h1>
|
|
69
|
+
* <p>{fetcher.data.user.email}</p>
|
|
70
|
+
* </div>
|
|
71
|
+
* );
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ### Loading with query parameters
|
|
77
|
+
*
|
|
78
|
+
* ```tsx
|
|
79
|
+
* function SearchResults() {
|
|
80
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.search")>("/api/search");
|
|
81
|
+
*
|
|
82
|
+
* const handleSearch = (query: string, page: number) => {
|
|
83
|
+
* // Pass query params to the load function
|
|
84
|
+
* fetcher.load({ q: query, page: String(page) });
|
|
85
|
+
* };
|
|
86
|
+
*
|
|
87
|
+
* return (
|
|
88
|
+
* <div>
|
|
89
|
+
* <input onChange={(e) => handleSearch(e.target.value, 1)} />
|
|
90
|
+
* {fetcher.data?.results.map((result) => (
|
|
91
|
+
* <div key={result.id}>{result.title}</div>
|
|
92
|
+
* ))}
|
|
93
|
+
* </div>
|
|
94
|
+
* );
|
|
95
|
+
* }
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ### Combining with useDynamicSubmitter for full CRUD
|
|
100
|
+
*
|
|
101
|
+
* You can use `useDynamicFetcher` alongside `useDynamicSubmitter` to create
|
|
102
|
+
* complete CRUD interfaces with type safety:
|
|
103
|
+
*
|
|
104
|
+
* ```tsx
|
|
105
|
+
* import { useDynamicFetcher, useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
106
|
+
*
|
|
107
|
+
* function PostEditor({ postId }: { postId: string }) {
|
|
108
|
+
* // Fetch post data
|
|
109
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.posts.$postId")>(
|
|
110
|
+
* "/api/posts/:postId",
|
|
111
|
+
* { postId }
|
|
112
|
+
* );
|
|
113
|
+
*
|
|
114
|
+
* // Submit updates
|
|
115
|
+
* const submitter = useDynamicSubmitter<typeof import("./api.posts.$postId")>(
|
|
116
|
+
* "/api/posts/:postId",
|
|
117
|
+
* { postId }
|
|
118
|
+
* );
|
|
119
|
+
*
|
|
120
|
+
* useEffect(() => {
|
|
121
|
+
* fetcher.load();
|
|
122
|
+
* }, [fetcher.load]);
|
|
123
|
+
*
|
|
124
|
+
* const handleSave = async (title: string, content: string) => {
|
|
125
|
+
* await submitter.submitJson({ title, content }, { method: "PUT" });
|
|
126
|
+
* // Reload after save
|
|
127
|
+
* fetcher.load();
|
|
128
|
+
* };
|
|
129
|
+
*
|
|
130
|
+
* if (!fetcher.data) return <div>Loading...</div>;
|
|
131
|
+
*
|
|
132
|
+
* return (
|
|
133
|
+
* <form onSubmit={(e) => {
|
|
134
|
+
* e.preventDefault();
|
|
135
|
+
* const form = new FormData(e.currentTarget);
|
|
136
|
+
* handleSave(form.get("title") as string, form.get("content") as string);
|
|
137
|
+
* }}>
|
|
138
|
+
* <input name="title" defaultValue={fetcher.data.post.title} />
|
|
139
|
+
* <textarea name="content" defaultValue={fetcher.data.post.content} />
|
|
140
|
+
* <button disabled={submitter.state !== "idle"}>
|
|
141
|
+
* {submitter.state === "submitting" ? "Saving..." : "Save"}
|
|
142
|
+
* </button>
|
|
143
|
+
* </form>
|
|
144
|
+
* );
|
|
145
|
+
* }
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Creates a type-safe fetcher for loading data from dynamic routes.
|
|
151
|
+
*
|
|
152
|
+
* This hook provides full TypeScript inference for:
|
|
153
|
+
* - Route parameters (from the route path)
|
|
154
|
+
* - Loader response type (from the route's loader export)
|
|
155
|
+
*
|
|
156
|
+
* @template TInfo - The route module type (use `typeof import("./route-file")`)
|
|
157
|
+
*
|
|
158
|
+
* @param path - The route path (must match the route's `route` export)
|
|
159
|
+
* @param args - Route parameters (if the route has dynamic segments like `:id`)
|
|
160
|
+
*
|
|
161
|
+
* @returns An extended fetcher object with:
|
|
162
|
+
* - `load` - Function to load data, optionally with query parameters
|
|
163
|
+
* - `data` - Response data from the loader (typed)
|
|
164
|
+
* - `state` - Fetcher state ("idle" | "loading" | "submitting")
|
|
165
|
+
* - All other useFetcher properties (except `submit`)
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ### Basic usage
|
|
169
|
+
*
|
|
170
|
+
* ```typescript
|
|
171
|
+
* // In your route file (app/routes/api.products.$productId.ts):
|
|
172
|
+
* export const route: RoutePath<"/api/products/:productId"> = "/api/products/:productId";
|
|
173
|
+
* export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|
174
|
+
* return { product: await getProduct(params.productId) };
|
|
175
|
+
* };
|
|
176
|
+
*
|
|
177
|
+
* // In your component:
|
|
178
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.products.$productId")>(
|
|
179
|
+
* "/api/products/:productId",
|
|
180
|
+
* { productId: "abc123" }
|
|
181
|
+
* );
|
|
182
|
+
*
|
|
183
|
+
* useEffect(() => {
|
|
184
|
+
* fetcher.load();
|
|
185
|
+
* }, [fetcher.load]);
|
|
186
|
+
*
|
|
187
|
+
* // fetcher.data is typed as { product: Product } | undefined
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ### With query parameters
|
|
192
|
+
*
|
|
193
|
+
* ```typescript
|
|
194
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.search")>("/api/search");
|
|
195
|
+
*
|
|
196
|
+
* // Load with query params: /api/search?q=hello&limit=10
|
|
197
|
+
* fetcher.load({ q: "hello", limit: "10" });
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
declare const useDynamicFetcher: <TInfo extends RouteWithLoaderModule>(path: TInfo["route"], ...args: TInfo["route"] extends "undefined" ? HrefArgs<"/"> : HrefArgs<TInfo["route"]>) => Omit<ReturnType<typeof useFetcher<TInfo["loader"]>>, "load" | "submit"> & {
|
|
201
|
+
/**
|
|
202
|
+
* Load data from the route's loader.
|
|
203
|
+
*
|
|
204
|
+
* @param queryParams - Optional query parameters to append to the URL
|
|
205
|
+
* @returns A promise that resolves when the load is complete
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```typescript
|
|
209
|
+
* // Load without query params
|
|
210
|
+
* fetcher.load();
|
|
211
|
+
*
|
|
212
|
+
* // Load with query params
|
|
213
|
+
* fetcher.load({ page: "2", sort: "name" });
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
load: (queryParams?: Record<string, string>) => Promise<void>;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export { useDynamicFetcher };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"useDynamicFetcher.js"}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useFetcher, SubmitTarget, SubmitOptions, FetcherFormProps } from 'react-router';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { HrefArgs } from './types/HrefArgs.js';
|
|
5
|
+
import { RouteWithActionModule } from './types/RouteWithActionModule.js';
|
|
6
|
+
import './types/RegisterPages.js';
|
|
7
|
+
import './types/Func.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @fileoverview Type-safe dynamic form submission hook for React Router 7
|
|
11
|
+
*
|
|
12
|
+
* This module provides a hook that creates a type-safe fetcher for submitting forms
|
|
13
|
+
* to dynamic routes with full TypeScript inference for the form schema and route params.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ### Route Setup (`app/routes/admin.posts.$id.tsx`)
|
|
17
|
+
*
|
|
18
|
+
* First, set up your route with the required exports:
|
|
19
|
+
*
|
|
20
|
+
* ```typescript
|
|
21
|
+
* import { z } from "zod";
|
|
22
|
+
* import { formAction, type RoutePath } from "@firtoz/router-toolkit";
|
|
23
|
+
* import { success, fail } from "@firtoz/maybe-error";
|
|
24
|
+
*
|
|
25
|
+
* // Export the route path for type inference
|
|
26
|
+
* export const route: RoutePath<"/admin/posts/:id"> = "/admin/posts/:id";
|
|
27
|
+
*
|
|
28
|
+
* // Define the form schema
|
|
29
|
+
* export const formSchema = z.object({
|
|
30
|
+
* title: z.string().min(1, "Title is required"),
|
|
31
|
+
* content: z.string().min(10, "Content must be at least 10 characters"),
|
|
32
|
+
* published: z.boolean().optional().default(false),
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // Create the action using formAction
|
|
36
|
+
* export const action = formAction({
|
|
37
|
+
* schema: formSchema,
|
|
38
|
+
* handler: async ({ request, params }, formData) => {
|
|
39
|
+
* const postId = params.id;
|
|
40
|
+
* const updated = await db.posts.update({
|
|
41
|
+
* where: { id: postId },
|
|
42
|
+
* data: formData,
|
|
43
|
+
* });
|
|
44
|
+
* return success(updated);
|
|
45
|
+
* },
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ### Using the hook in a component
|
|
51
|
+
*
|
|
52
|
+
* ```tsx
|
|
53
|
+
* import { useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
54
|
+
*
|
|
55
|
+
* function EditPostForm({ postId }: { postId: string }) {
|
|
56
|
+
* // Type-safe submitter with full inference
|
|
57
|
+
* const submitter = useDynamicSubmitter<typeof import("./admin.posts.$id")>(
|
|
58
|
+
* "/admin/posts/:id",
|
|
59
|
+
* { id: postId }
|
|
60
|
+
* );
|
|
61
|
+
*
|
|
62
|
+
* // submitter.data is the typed response from the action
|
|
63
|
+
* // submitter.state is "idle" | "loading" | "submitting"
|
|
64
|
+
*
|
|
65
|
+
* // Option 1: Submit as JSON (recommended for programmatic submissions)
|
|
66
|
+
* // Defaults to POST if no options provided
|
|
67
|
+
* const handleSubmitJson = async () => {
|
|
68
|
+
* await submitter.submitJson({
|
|
69
|
+
* title: "My Post",
|
|
70
|
+
* content: "Post content here",
|
|
71
|
+
* published: true,
|
|
72
|
+
* });
|
|
73
|
+
* };
|
|
74
|
+
*
|
|
75
|
+
* // Option 2: Submit with FormData or SubmitTarget
|
|
76
|
+
* const handleSubmit = async (formData: FormData) => {
|
|
77
|
+
* await submitter.submit(formData, { method: "POST" });
|
|
78
|
+
* };
|
|
79
|
+
*
|
|
80
|
+
* // Option 3: Use the Form component (defaults to POST)
|
|
81
|
+
* return (
|
|
82
|
+
* <submitter.Form>
|
|
83
|
+
* <input name="title" />
|
|
84
|
+
* <textarea name="content" />
|
|
85
|
+
* <button type="submit">Save</button>
|
|
86
|
+
* </submitter.Form>
|
|
87
|
+
* );
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ### Handling responses
|
|
93
|
+
*
|
|
94
|
+
* ```tsx
|
|
95
|
+
* function LoginForm() {
|
|
96
|
+
* const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
|
|
97
|
+
*
|
|
98
|
+
* useEffect(() => {
|
|
99
|
+
* if (submitter.data?.success) {
|
|
100
|
+
* // Handle success
|
|
101
|
+
* console.log("Logged in as:", submitter.data.value.user.email);
|
|
102
|
+
* } else if (submitter.data && !submitter.data.success) {
|
|
103
|
+
* // Handle error
|
|
104
|
+
* if (submitter.data.error.type === "validation") {
|
|
105
|
+
* console.log("Validation errors:", submitter.data.error.error);
|
|
106
|
+
* }
|
|
107
|
+
* }
|
|
108
|
+
* }, [submitter.data]);
|
|
109
|
+
*
|
|
110
|
+
* return (
|
|
111
|
+
* <submitter.Form>
|
|
112
|
+
* <input name="email" type="email" />
|
|
113
|
+
* <input name="password" type="password" />
|
|
114
|
+
* <button disabled={submitter.state !== "idle"}>
|
|
115
|
+
* {submitter.state === "submitting" ? "Logging in..." : "Login"}
|
|
116
|
+
* </button>
|
|
117
|
+
* </submitter.Form>
|
|
118
|
+
* );
|
|
119
|
+
* }
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Function type for submitting form data with a SubmitTarget.
|
|
125
|
+
*
|
|
126
|
+
* Accepts the form schema data combined with SubmitTarget (FormData, HTMLFormElement, etc.)
|
|
127
|
+
* Use this when you have a FormData object or form element reference.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```typescript
|
|
131
|
+
* // With FormData
|
|
132
|
+
* submitter.submit(formData, { method: "POST" });
|
|
133
|
+
*
|
|
134
|
+
* // With form element reference
|
|
135
|
+
* submitter.submit(formRef.current, { method: "POST" });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
type SubmitFunc<TModule extends RouteWithActionModule> = (target: z.infer<TModule["formSchema"]> & SubmitTarget, options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
|
|
139
|
+
method: Exclude<SubmitOptions["method"], "GET">;
|
|
140
|
+
}) => Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Options for submitJson function.
|
|
143
|
+
* Method defaults to "POST" if not specified.
|
|
144
|
+
*/
|
|
145
|
+
type SubmitJsonOptions = Omit<SubmitOptions, "action" | "method" | "encType"> & {
|
|
146
|
+
method?: Exclude<SubmitOptions["method"], "GET">;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Function type for submitting form data as JSON.
|
|
150
|
+
*
|
|
151
|
+
* Accepts only the inferred form schema type (plain object).
|
|
152
|
+
* Automatically serializes the data as JSON. This is the recommended
|
|
153
|
+
* approach for programmatic form submissions.
|
|
154
|
+
*
|
|
155
|
+
* Options are optional and default to `{ method: "POST" }`.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```typescript
|
|
159
|
+
* // Submit a plain object - fully type-safe (defaults to POST)
|
|
160
|
+
* await submitter.submitJson({
|
|
161
|
+
* email: "user@example.com",
|
|
162
|
+
* password: "secret123",
|
|
163
|
+
* rememberMe: true,
|
|
164
|
+
* });
|
|
165
|
+
*
|
|
166
|
+
* // Or specify a different method
|
|
167
|
+
* await submitter.submitJson(data, { method: "PUT" });
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
type SubmitJsonFunc<TModule extends RouteWithActionModule> = (data: z.infer<TModule["formSchema"]>, options?: SubmitJsonOptions) => Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Form component type with pre-bound action URL.
|
|
173
|
+
*
|
|
174
|
+
* Renders a form element that automatically submits to the correct route.
|
|
175
|
+
* Method defaults to "POST" if not specified.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```typescript
|
|
179
|
+
* // Defaults to POST
|
|
180
|
+
* <submitter.Form>
|
|
181
|
+
* <input name="title" />
|
|
182
|
+
* <button type="submit">Submit</button>
|
|
183
|
+
* </submitter.Form>
|
|
184
|
+
*
|
|
185
|
+
* // Or specify a different method
|
|
186
|
+
* <submitter.Form method="PUT">
|
|
187
|
+
* ...
|
|
188
|
+
* </submitter.Form>
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
type SubmitForm = (props: Omit<FetcherFormProps & React.RefAttributes<HTMLFormElement>, "action" | "method"> & {
|
|
192
|
+
method?: Exclude<SubmitOptions["method"], "GET">;
|
|
193
|
+
}) => React.ReactElement;
|
|
194
|
+
/**
|
|
195
|
+
* Creates a type-safe fetcher for submitting forms to dynamic routes.
|
|
196
|
+
*
|
|
197
|
+
* This hook provides full TypeScript inference for:
|
|
198
|
+
* - Route parameters (from the route path)
|
|
199
|
+
* - Form data schema (from the route's formSchema export)
|
|
200
|
+
* - Action response type (from the route's action export)
|
|
201
|
+
*
|
|
202
|
+
* @template TInfo - The route module type (use `typeof import("./route-file")`)
|
|
203
|
+
*
|
|
204
|
+
* @param path - The route path (must match the route's `route` export)
|
|
205
|
+
* @param args - Route parameters (if the route has dynamic segments like `:id`)
|
|
206
|
+
*
|
|
207
|
+
* @returns An extended fetcher object with:
|
|
208
|
+
* - `submit` - Submit with FormData/SubmitTarget (includes schema type)
|
|
209
|
+
* - `submitJson` - Submit a plain object as JSON (schema type only)
|
|
210
|
+
* - `Form` - Pre-bound form component
|
|
211
|
+
* - `data` - Response data from the action (typed)
|
|
212
|
+
* - `state` - Fetcher state ("idle" | "loading" | "submitting")
|
|
213
|
+
* - All other useFetcher properties
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ### Basic usage with route parameters
|
|
217
|
+
*
|
|
218
|
+
* ```typescript
|
|
219
|
+
* // In your route file (app/routes/users.$userId.settings.tsx):
|
|
220
|
+
* export const route: RoutePath<"/users/:userId/settings"> = "/users/:userId/settings";
|
|
221
|
+
* export const formSchema = z.object({
|
|
222
|
+
* displayName: z.string().min(2),
|
|
223
|
+
* email: z.string().email(),
|
|
224
|
+
* notifications: z.boolean().default(true),
|
|
225
|
+
* });
|
|
226
|
+
* export const action = formAction({ schema: formSchema, handler: ... });
|
|
227
|
+
*
|
|
228
|
+
* // In your component:
|
|
229
|
+
* const submitter = useDynamicSubmitter<typeof import("./users.$userId.settings")>(
|
|
230
|
+
* "/users/:userId/settings",
|
|
231
|
+
* { userId: "123" }
|
|
232
|
+
* );
|
|
233
|
+
*
|
|
234
|
+
* // Submit using submitJson (type-safe, no FormData needed, defaults to POST)
|
|
235
|
+
* await submitter.submitJson({
|
|
236
|
+
* displayName: "John Doe",
|
|
237
|
+
* email: "john@example.com",
|
|
238
|
+
* notifications: true,
|
|
239
|
+
* });
|
|
240
|
+
*
|
|
241
|
+
* // Check the response
|
|
242
|
+
* if (submitter.data?.success) {
|
|
243
|
+
* console.log("Settings updated!");
|
|
244
|
+
* }
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
declare const useDynamicSubmitter: <TInfo extends RouteWithActionModule>(path: TInfo["route"], ...args: TInfo["route"] extends "undefined" ? HrefArgs<"/"> : HrefArgs<TInfo["route"]>) => Omit<ReturnType<typeof useFetcher<TInfo["action"]>>, "load" | "submit" | "Form"> & {
|
|
248
|
+
/** Submit with FormData or SubmitTarget (schema type & SubmitTarget) */
|
|
249
|
+
submit: SubmitFunc<TInfo>;
|
|
250
|
+
/** Submit a plain object as JSON (schema type only, defaults to POST) */
|
|
251
|
+
submitJson: SubmitJsonFunc<TInfo>;
|
|
252
|
+
/** Pre-bound Form component with action URL already set (defaults to POST) */
|
|
253
|
+
Form: SubmitForm;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export { useDynamicSubmitter };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"useDynamicSubmitter.js"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useFetcher } from 'react-router';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A hook that tracks changes in a fetcher's state and calls a callback when it changes.
|
|
5
|
+
* @param fetcher The fetcher instance to track
|
|
6
|
+
* @param onChange Callback that receives the previous state and new state when the state changes
|
|
7
|
+
*/
|
|
8
|
+
declare const useFetcherStateChanged: (fetcher: Pick<ReturnType<typeof useFetcher>, "state">, onChange: (lastState: typeof fetcher.state | undefined, newState: typeof fetcher.state) => void) => void;
|
|
9
|
+
|
|
10
|
+
export { useFetcherStateChanged };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"useFetcherStateChanged.js"}
|