fix(table): fix cross-filter not clearing on second click in Interactive Table (#39253)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxime Beauchemin
2026-04-15 10:30:36 -07:00
committed by GitHub
parent 44e77fdf2b
commit c2d96e0dce
8 changed files with 369 additions and 54 deletions

View File

@@ -40,7 +40,7 @@ import {
GridReadyEvent,
GridState,
CellClickedEvent,
IMenuActionParams,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { t } from '@apache-superset/core/translation';
import {
@@ -96,8 +96,9 @@ export interface AgGridTableProps {
percentMetrics: string[];
serverPageLength: number;
hasServerPageLengthChanged: boolean;
handleCrossFilter: (event: CellClickedEvent | IMenuActionParams) => void;
isActiveFilterValue: (key: string, val: DataRecordValue) => boolean;
handleCellClicked: (event: CellClickedEvent) => void;
handleSelectionChanged: (event: SelectionChangedEvent) => void;
filters?: Record<string, DataRecordValue[]> | null;
renderTimeComparisonDropdown: () => JSX.Element | null;
cleanedTotals: DataRecord;
showTotals: boolean;
@@ -135,8 +136,9 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
percentMetrics,
serverPageLength,
hasServerPageLengthChanged,
handleCrossFilter,
isActiveFilterValue,
handleCellClicked,
handleSelectionChanged,
filters,
renderTimeComparisonDropdown,
cleanedTotals,
showTotals,
@@ -422,6 +424,15 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
}
}, [width]);
useEffect(() => {
if (
(!filters || Object.keys(filters).length === 0) &&
gridRef.current?.api?.getSelectedRows().length
) {
gridRef.current.api.deselectAll();
}
}, [filters]);
const onGridReady = (params: GridReadyEvent) => {
// This will make columns fill the grid width
params.api.sizeColumnsToFit();
@@ -500,7 +511,8 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
onColumnGroupOpened={params => params.api.sizeColumnsToFit()}
rowSelection="multiple"
animateRows
onCellClicked={handleCrossFilter}
onCellClicked={handleCellClicked}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
@@ -592,7 +604,6 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
initialSortState: getInitialSortState(
serverPaginationData?.sortBy || [],
),
isActiveFilterValue,
lastFilteredColumn: serverPaginationData?.lastFilteredColumn,
lastFilteredInputPosition:
serverPaginationData?.lastFilteredInputPosition,

View File

@@ -23,12 +23,12 @@ import {
getTimeFormatterForGranularity,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useCallback, useEffect, useState, useMemo } from 'react';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { isEqual } from 'lodash';
import {
CellClickedEvent,
IMenuActionParams,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
import {
AgGridTableChartTransformedProps,
@@ -40,7 +40,7 @@ import AgGridDataTable from './AgGridTable';
import { updateTableOwnState } from './utils/externalAPIs';
import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVisibility';
import { useColDefs } from './utils/useColDefs';
import { getCrossFilterDataMask } from './utils/getCrossFilterDataMask';
import { buildSelectionCrossFilterDataMask } from './utils/getCrossFilterDataMask';
import { StyledChartContainer } from './styles';
import type { FilterState } from './utils/filterStateManager';
@@ -248,7 +248,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const isActiveFilterValue = useCallback(
function isActiveFilterValue(key: string, val: DataRecordValue) {
return !!filters && filters[key]?.includes(val);
if (!filters || !filters[key]) return false;
return filters[key].some(filterVal => {
if (filterVal === val) return true;
if (filterVal instanceof Date && val instanceof Date) {
return filterVal.getTime() === val.getTime();
}
return false;
});
},
[filters],
);
@@ -263,37 +270,68 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[timeGrain, isRawRecords],
);
const toggleFilter = useCallback(
(event: CellClickedEvent | IMenuActionParams) => {
const activeColumnRef = useRef<string | null>(null);
const handleCellClicked = useCallback(
(event: CellClickedEvent) => {
if (!emitCrossFilters || !event.column) return;
const colDef = event.column.getColDef();
if (colDef.context?.isMetric || colDef.context?.isPercentMetric) return;
const key = event.column.getColId();
activeColumnRef.current = key;
// Re-click on already-filtered single selection → untoggle
// AG Grid doesn't change selection when re-clicking the same row,
// so onSelectionChanged won't fire — handle clear directly here
const selectedNodes = event.api.getSelectedNodes();
if (
emitCrossFilters &&
event.column &&
!(
event.column.getColDef().context?.isMetric ||
event.column.getColDef().context?.isPercentMetric
)
selectedNodes.length === 1 &&
selectedNodes[0] === event.node &&
isActiveFilterValue(key, event.value)
) {
const crossFilterProps = {
key: event.column.getColId(),
value: event.value,
filters,
timeGrain,
isActiveFilterValue,
timestampFormatter,
};
setDataMask(getCrossFilterDataMask(crossFilterProps).dataMask);
event.node.setSelected(false);
setDataMask(
buildSelectionCrossFilterDataMask({
key,
values: [],
timeGrain,
timestampFormatter,
}).dataMask,
);
}
},
[
emitCrossFilters,
setDataMask,
filters,
timeGrain,
isActiveFilterValue,
setDataMask,
timeGrain,
timestampFormatter,
],
);
const handleSelectionChanged = useCallback(
(event: SelectionChangedEvent) => {
if (!emitCrossFilters || !activeColumnRef.current) return;
const key = activeColumnRef.current;
const selectedRows = event.api.getSelectedRows();
const values = selectedRows
.map(row => row[key] as DataRecordValue)
.filter(v => v != null);
setDataMask(
buildSelectionCrossFilterDataMask({
key,
values,
timeGrain,
timestampFormatter,
}).dataMask,
);
},
[emitCrossFilters, setDataMask, timeGrain, timestampFormatter],
);
const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => {
const modifiedOwnState = {
@@ -395,11 +433,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
onFilterChanged={handleFilterChanged}
metricColumns={metricColumns}
id={slice_id}
handleCrossFilter={toggleFilter}
handleCellClicked={handleCellClicked}
handleSelectionChanged={handleSelectionChanged}
filters={filters}
percentMetrics={percentMetrics}
serverPageLength={serverPageLength}
hasServerPageLengthChanged={hasServerPageLengthChanged}
isActiveFilterValue={isActiveFilterValue}
renderTimeComparisonDropdown={
isUsingTimeComparison ? renderTimeComparisonVisibility : () => null
}

View File

@@ -27,15 +27,11 @@ type GetCellClassParams = CellClassParams & {
const getCellClass = (params: GetCellClassParams) => {
const { col, emitCrossFilters } = params;
const isActiveFilterValue = params?.context?.isActiveFilterValue;
let className = '';
if (emitCrossFilters) {
if (!col?.isMetric) {
className += ' dt-is-filter';
}
if (isActiveFilterValue?.(col?.key, params?.value)) {
className += ' dt-is-active-filter';
}
if (col?.config?.truncateLongCells) {
className += ' dt-truncate-cell';
}

View File

@@ -34,6 +34,55 @@ type GetCrossFilterDataMaskProps = {
timestampFormatter: (value: DataRecordValue) => string;
};
type BuildSelectionCrossFilterProps = {
key: string;
values: DataRecordValue[];
timeGrain?: TimeGranularity;
timestampFormatter: (value: DataRecordValue) => string;
};
export const buildSelectionCrossFilterDataMask = ({
key,
values,
timeGrain,
timestampFormatter,
}: BuildSelectionCrossFilterProps) => {
if (values.length === 0) {
return {
dataMask: {
extraFormData: { filters: [] },
filterState: { label: null, value: null, filters: null },
},
};
}
const updatedFilters: DataRecordFilters = { [key]: values };
const isTimestamp = key === DTTM_ALIAS;
const label = values
.map(v => (isTimestamp ? timestampFormatter(v) : v))
.join(', ');
return {
dataMask: {
extraFormData: {
filters: [
{
col: key,
op: 'IN' as const,
val: values.map(el => (el instanceof Date ? el.getTime() : el!)),
grain: isTimestamp ? timeGrain : undefined,
},
],
},
filterState: {
label,
value: [values],
filters: updatedFilters,
},
},
};
};
export const getCrossFilterDataMask = ({
key,
value,