diff --git a/.changeset/fix-nested-array-meta-shift.md b/.changeset/fix-nested-array-meta-shift.md new file mode 100644 index 0000000000..0a7f07b0f8 --- /dev/null +++ b/.changeset/fix-nested-array-meta-shift.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Shift the correct index when an array field is nested inside another array field. diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index bab8a0630f..9b115c555b 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -97,7 +97,7 @@ export function metaHelper< new Map, AnyFieldLikeMeta | undefined>(), ) - shiftMeta(affectedFields, fromIndex < toIndex ? 'up' : 'down') + shiftMeta(field, affectedFields, fromIndex < toIndex ? 'up' : 'down') // Reapply the stored field meta at the destination index Object.keys(formApi.fieldInfo) @@ -122,7 +122,7 @@ export function metaHelper< bumpArrayVersion(field) const affectedFields = getAffectedFields(field, index, 'remove') - shiftMeta(affectedFields, 'up') + shiftMeta(field, affectedFields, 'up') } /** @@ -165,7 +165,7 @@ export function metaHelper< bumpArrayVersion(field) const affectedFields = getAffectedFields(field, insertIndex, 'insert') - shiftMeta(affectedFields, 'down') + shiftMeta(field, affectedFields, 'down') affectedFields.forEach((fieldKey) => { if (fieldKey.toString().startsWith(getFieldPath(field, insertIndex))) { @@ -221,22 +221,34 @@ export function metaHelper< } function updateIndex( + field: DeepKeys, fieldKey: string, direction: 'up' | 'down', ): DeepKeys { - return fieldKey.replace(/\[(\d+)\]/, (_, num) => { - const currIndex = parseInt(num, 10) - const newIndex = - direction === 'up' ? currIndex + 1 : Math.max(0, currIndex - 1) - return `[${newIndex}]` - }) as DeepKeys + // Only the index belonging to `field` may move; a nested array field such + // as `teams[0].members` carries indices of its own that must stay put. + const prefix = `${field}[` + const closing = fieldKey.indexOf(']', prefix.length) + if (!fieldKey.startsWith(prefix) || closing === -1) { + return fieldKey as DeepKeys + } + + const currIndex = parseInt(fieldKey.slice(prefix.length, closing), 10) + const newIndex = + direction === 'up' ? currIndex + 1 : Math.max(0, currIndex - 1) + + return `${prefix}${newIndex}${fieldKey.slice(closing)}` as DeepKeys } - function shiftMeta(fields: DeepKeys[], direction: 'up' | 'down') { + function shiftMeta( + field: DeepKeys, + fields: DeepKeys[], + direction: 'up' | 'down', + ) { const sortedFields = direction === 'up' ? fields : [...fields].reverse() sortedFields.forEach((fieldKey) => { - const nextFieldKey = updateIndex(fieldKey.toString(), direction) + const nextFieldKey = updateIndex(field, fieldKey.toString(), direction) const nextFieldMeta = formApi.getFieldMeta(nextFieldKey) if (nextFieldMeta) { formApi.setFieldMeta(fieldKey, nextFieldMeta) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index c57bb044e8..ae28a86e32 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -566,6 +566,37 @@ describe('form api', () => { expect(field1.state.meta.isBlurred).toBe(true) }) + it('should shift meta of an array nested inside another array when inserting values', async () => { + const form = new FormApi({ + defaultValues: { + teams: [{ members: ['a', 'b'] }], + }, + }) + form.mount() + new FieldApi({ form, name: 'teams' }).mount() + new FieldApi({ form, name: 'teams[0].members' }).mount() + const member0 = new FieldApi({ form, name: 'teams[0].members[0]' }) + member0.mount() + const member1 = new FieldApi({ form, name: 'teams[0].members[1]' }) + member1.mount() + + member0.handleBlur() + + expect(member0.state.meta.isBlurred).toBe(true) + expect(member1.state.meta.isBlurred).toBe(false) + + await form.insertFieldValue('teams[0].members', 0, 'x') + + expect(form.getFieldValue('teams[0].members')).toStrictEqual([ + 'x', + 'a', + 'b', + ]) + // member0's meta moved onto member1 now + expect(member0.state.meta.isBlurred).toBe(false) + expect(member1.state.meta.isBlurred).toBe(true) + }) + it("should validate all shifted fields when inserting an array field's value", async () => { const form = new FormApi({ defaultValues: { @@ -734,6 +765,36 @@ describe('form api', () => { expect(field1Surname.state.meta.isBlurred).toBe(true) }) + it('should shift meta of an array nested inside another array when removing values', async () => { + const form = new FormApi({ + defaultValues: { + teams: [{ members: ['a', 'b', 'c'] }], + }, + }) + form.mount() + new FieldApi({ form, name: 'teams' }).mount() + new FieldApi({ form, name: 'teams[0].members' }).mount() + const member0 = new FieldApi({ form, name: 'teams[0].members[0]' }) + member0.mount() + const member1 = new FieldApi({ form, name: 'teams[0].members[1]' }) + member1.mount() + const member2 = new FieldApi({ form, name: 'teams[0].members[2]' }) + member2.mount() + + member2.handleBlur() + + expect(member0.state.meta.isBlurred).toBe(false) + expect(member1.state.meta.isBlurred).toBe(false) + expect(member2.state.meta.isBlurred).toBe(true) + + await form.removeFieldValue('teams[0].members', 0) + + expect(form.getFieldValue('teams[0].members')).toStrictEqual(['b', 'c']) + // member2's meta moved onto member1 now + expect(member0.state.meta.isBlurred).toBe(false) + expect(member1.state.meta.isBlurred).toBe(true) + }) + it("should swap an array field's value", () => { const form = new FormApi({ defaultValues: {