Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/array field validation #700

Merged
merged 19 commits into from
Jun 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
22868fb
fix(FormApi): missing onChange validation after array field helper me…
gutentag2012 May 12, 2024
7afd72a
test(FormApi): onChange validation after array field helper methods +…
gutentag2012 May 12, 2024
f4508c8
style(FormApi): Run linter
gutentag2012 May 12, 2024
4aaecd9
feat(FormApi): add helper function to validate a single field on a form
gutentag2012 May 12, 2024
c679d6f
test(FieldApi): onChange validation after array field helper methods
gutentag2012 May 12, 2024
e773f98
test(FieldApi): single field validation
gutentag2012 May 12, 2024
cdc1fde
style: Run linter
gutentag2012 May 12, 2024
4759964
docs(FormApi): add `validateField` to references documentation
gutentag2012 May 12, 2024
184a2e5
chore: Remove log + only test
gutentag2012 May 20, 2024
5989407
Merge main into fix/array-field-validation
gutentag2012 May 30, 2024
54867b5
chore: fix failing tests after merge
gutentag2012 May 30, 2024
c87b69f
chore(core): prevent field validation from forcing the `isTouched` state
gutentag2012 May 31, 2024
de8d8aa
chore(core): fix tests
gutentag2012 May 31, 2024
7c92b8d
chore(core): remove redundant async notation
gutentag2012 Jun 2, 2024
dbeea0f
chore(core): touch fields during validation again
gutentag2012 Jun 2, 2024
6412d2d
chore(core): add method to validate array fields sub-fields from a gi…
gutentag2012 Jun 2, 2024
72816d6
fix(core): validate array + array subfields when using helper functions
gutentag2012 Jun 2, 2024
60a2018
test(core): adjust test to fixes in array helper function validation
gutentag2012 Jun 2, 2024
43dff36
docs(core): add ref to new `validateArrayFieldsStartingFrom` function
gutentag2012 Jun 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/reference/formApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ A class representing the Form API. It handles the logic and interactions with th
validateAllFields(cause: ValidationCause): Promise<ValidationError[]>
```
- Validates all fields in the form using the correct handlers for a given validation type.
- ```tsx
validateArrayFieldsStartingFrom<TField extends DeepKeys<TFormData>>(field: TField, index: number, cause: ValidationCause): ValidationError[] | Promise<ValidationError[]>
```
- Validates the children of a specified array in the form starting from a given index until the end using the correct handlers for a given validation type.
- ```tsx
validateField<TField extends DeepKeys<TFormData>>(field: TField, cause: ValidationCause): ValidationError[] | Promise<ValidationError[]>
```
- Validates a specified field in the form using the correct handlers for a given validation type.

- ```tsx
handleSubmit()
Expand Down
5 changes: 2 additions & 3 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,8 @@ export class FieldApi<
value: TData extends any[] ? TData[number] : never,
) => this.form.insertFieldValue(this.name, index, value as any)

removeValue = async (index: number, opts?: { touch: boolean }) => {
await this.form.removeFieldValue(this.name, index, opts)
}
removeValue = (index: number, opts?: { touch: boolean }) =>
this.form.removeFieldValue(this.name, index, opts)

swapValues = (aIndex: number, bIndex: number) =>
this.form.swapFieldValues(this.name, aIndex, bIndex)
Expand Down
78 changes: 75 additions & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,59 @@
return fieldErrorMapMap.flat()
}

validateArrayFieldsStartingFrom = async <TField extends DeepKeys<TFormData>>(
field: TField,
index: number,
cause: ValidationCause,
) => {
const currentValue = this.getFieldValue(field)

const lastIndex = Array.isArray(currentValue)
? Math.max(currentValue.length - 1, 0)
: null

Check warning on line 382 in packages/form-core/src/FormApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FormApi.ts#L382

Added line #L382 was not covered by tests

// We have to validate all fields that have shifted (at least the current field)
const fieldKeysToValidate = [`${field}[${index}]`]
for (let i = index + 1; i <= (lastIndex ?? 0); i++) {
fieldKeysToValidate.push(`${field}[${i}]`)
}

// We also have to include all fields that are nested in the shifted fields
const fieldsToValidate = Object.keys(this.fieldInfo).filter((fieldKey) =>
fieldKeysToValidate.some((key) => fieldKey.startsWith(key)),
) as DeepKeys<TFormData>[]

// Validate the fields
const fieldValidationPromises: Promise<ValidationError[]>[] = [] as any
this.store.batch(() => {
fieldsToValidate.forEach((nestedField) => {
fieldValidationPromises.push(
Promise.resolve().then(() => this.validateField(nestedField, cause)),
)
})
})

const fieldErrorMapMap = await Promise.all(fieldValidationPromises)
return fieldErrorMapMap.flat()
}

