@octanejs/formisch 0.0.1
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 +21 -0
- package/README.md +73 -0
- package/UPSTREAM.md +95 -0
- package/package.json +65 -0
- package/src/components/Field/Field.tsrx +22 -0
- package/src/components/Field/Field.tsrx.d.ts +17 -0
- package/src/components/Field/index.ts +1 -0
- package/src/components/FieldArray/FieldArray.tsrx +22 -0
- package/src/components/FieldArray/FieldArray.tsrx.d.ts +18 -0
- package/src/components/FieldArray/index.ts +1 -0
- package/src/components/Form/Form.tsrx +28 -0
- package/src/components/Form/Form.tsrx.d.ts +15 -0
- package/src/components/Form/index.ts +1 -0
- package/src/components/index.ts +3 -0
- package/src/core/array/copyItemState/copyItemState.ts +101 -0
- package/src/core/array/copyItemState/index.ts +1 -0
- package/src/core/array/index.ts +3 -0
- package/src/core/array/resetItemState/index.ts +1 -0
- package/src/core/array/resetItemState/resetItemState.ts +172 -0
- package/src/core/array/swapItemState/index.ts +1 -0
- package/src/core/array/swapItemState/swapItemState.ts +138 -0
- package/src/core/field/focusFieldElement/focusFieldElement.ts +32 -0
- package/src/core/field/focusFieldElement/index.ts +1 -0
- package/src/core/field/getDirtyFieldInput/getDirtyFieldInput.ts +66 -0
- package/src/core/field/getDirtyFieldInput/index.ts +1 -0
- package/src/core/field/getElementInput/getElementInput.ts +78 -0
- package/src/core/field/getElementInput/index.ts +1 -0
- package/src/core/field/getFieldBool/getFieldBool.ts +22 -0
- package/src/core/field/getFieldBool/index.ts +1 -0
- package/src/core/field/getFieldInput/getFieldInput.ts +52 -0
- package/src/core/field/getFieldInput/index.ts +1 -0
- package/src/core/field/getFieldStore/getFieldStore.ts +34 -0
- package/src/core/field/getFieldStore/index.ts +1 -0
- package/src/core/field/index.ts +11 -0
- package/src/core/field/initializeFieldStore/index.ts +1 -0
- package/src/core/field/initializeFieldStore/initializeFieldStore.ts +325 -0
- package/src/core/field/setFieldBool/index.ts +1 -0
- package/src/core/field/setFieldBool/setFieldBool.ts +29 -0
- package/src/core/field/setFieldInput/index.ts +1 -0
- package/src/core/field/setFieldInput/setFieldInput.ts +180 -0
- package/src/core/field/setInitialFieldInput/index.ts +1 -0
- package/src/core/field/setInitialFieldInput/setInitialFieldInput.ts +99 -0
- package/src/core/field/walkFieldStore/index.ts +1 -0
- package/src/core/field/walkFieldStore/walkFieldStore.ts +49 -0
- package/src/core/form/createFormStore/createFormStore.ts +56 -0
- package/src/core/form/createFormStore/index.ts +1 -0
- package/src/core/form/decodeFormData/decodeFormData.ts +436 -0
- package/src/core/form/decodeFormData/index.ts +1 -0
- package/src/core/form/index.ts +4 -0
- package/src/core/form/validateFormInput/index.ts +1 -0
- package/src/core/form/validateFormInput/validateFormInput.ts +138 -0
- package/src/core/form/validateIfRequired/index.ts +1 -0
- package/src/core/form/validateIfRequired/validateIfRequired.ts +31 -0
- package/src/core/framework/index.ts +80 -0
- package/src/core/index.ts +6 -0
- package/src/core/types/field/field.ts +201 -0
- package/src/core/types/field/index.ts +1 -0
- package/src/core/types/form/form.ts +140 -0
- package/src/core/types/form/index.ts +1 -0
- package/src/core/types/index.ts +6 -0
- package/src/core/types/path/index.ts +10 -0
- package/src/core/types/path/path.ts +301 -0
- package/src/core/types/schema/index.ts +1 -0
- package/src/core/types/schema/schema.ts +18 -0
- package/src/core/types/signal/index.ts +1 -0
- package/src/core/types/signal/signal.ts +23 -0
- package/src/core/types/utils/index.ts +1 -0
- package/src/core/types/utils/utils.ts +46 -0
- package/src/core/values.ts +4 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useField/index.ts +1 -0
- package/src/hooks/useField/useField.ts +114 -0
- package/src/hooks/useFieldArray/index.ts +1 -0
- package/src/hooks/useFieldArray/useFieldArray.ts +63 -0
- package/src/hooks/useForm/index.ts +1 -0
- package/src/hooks/useForm/useForm.ts +68 -0
- package/src/hooks/useSignals/index.ts +1 -0
- package/src/hooks/useSignals/useSignals.ts +31 -0
- package/src/index.ts +19 -0
- package/src/internal.ts +32 -0
- package/src/methods/focus/focus.ts +35 -0
- package/src/methods/focus/index.ts +1 -0
- package/src/methods/getDeepErrorEntries/getDeepErrorEntries.ts +108 -0
- package/src/methods/getDeepErrorEntries/index.ts +1 -0
- package/src/methods/getDeepErrors/getDeepErrors.ts +90 -0
- package/src/methods/getDeepErrors/index.ts +1 -0
- package/src/methods/getDirtyInput/getDirtyInput.ts +87 -0
- package/src/methods/getDirtyInput/index.ts +1 -0
- package/src/methods/getDirtyPaths/getDirtyPaths.ts +123 -0
- package/src/methods/getDirtyPaths/index.ts +1 -0
- package/src/methods/getErrors/getErrors.ts +70 -0
- package/src/methods/getErrors/index.ts +1 -0
- package/src/methods/getInput/getInput.ts +75 -0
- package/src/methods/getInput/index.ts +1 -0
- package/src/methods/handleSubmit/handleSubmit.ts +83 -0
- package/src/methods/handleSubmit/index.ts +1 -0
- package/src/methods/index.ts +23 -0
- package/src/methods/insert/index.ts +1 -0
- package/src/methods/insert/insert.ts +134 -0
- package/src/methods/isDirty/index.ts +1 -0
- package/src/methods/isDirty/isDirty.ts +72 -0
- package/src/methods/isEdited/index.ts +1 -0
- package/src/methods/isEdited/isEdited.ts +72 -0
- package/src/methods/isTouched/index.ts +1 -0
- package/src/methods/isTouched/isTouched.ts +72 -0
- package/src/methods/isValid/index.ts +1 -0
- package/src/methods/isValid/isValid.ts +74 -0
- package/src/methods/move/index.ts +1 -0
- package/src/methods/move/move.ts +124 -0
- package/src/methods/pickDirty/index.ts +1 -0
- package/src/methods/pickDirty/pickDirty.ts +87 -0
- package/src/methods/remove/index.ts +1 -0
- package/src/methods/remove/remove.ts +76 -0
- package/src/methods/replace/index.ts +1 -0
- package/src/methods/replace/replace.ts +80 -0
- package/src/methods/reset/index.ts +1 -0
- package/src/methods/reset/reset.ts +216 -0
- package/src/methods/setErrors/index.ts +1 -0
- package/src/methods/setErrors/setErrors.ts +63 -0
- package/src/methods/setInput/index.ts +1 -0
- package/src/methods/setInput/setInput.ts +87 -0
- package/src/methods/submit/index.ts +1 -0
- package/src/methods/submit/submit.ts +11 -0
- package/src/methods/swap/index.ts +1 -0
- package/src/methods/swap/swap.ts +85 -0
- package/src/methods/validate/index.ts +1 -0
- package/src/methods/validate/validate.ts +34 -0
- package/src/types/field.ts +48 -0
- package/src/types/form.ts +12 -0
- package/src/types/index.ts +2 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type * as v from 'valibot';
|
|
2
|
+
import { type FieldSchema, initializeFieldStore } from '../../field/index.ts';
|
|
3
|
+
import { createSignal } from '../../framework/index.ts';
|
|
4
|
+
import type { EmptyInput, FormConfig, FormSchema, InternalFormStore } from '../../types/index.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The default empty input of a form. Required string fields start as an empty
|
|
8
|
+
* string, while every other type starts as `undefined`.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_EMPTY_INPUT: EmptyInput = { string: '' };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new internal form store from the provided configuration.
|
|
14
|
+
* Initializes the field store hierarchy, sets validation modes, and
|
|
15
|
+
* creates form state signals.
|
|
16
|
+
*
|
|
17
|
+
* @param config The form configuration.
|
|
18
|
+
* @param parse The schema parse function.
|
|
19
|
+
*
|
|
20
|
+
* @returns The internal form store.
|
|
21
|
+
*/
|
|
22
|
+
// @__NO_SIDE_EFFECTS__
|
|
23
|
+
export function createFormStore(
|
|
24
|
+
config: FormConfig,
|
|
25
|
+
parse: (input: unknown) => Promise<v.SafeParseResult<FormSchema>>,
|
|
26
|
+
): InternalFormStore {
|
|
27
|
+
// Create partial store object
|
|
28
|
+
const store: Partial<InternalFormStore> = {};
|
|
29
|
+
|
|
30
|
+
// Merge configured empty input on top of the defaults before initializing so
|
|
31
|
+
// the field stores can read it from the form store
|
|
32
|
+
store.emptyInput = { ...DEFAULT_EMPTY_INPUT, ...config.emptyInput };
|
|
33
|
+
|
|
34
|
+
// Set form config and validation
|
|
35
|
+
store.validators = 0;
|
|
36
|
+
store.validate = config.validate ?? 'submit';
|
|
37
|
+
store.revalidate = config.revalidate ?? 'input';
|
|
38
|
+
store.parse = parse;
|
|
39
|
+
|
|
40
|
+
// Initialize form state signals
|
|
41
|
+
store.isSubmitting = createSignal(false);
|
|
42
|
+
store.isSubmitted = createSignal(false);
|
|
43
|
+
store.isValidating = createSignal(false);
|
|
44
|
+
|
|
45
|
+
// Initialize field store hierarchy from schema
|
|
46
|
+
initializeFieldStore(
|
|
47
|
+
store as InternalFormStore,
|
|
48
|
+
store,
|
|
49
|
+
config.schema as FieldSchema,
|
|
50
|
+
config.initialInput,
|
|
51
|
+
[],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Return initialized store
|
|
55
|
+
return store as InternalFormStore;
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './createFormStore.ts';
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import type { FormSchema } from '../../types/index.ts';
|
|
2
|
+
|
|
3
|
+
// Matches decimal number with optional sign, fraction and exponent (e.g. "42",
|
|
4
|
+
// "-7", "+0.5", ".5", "1.5e3"), rejecting junk like hex or "Infinity"
|
|
5
|
+
// eslint-disable-next-line security/detect-unsafe-regex
|
|
6
|
+
const NUMBER_REGEX = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
|
|
7
|
+
|
|
8
|
+
// Matches timezone-less ISO date and time with optional seconds and fractional
|
|
9
|
+
// seconds (e.g. "2023-06-15T14:30" or "2023-06-15T14:30:45.123"), as emitted by
|
|
10
|
+
// `<input type="datetime-local">`
|
|
11
|
+
const ISO_DATE_TIME_REGEX =
|
|
12
|
+
// eslint-disable-next-line security/detect-unsafe-regex
|
|
13
|
+
/^\d{4}-(?:0[1-9]|1[0-2])-(?:[12]\d|0[1-9]|3[01])T(?:0\d|1\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?$/u;
|
|
14
|
+
|
|
15
|
+
// Hint: Maximum number of items allowed per array field. A numeric path
|
|
16
|
+
// segment is used as an array index, so a single crafted key like
|
|
17
|
+
// `["items",1000000000]` would set the array length to one billion. Creating
|
|
18
|
+
// the sparse array is cheap, but every later pass that walks it by length
|
|
19
|
+
// then runs in O(length), such as `fillDefaults` and the schema validation
|
|
20
|
+
// the caller runs on the result. An array of booleans even writes a value at
|
|
21
|
+
// every index, so a tiny request can exhaust CPU and memory. The limit is a
|
|
22
|
+
// fixed number because a legitimate sparse array (one checked checkbox at a
|
|
23
|
+
// high index, the rest unchecked and absent) is indistinguishable from an
|
|
24
|
+
// attack; only the index magnitude differs. Throwing rather than truncating
|
|
25
|
+
// avoids silently returning wrong data.
|
|
26
|
+
const MAX_ARRAY_LENGTH = 5000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Internal schema type with the structural properties that are read while
|
|
30
|
+
* traversing a Valibot schema.
|
|
31
|
+
*/
|
|
32
|
+
interface InternalSchema {
|
|
33
|
+
readonly type: string;
|
|
34
|
+
readonly wrapped?: InternalSchema;
|
|
35
|
+
readonly getter?: (input: undefined) => InternalSchema;
|
|
36
|
+
readonly entries?: Record<string, InternalSchema>;
|
|
37
|
+
readonly item?: InternalSchema;
|
|
38
|
+
readonly items?: InternalSchema[];
|
|
39
|
+
readonly options?: InternalSchema[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Unwraps wrapper and lazy schemas until a concrete schema is reached.
|
|
44
|
+
*
|
|
45
|
+
* @param schema The schema to unwrap.
|
|
46
|
+
*
|
|
47
|
+
* @returns The unwrapped schema.
|
|
48
|
+
*/
|
|
49
|
+
function unwrapSchema(schema: InternalSchema): InternalSchema {
|
|
50
|
+
switch (schema.type) {
|
|
51
|
+
case 'exact_optional':
|
|
52
|
+
case 'nullable':
|
|
53
|
+
case 'nullish':
|
|
54
|
+
case 'optional':
|
|
55
|
+
case 'undefinedable':
|
|
56
|
+
case 'non_nullable':
|
|
57
|
+
case 'non_nullish':
|
|
58
|
+
case 'non_optional':
|
|
59
|
+
return unwrapSchema(schema.wrapped!);
|
|
60
|
+
case 'lazy':
|
|
61
|
+
return unwrapSchema(schema.getter!(undefined));
|
|
62
|
+
default:
|
|
63
|
+
return schema;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the child schema for the given key by traversing objects, arrays,
|
|
69
|
+
* tuples and schema options. Returns `undefined` if no child schema is found.
|
|
70
|
+
*
|
|
71
|
+
* @param schema The parent schema.
|
|
72
|
+
* @param key The path key.
|
|
73
|
+
*
|
|
74
|
+
* @returns The child schema or `undefined`.
|
|
75
|
+
*/
|
|
76
|
+
function getChildSchema(
|
|
77
|
+
schema: InternalSchema | undefined,
|
|
78
|
+
key: string | number,
|
|
79
|
+
): InternalSchema | undefined {
|
|
80
|
+
if (schema) {
|
|
81
|
+
// Unwrap schema before reading its structure
|
|
82
|
+
const unwrapped = unwrapSchema(schema);
|
|
83
|
+
|
|
84
|
+
// If schema is object, return entry schema
|
|
85
|
+
if (
|
|
86
|
+
unwrapped.type === 'object' ||
|
|
87
|
+
unwrapped.type === 'loose_object' ||
|
|
88
|
+
unwrapped.type === 'strict_object'
|
|
89
|
+
) {
|
|
90
|
+
return unwrapped.entries![key];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If schema is array, return item schema
|
|
94
|
+
if (unwrapped.type === 'array') {
|
|
95
|
+
return unwrapped.item;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If schema is tuple, return item schema at index
|
|
99
|
+
if (
|
|
100
|
+
unwrapped.type === 'tuple' ||
|
|
101
|
+
unwrapped.type === 'loose_tuple' ||
|
|
102
|
+
unwrapped.type === 'strict_tuple'
|
|
103
|
+
) {
|
|
104
|
+
return unwrapped.items![key as number];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// If schema has options, return first matching child schema
|
|
108
|
+
if (
|
|
109
|
+
unwrapped.type === 'union' ||
|
|
110
|
+
unwrapped.type === 'intersect' ||
|
|
111
|
+
unwrapped.type === 'variant'
|
|
112
|
+
) {
|
|
113
|
+
// Hint: The first matching option is used. For a union or variant where
|
|
114
|
+
// the same key has different types across options, the value is decoded
|
|
115
|
+
// based on the first option, since the matching branch is only known
|
|
116
|
+
// during validation.
|
|
117
|
+
for (const option of unwrapped.options!) {
|
|
118
|
+
const childSchema = getChildSchema(option, key);
|
|
119
|
+
if (childSchema !== undefined) {
|
|
120
|
+
return childSchema;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decodes a stringified date based on its format. Empty strings become `null`.
|
|
129
|
+
*
|
|
130
|
+
* @param value The stringified value.
|
|
131
|
+
*
|
|
132
|
+
* @returns The decoded date.
|
|
133
|
+
*/
|
|
134
|
+
function decodeDate(value: string): Date | null | undefined {
|
|
135
|
+
if (!value || value === 'null') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (value === 'undefined') {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
// Hint: A timezone-less date and time (from `<input type="datetime-local">`)
|
|
142
|
+
// is interpreted as local time by `new Date`, so it is forced to UTC. Dates,
|
|
143
|
+
// months and full timestamps are already parsed as UTC.
|
|
144
|
+
if (ISO_DATE_TIME_REGEX.test(value)) {
|
|
145
|
+
return new Date(`${value}Z`);
|
|
146
|
+
}
|
|
147
|
+
return new Date(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decodes a stringified boolean. Empty strings become `null`.
|
|
152
|
+
*
|
|
153
|
+
* @param value The stringified value.
|
|
154
|
+
*
|
|
155
|
+
* @returns The decoded boolean.
|
|
156
|
+
*/
|
|
157
|
+
function decodeBoolean(value: string): boolean | null | undefined {
|
|
158
|
+
if (!value || value === 'null') {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (value === 'undefined') {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
return !(value === 'false' || value === 'off' || value === '0');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Decodes a stringified number. Empty strings become `null` and non-numeric
|
|
169
|
+
* values become `NaN`.
|
|
170
|
+
*
|
|
171
|
+
* @param value The stringified value.
|
|
172
|
+
*
|
|
173
|
+
* @returns The decoded number.
|
|
174
|
+
*/
|
|
175
|
+
function decodeNumber(value: string): number | null | undefined {
|
|
176
|
+
if (!value || value === 'null') {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (value === 'undefined') {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (NUMBER_REGEX.test(value)) {
|
|
183
|
+
return Number(value);
|
|
184
|
+
}
|
|
185
|
+
return NaN;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Decodes a stringified bigint. Empty strings become `null` and invalid values
|
|
190
|
+
* are returned unchanged.
|
|
191
|
+
*
|
|
192
|
+
* @param value The stringified value.
|
|
193
|
+
*
|
|
194
|
+
* @returns The decoded bigint.
|
|
195
|
+
*/
|
|
196
|
+
function decodeBigint(value: string): bigint | string | null | undefined {
|
|
197
|
+
if (!value || value === 'null') {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (value === 'undefined') {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return BigInt(value);
|
|
205
|
+
} catch {
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Decodes a single form data value based on the concrete schema type. Files
|
|
212
|
+
* and unknown types are returned unchanged.
|
|
213
|
+
*
|
|
214
|
+
* @param value The form data value.
|
|
215
|
+
* @param schema The schema of the value.
|
|
216
|
+
*
|
|
217
|
+
* @returns The decoded value.
|
|
218
|
+
*/
|
|
219
|
+
function decodeValue(value: FormDataEntryValue, schema: InternalSchema | undefined): unknown {
|
|
220
|
+
// Non-string values (files) and unknown schemas are returned unchanged
|
|
221
|
+
if (typeof value !== 'string' || !schema) {
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Decode value based on concrete schema type
|
|
226
|
+
switch (unwrapSchema(schema).type) {
|
|
227
|
+
case 'number':
|
|
228
|
+
return decodeNumber(value);
|
|
229
|
+
case 'boolean':
|
|
230
|
+
return decodeBoolean(value);
|
|
231
|
+
case 'date':
|
|
232
|
+
return decodeDate(value);
|
|
233
|
+
case 'bigint':
|
|
234
|
+
return decodeBigint(value);
|
|
235
|
+
default:
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Fills in default values that are lost during the form data transfer. Booleans
|
|
242
|
+
* of unchecked checkboxes become `false` and absent arrays become empty. Only
|
|
243
|
+
* containers that are present in the decoded data are completed.
|
|
244
|
+
*
|
|
245
|
+
* @param schema The schema of the value.
|
|
246
|
+
* @param parent The parent object or array holding the value.
|
|
247
|
+
* @param key The key of the value within its parent.
|
|
248
|
+
*/
|
|
249
|
+
function fillDefaults(
|
|
250
|
+
schema: InternalSchema,
|
|
251
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
252
|
+
parent: any,
|
|
253
|
+
key: string | number,
|
|
254
|
+
): void {
|
|
255
|
+
// Unwrap schema before reading its structure
|
|
256
|
+
const unwrappedSchema = unwrapSchema(schema);
|
|
257
|
+
|
|
258
|
+
// If schema is boolean, default absent (unchecked checkbox) values to `false`
|
|
259
|
+
// Hint: Only `undefined` is treated as absent so that a decoded `null` (e.g.
|
|
260
|
+
// from a nullable boolean) is preserved instead of being coerced to `false`.
|
|
261
|
+
if (unwrappedSchema.type === 'boolean') {
|
|
262
|
+
if (parent[key] === undefined) {
|
|
263
|
+
parent[key] = false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Otherwise, if schema is array, default absent arrays and complete items
|
|
267
|
+
} else if (unwrappedSchema.type === 'array') {
|
|
268
|
+
if (Array.isArray(parent[key])) {
|
|
269
|
+
for (let index = 0; index < parent[key].length; index++) {
|
|
270
|
+
fillDefaults(unwrappedSchema.item!, parent[key], index);
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
parent[key] = [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Otherwise, if schema is tuple, complete items of present tuples
|
|
277
|
+
} else if (
|
|
278
|
+
unwrappedSchema.type === 'tuple' ||
|
|
279
|
+
unwrappedSchema.type === 'loose_tuple' ||
|
|
280
|
+
unwrappedSchema.type === 'strict_tuple'
|
|
281
|
+
) {
|
|
282
|
+
if (Array.isArray(parent[key])) {
|
|
283
|
+
for (let index = 0; index < unwrappedSchema.items!.length; index++) {
|
|
284
|
+
fillDefaults(unwrappedSchema.items![index], parent[key], index);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Otherwise, if schema is object, complete present entries
|
|
289
|
+
} else if (
|
|
290
|
+
unwrappedSchema.type === 'object' ||
|
|
291
|
+
unwrappedSchema.type === 'loose_object' ||
|
|
292
|
+
unwrappedSchema.type === 'strict_object'
|
|
293
|
+
) {
|
|
294
|
+
if (parent[key] && typeof parent[key] === 'object') {
|
|
295
|
+
for (const entryKey in unwrappedSchema.entries) {
|
|
296
|
+
fillDefaults(unwrappedSchema.entries[entryKey], parent[key], entryKey);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Otherwise, if schema has options, complete for each option
|
|
301
|
+
// Hint: Defaults from every option are applied because the matching branch
|
|
302
|
+
// of a union or variant is only known during validation, not while
|
|
303
|
+
// decoding. This is correct for intersect (all options apply) and harmless
|
|
304
|
+
// for object options (unknown keys are ignored on parse). A `strictObject`
|
|
305
|
+
// option may reject the extra keys though, so reliably decoding such a
|
|
306
|
+
// variant would require resolving its branch via the discriminator, which
|
|
307
|
+
// is not possible before validation.
|
|
308
|
+
} else if (
|
|
309
|
+
unwrappedSchema.type === 'union' ||
|
|
310
|
+
unwrappedSchema.type === 'intersect' ||
|
|
311
|
+
unwrappedSchema.type === 'variant'
|
|
312
|
+
) {
|
|
313
|
+
for (const option of unwrappedSchema.options!) {
|
|
314
|
+
fillDefaults(option, parent, key);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Decodes the entries of a form data object into nested form values using the
|
|
321
|
+
* Valibot schema as the source of truth. Information that is lost during the
|
|
322
|
+
* transfer via HTTP, like numbers, booleans, dates and unchecked checkboxes,
|
|
323
|
+
* is restored based on the schema.
|
|
324
|
+
*
|
|
325
|
+
* The keys of the form data are expected to be the stringified field paths that
|
|
326
|
+
* Formisch assigns to its field elements (for example `["todos",0,"label"]`).
|
|
327
|
+
*
|
|
328
|
+
* @param schema The form schema.
|
|
329
|
+
* @param formData The form data object.
|
|
330
|
+
*
|
|
331
|
+
* @returns The decoded form values.
|
|
332
|
+
*/
|
|
333
|
+
// @__NO_SIDE_EFFECTS__
|
|
334
|
+
export function decodeFormData<TSchema extends FormSchema>(
|
|
335
|
+
schema: TSchema,
|
|
336
|
+
formData: FormData,
|
|
337
|
+
): unknown {
|
|
338
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
339
|
+
const values: any = {};
|
|
340
|
+
|
|
341
|
+
// Build nested values from form data entries
|
|
342
|
+
formData.forEach((value, key) => {
|
|
343
|
+
// Convert stringified key back to field path array, ignore invalid JSON
|
|
344
|
+
let path: unknown = null;
|
|
345
|
+
try {
|
|
346
|
+
path = JSON.parse(key);
|
|
347
|
+
} catch {
|
|
348
|
+
// Ignore invalid JSON keys
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Only process valid, non-empty field paths whose value is not empty
|
|
352
|
+
// (unselected) file input
|
|
353
|
+
if (
|
|
354
|
+
Array.isArray(path) &&
|
|
355
|
+
path.length > 0 &&
|
|
356
|
+
(typeof value === 'string' || value.size > 0 || value.name !== '')
|
|
357
|
+
) {
|
|
358
|
+
// Create temporary references for parent value and schema
|
|
359
|
+
let parentValue = values;
|
|
360
|
+
let parentSchema: InternalSchema | undefined = schema as InternalSchema;
|
|
361
|
+
|
|
362
|
+
// Traverse path segments and build nested structure based on schema
|
|
363
|
+
for (let index = 0; index < path.length; index++) {
|
|
364
|
+
const segment = path[index];
|
|
365
|
+
|
|
366
|
+
// Skip invalid keys and keys that could pollute object prototype
|
|
367
|
+
if (
|
|
368
|
+
(typeof segment !== 'string' && typeof segment !== 'number') ||
|
|
369
|
+
segment === '' ||
|
|
370
|
+
segment === '__proto__' ||
|
|
371
|
+
segment === 'prototype' ||
|
|
372
|
+
segment === 'constructor'
|
|
373
|
+
) {
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Arrays are indexed by numbers; string segments would write properties
|
|
378
|
+
// like `length` or `push` that inflate or corrupt them
|
|
379
|
+
if (Array.isArray(parentValue)) {
|
|
380
|
+
if (typeof segment === 'string') {
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Throw on oversized array index (see MAX_ARRAY_LENGTH)
|
|
385
|
+
if (segment >= MAX_ARRAY_LENGTH) {
|
|
386
|
+
throw new Error(`Array exceeds the maximum length of ${MAX_ARRAY_LENGTH}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Get child schema for current segment
|
|
391
|
+
const childSchema = getChildSchema(parentSchema, segment);
|
|
392
|
+
|
|
393
|
+
// If segment is last one, set or append decoded value
|
|
394
|
+
if (index === path.length - 1) {
|
|
395
|
+
const unwrappedSchema = childSchema && unwrapSchema(childSchema);
|
|
396
|
+
|
|
397
|
+
// If schema is dynamic array, append decoded item
|
|
398
|
+
if (unwrappedSchema && unwrappedSchema.type === 'array') {
|
|
399
|
+
parentValue[segment] ??= [];
|
|
400
|
+
parentValue[segment].push(decodeValue(value, unwrappedSchema.item));
|
|
401
|
+
|
|
402
|
+
// Otherwise, set decoded value
|
|
403
|
+
} else {
|
|
404
|
+
parentValue[segment] = decodeValue(value, childSchema);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Otherwise, create next container and continue traversing
|
|
408
|
+
} else {
|
|
409
|
+
if (parentValue[segment] == null) {
|
|
410
|
+
// Create array for array and tuple schemas, object otherwise
|
|
411
|
+
const schemaType = childSchema && unwrapSchema(childSchema).type;
|
|
412
|
+
parentValue[segment] =
|
|
413
|
+
schemaType === 'array' ||
|
|
414
|
+
schemaType === 'tuple' ||
|
|
415
|
+
schemaType === 'loose_tuple' ||
|
|
416
|
+
schemaType === 'strict_tuple'
|
|
417
|
+
? []
|
|
418
|
+
: {};
|
|
419
|
+
|
|
420
|
+
// Otherwise, stop on conflicting scalar value to avoid writing a
|
|
421
|
+
// property to a non-object, which throws in strict mode
|
|
422
|
+
} else if (typeof parentValue[segment] !== 'object') {
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
parentValue = parentValue[segment];
|
|
426
|
+
parentSchema = childSchema;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Fill in default values that are lost during transfer
|
|
433
|
+
fillDefaults(schema as InternalSchema, { values }, 'values');
|
|
434
|
+
|
|
435
|
+
return values;
|
|
436
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './decodeFormData.ts';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './validateFormInput.ts';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type * as v from 'valibot';
|
|
2
|
+
import { focusFieldElement, getFieldInput, walkFieldStore } from '../../field/index.ts';
|
|
3
|
+
import { batch, untrack } from '../../framework/index.ts';
|
|
4
|
+
import type { InternalFormStore, Schema } from '../../types/index.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate form input config interface.
|
|
8
|
+
*/
|
|
9
|
+
export interface ValidateFormInputConfig {
|
|
10
|
+
/**
|
|
11
|
+
* Whether to focus the first field with an error.
|
|
12
|
+
*/
|
|
13
|
+
readonly shouldFocus?: boolean | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validates the form input using the configured Valibot schema. Parses the
|
|
18
|
+
* current form input, processes validation issues, assigns errors to fields,
|
|
19
|
+
* and optionally focuses the first field with an error.
|
|
20
|
+
*
|
|
21
|
+
* @param internalFormStore The form store to validate.
|
|
22
|
+
* @param config The validation configuration.
|
|
23
|
+
*
|
|
24
|
+
* @returns The Valibot validation result.
|
|
25
|
+
*/
|
|
26
|
+
export async function validateFormInput(
|
|
27
|
+
internalFormStore: InternalFormStore,
|
|
28
|
+
config?: ValidateFormInputConfig,
|
|
29
|
+
): Promise<v.SafeParseResult<Schema>> {
|
|
30
|
+
// Update validation state
|
|
31
|
+
internalFormStore.validators++;
|
|
32
|
+
internalFormStore.isValidating.value = true;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Parse form input with Valibot schema
|
|
36
|
+
const result = await internalFormStore.parse(untrack(() => getFieldInput(internalFormStore)));
|
|
37
|
+
|
|
38
|
+
// Create variables for root and nested errors
|
|
39
|
+
let rootErrors: [string, ...string[]] | undefined;
|
|
40
|
+
let nestedErrors: Record<string, [string, ...string[]] | undefined> | undefined;
|
|
41
|
+
|
|
42
|
+
// Process validation issues into error variables
|
|
43
|
+
if (result.issues) {
|
|
44
|
+
// Initialize nested errors object
|
|
45
|
+
nestedErrors = {};
|
|
46
|
+
|
|
47
|
+
// Process each validation issue
|
|
48
|
+
for (const issue of result.issues) {
|
|
49
|
+
// If issue has path, assign to nested errors
|
|
50
|
+
if (issue.path) {
|
|
51
|
+
// Initialize path array
|
|
52
|
+
const path = [];
|
|
53
|
+
|
|
54
|
+
// Build path from issue path items
|
|
55
|
+
for (const pathItem of issue.path) {
|
|
56
|
+
const key = pathItem.key;
|
|
57
|
+
const keyType = typeof key;
|
|
58
|
+
const itemType = pathItem.type;
|
|
59
|
+
// Skip unsupported path types
|
|
60
|
+
if (
|
|
61
|
+
(keyType !== 'string' && keyType !== 'number') ||
|
|
62
|
+
itemType === 'map' ||
|
|
63
|
+
itemType === 'set'
|
|
64
|
+
) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add key to path
|
|
69
|
+
path.push(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Convert path to name of field
|
|
73
|
+
const name = JSON.stringify(path);
|
|
74
|
+
|
|
75
|
+
// Append or initialize nested errors
|
|
76
|
+
const fieldErrors = nestedErrors[name];
|
|
77
|
+
if (fieldErrors) {
|
|
78
|
+
fieldErrors.push(issue.message);
|
|
79
|
+
} else {
|
|
80
|
+
nestedErrors[name] = [issue.message];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Otherwise, assign to root errors
|
|
84
|
+
} else {
|
|
85
|
+
if (rootErrors) {
|
|
86
|
+
rootErrors.push(issue.message);
|
|
87
|
+
} else {
|
|
88
|
+
rootErrors = [issue.message];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create variable to decide if first error field should be focused
|
|
95
|
+
let shouldFocus = config?.shouldFocus ?? false;
|
|
96
|
+
|
|
97
|
+
// Batch error, focus and validation state updates together so reactive
|
|
98
|
+
// subscribers observe a single consistent update
|
|
99
|
+
batch(() => {
|
|
100
|
+
// Untracked to avoid subscribing a surrounding reactive scope to the
|
|
101
|
+
// form structure.
|
|
102
|
+
untrack(() => {
|
|
103
|
+
// Set or reset errors on each field store.
|
|
104
|
+
walkFieldStore(internalFormStore, (internalFieldStore) => {
|
|
105
|
+
if (internalFieldStore.path.length === 0) {
|
|
106
|
+
internalFieldStore.errors.value = rootErrors ?? null;
|
|
107
|
+
} else {
|
|
108
|
+
const fieldErrors = nestedErrors?.[internalFieldStore.name] ?? null;
|
|
109
|
+
internalFieldStore.errors.value = fieldErrors;
|
|
110
|
+
|
|
111
|
+
// Focus the first erroring field whose element can actually receive
|
|
112
|
+
// focus, so the focus is not consumed by a field without a focusable
|
|
113
|
+
// element (e.g. unmounted or hidden)
|
|
114
|
+
if (shouldFocus && fieldErrors && focusFieldElement(internalFieldStore)) {
|
|
115
|
+
shouldFocus = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Reset validation state of form
|
|
122
|
+
internalFormStore.validators--;
|
|
123
|
+
internalFormStore.isValidating.value = internalFormStore.validators > 0;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Return validation result
|
|
127
|
+
return result;
|
|
128
|
+
|
|
129
|
+
// If parsing throws, still reset validation state so form does not stay
|
|
130
|
+
// stuck in a validating state
|
|
131
|
+
} catch (error) {
|
|
132
|
+
batch(() => {
|
|
133
|
+
internalFormStore.validators--;
|
|
134
|
+
internalFormStore.isValidating.value = internalFormStore.validators > 0;
|
|
135
|
+
});
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './validateIfRequired.ts';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getFieldBool } from '../../field/index.ts';
|
|
2
|
+
import { untrack } from '../../framework/index.ts';
|
|
3
|
+
import type { InternalFieldStore, InternalFormStore, ValidationMode } from '../../types/index.ts';
|
|
4
|
+
import { validateFormInput } from '../validateFormInput/validateFormInput.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validates the form input if required based on the validation mode and form
|
|
8
|
+
* state. Determines whether to use initial validation mode, revalidation mode,
|
|
9
|
+
* or skip validation entirely.
|
|
10
|
+
*
|
|
11
|
+
* @param internalFormStore The form store to validate.
|
|
12
|
+
* @param internalFieldStore The field store that triggered validation.
|
|
13
|
+
* @param validationMode The validation mode that triggered this check.
|
|
14
|
+
*/
|
|
15
|
+
export function validateIfRequired(
|
|
16
|
+
internalFormStore: InternalFormStore,
|
|
17
|
+
internalFieldStore: InternalFieldStore,
|
|
18
|
+
validationMode: ValidationMode,
|
|
19
|
+
): void {
|
|
20
|
+
if (
|
|
21
|
+
validationMode ===
|
|
22
|
+
(internalFormStore.validate === 'initial' ||
|
|
23
|
+
(internalFormStore.validate === 'submit'
|
|
24
|
+
? untrack(() => internalFormStore.isSubmitted.value)
|
|
25
|
+
: untrack(() => getFieldBool(internalFieldStore, 'errors')))
|
|
26
|
+
? internalFormStore.revalidate
|
|
27
|
+
: internalFormStore.validate)
|
|
28
|
+
) {
|
|
29
|
+
validateFormInput(internalFormStore);
|
|
30
|
+
}
|
|
31
|
+
}
|