Skip to content

Commit 506523e

Browse files
feat(form-core): add array method field.filterValues and form.filterFieldValues
Co-authored-by: LeCarbonator <[email protected]>
1 parent b5ea568 commit 506523e

File tree

5 files changed

+395
-18
lines changed

5 files changed

+395
-18
lines changed

packages/form-core/src/FieldApi.ts

+21
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,27 @@ export class FieldApi<
12881288
this.triggerOnChangeListener()
12891289
}
12901290

1291+
/**
1292+
* Filter values in the array using the provided predicate callback.
1293+
* @param predicate — The predicate callback to pass to the array's filter function.
1294+
* @param opts
1295+
*/
1296+
filterValues = (
1297+
predicate: (
1298+
value: TData extends Array<any> ? TData[number] : never,
1299+
index: number,
1300+
array: TData,
1301+
) => boolean,
1302+
opts?: UpdateMetaOptions & {
1303+
/** `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. */
1304+
thisArg?: any
1305+
},
1306+
) => {
1307+
this.form.filterFieldValues(this.name, predicate, opts)
1308+
1309+
this.triggerOnChangeListener()
1310+
}
1311+
12911312
/**
12921313
* @private
12931314
*/

packages/form-core/src/FormApi.ts

+81
Original file line numberDiff line numberDiff line change
@@ -1962,6 +1962,87 @@ export class FormApi<
19621962
this.validateField(`${field}[${index2}]` as DeepKeys<TFormData>, 'change')
19631963
}
19641964

1965+
/**
1966+
* Filter the array values by the provided predicate.
1967+
* @param field
1968+
* @param predicate — The predicate callback to pass to the array's filter function.
1969+
* @param opts
1970+
*/
1971+
filterFieldValues = <
1972+
TField extends DeepKeys<TFormData>,
1973+
TData extends DeepValue<TFormData, TField>,
1974+
>(
1975+
field: TField,
1976+
predicate: (
1977+
value: TData extends Array<any> ? TData[number] : never,
1978+
index: number,
1979+
array: TData,
1980+
) => boolean,
1981+
opts?: UpdateMetaOptions & {
1982+
/** `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. */
1983+
thisArg?: any
1984+
},
1985+
) => {
1986+
const { thisArg, ...metaOpts } = opts ?? {}
1987+
const fieldValue = this.getFieldValue(field)
1988+
1989+
const arrayData = {
1990+
previousLength: Array.isArray(fieldValue)
1991+
? (fieldValue as unknown[]).length
1992+
: null,
1993+
validateFromIndex: null as number | null,
1994+
}
1995+
1996+
const remainingIndeces: number[] = []
1997+
1998+
const filterFunction =
1999+
opts?.thisArg === undefined ? predicate : predicate.bind(opts.thisArg)
2000+
2001+
this.setFieldValue(
2002+
field,
2003+
(prev: any) =>
2004+
prev.filter((value: any, index: number, array: TData) => {
2005+
const keepElement = filterFunction(value, index, array)
2006+
if (!keepElement) {
2007+
// remember the first index that got filtered
2008+
arrayData.validateFromIndex ??= index
2009+
return false
2010+
}
2011+
remainingIndeces.push(index)
2012+
return true
2013+
}),
2014+
metaOpts,
2015+
)
2016+
2017+
// Shift meta accounting for filtered values
2018+
metaHelper(this).handleArrayFieldMetaShift(
2019+
field,
2020+
remainingIndeces,
2021+
'filter',
2022+
)
2023+
2024+
// remove dangling fields if the filter call reduced the length of the array
2025+
if (
2026+
arrayData.previousLength !== null &&
2027+
remainingIndeces.length !== arrayData.previousLength
2028+
) {
2029+
for (let i = remainingIndeces.length; i < arrayData.previousLength; i++) {
2030+
const fieldKey = `${field}[${i}]`
2031+
this.deleteField(fieldKey as never)
2032+
}
2033+
}
2034+
2035+
// validate the array and the fields starting from the shifted elements
2036+
this.validateField(field, 'change')
2037+
if (arrayData.validateFromIndex !== null) {
2038+
this.validateArrayFieldsStartingFrom(
2039+
field,
2040+
arrayData.validateFromIndex,
2041+
'change',
2042+
)
2043+
}
2044+
}
2045+
19652046
/**
19662047
* Resets the field value and meta to default state
19672048
*/

packages/form-core/src/metaHelper.ts