validateField = <TField extends DeepKeys<TFormData>>(
field: TField,
cause: ValidationCause,
) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const fieldInstance = this.fieldInfo[field]?.instance
if (!fieldInstance) return []

// If the field is not touched (same logic as in validateAllFields)
if (!fieldInstance.state.meta.isTouched) {
// Mark it as touched
fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true }))
}

return fieldInstance.validate(cause)
}

// TODO: This code is copied from FieldApi, we should refactor to share
validateSync = (cause: ValidationCause) => {
const validates = getSyncValidatorArray(cause, this.options)
Expand Down Expand Up @@ -689,14 +742,15 @@
: never,
opts?: { touch?: boolean },
) => {
return this.setFieldValue(
this.setFieldValue(
field,
(prev) => [...(Array.isArray(prev) ? prev : []), value] as any,
opts,
)
this.validateField(field, 'change')
}

insertFieldValue = <TField extends DeepKeys<TFormData>>(
insertFieldValue = async <TField extends DeepKeys<TFormData>>(
field: TField,
index: number,
value: DeepValue<TFormData, TField> extends any[]
Expand All @@ -713,6 +767,10 @@
},
opts,
)

// Validate the whole array + all fields that have shifted
await this.validateField(field, 'change')
await this.validateArrayFieldsStartingFrom(field, index, 'change')
}

removeFieldValue = async <TField extends DeepKeys<TFormData>>(
Expand Down Expand Up @@ -746,7 +804,9 @@
fieldsToDelete.forEach((f) => this.deleteField(f as TField))
}

await this.validateAllFields('change')
// Validate the whole array + all fields that have shifted
await this.validateField(field, 'change')
await this.validateArrayFieldsStartingFrom(field, index, 'change')
}

swapFieldValues = <TField extends DeepKeys<TFormData>>(
Expand All @@ -759,6 +819,12 @@
const prev2 = prev[index2]!
return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1)
})

// Validate the whole array
this.validateField(field, 'change')
// Validate the swapped fields
this.validateField(`${field}[${index1}]` as DeepKeys<TFormData>, 'change')
this.validateField(`${field}[${index2}]` as DeepKeys<TFormData>, 'change')
}

moveFieldValues = <TField extends DeepKeys<TFormData>>(
Expand All @@ -770,6 +836,12 @@
prev.splice(index2, 0, prev.splice(index1, 1)[0])
return prev
})

// Validate the whole array
this.validateField(field, 'change')
// Validate the moved fields
this.validateField(`${field}[${index1}]` as DeepKeys<TFormData>, 'change')
this.validateField(`${field}[${index2}]` as DeepKeys<TFormData>, 'change')
}
}

Expand Down
157 changes: 157 additions & 0 deletions packages/form-core/src/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,35 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['one', 'other'])
})

it('should run onChange validation when pushing an array fields value', async () => {
const form = new FormApi({
defaultValues: {
names: ['test'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
})
field.mount()

field.pushValue('other')

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should insert a value into an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand All @@ -124,6 +153,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['one', 'other'])
})

it('should run onChange validation when inserting an array fields value', () => {
const form = new FormApi({
defaultValues: {
names: ['test'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

field.insertValue(1, 'other')

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should remove a value from an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand All @@ -141,6 +202,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['one'])
})

it('should run onChange validation when removing an array fields value', async () => {
const form = new FormApi({
defaultValues: {
names: ['test'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

await field.removeValue(0)

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should remove a subfield from an array field correctly', async () => {
const form = new FormApi({
defaultValues: {
Expand Down Expand Up @@ -269,6 +362,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['two', 'one'])
})

it('should run onChange validation when swapping an array fields value', () => {
const form = new FormApi({
defaultValues: {
names: ['test', 'test2'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

field.swapValues(0, 1)

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should move a value from an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand All @@ -286,6 +411,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['three', 'one', 'two', 'four'])
})

it('should run onChange validation when moving an array fields value', () => {
const form = new FormApi({
defaultValues: {
names: ['test', 'test2'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

field.moveValue(0, 1)

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should not throw errors when no meta info is stored on a field and a form re-renders', async () => {
const form = new FormApi({
defaultValues: {
Expand Down