inertiajs-use-api 0.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/AGENTS.md +63 -0
- package/README.md +266 -0
- package/SKILL.md +240 -0
- package/dist/configure.d.ts +36 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +22 -0
- package/dist/configure.js.map +1 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/use-api.d.ts +3 -0
- package/dist/use-api.d.ts.map +1 -0
- package/dist/use-api.js +176 -0
- package/dist/use-api.js.map +1 -0
- package/package.json +62 -0
- package/src/configure.ts +57 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +16 -0
- package/src/types.ts +67 -0
- package/src/use-api.ts +203 -0
package/src/use-api.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { router } from "@inertiajs/core";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { getUseApiConfig, readXsrfToken } from "./configure.js";
|
|
5
|
+
import { ApiError } from "./errors.js";
|
|
6
|
+
import type { FieldErrors, Method, QueryParams, SubmitOptions, UseApi } from "./types.js";
|
|
7
|
+
|
|
8
|
+
function appendQuery(url: string, params?: QueryParams): string {
|
|
9
|
+
if (!params) return url;
|
|
10
|
+
const usp = new URLSearchParams();
|
|
11
|
+
for (const [key, value] of Object.entries(params)) {
|
|
12
|
+
if (value === null || value === undefined) continue;
|
|
13
|
+
usp.append(key, String(value));
|
|
14
|
+
}
|
|
15
|
+
const qs = usp.toString();
|
|
16
|
+
if (!qs) return url;
|
|
17
|
+
return url + (url.includes("?") ? "&" : "?") + qs;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveUrl(url: string, baseUrl?: string): string {
|
|
21
|
+
if (!baseUrl) return url;
|
|
22
|
+
if (/^https?:\/\//i.test(url)) return url;
|
|
23
|
+
return `${baseUrl.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
27
|
+
const lower = name.toLowerCase();
|
|
28
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === lower);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useApi<TForm extends object = Record<string, unknown>, TResponse = unknown>(
|
|
32
|
+
initialData: TForm = {} as TForm,
|
|
33
|
+
): UseApi<TForm, TResponse> {
|
|
34
|
+
const initialRef = useRef(initialData);
|
|
35
|
+
const [data, setDataState] = useState<TForm>(initialData);
|
|
36
|
+
const [errors, setErrors] = useState<FieldErrors<TForm>>({});
|
|
37
|
+
const [processing, setProcessing] = useState(false);
|
|
38
|
+
const [response, setResponse] = useState<TResponse | null>(null);
|
|
39
|
+
const [wasSuccessful, setWasSuccessful] = useState(false);
|
|
40
|
+
const [status, setStatus] = useState<number | null>(null);
|
|
41
|
+
const inFlightRef = useRef<Set<AbortController>>(new Set());
|
|
42
|
+
|
|
43
|
+
const setData = useCallback<UseApi<TForm, TResponse>["setData"]>((field, value) => {
|
|
44
|
+
setDataState((prev) =>
|
|
45
|
+
typeof field === "object" ? { ...prev, ...(field as Partial<TForm>) } : { ...prev, [field]: value },
|
|
46
|
+
);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const reset = useCallback(() => {
|
|
50
|
+
setDataState(initialRef.current);
|
|
51
|
+
setErrors({});
|
|
52
|
+
setResponse(null);
|
|
53
|
+
setWasSuccessful(false);
|
|
54
|
+
setStatus(null);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const clearErrors = useCallback(() => setErrors({}), []);
|
|
58
|
+
|
|
59
|
+
const cancel = useCallback(() => {
|
|
60
|
+
for (const controller of inFlightRef.current) {
|
|
61
|
+
controller.abort();
|
|
62
|
+
}
|
|
63
|
+
inFlightRef.current.clear();
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const submit = useCallback<UseApi<TForm, TResponse>["submit"]>(
|
|
67
|
+
async (method, url, options = {}) => {
|
|
68
|
+
const config = getUseApiConfig();
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
inFlightRef.current.add(controller);
|
|
71
|
+
|
|
72
|
+
if (options.signal) {
|
|
73
|
+
if (options.signal.aborted) {
|
|
74
|
+
controller.abort();
|
|
75
|
+
} else {
|
|
76
|
+
options.signal.addEventListener("abort", () => controller.abort(), {
|
|
77
|
+
once: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
options.onBefore?.();
|
|
83
|
+
setProcessing(true);
|
|
84
|
+
setErrors({});
|
|
85
|
+
setWasSuccessful(false);
|
|
86
|
+
setStatus(null);
|
|
87
|
+
|
|
88
|
+
const fullUrl = appendQuery(resolveUrl(url, config.baseUrl), options.params);
|
|
89
|
+
const xsrf = readXsrfToken();
|
|
90
|
+
const xsrfHeaderName = config.xsrfHeaderName ?? "X-XSRF-TOKEN";
|
|
91
|
+
|
|
92
|
+
const headers: Record<string, string> = {
|
|
93
|
+
Accept: "application/json",
|
|
94
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
95
|
+
...(config.defaultHeaders ?? {}),
|
|
96
|
+
...(xsrf ? { [xsrfHeaderName]: xsrf } : {}),
|
|
97
|
+
...options.headers,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const body = options.data === undefined ? data : { ...data, ...(options.data as Partial<TForm>) };
|
|
101
|
+
const hasBody = method !== "get" && body !== undefined && Object.keys(body).length > 0;
|
|
102
|
+
if (hasBody && !hasHeader(headers, "content-type")) {
|
|
103
|
+
headers["Content-Type"] = "application/json";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let res: Response;
|
|
107
|
+
try {
|
|
108
|
+
try {
|
|
109
|
+
res = await fetch(fullUrl, {
|
|
110
|
+
method: method.toUpperCase(),
|
|
111
|
+
credentials: "include",
|
|
112
|
+
headers,
|
|
113
|
+
body: hasBody ? JSON.stringify(body) : undefined,
|
|
114
|
+
signal: controller.signal,
|
|
115
|
+
});
|
|
116
|
+
} catch (networkErr) {
|
|
117
|
+
if ((networkErr as Error).name === "AbortError") throw networkErr;
|
|
118
|
+
if (options.errorToast !== false && config.onErrorToast) {
|
|
119
|
+
config.onErrorToast(options.errorToast ?? "Network error. Please try again.");
|
|
120
|
+
}
|
|
121
|
+
throw networkErr;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
125
|
+
const json: unknown = contentType.includes("application/json") ? await res.json() : null;
|
|
126
|
+
|
|
127
|
+
setStatus(res.status);
|
|
128
|
+
config.onResponse?.(json, res.status, res.ok);
|
|
129
|
+
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const flat = (config.parseErrors?.(json, res.status) ?? {}) as FieldErrors<TForm>;
|
|
132
|
+
setErrors(flat);
|
|
133
|
+
|
|
134
|
+
const message = config.parseMessage?.(json, res.status) ?? `Request failed (${res.status})`;
|
|
135
|
+
|
|
136
|
+
if (options.errorToast !== false && config.onErrorToast) {
|
|
137
|
+
config.onErrorToast(options.errorToast ?? message);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
options.onError?.(flat, json, res.status);
|
|
141
|
+
throw new ApiError(res.status, message, json);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const typedResponse = json as TResponse;
|
|
145
|
+
setResponse(typedResponse);
|
|
146
|
+
setWasSuccessful(true);
|
|
147
|
+
|
|
148
|
+
if (options.intoProp) {
|
|
149
|
+
if (typeof options.intoProp === "string") {
|
|
150
|
+
router.replaceProp(options.intoProp, () => typedResponse as unknown);
|
|
151
|
+
} else {
|
|
152
|
+
const partial = options.intoProp(typedResponse);
|
|
153
|
+
for (const [name, value] of Object.entries(partial)) {
|
|
154
|
+
router.replaceProp(name, () => value);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (options.reloadProps) {
|
|
160
|
+
const only = Array.isArray(options.reloadProps) ? options.reloadProps : [options.reloadProps];
|
|
161
|
+
router.reload({ ...(options.reloadOptions ?? {}), only });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (options.successToast !== undefined && config.onSuccessToast) {
|
|
165
|
+
config.onSuccessToast(options.successToast);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
options.onSuccess?.(typedResponse);
|
|
169
|
+
return typedResponse;
|
|
170
|
+
} finally {
|
|
171
|
+
inFlightRef.current.delete(controller);
|
|
172
|
+
if (inFlightRef.current.size === 0) {
|
|
173
|
+
setProcessing(false);
|
|
174
|
+
}
|
|
175
|
+
options.onFinish?.();
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
[data],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const verb = (method: Method) => (url: string, options?: SubmitOptions<TResponse, TForm>) =>
|
|
182
|
+
submit(method, url, options);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
data,
|
|
186
|
+
setData,
|
|
187
|
+
errors,
|
|
188
|
+
hasErrors: Object.keys(errors).length > 0,
|
|
189
|
+
processing,
|
|
190
|
+
response,
|
|
191
|
+
wasSuccessful,
|
|
192
|
+
status,
|
|
193
|
+
reset,
|
|
194
|
+
clearErrors,
|
|
195
|
+
cancel,
|
|
196
|
+
submit,
|
|
197
|
+
get: verb("get"),
|
|
198
|
+
post: verb("post"),
|
|
199
|
+
put: verb("put"),
|
|
200
|
+
patch: verb("patch"),
|
|
201
|
+
delete: verb("delete"),
|
|
202
|
+
};
|
|
203
|
+
}
|