This is an automated email from the ASF dual-hosted git repository.
msyavuz 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 7999b7410f2 fix(explore): restore Back button undo for chart changes
(#42473)
7999b7410f2 is described below
commit 7999b7410f2c6e4fb0e31b18b47eb21dec9c65f3
Author: Mehmet Salih Yavuz <[email protected]>
AuthorDate: Wed Jul 29 13:55:57 2026 +0300
fix(explore): restore Back button undo for chart changes (#42473)
---
.../ExploreChartHeader/ExploreChartHeader.test.tsx | 29 ++++
.../components/ExploreChartHeader/index.tsx | 17 ++
.../ExploreViewContainer.test.tsx | 191 ++++++++++++++++++++-
.../components/ExploreViewContainer/index.tsx | 70 +++++++-
.../explore/exploreUtils/exploreHistory.test.ts | 71 ++++++++
.../src/explore/exploreUtils/exploreHistory.ts | 77 +++++++++
.../src/hooks/useUnsavedChangesPrompt/index.ts | 13 +-
superset-frontend/src/pages/Chart/Chart.test.tsx | 149 ++++++++++++++++
superset-frontend/src/pages/Chart/index.tsx | 21 ++-
9 files changed, 630 insertions(+), 8 deletions(-)
diff --git
a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
index 81aa12fe758..9e0ee9feedf 100644
---
a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
+++
b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
@@ -31,9 +31,11 @@ import * as downloadAsImage from 'src/utils/downloadAsImage';
import * as exploreUtils from 'src/explore/exploreUtils';
import {
FeatureFlag,
+ QueryFormData,
VizType,
getChartMetadataRegistry,
} from '@superset-ui/core';
+import { toChartStateHistoryState } from
'src/explore/exploreUtils/exploreHistory';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import ExploreHeader, { ExploreChartHeaderProps } from '.';
import fs from 'fs';
@@ -388,6 +390,33 @@ describe('ExploreChartHeader', () => {
);
});
+ test('treats chart states of the same chart as in place transitions', async
() => {
+ const formData = {
+ viz_type: VizType.Histogram,
+ datasource: '49__table',
+ slice_id: 318,
+ } as QueryFormData;
+ render(<ExploreHeader {...createProps({ formData })} />, {
+ useRedux: true,
+ });
+
+ const [{ isInPlaceTransition }] = (useUnsavedChangesPrompt as jest.Mock)
+ .mock.lastCall;
+
+ expect(
+ isInPlaceTransition(
+ toChartStateHistoryState({ ...formData, row_limit: 10 }),
+ ),
+ ).toBe(true);
+ expect(
+ isInPlaceTransition(
+ toChartStateHistoryState({ ...formData, slice_id: 42 }),
+ ),
+ ).toBe(false);
+ expect(isInPlaceTransition({ fromDashboard: true })).toBe(false);
+ expect(isInPlaceTransition(undefined)).toBe(false);
+ });
+
test('Save chart', async () => {
const setSaveChartModalVisibilitySpy = jest.spyOn(
saveModalActions,
diff --git
a/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
index 679f25c0b18..7f3c734d255 100644
--- a/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
@@ -18,6 +18,7 @@
*/
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
+import type { Location } from 'history';
import { useDispatch } from 'react-redux';
import {
Tooltip,
@@ -45,6 +46,10 @@ import { applyColors, resetColors } from
'src/utils/colorScheme';
import ReportModal from 'src/features/reports/ReportModal';
import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
+import {
+ getChartStateFromHistoryState,
+ isSameChartState,
+} from 'src/explore/exploreUtils/exploreHistory';
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
import { StreamingExportModal } from 'src/components/StreamingExportModal';
import { Tag } from 'src/components/Tag';
@@ -243,6 +248,17 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
[originalFormData, currentFormData],
);
+ // Explore's own chart-state entries (see updateHistory) keep the user on the
+ // chart, so stepping through them isn't leaving unsaved changes behind.
+ const isChartStateTransition = useCallback(
+ (state: Location['state']) =>
+ isSameChartState(getChartStateFromHistoryState(state), {
+ ...formData,
+ slice_id: formData?.slice_id ?? slice?.slice_id,
+ }),
+ [formData, slice?.slice_id],
+ );
+
const {
showModal: showUnsavedChangesModal,
setShowModal: setShowUnsavedChangesModal,
@@ -256,6 +272,7 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
},
isSaveModalVisible,
manualSaveOnUnsavedChanges: true,
+ isInPlaceTransition: isChartStateTransition,
});
const showModal = useCallback(() => {
diff --git
a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
index 24edcc44617..65378889e34 100644
---
a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
+++
b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
@@ -27,9 +27,11 @@ import {
VizType,
} from '@superset-ui/core';
import { QUERY_MODE_REQUISITES } from 'src/explore/constants';
-import { Router, Route } from 'react-router-dom';
+import { MemoryRouter, Route, Router } from 'react-router-dom';
+import type { RouteComponentProps } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import {
+ act,
render,
screen,
userEvent,
@@ -140,15 +142,20 @@ jest.mock('../ControlPanelsContainer', () => ({
onQuery,
buttonErrorMessage,
errorMessage,
+ chartIsStale,
}: {
onQuery: () => void;
buttonErrorMessage?: ReactNode;
errorMessage?: ReactNode;
+ chartIsStale?: boolean;
}) => {
const message = buttonErrorMessage ?? errorMessage;
return (
- <div data-test="control-panels-container">
+ <div
+ data-test="control-panels-container"
+ data-stale={String(!!chartIsStale)}
+ >
<button type="button" onClick={onQuery}>
Update chart
</button>
@@ -327,6 +334,186 @@ test('reuses the same form_data param when updating',
async () => {
getChartControlPanelRegistry().remove('table');
});
+test('pushes a history entry when the chart changed, so Back undoes it', async
() => {
+ getChartControlPanelRegistry().registerValue('table', {
+ controlPanelSections: [],
+ });
+ const initialState = {
+ ...reduxState,
+ explore: {
+ ...reduxState.explore,
+ form_data: {
+ datasource: '1__table',
+ viz_type: VizType.Table,
+ metrics: [],
+ },
+ controls: { ...reduxState.explore.controls, row_limit: { value: 100 } },
+ },
+ };
+ const store = createStore(initialState, reducerIndex);
+ const history = createMemoryHistory({
+ initialEntries: [`${defaultPath}${SEARCH}`],
+ });
+ const pushSpy = jest.spyOn(history, 'push');
+ renderWithRouter({
+ search: SEARCH,
+ history,
+ initialState,
+ store: store as Store,
+ });
+ // the entry Back should return to
+ await waitFor(() =>
+ expect(history.location.state).toEqual(
+ expect.objectContaining({ row_limit: 100 }),
+ ),
+ );
+ act(() => {
+ store.dispatch(exploreActions.setControlValue('row_limit', 200));
+ });
+ await userEvent.click(screen.getByText('Update chart'));
+ await waitFor(() =>
+ expect(pushSpy).toHaveBeenCalledWith(
+ expect.stringMatching('form_data_key'),
+ // the chart id is stamped on, the controls don't produce one
+ expect.objectContaining({ row_limit: 200, slice_id: 1 }),
+ ),
+ );
+ pushSpy.mockRestore();
+ getChartControlPanelRegistry().remove('table');
+});
+
+const renderWithMemoryRouter = ({
+ initialState,
+ store,
+}: {
+ initialState: object;
+ store: Store;
+}) => {
+ // MemoryRouter builds the history react-router itself ships, the one Explore
+ // sees at runtime - the top-level `history` package is a different major
+ let routerHistory: RouteComponentProps['history'] | undefined;
+ const result = render(
+ <MemoryRouter initialEntries={[`${defaultPath}${SEARCH}`]}>
+ <Route
+ render={({ history }) => {
+ routerHistory ??= history;
+ return <ExploreViewContainer />;
+ }}
+ />
+ </MemoryRouter>,
+ { useRedux: true, useDnd: true, initialState, store },
+ );
+ return {
+ ...result,
+ routerHistory: routerHistory as RouteComponentProps['history'],
+ };
+};
+
+test('restores the state of a popped entry without leaving the chart stale',
async () => {
+ getChartControlPanelRegistry().registerValue('table', {
+ controlPanelSections: [
+ { label: 'Options', expanded: true, controlSetRows: [['row_limit']] },
+ ],
+ });
+ const initialState = {
+ ...reduxState,
+ explore: {
+ ...reduxState.explore,
+ form_data: {
+ datasource: '1__table',
+ viz_type: VizType.Table,
+ metrics: [],
+ },
+ controls: { ...reduxState.explore.controls, row_limit: { value: 100 } },
+ },
+ };
+ const store = createStore(initialState, reducerIndex);
+ const { routerHistory } = renderWithMemoryRouter({
+ initialState,
+ store: store as Store,
+ });
+ await waitFor(() =>
+ expect(routerHistory.location.state).toEqual(
+ expect.objectContaining({ row_limit: 100 }),
+ ),
+ );
+ act(() => {
+ store.dispatch(exploreActions.setControlValue('row_limit', 200));
+ });
+ expect(screen.getByTestId('control-panels-container')).toHaveAttribute(
+ 'data-stale',
+ 'true',
+ );
+ await userEvent.click(screen.getByText('Update chart'));
+ await waitFor(() =>
+ expect(routerHistory.location.state).toEqual(
+ expect.objectContaining({ row_limit: 200 }),
+ ),
+ );
+
+ act(() => {
+ routerHistory.goBack();
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId('control-panels-container')).toHaveAttribute(
+ 'data-stale',
+ 'false',
+ ),
+ );
+ getChartControlPanelRegistry().remove('table');
+});
+
+test('doesnt push an entry for the state a popped entry restored', async () =>
{
+ getChartControlPanelRegistry().registerValue('table', {
+ controlPanelSections: [
+ { label: 'Options', expanded: true, controlSetRows: [['y_axis_format']]
},
+ ],
+ });
+ const initialState = {
+ ...reduxState,
+ explore: {
+ ...reduxState.explore,
+ form_data: {
+ datasource: '1__table',
+ viz_type: VizType.Table,
+ metrics: [],
+ },
+ controls: {
+ ...reduxState.explore.controls,
+ y_axis_format: { value: ',d', renderTrigger: true },
+ },
+ },
+ };
+ const store = createStore(initialState, reducerIndex);
+ const { routerHistory } = renderWithMemoryRouter({
+ initialState,
+ store: store as Store,
+ });
+ await waitFor(() =>
+ expect(routerHistory.location.state).toEqual(
+ expect.objectContaining({ y_axis_format: ',d' }),
+ ),
+ );
+ const pushSpy = jest.spyOn(routerHistory, 'push');
+ // a render-trigger change gets its own entry
+ act(() => {
+ store.dispatch(exploreActions.setControlValue('y_axis_format', '.2f'));
+ });
+ await waitFor(() => expect(pushSpy).toHaveBeenCalledTimes(1));
+
+ act(() => {
+ routerHistory.goBack();
+ });
+ await waitFor(() =>
+ expect(routerHistory.location.state).toEqual(
+ expect.objectContaining({ y_axis_format: ',d' }),
+ ),
+ );
+ expect(pushSpy).toHaveBeenCalledTimes(1);
+ pushSpy.mockRestore();
+ getChartControlPanelRegistry().remove('table');
+});
+
test('doesnt call replace when pathname is not /explore', async () => {
getChartMetadataRegistry().registerValue(
'table',
diff --git
a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index cd131a97d1b..fa52e944e58 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -23,10 +23,11 @@ import {
useCallback,
useEffect,
useMemo,
+ useRef,
useState,
} from 'react';
import { bindActionCreators, Dispatch } from 'redux';
-import { connect } from 'react-redux';
+import { connect, shallowEqual, useSelector } from 'react-redux';
import {
useChangeEffect,
useComponentDidMount,
@@ -48,6 +49,7 @@ import { logging } from '@apache-superset/core/utils';
import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash-es';
import { Resizable } from 're-resizable';
import { useHistory } from 'react-router-dom';
+import type { Action, Location } from 'history';
import { ActionButton, Tooltip } from '@superset-ui/core/components';
import { usePluginContext } from 'src/components';
import { Global } from '@emotion/react';
@@ -74,6 +76,12 @@ import { mergeExtraFormData } from
'src/dashboard/components/nativeFilters/utils
import { postFormData, putFormData } from 'src/explore/exploreUtils/formData';
import { datasourcesActions } from 'src/explore/actions/datasourcesActions';
import { mountExploreUrl } from 'src/explore/exploreUtils';
+import {
+ getChartStateFromHistoryState,
+ isSameChartState,
+ selectRestoreTarget,
+ toChartStateHistoryState,
+} from 'src/explore/exploreUtils/exploreHistory';
import { getFormDataFromControls } from 'src/explore/controlUtils';
import * as exploreActions from 'src/explore/actions/exploreActions';
import * as saveModalActions from 'src/explore/actions/saveModalActions';
@@ -173,6 +181,7 @@ const updateHistory = debounce(
title,
tabId,
history,
+ sliceId,
) => {
const payload = { ...formData };
const chartId = formData.slice_id;
@@ -227,7 +236,20 @@ const updateHistory = debounce(
force,
false,
);
- history.replace(url, payload);
+ const previousChartState = getChartStateFromHistoryState(
+ history.location.state,
+ );
+ const state = toChartStateHistoryState(payload, sliceId);
+ if (
+ isReplace ||
+ !previousChartState ||
+ isEqual(previousChartState, getChartStateFromHistoryState(state))
+ ) {
+ history.replace(url, state);
+ } else {
+ // one entry per chart state is what makes the Back button undo it
+ history.push(url, state);
+ }
}
} catch (e) {
logging.warn('Failed at altering browser history', e);
@@ -382,6 +404,9 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
const [lastQueriedControls, setLastQueriedControls] = useState(
props.controls,
);
+ /** set while the chart state of a popped history entry is being applied */
+ const restoringFromHistory = useRef(false);
+ const restoreTarget = useSelector(selectRestoreTarget, shallowEqual);
const [isCollapsed, setIsCollapsed] = useState(false);
const [width, setWidth] = useState(
@@ -479,6 +504,7 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
title,
tabId,
history,
+ restoreTarget.slice_id,
);
},
[
@@ -486,6 +512,7 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
props.form_data,
props.datasource.id,
props.datasource.type,
+ restoreTarget.slice_id,
props.standalone,
props.force,
tabId,
@@ -556,6 +583,9 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
);
});
+ // a pending update would run against whatever chart the user moved on to
+ useEffect(() => () => updateHistory.cancel(), []);
+
useChangeEffect(tabId, (previous, current) => {
if (current) {
addHistory({ isReplace: true });
@@ -600,7 +630,10 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
: getFormDataFromControls(props.controls);
props.actions.updateQueryFormData(newQueryFormData, props.chart.id);
props.actions.renderTriggered(new Date().getTime(), props.chart.id);
- addHistory();
+ if (!restoringFromHistory.current) {
+ // an entry for a state we just stepped back to would break the next
Back
+ addHistory();
+ }
},
[
addHistory,
@@ -611,6 +644,27 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
],
);
+ // Explore's own entries carry the chart state they were pushed with, so a
POP
+ // into one is an undo/redo: apply it and re-query in place, which is also
why
+ // ExplorePage skips its reload for them.
+ useEffect(
+ () =>
+ history.listen((loc: Location, action: Action) => {
+ const chartState = getChartStateFromHistoryState(loc.state);
+ if (
+ action !== 'POP' ||
+ !chartState ||
+ !isSameChartState(chartState, restoreTarget)
+ ) {
+ return;
+ }
+ restoringFromHistory.current = true;
+ props.actions.setExploreControls(chartState);
+ props.actions.triggerQuery(true, props.chart.id);
+ }),
+ [history, restoreTarget, props.actions, props.chart.id],
+ );
+
// effect to run when controls change
useEffect(() => {
if (
@@ -736,6 +790,16 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
}
}, [props.controls, props.ownState]);
+ // A restore queries the controls it just applied, so they are the last
+ // queried ones - otherwise the chart would be reported as stale.
+ // Runs after the effect above so that one still sees the restore.
+ useEffect(() => {
+ if (restoringFromHistory.current) {
+ restoringFromHistory.current = false;
+ setLastQueriedControls(props.controls);
+ }
+ }, [props.controls]);
+
const chartIsStale = useMemo(() => {
if (lastQueriedControls) {
const { controls } = props;
diff --git a/superset-frontend/src/explore/exploreUtils/exploreHistory.test.ts
b/superset-frontend/src/explore/exploreUtils/exploreHistory.test.ts
new file mode 100644
index 00000000000..142bc1fad55
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/exploreHistory.test.ts
@@ -0,0 +1,71 @@
+/**
+ * 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 { QueryFormData, VizType } from '@superset-ui/core';
+import {
+ getChartStateFromHistoryState,
+ isSameChartState,
+ toChartStateHistoryState,
+} from './exploreHistory';
+
+const formData = {
+ datasource: '1__table',
+ viz_type: VizType.Table,
+ slice_id: 7,
+} as QueryFormData;
+
+test('round trips form data through the history state', () => {
+ expect(
+ getChartStateFromHistoryState(toChartStateHistoryState(formData)),
+ ).toEqual(formData);
+});
+
+test('stamps the chart id on form data that carries none', () => {
+ const controlsFormData = { datasource: '1__table' } as QueryFormData;
+ expect(
+ getChartStateFromHistoryState(
+ toChartStateHistoryState(controlsFormData, 7),
+ ),
+ ).toEqual({ ...controlsFormData, slice_id: 7 });
+ expect(
+ getChartStateFromHistoryState(toChartStateHistoryState(formData, 8)),
+ ).toEqual(formData);
+});
+
+test('ignores history states that hold no chart state', () => {
+ expect(getChartStateFromHistoryState(undefined)).toBeUndefined();
+ expect(
+ getChartStateFromHistoryState({ saveAction: 'overwrite' }),
+ ).toBeUndefined();
+ expect(getChartStateFromHistoryState(formData)).toBeUndefined();
+});
+
+test('matches chart states of the same chart and dataset only', () => {
+ expect(isSameChartState(formData, { ...formData, row_limit: 10
})).toBe(true);
+ expect(isSameChartState(formData, { ...formData, slice_id: 8 })).toBe(false);
+ expect(
+ isSameChartState(formData, { ...formData, datasource: '2__table' }),
+ ).toBe(false);
+ expect(isSameChartState(formData, undefined)).toBe(false);
+});
+
+test('treats a missing slice id as the unsaved chart', () => {
+ const newChart = { datasource: '1__table' } as QueryFormData;
+ expect(isSameChartState(newChart, { ...newChart, slice_id: 0 })).toBe(true);
+ expect(isSameChartState(newChart, { ...newChart, slice_id: 7 })).toBe(false);
+});
diff --git a/superset-frontend/src/explore/exploreUtils/exploreHistory.ts
b/superset-frontend/src/explore/exploreUtils/exploreHistory.ts
new file mode 100644
index 00000000000..a43c17167e3
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/exploreHistory.ts
@@ -0,0 +1,77 @@
+/**
+ * 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 { JsonObject, QueryFormData } from '@superset-ui/core';
+import { omit } from 'lodash-es';
+import { UNSAVED_CHART_ID } from 'src/explore/constants';
+import type { ExplorePageState } from 'src/explore/types';
+
+/**
+ * Explore stores the chart's form data in the history entry so that stepping
+ * back through entries restores previous chart states. The marker tells those
+ * entries apart from regular navigation to another page or chart.
+ */
+const CHART_STATE_MARKER = '__exploreChartState';
+
+/**
+ * The chart id has to be stamped on: form data assembled from the controls
+ * carries none, so a state would otherwise not be attributable to its chart.
+ */
+export const toChartStateHistoryState = (
+ formData: QueryFormData,
+ chartId?: number,
+): JsonObject => ({
+ ...formData,
+ slice_id: formData.slice_id ?? chartId,
+ [CHART_STATE_MARKER]: true,
+});
+
+export const getChartStateFromHistoryState = (
+ state: unknown,
+): QueryFormData | undefined =>
+ state &&
+ typeof state === 'object' &&
+ (state as JsonObject)[CHART_STATE_MARKER] === true
+ ? (omit(state as JsonObject, CHART_STATE_MARKER) as QueryFormData)
+ : undefined;
+
+/**
+ * Chart states are only interchangeable within the same chart and dataset -
+ * applying one to another chart would mix the two.
+ */
+/**
+ * The chart a state can be restored into. ExplorePage decides whether to
reload
+ * and ExploreViewContainer whether to restore, so both have to read it here -
+ * disagreeing leaves a POP resolving to neither.
+ */
+export const selectRestoreTarget = (
+ state: ExplorePageState,
+): Partial<QueryFormData> => ({
+ slice_id:
+ state.explore?.slice?.slice_id ?? state.explore?.form_data?.slice_id,
+ datasource: state.explore?.controls?.datasource?.value as string | undefined,
+});
+
+export const isSameChartState = (
+ a?: Partial<QueryFormData>,
+ b?: Partial<QueryFormData>,
+): boolean =>
+ !!a &&
+ !!b &&
+ (a.slice_id ?? UNSAVED_CHART_ID) === (b.slice_id ?? UNSAVED_CHART_ID) &&
+ a.datasource === b.datasource;
diff --git a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
index 2301b2c515d..995a8007fed 100644
--- a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
+++ b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
@@ -28,6 +28,12 @@ type UseUnsavedChangesPromptProps = {
onSave: () => Promise<void> | void;
isSaveModalVisible?: boolean;
manualSaveOnUnsavedChanges?: boolean;
+ /**
+ * Transitions that keep the user on the current page - Explore, for one,
adds
+ * an entry per chart state so that Back undoes it - aren't navigation away
+ * and shouldn't prompt.
+ */
+ isInPlaceTransition?: (state: Location['state']) => boolean;
};
export const useUnsavedChangesPrompt = ({
@@ -35,6 +41,7 @@ export const useUnsavedChangesPrompt = ({
onSave,
isSaveModalVisible = false,
manualSaveOnUnsavedChanges = false,
+ isInPlaceTransition,
}: UseUnsavedChangesPromptProps) => {
const history = useHistory();
const [showModal, setShowModal] = useState(false);
@@ -88,6 +95,10 @@ export const useUnsavedChangesPrompt = ({
return undefined;
}
+ if (isInPlaceTransition?.(state)) {
+ return undefined;
+ }
+
if (manualSaveRef.current) {
manualSaveRef.current = false;
return undefined;
@@ -105,7 +116,7 @@ export const useUnsavedChangesPrompt = ({
setShowModal(true);
return false;
},
- [history],
+ [history, isInPlaceTransition],
);
useEffect(() => {
diff --git a/superset-frontend/src/pages/Chart/Chart.test.tsx
b/superset-frontend/src/pages/Chart/Chart.test.tsx
index 6f15faceb99..3ead8f44dc1 100644
--- a/superset-frontend/src/pages/Chart/Chart.test.tsx
+++ b/superset-frontend/src/pages/Chart/Chart.test.tsx
@@ -19,11 +19,14 @@
import fetchMock from 'fetch-mock';
import { Link } from 'react-router-dom';
import {
+ act,
+ createStore,
render,
waitFor,
screen,
fireEvent,
} from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
import { getExploreFormData } from 'spec/fixtures/mockExploreFormData';
import { getDashboardFormData } from 'spec/fixtures/mockDashboardFormData';
import { LocalStorageKeys } from 'src/utils/localStorageHelpers';
@@ -32,6 +35,8 @@ import { URL_PARAMS } from 'src/constants';
import { JsonObject, VizType } from '@superset-ui/core';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import { getParsedExploreURLParams } from
'src/explore/exploreUtils/getParsedExploreURLParams';
+import { toChartStateHistoryState } from
'src/explore/exploreUtils/exploreHistory';
+import * as exploreActions from 'src/explore/actions/exploreActions';
import * as messageToastActions from 'src/components/MessageToasts/actions';
import ChartPage from '.';
@@ -358,6 +363,150 @@ describe('ChartPage', () => {
JSON.stringify({ show_cell_bars: true }).slice(1, -1),
);
});
+
+ test('restores the chart state held by the entry on back-button navigation
(POP)', async () => {
+ const exploreApiRoute = 'glob:*/api/v1/explore/*';
+ const formData = getExploreFormData({
+ viz_type: VizType.Table,
+ show_cell_bars: true,
+ });
+ fetchMock.get(exploreApiRoute, {
+ result: { dataset: { id: 1 }, form_data: formData },
+ });
+ fetchMock.post('glob:*/api/v1/chart/data*', { result: [] });
+ const setExploreControlsSpy = jest.spyOn(
+ exploreActions,
+ 'setExploreControls',
+ );
+ render(
+ <>
+ <Link
+ to={{
+ pathname: '/',
+ search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
+ state: toChartStateHistoryState({
+ ...formData,
+ show_cell_bars: false,
+ }),
+ }}
+ >
+ Change the chart
+ </Link>
+ <Link to="/?slice_id=99">Navigate away</Link>
+ <ChartPage />
+ </>,
+ { useRouter: true, useRedux: true, useDnd: true },
+ );
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
+ );
+
+ // an entry Explore pushed for a chart change, then navigation off it
+ fireEvent.click(screen.getByText('Change the chart'));
+ fireEvent.click(screen.getByText('Navigate away'));
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(2),
+ );
+ fetchMock.clearHistory();
+ setExploreControlsSpy.mockClear();
+
+ window.history.back();
+ await waitFor(() =>
+ expect(setExploreControlsSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ show_cell_bars: false }),
+ ),
+ );
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(0);
+ });
+
+ test('re-fetches when the entry holds the state of another chart', async
() => {
+ const exploreApiRoute = 'glob:*/api/v1/explore/*';
+ const formData = getExploreFormData({ viz_type: VizType.Table });
+ fetchMock.get(exploreApiRoute, {
+ result: { dataset: { id: 1 }, form_data: formData },
+ });
+ const setExploreControlsSpy = jest.spyOn(
+ exploreActions,
+ 'setExploreControls',
+ );
+ render(
+ <>
+ <Link
+ to={{
+ pathname: '/',
+ search: `?${URL_PARAMS.sliceId.name}=99`,
+ state: toChartStateHistoryState({ ...formData, slice_id: 99 }),
+ }}
+ >
+ Another chart
+ </Link>
+ <Link to="/?slice_id=100">Navigate away</Link>
+ <ChartPage />
+ </>,
+ { useRouter: true, useRedux: true, useDnd: true },
+ );
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
+ );
+
+ fireEvent.click(screen.getByText('Another chart'));
+ fireEvent.click(screen.getByText('Navigate away'));
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(2),
+ );
+ fetchMock.clearHistory();
+ setExploreControlsSpy.mockClear();
+
+ window.history.back();
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
+ );
+ expect(setExploreControlsSpy).not.toHaveBeenCalled();
+ });
+
+ test('re-fetches when the dataset changed after the entry was pushed',
async () => {
+ const exploreApiRoute = 'glob:*/api/v1/explore/*';
+ const loads = () =>
+ fetchMock.callHistory.calls(exploreApiRoute, { method: 'GET' }).length;
+ const formData = getExploreFormData({ viz_type: VizType.Table });
+ fetchMock.get(exploreApiRoute, {
+ result: { dataset: { id: 1 }, form_data: formData },
+ });
+ const store = createStore({}, reducerIndex);
+ render(
+ <>
+ <Link
+ to={{
+ pathname: '/',
+ search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
+ state: toChartStateHistoryState(formData),
+ }}
+ >
+ Change the chart
+ </Link>
+ <Link to="/?slice_id=99">Navigate away</Link>
+ <ChartPage />
+ </>,
+ { useRouter: true, useRedux: true, useDnd: true, store },
+ );
+ await waitFor(() => expect(loads()).toBe(1));
+ fireEvent.click(screen.getByText('Change the chart'));
+ fireEvent.click(screen.getByText('Navigate away'));
+ await waitFor(() => expect(loads()).toBe(2));
+ fetchMock.clearHistory();
+
+ // the entry predates the swap, so it can't be applied to the chart on
screen
+ act(() => {
+ store.dispatch(
+ exploreActions.setExploreControls({
+ ...formData,
+ datasource: '3__table',
+ }),
+ );
+ });
+ window.history.back();
+ await waitFor(() => expect(loads()).toBe(1));
+ });
});
test('does not show error toast when request is aborted on unmount', async
() => {
diff --git a/superset-frontend/src/pages/Chart/index.tsx
b/superset-frontend/src/pages/Chart/index.tsx
index 4de2b6b712e..99ea4d77bc8 100644
--- a/superset-frontend/src/pages/Chart/index.tsx
+++ b/superset-frontend/src/pages/Chart/index.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
-import { useDispatch } from 'react-redux';
+import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import type { Location, Action } from 'history';
import { t } from '@apache-superset/core/translation';
@@ -36,6 +36,11 @@ import { URL_PARAMS } from 'src/constants';
import getFormDataWithExtraFilters from
'src/dashboard/util/charts/getFormDataWithExtraFilters';
import { getAppliedFilterValues } from
'src/dashboard/util/activeDashboardFilters';
import { getParsedExploreURLParams } from
'src/explore/exploreUtils/getParsedExploreURLParams';
+import {
+ getChartStateFromHistoryState,
+ isSameChartState,
+ selectRestoreTarget,
+} from 'src/explore/exploreUtils/exploreHistory';
import { hydrateExplore } from 'src/explore/actions/hydrateExplore';
import ExploreViewContainer from 'src/explore/components/ExploreViewContainer';
import { ExploreResponsePayload, SaveActionType } from 'src/explore/types';
@@ -138,6 +143,7 @@ export default function ExplorePage() {
const abortControllerRef = useRef<AbortController | null>(null);
const dispatch = useDispatch();
const history = useHistory();
+ const restoreTarget = useSelector(selectRestoreTarget, shallowEqual);
const loadExploreData = useCallback(
(
@@ -304,10 +310,21 @@ export default function ExplorePage() {
// PUSH/POP: full reload (unmount + re-fetch).
// REPLACE with saveAction state: re-fetch without unmount (keeps chart
visible).
// Other REPLACE: ignored (URL sync from updateHistory).
+ // Entries holding a chart state of the loaded chart are skipped: Explore
+ // pushed them itself, and ExploreViewContainer restores a popped one in
place.
useEffect(() => {
const unlisten = history.listen((loc: Location, action: Action) => {
const saveAction = (loc.state as Record<string, unknown>)?.saveAction as
SaveActionType | undefined;
+ const chartState = getChartStateFromHistoryState(loc.state);
+ if (chartState) {
+ if (action === 'PUSH') {
+ return;
+ }
+ if (action === 'POP' && isSameChartState(chartState, restoreTarget)) {
+ return;
+ }
+ }
if (action === 'PUSH' || action === 'POP') {
setIsLoaded(false);
loadExploreData(loc, saveAction);
@@ -316,7 +333,7 @@ export default function ExplorePage() {
}
});
return unlisten;
- }, [history, loadExploreData]);
+ }, [history, loadExploreData, restoreTarget]);
if (!isLoaded) {
return <Loading />;