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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "fix(vtable): map sorted view index to the source record in deleteRecords/updateRecords",
"type": "patch",
"packageName": "@visactor/vtable"
}
],
"packageName": "@visactor/vtable",
"email": "20151622+william-xue@users.noreply.github.com"
}
140 changes: 140 additions & 0 deletions packages/vtable/__tests__/data-update/listTable-data-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,4 +427,144 @@ describe('listTable data events test', () => {
table.updateFilterRules(table.dataSource.dataConfig.filterRules as any);
expect((table.records as any[]).map(r => r.name)).toEqual(['小明2', '小明3']);
});

test('deleteRecords on a sorted table should remove the source record shown in that row', () => {
table.release();
const records = [
{ id: 1, name: 'Employee 1' },
{ id: 2, name: 'Employee 2' },
{ id: 3, name: 'Employee 3' },
{ id: 4, name: 'Employee 4' },
{ id: 5, name: 'Employee 5' }
];

table = new ListTable({
container: containerDom,
columns: [
{ field: 'id', title: 'ID' },
{ field: 'name', title: 'Name' }
],
records,
sortState: { field: 'id', order: 'desc' },
syncRecordOperationsToSourceRecords: true
});

// 降序排列后 body 第一行是 id 为 5 的记录(数据源下标 4)
table.deleteRecords([0]);

// 删除的是显示在第一行的记录(id 5),而不是数据源数组的第 0 条
expect(records.map(record => record.id)).toEqual([1, 2, 3, 4]);
expect((table.records as any[]).map((record: any) => record.id)).toEqual([1, 2, 3, 4]);
});

test('deleteRecords should remove every source record when several sorted view rows are deleted at once', () => {
table.release();
const records = [
{ id: 1, name: 'Employee 1' },
{ id: 2, name: 'Employee 2' },
{ id: 3, name: 'Employee 3' },
{ id: 4, name: 'Employee 4' },
{ id: 5, name: 'Employee 5' }
];

table = new ListTable({
container: containerDom,
columns: [
{ field: 'id', title: 'ID' },
{ field: 'name', title: 'Name' }
],
records,
sortState: { field: 'id', order: 'desc' },
syncRecordOperationsToSourceRecords: true
});

// 降序视图 [5,4,3,2,1],删除视图行 [0,1] 即删除 id 5 与 id 4
table.deleteRecords([0, 1]);

expect(records.map(record => record.id)).toEqual([1, 2, 3]);
});

test('updateRecords on a sorted table should update the source record shown in that row', () => {
table.release();
const records = [
{ id: 1, name: 'Employee 1' },
{ id: 2, name: 'Employee 2' },
{ id: 3, name: 'Employee 3' },
{ id: 4, name: 'Employee 4' },
{ id: 5, name: 'Employee 5' }
];

table = new ListTable({
container: containerDom,
columns: [
{ field: 'id', title: 'ID' },
{ field: 'name', title: 'Name' }
],
records,
sortState: { field: 'id', order: 'desc' },
syncRecordOperationsToSourceRecords: true
});

table.updateRecords([{ id: 5, name: 'Employee 5 updated' }], [0]);

// 更新的同样是显示在第一行的记录(id 5,数据源下标 4)
expect(records.map(record => record.id)).toEqual([1, 2, 3, 4, 5]);
expect(records[4].name).toBe('Employee 5 updated');
});

test('deleteRecords/updateRecords on a paginated sorted table should honour the page offset', () => {
table.release();
const records = [
{ id: 1, name: 'Employee 1' },
{ id: 2, name: 'Employee 2' },
{ id: 3, name: 'Employee 3' },
{ id: 4, name: 'Employee 4' },
{ id: 5, name: 'Employee 5' }
];

table = new ListTable({
container: containerDom,
columns: [
{ field: 'id', title: 'ID' },
{ field: 'name', title: 'Name' }
],
records,
sortState: { field: 'id', order: 'desc' },
pagination: { totalCount: 5, perPageCount: 2, currentPage: 1 },
syncRecordOperationsToSourceRecords: true
});

// 降序结果为 [id5,id4,id3,id2,id1],第二页显示 id 3 与 id 2(数据源下标 2 与 1)
table.deleteRecords([0]);
expect(records.map(record => record.id)).toEqual([1, 2, 4, 5]);

// 删除后降序结果为 [id5,id4,id2,id1],第二页第一行仍是 id 2(数据源下标 1)
table.updateRecords([{ id: 2, name: 'Employee 2 updated' }], [0]);
expect(records.map(record => record.id)).toEqual([1, 2, 4, 5]);
expect(records[1].name).toBe('Employee 2 updated');
});

