diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 18940ac2a..512d25bfb 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1303,6 +1303,27 @@ export class FieldApi< this.triggerOnChangeListener() } + /** + * Filter values in the array using the provided predicate callback. + * @param predicate — The predicate callback to pass to the array's filter function. + * @param opts + */ + filterValues = ( + predicate: ( + value: TData extends Array ? TData[number] : never, + index: number, + array: TData, + ) => boolean, + opts?: UpdateMetaOptions & { + /** `thisArg` — An object to which the `this` keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the `this` value. */ + thisArg?: any + }, + ) => { + this.form.filterFieldValues(this.name, predicate, opts) + + this.triggerOnChangeListener() + } + /** * @private */ diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index eaa5544fe..1d58374b8 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1870,7 +1870,7 @@ export class FormApi< await this.validateField(field, 'change') // Shift down all meta after validating to make sure the new field has been mounted - metaHelper(this).handleArrayFieldMetaShift(field, index, 'insert') + metaHelper(this).handleArrayInsert(field, index) await this.validateArrayFieldsStartingFrom(field, index, 'change') } @@ -1926,7 +1926,7 @@ export class FormApi< ) // Shift up all meta - metaHelper(this).handleArrayFieldMetaShift(field, index, 'remove') + metaHelper(this).handleArrayRemove(field, index) if (lastIndex !== null) { const start = `${field}[${lastIndex}]` @@ -1958,7 +1958,7 @@ export class FormApi< ) // Swap meta - metaHelper(this).handleArrayFieldMetaShift(field, index1, 'swap', index2) + metaHelper(this).handleArraySwap(field, index1, index2) // Validate the whole array this.validateField(field, 'change') @@ -1986,7 +1986,7 @@ export class FormApi< ) // Move meta between index1 and index2 - metaHelper(this).handleArrayFieldMetaShift(field, index1, 'move', index2) + metaHelper(this).handleArrayMove(field, index1, index2) // Validate the whole array this.validateField(field, 'change') @@ -1995,6 +1995,83 @@ export class FormApi< this.validateField(`${field}[${index2}]` as DeepKeys, 'change') } + /** + * Filter the array values by the provided predicate. + * @param field + * @param predicate — The predicate callback to pass to the array's filter function. + * @param opts + */ + filterFieldValues = < + TField extends DeepKeys, + TData extends DeepValue, + >( + field: TField, + predicate: ( + value: TData extends Array ? TData[number] : never, + index: number, + array: TData, + ) => boolean, + opts?: UpdateMetaOptions & { + /** `thisArg` — An object to which the `this` keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the `this` value. */ + thisArg?: any + }, + ) => { + const { thisArg, ...metaOpts } = opts ?? {} + const fieldValue = this.getFieldValue(field) + + 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 = filterFunction(value, index, array) + if (!keepElement) { + // remember the first index that got filtered + arrayData.validateFromIndex ??= index + return false + } + remainingIndeces.push(index) + return true + }), + metaOpts, + ) + + // Shift meta accounting for filtered values + metaHelper(this).handleArrayFilter(field, remainingIndeces) + + // 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', + ) + } + } + /** * Resets the field value and meta to default state */ diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index 8e3e658df..cb287ddf1 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -6,7 +6,7 @@ import type { import type { AnyFieldMeta } from './FieldApi' import type { DeepKeys } from './util-types' -type ArrayFieldMode = 'insert' | 'remove' | 'swap' | 'move' +type ValueFieldMode = 'insert' | 'remove' | 'swap' | 'move' export const defaultFieldMeta: AnyFieldMeta = { isValidating: false, @@ -44,57 +44,203 @@ export function metaHelper< TSubmitMeta >, ) { + /** + * Handle the meta shift caused from moving a field from one index to another. + */ + function handleArrayMove( + field: DeepKeys, + fromIndex: number, + toIndex: number, + ) { + const affectedFields = getAffectedFields(field, fromIndex, 'move', toIndex) + + const startIndex = Math.min(fromIndex, toIndex) + const endIndex = Math.max(fromIndex, toIndex) + for (let i = startIndex; i <= endIndex; i++) { + affectedFields.push(getFieldPath(field, i)) + } + + // Store the original field meta that will be reapplied at the destination index + const fromFields = Object.keys(formApi.fieldInfo).reduce( + (fieldMap, fieldKey) => { + if (fieldKey.startsWith(getFieldPath(field, fromIndex))) { + fieldMap.set( + fieldKey as DeepKeys, + formApi.getFieldMeta(fieldKey as DeepKeys), + ) + } + return fieldMap + }, + new Map, AnyFieldMeta | undefined>(), + ) + + shiftMeta(affectedFields, fromIndex < toIndex ? 'up' : 'down') + + // Reapply the stored field meta at the destination index + Object.keys(formApi.fieldInfo) + .filter((fieldKey) => fieldKey.startsWith(getFieldPath(field, toIndex))) + .forEach((fieldKey) => { + const fromKey = fieldKey.replace( + getFieldPath(field, toIndex), + getFieldPath(field, fromIndex), + ) as DeepKeys + + const fromMeta = fromFields.get(fromKey) + if (fromMeta) { + formApi.setFieldMeta(fieldKey as DeepKeys, fromMeta) + } + }) + } + + /** + * Handle the meta shift from removing a field at the specified index. + */ + function handleArrayRemove(field: DeepKeys, index: number) { + const affectedFields = getAffectedFields(field, index, 'remove') + + shiftMeta(affectedFields, 'up') + } + + /** + * Handle the meta shift from swapping two fields at the specified indeces. + */ + function handleArraySwap( + field: DeepKeys, + index: number, + secondIndex: number, + ) { + const affectedFields = getAffectedFields(field, index, 'swap', secondIndex) + + affectedFields.forEach((fieldKey) => { + if (!fieldKey.toString().startsWith(getFieldPath(field, index))) { + return + } + + const swappedKey = fieldKey + .toString() + .replace( + getFieldPath(field, index), + getFieldPath(field, secondIndex), + ) as DeepKeys + + const [meta1, meta2] = [ + formApi.getFieldMeta(fieldKey), + formApi.getFieldMeta(swappedKey), + ] + + if (meta1) formApi.setFieldMeta(swappedKey, meta1) + if (meta2) formApi.setFieldMeta(fieldKey, meta2) + }) + } + + /** + * Handle the meta shift from inserting a field at the specified index. + */ + function handleArrayInsert(field: DeepKeys, insertIndex: number) { + const affectedFields = getAffectedFields(field, insertIndex, 'insert') + + shiftMeta(affectedFields, 'down') + + affectedFields.forEach((fieldKey) => { + if (fieldKey.toString().startsWith(getFieldPath(field, insertIndex))) { + formApi.setFieldMeta(fieldKey, getEmptyFieldMeta()) + } + }) + } + + /** + * @deprecated Use the explicit functions of the metaHelper instead. + */ function handleArrayFieldMetaShift( field: DeepKeys, index: number, - mode: ArrayFieldMode, + mode: ValueFieldMode, secondIndex?: number, ) { - const affectedFields = getAffectedFields(field, index, mode, secondIndex) - - const handlers = { - insert: () => handleInsertMode(affectedFields, field, index), - remove: () => handleRemoveMode(affectedFields), - swap: () => - secondIndex !== undefined && - handleSwapMode(affectedFields, field, index, secondIndex), - move: () => - secondIndex !== undefined && - handleMoveMode(affectedFields, field, index, secondIndex), + switch (mode) { + case 'insert': + return handleArrayInsert(field, index) + case 'remove': + return handleArrayRemove(field, index) + case 'swap': + return ( + secondIndex !== undefined && + handleArraySwap(field, index, secondIndex) + ) + case 'move': + return ( + secondIndex !== undefined && + handleArrayMove(field, index, secondIndex) + ) } + } - handlers[mode]() + /** + * Handle the meta shift from filtering out indeces. + * @param remainingIndices An array of indeces that were NOT filtered out of the original array. + */ + function handleArrayFilter( + field: DeepKeys, + remainingIndices: number[], + ) { + if (remainingIndices.length === 0) return + + // create a map between the index and its new location + remainingIndices.forEach((fromIndex, toIndex) => { + if (fromIndex === toIndex) return + // assign it the original meta + const fieldKey = getFieldPath(field, toIndex) + const originalFieldKey = getFieldPath(field, fromIndex) + const originalFieldMeta = formApi.getFieldMeta(originalFieldKey) + if (originalFieldMeta) { + formApi.setFieldMeta(fieldKey, originalFieldMeta) + } else { + formApi.setFieldMeta(fieldKey, { + ...getEmptyFieldMeta(), + isTouched: originalFieldKey as unknown as boolean, + }) + } + }) } - function getFieldPath(field: DeepKeys, index: number): string { - return `${field}[${index}]` + function getFieldPath( + field: DeepKeys, + index: number, + ): DeepKeys { + return `${field}[${index}]` as DeepKeys } function getAffectedFields( field: DeepKeys, index: number, - mode: ArrayFieldMode, + mode: ValueFieldMode, secondIndex?: number, ): DeepKeys[] { const affectedFieldKeys = [getFieldPath(field, index)] - if (mode === 'swap') { - affectedFieldKeys.push(getFieldPath(field, secondIndex!)) - } else if (mode === 'move') { - const [startIndex, endIndex] = [ - Math.min(index, secondIndex!), - Math.max(index, secondIndex!), - ] - for (let i = startIndex; i <= endIndex; i++) { - affectedFieldKeys.push(getFieldPath(field, i)) + switch (mode) { + case 'swap': + affectedFieldKeys.push(getFieldPath(field, secondIndex!)) + break + case 'move': { + const [startIndex, endIndex] = [ + Math.min(index, secondIndex!), + Math.max(index, secondIndex!), + ] + for (let i = startIndex; i <= endIndex; i++) { + affectedFieldKeys.push(getFieldPath(field, i)) + } + break } - } else { - const currentValue = formApi.getFieldValue(field) - const fieldItems = Array.isArray(currentValue) - ? (currentValue as Array).length - : 0 - for (let i = index + 1; i < fieldItems; i++) { - affectedFieldKeys.push(getFieldPath(field, i)) + default: { + const currentValue = formApi.getFieldValue(field) + const fieldItems = Array.isArray(currentValue) + ? (currentValue as Array).length + : 0 + for (let i = index + 1; i < fieldItems; i++) { + affectedFieldKeys.push(getFieldPath(field, i)) + } + break } } @@ -131,85 +277,12 @@ export function metaHelper< const getEmptyFieldMeta = (): AnyFieldMeta => defaultFieldMeta - const handleInsertMode = ( - fields: DeepKeys[], - field: DeepKeys, - insertIndex: number, - ) => { - shiftMeta(fields, 'down') - - fields.forEach((fieldKey) => { - if (fieldKey.toString().startsWith(getFieldPath(field, insertIndex))) { - formApi.setFieldMeta(fieldKey, getEmptyFieldMeta()) - } - }) - } - - const handleRemoveMode = (fields: DeepKeys[]) => { - shiftMeta(fields, 'up') - } - - const handleMoveMode = ( - fields: DeepKeys[], - field: DeepKeys, - fromIndex: number, - toIndex: number, - ) => { - // Store the original field meta that will be reapplied at the destination index - const fromFields = new Map( - Object.keys(formApi.fieldInfo) - .filter((fieldKey) => - fieldKey.startsWith(getFieldPath(field, fromIndex)), - ) - .map((fieldKey) => [ - fieldKey as DeepKeys, - formApi.getFieldMeta(fieldKey as DeepKeys), - ]), - ) - - shiftMeta(fields, fromIndex < toIndex ? 'up' : 'down') - - // Reapply the stored field meta at the destination index - Object.keys(formApi.fieldInfo) - .filter((fieldKey) => fieldKey.startsWith(getFieldPath(field, toIndex))) - .forEach((fieldKey) => { - const fromKey = fieldKey.replace( - getFieldPath(field, toIndex), - getFieldPath(field, fromIndex), - ) as DeepKeys - - const fromMeta = fromFields.get(fromKey) - if (fromMeta) { - formApi.setFieldMeta(fieldKey as DeepKeys, fromMeta) - } - }) + return { + handleArrayFieldMetaShift, + handleArrayMove, + handleArrayRemove, + handleArraySwap, + handleArrayInsert, + handleArrayFilter, } - - const handleSwapMode = ( - fields: DeepKeys[], - field: DeepKeys, - index: number, - secondIndex: number, - ) => { - fields.forEach((fieldKey) => { - if (!fieldKey.toString().startsWith(getFieldPath(field, index))) return - - const swappedKey = fieldKey - .toString() - .replace( - getFieldPath(field, index), - getFieldPath(field, secondIndex), - ) as DeepKeys - - const [meta1, meta2] = [ - formApi.getFieldMeta(fieldKey), - formApi.getFieldMeta(swappedKey), - ] - - if (meta1) formApi.setFieldMeta(swappedKey, meta1) - if (meta2) formApi.setFieldMeta(fieldKey, meta2) - }) - } - - return { handleArrayFieldMetaShift } } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index a9fb317bf..74f67fd40 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1197,6 +1197,9 @@ describe('field api', () => { field.moveValue(0, 1) expect(arr).toStrictEqual(['middle', 'end', 'start']) + + field.filterValues((value) => value !== 'start') + expect(arr).toStrictEqual(['middle', 'end']) }) it('should reset the form on a listener', () => { @@ -2054,6 +2057,122 @@ describe('field api', () => { }).not.toThrowError() }) + 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([]) + }) + it('should update submission meta when calling handleSubmit', async () => { let doError = true let externalAttemptsCounter = 0 diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index e2631c436..7420dab2c 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -3008,6 +3008,108 @@ describe('form api', () => { form.parseValuesWithSchemaAsync(z.any()) }).not.toThrowError() }) + + it('should filter fields when calling filterFieldValues', () => { + const form = new FormApi({ + defaultValues: { + items: ['a', 'b', 'c', 'd', 'e', 'f'], + }, + }) + + const fields = new Array(5) + .fill(0) + .map((_, i) => new FieldApi({ form, name: `items[${i}]` })) + + form.mount() + fields.forEach((field) => field.mount()) + + // Ensure the initial state has the fields + expect(form.getFieldValue('items')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) + + // Reset the field to an empty array + form.filterFieldValues('items', (v) => + [ + 'a', + 'b', + // 'c', + 'd', + // 'e', + 'f', + ].includes(v), + ) + + // Verify that the field value is now an empty array + expect(form.getFieldValue('items')).toEqual(['a', 'b', 'd', 'f']) + }) + + it('should shift meta when calling filterFieldValues', () => { + const form = new FormApi({ + defaultValues: { + items: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], + }, + }) + + const fields = new Array(10) + .fill(null) + .map((_, i) => new FieldApi({ form, name: `items[${i}]` })) + + form.mount() + fields.forEach((field) => field.mount()) + + /* + Before the filter, achieve the following touched state: + [ 'items[0]', 'a', false ], + [ 'items[1]', 'b', true ], + [ 'items[2]', 'c', false ], + [ 'items[3]', 'd', true ], + [ 'items[4]', 'e', false ], + [ 'items[5]', 'f', false ], + [ 'items[6]', 'g', true ], + [ 'items[7]', 'h', false ], + [ 'items[8]', 'i', true ], + [ 'items[9]', 'j', false ] + */ + function blurField(index: number) { + form.setFieldMeta(`items[${index}]`, (prev) => ({ + ...prev, + isBlurred: true, + })) + } + blurField(1) + blurField(3) + blurField(6) + blurField(8) + + form.filterFieldValues( + 'items', + (letter) => !['c', 'f', 'g'].includes(letter), + ) + + /** + The resulting array should now be + [ 'items[0]', 'a', false ], + [ 'items[1]', 'b', true ], + [ 'items[2]', 'd', true ], + [ 'items[3]', 'e', false ], + [ 'items[4]', 'h', false ], + [ 'items[5]', 'i', true ], + [ 'items[6]', 'j', false ], + */ + expect(form.getFieldValue(`items[${0}]`)).toBe('a') + expect(form.getFieldValue(`items[${1}]`)).toBe('b') + expect(form.getFieldValue(`items[${2}]`)).toBe('d') + expect(form.getFieldValue(`items[${3}]`)).toBe('e') + 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}]`)?.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) + }) }) it('should reset the errorSourceMap for the field when the form is reset', () => {