Skip to content

Commit 8e7f253

Browse files
committed
Factorized client validation.
1 parent 6c22f4e commit 8e7f253

File tree

4 files changed

+345
-348
lines changed

4 files changed

+345
-348
lines changed

src/lib/client/clientValidation.ts

Lines changed: 343 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,40 @@ import {
66
type FieldPath,
77
type ZodValidation,
88
type FormPathLeaves,
9-
SuperFormError
9+
SuperFormError,
10+
type TaintedFields
1011
} from '../index.js';
11-
import type { z, AnyZodObject } from 'zod';
12-
import { traversePath, traversePathsAsync } from '../traversal.js';
13-
import type { FormOptions } from './index.js';
12+
import type { z, AnyZodObject, ZodError, ZodTypeAny } from 'zod';
13+
import {
14+
isInvalidPath,
15+
setPaths,
16+
traversePath,
17+
traversePaths,
18+
traversePathsAsync
19+
} from '../traversal.js';
20+
import type { FormOptions, SuperForm, TaintOption } from './index.js';
1421
import { errorShape, mapErrors } from '../errors.js';
15-
import type { ValidateOptions } from './validateField.js';
16-
import type { FormPathType } from '$lib/stringPath.js';
22+
import type { FormPathType } from '../stringPath.js';
23+
import { clearErrors, clone } from '../utils.js';
24+
import { get } from 'svelte/store';
25+
26+
export type ValidateOptions<V> = Partial<{
27+
value: V;
28+
update: boolean | 'errors' | 'value';
29+
taint: TaintOption;
30+
errors: string | string[];
31+
}>;
1732

33+
/**
34+
* Validate current form data.
35+
*/
1836
export function validateForm<T extends AnyZodObject>(): Promise<
1937
SuperValidated<ZodValidation<T>>
2038
>;
2139

