@oomfware/forms 0.1.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/LICENSE +14 -0
- package/README.md +248 -0
- package/dist/index.d.mts +181 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +488 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +52 -0
- package/src/index.ts +4 -0
- package/src/lib/errors.ts +47 -0
- package/src/lib/form-utils.ts +431 -0
- package/src/lib/form.ts +469 -0
- package/src/lib/middleware.ts +99 -0
- package/src/lib/types.ts +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* error thrown when form validation fails imperatively
|
|
5
|
+
*/
|
|
6
|
+
export class ValidationError extends Error {
|
|
7
|
+
issues: StandardSchemaV1.Issue[];
|
|
8
|
+
|
|
9
|
+
constructor(issues: StandardSchemaV1.Issue[]) {
|
|
10
|
+
super('Validation failed');
|
|
11
|
+
this.name = 'ValidationError';
|
|
12
|
+
this.issues = issues;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* use this to throw a validation error to imperatively fail form validation.
|
|
18
|
+
* can be used in combination with `issue` passed to form actions to create field-specific issues.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { invalid, form } from '@oomfware/forms';
|
|
23
|
+
* import * as v from 'valibot';
|
|
24
|
+
*
|
|
25
|
+
* export const login = form(
|
|
26
|
+
* v.object({ name: v.string(), _password: v.string() }),
|
|
27
|
+
* async ({ name, _password }, issue) => {
|
|
28
|
+
* const success = tryLogin(name, _password);
|
|
29
|
+
* if (!success) {
|
|
30
|
+
* invalid('Incorrect username or password');
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* // ...
|
|
34
|
+
* }
|
|
35
|
+
* );
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function invalid(...issues: (StandardSchemaV1.Issue | string)[]): never {
|
|
39
|
+
throw new ValidationError(issues.map((issue) => (typeof issue === 'string' ? { message: issue } : issue)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* checks whether this is a validation error thrown by {@link invalid}.
|
|
44
|
+
*/
|
|
45
|
+
export function isValidationError(e: unknown): e is ValidationError {
|
|
46
|
+
return e instanceof ValidationError;
|
|
47
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* internal representation of a form validation issue with computed path info
|
|
5
|
+
*/
|
|
6
|
+
export interface InternalFormIssue {
|
|
7
|
+
/** dot/bracket notation path string (e.g., "user.emails[0]") */
|
|
8
|
+
name: string;
|
|
9
|
+
/** path segments as array */
|
|
10
|
+
path: (string | number)[];
|
|
11
|
+
/** error message */
|
|
12
|
+
message: string;
|
|
13
|
+
/** whether this issue came from server validation */
|
|
14
|
+
server: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* sets a value in a nested object using a path string, mutating the original object
|
|
19
|
+
*/
|
|
20
|
+
export function setNestedValue(object: Record<string, unknown>, pathString: string, value: unknown): void {
|
|
21
|
+
if (pathString.startsWith('n:')) {
|
|
22
|
+
pathString = pathString.slice(2);
|
|
23
|
+
value = value === '' ? undefined : parseFloat(value as string);
|
|
24
|
+
} else if (pathString.startsWith('b:')) {
|
|
25
|
+
pathString = pathString.slice(2);
|
|
26
|
+
value = value === 'on';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
deepSet(object, splitPath(pathString), value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* convert `FormData` into a POJO
|
|
34
|
+
*/
|
|
35
|
+
export function convertFormData(data: FormData): Record<string, unknown> {
|
|
36
|
+
const result: Record<string, unknown> = {};
|
|
37
|
+
|
|
38
|
+
for (let key of data.keys()) {
|
|
39
|
+
const isArray = key.endsWith('[]');
|
|
40
|
+
let values: unknown[] = data.getAll(key);
|
|
41
|
+
|
|
42
|
+
if (isArray) {
|
|
43
|
+
key = key.slice(0, -2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (values.length > 1 && !isArray) {
|
|
47
|
+
throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// an empty `<input type="file">` will submit a non-existent file, bizarrely
|
|
51
|
+
values = values.filter(
|
|
52
|
+
(entry) => typeof entry === 'string' || (entry as File).name !== '' || (entry as File).size > 0,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (key.startsWith('n:')) {
|
|
56
|
+
key = key.slice(2);
|
|
57
|
+
values = values.map((v) => (v === '' ? undefined : parseFloat(v as string)));
|
|
58
|
+
} else if (key.startsWith('b:')) {
|
|
59
|
+
key = key.slice(2);
|
|
60
|
+
values = values.map((v) => v === 'on');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setNestedValue(result, key, isArray ? values : values[0]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PATH_REGEX = /^[a-zA-Z_$]\w*(\.[a-zA-Z_$]\w*|\[\d+\])*$/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* splits a path string like "user.emails[0].address" into ["user", "emails", "0", "address"]
|
|
73
|
+
*/
|
|
74
|
+
export function splitPath(path: string): string[] {
|
|
75
|
+
if (!PATH_REGEX.test(path)) {
|
|
76
|
+
throw new Error(`Invalid path ${path}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return path.split(/\.|\[|\]/).filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* check if a property key is dangerous and could lead to prototype pollution
|
|
84
|
+
*/
|
|
85
|
+
function checkPrototypePollution(key: string): void {
|
|
86
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
87
|
+
throw new Error(`Invalid key "${key}": This key is not allowed to prevent prototype pollution.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* sets a value in a nested object using an array of keys, mutating the original object.
|
|
93
|
+
*/
|
|
94
|
+
export function deepSet(object: Record<string, unknown>, keys: string[], value: unknown): void {
|
|
95
|
+
let current: Record<string, unknown> = object;
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < keys.length - 1; i += 1) {
|
|
98
|
+
const key = keys[i]!;
|
|
99
|
+
|
|
100
|
+
checkPrototypePollution(key);
|
|
101
|
+
|
|
102
|
+
const isArray = /^\d+$/.test(keys[i + 1]!);
|
|
103
|
+
const exists = key in current;
|
|
104
|
+
const inner = current[key];
|
|
105
|
+
|
|
106
|
+
if (exists && isArray !== Array.isArray(inner)) {
|
|
107
|
+
throw new Error(`Invalid array key ${keys[i + 1]}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!exists) {
|
|
111
|
+
current[key] = isArray ? [] : {};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
current = current[key] as Record<string, unknown>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const finalKey = keys[keys.length - 1]!;
|
|
118
|
+
checkPrototypePollution(finalKey);
|
|
119
|
+
current[finalKey] = value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* gets a nested value from an object using a path array
|
|
124
|
+
*/
|
|
125
|
+
export function deepGet(object: Record<string, unknown>, path: (string | number)[]): unknown {
|
|
126
|
+
let current: unknown = object;
|
|
127
|
+
for (const key of path) {
|
|
128
|
+
if (current == null || typeof current !== 'object') {
|
|
129
|
+
return current;
|
|
130
|
+
}
|
|
131
|
+
current = (current as Record<string | number, unknown>)[key];
|
|
132
|
+
}
|
|
133
|
+
return current;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* normalizes a Standard Schema issue into our internal format
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeIssue(issue: StandardSchemaV1.Issue, server = false): InternalFormIssue {
|
|
140
|
+
const normalized: InternalFormIssue = { name: '', path: [], message: issue.message, server };
|
|
141
|
+
|
|
142
|
+
if (issue.path !== undefined) {
|
|
143
|
+
let name = '';
|
|
144
|
+
|
|
145
|
+
for (const segment of issue.path) {
|
|
146
|
+
const key = typeof segment === 'object' ? (segment.key as string | number) : segment;
|
|
147
|
+
|
|
148
|
+
normalized.path.push(key as string | number);
|
|
149
|
+
|
|
150
|
+
if (typeof key === 'number') {
|
|
151
|
+
name += `[${key}]`;
|
|
152
|
+
} else if (typeof key === 'string') {
|
|
153
|
+
name += name === '' ? key : '.' + key;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
normalized.name = name;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return normalized;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* flattens issues into a lookup object keyed by path
|
|
165
|
+
* includes a special '$' key containing all issues
|
|
166
|
+
*/
|
|
167
|
+
export function flattenIssues(issues: InternalFormIssue[]): Record<string, InternalFormIssue[]> {
|
|
168
|
+
const result: Record<string, InternalFormIssue[]> = {};
|
|
169
|
+
|
|
170
|
+
for (const issue of issues) {
|
|
171
|
+
(result.$ ??= []).push(issue);
|
|
172
|
+
|
|
173
|
+
let name = '';
|
|
174
|
+
|
|
175
|
+
if (issue.path !== undefined) {
|
|
176
|
+
for (const key of issue.path) {
|
|
177
|
+
if (typeof key === 'number') {
|
|
178
|
+
name += `[${key}]`;
|
|
179
|
+
} else if (typeof key === 'string') {
|
|
180
|
+
name += name === '' ? key : '.' + key;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
(result[name] ??= []).push(issue);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* builds a path string from an array of path segments
|
|
193
|
+
*/
|
|
194
|
+
export function buildPathString(path: (string | number)[]): string {
|
|
195
|
+
let result = '';
|
|
196
|
+
|
|
197
|
+
for (const segment of path) {
|
|
198
|
+
if (typeof segment === 'number') {
|
|
199
|
+
result += `[${segment}]`;
|
|
200
|
+
} else {
|
|
201
|
+
result += result === '' ? segment : '.' + segment;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// #region field proxy
|
|
209
|
+
|
|
210
|
+
export interface FieldIssue {
|
|
211
|
+
path: (string | number)[];
|
|
212
|
+
message: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface FieldProxyMethods<T> {
|
|
216
|
+
/** get the current value of this field */
|
|
217
|
+
value(): T | undefined;
|
|
218
|
+
/** set the value of this field */
|
|
219
|
+
set(value: T): T;
|
|
220
|
+
/** get validation issues for this exact field */
|
|
221
|
+
issues(): FieldIssue[] | undefined;
|
|
222
|
+
/** get all validation issues for this field and its descendants */
|
|
223
|
+
allIssues(): FieldIssue[] | undefined;
|
|
224
|
+
/**
|
|
225
|
+
* get props for binding to an input element.
|
|
226
|
+
* returns an object with `name`, `aria-invalid`, and type-specific props.
|
|
227
|
+
*/
|
|
228
|
+
as(type: InputType, value?: string): InputProps;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export type InputType =
|
|
232
|
+
| 'text'
|
|
233
|
+
| 'number'
|
|
234
|
+
| 'range'
|
|
235
|
+
| 'checkbox'
|
|
236
|
+
| 'radio'
|
|
237
|
+
| 'file'
|
|
238
|
+
| 'file multiple'
|
|
239
|
+
| 'select'
|
|
240
|
+
| 'select multiple'
|
|
241
|
+
| 'hidden'
|
|
242
|
+
| 'submit'
|
|
243
|
+
| 'email'
|
|
244
|
+
| 'password'
|
|
245
|
+
| 'tel'
|
|
246
|
+
| 'url'
|
|
247
|
+
| 'date'
|
|
248
|
+
| 'time'
|
|
249
|
+
| 'datetime-local'
|
|
250
|
+
| 'month'
|
|
251
|
+
| 'week'
|
|
252
|
+
| 'color'
|
|
253
|
+
| 'search';
|
|
254
|
+
|
|
255
|
+
export interface InputProps {
|
|
256
|
+
name: string;
|
|
257
|
+
'aria-invalid'?: 'true';
|
|
258
|
+
type?: string;
|
|
259
|
+
value?: string;
|
|
260
|
+
checked?: boolean;
|
|
261
|
+
multiple?: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* creates a proxy-based field accessor for form data.
|
|
266
|
+
* allows type-safe nested field access like `fields.user.emails[0].address.value()`.
|
|
267
|
+
*/
|
|
268
|
+
export function createFieldProxy<T>(
|
|
269
|
+
target: unknown,
|
|
270
|
+
getInput: () => Record<string, unknown>,
|
|
271
|
+
setInput: (path: (string | number)[], value: unknown) => void,
|
|
272
|
+
getIssues: () => Record<string, InternalFormIssue[]>,
|
|
273
|
+
path: (string | number)[] = [],
|
|
274
|
+
): T {
|
|
275
|
+
const getValue = () => {
|
|
276
|
+
return deepGet(getInput(), path);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return new Proxy(target as object, {
|
|
280
|
+
get(target, prop) {
|
|
281
|
+
if (typeof prop === 'symbol') return (target as Record<symbol, unknown>)[prop];
|
|
282
|
+
|
|
283
|
+
// Handle array access like jobs[0]
|
|
284
|
+
if (/^\d+$/.test(prop)) {
|
|
285
|
+
return createFieldProxy({}, getInput, setInput, getIssues, [...path, parseInt(prop, 10)]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const key = buildPathString(path);
|
|
289
|
+
|
|
290
|
+
if (prop === 'set') {
|
|
291
|
+
const setFunc = function (newValue: unknown) {
|
|
292
|
+
setInput(path, newValue);
|
|
293
|
+
return newValue;
|
|
294
|
+
};
|
|
295
|
+
return createFieldProxy(setFunc, getInput, setInput, getIssues, [...path, prop]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (prop === 'value') {
|
|
299
|
+
return createFieldProxy(getValue, getInput, setInput, getIssues, [...path, prop]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (prop === 'issues' || prop === 'allIssues') {
|
|
303
|
+
const issuesFunc = (): FieldIssue[] | undefined => {
|
|
304
|
+
const allIssues = getIssues()[key === '' ? '$' : key];
|
|
305
|
+
|
|
306
|
+
if (prop === 'allIssues') {
|
|
307
|
+
return allIssues?.map((issue) => ({
|
|
308
|
+
path: issue.path,
|
|
309
|
+
message: issue.message,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return allIssues
|
|
314
|
+
?.filter((issue) => issue.name === key)
|
|
315
|
+
?.map((issue) => ({
|
|
316
|
+
path: issue.path,
|
|
317
|
+
message: issue.message,
|
|
318
|
+
}));
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return createFieldProxy(issuesFunc, getInput, setInput, getIssues, [...path, prop]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (prop === 'as') {
|
|
325
|
+
const asFunc = (type: InputType, inputValue?: string): InputProps => {
|
|
326
|
+
const isArray =
|
|
327
|
+
type === 'file multiple' ||
|
|
328
|
+
type === 'select multiple' ||
|
|
329
|
+
(type === 'checkbox' && typeof inputValue === 'string');
|
|
330
|
+
|
|
331
|
+
const prefix =
|
|
332
|
+
type === 'number' || type === 'range' ? 'n:' : type === 'checkbox' && !isArray ? 'b:' : '';
|
|
333
|
+
|
|
334
|
+
// Base properties for all input types
|
|
335
|
+
const baseProps: InputProps = {
|
|
336
|
+
name: prefix + key + (isArray ? '[]' : ''),
|
|
337
|
+
get 'aria-invalid'() {
|
|
338
|
+
const issues = getIssues();
|
|
339
|
+
return key in issues ? 'true' : undefined;
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Add type attribute only for non-text inputs and non-select elements
|
|
344
|
+
if (type !== 'text' && type !== 'select' && type !== 'select multiple') {
|
|
345
|
+
baseProps.type = type === 'file multiple' ? 'file' : type;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Handle submit and hidden inputs
|
|
349
|
+
if (type === 'submit' || type === 'hidden') {
|
|
350
|
+
if (!inputValue) {
|
|
351
|
+
throw new Error(`\`${type}\` inputs must have a value`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return Object.defineProperties(baseProps, {
|
|
355
|
+
value: { value: inputValue, enumerable: true },
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Handle select inputs
|
|
360
|
+
if (type === 'select' || type === 'select multiple') {
|
|
361
|
+
return Object.defineProperties(baseProps, {
|
|
362
|
+
multiple: { value: isArray, enumerable: true },
|
|
363
|
+
value: {
|
|
364
|
+
enumerable: true,
|
|
365
|
+
get() {
|
|
366
|
+
return getValue();
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Handle checkbox inputs
|
|
373
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
374
|
+
if (type === 'radio' && !inputValue) {
|
|
375
|
+
throw new Error('Radio inputs must have a value');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (type === 'checkbox' && isArray && !inputValue) {
|
|
379
|
+
throw new Error('Checkbox array inputs must have a value');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return Object.defineProperties(baseProps, {
|
|
383
|
+
value: { value: inputValue ?? 'on', enumerable: true },
|
|
384
|
+
checked: {
|
|
385
|
+
enumerable: true,
|
|
386
|
+
get() {
|
|
387
|
+
const value = getValue();
|
|
388
|
+
|
|
389
|
+
if (type === 'radio') {
|
|
390
|
+
return value === inputValue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isArray) {
|
|
394
|
+
return ((value as string[] | undefined) ?? []).includes(inputValue!);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return value;
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Handle file inputs (can't persist value, just return name/type/multiple)
|
|
404
|
+
if (type === 'file' || type === 'file multiple') {
|
|
405
|
+
return Object.defineProperties(baseProps, {
|
|
406
|
+
multiple: { value: isArray, enumerable: true },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Handle all other input types (text, number, etc.)
|
|
411
|
+
return Object.defineProperties(baseProps, {
|
|
412
|
+
value: {
|
|
413
|
+
enumerable: true,
|
|
414
|
+
get() {
|
|
415
|
+
const value = getValue();
|
|
416
|
+
return value != null ? String(value) : '';
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
return createFieldProxy(asFunc, getInput, setInput, getIssues, [...path, 'as']);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Handle property access (nested fields)
|
|
426
|
+
return createFieldProxy({}, getInput, setInput, getIssues, [...path, prop]);
|
|
427
|
+
},
|
|
428
|
+
}) as T;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// #endregion
|