feat(Chart): Save Chart State globally (#35343)

This commit is contained in:
Alexandru Soare
2025-10-29 15:54:07 +02:00
committed by GitHub
parent 2db19008fb
commit 99b61143f6
36 changed files with 1772 additions and 38 deletions
@@ -22,25 +22,33 @@ import {
useMemo,
useRef,
memo,
FunctionComponent,
useState,
ChangeEvent,
useEffect,
type RefObject,
} from 'react';
import { ThemedAgGridReact } from '@superset-ui/core/components';
import { Constants, ThemedAgGridReact } from '@superset-ui/core/components';
import {
AgGridReact,
AllCommunityModule,
ClientSideRowModelModule,
type ColDef,
type ColumnState,
ModuleRegistry,
GridReadyEvent,
GridState,
CellClickedEvent,
IMenuActionParams,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { type FunctionComponent } from 'react';
import { JsonObject, DataRecordValue, DataRecord, t } from '@superset-ui/core';
import {
AgGridChartState,
DataRecordValue,
DataRecord,
JsonObject,
t,
} from '@superset-ui/core';
import { SearchOutlined } from '@ant-design/icons';
import { debounce, isEqual } from 'lodash';
import Pagination from './components/Pagination';
@@ -49,6 +57,17 @@ import { SearchOption, SortByItem } from '../types';
import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
import { PAGE_SIZE_OPTIONS } from '../consts';
export interface AgGridState extends Partial<GridState> {
timestamp?: number;
hasChanges?: boolean;
}
// AgGridChartState with optional metadata fields for state change events
export type AgGridChartStateWithMetadata = Partial<AgGridChartState> & {
timestamp?: number;
hasChanges?: boolean;
};
export interface AgGridTableProps {
gridTheme?: string;
isDarkMode?: boolean;
@@ -80,6 +99,9 @@ export interface AgGridTableProps {
cleanedTotals: DataRecord;
showTotals: boolean;
width: number;
onColumnStateChange?: (state: AgGridChartStateWithMetadata) => void;
gridRef?: RefObject<AgGridReact>;
chartState?: AgGridChartState;
}
ModuleRegistry.registerModules([AllCommunityModule, ClientSideRowModelModule]);
@@ -114,11 +136,14 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
cleanedTotals,
showTotals,
width,
onColumnStateChange,
chartState,
}) => {
const gridRef = useRef<AgGridReact>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rowData = useMemo(() => data, [data]);
const containerRef = useRef<HTMLDivElement>(null);
const lastCapturedStateRef = useRef<string | null>(null);
const searchId = `search-${id}`;
const gridInitialState: GridState = {
@@ -211,6 +236,34 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
if (!isSortable) return;
if (serverPagination && gridRef.current?.api && onColumnStateChange) {
const { api } = gridRef.current;
if (sortDir == null) {
api.applyColumnState({
defaultState: { sort: null },
});
} else {
api.applyColumnState({
defaultState: { sort: null },
state: [{ colId, sort: sortDir as 'asc' | 'desc', sortIndex: 0 }],
});
}
const columnState = api.getColumnState?.() || [];
const filterModel = api.getFilterModel?.() || {};
const sortModel = sortDir
? [{ colId, sort: sortDir as 'asc' | 'desc', sortIndex: 0 }]
: [];
onColumnStateChange({
columnState,
sortModel,
filterModel,
timestamp: Date.now(),
});
}
if (sortDir == null) {
onSortChange([]);
return;
@@ -234,6 +287,51 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
[serverPagination, gridInitialState, percentMetrics, onSortChange],
);
const handleGridStateChange = useCallback(
debounce(() => {
if (onColumnStateChange && gridRef.current?.api) {
try {
const { api } = gridRef.current;
const columnState = api.getColumnState ? api.getColumnState() : [];
const filterModel = api.getFilterModel ? api.getFilterModel() : {};
const sortModel = columnState
.filter(col => col.sort)
.map(col => ({
colId: col.colId,
sort: col.sort as 'asc' | 'desc',
sortIndex: col.sortIndex || 0,
}))
.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0));
const stateToSave = {
columnState,
sortModel,
filterModel,
timestamp: Date.now(),
};
const stateHash = JSON.stringify({
columnOrder: columnState.map(c => c.colId),
sorts: sortModel,
filters: filterModel,
});
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;
onColumnStateChange(stateToSave);
}
} catch (error) {
console.warn('Error capturing AG Grid state:', error);
}
}
}, Constants.SLOW_DEBOUNCE),
[onColumnStateChange],
);
useEffect(() => {
if (
hasServerPageLengthChanged &&
@@ -257,6 +355,24 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
const onGridReady = (params: GridReadyEvent) => {
// This will make columns fill the grid width
params.api.sizeColumnsToFit();
// Restore saved AG Grid state from permalink if available
if (chartState && params.api) {
try {
if (chartState.columnState) {
params.api.applyColumnState?.({
state: chartState.columnState as ColumnState[],
applyOrder: true,
});
}
if (chartState.filterModel) {
params.api.setFilterModel?.(chartState.filterModel);
}
} catch {
// Silently fail if state restoration fails
}
}
};
return (
@@ -313,7 +429,9 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
rowSelection="multiple"
animateRows
onCellClicked={handleCrossFilter}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
enableCellTextSelection
quickFilterText={serverPagination ? '' : quickFilterText}
@@ -82,6 +82,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
columnColorFormatters,
basicColorFormatters,
width,
onChartStateChange,
chartState,
} = props;
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
@@ -110,6 +112,15 @@ export default function TableChart<D extends DataRecord = DataRecord>(
comparisonColumns?.[0]?.key,
]);
const handleColumnStateChange = useCallback(
agGridState => {
if (onChartStateChange) {
onChartStateChange(agGridState);
}
},
[onChartStateChange],
);
const filteredColumns = useMemo(() => {
if (!isUsingTimeComparison) {
return columns;
@@ -289,6 +300,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
cleanedTotals={totals || {}}
showTotals={showTotals}
width={width}
onColumnStateChange={handleColumnStateChange}
chartState={chartState}
/>
</StyledChartContainer>
);
@@ -20,8 +20,11 @@ import {
AdhocColumn,
buildQueryContext,
ensureIsArray,
getColumnLabel,
getMetricLabel,
isPhysicalColumn,
QueryFormColumn,
QueryFormMetric,
QueryFormOrderBy,
QueryMode,
QueryObject,
@@ -192,6 +195,7 @@ const buildQuery: BuildQuery<TableChartFormData> = (
const moreProps: Partial<QueryObject> = {};
const ownState = options?.ownState ?? {};
// Build Query flag to check if its for either download as csv, excel or json
const isDownloadQuery =
['csv', 'xlsx'].includes(formData?.result_format || '') ||
@@ -211,22 +215,133 @@ const buildQuery: BuildQuery<TableChartFormData> = (
moreProps.row_offset = currentPage * pageSize;
}
// getting sort by in case of server pagination from own state
let sortByFromOwnState: QueryFormOrderBy[] | undefined;
if (Array.isArray(ownState?.sortBy) && ownState?.sortBy.length > 0) {
const sortByItem = ownState?.sortBy[0];
sortByFromOwnState = [[sortByItem?.key, !sortByItem?.desc]];
const sortSource =
isDownloadQuery && ownState?.sortModel
? ownState.sortModel
: ownState?.sortBy;
if (Array.isArray(sortSource) && sortSource.length > 0) {
const mapColIdToIdentifier = (colId: string): string | undefined => {
const matchingColumn = columns.find((col: QueryFormColumn) => {
const colLabel = getColumnLabel(col);
return colLabel === colId;
});
if (matchingColumn) {
if (
typeof matchingColumn === 'object' &&
'sqlExpression' in matchingColumn
) {
return matchingColumn.sqlExpression;
}
return getColumnLabel(matchingColumn);
}
const matchingMetric = (metrics || []).find((met: QueryFormMetric) => {
const metLabel = getMetricLabel(met);
return metLabel === colId || `%${metLabel}` === colId;
});
if (matchingMetric) {
return getMetricLabel(matchingMetric);
}
return colId;
};
sortByFromOwnState = sortSource
.map((sortItem: any) => {
const colId = sortItem?.colId || sortItem?.key;
const sortKey = mapColIdToIdentifier(colId);
if (!sortKey) return null;
const isDesc = sortItem?.sort === 'desc' || sortItem?.desc;
return [sortKey, !isDesc] as QueryFormOrderBy;
})
.filter((item): item is QueryFormOrderBy => item !== null);
// Add secondary sort for stable ordering (matches AG Grid's stable sort behavior)
if (sortByFromOwnState.length === 1 && isDownloadQuery && orderby) {
const primarySort = sortByFromOwnState[0][0];
orderby.forEach(orderItem => {
if (orderItem[0] !== primarySort) {
sortByFromOwnState!.push(orderItem);
}
});
}
}
// Note: In Superset, "columns" are dimensions and "metrics" are measures,
// but AG Grid treats them all as "columns" in the UI
let orderedColumns = columns;
let orderedMetrics = metrics;
if (
isDownloadQuery &&
ownState.columnOrder &&
Array.isArray(ownState.columnOrder)
) {
type ColumnOrMetric = QueryFormColumn | QueryFormMetric;
const matchesColId = (item: ColumnOrMetric, colId: string): boolean => {
if (typeof item === 'string') {
return item === colId;
}
// Check AdhocColumn properties
if ('sqlExpression' in item || 'columnName' in item) {
return (
(item as AdhocColumn).sqlExpression === colId ||
item.label === colId
);
}
// Check metric properties
return getMetricLabel(item) === colId || item.label === colId;
};
const reorderByColumnOrder = (
items: ColumnOrMetric[],
): ColumnOrMetric[] => {
const ordered: ColumnOrMetric[] = [];
const remaining = new Set(items);
ownState.columnOrder.forEach((colId: string) => {
const match = items.find(
item => remaining.has(item) && matchesColId(item, colId),
);
if (match) {
ordered.push(match);
remaining.delete(match);
}
});
remaining.forEach(item => ordered.push(item));
return ordered;
};
orderedColumns = reorderByColumnOrder(columns) as typeof columns;
orderedMetrics = reorderByColumnOrder(metrics || []) as typeof metrics;
}
let queryObject = {
...baseQueryObject,
columns,
extras,
columns: orderedColumns,
extras: {
...extras,
// Pass column order to enable mixed column+metric ordering
...(isDownloadQuery &&
ownState.columnOrder &&
Array.isArray(ownState.columnOrder)
? { column_order: ownState.columnOrder }
: {}),
},
orderby:
formData.server_pagination && sortByFromOwnState
(formData.server_pagination || isDownloadQuery) && sortByFromOwnState
? sortByFromOwnState
: orderby,
metrics,
metrics: orderedMetrics,
post_processing: postProcessing,
time_offsets: timeOffsets,
...moreProps,
@@ -275,6 +390,43 @@ const buildQuery: BuildQuery<TableChartFormData> = (
];
}
/**
* Helper to determine if a column is a metric (needs HAVING) or dimension (needs WHERE)
*/
const isMetricColumn = (colId: string): boolean => {
const metricLabels = new Set(
(metrics || []).map(m =>
typeof m === 'string' ? m : getMetricLabel(m),
),
);
return metricLabels.has(colId) || colId.startsWith('%');
};
/**
* Helper to classify SQL clauses into WHERE (for dimensions) and HAVING (for metrics)
*/
const classifySQLClauses = (
sqlClauses: Record<string, string>,
): { whereClause?: string; havingClause?: string } => {
const whereClauses: string[] = [];
const havingClauses: string[] = [];
Object.entries(sqlClauses).forEach(([colId, sqlClause]) => {
if (isMetricColumn(colId)) {
havingClauses.push(sqlClause);
} else {
whereClauses.push(sqlClause);
}
});
return {
whereClause:
whereClauses.length > 0 ? whereClauses.join(' AND ') : undefined,
havingClause:
havingClauses.length > 0 ? havingClauses.join(' AND ') : undefined,
};
};
if (formData.server_pagination) {
// Add search filter if search text exists
if (ownState.searchText && ownState?.searchColumn) {
@@ -292,6 +444,39 @@ const buildQuery: BuildQuery<TableChartFormData> = (
}
}
if (isDownloadQuery) {
// Apply any QueryFilterClause filters from ownState (e.g., server pagination search)
if (ownState.filters?.length) {
queryObject.filters = [
...(queryObject.filters || []),
...ownState.filters,
];
}
// Apply AG Grid filters converted to SQL WHERE/HAVING clauses
if (ownState.sqlClauses) {
const { whereClause, havingClause } = classifySQLClauses(
ownState.sqlClauses as Record<string, string>,
);
if (whereClause || havingClause) {
queryObject.extras = {
...queryObject.extras,
...(whereClause && {
where: queryObject.extras?.where
? `${queryObject.extras.where} AND ${whereClause}`
: whereClause,
}),
...(havingClause && {
having: queryObject.extras?.having
? `${queryObject.extras.having} AND ${havingClause}`
: havingClause,
}),
};
}
}
}
// Now since row limit control is always visible even
// in case of server pagination
// we must use row limit from form data
@@ -33,6 +33,12 @@ import { TableChartFormData, TableChartProps } from './types';
// must export something for the module to be exist in dev mode
export { default as __hack__ } from './types';
export * from './types';
export {
convertAgGridStateToOwnState,
convertSortModel,
convertColumnState,
convertFilterModel,
} from './stateConversion';
const metadata = new ChartMetadata({
behaviors: [
@@ -0,0 +1,234 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
BackendOwnState,
QuerySortBy,
type AgGridChartState,
type AgGridSortModel,
type AgGridFilterModel,
type AgGridFilter,
} from '@superset-ui/core';
/**
* AG Grid text filter type to backend operator mapping
*/
const TEXT_FILTER_OPERATORS: Record<string, string> = {
equals: '==',
notEqual: '!=',
contains: 'ILIKE',
notContains: 'NOT ILIKE',
startsWith: 'ILIKE',
endsWith: 'ILIKE',
};
/**
* AG Grid number filter type to backend operator mapping
*/
const NUMBER_FILTER_OPERATORS: Record<string, string> = {
equals: '==',
notEqual: '!=',
lessThan: '<',
lessThanOrEqual: '<=',
greaterThan: '>',
greaterThanOrEqual: '>=',
};
function getTextComparator(type: string, value: string): string {
if (type === 'contains' || type === 'notContains') {
return `%${value}%`;
}
if (type === 'startsWith') {
return `${value}%`;
}
if (type === 'endsWith') {
return `%${value}`;
}
return value;
}
/**
* Converts AG Grid sortModel to backend sortBy format
*/
export function convertSortModel(
sortModel: AgGridSortModel[],
): QuerySortBy[] | undefined {
if (!sortModel || sortModel.length === 0) {
return undefined;
}
const sortItem = sortModel[0];
return [
{
id: sortItem.colId,
key: sortItem.colId,
desc: sortItem.sort === 'desc',
},
];
}
/**
* Extracts column order from AG Grid columnState
*/
export function convertColumnState(
columnState: Array<{ colId: string }>,
): string[] | undefined {
if (!columnState || columnState.length === 0) {
return undefined;
}
return columnState.map(col => col.colId);
}
/**
* Converts any AG Grid filter to a SQL WHERE/HAVING clause.
* Recursively handles both simple filters (single condition) and complex filters (multiple conditions with AND/OR).
*
* Examples:
* - Simple text: {filterType: 'text', type: 'contains', filter: 'abc'} → "column_name ILIKE '%abc%'"
* - Simple number: {filterType: 'number', type: 'greaterThan', filter: 5} → "column_name > 5"
* - Complex: {operator: 'AND', condition1: {type: 'greaterThan', filter: 1}, condition2: {type: 'lessThan', filter: 16}}
* → "(column_name > 1 AND column_name < 16)"
* - Set: {filterType: 'set', values: ['a', 'b']} → "column_name IN ('a', 'b')"
*/
function convertFilterToSQL(
colId: string,
filter: AgGridFilter,
): string | null {
// Complex filter: has operator and conditions
if (
filter.operator &&
(filter.condition1 || filter.condition2 || filter.conditions)
) {
const conditions: string[] = [];
// Collect all conditions
[filter.condition1, filter.condition2, ...(filter.conditions || [])]
.filter(Boolean)
.forEach(condition => {
const sql = convertFilterToSQL(colId, condition!);
if (sql) conditions.push(sql);
});
if (conditions.length === 0) return null;
if (conditions.length === 1) return conditions[0];
return `(${conditions.join(` ${filter.operator} `)})`;
}
if (filter.filterType === 'text' && filter.filter && filter.type) {
const op = TEXT_FILTER_OPERATORS[filter.type];
const val = getTextComparator(filter.type, String(filter.filter));
return op === 'ILIKE' || op === 'NOT ILIKE'
? `${colId} ${op} '${val}'`
: `${colId} ${op} '${filter.filter}'`;
}
if (
filter.filterType === 'number' &&
filter.filter !== undefined &&
filter.type
) {
const op = NUMBER_FILTER_OPERATORS[filter.type];
return `${colId} ${op} ${filter.filter}`;
}
if (filter.filterType === 'date' && filter.dateFrom && filter.type) {
const op = NUMBER_FILTER_OPERATORS[filter.type];
return `${colId} ${op} '${filter.dateFrom}'`;
}
if (
filter.filterType === 'set' &&
Array.isArray(filter.values) &&
filter.values.length > 0
) {
const values = filter.values.map((v: string) => `'${v}'`).join(', ');
return `${colId} IN (${values})`;
}
return null;
}
/**
* Converts AG Grid filterModel to SQL WHERE/HAVING clauses.
* All filters (simple and complex) are uniformly converted to SQL for consistent backend handling.
*
* Returns a map of column IDs to their SQL filter expressions.
*/
export function convertFilterModel(
filterModel: AgGridFilterModel,
): { sqlClauses?: Record<string, string> } | undefined {
if (!filterModel || Object.keys(filterModel).length === 0) {
return undefined;
}
const sqlClauses: Record<string, string> = {};
Object.entries(filterModel).forEach(([colId, filter]) => {
const sqlClause = convertFilterToSQL(colId, filter);
if (sqlClause) {
sqlClauses[colId] = sqlClause;
}
});
if (Object.keys(sqlClauses).length === 0) {
return undefined;
}
return { sqlClauses };
}
/**
* Base converter for AG Grid-based charts (table, pivot, etc.)
* Converts AG Grid state to backend-compatible format.
*
* This can be extended by specific implementations (pivot) that need
* additional conversion logic.
*/
export function convertAgGridStateToOwnState(
agGridState: AgGridChartState,
): Partial<BackendOwnState> {
const ownState: Partial<BackendOwnState> = {};
const sortBy = convertSortModel(agGridState.sortModel);
if (sortBy) {
ownState.sortBy = sortBy;
}
const columnOrder = convertColumnState(agGridState.columnState);
if (columnOrder) {
ownState.columnOrder = columnOrder;
}
const filterConversion = convertFilterModel(agGridState.filterModel);
if (filterConversion?.sqlClauses) {
ownState.sqlClauses = filterConversion.sqlClauses;
}
if (agGridState.pageSize !== undefined) {
ownState.pageSize = agGridState.pageSize;
}
if (agGridState.currentPage !== undefined) {
ownState.currentPage = agGridState.currentPage;
}
return ownState;
}
@@ -469,7 +469,7 @@ const transformProps = (
queriesData = [],
ownState: serverPaginationData,
filterState,
hooks: { setDataMask = () => {} },
hooks: { setDataMask = () => {}, onChartStateChange },
emitCrossFilters,
theme,
} = chartProps;
@@ -737,6 +737,8 @@ const transformProps = (
basicColorColumnFormatters,
basicColorFormatters,
formData,
chartState: serverPaginationData?.chartState,
onChartStateChange,
};
};
@@ -34,6 +34,7 @@ import {
Currency,
JsonObject,
Metric,
AgGridChartState,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import {
@@ -176,6 +177,8 @@ export interface AgGridTableChartTransformedProps<
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[];
formData: TableChartFormData;
onChartStateChange?: (chartState: JsonObject) => void;
chartState?: AgGridChartState;
}
export enum ColorSchemeEnum {