Skip to content
Merged
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
43 changes: 35 additions & 8 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1983,6 +1983,9 @@ export class FormApi<
this.deleteField(fieldKey as never)
}
}

// validate array change
this.validateField(field, 'change')
}

/**
Expand All @@ -2007,17 +2010,28 @@ export class FormApi<
const { thisArg, ...metaOpts } = opts ?? {}
const fieldValue = this.getFieldValue(field)

const previousLength = Array.isArray(fieldValue)
? (fieldValue as unknown[]).length
: null
const arrayData = {
previousLength: Array.isArray(fieldValue)
? (fieldValue as unknown[]).length
: null,
validateFromIndex: null as number | null,
}

const remainingIndeces: number[] = []

const filterFunction =
opts?.thisArg === undefined ? predicate : predicate.bind(opts.thisArg)

this.setFieldValue(
field,
(prev: any) =>
prev.filter((value: any, index: number, array: TData) => {
const keepElement = predicate.bind(opts?.thisArg)(value, index, array)
if (!keepElement) return false
const keepElement = filterFunction(value, index, array)
if (!keepElement) {
// remember the first index that got filtered
arrayData.validateFromIndex ??= index
return false
}
remainingIndeces.push(index)
return true
}),
Expand All @@ -2031,13 +2045,26 @@ export class FormApi<
'filter',
)

// remove dangling fields from the filtered array's last index to the unfiltered last index
if (previousLength !== null && remainingIndeces.length !== previousLength) {
for (let i = remainingIndeces.length; i < previousLength; i++) {
// remove dangling fields if the filter call reduced the length of the array
if (
arrayData.previousLength !== null &&
remainingIndeces.length !== arrayData.previousLength
) {
for (let i = remainingIndeces.length; i < arrayData.previousLength; i++) {
const fieldKey = `${field}[${i}]`
this.deleteField(fieldKey as never)
}
}

// validate the array and the fields starting from the shifted elements
this.validateField(field, 'change')
if (arrayData.validateFromIndex !== null) {
this.validateArrayFieldsStartingFrom(
field,
arrayData.validateFromIndex,
'change',
)
}
}

/**
Expand Down
22 changes: 13 additions & 9 deletions packages/form-core/src/metaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type {
import type { AnyFieldMeta } from './FieldApi'
import type { DeepKeys } from './util-types'

type ArrayFieldMode = 'insert' | 'remove' | 'swap' | 'move' | 'filter'
type ValueFieldMode = 'insert' | 'remove' | 'swap' | 'move'
type ArrayFieldMode = 'filter'

export const defaultFieldMeta: AnyFieldMeta = {
isValidating: false,
Expand Down Expand Up @@ -46,28 +47,33 @@ export function metaHelper<
function handleArrayFieldMetaShift(
field: DeepKeys<TFormData>,
remainingIndeces: number[],
mode: Extract<ArrayFieldMode, 'filter'>,
mode: ArrayFieldMode,
): void
function handleArrayFieldMetaShift(
field: DeepKeys<TFormData>,
index: number,
mode: Extract<ArrayFieldMode, 'insert' | 'remove' | 'swap' | 'move'>,
mode: ValueFieldMode,
secondIndex?: number,
): void
function handleArrayFieldMetaShift(
field: DeepKeys<TFormData>,
index: number | number[],
mode: ArrayFieldMode,
mode: ArrayFieldMode | ValueFieldMode,
secondIndex?: number,
) {
if (Array.isArray(index)) {
if (mode === 'filter') {
return handleFilterMode(field, index)
}
} else {
const affectedFields = getAffectedFields(field, index, mode, secondIndex)
const affectedFields = getAffectedFields(
field,
index,
mode as ValueFieldMode,
secondIndex,
)

switch (mode) {
switch (mode as ValueFieldMode) {
case 'insert':
return handleInsertMode(affectedFields, field, index)
case 'remove':
Expand All @@ -82,8 +88,6 @@ export function metaHelper<
secondIndex !== undefined &&
handleMoveMode(affectedFields, field, index, secondIndex)
)
default:
break
}
}
}
Expand All @@ -98,7 +102,7 @@ export function metaHelper<
function getAffectedFields(
field: DeepKeys<TFormData>,
index: number,
mode: ArrayFieldMode,
mode: ValueFieldMode,
secondIndex?: number,
): DeepKeys<TFormData>[] {
const affectedFieldKeys = [getFieldPath(field, index)]
Expand Down
175 changes: 175 additions & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,12 @@ describe('field api', () => {

field.moveValue(0, 1)
expect(arr).toStrictEqual(['middle', 'end', 'start'])

field.filterValues((value) => value !== 'start')
expect(arr).toStrictEqual(['middle', 'end'])

field.clearValues()
expect(arr).toStrictEqual([])
})

it('should reset the form on a listener', () => {
Expand Down Expand Up @@ -1890,4 +1896,173 @@ describe('field api', () => {
expect(field.getMeta().errors).toStrictEqual([])
expect(form.state.canSubmit).toBe(true)
})

it('should clear all values in an array when calling clearValues', async () => {
const form = new FormApi({
defaultValues: {
names: ['one', 'two'],
},
})

form.mount()

const field = new FieldApi({
form,
name: 'names',
})

field.mount()

field.clearValues()

expect(field.state.value).toStrictEqual([])
})

it('should run onChange validators when calling clearValues', async () => {
const form = new FormApi({
defaultValues: {
names: ['test', 'test2'],
},
})
form.mount()

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

field.clearValues()

expect(field.getMeta().errors).toStrictEqual([
'At least 1 name is required',
])
})

it('should filter array values using the predicate when calling filterValues', async () => {
const form = new FormApi({
defaultValues: {
names: ['one', 'two', 'three'],
},
})

form.mount()

const field = new FieldApi({
form,
name: 'names',
})

field.mount()

field.filterValues((value) => value !== 'two')
expect(field.state.value).toStrictEqual(['one', 'three'])

field.filterValues((value) => value !== 'never')
expect(field.state.value).toStrictEqual(['one', 'three'])
})

it('should bind the predicate to the provided thisArg when calling filterValues', async () => {
// Very dirty way, but quick way to enforce lost `this` context
function SomeClass(this: any) {
this.check = 'correct this'
}
SomeClass.prototype.filterFunc = function () {
return this?.check === 'correct this'
}
// @ts-expect-error The 'new' expression expects class stuff, but
// we're trying to force ugly code in this unit test.
const instance = new SomeClass()

const predicate = instance.filterFunc

const form = new FormApi({
defaultValues: {
names: ['one', 'two', 'three'],
},
})

const field = new FieldApi({
form,
name: 'names',
})

form.mount()
field.mount()

field.filterValues(predicate, { thisArg: instance })
// thisArg was bound, expect it to have returned true
expect(field.state.value).toStrictEqual(['one', 'two', 'three'])
field.filterValues(predicate)
// thisArg wasn't bound, expect it to have returned false
expect(field.state.value).toStrictEqual([])
})

it('should run onChange validation on the array when calling filterValues', async () => {
vi.useFakeTimers()
const form = new FormApi({
defaultValues: {
names: ['one', 'two', 'three', 'four', 'five'],
},
})
form.mount()
function getField(i: number) {
return new FieldApi({
name: `names[${i}]`,
form,
validators: {
onChange: () => 'error',
},
})
}

const arrayField = new FieldApi({
form,
name: 'names',
validators: {
onChange: () => 'error',
},
})
arrayField.mount()

const field0 = getField(0)
const field1 = getField(1)
const field2 = getField(2)
const field3 = getField(3)
const field4 = getField(4)
field0.mount()
field1.mount()
field2.mount()
field3.mount()
field4.mount()

arrayField.filterValues((value) => value !== 'three')
// validating fields is separate from filterValues and done with a promise,
// so make sure they resolve first
await vi.runAllTimersAsync()

expect(arrayField.getMeta().errors).toStrictEqual(['error'])

// field 0 and 1 weren't shifted, so they shouldn't trigger validation
expect(field0.getMeta().errors).toStrictEqual([])
expect(field1.getMeta().errors).toStrictEqual([])

// but the following fields were shifted
expect(field2.getMeta().errors).toStrictEqual(['error'])
expect(field3.getMeta().errors).toStrictEqual(['error'])

// field4 no longer exists, so it shouldn't have errors
expect(field4.getMeta().errors).toStrictEqual([])
})
})
26 changes: 13 additions & 13 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2990,16 +2990,16 @@ describe('form api', () => {
[ 'items[8]', 'i', true ],
[ 'items[9]', 'j', false ]
*/
function touchField(index: number) {
function blurField(index: number) {
form.setFieldMeta(`items[${index}]`, (prev) => ({
...prev,
isTouched: true,
isBlurred: true,
}))
}
touchField(1)
touchField(3)
touchField(6)
touchField(8)
blurField(1)
blurField(3)
blurField(6)
blurField(8)

form.filterFieldValues(
'items',
Expand All @@ -3023,12 +3023,12 @@ describe('form api', () => {
expect(form.getFieldValue(`items[${4}]`)).toBe('h')
expect(form.getFieldValue(`items[${5}]`)).toBe('i')
expect(form.getFieldValue(`items[${6}]`)).toBe('j')
expect(form.getFieldMeta(`items[${0}]`)?.isTouched).toBe(false)
expect(form.getFieldMeta(`items[${1}]`)?.isTouched).toBe(true)
expect(form.getFieldMeta(`items[${2}]`)?.isTouched).toBe(true)
expect(form.getFieldMeta(`items[${3}]`)?.isTouched).toBe(false)
expect(form.getFieldMeta(`items[${4}]`)?.isTouched).toBe(false)
expect(form.getFieldMeta(`items[${5}]`)?.isTouched).toBe(true)
expect(form.getFieldMeta(`items[${6}]`)?.isTouched).toBe(false)
expect(form.getFieldMeta(`items[${0}]`)?.isBlurred).toBe(false)
expect(form.getFieldMeta(`items[${1}]`)?.isBlurred).toBe(true)
expect(form.getFieldMeta(`items[${2}]`)?.isBlurred).toBe(true)
expect(form.getFieldMeta(`items[${3}]`)?.isBlurred).toBe(false)
expect(form.getFieldMeta(`items[${4}]`)?.isBlurred).toBe(false)
expect(form.getFieldMeta(`items[${5}]`)?.isBlurred).toBe(true)
expect(form.getFieldMeta(`items[${6}]`)?.isBlurred).toBe(false)
})
})
Loading