Skip to content

Commit 99b6114

Browse files
feat(Chart): Save Chart State globally (#35343)
1 parent 2db1900 commit 99b6114

File tree

36 files changed

+1772
-38
lines changed

36 files changed

+1772
-38
lines changed

superset-embedded-sdk/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ export type EmbeddedDashboard = {
8989
observeDataMask: (
9090
callbackFn: ObserveDataMaskCallbackFn,
9191
) => void;
92-
getDataMask: () => Record<string, any>;
92+
getDataMask: () => Promise<Record<string, any>>;
93+
getChartStates: () => Promise<Record<string, any>>;
9394
setThemeConfig: (themeConfig: Record<string, any>) => void;
9495
};
9596

@@ -244,6 +245,7 @@ export async function embedDashboard({
244245
ourPort.get<string>('getDashboardPermalink', { anchor });
245246
const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs');
246247
const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask');
248+
const getChartStates = () => ourPort.get<Record<string, any>>('getChartStates');
247249
const observeDataMask = (
248250
callbackFn: ObserveDataMaskCallbackFn,
249251
) => {
@@ -270,6 +272,7 @@ export async function embedDashboard({
270272
getActiveTabs,
271273
observeDataMask,
272274
getDataMask,
275+
getChartStates,
273276
setThemeConfig
274277
};
275278
}

superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,44 @@ export type SetDataMaskHook = {
7171
({ filterState, extraFormData, ownState }: DataMask): void;
7272
};
7373

74+
/**
75+
* Backend-compatible filter clause for query execution
76+
*/
77+
export interface QueryFilterClause {
78+
col: string;
79+
op: string;
80+
val: string | number | string[] | number[];
81+
}
82+
83+
/**
84+
* Backend-compatible sort specification
85+
*/
86+
export interface QuerySortBy {
87+
id: string;
88+
key: string;
89+
desc: boolean;
90+
}
91+
92+
/**
93+
* Backend-compatible own state that will be sent to the chart data API.
94+
* This represents the standardized format that the backend expects.
95+
*/
96+
export interface BackendOwnState {
97+
sortBy?: QuerySortBy[];
98+
columnOrder?: string[];
99+
filters?: QueryFilterClause[];
100+
[key: string]: unknown; // Allow additional properties for chart-specific needs
101+
}
102+
103+
/**
104+
* Converter function that transforms chart-specific state to backend format.
105+
* Each chart plugin can implement this to convert its internal state representation
106+
* to the standardized backend format.
107+
*/
108+
export type ChartStateConverter<TChartState = JsonObject> = (
109+
chartState: TChartState,
110+
) => Partial<BackendOwnState>;
111+
74112
export interface PlainObject {
75113
// eslint-disable-next-line @typescript-eslint/no-explicit-any
76114
[key: string]: any;

superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export type { CustomCellRendererProps } from 'ag-grid-react';
161161
export type {
162162
ColDef,
163163
Column,
164+
ColumnState,
164165
GridOptions,
165166
GridState,
166167
GridReadyEvent,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import type { ColumnState, SortModelItem } from 'ag-grid-community';
21+
22+
// AG Grid filter type enums
23+
export enum AgGridFilterType {
24+
Text = 'text',
25+
Number = 'number',
26+
Date = 'date',
27+
Set = 'set',
28+
}
29+
30+
export enum AgGridTextFilterOperator {
31+
Equals = 'equals',
32+
NotEqual = 'notEqual',
33+
Contains = 'contains',
34+
NotContains = 'notContains',
35+
StartsWith = 'startsWith',
36+
EndsWith = 'endsWith',
37+
Blank = 'blank',
38+
NotBlank = 'notBlank',
39+
}
40+
41+
export enum AgGridNumberFilterOperator {
42+
Equals = 'equals',
43+
NotEqual = 'notEqual',
44+
LessThan = 'lessThan',
45+
LessThanOrEqual = 'lessThanOrEqual',
46+
GreaterThan = 'greaterThan',
47+
GreaterThanOrEqual = 'greaterThanOrEqual',
48+
InRange = 'inRange',
49+
Blank = 'blank',
50+
NotBlank = 'notBlank',
51+
}
52+
53+
export interface AgGridSortModel extends SortModelItem {
54+
sortIndex?: number;
55+
}
56+
57+
export interface AgGridFilter {
58+
filterType?: string;
59+
type?: string;
60+
filter?: string | number;
61+
filterTo?: number;
62+
values?: string[];
63+
dateFrom?: string;
64+
dateTo?: string;
65+
operator?: 'AND' | 'OR';
66+
condition1?: AgGridFilter;
67+
condition2?: AgGridFilter;
68+
conditions?: AgGridFilter[];
69+
}
70+
71+
export interface AgGridFilterModel {
72+
[colId: string]: AgGridFilter;
73+
}
74+
75+
export interface AgGridChartState {
76+
columnState: ColumnState[];
77+
sortModel: AgGridSortModel[];
78+
filterModel: AgGridFilterModel;
79+
columnOrder?: string[];
80+
pageSize?: number;
81+
currentPage?: number;
82+
}

superset-frontend/packages/superset-ui-core/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { NumberFormatter } from '../number-format';
2020
import { CurrencyFormatter } from '../currency-format';
2121

2222
export * from '../query/types';
23+
export * from './AgGrid';
2324

2425
export type Maybe<T> = T | null;
2526

superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,33 @@ import {
2222
useMemo,
2323
useRef,
2424
memo,
25+
FunctionComponent,
2526
useState,
2627
ChangeEvent,
2728
useEffect,
29+
type RefObject,
2830
} from 'react';
2931

30-
import { ThemedAgGridReact } from '@superset-ui/core/components';
32+
import { Constants, ThemedAgGridReact } from '@superset-ui/core/components';
3133
import {
3234
AgGridReact,
3335
AllCommunityModule,
3436
ClientSideRowModelModule,
3537
type ColDef,
38+
type ColumnState,
3639
ModuleRegistry,
3740
GridReadyEvent,
3841
GridState,
3942
CellClickedEvent,
4043
IMenuActionParams,
4144
} from '@superset-ui/core/components/ThemedAgGridReact';
42-
import { type FunctionComponent } from 'react';
43-
import { JsonObject, DataRecordValue, DataRecord, t } from '@superset-ui/core';
45+
import {
46+
AgGridChartState,
47+
DataRecordValue,
48+
DataRecord,
49+
JsonObject,
50+
t,
51+
} from '@superset-ui/core';
4452
import { SearchOutlined } from '@ant-design/icons';
4553
import { debounce, isEqual } from 'lodash';
4654
import Pagination from './components/Pagination';
@@ -49,6 +57,17 @@ import { SearchOption, SortByItem } from '../types';
4957
import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
5058
import { PAGE_SIZE_OPTIONS } from '../consts';
5159

60+
export interface AgGridState extends Partial<GridState> {
61+
timestamp?: number;
62+
hasChanges?: boolean;
63+
}
64+
65+
// AgGridChartState with optional metadata fields for state change events
66+
export type AgGridChartStateWithMetadata = Partial<AgGridChartState> & {
67+
timestamp?: number;
68+
hasChanges?: boolean;
69+
};
70+
5271
export interface AgGridTableProps {
5372
gridTheme?: string;
5473
isDarkMode?: boolean;
@@ -80,6 +99,9 @@ export interface AgGridTableProps {
8099
cleanedTotals: DataRecord;
81100
showTotals: boolean;
82101
width: number;
102+
onColumnStateChange?: (state: AgGridChartStateWithMetadata) => void;
103+
gridRef?: RefObject<AgGridReact>;
104+
chartState?: AgGridChartState;
83105
}
84106

85107
ModuleRegistry.registerModules([AllCommunityModule, ClientSideRowModelModule]);
@@ -114,11 +136,14 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
114136
cleanedTotals,
115137
showTotals,
116138
width,
139+
onColumnStateChange,
140+
chartState,
117141
}) => {
118142
const gridRef = useRef<AgGridReact>(null);
119143
const inputRef = useRef<HTMLInputElement>(null);
120144
const rowData = useMemo(() => data, [data]);
121145
const containerRef = useRef<HTMLDivElement>(null);
146+
const lastCapturedStateRef = useRef<string | null>(null);
122147

123148
const searchId = `search-${id}`;
124149
const gridInitialState: GridState = {
@@ -211,6 +236,34 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
211236

212237
if (!isSortable) return;
213238

239+
if (serverPagination && gridRef.current?.api && onColumnStateChange) {
240+
const { api } = gridRef.current;
241+
242+
if (sortDir == null) {
243+
api.applyColumnState({
244+
defaultState: { sort: null },
245+
});
246+
} else {
247+
api.applyColumnState({
248+
defaultState: { sort: null },
249+
state: [{ colId, sort: sortDir as 'asc' | 'desc', sortIndex: 0 }],
250+
});
251+
}
252+
253+
const columnState = api.getColumnState?.() || [];
254+
const filterModel = api.getFilterModel?.() || {};
255+
const sortModel = sortDir
256+
? [{ colId, sort: sortDir as 'asc' | 'desc', sortIndex: 0 }]
257+
: [];
258+
259+
onColumnStateChange({
260+
columnState,
261+
sortModel,
262+
filterModel,
263+
timestamp: Date.now(),
264+
});
265+
}
266+
214267
if (sortDir == null) {
215268
onSortChange([]);
216269
return;
@@ -234,6 +287,51 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
234287
[serverPagination, gridInitialState, percentMetrics, onSortChange],
235288
);
236289

290+
const handleGridStateChange = useCallback(
291+
debounce(() => {
292+
if (onColumnStateChange && gridRef.current?.api) {
293+
try {
294+
const { api } = gridRef.current;
295+
296+
const columnState = api.getColumnState ? api.getColumnState() : [];
297+
298+
const filterModel = api.getFilterModel ? api.getFilterModel() : {};
299+
300+
const sortModel = columnState
301+
.filter(col => col.sort)
302+
.map(col => ({
303+
colId: col.colId,
304+
sort: col.sort as 'asc' | 'desc',
305+
sortIndex: col.sortIndex || 0,
306+
}))
307+
.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0));
308+
309+
const stateToSave = {
310+
columnState,
311+
sortModel,
312+
filterModel,
313+
timestamp: Date.now(),
314+
};
315+
316+
const stateHash = JSON.stringify({
317+
columnOrder: columnState.map(c => c.colId),
318+
sorts: sortModel,
319+
filters: filterModel,
320+
});
321+
322+
if (stateHash !== lastCapturedStateRef.current) {
323+
lastCapturedStateRef.current = stateHash;
324+
325+
onColumnStateChange(stateToSave);
326+
}
327+
} catch (error) {
328+
console.warn('Error capturing AG Grid state:', error);
329+
}
330+
}
331+
}, Constants.SLOW_DEBOUNCE),
332+
[onColumnStateChange],
333+
);
334+
237335
useEffect(() => {
238336
if (
239337
hasServerPageLengthChanged &&
@@ -257,6 +355,24 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
257355
const onGridReady = (params: GridReadyEvent) => {
258356
// This will make columns fill the grid width
259357
params.api.sizeColumnsToFit();
358+
359+
// Restore saved AG Grid state from permalink if available
360+
if (chartState && params.api) {
361+
try {
362+
if (chartState.columnState) {
363+
params.api.applyColumnState?.({
364+
state: chartState.columnState as ColumnState[],
365+
applyOrder: true,
366+
});
367+
}
368+
369+
if (chartState.filterModel) {
370+
params.api.setFilterModel?.(chartState.filterModel);
371+
}
372+
} catch {
373+
// Silently fail if state restoration fails
374+
}
375+
}
260376
};
261377

262378
return (
@@ -313,7 +429,9 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
313429
rowSelection="multiple"
314430
animateRows
315431
onCellClicked={handleCrossFilter}
432+
onStateUpdated={handleGridStateChange}
316433
initialState={gridInitialState}
434+
maintainColumnOrder
317435
suppressAggFuncInHeader
318436
enableCellTextSelection
319437
quickFilterText={serverPagination ? '' : quickFilterText}

0 commit comments

Comments
 (0)