From 9aabcde8acf5d7c45430ba8fb986614a98cae79f Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 15 Apr 2025 22:44:59 +0200 Subject: [PATCH] refactor(form-core): add FieldApi unit tests for clearValues and filterValues --- packages/form-core/src/FormApi.ts | 43 +++++- packages/form-core/src/metaHelper.ts | 22 +-- packages/form-core/tests/FieldApi.spec.ts | 175 ++++++++++++++++++++++ packages/form-core/tests/FormApi.spec.ts | 26 ++-- 4 files changed, 236 insertions(+), 30 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 951507824..cfdb5c4cf 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1983,6 +1983,9 @@ export class FormApi< this.deleteField(fieldKey as never) } } + + // validate array change + this.validateField(field, 'change') } /** @@ -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 }), @@ -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', + ) + } } /** diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index 3efef37bd..bc78502db 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -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, @@ -46,18 +47,18 @@ export function metaHelper< function handleArrayFieldMetaShift( field: DeepKeys, remainingIndeces: number[], - mode: Extract, + mode: ArrayFieldMode, ): void function handleArrayFieldMetaShift( field: DeepKeys, index: number, - mode: Extract, + mode: ValueFieldMode, secondIndex?: number, ): void function handleArrayFieldMetaShift( field: DeepKeys, index: number | number[], - mode: ArrayFieldMode, + mode: ArrayFieldMode | ValueFieldMode, secondIndex?: number, ) { if (Array.isArray(index)) { @@ -65,9 +66,14 @@ export function metaHelper< 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': @@ -82,8 +88,6 @@ export function metaHelper< secondIndex !== undefined && handleMoveMode(affectedFields, field, index, secondIndex) ) - default: - break } } } @@ -98,7 +102,7 @@ export function metaHelper< function getAffectedFields( field: DeepKeys, index: number, - mode: ArrayFieldMode, + mode: ValueFieldMode, secondIndex?: number, ): DeepKeys[] { const affectedFieldKeys = [getFieldPath(field, index)] diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 8175718f4..10d0dbc98 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -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', () => { @@ -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([]) + }) }) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 87c9234eb..2e215c480 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -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', @@ -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) }) })