-
-
Notifications
You must be signed in to change notification settings - Fork 30
/
index.ts
224 lines (201 loc) · 7.26 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
import type { FieldErrors, FieldValues, Resolver } from "react-hook-form";
const tryParseJSON = (jsonString: string) => {
try {
const json = JSON.parse(jsonString);
return json;
} catch (e) {
return jsonString;
}
};
/**
* Generates an output object from the given form data, where the keys in the output object retain
* the structure of the keys in the form data. Keys containing integer indexes are treated as arrays.
*/
export const generateFormData = (
formData: FormData | URLSearchParams,
preserveStringified = false,
) => {
// Initialize an empty output object.
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const outputObject: Record<any, any> = {};
// Iterate through each key-value pair in the form data.
for (const [key, value] of formData.entries()) {
// Try to convert data to the original type, otherwise return the original value
const data = preserveStringified ? value : tryParseJSON(value.toString());
// Split the key into an array of parts.
const keyParts = key.split(".");
// Initialize a variable to point to the current object in the output object.
let currentObject = outputObject;
// Iterate through each key part except for the last one.
for (let i = 0; i < keyParts.length - 1; i++) {
// Get the current key part.
const keyPart = keyParts[i];
// If the current object doesn't have a property with the current key part,
// initialize it as an object or array depending on whether the next key part is a valid integer index or not.
if (!currentObject[keyPart]) {
currentObject[keyPart] = /^\d+$/.test(keyParts[i + 1]) ? [] : {};
}
// Move the current object pointer to the next level of the output object.
currentObject = currentObject[keyPart];
}
// Get the last key part.
const lastKeyPart = keyParts[keyParts.length - 1];
const lastKeyPartIsArray = /\[\d*\]$|\[\]$/.test(lastKeyPart);
// Handles array[] or array[0] cases
if (lastKeyPartIsArray) {
const key = lastKeyPart.replace(/\[\d*\]$|\[\]$/, "");
if (!currentObject[key]) {
currentObject[key] = [];
}
currentObject[key].push(data);
}
// Handles array.foo.0 cases
if (!lastKeyPartIsArray) {
// If the last key part is a valid integer index, push the value to the current array.
if (/^\d+$/.test(lastKeyPart)) {
currentObject.push(data);
}
// Otherwise, set a property on the current object with the last key part and the corresponding value.
else {
currentObject[lastKeyPart] = data;
}
}
}
// Return the output object.
return outputObject;
};
export const getFormDataFromSearchParams = <T extends FieldValues>(
request: Pick<Request, "url">,
preserveStringified = false,
): T => {
const searchParams = new URL(request.url).searchParams;
return generateFormData(searchParams, preserveStringified);
};
export const isGet = (request: Pick<Request, "method">) =>
request.method === "GET" || request.method === "get";
type ReturnData<T extends FieldValues> =
| {
data: T;
errors: undefined;
receivedValues: Partial<T>;
}
| {
data: undefined;
errors: FieldErrors<T>;
receivedValues: Partial<T>;
};
/**
* Parses the data from an HTTP request and validates it against a schema. Works in both loaders and actions, in loaders it extracts the data from the search params.
* In actions it extracts it from request formData.
*
* @returns A Promise that resolves to an object containing the validated data or any errors that occurred during validation.
*/
export const getValidatedFormData = async <T extends FieldValues>(
request: Request | FormData,
resolver: Resolver<T>,
preserveStringified = false,
): Promise<ReturnData<T>> => {
const receivedValues =
"url" in request && isGet(request)
? getFormDataFromSearchParams<T>(request, preserveStringified)
: await parseFormData<T>(request, preserveStringified);
const data = await validateFormData<T>(receivedValues, resolver);
return { ...data, receivedValues };
};
/**
* Helper method used in actions to validate the form data parsed from the frontend using zod and return a json error if validation fails.
* @param data Data to validate
* @param resolver Schema to validate and cast the data with
* @returns Returns the validated data if successful, otherwise returns the error object
*/
export const validateFormData = async <T extends FieldValues>(
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
data: any,
resolver: Resolver<T>,
) => {
const dataToValidate =
data instanceof FormData ? Object.fromEntries(data) : data;
const { errors, values } = await resolver(
dataToValidate,
{},
{ shouldUseNativeValidation: false, fields: {} },
);
if (Object.keys(errors).length > 0) {
return { errors: errors as FieldErrors<T>, data: undefined };
}
return { errors: undefined, data: values as T };
};
/**
Creates a new instance of FormData with the specified data and key.
@template T - The type of the data parameter. It can be any type of FieldValues.
@param {T} data - The data to be added to the FormData. It can be either an object of type FieldValues.
@param {boolean} stringifyAll - Should the form data be stringified or not (default: true) eg: {a: '"string"', b: "1"} vs {a: "string", b: "1"}
@returns {FormData} - The FormData object with the data added to it.
*/
export const createFormData = <T extends FieldValues>(
data: T,
stringifyAll = true,
): FormData => {
const formData = new FormData();
if (!data) {
return formData;
}
for (const [key, value] of Object.entries(data)) {
// Skip undefined values
if (value === undefined) {
continue;
}
// Handle FileList
if (typeof FileList !== "undefined" && value instanceof FileList) {
for (let i = 0; i < value.length; i++) {
formData.append(key, value[i]);
}
continue;
}
if (
Array.isArray(value) &&
value.length > 0 &&
(value[0] instanceof File || value[0] instanceof Blob)
) {
for (let i = 0; i < value.length; i++) {
formData.append(key, value[i]);
}
continue;
}
if (value instanceof File || value instanceof Blob) {
formData.append(key, value);
continue;
}
// Stringify all values if set
if (stringifyAll) {
formData.append(key, JSON.stringify(value));
continue;
}
// Handle strings
if (typeof value === "string") {
formData.append(key, value);
continue;
}
// Handle dates
if (value instanceof Date) {
formData.append(key, value.toISOString());
continue;
}
// Handle all the other values
formData.append(key, JSON.stringify(value));
}
return formData;
};
/**
Parses the specified Request object's FormData to retrieve the data associated with the specified key.
Or parses the specified FormData to retrieve the data
*/
// biome-ignore lint/complexity/noUselessTypeConstraint: <explanation>
export const parseFormData = async <T extends unknown>(
request: Request | FormData,
preserveStringified = false,
): Promise<T> => {
const formData =
request instanceof Request ? await request.formData() : request;
return generateFormData(formData, preserveStringified);
};