mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
feat(data-grid-filter): 优化筛选框显示与性能 (#858)
## 功能 - 优化筛选功能在大数据量时的性能 - 实现值统计结果的升降序排序功能 - 将原有复选框列表替换为带排序功能的Ant Design表格 - 修复筛选弹窗内的事件冒泡问题,避免干扰列表头拖拽 ## 验证 - 聚焦测试:18/18 通过。 - tsc --noEmit、Vite 生产构建、git diff --check 均通过。
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user