You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2025/10/29 13:54:12 UTC
(superset) branch master updated: feat(Chart): Save Chart State globally (#35343)
This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 99b61143f6 feat(Chart): Save Chart State globally (#35343)
99b61143f6 is described below
commit 99b61143f644a2582a78a216574267fe036ee726
Author: Alexandru Soare <37...@users.noreply.github.com>
AuthorDate: Wed Oct 29 15:54:07 2025 +0200
feat(Chart): Save Chart State globally (#35343)
---
superset-embedded-sdk/src/index.ts | 5 +-
.../superset-ui-core/src/chart/types/Base.ts | 38 ++
.../src/components/ThemedAgGridReact/index.tsx | 1 +
.../packages/superset-ui-core/src/types/AgGrid.ts | 82 ++++
.../packages/superset-ui-core/src/types/index.ts | 1 +
.../src/AgGridTable/index.tsx | 124 +++++-
.../src/AgGridTableChart.tsx | 13 +
.../plugin-chart-ag-grid-table/src/buildQuery.ts | 201 ++++++++-
.../plugin-chart-ag-grid-table/src/index.ts | 6 +
.../src/stateConversion.ts | 234 ++++++++++
.../src/transformProps.ts | 4 +-
.../plugin-chart-ag-grid-table/src/types.ts | 3 +
.../test/buildQuery.test.ts | 478 +++++++++++++++++++++
superset-frontend/src/components/Chart/Chart.tsx | 2 +
.../src/components/Chart/ChartRenderer.jsx | 2 +
.../src/dashboard/actions/dashboardState.js | 21 +
superset-frontend/src/dashboard/actions/hydrate.js | 3 +-
.../components/URLShortLinkButton/index.tsx | 13 +-
.../components/gridComponents/Chart/Chart.jsx | 58 ++-
.../components/menu/ShareMenuItems/index.tsx | 13 +-
.../src/dashboard/containers/DashboardPage.tsx | 5 +-
.../src/dashboard/reducers/dashboardState.js | 34 ++
superset-frontend/src/dashboard/types.ts | 2 +
.../index.ts => src/dashboard/types/chartState.ts} | 25 +-
.../src/dashboard/util/chartStateConverter.ts | 165 +++++++
superset-frontend/src/embedded/api.tsx | 18 +-
superset-frontend/src/embedded/index.tsx | 1 +
superset-frontend/src/setup/setupPlugins.ts | 5 +
superset-frontend/src/utils/urlUtils.ts | 22 +-
superset/charts/schemas.py | 11 +
superset/connectors/sqla/models.py | 10 +
superset/dashboards/permalink/schemas.py | 10 +
superset/dashboards/permalink/types.py | 3 +-
superset/models/helpers.py | 51 ++-
superset/views/core.py | 2 +-
tests/integration_tests/sqla_models_tests.py | 130 ++++++
36 files changed, 1750 insertions(+), 46 deletions(-)
diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts
index 7af13aa908..c04873924a 100644
--- a/superset-embedded-sdk/src/index.ts
+++ b/superset-embedded-sdk/src/index.ts
@@ -89,7 +89,8 @@ export type EmbeddedDashboard = {
observeDataMask: (
callbackFn: ObserveDataMaskCallbackFn,
) => void;
- getDataMask: () => Record<string, any>;
+ getDataMask: () => Promise<Record<string, any>>;
+ getChartStates: () => Promise<Record<string, any>>;
setThemeConfig: (themeConfig: Record<string, any>) => void;
};
@@ -244,6 +245,7 @@ export async function embedDashboard({
ourPort.get<string>('getDashboardPermalink', { anchor });
const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs');
const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask');
+ const getChartStates = () => ourPort.get<Record<string, any>>('getChartStates');
const observeDataMask = (
callbackFn: ObserveDataMaskCallbackFn,
) => {
@@ -270,6 +272,7 @@ export async function embedDashboard({
getActiveTabs,
observeDataMask,
getDataMask,
+ getChartStates,
setThemeConfig
};
}
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts
index 86dea30c9d..f438593dca 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts
+++ b/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts
@@ -71,6 +71,44 @@ export type SetDataMaskHook = {
({ filterState, extraFormData, ownState }: DataMask): void;
};
+/**
+ * Backend-compatible filter clause for query execution
+ */
+export interface QueryFilterClause {
+ col: string;
+ op: string;
+ val: string | number | string[] | number[];
+}
+
+/**
+ * Backend-compatible sort specification
+ */
+export interface QuerySortBy {
+ id: string;
+ key: string;
+ desc: boolean;
+}
+
+/**
+ * Backend-compatible own state that will be sent to the chart data API.
+ * This represents the standardized format that the backend expects.
+ */
+export interface BackendOwnState {
+ sortBy?: QuerySortBy[];
+ columnOrder?: string[];
+ filters?: QueryFilterClause[];
+ [key: string]: unknown; // Allow additional properties for chart-specific needs
+}
+
+/**
+ * Converter function that transforms chart-specific state to backend format.
+ * Each chart plugin can implement this to convert its internal state representation
+ * to the standardized backend format.
+ */
+export type ChartStateConverter<TChartState = JsonObject> = (
+ chartState: TChartState,
+) => Partial<BackendOwnState>;
+
export interface PlainObject {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
diff --git a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx
index da3fc8c591..b6eb12f934 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx
@@ -161,6 +161,7 @@ export type { CustomCellRendererProps } from 'ag-grid-react';
export type {
ColDef,
Column,
+ ColumnState,
GridOptions,
GridState,
GridReadyEvent,
diff --git a/superset-frontend/packages/superset-ui-core/src/types/AgGrid.ts b/superset-frontend/packages/superset-ui-core/src/types/AgGrid.ts
new file mode 100644
index 0000000000..eb291e6b33
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/types/AgGrid.ts
@@ -0,0 +1,82 @@
+/**
+ * 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 type { ColumnState, SortModelItem } from 'ag-grid-community';
+
+// AG Grid filter type enums
+export enum AgGridFilterType {
+ Text = 'text',
+ Number = 'number',
+ Date = 'date',
+ Set = 'set',
+}
+
+export enum AgGridTextFilterOperator {
+ Equals = 'equals',
+ NotEqual = 'notEqual',
+ Contains = 'contains',
+ NotContains = 'notContains',
+ StartsWith = 'startsWith',
+ EndsWith = 'endsWith',
+ Blank = 'blank',
+ NotBlank = 'notBlank',
+}
+
+export enum AgGridNumberFilterOperator {
+ Equals = 'equals',
+ NotEqual = 'notEqual',
+ LessThan = 'lessThan',
+ LessThanOrEqual = 'lessThanOrEqual',
+ GreaterThan = 'greaterThan',
+ GreaterThanOrEqual = 'greaterThanOrEqual',
+ InRange = 'inRange',
+ Blank = 'blank',
+ NotBlank = 'notBlank',
+}
+
+export interface AgGridSortModel extends SortModelItem {
+ sortIndex?: number;
+}
+
+export interface AgGridFilter {
+ filterType?: string;
+ type?: string;
+ filter?: string | number;
+ filterTo?: number;
+ values?: string[];
+ dateFrom?: string;
+ dateTo?: string;
+ operator?: 'AND' | 'OR';
+ condition1?: AgGridFilter;
+ condition2?: AgGridFilter;
+ conditions?: AgGridFilter[];
+}
+
+export interface AgGridFilterModel {
+ [colId: string]: AgGridFilter;
+}
+
+export interface AgGridChartState {
+ columnState: ColumnState[];
+ sortModel: AgGridSortModel[];
+ filterModel: AgGridFilterModel;
+ columnOrder?: string[];
+ pageSize?: number;
+ currentPage?: number;
+}
diff --git a/superset-frontend/packages/superset-ui-core/src/types/index.ts b/superset-frontend/packages/superset-ui-core/src/types/index.ts
index cb6e6dcfcb..a868ae76ff 100644
--- a/superset-frontend/packages/superset-ui-core/src/types/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/types/index.ts
@@ -20,6 +20,7 @@ import { NumberFormatter } from '../number-format';
import { CurrencyFormatter } from '../currency-format';
export * from '../query/types';
+export * from './AgGrid';
export type Maybe<T> = T | null;
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx
index 8b830b2ed4..af997c021e 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx
@@ -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}
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx
index 1307869e82..25fd2ab848 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx
@@ -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>
);
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts
index 64b091e87f..d181387c88 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts
@@ -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
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/index.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/index.ts
index c146d85df3..a4135dc69e 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/index.ts
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/index.ts
@@ -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: [
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts
new file mode 100644
index 0000000000..b25e247c14
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts
@@ -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;
+}
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts
index 616619b3e4..c4e714a54b 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts
@@ -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,
};
};
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts
index 937c6ed778..b4b89a0a9d 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts
@@ -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 {
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts
new file mode 100644
index 0000000000..075a5ce59a
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts
@@ -0,0 +1,478 @@
+/**
+ * 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 { AdhocColumn, QueryMode, VizType } from '@superset-ui/core';
+import buildQuery from '../src/buildQuery';
+import { TableChartFormData } from '../src/types';
+
+const basicFormData: TableChartFormData = {
+ viz_type: VizType.Table,
+ datasource: '11__table',
+ query_mode: QueryMode.Aggregate,
+ groupby: ['state'],
+ metrics: ['count'],
+};
+
+const createAdhocColumn = (
+ sqlExpression: string,
+ label: string,
+): AdhocColumn => ({
+ sqlExpression,
+ label,
+ expressionType: 'SQL',
+});
+
+describe('plugin-chart-ag-grid-table', () => {
+ describe('buildQuery - sort mapping for server pagination', () => {
+ it('should map string column colId to backend identifier', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ sortBy: [{ key: 'state', desc: false }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['state', true]]);
+ });
+
+ it('should map AdhocColumn colId by sqlExpression', () => {
+ const adhocColumn = createAdhocColumn('degree_type', 'Highest Degree');
+
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ groupby: [adhocColumn],
+ },
+ {
+ ownState: {
+ sortBy: [{ key: 'degree_type', desc: false }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['degree_type', true]]);
+ });
+
+ it('should map AdhocColumn colId by label', () => {
+ const adhocColumn = createAdhocColumn('degree_type', 'Highest Degree');
+
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ groupby: [adhocColumn],
+ },
+ {
+ ownState: {
+ sortBy: [{ key: 'Highest Degree', desc: false }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['degree_type', true]]);
+ });
+
+ it('should map string metric colId to backend identifier', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ metrics: ['SUM(revenue)'],
+ },
+ {
+ ownState: {
+ sortBy: [{ key: 'SUM(revenue)', desc: true }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['SUM(revenue)', false]]);
+ });
+
+ it('should map percent metric with % prefix', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ metrics: ['revenue'],
+ percent_metrics: ['revenue'],
+ },
+ {
+ ownState: {
+ sortBy: [{ key: '%revenue', desc: false }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['revenue', true]]);
+ });
+
+ it('should handle desc sort direction correctly', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ sortBy: [{ key: 'state', desc: true }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['state', false]]);
+ });
+ });
+
+ describe('buildQuery - CSV export with sortModel', () => {
+ it('should use sortModel for download queries', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ sortModel: [{ colId: 'state', sort: 'asc' }],
+ sortBy: [{ key: 'other', desc: false }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([
+ ['state', true],
+ ['count', false],
+ ]);
+ });
+
+ it('should map sortModel with desc direction', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ sortModel: [{ colId: 'state', sort: 'desc' }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby[0]).toEqual(['state', false]);
+ });
+
+ it('should handle multi-column sort from sortModel', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ groupby: ['state', 'city'],
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ sortModel: [
+ { colId: 'state', sort: 'asc', sortIndex: 0 },
+ { colId: 'city', sort: 'desc', sortIndex: 1 },
+ ],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([
+ ['state', true],
+ ['city', false],
+ ]);
+ });
+ });
+
+ describe('buildQuery - stable sort tie-breaker', () => {
+ it('should add default orderby as tie-breaker for single-column CSV export', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ result_format: 'csv',
+ metrics: ['count'],
+ },
+ {
+ ownState: {
+ sortModel: [{ colId: 'state', sort: 'asc' }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([
+ ['state', true],
+ ['count', false],
+ ]);
+ });
+
+ it('should not add tie-breaker if primary sort matches default orderby', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ result_format: 'csv',
+ metrics: ['count'],
+ },
+ {
+ ownState: {
+ sortModel: [{ colId: 'count', sort: 'desc' }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['count', false]]);
+ });
+
+ it('should not add tie-breaker for multi-column sorts', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ groupby: ['state', 'city'],
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ sortModel: [
+ { colId: 'state', sort: 'asc' },
+ { colId: 'city', sort: 'desc' },
+ ],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([
+ ['state', true],
+ ['city', false],
+ ]);
+ });
+
+ it('should not add tie-breaker for non-download queries with server pagination', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ sortBy: [{ key: 'state', desc: false }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.orderby).toEqual([['state', true]]);
+ });
+ });
+
+ describe('buildQuery - filter handling for CSV export', () => {
+ it('should apply AG Grid filters for download queries', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ filters: [
+ {
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'NY'],
+ },
+ ],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'NY'],
+ });
+ });
+
+ it('should append AG Grid filters to existing filters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ result_format: 'csv',
+ adhoc_filters: [
+ {
+ expressionType: 'SIMPLE',
+ subject: 'country',
+ operator: '==',
+ comparator: 'USA',
+ clause: 'WHERE',
+ },
+ ],
+ },
+ {
+ ownState: {
+ filters: [
+ {
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'NY'],
+ },
+ ],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters?.length).toBeGreaterThan(1);
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'NY'],
+ });
+ });
+
+ it('should not apply filters for non-download queries', () => {
+ const query = buildQuery(basicFormData, {
+ ownState: {
+ filters: [
+ {
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'NY'],
+ },
+ ],
+ },
+ }).queries[0];
+
+ expect(query.filters).not.toContainEqual({
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'NY'],
+ });
+ });
+
+ it('should handle empty filters array', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ filters: [],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toBeDefined();
+ });
+ });
+
+ describe('buildQuery - column reordering for CSV export', () => {
+ it('should reorder columns based on columnOrder', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ groupby: ['state', 'city', 'country'],
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ columnOrder: ['city', 'country', 'state', 'count'],
+ },
+ },
+ ).queries[0];
+
+ expect(query.columns).toEqual(['city', 'country', 'state']);
+ });
+
+ it('should reorder metrics based on columnOrder', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ metrics: ['count', 'revenue', 'profit'],
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ columnOrder: ['state', 'profit', 'count', 'revenue'],
+ },
+ },
+ ).queries[0];
+
+ expect(query.metrics).toEqual(['profit', 'count', 'revenue']);
+ });
+
+ it('should preserve unmatched columns at the end', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ groupby: ['state', 'city', 'country'],
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ columnOrder: ['city'],
+ },
+ },
+ ).queries[0];
+
+ expect(query.columns[0]).toEqual('city');
+ expect(query.columns).toContain('state');
+ expect(query.columns).toContain('country');
+ });
+
+ it('should match AdhocColumn by sqlExpression in columnOrder', () => {
+ const adhocColumn = createAdhocColumn('degree_type', 'Highest Degree');
+
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ groupby: ['state', adhocColumn],
+ result_format: 'csv',
+ },
+ {
+ ownState: {
+ columnOrder: ['degree_type', 'state'],
+ },
+ },
+ ).queries[0];
+
+ expect(query.columns[0]).toMatchObject({
+ sqlExpression: 'degree_type',
+ });
+ });
+
+ it('should not reorder for non-download queries', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ groupby: ['state', 'city'],
+ },
+ {
+ ownState: {
+ columnOrder: ['city', 'state'],
+ },
+ },
+ ).queries[0];
+
+ expect(query.columns).toEqual(['state', 'city']);
+ });
+ });
+});
diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx
index 201b676953..02808d465f 100644
--- a/superset-frontend/src/components/Chart/Chart.tsx
+++ b/superset-frontend/src/components/Chart/Chart.tsx
@@ -28,6 +28,7 @@ import {
SqlaFormData,
ClientErrorObject,
type JsonObject,
+ type AgGridChartState,
} from '@superset-ui/core';
import type { ChartState, Datasource, ChartStatus } from 'src/explore/types';
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
@@ -80,6 +81,7 @@ export interface ChartProps {
datasetsStatus?: 'loading' | 'error' | 'complete';
isInView?: boolean;
emitCrossFilters?: boolean;
+ onChartStateChange?: (chartState: AgGridChartState) => void;
}
export type Actions = {
diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx
index 410a588813..61cf48fdb5 100644
--- a/superset-frontend/src/components/Chart/ChartRenderer.jsx
+++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx
@@ -64,6 +64,7 @@ const propTypes = {
postTransformProps: PropTypes.func,
source: PropTypes.oneOf([ChartSource.Dashboard, ChartSource.Explore]),
emitCrossFilters: PropTypes.bool,
+ onChartStateChange: PropTypes.func,
};
const BLANK = {};
@@ -126,6 +127,7 @@ class ChartRenderer extends Component {
this.props.actions?.updateDataMask(this.props.chartId, dataMask);
},
onLegendScroll: this.handleLegendScroll,
+ onChartStateChange: this.props.onChartStateChange,
};
// TODO: queriesResponse comes from Redux store but it's being edited by
diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js
index c92faa8ab3..20cd25a241 100644
--- a/superset-frontend/src/dashboard/actions/dashboardState.js
+++ b/superset-frontend/src/dashboard/actions/dashboardState.js
@@ -748,6 +748,27 @@ export function setFullSizeChartId(chartId) {
return { type: SET_FULL_SIZE_CHART_ID, chartId };
}
+export const UPDATE_CHART_STATE = 'UPDATE_CHART_STATE';
+export function updateChartState(chartId, vizType, chartState) {
+ return {
+ type: UPDATE_CHART_STATE,
+ chartId,
+ vizType,
+ chartState,
+ lastModified: Date.now(),
+ };
+}
+
+export const REMOVE_CHART_STATE = 'REMOVE_CHART_STATE';
+export function removeChartState(chartId) {
+ return { type: REMOVE_CHART_STATE, chartId };
+}
+
+export const RESTORE_CHART_STATES = 'RESTORE_CHART_STATES';
+export function restoreChartStates(chartStates) {
+ return { type: RESTORE_CHART_STATES, chartStates };
+}
+
// Undo history ---------------------------------------------------------------
export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js
index 9a9ebb6046..b6f11988e1 100644
--- a/superset-frontend/src/dashboard/actions/hydrate.js
+++ b/superset-frontend/src/dashboard/actions/hydrate.js
@@ -56,7 +56,7 @@ import { FilterBarOrientation } from '../types';
export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
export const hydrateDashboard =
- ({ history, dashboard, charts, dataMask, activeTabs }) =>
+ ({ history, dashboard, charts, dataMask, activeTabs, chartStates }) =>
(dispatch, getState) => {
const { user, common, dashboardState } = getState();
const { metadata, position_data: positionData } = dashboard;
@@ -310,6 +310,7 @@ export const hydrateDashboard =
activeTabs: activeTabs || dashboardState?.activeTabs || [],
datasetsStatus:
dashboardState?.datasetsStatus || ResourceStatus.Loading,
+ chartStates: chartStates || dashboardState?.chartStates || {},
chartCustomizationItems,
},
dashboardLayout,
diff --git a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx
index a0c24f51f3..e6c798f673 100644
--- a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx
+++ b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx
@@ -30,6 +30,7 @@ import { useToasts } from 'src/components/MessageToasts/withToasts';
import { shallowEqual, useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
import { Typography } from '@superset-ui/core/components/Typography';
+import { hasStatefulCharts } from 'src/dashboard/util/chartStateConverter';
export type URLShortLinkButtonProps = {
dashboardId: number;
@@ -49,21 +50,31 @@ export default function URLShortLinkButton({
const theme = useTheme();
const [shortUrl, setShortUrl] = useState('');
const { addDangerToast } = useToasts();
- const { dataMask, activeTabs } = useSelector(
+ const { dataMask, activeTabs, chartStates, sliceEntities } = useSelector(
(state: RootState) => ({
dataMask: state.dataMask,
activeTabs: state.dashboardState.activeTabs,
+ chartStates: state.dashboardState.chartStates,
+ sliceEntities: state.sliceEntities?.slices,
}),
shallowEqual,
);
const getCopyUrl = async () => {
try {
+ // Check if dashboard has AG Grid tables (Table V2)
+ const includeChartState =
+ hasStatefulCharts(sliceEntities) &&
+ chartStates &&
+ Object.keys(chartStates).length > 0;
+
const url = await getDashboardPermalink({
dashboardId,
dataMask,
activeTabs,
anchor: anchorLinkId,
+ chartStates: includeChartState ? chartStates : undefined,
+ includeChartState,
});
setShortUrl(url);
} catch (error) {
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx
index e91a1667b8..f30bcfa4e9 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx
@@ -38,6 +38,10 @@ import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants';
import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
+import {
+ convertChartStateToOwnState,
+ hasChartStateConverter,
+} from '../../../util/chartStateConverter';
import SliceHeader from '../../SliceHeader';
import MissingChart from '../../MissingChart';
@@ -49,6 +53,7 @@ import {
setFocusedFilterField,
toggleExpandSlice,
unsetFocusedFilterField,
+ updateChartState,
} from '../../../actions/dashboardState';
import { changeFilter } from '../../../actions/dashboardFilters';
import { refreshChart } from '../../../../components/Chart/chartAction';
@@ -200,6 +205,16 @@ const Chart = props => {
[boundActionCreators.logEvent, boundActionCreators.changeFilter, chart.id],
);
+ // Chart state handler for stateful charts
+ const handleChartStateChange = useCallback(
+ chartState => {
+ if (hasChartStateConverter(slice?.viz_type)) {
+ dispatch(updateChartState(props.id, slice.viz_type, chartState));
+ }
+ },
+ [dispatch, props.id, slice?.viz_type],
+ );
+
useEffect(() => {
if (isExpanded) {
const descriptionHeight =
@@ -280,6 +295,9 @@ const Chart = props => {
const allSliceIds = useSelector(state => state.dashboardState.sliceIds);
const nativeFilters = useSelector(state => state.nativeFilters?.filters);
const dataMask = useSelector(state => state.dataMask);
+ const chartStates = useSelector(
+ state => state.dashboardState.chartStates || EMPTY_OBJECT,
+ );
const labelsColor = useSelector(
state => state.dashboardInfo?.metadata?.label_colors || EMPTY_OBJECT,
);
@@ -383,20 +401,41 @@ const Chart = props => {
slice_id: slice.slice_id,
is_cached: isCached,
});
+
+ let ownState = dataMask[props.id]?.ownState || {};
+
+ // Convert chart-specific state to backend format using registered converter
+ if (
+ hasChartStateConverter(slice.viz_type) &&
+ chartStates[props.id]?.state
+ ) {
+ const convertedState = convertChartStateToOwnState(
+ slice.viz_type,
+ chartStates[props.id].state,
+ );
+ ownState = {
+ ...ownState,
+ ...convertedState,
+ };
+ }
+
exportChart({
formData: isFullCSV ? { ...formData, row_limit: maxRows } : formData,
resultType: isPivot ? 'post_processed' : 'full',
resultFormat: format,
force: true,
- ownState: dataMask[props.id]?.ownState,
+ ownState,
});
},
[
slice.slice_id,
+ slice.viz_type,
isCached,
formData,
- props.maxRows,
+ maxRows,
dataMask[props.id]?.ownState,
+ chartStates,
+ props.id,
boundActionCreators.logEvent,
],
);
@@ -537,7 +576,19 @@ const Chart = props => {
formData={formData}
labelsColor={labelsColor}
labelsColorMap={labelsColorMap}
- ownState={dataMask[props.id]?.ownState}
+ ownState={{
+ ...dataMask[props.id]?.ownState,
+ ...(hasChartStateConverter(slice.viz_type) &&
+ chartStates[props.id]?.state
+ ? {
+ ...convertChartStateToOwnState(
+ slice.viz_type,
+ chartStates[props.id].state,
+ ),
+ chartState: chartStates[props.id].state,
+ }
+ : {}),
+ }}
filterState={dataMask[props.id]?.filterState}
queriesResponse={chart.queriesResponse}
timeout={timeout}
@@ -547,6 +598,7 @@ const Chart = props => {
datasetsStatus={datasetsStatus}
isInView={props.isInView}
emitCrossFilters={emitCrossFilters}
+ onChartStateChange={handleChartStateChange}
/>
</ChartWrapper>
</SliceContainer>
diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx
index ed1cacc5cd..5659ec2e9b 100644
--- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx
+++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx
@@ -23,6 +23,7 @@ import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { shallowEqual, useSelector } from 'react-redux';
+import { hasStatefulCharts } from 'src/dashboard/util/chartStateConverter';
export interface ShareMenuItemProps
extends ComponentProps<typeof Menu.SubMenu> {
@@ -58,20 +59,30 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
disabled,
...rest
} = props;
- const { dataMask, activeTabs } = useSelector(
+ const { dataMask, activeTabs, chartStates, sliceEntities } = useSelector(
(state: RootState) => ({
dataMask: state.dataMask,
activeTabs: state.dashboardState.activeTabs,
+ chartStates: state.dashboardState.chartStates,
+ sliceEntities: state.sliceEntities?.slices,
}),
shallowEqual,
);
async function generateUrl() {
+ // Only include chart state for AG Grid tables
+ const includeChartState =
+ hasStatefulCharts(sliceEntities) &&
+ chartStates &&
+ Object.keys(chartStates).length > 0;
+
return getDashboardPermalink({
dashboardId,
dataMask,
activeTabs,
anchor: dashboardComponentId,
+ chartStates: includeChartState ? chartStates : undefined,
+ includeChartState,
});
}
diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
index 43dc7a591d..8b26461bfb 100644
--- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx
+++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
@@ -47,6 +47,7 @@ import {
} from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
import DashboardContainer from 'src/dashboard/containers/Dashboard';
import CrudThemeProvider from 'src/components/CrudThemeProvider';
+import type { DashboardChartStates } from 'src/dashboard/types/chartState';
import { nanoid } from 'nanoid';
import { RootState } from '../types';
@@ -174,10 +175,11 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// activeTabs is initialized with undefined so that it doesn't override
// the currently stored value when hydrating
let activeTabs: string[] | undefined;
+ let chartStates: DashboardChartStates | undefined;
if (permalinkKey) {
const permalinkValue = await getPermalinkValue(permalinkKey);
if (permalinkValue) {
- ({ dataMask, activeTabs } = permalinkValue.state);
+ ({ dataMask, activeTabs, chartStates } = permalinkValue.state);
}
} else if (nativeFilterKeyValue) {
dataMask = await getFilterValue(id, nativeFilterKeyValue);
@@ -197,6 +199,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
charts,
activeTabs,
dataMask,
+ chartStates,
}),
);
}
diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js
index d9444300f9..9336d56388 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardState.js
+++ b/superset-frontend/src/dashboard/reducers/dashboardState.js
@@ -50,6 +50,9 @@ import {
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE,
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED,
TOGGLE_NATIVE_FILTERS_BAR,
+ UPDATE_CHART_STATE,
+ REMOVE_CHART_STATE,
+ RESTORE_CHART_STATES,
} from '../actions/dashboardState';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
@@ -274,6 +277,37 @@ export default function dashboardStateReducer(state = {}, action) {
nativeFiltersBarOpen: action.isOpen,
};
},
+ [UPDATE_CHART_STATE]() {
+ const { chartId, vizType, chartState, lastModified } = action;
+ return {
+ ...state,
+ chartStates: {
+ ...state.chartStates,
+ [chartId]: {
+ chartId,
+ vizType,
+ state: chartState,
+ lastModified,
+ },
+ },
+ };
+ },
+ [REMOVE_CHART_STATE]() {
+ const { chartId } = action;
+ const updatedChartStates = { ...state.chartStates };
+ delete updatedChartStates[chartId];
+ return {
+ ...state,
+ chartStates: updatedChartStates,
+ };
+ },
+ [RESTORE_CHART_STATES]() {
+ const { chartStates } = action;
+ return {
+ ...state,
+ chartStates: chartStates || {},
+ };
+ },
};
if (action.type in actionHandlers) {
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 6f1a5fcf00..1848a708e5 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -128,6 +128,7 @@ export type DashboardState = {
dashboardId: number;
data: JsonObject;
};
+ chartStates?: Record<string, any>;
};
export type DashboardInfo = {
id: number;
@@ -245,6 +246,7 @@ export interface DashboardPermalinkState {
activeTabs: string[];
anchor: string;
urlParams?: UrlParamEntries;
+ chartStates?: Record<string, any>;
}
export interface DashboardPermalinkValue {
diff --git a/superset-frontend/packages/superset-ui-core/src/types/index.ts b/superset-frontend/src/dashboard/types/chartState.ts
similarity index 64%
copy from superset-frontend/packages/superset-ui-core/src/types/index.ts
copy to superset-frontend/src/dashboard/types/chartState.ts
index cb6e6dcfcb..89f98c326b 100644
--- a/superset-frontend/packages/superset-ui-core/src/types/index.ts
+++ b/superset-frontend/src/dashboard/types/chartState.ts
@@ -16,15 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { NumberFormatter } from '../number-format';
-import { CurrencyFormatter } from '../currency-format';
-export * from '../query/types';
+// Re-export AG Grid types from @superset-ui/core for convenience
+import type { AgGridChartState } from '@superset-ui/core';
-export type Maybe<T> = T | null;
+export type {
+ AgGridSortModel,
+ AgGridFilter,
+ AgGridFilterModel,
+ AgGridChartState,
+} from '@superset-ui/core';
-export type Optional<T> = T | undefined;
+export interface ChartState {
+ chartId: number;
+ vizType: string;
+ state: AgGridChartState;
+ lastModified?: number;
+}
-export type ValueOf<T> = T[keyof T];
-
-export type ValueFormatter = NumberFormatter | CurrencyFormatter;
+export interface DashboardChartStates {
+ [chartId: string]: ChartState;
+}
diff --git a/superset-frontend/src/dashboard/util/chartStateConverter.ts b/superset-frontend/src/dashboard/util/chartStateConverter.ts
new file mode 100644
index 0000000000..0b8fd836c8
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/chartStateConverter.ts
@@ -0,0 +1,165 @@
+/**
+ * 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 type {
+ ChartStateConverter,
+ BackendOwnState,
+ JsonObject,
+} from '@superset-ui/core';
+
+/**
+ * Registry of chart-specific state converters.
+ * Maps viz_type to a function that converts chart state to backend format.
+ *
+ * This allows chart plugins to define their own state conversion logic
+ * without polluting the dashboard code with viz-type-specific logic.
+ */
+class ChartStateConverterRegistry {
+ private converters = new Map<string, ChartStateConverter>();
+
+ /**
+ * Register a state converter for a specific viz type
+ */
+ register(vizType: string, converter: ChartStateConverter): void {
+ this.converters.set(vizType, converter);
+ }
+
+ /**
+ * Convert chart-specific state to backend-compatible ownState format.
+ * Returns an empty object if no converter is registered for the viz type.
+ */
+ convert(vizType: string, chartState: JsonObject): Partial<BackendOwnState> {
+ const converter = this.converters.get(vizType);
+ if (!converter) {
+ return {};
+ }
+
+ try {
+ return converter(chartState);
+ } catch (error) {
+ // Log error but don't throw - graceful degradation
+ console.warn(
+ `Error converting chart state for viz type "${vizType}":`,
+ error,
+ );
+ return {};
+ }
+ }
+
+ /**
+ * Check if a viz type has a registered state converter
+ */
+ has(vizType: string): boolean {
+ return this.converters.has(vizType);
+ }
+
+ /**
+ * Remove a registered converter (useful for testing)
+ */
+ unregister(vizType: string): void {
+ this.converters.delete(vizType);
+ }
+
+ /**
+ * Clear all registered converters (useful for testing)
+ */
+ clear(): void {
+ this.converters.clear();
+ }
+}
+
+// Singleton instance
+const registry = new ChartStateConverterRegistry();
+
+/**
+ * Register a state converter for a specific viz type
+ *
+ * @example
+ * ```typescript
+ * import { convertAgGridStateToOwnState } from 'plugins/plugin-chart-ag-grid-table';
+ * import { registerChartStateConverter } from 'src/dashboard/util/chartStateConverter';
+ *
+ * registerChartStateConverter('ag-grid-table', convertAgGridStateToOwnState);
+ * ```
+ */
+export function registerChartStateConverter(
+ vizType: string,
+ converter: ChartStateConverter,
+): void {
+ registry.register(vizType, converter);
+}
+
+/**
+ * Convert chart-specific state to backend-compatible ownState format
+ *
+ * @param vizType - The visualization type (e.g., 'ag-grid-table')
+ * @param chartState - The chart-specific state object
+ * @returns Backend-compatible ownState, or empty object if no converter is registered
+ */
+export function convertChartStateToOwnState(
+ vizType: string,
+ chartState: JsonObject,
+): Partial<BackendOwnState> {
+ return registry.convert(vizType, chartState);
+}
+
+/**
+ * Check if a viz type has a registered state converter
+ *
+ * @param vizType - The visualization type to check
+ * @returns true if a converter is registered for this viz type
+ */
+export function hasChartStateConverter(vizType: string): boolean {
+ return registry.has(vizType);
+}
+
+/**
+ * Check if a dashboard contains any charts with registered state converters
+ * (i.e., charts that maintain stateful behavior like sorting, filtering, column order)
+ *
+ * @param sliceEntities - The slice entities object from Redux state
+ * @returns true if at least one stateful chart exists in the dashboard
+ *
+ * @example
+ * ```typescript
+ * const hasStateful = hasStatefulCharts(state.sliceEntities.slices);
+ * if (hasStateful && chartStates) {
+ * // Include chart states in permalink
+ * }
+ * ```
+ */
+export function hasStatefulCharts(
+ sliceEntities: Record<string, any> | null | undefined,
+): boolean {
+ if (!sliceEntities) {
+ return false;
+ }
+
+ return Object.values(sliceEntities).some(
+ slice =>
+ slice &&
+ typeof slice === 'object' &&
+ 'viz_type' in slice &&
+ hasChartStateConverter(slice.viz_type),
+ );
+}
+
+// Export registry instance for testing purposes
+// eslint-disable-next-line no-underscore-dangle
+export const __chartStateConverterRegistryForTesting__ = registry;
diff --git a/superset-frontend/src/embedded/api.tsx b/superset-frontend/src/embedded/api.tsx
index 2665916758..dd6c077d02 100644
--- a/superset-frontend/src/embedded/api.tsx
+++ b/superset-frontend/src/embedded/api.tsx
@@ -20,6 +20,8 @@ import { DataMaskStateWithId } from '@superset-ui/core';
import getBootstrapData from 'src/utils/getBootstrapData';
import { store } from '../views/store';
import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils';
+import { DashboardChartStates } from '../dashboard/types/chartState';
+import { hasStatefulCharts } from '../dashboard/util/chartStateConverter';
const bootstrapData = getBootstrapData();
@@ -33,6 +35,7 @@ type EmbeddedSupersetApi = {
getDashboardPermalink: ({ anchor }: { anchor: string }) => Promise<string>;
getActiveTabs: () => string[];
getDataMask: () => DataMaskStateWithId;
+ getChartStates: () => DashboardChartStates;
};
const getScrollSize = (): Size => ({
@@ -46,18 +49,27 @@ const getDashboardPermalink = async ({
anchor: string;
}): Promise<string> => {
const state = store?.getState();
- const { dashboardId, dataMask, activeTabs } = {
+ const { dashboardId, dataMask, activeTabs, chartStates, sliceEntities } = {
dashboardId:
state?.dashboardInfo?.id || bootstrapData?.embedded!.dashboard_id,
dataMask: state?.dataMask,
activeTabs: state.dashboardState?.activeTabs,
+ chartStates: state.dashboardState?.chartStates,
+ sliceEntities: state?.sliceEntities?.slices,
};
+ const includeChartState =
+ hasStatefulCharts(sliceEntities) &&
+ chartStates &&
+ Object.keys(chartStates).length > 0;
+
return getDashboardPermalinkUtil({
dashboardId,
dataMask,
activeTabs,
anchor,
+ chartStates: includeChartState ? chartStates : undefined,
+ includeChartState,
});
};
@@ -65,9 +77,13 @@ const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
const getDataMask = () => store?.getState()?.dataMask || {};
+const getChartStates = () =>
+ store?.getState()?.dashboardState?.chartStates || {};
+
export const embeddedApi: EmbeddedSupersetApi = {
getScrollSize,
getDashboardPermalink,
getActiveTabs,
getDataMask,
+ getChartStates,
};
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx
index 9f9123909c..cc56b84db5 100644
--- a/superset-frontend/src/embedded/index.tsx
+++ b/superset-frontend/src/embedded/index.tsx
@@ -250,6 +250,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
);
Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs);
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
+ Switchboard.defineMethod('getChartStates', embeddedApi.getChartStates);
Switchboard.defineMethod(
'setThemeConfig',
(payload: { themeConfig: SupersetThemeConfig }) => {
diff --git a/superset-frontend/src/setup/setupPlugins.ts b/superset-frontend/src/setup/setupPlugins.ts
index cad8a8fe98..f222dccd5a 100644
--- a/superset-frontend/src/setup/setupPlugins.ts
+++ b/superset-frontend/src/setup/setupPlugins.ts
@@ -17,8 +17,10 @@
* under the License.
*/
import { getChartControlPanelRegistry } from '@superset-ui/core';
+import { convertAgGridStateToOwnState } from '@superset-ui/plugin-chart-ag-grid-table';
import MainPreset from '../visualizations/presets/MainPreset';
import setupPluginsExtra from './setupPluginsExtra';
+import { registerChartStateConverter } from '../dashboard/util/chartStateConverter';
import Separator from '../explore/controlPanels/Separator';
@@ -28,5 +30,8 @@ export default function setupPlugins() {
// TODO: Remove these shims once the control panel configs are moved into the plugin package.
getChartControlPanelRegistry().registerValue('separator', Separator);
+ // Register chart state converters for stateful charts
+ registerChartStateConverter('ag-grid-table', convertAgGridStateToOwnState);
+
setupPluginsExtra();
}
diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts
index 80e8948d22..7b094b8484 100644
--- a/superset-frontend/src/utils/urlUtils.ts
+++ b/superset-frontend/src/utils/urlUtils.ts
@@ -161,6 +161,8 @@ export function getDashboardPermalink({
dataMask,
activeTabs,
anchor, // the anchor part of the link which corresponds to the tab/chart id
+ chartStates, // chart-level customizations (optional)
+ includeChartState = false, // whether to include chart state in permalink (FALSE by default)
}: {
dashboardId: string | number;
/**
@@ -176,14 +178,28 @@ export function getDashboardPermalink({
* and highlighted upon page load.
*/
anchor?: string;
+ /**
+ * Chart-level state (column order, sorting, filtering, etc.)
+ */
+ chartStates?: JsonObject;
+ /**
+ * Whether to include chart state in the permalink (default: false)
+ */
+ includeChartState?: boolean;
}) {
- // only encode filter state if non-empty
- return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, {
+ const payload: JsonObject = {
urlParams: getDashboardUrlParams(),
dataMask,
activeTabs,
anchor,
- });
+ };
+
+ // Only include chart states when explicitly requested AND when they exist
+ if (includeChartState && chartStates && Object.keys(chartStates).length > 0) {
+ payload.chartStates = chartStates;
+ }
+
+ return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, payload);
}
const externalUrlRegex =
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 2e3dec7fd8..5704210c49 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -1018,6 +1018,17 @@ class ChartDataExtrasSchema(Schema):
},
allow_none=True,
)
+ column_order = fields.List(
+ fields.String(),
+ metadata={
+ "description": (
+ "Ordered list of column names for result ordering. "
+ "Used to preserve user's column reordering (including mixed "
+ "dimension columns and metrics)"
+ )
+ },
+ allow_none=True,
+ )
class AnnotationLayerSchema(Schema):
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 9749d07675..0ef9572c50 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -1629,6 +1629,16 @@ class SqlaTable(
if len(df.columns) > len(labels_expected):
df = df.iloc[:, 0 : len(labels_expected)]
df.columns = labels_expected
+
+ extras = query_obj.get("extras", {})
+ column_order = extras.get("column_order")
+ if column_order and isinstance(column_order, list):
+ existing_cols = [col for col in column_order if col in df.columns]
+ remaining_cols = [
+ col for col in df.columns if col not in existing_cols
+ ]
+ final_order = existing_cols + remaining_cols
+ df = df[final_order]
return df
try:
diff --git a/superset/dashboards/permalink/schemas.py b/superset/dashboards/permalink/schemas.py
index c3b81632db..4b3dc4e44d 100644
--- a/superset/dashboards/permalink/schemas.py
+++ b/superset/dashboards/permalink/schemas.py
@@ -52,6 +52,16 @@ class DashboardPermalinkStateSchema(Schema):
allow_none=True,
metadata={"description": "Optional anchor link added to url hash"},
)
+ chartStates = fields.Dict( # noqa: N815
+ required=False,
+ allow_none=True,
+ metadata={
+ "description": (
+ "Chart-level state for stateful tables "
+ "(column order, sorting, filtering)"
+ )
+ },
+ )
class DashboardPermalinkSchema(Schema):
diff --git a/superset/dashboards/permalink/types.py b/superset/dashboards/permalink/types.py
index 4961d2a17b..c1a3d27c51 100644
--- a/superset/dashboards/permalink/types.py
+++ b/superset/dashboards/permalink/types.py
@@ -17,11 +17,12 @@
from typing import Any, Optional, TypedDict
-class DashboardPermalinkState(TypedDict):
+class DashboardPermalinkState(TypedDict, total=False):
dataMask: Optional[dict[str, Any]]
activeTabs: Optional[list[str]]
anchor: Optional[str]
urlParams: Optional[list[tuple[str, str]]]
+ chartStates: Optional[dict[str, Any]]
class DashboardPermalinkValue(TypedDict):
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 88230d7bc9..87f354a6f3 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -2035,8 +2035,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if groupby_all_columns:
qry = qry.group_by(*groupby_all_columns.values())
- where_clause_and = []
- having_clause_and = []
+ where_clause_and: list[ColumnElement] = []
+ having_clause_and: list[ColumnElement] = []
for flt in filter: # type: ignore
if not all(flt.get(s) for s in ["col", "op"]):
@@ -2047,6 +2047,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
op = utils.FilterOperator(flt["op"].upper())
col_obj: Optional["TableColumn"] = None
sqla_col: Optional[Column] = None
+ is_metric_filter = (
+ False # Track if this is a filter on a metric (needs HAVING clause)
+ )
if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col:
col_obj = dttm_col
elif is_adhoc_column(flt_col):
@@ -2057,13 +2060,32 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
rejected_adhoc_filters_columns.append(flt_col)
continue
else:
+ # Check if it's a regular column first
col_obj = columns_by_name.get(cast(str, flt_col))
+ # If not found in columns, check if it's a metric
+ # This supports filtering on metric columns for any chart type
+ if (
+ col_obj is None
+ and isinstance(flt_col, str)
+ and flt_col in metrics_by_name
+ ):
+ # Convert metric to SQLA column expression
+ sqla_col = metrics_by_name[flt_col].get_sqla_col(
+ template_processor=template_processor
+ )
+ is_metric_filter = True
filter_grain = flt.get("grain")
if get_column_name(flt_col) in removed_filters:
# Skip generating SQLA filter when the jinja template handles it.
continue
+ # Determine which clause list to use: HAVING for metrics, WHERE for columns
+ # Metric filters use HAVING clause because they involve aggregate functions
+ target_clause_list = (
+ having_clause_and if is_metric_filter else where_clause_and
+ )
+
if col_obj or sqla_col is not None:
if sqla_col is not None:
pass
@@ -2121,7 +2143,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
_(bus_resp["error_message"])
)
- where_clause_and.append(
+ target_clause_list.append(
ADVANCED_DATA_TYPES[col_advanced_data_type].translate_filter(
sqla_col, op, bus_resp["values"]
)
@@ -2144,20 +2166,20 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
cond = sqla_col.in_(eq)
if op == utils.FilterOperator.NOT_IN:
cond = ~cond
- where_clause_and.append(cond)
+ target_clause_list.append(cond)
elif op in {
utils.FilterOperator.IS_NULL,
utils.FilterOperator.IS_NOT_NULL,
}:
- where_clause_and.append(
+ target_clause_list.append(
db_engine_spec.handle_null_filter(sqla_col, op)
)
elif op == utils.FilterOperator.IS_TRUE:
- where_clause_and.append(
+ target_clause_list.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, True)
)
elif op == utils.FilterOperator.IS_FALSE:
- where_clause_and.append(
+ target_clause_list.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, False)
)
else:
@@ -2183,7 +2205,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
utils.FilterOperator.GREATER_THAN_OR_EQUALS,
utils.FilterOperator.LESS_THAN_OR_EQUALS,
}:
- where_clause_and.append(
+ target_clause_list.append(
db_engine_spec.handle_comparison_filter(sqla_col, op, eq)
)
elif op in {
@@ -2194,14 +2216,14 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
sqla_col = sa.cast(sqla_col, sa.String)
if op == utils.FilterOperator.LIKE:
- where_clause_and.append(sqla_col.like(eq))
+ target_clause_list.append(sqla_col.like(eq))
else:
- where_clause_and.append(sqla_col.ilike(eq))
+ target_clause_list.append(sqla_col.ilike(eq))
elif op in {utils.FilterOperator.NOT_LIKE}:
if target_generic_type != GenericDataType.STRING:
sqla_col = sa.cast(sqla_col, sa.String)
- where_clause_and.append(sqla_col.not_like(eq))
+ target_clause_list.append(sqla_col.not_like(eq))
elif (
op == utils.FilterOperator.TEMPORAL_RANGE
and isinstance(eq, str)
@@ -2212,7 +2234,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
time_shift=time_shift,
extras=extras,
)
- where_clause_and.append(
+ target_clause_list.append(
self.get_time_filter(
time_col=col_obj,
start_dttm=_since,
@@ -2226,6 +2248,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
raise QueryObjectValidationError(
_("Invalid filter operation type: %(op)s", op=op)
)
+ else:
+ # col_obj is None and sqla_col is None - column not found!
+ # Silently skip - this can happen for removed columns or invalid filters
+ pass
where_clause_and += self.get_sqla_row_level_filters(template_processor)
if extras:
where = extras.get("where")
@@ -2253,6 +2279,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
qry = qry.where(
self.get_fetch_values_predicate(template_processor=template_processor)
)
+
if granularity:
qry = qry.where(and_(*(time_filters + where_clause_and)))
else:
diff --git a/superset/views/core.py b/superset/views/core.py
index b7610f6a2f..3c67d21ffa 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -826,7 +826,7 @@ class Superset(BaseSupersetView):
# native_filters doesnt need to be encoded here
url = f"{url}&native_filters={param_val}"
else:
- params = parse.urlencode([param_key, param_val]) # type: ignore
+ params = parse.urlencode([(param_key, param_val)])
url = f"{url}&{params}"
if original_params := request.query_string.decode():
url = f"{url}&{original_params}"
diff --git a/tests/integration_tests/sqla_models_tests.py b/tests/integration_tests/sqla_models_tests.py
index c7b0590712..2e9c4c00e5 100644
--- a/tests/integration_tests/sqla_models_tests.py
+++ b/tests/integration_tests/sqla_models_tests.py
@@ -1135,3 +1135,133 @@ def test__temporal_range_operator_in_adhoc_filter(physical_dataset):
)
df = pd.DataFrame(index=[0], data={"col1": 4, "col2": "e"})
assert df.equals(result.df)
+
+
+def test_generic_metric_filtering_without_chart_flag(login_as_admin):
+ """
+ Test that filters on metrics work without chart-specific flags.
+
+ This ensures metric filtering is generic and works for any chart type.
+ """
+ from superset import db
+ from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+ from superset.utils.database import get_example_database
+
+ database = get_example_database()
+ table = SqlaTable(
+ table_name="test_metric_filter",
+ database=database,
+ )
+
+ col = TableColumn(
+ column_name="name",
+ type="VARCHAR(255)",
+ table=table,
+ )
+ table.columns = [col]
+
+ metric = SqlMetric(
+ metric_name="count",
+ expression="COUNT(*)",
+ table=table,
+ )
+ table.metrics = [metric]
+
+ db.session.add(table)
+ db.session.commit()
+
+ try:
+ query_obj = {
+ "granularity": None,
+ "from_dttm": None,
+ "to_dttm": None,
+ "groupby": ["name"],
+ "metrics": ["count"],
+ "filter": [
+ {
+ "col": "count",
+ "op": ">",
+ "val": 0,
+ }
+ ],
+ "is_timeseries": False,
+ "extras": {},
+ }
+
+ sqla_query = table.get_sqla_query(**query_obj)
+ sql = str(
+ sqla_query.sqla_query.compile(compile_kwargs={"literal_binds": True})
+ ).lower()
+
+ assert "having" in sql, "Metric filter should use HAVING clause. SQL: " + sql
+ finally:
+ db.session.delete(table)
+ db.session.commit()
+
+
+def test_column_ordering_without_chart_flag(login_as_admin):
+ """
+ Test that column_order works without chart-specific flags.
+
+ This ensures column ordering is generic and works for any chart type.
+ """
+ from unittest.mock import patch
+ from superset import db
+ from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+ from superset.utils.database import get_example_database
+
+ database = get_example_database()
+ table = SqlaTable(
+ table_name="test_column_order",
+ database=database,
+ )
+
+ col_a = TableColumn(column_name="col_a", type="VARCHAR(255)", table=table)
+ col_b = TableColumn(column_name="col_b", type="VARCHAR(255)", table=table)
+ table.columns = [col_a, col_b]
+
+ metric_x = SqlMetric(metric_name="metric_x", expression="COUNT(*)", table=table)
+ metric_y = SqlMetric(metric_name="metric_y", expression="SUM(val)", table=table)
+ table.metrics = [metric_x, metric_y]
+
+ db.session.add(table)
+ db.session.commit()
+
+ try:
+ mock_df = pd.DataFrame(
+ {
+ "col_a": [1, 2],
+ "col_b": [3, 4],
+ "metric_x": [10, 20],
+ "metric_y": [100, 200],
+ }
+ )
+
+ def mock_get_df(sql, catalog=None, schema=None, mutator=None):
+ """Mock get_df that calls the mutator function if provided."""
+ df = mock_df.copy()
+ if mutator:
+ df = mutator(df)
+ return df
+
+ with patch.object(database, "get_df", side_effect=mock_get_df):
+ query_obj = {
+ "granularity": None,
+ "from_dttm": None,
+ "to_dttm": None,
+ "groupby": ["col_a", "col_b"],
+ "metrics": ["metric_x", "metric_y"],
+ "filter": [],
+ "is_timeseries": False,
+ "extras": {"column_order": ["metric_y", "col_b", "metric_x", "col_a"]},
+ }
+
+ result = table.query(query_obj)
+
+ expected_order = ["metric_y", "col_b", "metric_x", "col_a"]
+ assert list(result.df.columns) == expected_order, (
+ f"Expected {expected_order}, got {list(result.df.columns)}"
+ )
+ finally:
+ db.session.delete(table)
+ db.session.commit()