From e29c760888b4fb869c4e68fb96e19b8c4eb70d4a Mon Sep 17 00:00:00 2001 From: Ryan Leckey Date: Sat, 25 May 2024 16:44:17 -0700 Subject: [PATCH] fix: ensure transformed values in field-level schemas are used on submit Signed-off-by: Ryan Leckey --- packages/rules/src/toTypedSchema.ts | 4 +- packages/vee-validate/src/types/forms.ts | 92 +++++++++++-------- packages/vee-validate/src/useFieldState.ts | 8 +- packages/vee-validate/src/useForm.ts | 45 ++++++--- packages/vee-validate/src/useValidateField.ts | 6 +- packages/vee-validate/src/validate.ts | 42 +++++---- packages/vee-validate/tests/useForm.spec.ts | 1 + packages/yup/tests/yup.spec.ts | 41 +++++++++ packages/zod/tests/zod.spec.ts | 42 +++++++++ 9 files changed, 199 insertions(+), 82 deletions(-) diff --git a/packages/rules/src/toTypedSchema.ts b/packages/rules/src/toTypedSchema.ts index a5b47d884..d5ef24860 100644 --- a/packages/rules/src/toTypedSchema.ts +++ b/packages/rules/src/toTypedSchema.ts @@ -1,5 +1,5 @@ import { keysOf } from '../../vee-validate/src/utils'; -import { TypedSchema, RawFormSchema, validateObject, TypedSchemaError, validate } from 'vee-validate'; +import { TypedSchema, RawFormSchema, validateObject, TypedSchemaError, validate, GenericObject } from 'vee-validate'; import { Optional, isObject } from '../../shared'; export function toTypedSchema = Optional>( @@ -21,7 +21,7 @@ export function toTypedSchema = }; } - const result = await validateObject(rawSchema, values); + const result = await validateObject(rawSchema, values as GenericObject | undefined); return { errors: keysOf(result.errors).map(path => { diff --git a/packages/vee-validate/src/types/forms.ts b/packages/vee-validate/src/types/forms.ts index 612ad71c7..486b74bdd 100644 --- a/packages/vee-validate/src/types/forms.ts +++ b/packages/vee-validate/src/types/forms.ts @@ -4,11 +4,16 @@ import { FieldValidationMetaInfo } from '../../../shared'; import { Path, PathValue } from './paths'; import { PartialDeep } from 'type-fest'; -export interface ValidationResult { +export interface ValidationResult { errors: string[]; valid: boolean; + value?: TValue; } +export type FlattenAndMapPathsValidationResult = { + [K in Path]: ValidationResult; +}; + export interface TypedSchemaError { path?: string; errors: string[]; @@ -81,17 +86,17 @@ export interface ValidationOptions { warn: boolean; } -export type FieldValidator = (opts?: Partial) => Promise; +export type FieldValidator = (opts?: Partial) => Promise>; -export interface PathStateConfig { +export interface PathStateConfig { bails: boolean; label: MaybeRefOrGetter; type: InputType; - validate: FieldValidator; + validate: FieldValidator; schema?: MaybeRefOrGetter; } -export interface PathState { +export interface PathState { id: number | number[]; path: string; touched: boolean; @@ -100,8 +105,8 @@ export interface PathState { required: boolean; validated: boolean; pending: boolean; - initialValue: TValue | undefined; - value: TValue | undefined; + initialValue: TInput | undefined; + value: TInput | undefined; errors: string[]; bails: boolean; label: string | undefined; @@ -112,7 +117,7 @@ export interface PathState { pendingUnmount: Record; pendingReset: boolean; }; - validate?: FieldValidator; + validate?: FieldValidator; } export interface FieldEntry { @@ -139,29 +144,29 @@ export interface PrivateFieldArrayContext extends FieldArrayCo path: MaybeRefOrGetter; } -export interface PrivateFieldContext { +export interface PrivateFieldContext { id: number; name: MaybeRef; - value: Ref; - meta: FieldMeta; + value: Ref; + meta: FieldMeta; errors: Ref; errorMessage: Ref; label?: MaybeRefOrGetter; type?: string; bails?: boolean; keepValueOnUnmount?: MaybeRefOrGetter; - checkedValue?: MaybeRefOrGetter; - uncheckedValue?: MaybeRefOrGetter; + checkedValue?: MaybeRefOrGetter; + uncheckedValue?: MaybeRefOrGetter; checked?: Ref; - resetField(state?: Partial>): void; + resetField(state?: Partial>): void; handleReset(): void; - validate: FieldValidator; + validate: FieldValidator; handleChange(e: Event | unknown, shouldValidate?: boolean): void; handleBlur(e?: Event, shouldValidate?: boolean): void; - setState(state: Partial>): void; + setState(state: Partial>): void; setTouched(isTouched: boolean): void; setErrors(message: string | string[]): void; - setValue(value: TValue, shouldValidate?: boolean): void; + setValue(value: TInput, shouldValidate?: boolean): void; } export type FieldContext = Omit, 'id' | 'instances'>; @@ -197,33 +202,37 @@ export interface FormActions { resetField(field: Path, state?: Partial): void; } -export interface FormValidationResult { +export interface FormValidationResult { valid: boolean; - results: Partial, ValidationResult>>; - errors: Partial, string>>; - values?: TOutput; + results: Partial>; + errors: Partial, string>>; + values?: Partial; } -export interface SubmissionContext extends FormActions { +export interface SubmissionContext extends FormActions { evt?: Event; - controlledValues: Partial; + controlledValues: Partial; } -export type SubmissionHandler = ( +export type SubmissionHandler = ( values: TOutput, - ctx: SubmissionContext, + ctx: SubmissionContext, ) => TReturn; -export interface InvalidSubmissionContext { - values: TValues; +export interface InvalidSubmissionContext< + TInput extends GenericObject = GenericObject, + TOutput extends GenericObject = TInput, +> { + values: TInput; evt?: Event; - errors: Partial, string>>; - results: Partial, ValidationResult>>; + errors: Partial, string>>; + results: FormValidationResult['results']; } -export type InvalidSubmissionHandler = ( - ctx: InvalidSubmissionContext, -) => void; +export type InvalidSubmissionHandler< + TInput extends GenericObject = GenericObject, + TOutput extends GenericObject = TInput, +> = (ctx: InvalidSubmissionContext) => void; export type RawFormSchema = Record, string | GenericValidateFunction | GenericObject>; @@ -231,9 +240,9 @@ export type FieldPathLookup = Par Record, PrivateFieldContext | PrivateFieldContext[]> >; -type HandleSubmitFactory = ( +type HandleSubmitFactory = ( cb: SubmissionHandler, - onSubmitValidationErrorCb?: InvalidSubmissionHandler, + onSubmitValidationErrorCb?: InvalidSubmissionHandler, ) => (e?: Event) => Promise; export type PublicPathState = Omit< @@ -310,8 +319,10 @@ export interface BaseInputBinds { onInput: (e: Event) => void; } -export interface PrivateFormContext - extends FormActions { +export interface PrivateFormContext< + TValues extends GenericObject = GenericObject, + TOutput extends GenericObject = TValues, +> extends FormActions { formId: number; values: TValues; initialValues: Ref>; @@ -327,14 +338,17 @@ export interface PrivateFormContext; validateSchema?: (mode: SchemaValidationMode) => Promise>; validate(opts?: Partial): Promise>; - validateField(field: Path, opts?: Partial): Promise; + validateField>( + field: TPath, + opts?: Partial, + ): Promise>; stageInitialValue(path: string, value: unknown, updateOriginal?: boolean): void; unsetInitialValue(path: string): void; handleSubmit: HandleSubmitFactory & { withControlled: HandleSubmitFactory }; setFieldInitialValue(path: string, value: unknown, updateOriginal?: boolean): void; createPathState>( path: MaybeRef, - config?: Partial, + config?: Partial>, ): PathState>; getPathState>(path: TPath): PathState> | undefined; getAllPathStates(): PathState[]; @@ -387,7 +401,7 @@ export interface PrivateFormContext & TExtras>; } -export interface FormContext +export interface FormContext extends Omit< PrivateFormContext, | 'formId' diff --git a/packages/vee-validate/src/useFieldState.ts b/packages/vee-validate/src/useFieldState.ts index bf9dac061..b31de2745 100644 --- a/packages/vee-validate/src/useFieldState.ts +++ b/packages/vee-validate/src/useFieldState.ts @@ -18,14 +18,14 @@ export interface FieldStateComposable { setState(state: Partial>): void; } -export interface StateInit { - modelValue: MaybeRef; +export interface StateInit { + modelValue: MaybeRef; form?: PrivateFormContext; bails: boolean; label?: MaybeRefOrGetter; type?: InputType; - validate?: FieldValidator; - schema?: MaybeRefOrGetter | undefined>; + validate?: FieldValidator; + schema?: MaybeRefOrGetter | undefined>; } let ID_COUNTER = 0; diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index b8560f98c..45582ffbf 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -26,6 +26,7 @@ import { ValidationResult, FormState, FormValidationResult, + FlattenAndMapPathsValidationResult, PrivateFormContext, FormContext, FormErrors, @@ -109,7 +110,7 @@ function resolveInitialValues(opt export function useForm< TValues extends GenericObject = GenericObject, - TOutput = TValues, + TOutput extends GenericObject = TValues, TSchema extends FormSchema | TypedSchema = | FormSchema | TypedSchema, @@ -266,10 +267,10 @@ export function useForm< const schema = opts?.validationSchema; - function createPathState( - path: MaybeRefOrGetter>, - config?: Partial, - ): PathState { + function createPathState>( + path: MaybeRefOrGetter, + config?: Partial>, + ): PathState { const initialValue = computed(() => getFromPath(initialValues.value, toValue(path))); const pathStateExists = pathStateLookup.value[toValue(path)]; const isCheckboxOrRadio = config?.type === 'checkbox' || config?.type === 'radio'; @@ -285,7 +286,7 @@ export function useForm< pathStateExists.fieldsCount++; pathStateExists.__flags.pendingUnmount[id] = false; - return pathStateExists as PathState; + return pathStateExists as PathState; } const currentValue = computed(() => getFromPath(formValues, toValue(path))); @@ -336,7 +337,7 @@ export function useForm< dirty: computed(() => { return !isEqual(unref(currentValue), unref(initialValue)); }), - }) as PathState; + }) as PathState; pathStates.value.push(state); pathStateLookup.value[pathValue] = state; @@ -510,7 +511,7 @@ export function useForm< function makeSubmissionFactory(onlyControlled: boolean) { return function submitHandlerFactory( fn?: SubmissionHandler, - onValidationError?: InvalidSubmissionHandler, + onValidationError?: InvalidSubmissionHandler, ) { return function submissionHandler(e: unknown) { if (e instanceof Event) { @@ -529,9 +530,10 @@ export function useForm< if (result.valid && typeof fn === 'function') { const controlled = deepCopy(controlledValues.value); - let submittedValues = (onlyControlled ? controlled : values) as unknown as TOutput; + const submittedValues = (onlyControlled ? controlled : values) as unknown as TOutput; + if (result.values) { - submittedValues = result.values; + Object.assign(submittedValues, result.values); } return fn(submittedValues, { @@ -859,14 +861,16 @@ export function useForm< key: state.path, valid: true, errors: [], + value: undefined, }); } - return state.validate(opts).then((result: ValidationResult) => { + return state.validate(opts).then(result => { return { key: state.path, valid: result.valid, errors: result.errors, + value: result.value, }; }); }), @@ -874,14 +878,20 @@ export function useForm< isValidating.value = false; - const results: Partial> = {}; + const results: Partial> = {}; const errors: Partial> = {}; + const values: Partial = {}; + for (const validation of validations) { results[validation.key as Path] = { valid: validation.valid, errors: validation.errors, }; + if (validation.value) { + setInPath(values, validation.key, validation.value); + } + if (validation.errors.length) { errors[validation.key as Path] = validation.errors[0]; } @@ -891,10 +901,14 @@ export function useForm< valid: validations.every(r => r.valid), results, errors, + values, }; } - async function validateField(path: Path, opts?: Partial): Promise { + async function validateField>( + path: TPath, + opts?: Partial, + ): Promise> { const state = findPathState(path); if (state && opts?.mode !== 'silent') { state.validated = true; @@ -1291,7 +1305,10 @@ function useFormInitialValues( }; } -function mergeValidationResults(a: ValidationResult, b?: ValidationResult): ValidationResult { +function mergeValidationResults( + a: ValidationResult, + b?: ValidationResult, +): ValidationResult { if (!b) { return a; } diff --git a/packages/vee-validate/src/useValidateField.ts b/packages/vee-validate/src/useValidateField.ts index f6a79992c..7763516e4 100644 --- a/packages/vee-validate/src/useValidateField.ts +++ b/packages/vee-validate/src/useValidateField.ts @@ -6,13 +6,13 @@ import { injectWithSelf, warn } from './utils'; /** * Validates a single field */ -export function useValidateField(path?: MaybeRefOrGetter) { +export function useValidateField(path?: MaybeRefOrGetter) { const form = injectWithSelf(FormContextKey); const field = path ? undefined : inject(FieldContextKey); - return function validateField(): Promise { + return function validateField(): Promise> { if (field) { - return field.validate(); + return field.validate() as Promise>; } if (form && path) { diff --git a/packages/vee-validate/src/validate.ts b/packages/vee-validate/src/validate.ts index 2c29ad538..ae513cc9a 100644 --- a/packages/vee-validate/src/validate.ts +++ b/packages/vee-validate/src/validate.ts @@ -6,9 +6,11 @@ import { ValidationResult, GenericValidateFunction, TypedSchema, + FlattenAndMapPathsValidationResult, FormValidationResult, RawFormSchema, YupSchema, + GenericObject, TypedSchemaError, Path, TypedSchemaContext, @@ -18,13 +20,13 @@ import { isCallable, FieldValidationMetaInfo } from '../../shared'; /** * Used internally */ -interface FieldValidationContext { +interface FieldValidationContext { name: string; label?: string; rules: - | GenericValidateFunction - | GenericValidateFunction[] - | TypedSchema + | GenericValidateFunction + | GenericValidateFunction[] + | TypedSchema | string | Record; bails: boolean; @@ -41,18 +43,18 @@ interface ValidationOptions { /** * Validates a value against the rules. */ -export async function validate( - value: TValue, +export async function validate( + value: TInput, rules: | string | Record - | GenericValidateFunction - | GenericValidateFunction[] - | TypedSchema, + | GenericValidateFunction + | GenericValidateFunction[] + | TypedSchema, options: ValidationOptions = {}, -): Promise { +): Promise> { const shouldBail = options?.bails; - const field: FieldValidationContext = { + const field: FieldValidationContext = { name: options?.name || '{field}', rules, label: options?.label, @@ -61,18 +63,17 @@ export async function validate( }; const result = await _validate(field, value); - const errors = result.errors; return { - errors, - valid: !errors.length, + ...result, + valid: !result.errors.length, }; } /** * Starts the validation process. */ -async function _validate(field: FieldValidationContext, value: TValue) { +async function _validate(field: FieldValidationContext, value: TInput) { const rules = field.rules; if (isTypedSchema(rules) || isYupValidator(rules)) { return validateFieldWithTypedSchema(value, { ...field, rules }); @@ -222,6 +223,7 @@ async function validateFieldWithTypedSchema( } return { + value: result.value, errors: messages, }; } @@ -300,14 +302,14 @@ function fillTargetValues(params: unknown[] | Record, crossTabl ); } -export async function validateTypedSchema( +export async function validateTypedSchema( schema: TypedSchema | YupSchema, values: TValues, ): Promise> { const typedSchema = isTypedSchema(schema) ? schema : yupToTypedSchema(schema); const validationResult = await typedSchema.parse(deepCopy(values)); - const results: Partial, ValidationResult>> = {}; + const results: Partial> = {}; const errors: Partial, string>> = {}; for (const error of validationResult.errors) { const messages = error.errors; @@ -330,9 +332,9 @@ export async function validateTypedSchema( }; } -export async function validateObjectSchema( +export async function validateObjectSchema( schema: RawFormSchema, - values: TValues, + values: TValues | undefined, opts?: Partial<{ names: Record; bailsMap: Record }>, ): Promise> { const paths = keysOf(schema) as Path[]; @@ -354,7 +356,7 @@ export async function validateObjectSchema( let isAllValid = true; const validationResults = await Promise.all(validations); - const results: Partial, ValidationResult>> = {}; + const results: Partial> = {}; const errors: Partial, string>> = {}; for (const result of validationResults) { results[result.path] = { diff --git a/packages/vee-validate/tests/useForm.spec.ts b/packages/vee-validate/tests/useForm.spec.ts index ba5173729..ff1e916a6 100644 --- a/packages/vee-validate/tests/useForm.spec.ts +++ b/packages/vee-validate/tests/useForm.spec.ts @@ -240,6 +240,7 @@ describe('useForm()', () => { errors: [REQUIRED_MESSAGE], }, }, + values: {}, }); }); diff --git a/packages/yup/tests/yup.spec.ts b/packages/yup/tests/yup.spec.ts index fa5a372ee..4446d615e 100644 --- a/packages/yup/tests/yup.spec.ts +++ b/packages/yup/tests/yup.spec.ts @@ -546,6 +546,47 @@ test('reports required state for field-level schemas without a form context', as ); }); +test('uses transformed value as submitted value', async () => { + const onSubmitSpy = vi.fn(); + let onSubmit!: () => void; + + const wrapper = mountWithHoc({ + setup() { + const { handleSubmit } = useForm<{ + req: string; + }>(); + + const { value } = useField('test', toTypedSchema(yup.string().transform(val => `modified: ${val}`))); + + // submit now + onSubmit = handleSubmit(onSubmitSpy); + + return { + value, + }; + }, + template: ` +
+ +
+ `, + }); + + const input = wrapper.$el.querySelector('input'); + + setValue(input, '12345678'); + await flushPromises(); + onSubmit(); + await flushPromises(); + await expect(onSubmitSpy).toHaveBeenCalledTimes(1); + await expect(onSubmitSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + test: 'modified: 12345678', + }), + expect.anything(), + ); +}); + test('supports yup.strip', async () => { const onSubmitSpy = vi.fn(); let onSubmit!: () => void; diff --git a/packages/zod/tests/zod.spec.ts b/packages/zod/tests/zod.spec.ts index a25342dda..b6d23888d 100644 --- a/packages/zod/tests/zod.spec.ts +++ b/packages/zod/tests/zod.spec.ts @@ -660,3 +660,45 @@ test('reports required state for field-level schemas without a form context', as }), ); }); + +test('uses transformed value as submitted value', async () => { + const onSubmitSpy = vi.fn(); + let onSubmit!: () => void; + + const wrapper = mountWithHoc({ + setup() { + const { handleSubmit } = useForm<{ + test: string; + }>(); + + const testRules = toTypedSchema(z.string().transform(value => `modified: ${value}`)); + const { value } = useField('test', testRules); + + // submit now + onSubmit = handleSubmit(onSubmitSpy); + + return { + value, + }; + }, + template: ` +
+ +
+ `, + }); + + const input = wrapper.$el.querySelector('input'); + + setValue(input, '12345678'); + await flushPromises(); + onSubmit(); + await flushPromises(); + await expect(onSubmitSpy).toHaveBeenCalledTimes(1); + await expect(onSubmitSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + test: 'modified: 12345678', + }), + expect.anything(), + ); +});