feat(data-grid-filter): 优化筛选框显示与性能 (#858)

## 功能
- 优化筛选功能在大数据量时的性能
- 实现值统计结果的升降序排序功能
- 将原有复选框列表替换为带排序功能的Ant Design表格
- 修复筛选弹窗内的事件冒泡问题,避免干扰列表头拖拽

## 验证
- 聚焦测试:18/18 通过。
- tsc --noEmit、Vite 生产构建、git diff --check 均通过。
This commit is contained in:
Syngnat
2026-08-06 20:48:43 +08:00
committed by GitHub
10 changed files with 216 additions and 48 deletions

View File

@@ -41,6 +41,44 @@ vi.mock('antd', () => ({
{children}
</label>
),
Table: ({
columns = [],
dataSource = [],
onChange,
}: {
columns?: Array<{
key?: React.Key;
title?: React.ReactNode;
dataIndex?: string;
sortOrder?: string | null;
render?: (value: unknown, record: Record<string, unknown>, index: number) => React.ReactNode;
}>;
dataSource?: Array<Record<string, unknown>>;
onChange?: (...args: unknown[]) => void;
}) => (
<table
data-value-count-table="true"
data-count-sort-order={String(columns.find((column) => column.key === 'count')?.sortOrder || '')}
onClick={() => onChange?.({}, {}, { order: 'ascend' })}
>
<thead>
<tr>{columns.map((column) => <th key={column.key}>{column.title}</th>)}</tr>
</thead>
<tbody>
{dataSource.map((record, index) => (
<tr key={String(record.key || index)}>
{columns.map((column) => (
<td key={column.key}>
{column.render
? column.render(column.dataIndex ? record[column.dataIndex] : undefined, record, index)
: (column.dataIndex ? String(record[column.dataIndex] ?? '') : null)}
</td>
))}
</tr>
))}
</tbody>
</table>
),
Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => (
<>
<div data-testid="tooltip-title">{title}</div>
@@ -200,6 +238,46 @@ describe('DataGridColumnTitle', () => {
expect(markup).toContain('value="active"');
});
it('isolates pointer interactions inside the filter popover from column dragging', () => {
const renderer = create(
<DataGridColumnTitle
columnName="status"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
columnFilter={{
active: false,
operatorOptions: [{ value: '=', label: '=' }],
defaultOperator: '=',
filterLabel: 'Filter',
applyLabel: 'Apply',
clearLabel: 'Clear',
valuePlaceholder: 'Value',
secondValuePlaceholder: 'End value',
listValuePlaceholder: 'List values',
noValuePlaceholder: 'No value needed',
isNoValueOp: () => false,
isBetweenOp: () => false,
isListOp: () => false,
onApply: () => true,
onClear: () => true,
}}
/>,
);
const filterPopover = renderer.root.findByProps({ 'data-grid-column-filter-popover': 'true' });
const stopPropagation = vi.fn();
act(() => {
filterPopover.props.onMouseDown({ stopPropagation });
filterPopover.props.onPointerDown({ stopPropagation });
});
expect(stopPropagation).toHaveBeenCalledTimes(2);
});
it('applies the column filter from the popover action button', () => {
const onApply = vi.fn(() => true);
const renderer = create(
@@ -329,6 +407,8 @@ describe('DataGridColumnTitle', () => {
'data_grid.filter.value_counts.nullish': '(Null)',
'data_grid.filter.value_counts.empty': '(Empty)',
'data_grid.filter.value_counts.no_matches': 'No matches',
'data_grid.filter.value_counts.value': 'Value',
'data_grid.filter.value_counts.count': 'Count',
}[key] || key);
const renderer = create(
<DataGridColumnTitle
@@ -369,6 +449,11 @@ describe('DataGridColumnTitle', () => {
/>,
);
const valueCountTable = () => renderer.root.findByProps({ 'data-value-count-table': 'true' });
expect(valueCountTable().props['data-count-sort-order']).toBe('descend');
act(() => valueCountTable().props.onClick());
expect(valueCountTable().props['data-count-sort-order']).toBe('ascend');
const searchInput = renderer.root.findAllByType('input')
.find((input) => input.props.placeholder === 'Search values');
expect(searchInput).toBeTruthy();

View File

@@ -1,8 +1,13 @@
import React from 'react';
import { Button, Checkbox, Input, Popover, Select, Tooltip } from 'antd';
import { Button, Checkbox, Input, Popover, Select, Table, Tooltip } from 'antd';
import type { TableColumnsType } from 'antd';
import { FilterOutlined, LinkOutlined, PushpinOutlined, SearchOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
import type { DataGridColumnValueCount } from '../utils/dataGridClientFilter';
import {
sortGridColumnValueCounts,
type DataGridColumnValueCount,
type DataGridColumnValueCountSortOrder,
} from '../utils/dataGridClientFilter';
import type { FilterValueSelection } from '../utils/sql';
export type DataGridColumnTitleTranslate = (key: string, params?: I18nParams) => string;
@@ -116,6 +121,7 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
const [draftFilterValue, setDraftFilterValue] = React.useState(columnFilter?.initialValue || '');
const [draftFilterValue2, setDraftFilterValue2] = React.useState(columnFilter?.initialValue2 || '');
const [valueCountSearch, setValueCountSearch] = React.useState('');
const [valueCountSortOrder, setValueCountSortOrder] = React.useState<DataGridColumnValueCountSortOrder>('descend');
const [draftValueSelection, setDraftValueSelection] = React.useState<FilterValueSelection>(
normalizeValueSelection(columnFilter?.initialValueSelection),
);
@@ -303,6 +309,7 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
!normalizedValueCountSearch
|| getValueCountDisplay(item).toLocaleLowerCase().includes(normalizedValueCountSearch)
));
const sortedFilteredValueCounts = sortGridColumnValueCounts(filteredValueCounts, valueCountSortOrder);
const isValueCountSelected = (item: DataGridColumnValueCount) => {
if (item.kind === 'nullish') return !!draftValueSelection.includeNull;
if (item.kind === 'empty') return !!draftValueSelection.includeEmpty;
@@ -354,10 +361,62 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
});
if (applied !== false) setFilterPopoverOpen(false);
};
const valueCountTableColumns: TableColumnsType<DataGridColumnValueCount> = [
{
key: 'select',
width: 32,
align: 'center',
title: (
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '100%' }}>
<Checkbox
data-grid-column-value-select-all="true"
aria-label={translate('data_grid.filter.value_counts.select_all')}
title={translate('data_grid.filter.value_counts.select_all')}
checked={areAllVisibleValueCountsSelected}
indeterminate={hasVisibleValueCountSelection && !areAllVisibleValueCountsSelected}
disabled={filteredValueCounts.length === 0}
onChange={toggleVisibleValueCounts}
/>
</span>
),
render: (_value, item) => (
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '100%' }}>
<Checkbox
data-grid-column-value-count-kind={item.kind}
title={getValueCountDisplay(item)}
checked={isValueCountSelected(item)}
onChange={() => toggleValueCount(item)}
/>
</span>
),
},
{
key: 'value',
dataIndex: 'display',
title: translate('data_grid.filter.value_counts.value'),
ellipsis: true,
render: (_value, item) => (
<span title={getValueCountDisplay(item)}>
{getValueCountDisplay(item)}
</span>
),
},
{
key: 'count',
dataIndex: 'count',
width: 64,
align: 'right',
title: translate('data_grid.filter.value_counts.count'),
sorter: true,
sortOrder: valueCountSortOrder,
},
];
const filterPopoverContent = (
<div
data-grid-column-filter-popover="true"
onClick={stopColumnHeaderInteraction}
onMouseDown={stopColumnHeaderInteraction}
onPointerDown={stopColumnHeaderInteraction}
style={{
width: 260,
display: 'flex',
@@ -459,52 +518,30 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
placeholder={translate('data_grid.filter.value_counts.search_placeholder')}
onChange={(event) => setValueCountSearch(event.target.value)}
/>
<Checkbox
data-grid-column-value-select-all="true"
checked={areAllVisibleValueCountsSelected}
indeterminate={hasVisibleValueCountSelection && !areAllVisibleValueCountsSelected}
disabled={filteredValueCounts.length === 0}
onChange={toggleVisibleValueCounts}
>
{translate('data_grid.filter.value_counts.select_all')}
</Checkbox>
<div
className="custom-scrollbar"
className="data-grid-column-value-counts-table"
data-grid-column-value-counts="true"
style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}
style={{ minHeight: 0 }}
>
{filteredValueCounts.map((item) => (
<Checkbox
key={item.key}
data-grid-column-value-count-kind={item.kind}
title={getValueCountDisplay(item)}
checked={isValueCountSelected(item)}
onChange={() => toggleValueCount(item)}
style={{
minHeight: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '3px 6px',
color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(15,23,42,0.85)',
}}
>
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, minWidth: 0, width: '100%' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{getValueCountDisplay(item)}
</span>
<span style={{ flex: 'none', color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(15,23,42,0.46)' }}>
{item.count}
</span>
</span>
</Checkbox>
))}
{filteredValueCounts.length === 0 && (
<span style={{ padding: '6px 4px', fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.46)' : 'rgba(15,23,42,0.42)' }}>
{translate('data_grid.filter.value_counts.no_matches')}
</span>
)}
<Table<DataGridColumnValueCount>
bordered
columns={valueCountTableColumns}
dataSource={sortedFilteredValueCounts}
locale={{ emptyText: translate('data_grid.filter.value_counts.no_matches') }}
pagination={false}
rowHoverable={false}
rowKey="key"
scroll={{ x: 240, y: 180 }}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
sortDirections={['descend', 'ascend', 'descend']}
tableLayout="fixed"
virtual
onChange={(_pagination, _filters, sorter) => {
const nextSortOrder = Array.isArray(sorter) ? sorter[0]?.order : sorter.order;
setValueCountSortOrder(nextSortOrder === 'ascend' ? 'ascend' : 'descend');
}}
/>
</div>
</div>
)}

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { countGridColumnValues, filterRowsByGridConditions } from './dataGridClientFilter';
import {
countGridColumnValues,
filterRowsByGridConditions,
sortGridColumnValueCounts,
} from './dataGridClientFilter';
describe('countGridColumnValues', () => {
it('counts nullish, empty, scalar, and stable complex values separately', () => {
@@ -38,6 +42,26 @@ describe('countGridColumnValues', () => {
{ key: 'string:2', count: 1 },
]);
});
it('sorts value counts by count in either direction without changing tie ordering', () => {
const valueCounts = countGridColumnValues([
{ value: 'z' },
{ value: 'a' },
{ value: 'b' },
{ value: 'z' },
], 'value');
expect(sortGridColumnValueCounts(valueCounts, 'ascend').map(({ display, count }) => ({ display, count }))).toEqual([
{ display: 'a', count: 1 },
{ display: 'b', count: 1 },
{ display: 'z', count: 2 },
]);
expect(sortGridColumnValueCounts(valueCounts, 'descend').map(({ display, count }) => ({ display, count }))).toEqual([
{ display: 'z', count: 2 },
{ display: 'a', count: 1 },
{ display: 'b', count: 1 },
]);
});
});
describe('filterRowsByGridConditions', () => {

View File

@@ -8,6 +8,8 @@ export type DataGridColumnValueCount = {
count: number;
};
export type DataGridColumnValueCountSortOrder = 'ascend' | 'descend';
const stableSerializeComplexValue = (value: unknown, seen = new WeakSet<object>()): string => {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
@@ -75,8 +77,16 @@ export const countGridColumnValues = <T extends Record<string, any>>(
}
});
return Array.from(counts.values()).sort((left, right) => (
right.count - left.count
return sortGridColumnValueCounts(Array.from(counts.values()), 'descend');
};
export const sortGridColumnValueCounts = (
valueCounts: DataGridColumnValueCount[],
sortOrder: DataGridColumnValueCountSortOrder,
): DataGridColumnValueCount[] => {
const direction = sortOrder === 'ascend' ? 1 : -1;
return [...valueCounts].sort((left, right) => (
direction * (left.count - right.count)
|| left.display.localeCompare(right.display, undefined, { numeric: true, sensitivity: 'base' })
|| left.key.localeCompare(right.key)
));