test('deleteRecords should use one source slot per deleted view row when a record object is reused', () => {
table.release();
const reused = { id: 1, name: 'Employee 1' };
const other = { id: 2, name: 'Employee 2' };
// 同一个对象被源数组引用多次(例如多张表共用同一条记录)
const records = [reused, other, reused, other];

table = new ListTable({
container: containerDom,
columns: [
{ field: 'id', title: 'ID' },
{ field: 'name', title: 'Name' }
],
records,
syncRecordOperationsToSourceRecords: true
});

// 删除显示行 [1,2,3]:每一行各占一个原始槽位,保留的是第 0 行引用的那条记录
table.deleteRecords([1, 2, 3]);

expect(records.length).toBe(1);
expect(records[0]).toBe(reused);
});
});
64 changes: 55 additions & 9 deletions packages/vtable/src/data/DataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,29 @@ export class DataSource extends EventTarget implements DataSourceAPI {
private _hasFilterInEffect(): boolean {
return (this.dataConfig?.filterRules?.length ?? 0) >= 1 || (this.lastFilterRules?.length ?? 0) >= 1;
}
/**
* body 中的显示索引 → this.records(数据源数组)索引。
* body 显示索引是「当前页」内的索引:分页时当前页的映射保存在 _currentPagerIndexedData 中
* (等价于 getRecordIndexPaths(viewIndex)),未分页时它与 currentIndexedData 一致。
* 排序/筛选后它们保存的是「视图顺序 → 数据源顺序」的映射,未排序/未筛选时是恒等映射。
*/
private _getRecordIndexFromViewIndex(viewIndex: number): number {
const pagerIndexedData = this._currentPagerIndexedData;
let mappedIndex: number | number[] | undefined;
if (Array.isArray(pagerIndexedData) && viewIndex >= 0 && viewIndex < pagerIndexedData.length) {
mappedIndex = pagerIndexedData[viewIndex];
}
if (mappedIndex === undefined) {
const indexedData = this.currentIndexedData;
mappedIndex = Array.isArray(indexedData) && viewIndex >= 0 ? indexedData[viewIndex] : undefined;
}
return typeof mappedIndex === 'number' ? mappedIndex : viewIndex;
}
/** body 显示索引的有效范围:分页时为当前页行数,否则为数据源长度 */
private _getViewRecordCount(): number {
const pagerIndexedData = this._currentPagerIndexedData;
return Array.isArray(pagerIndexedData) && pagerIndexedData.length > 0 ? pagerIndexedData.length : this.records.length;
}
private _normalizeInsertIndex(index: number, length: number): number {
if (index === undefined || index === null) {
return length;
Expand Down Expand Up @@ -1244,20 +1267,43 @@ export class DataSource extends EventTarget implements DataSourceAPI {
}

const realDeletedRecordIndexs: number[] = [];
const recordIndexsMaxToMin = recordIndexs.slice().sort((a, b) => b - a);
const rawDeletedIndexs: number[] = [];
// 先一次性把 body 显示索引解析为「待删记录 + 原始下标」,再按原始下标降序删除:
// rawRecords 与 this.records 是同一数组(无筛选)时,边解析边 splice 会使后续映射失效、漏删记录
// (如降序表批量删除视图行 [0,1] 时只删掉一条)。
const pendingDeletes: number[] = [];
const handledViewIndexs = new Set<number>();
const reservedRawIndexs = new Set<number>();
const recordIndexsMaxToMin = recordIndexs.slice().sort((a, b) => b - a);
const viewRecordCount = this._getViewRecordCount();
for (let index = 0; index < recordIndexsMaxToMin.length; index++) {
const viewIndex = recordIndexsMaxToMin[index];
if (viewIndex >= this.records.length || viewIndex < 0) {
if (viewIndex < 0 || viewIndex >= viewRecordCount || handledViewIndexs.has(viewIndex)) {
continue;
}
const deletedRecord = this.records[viewIndex];
const rawIndex = rawRecords.indexOf(deletedRecord);
if (rawIndex >= 0) {
rawRecords.splice(rawIndex, 1);
rawDeletedIndexs.push(rawIndex);
handledViewIndexs.add(viewIndex);
// viewIndex 是 body 中的显示索引:分页/排序后需先映射回数据源索引,否则会删到别的记录
const deletedRecord = this.records[this._getRecordIndexFromViewIndex(viewIndex)];
if (deletedRecord === undefined) {
continue;
}
realDeletedRecordIndexs.push(viewIndex);
// 同一个对象可能被源数组多次引用:此时每个显示行应各占一个原始槽位,
// 否则 indexOf 对多行返回同一原始下标,连续 splice 会误删其它行
let rawIndex = rawRecords.indexOf(deletedRecord);
while (rawIndex >= 0 && reservedRawIndexs.has(rawIndex)) {
rawIndex = rawRecords.indexOf(deletedRecord, rawIndex + 1);
}
if (rawIndex >= 0) {
reservedRawIndexs.add(rawIndex);
pendingDeletes.push(rawIndex);

@visactor-review-bot visactor-review-bot Bot Oct 8, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 批量删除重复引用记录时避免重复删除同一原始下标

当 syncRecordOperationsToSourceRecords: true 且源数组中复用同一记录对象时,不同显示行经 rawRecords.indexOf(deletedRecord) 会解析到同一原始下标;这里将它多次加入 pendingDeletes,后面连续 splice 会误删其他行。例如无排序、无筛选时源数组为 [A, B, A, B](两个 A 引用同一对象,两个 B 亦然),调用 deleteRecords([1, 2, 3]) 应留下 A,但待删下标变成 [1, 1, 0],实际留下 B;改动前逐次删除在此输入会留下 A。这与既有讨论中的排序后映射失效不同,是预收集下标带来的新回归。建议直接使用已映射的源下标(筛选态则建立按出现次数匹配的原始位置映射),确保每个显示行对应不同原始槽位。上述结果来自按源码删除循环构造的 Node 最小复现,未运行工程测试。


Confirmed fixed (cdab8fe): For the original [A, B, A, B] input deleting view rows [1, 2, 3], the reservedRawIndexs loop now skips already-reserved source positions, then splices distinct positions in descending order. The ListTable sync path still invokes this branch for non-tree, non-grouped tables (record-helper.ts:1004-1009). A source-matched Node reproduction leaves A, so the original incorrect result is gone; this is not a full integration test.

}
}
// 按原始下标从大到小删除,保证前面的删除不影响后面的下标
pendingDeletes.sort((a, b) => b - a);
for (let index = 0; index < pendingDeletes.length; index++) {
rawRecords.splice(pendingDeletes[index], 1);
rawDeletedIndexs.push(pendingDeletes[index]);
}

this.beforeChangedRecordsMap.clear();
Expand Down Expand Up @@ -1346,10 +1392,10 @@ export class DataSource extends EventTarget implements DataSourceAPI {
return acc[key].children;
}, rawRecords)[recordIndex[recordIndex.length - 1]] = records[index];
} else {
if (recordIndex >= this.records.length || recordIndex < 0) {
if (recordIndex >= this._getViewRecordCount() || recordIndex < 0) {
continue;
}
const oldRecord = this.records[recordIndex];
const oldRecord = this.records[this._getRecordIndexFromViewIndex(recordIndex)];
const rawIndex = rawRecords.indexOf(oldRecord);
if (rawIndex >= 0) {
rawRecords[rawIndex] = records[index];
Expand Down
Loading