fix(ag-grid): persist AG Grid column filters in explore permalinks (#38393)

This commit is contained in:
amaannawab923
2026-03-11 01:56:24 +05:30
committed by GitHub
parent fe7f220c21
commit 9215eb5e45
12 changed files with 198 additions and 15 deletions
+19 -1
View File
@@ -36,6 +36,10 @@ import {
isChartCustomization,
} from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import {
HYDRATE_EXPLORE,
HydrateExplore,
} from 'src/explore/actions/hydrateExplore';
import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
import {
migrateChartCustomizationArray,
@@ -195,7 +199,7 @@ function updateDataMaskForFilterChanges(
const dataMaskReducer = produce(
(
draft: DataMaskStateWithId,
action: AnyDataMaskAction | HydrateDashboardAction,
action: AnyDataMaskAction | HydrateDashboardAction | HydrateExplore,
) => {
const cleanState: DataMaskStateWithId = {};
switch (action.type) {
@@ -286,6 +290,20 @@ const dataMaskReducer = produce(
return cleanState;
}
case HYDRATE_EXPLORE: {
const hydrateExploreAction = action as HydrateExplore;
const loadedDataMask = hydrateExploreAction.data.dataMask;
if (loadedDataMask) {
Object.entries(loadedDataMask).forEach(([id, mask]) => {
draft[id] = {
...getInitialDataMask(id),
...draft[id],
...mask,
};
});
}
return draft;
}
case SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE:
updateDataMaskForFilterChanges(
action.filterChanges,
@@ -153,6 +153,19 @@ export function setForceQuery(force: boolean) {
};
}
export const UPDATE_EXPLORE_CHART_STATE = 'UPDATE_EXPLORE_CHART_STATE';
export function updateExploreChartState(
chartId: number,
chartState: Record<string, unknown>,
) {
return {
type: UPDATE_EXPLORE_CHART_STATE,
chartId,
chartState,
lastModified: Date.now(),
};
}
export const SET_STASH_FORM_DATA = 'SET_STASH_FORM_DATA';
export function setStashFormData(
isHidden: boolean,
@@ -28,6 +28,8 @@ import { getControlsState } from 'src/explore/store';
import { Dispatch } from 'redux';
import {
Currency,
DataMaskStateWithId,
JsonObject,
ensureIsArray,
FeatureFlag,
getCategoricalSchemeRegistry,
@@ -60,7 +62,12 @@ export const hydrateExplore =
dataset,
metadata,
saveAction = null,
}: ExplorePageInitialData) =>
dataMask,
chartStates,
}: ExplorePageInitialData & {
dataMask?: DataMaskStateWithId;
chartStates?: Record<number, JsonObject>;
}) =>
(dispatch: Dispatch, getState: () => ExplorePageState) => {
const { user, datasources, charts, sliceEntities, common, explore } =
getState();
@@ -224,12 +231,13 @@ export const hydrateExplore =
saveModalAlert: null,
isVisible: false,
},
explore: exploreState,
explore: { ...exploreState, chartStates },
dataMask,
},
});
};
export type HydrateExplore = {
type: typeof HYDRATE_EXPLORE;
data: ExplorePageState;
data: ExplorePageState & { dataMask?: DataMaskStateWithId };
};
@@ -17,6 +17,7 @@
* under the License.
*/
import { useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Split from 'react-split';
import { t } from '@apache-superset/core/translation';
import {
@@ -33,6 +34,11 @@ import {
import { Alert } from '@apache-superset/core/components';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import ChartContainer from 'src/components/Chart/ChartContainer';
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
import {
convertChartStateToOwnState,
hasChartStateConverter,
} from 'src/dashboard/util/chartStateConverter';
import {
getItem,
setItem,
@@ -43,6 +49,7 @@ import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { buildV1ChartDataPayload } from 'src/explore/exploreUtils';
import { getChartRequiredFieldsMissingMessage } from 'src/utils/getChartRequiredFieldsMissingMessage';
import type { ChartState, Datasource } from 'src/explore/types';
import type { ExploreState } from 'src/explore/reducers/exploreReducer';
import type { Slice } from 'src/types/Chart';
import LastQueriedLabel from 'src/components/LastQueriedLabel';
import { DataTablesPane } from '../DataTablesPane';
@@ -126,6 +133,28 @@ const Styles = styled.div<{ showSplite: boolean }>`
}
`;
const EMPTY_OBJECT: Record<string, never> = {};
const createOwnStateWithChartState = (
baseOwnState: JsonObject,
chartState: { state?: JsonObject } | undefined,
vizTypeArg: string,
): JsonObject => {
if (!hasChartStateConverter(vizTypeArg)) {
return baseOwnState;
}
const state = chartState?.state;
if (!state) {
return baseOwnState;
}
const convertedState = convertChartStateToOwnState(vizTypeArg, state);
return {
...baseOwnState,
...convertedState,
chartState: state,
};
};
const ExploreChartPanel = ({
chart,
slice,
@@ -145,8 +174,34 @@ const ExploreChartPanel = ({
can_download: canDownload,
}: ExploreChartPanelProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const gutterMargin = theme.sizeUnit * GUTTER_SIZE_FACTOR;
const gutterHeight = theme.sizeUnit * GUTTER_SIZE_FACTOR;
const chartState = useSelector(
(state: { explore?: ExploreState }) =>
state.explore?.chartStates?.[chart.id],
);
const handleChartStateChange = useCallback(
(chartStateArg: JsonObject) => {
if (hasChartStateConverter(vizType)) {
dispatch(updateExploreChartState(chart.id, chartStateArg));
}
},
[dispatch, chart.id, vizType],
);
const mergedOwnState = useMemo(
() =>
createOwnStateWithChartState(
ownState || EMPTY_OBJECT,
chartState as { state?: JsonObject } | undefined,
vizType,
),
[ownState, chartState, vizType],
);
const {
ref: chartPanelRef,
observerRef: resizeObserverRef,
@@ -259,7 +314,7 @@ const ExploreChartPanel = ({
<ChartContainer
width={Math.floor(chartPanelWidth)}
height={chartPanelHeight}
ownState={ownState}
ownState={mergedOwnState}
annotationData={chart.annotationData}
chartId={chart.id}
triggerRender={triggerRender}
@@ -277,6 +332,7 @@ const ExploreChartPanel = ({
timeout={timeout}
triggerQuery={chart.triggerQuery}
vizType={vizType}
onChartStateChange={handleChartStateChange}
{...(chart.chartAlert && { chartAlert: chart.chartAlert })}
{...(chart.chartStackTrace && {
chartStackTrace: chart.chartStackTrace,
@@ -304,8 +360,9 @@ const ExploreChartPanel = ({
errorMessage,
force,
formData,
handleChartStateChange,
onQuery,
ownState,
mergedOwnState,
timeout,
triggerRender,
vizType,
@@ -172,7 +172,9 @@ interface ExploreSlice {
interface ExploreState {
charts?: Record<number, ChartState>;
explore?: ExploreSlice;
explore?: ExploreSlice & {
chartStates?: Record<number, JsonObject>;
};
common?: {
conf?: {
CSV_STREAMING_ROW_THRESHOLD?: number;
@@ -221,6 +223,15 @@ export const useExploreAdditionalActionsMenu = (
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
);
const exploreChartState = useSelector<
ExploreState,
JsonObject | undefined
>(state => {
const chartKey = state.explore ? getChartKey(state.explore) : undefined;
return chartKey != null
? state.explore?.chartStates?.[chartKey]
: undefined;
});
// Streaming export state and handlers
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
@@ -274,6 +285,9 @@ export const useExploreAdditionalActionsMenu = (
'EXPORT_CURRENT_VIEW' as Behavior,
);
const permalinkChartState = (exploreChartState as { state?: JsonObject })
?.state;
const shareByEmail = useCallback(async () => {
try {
const subject = t('Superset Chart');
@@ -282,6 +296,8 @@ export const useExploreAdditionalActionsMenu = (
}
const result = await getChartPermalink(
latestQueryFormData as Pick<QueryFormData, 'datasource'>,
undefined,
permalinkChartState,
);
if (!result?.url) {
throw new Error('Failed to generate permalink');
@@ -293,7 +309,7 @@ export const useExploreAdditionalActionsMenu = (
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}, [addDangerToast, latestQueryFormData]);
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
const exportCSV = useCallback(() => {
if (!canDownloadCSV) return null;
@@ -411,6 +427,8 @@ export const useExploreAdditionalActionsMenu = (
await copyTextToClipboard(async () => {
const result = await getChartPermalink(
latestQueryFormData as Pick<QueryFormData, 'datasource'>,
undefined,
permalinkChartState,
);
if (!result?.url) {
throw new Error('Failed to generate permalink');
@@ -421,7 +439,7 @@ export const useExploreAdditionalActionsMenu = (
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
}, [addDangerToast, addSuccessToast, latestQueryFormData, permalinkChartState]);
// Minimal client-side CSV builder used for "Current View" when pagination is disabled
const downloadClientCSV = (
@@ -17,7 +17,12 @@
* under the License.
*/
/* eslint camelcase: 0 */
import { ensureIsArray, QueryFormData, JsonValue } from '@superset-ui/core';
import {
ensureIsArray,
QueryFormData,
JsonValue,
JsonObject,
} from '@superset-ui/core';
import {
ControlState,
ControlStateMapping,
@@ -66,6 +71,7 @@ export interface ExploreState {
owners?: string[] | null;
};
saveAction?: SaveActionType | null;
chartStates?: Record<number, JsonObject>;
}
// Action type definitions
@@ -165,6 +171,13 @@ interface SetForceQueryAction {
force: boolean;
}
interface UpdateExploreChartStateAction {
type: typeof actions.UPDATE_EXPLORE_CHART_STATE;
chartId: number;
chartState: Record<string, unknown>;
lastModified: number;
}
type ExploreAction =
| DynamicPluginControlsReadyAction
| ToggleFaveStarAction
@@ -183,6 +196,7 @@ type ExploreAction =
| SetStashFormDataAction
| SliceUpdatedAction
| SetForceQueryAction
| UpdateExploreChartStateAction
| HydrateExplore;
// Extended control state for dynamic form controls - uses Record for flexibility
@@ -621,10 +635,25 @@ export default function exploreReducer(
force: typedAction.force,
};
},
[actions.UPDATE_EXPLORE_CHART_STATE]() {
const typedAction = action as UpdateExploreChartStateAction;
return {
...state,
chartStates: {
...state.chartStates,
[typedAction.chartId]: {
chartId: typedAction.chartId,
state: typedAction.chartState,
lastModified: typedAction.lastModified,
},
},
};
},
[HYDRATE_EXPLORE]() {
const typedAction = action as HydrateExplore;
const exploreData = typedAction.data.explore;
return {
...typedAction.data.explore,
...exploreData,
} as ExploreState;
},
};
+4 -1
View File
@@ -98,7 +98,10 @@ export interface ExplorePageInitialData {
}
export interface ExploreResponsePayload {
result: ExplorePageInitialData & { message: string };
result: ExplorePageInitialData & {
message: string;
chartState?: JsonObject;
};
}
export interface ExplorePageState {
@@ -150,11 +150,27 @@ export default function ExplorePage() {
)
: result.form_data;
let chartStates: Record<number, JsonObject> | undefined;
if (result.chartState) {
const sliceId =
getUrlParam(URL_PARAMS.sliceId) ||
(formData as JsonObject).slice_id ||
0;
chartStates = {
[sliceId]: {
chartId: sliceId,
state: result.chartState,
lastModified: Date.now(),
},
};
}
dispatch(
hydrateExplore({
...result,
form_data: formData,
saveAction,
chartStates,
}),
);
})
+7 -2
View File
@@ -195,11 +195,16 @@ async function resolvePermalinkUrl(
export async function getChartPermalink(
formData: Pick<QueryFormData, 'datasource'>,
excludedUrlParams?: string[],
chartState?: JsonObject,
): Promise<PermalinkResult> {
const result = await getPermalink('/api/v1/explore/permalink', {
const payload: JsonObject = {
formData,
urlParams: getChartUrlParams(excludedUrlParams),
});
};
if (chartState && Object.keys(chartState).length > 0) {
payload.chartState = chartState;
}
const result = await getPermalink('/api/v1/explore/permalink', payload);
return resolvePermalinkUrl(result);
}
+6 -1
View File
@@ -62,6 +62,7 @@ class GetExploreCommand(BaseCommand, ABC):
# pylint: disable=too-many-locals,too-many-branches,too-many-statements
def run(self) -> Optional[dict[str, Any]]: # noqa: C901
initial_form_data = {}
permalink_chart_state = None
if self._permalink_key is not None:
command = GetExplorePermalinkCommand(self._permalink_key)
permalink_value = command.run()
@@ -72,6 +73,7 @@ class GetExploreCommand(BaseCommand, ABC):
url_params = state.get("urlParams")
if url_params:
initial_form_data["url_params"] = dict(url_params)
permalink_chart_state = state.get("chartState")
elif self._form_data_key:
parameters = FormDataCommandParameters(key=self._form_data_key)
value = GetFormDataCommand(parameters).run()
@@ -168,13 +170,16 @@ class GetExploreCommand(BaseCommand, ABC):
if slc.changed_by:
metadata["changed_by"] = slc.changed_by.get_full_name()
return {
result: dict[str, Any] = {
"dataset": sanitize_datasource_data(datasource_data),
"form_data": form_data,
"slice": slc.data if slc else None,
"message": message,
"metadata": metadata,
}
if permalink_chart_state:
result["chartState"] = permalink_chart_state
return result
def validate(self) -> None:
pass
+10
View File
@@ -41,6 +41,16 @@ class ExplorePermalinkStateSchema(Schema):
allow_none=True,
metadata={"description": "URL Parameters"},
)
chartState = fields.Dict( # noqa: N815
required=False,
allow_none=True,
metadata={
"description": (
"Chart-level state for stateful tables "
"(column filters, sorting, column order)"
)
},
)
class ExplorePermalinkSchema(Schema):
+1
View File
@@ -20,6 +20,7 @@ from typing import Any, Optional, TypedDict
class ExplorePermalinkState(TypedDict, total=False):
formData: dict[str, Any]
urlParams: Optional[list[tuple[str, str]]]
chartState: Optional[dict[str, Any]]
class ExplorePermalinkValue(TypedDict):