mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-02 19:21:32 +08:00
✨ feat(data-grid): 支持数据视图列头筛选 #490
- 列头筛选复用工具栏筛选状态,应用后同步条件但不自动打开筛选面板 - 修复列头筛选应用、清除和操作符下拉交互 - 补充筛选状态同步、主键查询和列头交互回归测试
This commit is contained in:
@@ -2463,6 +2463,33 @@ describe('DataGrid layout', () => {
|
||||
expect(css).toContain('user-select: text !important;');
|
||||
});
|
||||
|
||||
it('wires data-view column header filters through the existing filter state', () => {
|
||||
const source = readDataGridSource();
|
||||
const dataViewerSource = readDataViewerSource();
|
||||
const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8');
|
||||
const columnTitleSource = readFileSync(new URL('./DataGridColumnTitle.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(filterHookSource).toContain('export type GridColumnFilterDraft');
|
||||
expect(filterHookSource).toContain('const applyColumnFilter = React.useCallback');
|
||||
expect(filterHookSource).toContain('onApplyFilter(nextConditions)');
|
||||
expect(source).toContain("const columnHeaderFilterEnabled = exportScope === 'table' && !!onApplyFilter;");
|
||||
expect(source).toContain("filterOpOptions.filter((option) => option.value !== 'CUSTOM')");
|
||||
expect(source).toContain("eventTarget?.closest?.('[data-grid-column-filter-trigger=\"true\"]')");
|
||||
expect(source).toContain("eventTarget?.closest?.('[data-grid-column-filter-popover=\"true\"]')");
|
||||
expect(source).toContain("eventTarget?.closest?.('.ant-select-dropdown')");
|
||||
expect(source).toContain('onApply: (draft) => applyColumnHeaderFilter(normalizedName, draft)');
|
||||
expect(source).toContain('onClear: () => clearColumnFilter(normalizedName)');
|
||||
expect(dataViewerSource).toContain('skipNextAutoFetchRef.current = false;');
|
||||
expect(dataViewerSource).toContain('setFilterConditions(normalizeViewerFilterConditions(conditions));');
|
||||
expect(columnTitleSource).toContain('data-grid-column-filter-trigger="true"');
|
||||
expect(columnTitleSource).toContain('const submitColumnFilter = (event?: React.SyntheticEvent<HTMLElement>) => {');
|
||||
expect(columnTitleSource).toContain('onClick={submitColumnFilter}');
|
||||
expect(columnTitleSource).toContain('onPressEnter={submitColumnFilter}');
|
||||
expect(columnTitleSource).toContain('getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}');
|
||||
expect(columnTitleSource).toContain('data-grid-column-filter-active={columnFilter.active ?');
|
||||
expect(columnTitleSource).toContain('data-grid-column-filter-popover="true"');
|
||||
});
|
||||
|
||||
it('keeps DataGrid scroll synchronization throttled to animation frames', () => {
|
||||
const source = readDataGridSource();
|
||||
const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -124,7 +124,7 @@ import {
|
||||
type V2CellContextMenuActionKey,
|
||||
type V2ColumnHeaderContextMenuActionKey,
|
||||
} from './V2TableContextMenu';
|
||||
import DataGridColumnTitle from './DataGridColumnTitle';
|
||||
import DataGridColumnTitle, { type DataGridColumnFilterDraft } from './DataGridColumnTitle';
|
||||
import DataGridColumnInfoPopoverContent from './DataGridColumnInfoPopoverContent';
|
||||
import DataGridColumnQuickFind from './DataGridColumnQuickFind';
|
||||
import DataGridPageFind from './DataGridPageFind';
|
||||
@@ -1041,29 +1041,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
openTableByName(String(target?.refTableName || '').trim());
|
||||
}, [openTableByName]);
|
||||
|
||||
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
|
||||
const normalizedName = String(name || '');
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()];
|
||||
|
||||
return (
|
||||
<DataGridColumnTitle
|
||||
columnName={normalizedName}
|
||||
columnMeta={meta}
|
||||
foreignKeyTarget={foreignKeyTarget}
|
||||
showColumnType={showColumnType}
|
||||
showColumnComment={showColumnComment}
|
||||
metaFontSize={densityParams.metaFontSize}
|
||||
columnMetaHintColor={columnMetaHintColor}
|
||||
columnMetaTooltipColor={columnMetaTooltipColor}
|
||||
darkMode={darkMode}
|
||||
highlighted={highlightedColumnName === normalizedName}
|
||||
translate={translateDataGrid}
|
||||
onOpenForeignKey={foreignKeyTarget ? () => openForeignKeyTarget(foreignKeyTarget) : undefined}
|
||||
/>
|
||||
);
|
||||
}, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, darkMode, densityParams.metaFontSize, foreignKeyMap, foreignKeyMapByLowerName, highlightedColumnName, openForeignKeyTarget, showColumnComment, showColumnType, translateDataGrid]);
|
||||
|
||||
const lockVirtualInlineTableScroll = useCallback((lock: boolean) => {
|
||||
if (lock) {
|
||||
if (virtualInlineScrollLockRef.current) {
|
||||
@@ -1261,6 +1238,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
addFilter,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
applyColumnFilter,
|
||||
clearColumnFilter,
|
||||
applyQuickWhereCondition,
|
||||
clearQuickWhereCondition,
|
||||
clearAllFiltersAndSorts,
|
||||
@@ -1290,6 +1269,102 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
resolveNextGridFilterOperatorForColumnChange,
|
||||
});
|
||||
|
||||
const columnHeaderFilterEnabled = exportScope === 'table' && !!onApplyFilter;
|
||||
const columnHeaderFilterOpOptions = useMemo(
|
||||
() => filterOpOptions.filter((option) => option.value !== 'CUSTOM'),
|
||||
[filterOpOptions],
|
||||
);
|
||||
const getColumnHeaderFilterState = useCallback((columnName: string) => {
|
||||
const normalizedName = String(columnName || '').trim();
|
||||
const columnFilterConditions = filterConditions.filter((cond) => (
|
||||
String(cond?.column || '') === normalizedName && String(cond?.op || '') !== 'CUSTOM'
|
||||
));
|
||||
const firstCondition = columnFilterConditions[0];
|
||||
const defaultOperator = resolveDefaultGridFilterOperator(getColumnFilterType(normalizedName));
|
||||
return {
|
||||
active: columnFilterConditions.some((cond) => cond.enabled !== false),
|
||||
defaultOperator,
|
||||
initialOperator: String(firstCondition?.op || defaultOperator),
|
||||
initialValue: String(firstCondition?.value ?? ''),
|
||||
initialValue2: String(firstCondition?.value2 ?? ''),
|
||||
};
|
||||
}, [filterConditions, getColumnFilterType]);
|
||||
|
||||
const applyColumnHeaderFilter = useCallback((columnName: string, draft: DataGridColumnFilterDraft) => {
|
||||
return applyColumnFilter({
|
||||
column: columnName,
|
||||
op: draft.op,
|
||||
value: draft.value,
|
||||
value2: draft.value2,
|
||||
});
|
||||
}, [applyColumnFilter]);
|
||||
|
||||
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
|
||||
const normalizedName = String(name || '');
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()];
|
||||
const columnFilterState = columnHeaderFilterEnabled ? getColumnHeaderFilterState(normalizedName) : null;
|
||||
|
||||
return (
|
||||
<DataGridColumnTitle
|
||||
columnName={normalizedName}
|
||||
columnMeta={meta}
|
||||
foreignKeyTarget={foreignKeyTarget}
|
||||
showColumnType={showColumnType}
|
||||
showColumnComment={showColumnComment}
|
||||
metaFontSize={densityParams.metaFontSize}
|
||||
columnMetaHintColor={columnMetaHintColor}
|
||||
columnMetaTooltipColor={columnMetaTooltipColor}
|
||||
darkMode={darkMode}
|
||||
highlighted={highlightedColumnName === normalizedName}
|
||||
translate={translateDataGrid}
|
||||
onOpenForeignKey={foreignKeyTarget ? () => openForeignKeyTarget(foreignKeyTarget) : undefined}
|
||||
columnFilter={columnFilterState ? {
|
||||
active: columnFilterState.active,
|
||||
operatorOptions: columnHeaderFilterOpOptions,
|
||||
defaultOperator: columnFilterState.defaultOperator,
|
||||
initialOperator: columnFilterState.initialOperator,
|
||||
initialValue: columnFilterState.initialValue,
|
||||
initialValue2: columnFilterState.initialValue2,
|
||||
filterLabel: translateDataGrid('data_grid.toolbar.filter'),
|
||||
applyLabel: translateDataGrid('data_grid.filter.apply'),
|
||||
clearLabel: translateDataGrid('data_grid.filter.clear'),
|
||||
valuePlaceholder: translateDataGrid('data_grid.filter.start_value_placeholder'),
|
||||
secondValuePlaceholder: translateDataGrid('data_grid.filter.end_value_placeholder'),
|
||||
listValuePlaceholder: translateDataGrid('data_grid.filter.list_values_placeholder'),
|
||||
noValuePlaceholder: translateDataGrid('data_grid.filter.no_value_placeholder'),
|
||||
isNoValueOp,
|
||||
isBetweenOp,
|
||||
isListOp,
|
||||
onApply: (draft) => applyColumnHeaderFilter(normalizedName, draft),
|
||||
onClear: () => clearColumnFilter(normalizedName),
|
||||
} : null}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
applyColumnHeaderFilter,
|
||||
clearColumnFilter,
|
||||
columnHeaderFilterEnabled,
|
||||
columnHeaderFilterOpOptions,
|
||||
columnMetaHintColor,
|
||||
columnMetaTooltipColor,
|
||||
columnMetaMap,
|
||||
columnMetaMapByLowerName,
|
||||
darkMode,
|
||||
densityParams.metaFontSize,
|
||||
foreignKeyMap,
|
||||
foreignKeyMapByLowerName,
|
||||
getColumnHeaderFilterState,
|
||||
highlightedColumnName,
|
||||
isBetweenOp,
|
||||
isListOp,
|
||||
isNoValueOp,
|
||||
openForeignKeyTarget,
|
||||
showColumnComment,
|
||||
showColumnType,
|
||||
translateDataGrid,
|
||||
]);
|
||||
|
||||
const selectedRowKeysRef = useRef(selectedRowKeys);
|
||||
const displayDataRef = useRef<any[]>([]);
|
||||
|
||||
@@ -2403,6 +2478,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (!onSort) return;
|
||||
const eventTarget = event.target as HTMLElement | null;
|
||||
if (eventTarget?.closest?.('[data-grid-fk-jump="true"]')) return;
|
||||
if (eventTarget?.closest?.('[data-grid-column-filter-trigger="true"]')) return;
|
||||
if (eventTarget?.closest?.('[data-grid-column-filter-popover="true"]')) return;
|
||||
if (eventTarget?.closest?.('.ant-select-dropdown')) return;
|
||||
const headerCell = event.currentTarget as HTMLElement;
|
||||
const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null;
|
||||
const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGridColumnTitle from './DataGridColumnTitle';
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Button: ({ children, type: buttonType, htmlType, ...props }: { children?: React.ReactNode; type?: string; htmlType?: 'button' | 'submit' | 'reset' }) => (
|
||||
<button type={htmlType || 'button'} data-button-type={buttonType} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Form: ({ children, component: Component = 'form', onFinish: _onFinish, ...props }: { children?: React.ReactNode; component?: React.ElementType; onFinish?: () => void }) => (
|
||||
<Component {...props}>{children}</Component>
|
||||
),
|
||||
Input: Object.assign(
|
||||
({ onPressEnter: _onPressEnter, ...props }: { onPressEnter?: () => void }) => <input {...props} />,
|
||||
{
|
||||
TextArea: ({ autoSize: _autoSize, ...props }: { autoSize?: unknown }) => <textarea {...props} />,
|
||||
},
|
||||
),
|
||||
Popover: ({ children, content, open }: { children: React.ReactNode; content?: React.ReactNode; open?: boolean }) => (
|
||||
<span data-popover-open={open ? 'true' : 'false'}>
|
||||
{content}
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Select: ({ options = [], value, onChange }: { options?: Array<{ value: string; label: string }>; value?: string; onChange?: (value: string) => void }) => (
|
||||
<select value={value} onChange={(event) => onChange?.(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => (
|
||||
<>
|
||||
<div data-testid="tooltip-title">{title}</div>
|
||||
@@ -14,6 +44,11 @@ vi.mock('antd', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
FilterOutlined: () => <span data-icon="filter" />,
|
||||
LinkOutlined: () => <span data-icon="link" />,
|
||||
}));
|
||||
|
||||
describe('DataGridColumnTitle', () => {
|
||||
it('marks v2 table headers as single-line when column type and comment rows are hidden', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
@@ -111,6 +146,170 @@ describe('DataGridColumnTitle', () => {
|
||||
expect(markup).toContain('data-ref-table-name="customers"');
|
||||
});
|
||||
|
||||
it('renders a compact column filter trigger with active state', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGridColumnTitle
|
||||
columnName="status"
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
columnFilter={{
|
||||
active: true,
|
||||
operatorOptions: [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: 'CONTAINS', label: 'Contains' },
|
||||
],
|
||||
defaultOperator: 'CONTAINS',
|
||||
initialOperator: '=',
|
||||
initialValue: 'active',
|
||||
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,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('class="gn-v2-column-title-shell"');
|
||||
expect(markup).toContain('data-grid-column-filter-trigger="true"');
|
||||
expect(markup).toContain('data-grid-column-filter-active="true"');
|
||||
expect(markup).toContain('data-grid-column-filter-popover="true"');
|
||||
expect(markup).toContain('Filter status');
|
||||
expect(markup).toContain('value="active"');
|
||||
});
|
||||
|
||||
it('applies the column filter from the popover action button', () => {
|
||||
const onApply = vi.fn(() => true);
|
||||
const renderer = create(
|
||||
<DataGridColumnTitle
|
||||
columnName="code"
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
columnFilter={{
|
||||
active: false,
|
||||
operatorOptions: [
|
||||
{ value: 'CONTAINS', label: 'Contains' },
|
||||
],
|
||||
defaultOperator: 'CONTAINS',
|
||||
initialOperator: 'CONTAINS',
|
||||
initialValue: '3551',
|
||||
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,
|
||||
onClear: () => true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const applyButton = renderer.root
|
||||
.findAllByType('button')
|
||||
.find((button) => button.children.includes('Apply'));
|
||||
|
||||
expect(applyButton).toBeTruthy();
|
||||
act(() => {
|
||||
applyButton!.props.onClick({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
op: 'CONTAINS',
|
||||
value: '3551',
|
||||
value2: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps column filter operator switching and clearing interactive', () => {
|
||||
const onApply = vi.fn(() => true);
|
||||
const onClear = vi.fn(() => true);
|
||||
const renderer = create(
|
||||
<DataGridColumnTitle
|
||||
columnName="title"
|
||||
showColumnType={false}
|
||||
showColumnComment={false}
|
||||
metaFontSize={11}
|
||||
columnMetaHintColor="#999"
|
||||
columnMetaTooltipColor="#fff"
|
||||
darkMode={false}
|
||||
columnFilter={{
|
||||
active: true,
|
||||
operatorOptions: [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: 'CONTAINS', label: 'Contains' },
|
||||
],
|
||||
defaultOperator: 'CONTAINS',
|
||||
initialOperator: 'CONTAINS',
|
||||
initialValue: '3551',
|
||||
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,
|
||||
onClear,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const operatorSelect = renderer.root.findByType('select');
|
||||
act(() => {
|
||||
operatorSelect.props.onChange({ target: { value: '=' } });
|
||||
});
|
||||
|
||||
const buttons = renderer.root.findAllByType('button');
|
||||
const clearButton = buttons.find((button) => button.children.includes('Clear'));
|
||||
const applyButton = buttons.find((button) => button.children.includes('Apply'));
|
||||
|
||||
expect(clearButton).toBeTruthy();
|
||||
expect(applyButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
applyButton!.props.onClick({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
});
|
||||
expect(onApply).toHaveBeenLastCalledWith({
|
||||
op: '=',
|
||||
value: '3551',
|
||||
value2: '',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
clearButton!.props.onClick();
|
||||
});
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses translated tooltip wrappers while preserving raw metadata values', () => {
|
||||
const translate = vi.fn((key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'data_grid.column.type_tooltip') return `TYPE ${String(params?.type)}`;
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { LinkOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Popover, Select, Tooltip } from 'antd';
|
||||
import { FilterOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
|
||||
export type DataGridColumnTitleTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
export type DataGridColumnFilterDraft = {
|
||||
op: string;
|
||||
value: string;
|
||||
value2?: string;
|
||||
};
|
||||
|
||||
export interface DataGridColumnFilterConfig {
|
||||
active: boolean;
|
||||
operatorOptions: Array<{ value: string; label: string }>;
|
||||
defaultOperator: string;
|
||||
initialOperator?: string;
|
||||
initialValue?: string;
|
||||
initialValue2?: string;
|
||||
filterLabel: string;
|
||||
applyLabel: string;
|
||||
clearLabel: string;
|
||||
valuePlaceholder: string;
|
||||
secondValuePlaceholder: string;
|
||||
listValuePlaceholder: string;
|
||||
noValuePlaceholder: string;
|
||||
isNoValueOp: (op: string) => boolean;
|
||||
isBetweenOp: (op: string) => boolean;
|
||||
isListOp: (op: string) => boolean;
|
||||
onApply: (draft: DataGridColumnFilterDraft) => boolean | void;
|
||||
onClear: () => boolean | void;
|
||||
}
|
||||
|
||||
export interface DataGridColumnTitleProps {
|
||||
columnName: string;
|
||||
columnMeta?: {
|
||||
@@ -24,8 +51,13 @@ export interface DataGridColumnTitleProps {
|
||||
highlighted?: boolean;
|
||||
translate?: DataGridColumnTitleTranslate;
|
||||
onOpenForeignKey?: () => void;
|
||||
columnFilter?: DataGridColumnFilterConfig | null;
|
||||
}
|
||||
|
||||
const stopColumnHeaderInteraction = (event: React.SyntheticEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
columnName,
|
||||
columnMeta,
|
||||
@@ -39,6 +71,7 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
highlighted = false,
|
||||
translate = defaultTranslate,
|
||||
onOpenForeignKey,
|
||||
columnFilter,
|
||||
}) => {
|
||||
const normalizedName = String(columnName || '');
|
||||
const columnType = String(columnMeta?.type || '').trim();
|
||||
@@ -48,6 +81,24 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
const shouldShowColumnType = showColumnType && columnType.length > 0;
|
||||
const shouldShowColumnComment = showColumnComment && columnComment.length > 0;
|
||||
const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment;
|
||||
const [filterPopoverOpen, setFilterPopoverOpen] = React.useState(false);
|
||||
const initialFilterOperator = columnFilter?.initialOperator || columnFilter?.defaultOperator || '=';
|
||||
const [draftFilterOperator, setDraftFilterOperator] = React.useState(initialFilterOperator);
|
||||
const [draftFilterValue, setDraftFilterValue] = React.useState(columnFilter?.initialValue || '');
|
||||
const [draftFilterValue2, setDraftFilterValue2] = React.useState(columnFilter?.initialValue2 || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!filterPopoverOpen || !columnFilter) return;
|
||||
setDraftFilterOperator(columnFilter.initialOperator || columnFilter.defaultOperator || '=');
|
||||
setDraftFilterValue(columnFilter.initialValue || '');
|
||||
setDraftFilterValue2(columnFilter.initialValue2 || '');
|
||||
}, [
|
||||
columnFilter?.defaultOperator,
|
||||
columnFilter?.initialOperator,
|
||||
columnFilter?.initialValue,
|
||||
columnFilter?.initialValue2,
|
||||
filterPopoverOpen,
|
||||
]);
|
||||
|
||||
const hoverLines: string[] = [];
|
||||
if (columnType) hoverLines.push(translate('data_grid.column.type_tooltip', { type: columnType }));
|
||||
@@ -151,35 +202,227 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hoverLines.length === 0) {
|
||||
return titleNode;
|
||||
const titleWithOptionalTooltip = (() => {
|
||||
if (hoverLines.length === 0) {
|
||||
return titleNode;
|
||||
}
|
||||
|
||||
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={(
|
||||
<pre
|
||||
className="gn-data-grid-column-meta-tooltip-content"
|
||||
style={{
|
||||
maxHeight: 260,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: tooltipTextColor,
|
||||
}}
|
||||
>
|
||||
{hoverLines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
rootClassName="gn-data-grid-column-meta-tooltip"
|
||||
styles={{ root: { maxWidth: 640 } }}
|
||||
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})();
|
||||
|
||||
if (!columnFilter) {
|
||||
return titleWithOptionalTooltip;
|
||||
}
|
||||
|
||||
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={(
|
||||
<pre
|
||||
className="gn-data-grid-column-meta-tooltip-content"
|
||||
style={{
|
||||
maxHeight: 260,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: tooltipTextColor,
|
||||
const noValueOperator = columnFilter.isNoValueOp(draftFilterOperator);
|
||||
const betweenOperator = columnFilter.isBetweenOp(draftFilterOperator);
|
||||
const listOperator = columnFilter.isListOp(draftFilterOperator);
|
||||
const activeColor = darkMode ? '#74d99f' : '#16a34a';
|
||||
const mutedColor = columnFilter.active
|
||||
? activeColor
|
||||
: (darkMode ? 'rgba(255,255,255,0.52)' : 'rgba(15, 23, 42, 0.46)');
|
||||
const filterButtonTitle = `${columnFilter.filterLabel} ${normalizedName}`;
|
||||
const submitColumnFilter = (event?: React.SyntheticEvent<HTMLElement>) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const applied = columnFilter.onApply({
|
||||
op: draftFilterOperator,
|
||||
value: draftFilterValue,
|
||||
value2: draftFilterValue2,
|
||||
});
|
||||
if (applied !== false) setFilterPopoverOpen(false);
|
||||
};
|
||||
const filterPopoverContent = (
|
||||
<div
|
||||
data-grid-column-filter-popover="true"
|
||||
onClick={stopColumnHeaderInteraction}
|
||||
style={{
|
||||
width: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 600,
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(15,23,42,0.88)',
|
||||
}}
|
||||
title={normalizedName}
|
||||
>
|
||||
{filterButtonTitle}
|
||||
</div>
|
||||
<Select
|
||||
size="small"
|
||||
value={draftFilterOperator}
|
||||
options={columnFilter.operatorOptions}
|
||||
popupMatchSelectWidth={false}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
|
||||
onChange={(value) => {
|
||||
const nextOperator = String(value || columnFilter.defaultOperator || '=');
|
||||
setDraftFilterOperator(nextOperator);
|
||||
if (columnFilter.isNoValueOp(nextOperator)) {
|
||||
setDraftFilterValue('');
|
||||
setDraftFilterValue2('');
|
||||
} else if (!columnFilter.isBetweenOp(nextOperator)) {
|
||||
setDraftFilterValue2('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{noValueOperator ? (
|
||||
<Input
|
||||
size="small"
|
||||
disabled
|
||||
value={columnFilter.noValuePlaceholder}
|
||||
/>
|
||||
) : listOperator ? (
|
||||
<Input.TextArea
|
||||
value={draftFilterValue}
|
||||
placeholder={columnFilter.listValuePlaceholder}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setDraftFilterValue(event.target.value)}
|
||||
/>
|
||||
) : betweenOperator ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input
|
||||
size="small"
|
||||
value={draftFilterValue}
|
||||
placeholder={columnFilter.valuePlaceholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onPressEnter={submitColumnFilter}
|
||||
onChange={(event) => setDraftFilterValue(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
value={draftFilterValue2}
|
||||
placeholder={columnFilter.secondValuePlaceholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onPressEnter={submitColumnFilter}
|
||||
onChange={(event) => setDraftFilterValue2(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
value={draftFilterValue}
|
||||
placeholder={columnFilter.valuePlaceholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onPressEnter={submitColumnFilter}
|
||||
onChange={(event) => setDraftFilterValue(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const cleared = columnFilter.onClear();
|
||||
if (cleared !== false) setFilterPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{hoverLines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
rootClassName="gn-data-grid-column-meta-tooltip"
|
||||
styles={{ root: { maxWidth: 640 } }}
|
||||
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
|
||||
{columnFilter.clearLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={submitColumnFilter}
|
||||
>
|
||||
{columnFilter.applyLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="gn-v2-column-title-shell"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
<span style={{ display: 'inline-flex', minWidth: 0, maxWidth: 'calc(100% - 24px)' }}>
|
||||
{titleWithOptionalTooltip}
|
||||
</span>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
open={filterPopoverOpen}
|
||||
onOpenChange={setFilterPopoverOpen}
|
||||
content={filterPopoverContent}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-grid-column-filter-trigger="true"
|
||||
data-grid-column-filter-active={columnFilter.active ? 'true' : undefined}
|
||||
aria-label={filterButtonTitle}
|
||||
title={filterButtonTitle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={stopColumnHeaderInteraction}
|
||||
onPointerDown={stopColumnHeaderInteraction}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
flex: '0 0 22px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
border: columnFilter.active ? `1px solid ${activeColor}` : '1px solid transparent',
|
||||
borderRadius: 6,
|
||||
background: columnFilter.active
|
||||
? (darkMode ? 'rgba(34, 197, 94, 0.14)' : 'rgba(34, 197, 94, 0.12)')
|
||||
: 'transparent',
|
||||
color: mutedColor,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<FilterOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -438,6 +438,48 @@ describe('DataViewer safe editing locator', () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('requeries table preview when a column header filter is applied', async () => {
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'missav_bot';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'id', key: 'PRI' }, { name: 'code', key: '' }],
|
||||
});
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: true,
|
||||
fields: ['id', 'code'],
|
||||
data: [{ id: 2, code: 'EROFV-3551' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<DataViewer tab={createTab({ dbName: 'missav_bot', tableName: 'videos', title: 'videos' })} />);
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
backendApp.DBQuery.mockClear();
|
||||
await act(async () => {
|
||||
dataGridState.latestProps?.onApplyFilter([{
|
||||
id: 1,
|
||||
enabled: true,
|
||||
logic: 'AND',
|
||||
column: 'code',
|
||||
op: 'CONTAINS',
|
||||
value: '3551',
|
||||
value2: '',
|
||||
}]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const filteredSelectSql = backendApp.DBQuery.mock.calls
|
||||
.map((call: any[]) => String(call[2] || ''))
|
||||
.find((sql: string) => /select\s+\*\s+from\s+`videos`/i.test(sql) && /where/i.test(sql));
|
||||
expect(filteredSelectSql).toContain("`code` LIKE '%3551%'");
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('keeps DuckDB table preview writable when primary key metadata arrives for a qualified table name', async () => {
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
|
||||
@@ -1151,7 +1151,17 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
}, []);
|
||||
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
||||
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
||||
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
|
||||
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => {
|
||||
skipNextAutoFetchRef.current = false;
|
||||
initialLoadRef.current = true;
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: 1,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
setFilterConditions(normalizeViewerFilterConditions(conditions));
|
||||
}, []);
|
||||
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
|
||||
const normalized = normalizeQuickWhereCondition(condition);
|
||||
const validation = validateQuickWhereCondition(normalized);
|
||||
@@ -1159,6 +1169,14 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
message.error(validation.message);
|
||||
return;
|
||||
}
|
||||
skipNextAutoFetchRef.current = false;
|
||||
initialLoadRef.current = true;
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: 1,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
setQuickWhereCondition(normalized);
|
||||
}, []);
|
||||
|
||||
|
||||
83
frontend/src/components/useDataGridFilters.test.tsx
Normal file
83
frontend/src/components/useDataGridFilters.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useDataGridFilters, type UseDataGridFiltersResult } from './useDataGridFilters';
|
||||
|
||||
const createFilterHookProps = (
|
||||
overrides: Partial<Parameters<typeof useDataGridFilters>[0]> = {},
|
||||
): Parameters<typeof useDataGridFilters>[0] => ({
|
||||
appliedFilterConditions: [],
|
||||
quickWhereCondition: '',
|
||||
showFilter: false,
|
||||
displayColumnNames: ['id', 'code', 'title'],
|
||||
allTableColumnNames: ['id', 'code', 'title'],
|
||||
columnMetaMap: {
|
||||
id: { type: 'bigint' },
|
||||
code: { type: 'varchar(50)' },
|
||||
title: { type: 'varchar(500)' },
|
||||
},
|
||||
dbType: 'mysql',
|
||||
darkMode: false,
|
||||
getColumnFilterType: (columnName) => {
|
||||
if (columnName === 'id') return 'bigint';
|
||||
return 'varchar(255)';
|
||||
},
|
||||
resolveDefaultGridFilterOperator: (columnType) => (
|
||||
String(columnType || '').toLowerCase().includes('char') ? 'CONTAINS' : '='
|
||||
),
|
||||
resolveNextGridFilterOperatorForColumnChange: () => 'CONTAINS',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useDataGridFilters', () => {
|
||||
it('syncs column-header filters into the shared toolbar filter state without requiring the filter panel to be open', () => {
|
||||
const onApplyFilter = vi.fn();
|
||||
const hookProps = createFilterHookProps({
|
||||
showFilter: false,
|
||||
onApplyFilter,
|
||||
});
|
||||
let latest: UseDataGridFiltersResult | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
|
||||
const Harness = () => {
|
||||
latest = useDataGridFilters(hookProps);
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
renderer = create(<Harness />);
|
||||
});
|
||||
|
||||
expect(latest?.filterConditions).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
expect(latest?.applyColumnFilter({
|
||||
column: 'code',
|
||||
op: 'CONTAINS',
|
||||
value: '3551',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
expect(latest?.filterConditions).toMatchObject([{
|
||||
enabled: true,
|
||||
logic: 'AND',
|
||||
column: 'code',
|
||||
op: 'CONTAINS',
|
||||
value: '3551',
|
||||
value2: '',
|
||||
}]);
|
||||
expect(onApplyFilter).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
logic: 'AND',
|
||||
column: 'code',
|
||||
op: 'CONTAINS',
|
||||
value: '3551',
|
||||
value2: '',
|
||||
}),
|
||||
]);
|
||||
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,13 @@ export type GridFilterConditionState = FilterCondition & {
|
||||
value2?: string;
|
||||
};
|
||||
|
||||
export type GridColumnFilterDraft = {
|
||||
column: string;
|
||||
op: string;
|
||||
value?: string;
|
||||
value2?: string;
|
||||
};
|
||||
|
||||
type GridSortInfo = {
|
||||
columnKey: string;
|
||||
order: string;
|
||||
@@ -65,6 +72,8 @@ export interface UseDataGridFiltersResult {
|
||||
addFilter: () => void;
|
||||
updateFilter: (id: number, field: keyof GridFilterConditionState, val: string | boolean) => void;
|
||||
removeFilter: (id: number) => void;
|
||||
applyColumnFilter: (draft: GridColumnFilterDraft) => boolean;
|
||||
clearColumnFilter: (columnName: string) => boolean;
|
||||
applyQuickWhereCondition: (condition?: string) => boolean;
|
||||
clearQuickWhereCondition: () => void;
|
||||
clearAllFiltersAndSorts: () => void;
|
||||
@@ -345,6 +354,76 @@ export const useDataGridFilters = ({
|
||||
if (onApplyFilter) onApplyFilter(filterConditions);
|
||||
}, [applyQuickWhereCondition, filterConditions, onApplyFilter]);
|
||||
|
||||
const applyColumnFilter = React.useCallback((draft: GridColumnFilterDraft): boolean => {
|
||||
const column = String(draft?.column || '').trim();
|
||||
if (!column) return false;
|
||||
if (!applyQuickWhereCondition()) return false;
|
||||
|
||||
const existingColumnConditions = filterConditions.filter((cond) => (
|
||||
String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM'
|
||||
));
|
||||
const existingPrimary = existingColumnConditions[0];
|
||||
const op = String(draft?.op || resolveDefaultGridFilterOperator(getColumnFilterType(column))).trim();
|
||||
const id = Number.isFinite(Number(existingPrimary?.id)) ? Number(existingPrimary?.id) : nextFilterId;
|
||||
existingColumnConditions.forEach((cond) => {
|
||||
autoDefaultFilterIdsRef.current.delete(cond.id);
|
||||
});
|
||||
autoDefaultFilterIdsRef.current.delete(id);
|
||||
|
||||
const nextCondition: GridFilterConditionState = {
|
||||
id,
|
||||
enabled: true,
|
||||
logic: normalizeFilterLogic(existingPrimary?.logic),
|
||||
column,
|
||||
op,
|
||||
value: isNoValueOp(op) ? '' : String(draft?.value ?? ''),
|
||||
value2: isNoValueOp(op) || !isBetweenOp(op) ? '' : String(draft?.value2 ?? ''),
|
||||
};
|
||||
const nextConditions = [
|
||||
...filterConditions.filter((cond) => !(
|
||||
String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM'
|
||||
)),
|
||||
nextCondition,
|
||||
];
|
||||
|
||||
setFilterConditions(nextConditions);
|
||||
if (!existingPrimary) {
|
||||
setNextFilterId((prev) => Math.max(prev, id + 1));
|
||||
}
|
||||
if (onApplyFilter) onApplyFilter(nextConditions);
|
||||
return true;
|
||||
}, [
|
||||
applyQuickWhereCondition,
|
||||
filterConditions,
|
||||
getColumnFilterType,
|
||||
isBetweenOp,
|
||||
isNoValueOp,
|
||||
nextFilterId,
|
||||
normalizeFilterLogic,
|
||||
onApplyFilter,
|
||||
resolveDefaultGridFilterOperator,
|
||||
]);
|
||||
|
||||
const clearColumnFilter = React.useCallback((columnName: string): boolean => {
|
||||
const column = String(columnName || '').trim();
|
||||
if (!column) return false;
|
||||
|
||||
const nextConditions = filterConditions.filter((cond) => !(
|
||||
String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM'
|
||||
));
|
||||
if (nextConditions.length === filterConditions.length) return true;
|
||||
if (!applyQuickWhereCondition()) return false;
|
||||
|
||||
filterConditions.forEach((cond) => {
|
||||
if (String(cond?.column || '') === column && String(cond?.op || '') !== 'CUSTOM') {
|
||||
autoDefaultFilterIdsRef.current.delete(cond.id);
|
||||
}
|
||||
});
|
||||
setFilterConditions(nextConditions);
|
||||
if (onApplyFilter) onApplyFilter(nextConditions);
|
||||
return true;
|
||||
}, [applyQuickWhereCondition, filterConditions, onApplyFilter]);
|
||||
|
||||
const applyAllFiltersEnabled = React.useCallback(() => {
|
||||
setFilterConditions((prev) => prev.map((cond) => ({ ...cond, enabled: true })));
|
||||
}, []);
|
||||
@@ -372,6 +451,8 @@ export const useDataGridFilters = ({
|
||||
addFilter,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
applyColumnFilter,
|
||||
clearColumnFilter,
|
||||
applyQuickWhereCondition,
|
||||
clearQuickWhereCondition,
|
||||
clearAllFiltersAndSorts,
|
||||
|
||||
Reference in New Issue
Block a user