+72-18
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import type {
66
import type { AnyFieldMeta } from './FieldApi'
77
import type { DeepKeys } from './util-types'
88

9-
type ArrayFieldMode = 'insert' | 'remove' | 'swap' | 'move'
9+
type ValueFieldMode = 'insert' | 'remove' | 'swap' | 'move'
10+
type ArrayFieldMode = 'filter'
1011

1112
export const defaultFieldMeta: AnyFieldMeta = {
1213
isValidating: false,
@@ -45,34 +46,63 @@ export function metaHelper<
4546
) {
4647
function handleArrayFieldMetaShift(
4748
field: DeepKeys<TFormData>,
48-
index: number,
49+
remainingIndeces: number[],
4950
mode: ArrayFieldMode,
51+
): void
52+
function handleArrayFieldMetaShift(
53+
field: DeepKeys<TFormData>,
54+
index: number,
55+
mode: ValueFieldMode,
56+
secondIndex?: number,
57+
): void
58+
function handleArrayFieldMetaShift(
59+
field: DeepKeys<TFormData>,
60+
index: number | number[],
61+
mode: ArrayFieldMode | ValueFieldMode,
5062
secondIndex?: number,
5163
) {
52-
const affectedFields = getAffectedFields(field, index, mode, secondIndex)
53-
54-
const handlers = {
55-
insert: () => handleInsertMode(affectedFields, field, index),
56-
remove: () => handleRemoveMode(affectedFields),
57-
swap: () =>
58-
secondIndex !== undefined &&
59-
handleSwapMode(affectedFields, field, index, secondIndex),
60-
move: () =>
61-
secondIndex !== undefined &&
62-
handleMoveMode(affectedFields, field, index, secondIndex),
63-
}
64+
if (Array.isArray(index)) {
65+
if (mode === 'filter') {
66+
return handleFilterMode(field, index)
67+
}
68+
} else {
69+
const affectedFields = getAffectedFields(
70+
field,
71+
index,
72+
mode as ValueFieldMode,
73+
secondIndex,
74+
)
6475

65-
handlers[mode]()
76+
switch (mode as ValueFieldMode) {
77+
case 'insert':
78+
return handleInsertMode(affectedFields, field, index)
79+
case 'remove':
80+
return handleRemoveMode(affectedFields)
81+
case 'swap':
82+
return (
83+
secondIndex !== undefined &&
84+
handleSwapMode(affectedFields, field, index, secondIndex)
85+
)
86+
case 'move':
87+
return (
88+
secondIndex !== undefined &&
89+
handleMoveMode(affectedFields, field, index, secondIndex)
90+
)
91+
}
92+
}
6693
}
6794

68-
function getFieldPath(field: DeepKeys<TFormData>, index: number): string {
69-
return `${field}[${index}]`
95+
function getFieldPath(
96+
field: DeepKeys<TFormData>,
97+
index: number,
98+
): DeepKeys<TFormData> {
99+
return `${field}[${index}]` as DeepKeys<TFormData>
70100
}
71101

72102
function getAffectedFields(
73103
field: DeepKeys<TFormData>,
74104
index: number,
75-
mode: ArrayFieldMode,
105+
mode: ValueFieldMode,
76106
secondIndex?: number,
77107
): DeepKeys<TFormData>[] {
78108
const affectedFieldKeys = [getFieldPath(field, index)]
@@ -148,6 +178,30 @@ export function metaHelper<
148178
shiftMeta(fields, 'up')
149179
}
150180

181+
const handleFilterMode = (
182+
field: DeepKeys<TFormData>,
183+
remainingIndices: number[],
184+
) => {
185+
if (remainingIndices.length === 0) return
186+
187+
// create a map between the index and its new location
188+
remainingIndices.forEach((fromIndex, toIndex) => {
189+
if (fromIndex === toIndex) return
190+
// assign it the original meta
191+
const fieldKey = getFieldPath(field, toIndex)
192+
const originalFieldKey = getFieldPath(field, fromIndex)
193+
const originalFieldMeta = formApi.getFieldMeta(originalFieldKey)
194+
if (originalFieldMeta) {
195+
formApi.setFieldMeta(fieldKey, originalFieldMeta)
196+
} else {
197+
formApi.setFieldMeta(fieldKey, {
198+
...getEmptyFieldMeta(),
199+
isTouched: originalFieldKey as unknown as boolean,
200+
})
201+
}
202+
})
203+
}
204+
151205
const handleMoveMode = (
152206
fields: DeepKeys<TFormData>[],
153207
field: DeepKeys<TFormData>,

packages/form-core/tests/FieldApi.spec.ts

+119
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,9 @@ describe('field api', () => {
11951195

11961196
field.moveValue(0, 1)
11971197
expect(arr).toStrictEqual(['middle', 'end', 'start'])
1198+
1199+
field.filterValues((value) => value !== 'start')
1200+
expect(arr).toStrictEqual(['middle', 'end'])
11981201
})
11991202

12001203
it('should reset the form on a listener', () => {
@@ -2051,4 +2054,120 @@ describe('field api', () => {
20512054
field.parseValueWithSchemaAsync(z.any())
20522055
}).not.toThrowError()
20532056
})
2057+
2058+
it('should filter array values using the predicate when calling filterValues', async () => {
2059+
const form = new FormApi({
2060+
defaultValues: {
2061+
names: ['one', 'two', 'three'],
2062+
},
2063+
})
2064+
2065+
form.mount()
2066+
2067+
const field = new FieldApi({
2068+
form,
2069+
name: 'names',
2070+
})
2071+
2072+
field.mount()
2073+
2074+
field.filterValues((value) => value !== 'two')
2075+
expect(field.state.value).toStrictEqual(['one', 'three'])
2076+
2077+
field.filterValues((value) => value !== 'never')
2078+
expect(field.state.value).toStrictEqual(['one', 'three'])
2079+
})
2080+
2081+
it('should bind the predicate to the provided thisArg when calling filterValues', async () => {
2082+
// Very dirty way, but quick way to enforce lost `this` context
2083+
function SomeClass(this: any) {
2084+
this.check = 'correct this'
2085+
}
2086+
SomeClass.prototype.filterFunc = function () {
2087+
return this?.check === 'correct this'
2088+
}
2089+
// @ts-expect-error The 'new' expression expects class stuff, but
2090+
// we're trying to force ugly code in this unit test.
2091+
const instance = new SomeClass()
2092+
2093+
const predicate = instance.filterFunc
2094+
2095+
const form = new FormApi({
2096+
defaultValues: {
2097+
names: ['one', 'two', 'three'],
2098+
},
2099+
})
2100+
2101+
const field = new FieldApi({
2102+
form,
2103+
name: 'names',
2104+
})
2105+
2106+
form.mount()
2107+
field.mount()
2108+
2109+
field.filterValues(predicate, { thisArg: instance })
2110+
// thisArg was bound, expect it to have returned true
2111+
expect(field.state.value).toStrictEqual(['one', 'two', 'three'])
2112+
field.filterValues(predicate)
2113+
// thisArg wasn't bound, expect it to have returned false
2114+
expect(field.state.value).toStrictEqual([])
2115+
})
2116+
2117+
it('should run onChange validation on the array when calling filterValues', async () => {
2118+
vi.useFakeTimers()
2119+
const form = new FormApi({
2120+
defaultValues: {
2121+
names: ['one', 'two', 'three', 'four', 'five'],
2122+
},
2123+
})
2124+
form.mount()
2125+
function getField(i: number) {
2126+
return new FieldApi({
2127+
name: `names[${i}]`,
2128+
form,
2129+
validators: {
2130+
onChange: () => 'error',
2131+
},
2132+
})
2133+
}
2134+
2135+
const arrayField = new FieldApi({
2136+
form,
2137+
name: 'names',
2138+
validators: {
2139+
onChange: () => 'error',
2140+
},
2141+
})
2142+
arrayField.mount()
2143+
2144+
const field0 = getField(0)
2145+
const field1 = getField(1)
2146+
const field2 = getField(2)
2147+
const field3 = getField(3)
2148+
const field4 = getField(4)
2149+
field0.mount()
2150+
field1.mount()
2151+
field2.mount()
2152+
field3.mount()
2153+
field4.mount()
2154+
2155+
arrayField.filterValues((value) => value !== 'three')
2156+
// validating fields is separate from filterValues and done with a promise,
2157+
// so make sure they resolve first
2158+
await vi.runAllTimersAsync()
2159+
2160+
expect(arrayField.getMeta().errors).toStrictEqual(['error'])
2161+
2162+
// field 0 and 1 weren't shifted, so they shouldn't trigger validation
2163+
expect(field0.getMeta().errors).toStrictEqual([])
2164+
expect(field1.getMeta().errors).toStrictEqual([])
2165+
2166+
// but the following fields were shifted
2167+
expect(field2.getMeta().errors).toStrictEqual(['error'])
2168+
expect(field3.getMeta().errors).toStrictEqual(['error'])
2169+
2170+
// field4 no longer exists, so it shouldn't have errors
2171+
expect(field4.getMeta().errors).toStrictEqual([])
2172+
})
20542173
})

0 commit comments

Comments
 (0)