Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-nested-array-meta-shift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

Shift the correct index when an array field is nested inside another array field.
34 changes: 23 additions & 11 deletions packages/form-core/src/metaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function metaHelper<
new Map<DeepKeys<TFormData>, 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)
Expand All @@ -122,7 +122,7 @@ export function metaHelper<
bumpArrayVersion(field)
const affectedFields = getAffectedFields(field, index, 'remove')

shiftMeta(affectedFields, 'up')
shiftMeta(field, affectedFields, 'up')
}

/**
Expand Down Expand 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))) {
Expand Down Expand Up @@ -221,22 +221,34 @@ export function metaHelper<
}

function updateIndex(
field: DeepKeys<TFormData>,
fieldKey: string,
direction: 'up' | 'down',
): DeepKeys<TFormData> {
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<TFormData>
// 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<TFormData>
}

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<TFormData>
}

function shiftMeta(fields: DeepKeys<TFormData>[], direction: 'up' | 'down') {
function shiftMeta(
field: DeepKeys<TFormData>,
fields: DeepKeys<TFormData>[],
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)
Expand Down
61 changes: 61 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down