diff --git a/common/changes/@visactor/vtable/fix-issue-5137-filter-column-order_2026-05-20-02-27.json b/common/changes/@visactor/vtable/fix-issue-5137-filter-column-order_2026-05-20-02-27.json new file mode 100644 index 000000000..2ebebd87e --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5137-filter-column-order_2026-05-20-02-27.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: preserve hidden column order after filter update\n\n", + "type": "none", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} \ No newline at end of file diff --git a/packages/vtable-plugins/__tests__/filter/filter-plugin.test.ts b/packages/vtable-plugins/__tests__/filter/filter-plugin.test.ts new file mode 100644 index 000000000..71cb30bf8 --- /dev/null +++ b/packages/vtable-plugins/__tests__/filter/filter-plugin.test.ts @@ -0,0 +1,163 @@ +// @ts-nocheck +let subscribeCallback: ((state: any, action?: any) => void) | undefined; + +jest.mock('@visactor/vtable', () => ({ + TABLE_EVENT_TYPE: { + BEFORE_INIT: 'before_init', + BEFORE_UPDATE_OPTION: 'before_update_option', + ICON_CLICK: 'icon_click', + SCROLL: 'scroll', + CHANGE_CELL_VALUE: 'change_cell_value', + UPDATE_RECORD: 'update_record', + ADD_RECORD: 'add_record', + DELETE_RECORD: 'delete_record', + ADD_COLUMN: 'add_column', + DELETE_COLUMN: 'delete_column' + }, + TYPES: { + IconPosition: { + right: 'right' + } + } +})); + +jest.mock('../../src/filter/filter-engine', () => ({ + FilterEngine: jest.fn().mockImplementation(() => ({})) +})); + +jest.mock('../../src/filter/filter-state-manager', () => ({ + FilterStateManager: jest.fn().mockImplementation(() => ({ + subscribe: (cb: (state: any, action?: any) => void) => { + subscribeCallback = cb; + }, + getFilterState: () => undefined, + getActiveFilterFields: () => [], + reapplyCurrentFilters: jest.fn(), + dispatch: jest.fn() + })) +})); + +jest.mock('../../src/filter/filter-toolbar', () => ({ + FilterToolbar: jest.fn().mockImplementation(() => ({ + render: jest.fn(), + updateStyles: jest.fn(), + isVisible: false, + hide: jest.fn(), + show: jest.fn(), + adjustMenuPosition: jest.fn(), + valueFilter: { + syncSingleStateFromTableData: jest.fn() + } + })) +})); + +const { FilterPlugin } = require('../../src/filter/filter'); + +describe('FilterPlugin', () => { + beforeEach(() => { + subscribeCallback = undefined; + }); + + test('falls back to option columns before list table layout is ready', () => { + const optionColumns = [ + { field: 'a', title: 'A' }, + { field: 'b', title: 'B' } + ]; + const table = { + isListTable: () => true, + updateColumns: jest.fn(), + get columns() { + throw new TypeError("Cannot read properties of undefined (reading 'layoutMap')"); + } + }; + + const plugin = new FilterPlugin({}); + plugin.table = table; + + expect(() => + plugin.initFilterPlugin({ + options: { + columns: optionColumns + } + }) + ).not.toThrow(); + + expect(table.updateColumns).not.toHaveBeenCalled(); + }); + + test('uses current table column order when filter state updates', () => { + const staleColumns = [ + { field: 'a', title: 'A' }, + { field: 'b', title: 'B' }, + { field: 'c', title: 'C' } + ]; + const currentColumns = [ + { field: 'a', title: 'A' }, + { field: 'c', title: 'C' }, + { field: 'b', title: 'B' } + ]; + const table = { + isListTable: () => true, + updateColumns: jest.fn(), + get columns() { + return currentColumns; + } + }; + + const plugin = new FilterPlugin({}); + plugin.table = table; + plugin.initFilterPlugin({ + options: { + columns: staleColumns + } + }); + + subscribeCallback?.({}, { type: 'apply_filters' }); + + expect(table.updateColumns).toHaveBeenCalledWith(currentColumns, { + clearRowHeightCache: false + }); + }); + + test('uses synced options.columns when hidden columns exist', () => { + const syncedOptionColumns = [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' }, + { field: 'gender', title: 'Gender', hide: true }, + { field: 'department', title: 'Department' }, + { field: 'city', title: 'City' }, + { field: 'status', title: 'Status' } + ]; + const table = { + isListTable: () => true, + options: { + columns: syncedOptionColumns + }, + updateColumns: jest.fn(), + get columns() { + return [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' }, + { field: 'city', title: 'City' }, + { field: 'gender', title: 'Gender', hide: true }, + { field: 'department', title: 'Department' }, + { field: 'status', title: 'Status' } + ]; + } + }; + + const plugin = new FilterPlugin({}); + plugin.table = table; + plugin.initFilterPlugin({ + options: { + columns: syncedOptionColumns + } + }); + + subscribeCallback?.({}, { type: 'apply_filters' }); + + expect(table.updateColumns).toHaveBeenCalledWith(syncedOptionColumns, { + clearRowHeightCache: false + }); + }); +}); diff --git a/packages/vtable-plugins/demo/filter/issue-5137.ts b/packages/vtable-plugins/demo/filter/issue-5137.ts new file mode 100644 index 000000000..aa5161fc7 --- /dev/null +++ b/packages/vtable-plugins/demo/filter/issue-5137.ts @@ -0,0 +1,221 @@ +import * as VTable from '@visactor/vtable'; +import { FilterPlugin } from '../../src/filter'; + +const CONTAINER_ID = 'vTable'; + +type RecordItem = { + id: number; + name: string; + gender: '男' | '女'; + city: string; + department: string; + status: '在职' | '请假' | '离职'; +}; + +function createRecords(): RecordItem[] { + return [ + { id: 1, name: '张三', gender: '男', city: '北京', department: '研发', status: '在职' }, + { id: 2, name: '李四', gender: '女', city: '上海', department: '销售', status: '请假' }, + { id: 3, name: '王五', gender: '男', city: '深圳', department: '研发', status: '在职' }, + { id: 4, name: '赵六', gender: '女', city: '杭州', department: '设计', status: '离职' }, + { id: 5, name: '钱七', gender: '男', city: '广州', department: '运营', status: '在职' }, + { id: 6, name: '孙八', gender: '女', city: '苏州', department: '销售', status: '请假' } + ]; +} + +function createColumns(): VTable.ColumnsDefine { + return [ + { field: 'id', title: 'ID', width: 80, sort: true }, + { field: 'name', title: '姓名', width: 120, sort: true }, + { field: 'gender', title: '性别', width: 100 }, + { field: 'city', title: '城市', width: 120 }, + { field: 'department', title: '部门', width: 120 }, + { field: 'status', title: '状态', width: 120 } + ]; +} + +function getOrderText(table: VTable.ListTable) { + const visibleFields: string[] = []; + for (let col = 0; col < table.colCount; col++) { + visibleFields.push(String(table.getHeaderField(col, 0))); + } + return visibleFields.join(' | '); +} + +function getOptionOrderText(table: VTable.ListTable) { + return (table.options.columns ?? []).map(col => `${String(col.field)}${col.hide ? '(hide)' : ''}`).join(' | '); +} + +function getVisibleHeaderColByField(table: VTable.ListTable, field: string) { + for (let col = 0; col < table.colCount; col++) { + if (table.getHeaderField(col, 0) === field) { + return col; + } + } + return -1; +} + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + + const panel = document.createElement('div'); + panel.style.position = 'fixed'; + panel.style.top = '12px'; + panel.style.right = '12px'; + panel.style.zIndex = '9999'; + panel.style.display = 'flex'; + panel.style.flexDirection = 'column'; + panel.style.gap = '8px'; + panel.style.width = '420px'; + panel.style.padding = '12px'; + panel.style.background = 'rgba(17,24,39,0.88)'; + panel.style.color = '#fff'; + panel.style.fontSize = '12px'; + panel.style.borderRadius = '8px'; + + const title = document.createElement('div'); + title.textContent = 'issue-5137 repro: 隐藏列 -> 拖拽排序 -> Filter 确认'; + title.style.fontWeight = 'bold'; + + const desc = document.createElement('div'); + desc.textContent = + '手动复现:1. 点击“隐藏性别列” 2. 直接拖拽“城市”列到“姓名”后面 3. 点击“状态”列的筛选图标并确认。也可用下面的一键脚本。'; + desc.style.lineHeight = '1.5'; + + const buttonRow = document.createElement('div'); + buttonRow.style.display = 'flex'; + buttonRow.style.flexWrap = 'wrap'; + buttonRow.style.gap = '8px'; + + const status = document.createElement('pre'); + status.style.margin = '0'; + status.style.whiteSpace = 'pre-wrap'; + status.style.lineHeight = '1.5'; + + const records = createRecords(); + const columns = createColumns(); + const filterPlugin = new FilterPlugin({ + defaultEnabled: true + }); + + const table = new VTable.ListTable({ + container, + records, + columns, + widthMode: 'standard', + dragHeaderMode: 'all', + plugins: [filterPlugin] + }); + + function updateStatus(label: string) { + status.textContent = `${label}\n` + `current: ${getOrderText(table)}\n` + `options: ${getOptionOrderText(table)}`; + } + + function hideGenderColumn() { + const nextColumns = table.columns.map(col => { + if (col.field === 'gender') { + return { + ...col, + hide: true + }; + } + return { ...col }; + }); + table.updateColumns(nextColumns, { clearRowHeightCache: false }); + updateStatus('已隐藏 `gender` 列'); + } + + function dragCityAfterDepartment() { + const cityIndex = getVisibleHeaderColByField(table, 'city'); + const departmentIndex = getVisibleHeaderColByField(table, 'department'); + if (cityIndex < 0 || departmentIndex < 0) { + updateStatus('未找到 `city` 或 `department` 列'); + return; + } + table.changeHeaderPosition({ + source: { col: cityIndex, row: 0 }, + target: { col: departmentIndex, row: 0 }, + movingColumnOrRow: 'column' + }); + updateStatus('已把 `city` 拖到 `department` 后面'); + } + + function applyStatusFilter() { + filterPlugin.applyFilterSnapshot({ + filters: [ + { + field: 'status', + type: 'byValue', + values: ['在职', '请假'], + enable: true + } + ] + }); + updateStatus('已执行一次 Filter 确认(status in 在职/请假)'); + } + + function runAllSteps() { + hideGenderColumn(); + dragCityAfterDepartment(); + applyStatusFilter(); + updateStatus('已完成完整复现链路'); + } + + function resetTable() { + table.updateOption({ + ...table.options, + records: createRecords(), + columns: createColumns(), + plugins: [filterPlugin] + }); + filterPlugin.applyFilterSnapshot({ filters: [] }); + updateStatus('已重置为初始状态'); + } + + function createButton(text: string, onClick: () => void) { + const button = document.createElement('button'); + button.textContent = text; + button.style.padding = '6px 10px'; + button.style.cursor = 'pointer'; + button.style.borderRadius = '4px'; + button.style.border = '1px solid rgba(255,255,255,0.2)'; + button.style.background = 'rgba(255,255,255,0.12)'; + button.style.color = '#fff'; + button.addEventListener('click', onClick); + return button; + } + + buttonRow.appendChild(createButton('隐藏性别列', hideGenderColumn)); + buttonRow.appendChild(createButton('拖拽 city 到 department 后', dragCityAfterDepartment)); + buttonRow.appendChild(createButton('一键复现', runAllSteps)); + buttonRow.appendChild(createButton('重置', resetTable)); + + panel.appendChild(title); + panel.appendChild(desc); + panel.appendChild(buttonRow); + panel.appendChild(status); + document.body.appendChild(panel); + + const demoWindow = window as Window & + typeof globalThis & { + tableInstance?: VTable.ListTable; + filterPlugin?: FilterPlugin; + issue5137?: Record; + }; + + demoWindow.tableInstance = table; + demoWindow.filterPlugin = filterPlugin; + demoWindow.issue5137 = { + hideGenderColumn, + dragCityAfterDepartment, + applyStatusFilter, + runAllSteps, + resetTable, + getOrderText: () => getOrderText(table) + }; + + updateStatus('初始化完成'); +} diff --git a/packages/vtable-plugins/demo/menu.ts b/packages/vtable-plugins/demo/menu.ts index a9718ec90..f5e19298c 100644 --- a/packages/vtable-plugins/demo/menu.ts +++ b/packages/vtable-plugins/demo/menu.ts @@ -19,6 +19,10 @@ export const menus = [ path: 'filter', name: 'bug' }, + { + path: 'filter', + name: 'issue-5137' + }, { path: 'filter', name: 'value-filter' diff --git a/packages/vtable-plugins/src/filter/filter.ts b/packages/vtable-plugins/src/filter/filter.ts index 1c9ad2f53..788d4e0a3 100644 --- a/packages/vtable-plugins/src/filter/filter.ts +++ b/packages/vtable-plugins/src/filter/filter.ts @@ -78,17 +78,20 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { this.filterEngine = new FilterEngine(this.pluginOptions); this.filterStateManager = new FilterStateManager(this.table, this.filterEngine); this.filterToolbar = new FilterToolbar(this.table, this.filterStateManager, this.pluginOptions); + // BEFORE_INIT 阶段 table.columns 可能还不可用,先缓存本次 options.columns 作为 getCurrentColumns 的回退值。 this.columns = eventArgs.options.columns; this.filterToolbar.render(document.body); - this.updateFilterIcons(this.columns); + this.updateFilterIcons(this.getCurrentColumns()); this.filterStateManager.subscribe((_: FilterState, action?: FilterAction) => { // 新增筛选配置时,不需要更新筛选图标以及表格 if (action?.type === FilterActionType.ADD_FILTER) { return; } - this.updateFilterIcons(this.columns); - (this.table as ListTable).updateColumns(this.columns, { + const currentColumns = this.getCurrentColumns(); + this.columns = currentColumns; + this.updateFilterIcons(currentColumns); + (this.table as ListTable).updateColumns(currentColumns, { clearRowHeightCache: false }); }); @@ -176,7 +179,9 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { // 更新筛选器UI样式 this.filterToolbar.updateStyles(this.pluginOptions.styles); // 更新icon - (this.table as ListTable).updateColumns(this.columns, { + const currentColumns = this.getCurrentColumns(); + this.columns = currentColumns; + (this.table as ListTable).updateColumns(currentColumns, { clearRowHeightCache: false }); } @@ -226,6 +231,21 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { this.updateFilterIcons(options.columns); } + private getCurrentColumns(): ColumnsDefine { + if (this.table?.isListTable?.()) { + const optionColumns = (this.table as ListTable).options?.columns; + if (optionColumns?.length) { + return optionColumns; + } + try { + return (this.table as ListTable).columns; + } catch (error) { + // BEFORE_INIT 阶段 ListTable 的 layoutMap 可能尚未建立,回退到最近一次缓存的 columns。 + } + } + return this.columns ?? []; + } + /** * 重新应用所有激活的筛选状态 * 在 updateOption 后调用,因为 updateOption 会全量更新表格 diff --git a/packages/vtable/__tests__/columns/listTable-dragHeader.test.ts b/packages/vtable/__tests__/columns/listTable-dragHeader.test.ts index b366f9045..5d3bed929 100644 --- a/packages/vtable/__tests__/columns/listTable-dragHeader.test.ts +++ b/packages/vtable/__tests__/columns/listTable-dragHeader.test.ts @@ -125,6 +125,84 @@ describe('listTable-cellType-function init test', () => { ]); listTable2.release(); }); + test('listTable changeHeaderPosition keeps options.columns in sync for follow-up updates', () => { + const containerDom2: HTMLElement = createDiv(); + containerDom2.style.position = 'relative'; + containerDom2.style.width = '1000px'; + containerDom2.style.height = '800px'; + const records2 = [ + { a: 1, b: 2, c: 3 }, + { a: 4, b: 5, c: 6 } + ]; + const columns2 = [ + { field: 'a', title: 'A' }, + { field: 'b', title: 'B' }, + { field: 'c', title: 'C' } + ]; + const listTable2 = new ListTable(containerDom2, { records: records2, columns: columns2, dragHeaderMode: 'all' }); + listTable2.changeHeaderPosition({ + source: { col: 1, row: 0 }, + target: { col: 2, row: 0 }, + movingColumnOrRow: 'column' + }); + expect(listTable2.options.columns).toEqual([ + { field: 'a', title: 'A' }, + { field: 'c', title: 'C' }, + { field: 'b', title: 'B' } + ]); + listTable2.updateOption({ + ...listTable2.options, + widthMode: 'standard' + }); + expect(listTable2.columns).toEqual([ + { field: 'a', title: 'A' }, + { field: 'c', title: 'C' }, + { field: 'b', title: 'B' } + ]); + listTable2.release(); + }); + test('listTable changeHeaderPosition keeps hidden-column order in sync for follow-up updates', () => { + const containerDom2: HTMLElement = createDiv(); + containerDom2.style.position = 'relative'; + containerDom2.style.width = '1000px'; + containerDom2.style.height = '800px'; + const records2 = [ + { id: 1, name: 'A', gender: 'M', city: 'BJ', department: 'RD', status: 'on' }, + { id: 2, name: 'B', gender: 'F', city: 'SH', department: 'Sales', status: 'off' } + ]; + const columns2 = [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' }, + { field: 'gender', title: 'Gender', hide: true }, + { field: 'city', title: 'City' }, + { field: 'department', title: 'Department' }, + { field: 'status', title: 'Status' } + ]; + const listTable2 = new ListTable(containerDom2, { records: records2, columns: columns2, dragHeaderMode: 'all' }); + listTable2.changeHeaderPosition({ + source: { col: 2, row: 0 }, + target: { col: 3, row: 0 }, + movingColumnOrRow: 'column' + }); + expect(listTable2.options.columns).toEqual([ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' }, + { field: 'gender', title: 'Gender', hide: true }, + { field: 'department', title: 'Department' }, + { field: 'city', title: 'City' }, + { field: 'status', title: 'Status' } + ]); + listTable2.updateOption({ + ...listTable2.options, + widthMode: 'standard' + }); + expect(listTable2.getHeaderField(0, 0)).toBe('id'); + expect(listTable2.getHeaderField(1, 0)).toBe('name'); + expect(listTable2.getHeaderField(2, 0)).toBe('department'); + expect(listTable2.getHeaderField(3, 0)).toBe('city'); + expect(listTable2.getHeaderField(4, 0)).toBe('status'); + listTable2.release(); + }); test('listTable dragHeader interaction', () => { option.transpose = true; listTable.updateOption(option); diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 1a79253a3..47e7c0d69 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -1056,10 +1056,28 @@ export class ListTable extends BaseTable implements ListTableAPI { } adjustHeightResizedRowMap(moveContext, this); } + this.syncColumnsStateFromLayoutMap(); return moveContext; } return null; } + private syncColumnsStateFromLayoutMap() { + const visibleColumns = this.internalProps.layoutMap.columnObjects.map(column => column.define); + let visibleIndex = 0; + const nextColumns = this.internalProps.columns.map(column => { + if (column.hide === true) { + return column; + } + const nextVisibleColumn = visibleColumns[visibleIndex]; + visibleIndex += 1; + return nextVisibleColumn ?? column; + }); + this.internalProps.columns = cloneDeepSpec(nextColumns, ['children']); + this.options.columns = nextColumns; + if (this.options.header) { + this.options.header = nextColumns; + } + } changeRecordOrder(sourceIndex: number, targetIndex: number) { if (this.transpose) { sourceIndex = this.getRecordShowIndexByCell(sourceIndex, 0);