Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions examples/react/simple/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export default function App() {
defaultValues: {
firstName: '',
lastName: '',
password: '',
confirmPassword: '',
},
onSubmit: async ({ value }) => {
// Do something with form data
Expand Down Expand Up @@ -95,6 +97,59 @@ export default function App() {
)}
/>
</div>
<div>
<form.Field
name="password"
validators={{
onChange: ({ value }) =>
!value
? 'A password is required'
: value.length < 6
? 'Password must be at least 6 characters'
: undefined,
}}
children={(field) => (
<>
<label htmlFor={field.name}>Password:</label>
<input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<FieldInfo field={field} />
</>
)}
/>
</div>
<div>
<form.Field
name="confirmPassword"
validators={{
onDynamic: ({ value, fieldApi }) => {
const password = fieldApi.form.getFieldValue('password')
return value !== password ? 'Passwords must match' : undefined
},
onDynamicListenTo: ['password'],
}}
children={(field) => (
<>
<label htmlFor={field.name}>Confirm Password:</label>
<input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<FieldInfo field={field} />
</>
)}
/>
</div>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
Expand Down
96 changes: 79 additions & 17 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TParentData>[]
}

export interface FieldListeners<
Expand Down Expand Up @@ -1362,6 +1366,23 @@ export class FieldApi<
}

if (!options?.dontValidate) {
const dynamicErrKey = getErrorMapKey('dynamic')
if (
this.state.meta.errorMap[dynamicErrKey] &&
this.state.meta.errorSourceMap[dynamicErrKey] === 'field'
) {
this.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[dynamicErrKey]: undefined,
},
errorSourceMap: {
...prev.errorSourceMap,
[dynamicErrKey]: undefined,
},
}))
}
this.validate('change')
}
}
Expand Down Expand Up @@ -1533,16 +1554,25 @@ export class FieldApi<
getLinkedFields = (cause: ValidationCause) => {
const fields = Object.values(this.form.fieldInfo) as FieldInfo<any>[]

const linkedFields: AnyFieldApi[] = []
const linkedFields: Array<{
field: AnyFieldApi
validatorCause: ValidationCause
}> = []
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: 'dynamic' })
}
}

Expand All @@ -1565,8 +1595,8 @@ export class FieldApi<

const linkedFields = this.getLinkedFields(cause)
const linkedFieldValidates = linkedFields.reduce(
(acc, field) => {
const fieldValidates = getSyncValidatorArray(cause, {
(acc, { field, validatorCause }) => {
const fieldValidates = getSyncValidatorArray(validatorCause, {
...field.options,
form: field.form,
validationLogic:
Expand Down Expand Up @@ -1638,9 +1668,25 @@ 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
if (fieldValidateObj.cause === 'dynamic') {
const dynamicErrKey = getErrorMapKey('dynamic')
if (fieldValidateObj.field.state.meta.errorMap[dynamicErrKey]) {
fieldValidateObj.field.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[dynamicErrKey]: undefined,
},
errorSourceMap: {
...prev.errorSourceMap,
[dynamicErrKey]: undefined,
},
}))
}
}
validateFieldFn(fieldValidateObj.field, fieldValidateObj)
}
})

Expand Down Expand Up @@ -1704,8 +1750,8 @@ export class FieldApi<

const linkedFields = this.getLinkedFields(cause)
const linkedFieldValidates = linkedFields.reduce(
(acc, field) => {
const fieldValidates = getAsyncValidatorArray(cause, {
(acc, { field, validatorCause }) => {
const fieldValidates = getAsyncValidatorArray(validatorCause, {
...field.options,
form: field.form,
validationLogic:
Expand All @@ -1727,7 +1773,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 }))
}

Expand Down Expand Up @@ -1825,11 +1871,27 @@ 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
if (fieldValidateObj.cause === 'dynamic') {
const dynamicErrKey = getErrorMapKey('dynamic')
if (fieldValidateObj.field.state.meta.errorMap[dynamicErrKey]) {
fieldValidateObj.field.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
[dynamicErrKey]: undefined,
},
errorSourceMap: {
...prev.errorSourceMap,
[dynamicErrKey]: undefined,
},
}))
}
}
validateFieldAsyncFn(
fieldValitateObj.field,
fieldValitateObj,
fieldValidateObj.field,
fieldValidateObj,
linkedPromises,
)
}
Expand All @@ -1842,7 +1904,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 }))
}

Expand Down
14 changes: 13 additions & 1 deletion packages/form-core/src/ValidationLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
}
Expand Down
Loading
Loading