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()