40+
/**
41+
* Validate a specific field in the form.
42+
*/
2243
export function validateForm<T extends AnyZodObject>(
2344
path: FormPathLeaves<z.infer<T>>,
2445
opts?: ValidateOptions<
@@ -40,6 +61,9 @@ export function validateForm<T extends AnyZodObject>(
4061
return { path, opts } as any;
4162
}
4263

64+
/**
65+
* Validate form data.
66+
*/
4367
export async function clientValidation<T extends AnyZodObject, M = unknown>(
4468
options: FormOptions<T, M>,
4569
checkData: z.infer<T>,
@@ -171,3 +195,316 @@ async function _clientValidation<T extends AnyZodObject, M = unknown>(
171195
id: formId
172196
};
173197
}
198+
199+
/**
200+
* Validate and set/clear object level errors.
201+
*/
202+
export async function validateObjectErrors<T extends AnyZodObject, M>(
203+
formOptions: FormOptions<T, M>,
204+
data: z.infer<T>,
205+
Errors: SuperForm<T, M>['errors']
206+
) {
207+
if (
208+
typeof formOptions.validators !== 'object' ||
209+
!('safeParseAsync' in formOptions.validators)
210+
) {
211+
return;
212+
}
213+
214+
const validators = formOptions.validators as AnyZodObject;
215+
const result = await validators.safeParseAsync(data);
216+
217+
if (!result.success) {
218+
const newErrors = mapErrors(
219+
result.error.format(),
220+
errorShape(validators as AnyZodObject)
221+
);
222+
223+
Errors.update((currentErrors) => {
224+
// Clear current object-level errors
225+
traversePaths(currentErrors, (pathData) => {
226+
if (pathData.key == '_errors') {
227+
return pathData.set(undefined);
228+
}
229+
});
230+
231+
// Add new object-level errors and tainted field errors
232+
traversePaths(newErrors, (pathData) => {
233+
if (pathData.key == '_errors') {
234+
return setPaths(currentErrors, [pathData.path], pathData.value);
235+
}
236+
});
237+
238+
return currentErrors;
239+
});
240+
} else {
241+
Errors.update((currentErrors) => {
242+
// Clear current object-level errors
243+
traversePaths(currentErrors, (pathData) => {
244+
if (pathData.key == '_errors') {
245+
return pathData.set(undefined);
246+
}
247+
});
248+
return currentErrors;
249+
});
250+
}
251+
}
252+
253+
/**
254+
* Validate a specific form field.
255+
* @DCI-context
256+
*/
257+
export async function validateField<T extends AnyZodObject, M>(
258+
path: string[],
259+
formOptions: FormOptions<T, M>,
260+
data: SuperForm<T, M>['form'],
261+
Errors: SuperForm<T, M>['errors'],
262+
Tainted: SuperForm<T, M>['tainted'],
263+
options: ValidateOptions<unknown> = {}
264+
): Promise<string[] | undefined> {
265+
function Errors_clear() {
266+
clearErrors(Errors, { undefinePath: path, clearFormLevelErrors: true });
267+
}
268+
269+
function Errors_update(errorMsgs: null | undefined | string | string[]) {
270+
if (typeof errorMsgs === 'string') errorMsgs = [errorMsgs];
271+
272+
if (options.update === true || options.update == 'errors') {
273+
Errors.update((errors) => {
274+
const error = traversePath(
275+
errors,
276+
path as FieldPath<typeof errors>,
277+
(node) => {
278+
if (isInvalidPath(path, node)) {
279+
throw new SuperFormError(
280+
'Errors can only be added to form fields, not to arrays or objects in the schema. Path: ' +
281+
node.path.slice(0, -1)
282+
);
283+
} else if (node.value === undefined) {
284+
node.parent[node.key] = {};
285+
return node.parent[node.key];
286+
} else {
287+
return node.value;
288+
}
289+
}
290+
);
291+
292+
if (!error)
293+
throw new SuperFormError(
294+
'Error path could not be created: ' + path
295+
);
296+
297+
error.parent[error.key] = errorMsgs ?? undefined;
298+
return errors;
299+
});
300+
}
301+
return errorMsgs ?? undefined;
302+
}
303+
304+
const errors = await _validateField(
305+
path,
306+
formOptions.validators,
307+
data,
308+
Errors,
309+
Tainted,
310+
options
311+
);
312+
313+
if (errors.validated) {
314+
if (errors.validated === 'all' && !errors.errors) {
315+
// We validated the whole data structure, so clear all errors on success after delayed validators.
316+
// it will also set the current path to undefined, so it can be used in
317+
// the tainted+error check in oninput.
318+
Errors_clear();
319+
} else {
320+
return Errors_update(errors.errors);
321+
}
322+
} else if (
323+
errors.validated === false &&
324+
formOptions.defaultValidator == 'clear'
325+
) {
326+
return Errors_update(undefined);
327+
}
328+
329+
return errors.errors;
330+
}
331+
332+
// @DCI-context
333+
async function _validateField<T extends AnyZodObject, M>(
334+
path: string[],
335+
validators: FormOptions<T, M>['validators'],
336+
data: SuperForm<T, M>['form'],
337+
Errors: SuperForm<T, M>['errors'],
338+
Tainted: SuperForm<T, M>['tainted'],
339+
options: ValidateOptions<unknown> = {}
340+
): Promise<{ validated: boolean | 'all'; errors: string[] | undefined }> {
341+
if (options.update === undefined) options.update = true;
342+
if (options.taint === undefined) options.taint = false;
343+
if (typeof options.errors == 'string') options.errors = [options.errors];
344+
345+
const Context = {
346+
value: options.value,
347+
shouldUpdate: true,
348+
currentData: undefined as z.infer<T> | undefined,
349+
// Remove numeric indices, they're not used for validators.
350+
validationPath: path.filter((p) => isNaN(parseInt(p)))
351+
};
352+
353+
async function defaultValidate() {
354+
return { validated: false, errors: undefined } as const;
355+
}
356+
357+
///// Roles ///////////////////////////////////////////////////////
358+
359+
function Tainted_isPathTainted(
360+
path: string[],
361+
tainted: TaintedFields<AnyZodObject> | undefined
362+
) {
363+
if (tainted === undefined) return false;
364+
const leaf = traversePath(tainted, path as FieldPath<typeof tainted>);
365+
if (!leaf) return false;
366+
return leaf.value === true;
367+
}
368+
369+
function Errors_update(updater: Parameters<typeof Errors.update>[0]) {
370+
Errors.update(updater);
371+
}
372+
373+
function Errors_clearFormLevelErrors() {
374+
Errors.update(($errors) => {
375+
$errors._errors = undefined;
376+
return $errors;
377+
});
378+
}
379+
380+
function Errors_fromZod(
381+
errors: ZodError<unknown>,
382+
validator: AnyZodObject
383+
) {
384+
return mapErrors(errors.format(), errorShape(validator));
385+
}
386+
387+
///////////////////////////////////////////////////////////////////
388+
389+
if (!('value' in options)) {
390+
// Use value from data
391+
Context.currentData = get(data);
392+
393+
const dataToValidate = traversePath(
394+
Context.currentData,
395+
path as FieldPath<typeof Context.currentData>
396+
);
397+
398+
Context.value = dataToValidate?.value;
399+
} else if (options.update === true || options.update === 'value') {
400+
// Value should be updating the data
401+
data.update(
402+
($data) => {
403+
setPaths($data, [path], Context.value);
404+
return (Context.currentData = $data);
405+
},
406+
{ taint: options.taint }
407+
);
408+
} else {
409+
Context.shouldUpdate = false;
410+
}
411+
412+
//console.log('🚀 ~ file: index.ts:871 ~ validate:', path, value);
413+
414+
if (typeof validators !== 'object') {
415+
return defaultValidate();
416+
}
417+
418+
if ('safeParseAsync' in validators) {
419+
// Zod validator
420+
if (!Context.shouldUpdate) {
421+
// If value shouldn't update, clone and set the new value
422+
Context.currentData = clone(Context.currentData ?? get(data));
423+
setPaths(Context.currentData, [path], Context.value);
424+
}
425+
426+
const result = await (validators as ZodTypeAny).safeParseAsync(
427+
Context.currentData
428+
);
429+
430+
if (!result.success) {
431+
const newErrors = Errors_fromZod(
432+
result.error,
433+
validators as AnyZodObject
434+
);
435+
436+
if (options.update === true || options.update == 'errors') {
437+
// Set errors for other (tainted) fields, that may have been changed
438+
const taintedFields = get(Tainted);
439+
440+
Errors_update((currentErrors) => {
441+
// Clear current object-level errors
442+
traversePaths(currentErrors, (pathData) => {
443+
if (pathData.key == '_errors') {
444+
return pathData.set(undefined);
445+
}
446+
});
447+
448+
// Add new object-level errors and tainted field errors
449+
traversePaths(newErrors, (pathData) => {
450+
if (pathData.key == '_errors') {
451+
return setPaths(
452+
currentErrors,
453+
[pathData.path],
454+
pathData.value
455+
);
456+
}
457+
if (!Array.isArray(pathData.value)) return;
458+
if (Tainted_isPathTainted(pathData.path, taintedFields)) {
459+
setPaths(currentErrors, [pathData.path], pathData.value);
460+
}
461+
return 'skip';
462+
});
463+
464+
return currentErrors;
465+
});
466+
}
467+
468+
// Finally, set errors for the specific field
469+
// it will be set to undefined if no errors, so the tainted+error check
470+
// in oninput can determine if errors should be displayed or not.
471+
const current = traversePath(
472+
newErrors,
473+
path as FieldPath<typeof newErrors>
474+
);
475+
476+
return {
477+
validated: true,
478+
errors: options.errors ?? current?.value
479+
};
480+
} else {
481+
// Clear form-level errors
482+
Errors_clearFormLevelErrors();
483+
return { validated: true, errors: undefined };
484+
}
485+
} else {
486+
// SuperForms validator
487+
488+
const validator = traversePath(
489+
validators,
490+
Context.validationPath as FieldPath<typeof validators>
491+
);
492+
493+
if (!validator) {
494+
// Path didn't exist
495+
throw new SuperFormError('No Superforms validator found: ' + path);
496+
} else if (validator.value === undefined) {
497+
// No validator, use default
498+
return defaultValidate();
499+
} else {
500+
const result = (await validator.value(Context.value)) as
501+
| string[]
502+
| undefined;
503+
504+
return {
505+
validated: true,
506+
errors: result ? options.errors ?? result : result
507+
};
508+
}
509+
}
510+
}

src/lib/client/formEnhance.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@ import {
1414
import type { z, AnyZodObject } from 'zod';
1515
import { stringify } from 'devalue';
1616
import type { Entity } from '../schemaEntity.js';
17-
import { validateField } from './validateField.js';
1817
import type { FormOptions, SuperForm } from './index.js';
19-
import { clientValidation } from './clientValidation.js';
18+
import { clientValidation, validateField } from './clientValidation.js';
2019

2120
enum FetchStatus {
2221
Idle = 0,

src/lib/client/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
validateField,
4141
validateObjectErrors,
4242
type ValidateOptions
43-
} from './validateField.js';
43+
} from './clientValidation.js';
4444
import {
4545
formEnhance,
4646
shouldSyncFlash,

0 commit comments

Comments
 (0)