diff --git a/examples/react/simple/src/index.tsx b/examples/react/simple/src/index.tsx index 4228f1e0e..9457d2d34 100644 --- a/examples/react/simple/src/index.tsx +++ b/examples/react/simple/src/index.tsx @@ -23,6 +23,8 @@ export default function App() { defaultValues: { firstName: '', lastName: '', + password: '', + confirmPassword: '', }, onSubmit: async ({ value }) => { // Do something with form data @@ -95,6 +97,33 @@ export default function App() { )} /> +
+ + !value + ? 'A password is required' + : value.length < 6 + ? 'Password must be at least 6 characters' + : undefined, + }} + children={(field) => ( + <> + + field.handleChange(e.target.value)} + /> + + + )} + /> +
[state.canSubmit, state.isSubmitting]} children={([canSubmit, isSubmitting]) => ( diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 7a97afe71..591c46eda 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -370,6 +370,10 @@ export interface FieldValidators< onDynamic?: TOnDynamic onDynamicAsync?: TOnDynamicAsync onDynamicAsyncDebounceMs?: number + /** + * An optional list of field names that should trigger this field's `onDynamic` and `onDynamicAsync` events when its value changes + */ + onDynamicListenTo?: DeepKeys[] } export interface FieldListeners< @@ -1533,16 +1537,30 @@ export class FieldApi< getLinkedFields = (cause: ValidationCause) => { const fields = Object.values(this.form.fieldInfo) as FieldInfo[] - const linkedFields: AnyFieldApi[] = [] + const linkedFields: Array<{ + field: AnyFieldApi + validatorCause: ValidationCause + validatorType?: 'dynamic' + }> = [] for (const field of fields) { if (!field.instance) continue - const { onChangeListenTo, onBlurListenTo } = + const { onChangeListenTo, onBlurListenTo, onDynamicListenTo } = field.instance.options.validators || {} if (cause === 'change' && onChangeListenTo?.includes(this.name)) { - linkedFields.push(field.instance) + linkedFields.push({ field: field.instance, validatorCause: 'change' }) } if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) { - linkedFields.push(field.instance) + linkedFields.push({ field: field.instance, validatorCause: 'blur' }) + } + if ( + (cause === 'change' || cause === 'blur') && + onDynamicListenTo?.includes(this.name as string) + ) { + linkedFields.push({ + field: field.instance, + validatorCause: cause, + validatorType: 'dynamic', + }) } } @@ -1565,14 +1583,34 @@ export class FieldApi< const linkedFields = this.getLinkedFields(cause) const linkedFieldValidates = linkedFields.reduce( - (acc, field) => { - const fieldValidates = getSyncValidatorArray(cause, { - ...field.options, + (acc, { field, validatorCause, validatorType }) => { + let fieldOptions = field.options + + if (validatorType === 'dynamic' && field.options.validators) { + const modifiedValidators = { ...field.options.validators } + + if (validatorCause === 'change') { + modifiedValidators.onChange = modifiedValidators.onDynamic + } else if (validatorCause === 'blur') { + modifiedValidators.onBlur = modifiedValidators.onDynamic + } + + fieldOptions = { + ...field.options, + validators: modifiedValidators, + } + } + + const fieldValidates = getSyncValidatorArray(validatorCause, { + ...fieldOptions, form: field.form, validationLogic: field.form.options.validationLogic || defaultValidationLogic, }) fieldValidates.forEach((validate) => { + if (validatorType === 'dynamic') { + validate.cause = 'dynamic' + } ;(validate as any).field = field }) return acc.concat(fieldValidates as never) @@ -1638,9 +1676,9 @@ export class FieldApi< for (const validateObj of validates) { validateFieldFn(this, validateObj) } - for (const fieldValitateObj of linkedFieldValidates) { - if (!fieldValitateObj.validate) continue - validateFieldFn(fieldValitateObj.field, fieldValitateObj) + for (const fieldValidateObj of linkedFieldValidates) { + if (!fieldValidateObj.validate) continue + validateFieldFn(fieldValidateObj.field, fieldValidateObj) } }) @@ -1669,6 +1707,27 @@ export class FieldApi< })) } + const dynamicErrKey = getErrorMapKey('dynamic') + + if ( + this.state.meta.errorMap[dynamicErrKey] && + this.state.meta.errorSourceMap[dynamicErrKey] === 'field' && + cause !== 'dynamic' && + !hasErrored + ) { + this.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [dynamicErrKey]: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [dynamicErrKey]: undefined, + }, + })) + } + return { hasErrored } } @@ -1704,14 +1763,34 @@ export class FieldApi< const linkedFields = this.getLinkedFields(cause) const linkedFieldValidates = linkedFields.reduce( - (acc, field) => { - const fieldValidates = getAsyncValidatorArray(cause, { - ...field.options, + (acc, { field, validatorCause, validatorType }) => { + let fieldOptions = field.options + + if (validatorType === 'dynamic' && field.options.validators) { + const modifiedValidators = { ...field.options.validators } + + if (validatorCause === 'change') { + modifiedValidators.onChangeAsync = modifiedValidators.onDynamicAsync + } else if (validatorCause === 'blur') { + modifiedValidators.onBlurAsync = modifiedValidators.onDynamicAsync + } + + fieldOptions = { + ...field.options, + validators: modifiedValidators, + } + } + + const fieldValidates = getAsyncValidatorArray(validatorCause, { + ...fieldOptions, form: field.form, validationLogic: field.form.options.validationLogic || defaultValidationLogic, }) fieldValidates.forEach((validate) => { + if (validatorType === 'dynamic') { + validate.cause = 'dynamic' + } ;(validate as any).field = field }) return acc.concat(fieldValidates as never) @@ -1727,7 +1806,7 @@ export class FieldApi< this.setMeta((prev) => ({ ...prev, isValidating: true })) } - for (const linkedField of linkedFields) { + for (const { field: linkedField } of linkedFields) { linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) } @@ -1825,11 +1904,11 @@ export class FieldApi< if (!validateObj.validate) continue validateFieldAsyncFn(this, validateObj, validatesPromises) } - for (const fieldValitateObj of linkedFieldValidates) { - if (!fieldValitateObj.validate) continue + for (const fieldValidateObj of linkedFieldValidates) { + if (!fieldValidateObj.validate) continue validateFieldAsyncFn( - fieldValitateObj.field, - fieldValitateObj, + fieldValidateObj.field, + fieldValidateObj, linkedPromises, ) } @@ -1842,7 +1921,7 @@ export class FieldApi< this.setMeta((prev) => ({ ...prev, isValidating: false })) - for (const linkedField of linkedFields) { + for (const { field: linkedField } of linkedFields) { linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) } diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 0bc575d67..e153fe5c8 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -204,7 +204,9 @@ export class FieldGroupApi< if ( validators && - (validators.onChangeListenTo || validators.onBlurListenTo) + (validators.onChangeListenTo || + validators.onBlurListenTo || + validators.onDynamicListenTo) ) { const newValidators = { ...validators } @@ -219,6 +221,9 @@ export class FieldGroupApi< validators.onChangeListenTo, ) newValidators.onBlurListenTo = remapListenTo(validators.onBlurListenTo) + newValidators.onDynamicListenTo = remapListenTo( + validators.onDynamicListenTo, + ) newProps.validators = newValidators } diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts index e37449528..2d358936a 100644 --- a/packages/form-core/src/ValidationLogic.ts +++ b/packages/form-core/src/ValidationLogic.ts @@ -26,7 +26,7 @@ export interface ValidationLogicProps { | undefined | null event: { - type: 'blur' | 'change' | 'submit' | 'mount' | 'server' + type: 'blur' | 'change' | 'submit' | 'mount' | 'server' | 'dynamic' fieldName?: string async: boolean } @@ -147,6 +147,11 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => { cause: 'submit', } as const + const onDynamicValidator = { + fn: isAsync ? props.validators.onDynamicAsync : props.validators.onDynamic, + cause: 'dynamic', + } as const + // Allows us to clear onServer errors const onServerValidator = isAsync ? undefined @@ -193,6 +198,13 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => { form: props.form, }) } + case 'dynamic': { + // Run dynamic, server validation + return props.runValidation({ + validators: [onDynamicValidator, onServerValidator], + form: props.form, + }) + } default: { throw new Error(`Unknown validation event type: ${props.event.type}`) } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 320ac1aea..1eaae67ca 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1908,6 +1908,127 @@ describe('field api', () => { ]) }) + it('should run onDynamic on a linked field', () => { + const form = new FormApi({ + defaultValues: { + password: '', + confirm_password: '', + }, + }) + + form.mount() + + const passField = new FieldApi({ + form, + name: 'password', + }) + + const passconfirmField = new FieldApi({ + form, + name: 'confirm_password', + validators: { + onDynamicListenTo: ['password'], + onChange: ({ value, fieldApi }) => { + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + onDynamic: ({ value, fieldApi }) => { + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }, + }) + + passField.mount() + passconfirmField.mount() + + passField.setValue('one') + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + passconfirmField.setValue('one') + expect(passconfirmField.getMeta().isValid).toBe(true) + expect(passconfirmField.state.meta.errors).toStrictEqual([]) + passField.setValue('two') + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + }) + + it('should run onDynamicAsync on a linked field', async () => { + vi.useFakeTimers() + + const fn = vi.fn() + + const form = new FormApi({ + defaultValues: { + password: '', + confirm_password: '', + }, + }) + + form.mount() + + const passField = new FieldApi({ + form, + name: 'password', + }) + + const passconfirmField = new FieldApi({ + form, + name: 'confirm_password', + validators: { + onDynamicListenTo: ['password'], + onChangeAsync: async ({ value, fieldApi }) => { + await new Promise((resolve) => setTimeout(resolve, 100)) + fn() + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + onDynamicAsync: async ({ value, fieldApi }) => { + await new Promise((resolve) => setTimeout(resolve, 100)) + fn() + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }, + }) + + passField.mount() + passconfirmField.mount() + + passField.setValue('one') + await vi.advanceTimersByTimeAsync(100) + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + + passconfirmField.setValue('one') + await vi.advanceTimersByTimeAsync(100) + expect(passconfirmField.getMeta().isValid).toBe(true) + expect(passconfirmField.state.meta.errors).toStrictEqual([]) + + passField.setValue('two') + await vi.advanceTimersByTimeAsync(100) + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + + vi.useRealTimers() + }) + it('should add a new value to the fieldApi errorMap', () => { interface Form { name: string