This is an automated email from the ASF dual-hosted git repository.
aminghadersohi 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 7b716b1838c fix(versioning): clean up history actions and refresh
after saves (#44613)
7b716b1838c is described below
commit 7b716b1838c5143d9341799c9e2c441760e83f94
Author: Mike Bridge <[email protected]>
AuthorDate: Thu Sep 24 16:36:38 2026 -0600
fix(versioning): clean up history actions and refresh after saves (#44613)
---
docs/docs/using-superset/version-history.mdx | 7 +
.../actions/chartCustomizationActions.test.ts | 20 +-
.../dashboard/actions/chartCustomizationActions.ts | 4 +-
.../src/dashboard/actions/dashboardInfo.ts | 6 +
.../PropertiesModal/PropertiesModal.test.tsx | 6 +
.../dashboard/components/PropertiesModal/index.tsx | 6 +-
.../src/dashboard/reducers/dashboardInfo.ts | 10 +
.../src/dashboard/reducers/dashboardState.ts | 2 +
.../explore/components/PropertiesModal/index.tsx | 4 +
.../src/explore/reducers/saveModalReducer.ts | 15 +-
.../src/features/versionHistory/ActionRow.tsx | 110 +------
.../DashboardVersionHistory.test.tsx | 89 +++++-
.../versionHistory/DashboardVersionHistory.tsx | 46 ++-
.../versionHistory/ExploreVersionHistory.test.tsx | 344 ++++++++++++++++++++-
.../versionHistory/ExploreVersionHistory.tsx | 53 ++--
.../features/versionHistory/SaveGroupItem.test.tsx | 92 +++++-
.../src/features/versionHistory/SaveGroupItem.tsx | 5 -
.../versionHistory/VersionHistoryPanel.test.tsx | 3 +-
18 files changed, 626 insertions(+), 196 deletions(-)
diff --git a/docs/docs/using-superset/version-history.mdx
b/docs/docs/using-superset/version-history.mdx
index 5dc131158f4..f07580d0690 100644
--- a/docs/docs/using-superset/version-history.mdx
+++ b/docs/docs/using-superset/version-history.mdx
@@ -65,6 +65,9 @@ type, a first save can carry more of these automatic
rewrites. It
happens at most once per chart per upgrade, and later saves record only
what the user changed.
+The open history panel refreshes after a successful save; you do not need to
+close and reopen it to see the new version.
+
## Previewing a version
Selecting a version loads it into the page so you can see it before deciding
@@ -111,6 +114,10 @@ and would overwrite the restore on the next sync.
## Opening a version as something new
+Use the **… (actions)** menu on a save-group header to open that version as
+a new chart or dashboard. Individual field-change rows preview the version;
+they do not have separate action menus.
+
**Open as new** creates a brand-new chart or dashboard from the selected
version and leaves the original untouched. This is the safer option when you
want an old state back but are not certain you want to give up the current one.
diff --git
a/superset-frontend/src/dashboard/actions/chartCustomizationActions.test.ts
b/superset-frontend/src/dashboard/actions/chartCustomizationActions.test.ts
index 09defc6c3c0..1553580a0b1 100644
--- a/superset-frontend/src/dashboard/actions/chartCustomizationActions.test.ts
+++ b/superset-frontend/src/dashboard/actions/chartCustomizationActions.test.ts
@@ -23,7 +23,10 @@ import {
saveChartCustomization,
} from './chartCustomizationActions';
import { SET_IN_SCOPE_STATUS_OF_FILTERS } from './nativeFilters';
-import { DASHBOARD_INFO_UPDATED } from './dashboardInfo';
+import {
+ DASHBOARD_INFO_UPDATED,
+ dashboardSaveSucceeded,
+} from './dashboardInfo';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
@@ -225,6 +228,8 @@ test('saveChartCustomization filters null entries from
currentConfig before merg
const thunk = saveChartCustomization([customization], [], [], false);
await thunk(dispatch, getState, null);
+ expect(dispatch).toHaveBeenCalledWith(dashboardSaveSucceeded(1));
+
// DASHBOARD_INFO_UPDATED should have merged config without nulls
const infoUpdateCall = dispatch.mock.calls.find(
([action]: [{ type: string }]) => action.type === DASHBOARD_INFO_UPDATED,
@@ -271,3 +276,16 @@ test('saveChartCustomization filters null entries from
oldConfig when resetDataM
// Should not throw when building oldCustomizationsById from null-containing
config
await expect(thunk(dispatch, getState, null)).resolves.toBeDefined();
});
+
+test('failed customization persistence does not signal a successful dashboard
save', async () => {
+ fetchMock.put('glob:*/api/v1/dashboard/1/chart_customizations', 500);
+ const { getState, dispatch } = setup({
+ dashboardInfo: { id: 1, metadata: {} },
+ });
+
+ await expect(
+ saveChartCustomization([], [])(dispatch, getState, null),
+ ).rejects.toBeDefined();
+
+ expect(dispatch).not.toHaveBeenCalledWith(dashboardSaveSucceeded(1));
+});
diff --git
a/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts
b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts
index f63a069cdbd..16c92b86624 100644
--- a/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts
+++ b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts
@@ -34,7 +34,7 @@ import {
removeDataMask,
setDataMaskForFilterChangesComplete,
} from 'src/dataMask/actions';
-import { dashboardInfoChanged } from './dashboardInfo';
+import { dashboardInfoChanged, dashboardSaveSucceeded } from './dashboardInfo';
import {
SET_NATIVE_FILTERS_CONFIG_COMPLETE,
SET_IN_SCOPE_STATUS_OF_FILTERS,
@@ -106,6 +106,8 @@ export function saveChartCustomization(
reordered: reorderedIds,
});
+ dispatch(dashboardSaveSucceeded(id));
+
const currentMetadata = getState().dashboardInfo.metadata;
const currentConfig =
currentMetadata?.chart_customization_config?.filter(Boolean) || [];
diff --git a/superset-frontend/src/dashboard/actions/dashboardInfo.ts
b/superset-frontend/src/dashboard/actions/dashboardInfo.ts
index fb10123d307..a9a58374356 100644
--- a/superset-frontend/src/dashboard/actions/dashboardInfo.ts
+++ b/superset-frontend/src/dashboard/actions/dashboardInfo.ts
@@ -38,6 +38,12 @@ const createUpdateDashboardApi = (id: number) =>
endpoint: `/api/v1/dashboard/${id}`,
});
+export const DASHBOARD_SAVE_SUCCEEDED = 'DASHBOARD_SAVE_SUCCEEDED';
+
+export function dashboardSaveSucceeded(dashboardId: number) {
+ return { type: DASHBOARD_SAVE_SUCCEEDED, dashboardId };
+}
+
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
export const DASHBOARD_INFO_FILTERS_CHANGED = 'DASHBOARD_INFO_FILTERS_CHANGED';
diff --git
a/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx
b/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx
index ba9616619a6..20cf9191cf9 100644
---
a/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx
+++
b/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx
@@ -28,6 +28,7 @@ import * as ColorSchemeSelect from
'src/dashboard/components/ColorSchemeSelect';
import * as SupersetCore from '@superset-ui/core';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import PropertiesModal from '.';
+import * as dashboardInfoActions from 'src/dashboard/actions/dashboardInfo';
// Increase timeout for CI environment
jest.setTimeout(60000);
@@ -529,6 +530,7 @@ describe('PropertiesModal', () => {
});
test('preserves certification fields on save without opening Certification
section', async () => {
+ const saved = jest.spyOn(dashboardInfoActions, 'dashboardSaveSucceeded');
// Accordion Collapse only mounts the active panel, so certifiedBy /
// certificationDetails FormItems are unregistered until Certification is
// opened. onFinish must use getFieldsValue(true) to read store values for
@@ -571,6 +573,8 @@ describe('PropertiesModal', () => {
expect(submitCall.certifiedBy).toBe('John Doe');
expect(submitCall.certificationDetails).toBe('Sample certification');
+ expect(saved).toHaveBeenCalledTimes(1);
+ expect(saved).toHaveBeenCalledWith(props.dashboardId);
expect(put).toHaveBeenCalled();
const putRequest = put.mock.calls[0][0];
expect(typeof putRequest.body).toBe('string');
@@ -580,6 +584,7 @@ describe('PropertiesModal', () => {
});
test('submitting with onlyApply:true', async () => {
+ const saved = jest.spyOn(dashboardInfoActions, 'dashboardSaveSucceeded');
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
props.onlyApply = true;
@@ -607,6 +612,7 @@ describe('PropertiesModal', () => {
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledTimes(1);
});
+ expect(saved).not.toHaveBeenCalled();
});
test('passes full theme object with json_data to onSubmit when theme is
selected', async () => {
diff --git
a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
index 9832e81e128..3ed442c4da9 100644
--- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
@@ -55,7 +55,10 @@ import {
setColorScheme,
setDashboardMetadata,
} from 'src/dashboard/actions/dashboardState';
-import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
+import {
+ dashboardInfoChanged,
+ dashboardSaveSucceeded,
+} from 'src/dashboard/actions/dashboardInfo';
import { areObjectsEqual } from 'src/reduxUtils';
import { AsyncModeOverride } from 'src/utils/asyncMode';
import { StandardModal, useModalValidation } from 'src/components/Modal';
@@ -477,6 +480,7 @@ const PropertiesModal = ({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(saveData),
}).then(() => {
+ dispatch(dashboardSaveSucceeded(dashboardId));
onSubmit(onSubmitProps);
onHide();
addSuccessToast(t('The dashboard has been saved'));
diff --git a/superset-frontend/src/dashboard/reducers/dashboardInfo.ts
b/superset-frontend/src/dashboard/reducers/dashboardInfo.ts
index d2decf48a3f..eb4795ab279 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardInfo.ts
+++ b/superset-frontend/src/dashboard/reducers/dashboardInfo.ts
@@ -24,6 +24,7 @@ import {
} from '@superset-ui/core';
import {
DASHBOARD_INFO_UPDATED,
+ DASHBOARD_SAVE_SUCCEEDED,
SET_FILTER_BAR_ORIENTATION,
SET_CROSS_FILTERS_ENABLED,
DASHBOARD_INFO_FILTERS_CHANGED,
@@ -71,6 +72,7 @@ type DashboardInfoReducerAction =
type DashboardInfoState = Partial<DashboardInfo> & {
last_modified_time?: number;
+ versionHistoryRevision?: number;
[key: string]: unknown;
};
@@ -124,6 +126,13 @@ export default function dashboardInfoReducer(
action: DashboardInfoReducerAction,
): DashboardInfoState {
switch (action.type) {
+ case DASHBOARD_SAVE_SUCCEEDED:
+ return (action as DashboardInfoAction).dashboardId === state.id
+ ? {
+ ...state,
+ versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
+ }
+ : state;
case DASHBOARD_INFO_UPDATED: {
const dashAction = action as DashboardInfoAction;
const newInfo = dashAction.newInfo || {};
@@ -189,6 +198,7 @@ export default function dashboardInfoReducer(
native_filter_configuration: newConfigWithScopes,
} as DashboardInfo['metadata'],
last_modified_time: Math.round(new Date().getTime() / 1000),
+ versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
};
}
case HYDRATE_DASHBOARD: {
diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.ts
b/superset-frontend/src/dashboard/reducers/dashboardState.ts
index 4324f96a389..d3de99b0242 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardState.ts
+++ b/superset-frontend/src/dashboard/reducers/dashboardState.ts
@@ -90,6 +90,7 @@ interface DashboardStateShape {
hasUnsavedChanges?: boolean;
dashboardIsSaving?: boolean;
lastModifiedTime?: number;
+ versionHistoryRevision?: number;
refreshFrequency?: number;
shouldPersistRefreshFrequency?: boolean;
isRefreshing?: boolean;
@@ -294,6 +295,7 @@ export default function dashboardStateReducer(
updatedColorScheme: false,
// server-side returns last_modified_time for latest change
lastModifiedTime: action.lastModifiedTime,
+ versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
};
},
[SET_UNSAVED_CHANGES](): DashboardStateShape {
diff --git a/superset-frontend/src/explore/components/PropertiesModal/index.tsx
b/superset-frontend/src/explore/components/PropertiesModal/index.tsx
index ea2486b6e06..58302b2a780 100644
--- a/superset-frontend/src/explore/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/explore/components/PropertiesModal/index.tsx
@@ -41,6 +41,8 @@ import {
ensureIsArray,
} from '@superset-ui/core';
import Chart, { Slice } from 'src/types/Chart';
+import { useAppDispatch } from 'src/views/store';
+import { saveSliceSuccess } from 'src/explore/actions/saveModalActions';
import withToasts from 'src/components/MessageToasts/withToasts';
import { type TagType } from 'src/components';
import { TagTypeEnum } from 'src/components/Tag/TagType';
@@ -80,6 +82,7 @@ function PropertiesModal({
addDangerToast,
renderExtraFields,
}: PropertiesModalProps) {
+ const dispatch = useAppDispatch();
const [submitting, setSubmitting] = useState(false);
// values of form inputs
const [name, setName] = useState(slice.slice_name || '');
@@ -261,6 +264,7 @@ function PropertiesModal({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
+ dispatch(saveSliceSuccess({ id: slice.slice_id }));
res = await SupersetClient.get({
endpoint: chartEndpoint,
});
diff --git a/superset-frontend/src/explore/reducers/saveModalReducer.ts
b/superset-frontend/src/explore/reducers/saveModalReducer.ts
index c4c619e7b46..8f6051d5f72 100644
--- a/superset-frontend/src/explore/reducers/saveModalReducer.ts
+++ b/superset-frontend/src/explore/reducers/saveModalReducer.ts
@@ -25,6 +25,7 @@ interface SaveModalState {
dashboards?: unknown[];
saveModalAlert?: string;
data?: unknown;
+ lastSavedChart?: { id: number };
}
interface SaveModalAction {
@@ -56,11 +57,21 @@ export default function saveModalReducer(
return { ...state, saveModalAlert: 'Failed to save slice' };
},
[actions.SAVE_SLICE_SUCCESS]() {
- return { ...state, data: action.data };
+ const { data } = action;
+ const lastSavedChart =
+ typeof data === 'object' &&
+ data !== null &&
+ 'id' in data &&
+ typeof data.id === 'number'
+ ? { id: data.id }
+ : state.lastSavedChart;
+ return { ...state, data, lastSavedChart };
},
[HYDRATE_EXPLORE]() {
const payload = action.data as { saveModal?: SaveModalState } |
undefined;
- return { ...payload?.saveModal };
+ // Hydration can be batched with save success before subscribers render.
+ // Keep the success marker while resetting the modal's transient state.
+ return { ...payload?.saveModal, lastSavedChart: state.lastSavedChart };
},
};
diff --git a/superset-frontend/src/features/versionHistory/ActionRow.tsx
b/superset-frontend/src/features/versionHistory/ActionRow.tsx
index 3ff1bce5795..7daea0c9918 100644
--- a/superset-frontend/src/features/versionHistory/ActionRow.tsx
+++ b/superset-frontend/src/features/versionHistory/ActionRow.tsx
@@ -17,10 +17,8 @@
* under the License.
*/
import { KeyboardEvent } from 'react';
-import { t } from '@apache-superset/core/translation';
-import { styled, useTheme } from '@apache-superset/core/theme';
-import { Button, Dropdown, Icons } from '@superset-ui/core/components';
-import type { ActivityRecord, VersionedEntityType } from './types';
+import { styled } from '@apache-superset/core/theme';
+import type { ActivityRecord } from './types';
import {
describeRecord,
formatAuthor,
@@ -97,41 +95,8 @@ const Meta = styled.div`
`}
`;
-// The kebab centers within the first text line (one line-height tall)
-// so it tracks the title, not the middle of the two-line row.
-const KebabWrapper = styled.div`
- ${({ theme }) => `
- align-self: flex-start;
- display: flex;
- align-items: center;
- height: ${theme.fontSize * theme.lineHeight}px;
- `}
-`;
-
-// Icon-only trigger: the design gives row actions the brand accent
-// rather than the link-button blue or a neutral grey.
-const KebabButton = styled(Button)`
- ${({ theme }) => `
- && {
- color: ${theme.colorPrimary};
- }
- &&:hover,
- &&:focus {
- color: ${theme.colorPrimaryHover};
- }
- `}
-`;
-
export interface ActionRowProps {
- entityType: VersionedEntityType;
record: ActivityRecord;
- /** False for the current (live) version, where restoring is a no-op. */
- showRestore: boolean;
- /**
- * False when the enclosing group has no versionUuid to act on — both
- * kebab actions name a specific version, so the menu would be dead.
- */
- showActions?: boolean;
/**
* True when the row belongs to the active group — the current (live)
* version at rest, or a historical version while previewed. Drives the
@@ -140,73 +105,20 @@ export interface ActionRowProps {
isHighlighted: boolean;
isLast: boolean;
onPreview: () => void;
- onRestore: () => void;
- onOpenAsNew: () => void;
}
export default function ActionRow({
- entityType,
record,
- showRestore,
- showActions = true,
isHighlighted,
isLast,
onPreview,
- onRestore,
- onOpenAsNew,
}: ActionRowProps) {
- const theme = useTheme();
const label = describeRecord(record);
const meta = `${formatAuthor(record.changed_by)} ·
${formatVersionDateTimeShort(
record.issued_at,
)}`;
- const itemStyle = {
- height: theme.controlHeightLG,
- paddingLeft: theme.sizeUnit * 6,
- paddingRight: theme.sizeUnit * 6,
- display: 'flex',
- alignItems: 'center',
- };
- const menuItems = [
- ...(showRestore
- ? [
- {
- key: 'restore',
- label: t('Restore this version'),
- style: itemStyle,
- onClick: ({
- domEvent,
- }: {
- domEvent: { stopPropagation: () => void };
- }) => {
- domEvent.stopPropagation();
- onRestore();
- },
- },
- ]
- : []),
- {
- key: 'open-as-new',
- label:
- entityType === 'chart'
- ? t('Open as new chart')
- : t('Open as new dashboard'),
- style: itemStyle,
- onClick: ({
- domEvent,
- }: {
- domEvent: { stopPropagation: () => void };
- }) => {
- domEvent.stopPropagation();
- onOpenAsNew();
- },
- },
- ];
-
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
- // Keydowns from the nested kebab <button> bubble up here; only
- // activate when the row itself has focus, so Enter/Space on the kebab
- // opens its menu instead of triggering a preview.
+ // Only activate when the row itself has focus.
if (event.target !== event.currentTarget) {
return;
}
@@ -218,7 +130,7 @@ export default function ActionRow({
return (
<Row
- // eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- row
contains a nested kebab <button>, which a real <button> cannot
+ // eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- timeline
layout uses a focusable row with explicit keyboard activation
role="button"
tabIndex={0}
onClick={onPreview}
@@ -233,20 +145,6 @@ export default function ActionRow({
<Title title={label}>{label}</Title>
<Meta>{meta}</Meta>
</Content>
- <KebabWrapper>
- {showActions && (
- <Dropdown menu={{ items: menuItems }} trigger={['click']}>
- <KebabButton
- buttonSize="xsmall"
- buttonStyle="link"
- aria-label={t('More actions')}
- onClick={event => event.stopPropagation()}
- >
- <Icons.MoreOutlined iconSize="m" />
- </KebabButton>
- </Dropdown>
- )}
- </KebabWrapper>
</Row>
);
}
diff --git
a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
index 16bf309f9d6..5c9e0fcecf5 100644
---
a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
+++
b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
@@ -16,6 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { clearAllChartCustomizations } from
'src/dashboard/actions/chartCustomizationActions';
+import dashboardStateReducer from 'src/dashboard/reducers/dashboardState';
+import dashboardInfoReducer from 'src/dashboard/reducers/dashboardInfo';
+import { onSave } from 'src/dashboard/actions/dashboardState';
+import {
+ dashboardInfoChanged,
+ dashboardSaveSucceeded,
+ nativeFiltersConfigChanged,
+} from 'src/dashboard/actions/dashboardInfo';
import type { AnyAction, Store } from 'redux';
import { act, render, screen } from 'spec/helpers/testing-library';
import type { VersionHistoryState } from './types';
@@ -78,17 +87,11 @@ const versionHistoryState = (
interface TestState {
versionHistory: VersionHistoryState;
- dashboardInfo: {
- uuid: string;
- last_modified_time: number;
- dash_edit_perm?: boolean;
- is_managed_externally?: boolean;
- };
- dashboardState: { hasUnsavedChanges: boolean; lastModifiedTime: number };
+ dashboardInfo: ReturnType<typeof dashboardInfoReducer>;
+ dashboardState: ReturnType<typeof dashboardStateReducer>;
}
-/** Minimal recording store: dispatched actions are captured, never reduced,
- * so tests drive state transitions explicitly via setState. */
+/** Reduce real dashboard saves; unrelated transitions can be driven
explicitly. */
function makeTestStore(initial: TestState) {
let state = initial;
const actions: AnyAction[] = [];
@@ -102,6 +105,12 @@ function makeTestStore(initial: TestState) {
},
dispatch(action: AnyAction) {
actions.push(action);
+ state = {
+ ...state,
+ dashboardInfo: dashboardInfoReducer(state.dashboardInfo, action),
+ dashboardState: dashboardStateReducer(state.dashboardState, action),
+ };
+ listeners.forEach(listener => listener());
return action;
},
subscribe(listener: () => void) {
@@ -117,6 +126,7 @@ const makeStore = () =>
makeTestStore({
versionHistory: versionHistoryState(),
dashboardInfo: {
+ id: 1,
uuid: 'dash-uuid',
last_modified_time: 100,
dash_edit_perm: true,
@@ -147,28 +157,27 @@ afterEach(() => {
jest.clearAllMocks();
});
-test('refreshes the timeline when an edit-mode save bumps lastModifiedTime',
() => {
+test('refreshes the timeline on a real edit-mode save', () => {
const store = makeStore();
renderAdapter(store);
expect(refresh).not.toHaveBeenCalled();
act(() => {
- store.setState({
- dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 600 },
- });
+ store.dispatch(onSave(600));
});
expect(refresh).toHaveBeenCalledTimes(1);
});
-test('refreshes the timeline when a filter or properties save bumps
last_modified_time', () => {
+test('timestamp metadata changes do not duplicate a successful properties
refresh', () => {
const store = makeStore();
renderAdapter(store);
act(() => {
- store.setState({
- dashboardInfo: { uuid: 'dash-uuid', last_modified_time: 200 },
- });
+ store.dispatch(dashboardSaveSucceeded(1));
+ });
+ act(() => {
+ store.dispatch(dashboardInfoChanged({ description: 'updated' }));
});
expect(refresh).toHaveBeenCalledTimes(1);
@@ -302,3 +311,49 @@ test('renders the panel in place while open — the
closed-state discriminator',
expect(container).not.toBeEmptyDOMElement();
expect(screen.getByTestId('mock-version-history-panel')).toBeInTheDocument();
});
+
+test.each(['edit', 'properties', 'native filters'])(
+ 'real %s success reducer refreshes history for successive saves in the same
second',
+ path => {
+ const now = jest.spyOn(Date.prototype, 'getTime').mockReturnValue(100000);
+ try {
+ const store = makeStore();
+ renderAdapter(store);
+ for (let save = 1; save <= 2; save += 1) {
+ act(() => {
+ if (path === 'edit') {
+ store.dispatch(onSave(500));
+ } else if (path === 'properties') {
+ store.dispatch(dashboardSaveSucceeded(1));
+ } else {
+ store.dispatch(nativeFiltersConfigChanged([]));
+ }
+ });
+ expect(refresh).toHaveBeenCalledTimes(save);
+ }
+ } finally {
+ now.mockRestore();
+ }
+ },
+);
+
+test('local properties and customization edits do not refresh server history',
() => {
+ const store = makeStore();
+ renderAdapter(store);
+ act(() => {
+ store.dispatch(dashboardInfoChanged({ description: 'Draft' }));
+ });
+ act(() => {
+ store.dispatch(clearAllChartCustomizations());
+ });
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test('a late properties save for another dashboard does not refresh this one',
() => {
+ const store = makeStore();
+ renderAdapter(store);
+ act(() => {
+ store.dispatch(dashboardSaveSucceeded(2));
+ });
+ expect(refresh).not.toHaveBeenCalled();
+});
diff --git
a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
index 40635b2b78e..ab44662422c 100644
--- a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
+++ b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
@@ -18,7 +18,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
-import { useAppDispatch } from 'src/views/store';
+import { useAppDispatch, useAppSelector } from 'src/views/store';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import { t } from '@apache-superset/core/translation';
import { useToasts } from 'src/components/MessageToasts/withToasts';
@@ -141,44 +141,36 @@ export default function DashboardVersionHistory() {
// "Restored version" entry shows up.
const restoreCount = useSelector(selectVersionRestoreCount);
const lastRestoredUuid = useSelector(selectVersionLastRestoredUuid);
- // Saves made while the panel is open must surface as new timeline
- // entries without reopening it. Saves bump one of two redux signals
- // depending on the path: edit-mode saves round-trip through ON_SAVE
- // (dashboardState.lastModifiedTime), while native-filter and
- // properties saves bump dashboardInfo.last_modified_time.
- const saveSignal = useSelector<RootState, string>(state =>
+ // Only successful writes bump these revisions. Timestamps also change for
+ // local metadata edits and may repeat across multiple saves in one second.
+ const saveRevision = useAppSelector(state =>
[
- state.dashboardState?.lastModifiedTime ?? '',
- state.dashboardInfo?.last_modified_time ?? '',
+ state.dashboardState?.versionHistoryRevision ?? 0,
+ state.dashboardInfo?.versionHistoryRevision ?? 0,
].join('|'),
);
+ const lastSaveRevisionRef = useRef(saveRevision);
+ const lastSaveUuidRef = useRef(uuid);
const lastRestoreCountRef = useRef(restoreCount);
- const lastSaveSignalRef = useRef(saveSignal);
const refreshActivity = activity.refresh;
useEffect(() => {
+ const saved =
+ uuid === lastSaveUuidRef.current &&
+ saveRevision !== lastSaveRevisionRef.current;
+ lastSaveRevisionRef.current = saveRevision;
+ lastSaveUuidRef.current = uuid;
if (restoreCount !== lastRestoreCountRef.current) {
lastRestoreCountRef.current = restoreCount;
- if (lastRestoredUuid !== uuid) {
- // A restore of some other entity, resolving after navigation. This
- // dashboard's timeline gained nothing; don't refetch it.
+ if (lastRestoredUuid === uuid) {
+ // The restore refresh covers any simultaneous save signal.
+ refreshActivity();
return;
}
- // The restore refresh covers any save-signal movement caused by
- // the same change; sync it so it does not refetch again.
- lastSaveSignalRef.current = saveSignal;
- refreshActivity();
- return;
}
- if (saveSignal !== lastSaveSignalRef.current) {
- // A signal appearing where none existed is the page's initial
- // hydration, not a save.
- const isInitialHydration = lastSaveSignalRef.current === '|';
- lastSaveSignalRef.current = saveSignal;
- if (!isInitialHydration) {
- refreshActivity();
- }
+ if (saved) {
+ refreshActivity();
}
- }, [lastRestoredUuid, refreshActivity, restoreCount, saveSignal, uuid]);
+ }, [lastRestoredUuid, refreshActivity, restoreCount, saveRevision, uuid]);
const handleClose = useCallback(() => {
dispatch(closeVersionHistoryPanel());
diff --git
a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
index 922d9807ce2..c0124b1aae9 100644
---
a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
+++
b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
@@ -17,13 +17,39 @@
* under the License.
*/
import type { AnyAction, Store } from 'redux';
-import { act, render, waitFor } from 'spec/helpers/testing-library';
-import { hydrateExplore } from 'src/explore/actions/hydrateExplore';
+import { ToastType } from 'src/components/MessageToasts/types';
+import fetchMock from 'fetch-mock';
+import { DatasourceType, QueryFormData } from '@superset-ui/core';
+import type { Slice } from 'src/dashboard/types';
+import {
+ updateSlice,
+ createSlice,
+ saveSliceSuccess,
+} from 'src/explore/actions/saveModalActions';
+import saveModalReducer from 'src/explore/reducers/saveModalReducer';
+import {
+ act,
+ render,
+ waitFor,
+ screen,
+ userEvent,
+} from 'spec/helpers/testing-library';
+import {
+ hydrateExplore,
+ HYDRATE_EXPLORE,
+} from 'src/explore/actions/hydrateExplore';
+import PropertiesModal from 'src/explore/components/PropertiesModal';
+import { sliceUpdated } from 'src/explore/actions/exploreActions';
import type { VersionHistoryState } from './types';
import { fetchExploreRehydrationData } from './api';
import { useVersionActivity } from './useVersionActivity';
import ExploreVersionHistory from './ExploreVersionHistory';
+jest.mock('src/explore/exploreUtils', () => ({
+ ...jest.requireActual('src/explore/exploreUtils'),
+ buildV1ChartDataPayload: jest.fn(() => ({})),
+}));
+
jest.mock('./VersionHistoryPanel', () => ({
__esModule: true,
default: () => null,
@@ -44,9 +70,12 @@ jest.mock('./api', () => ({
fetchExploreRehydrationData: jest.fn(),
}));
jest.mock('src/explore/actions/hydrateExplore', () => ({
+ HYDRATE_EXPLORE: 'HYDRATE_EXPLORE',
hydrateExplore: jest.fn(),
}));
jest.mock('src/components/MessageToasts/withToasts', () => ({
+ __esModule: true,
+ ...jest.requireActual('src/components/MessageToasts/withToasts'),
useToasts: () => ({ addDangerToast: jest.fn() }),
}));
@@ -80,7 +109,8 @@ interface TestSlice {
interface TestState {
versionHistory: VersionHistoryState;
- explore: { slice?: TestSlice };
+ explore: { slice?: TestSlice; form_data?: QueryFormData };
+ saveModal?: ReturnType<typeof saveModalReducer>;
}
const slice = (changedOn: string): TestSlice => ({
@@ -89,8 +119,7 @@ const slice = (changedOn: string): TestSlice => ({
changed_on: changedOn,
});
-/** Minimal recording store: dispatched actions are captured, never reduced,
- * so tests drive state transitions explicitly via setState. */
+/** Reduce the real save response; unrelated transitions can be driven
explicitly. */
function makeTestStore(initial: TestState) {
let state = initial;
const actions: AnyAction[] = [];
@@ -102,8 +131,13 @@ function makeTestStore(initial: TestState) {
state = { ...state, ...partial };
listeners.forEach(listener => listener());
},
- dispatch(action: AnyAction) {
+ dispatch<T extends AnyAction>(action: T): T {
actions.push(action);
+ state = {
+ ...state,
+ saveModal: saveModalReducer(state.saveModal, action),
+ };
+ listeners.forEach(listener => listener());
return action;
},
subscribe(listener: () => void) {
@@ -149,13 +183,17 @@ beforeEach(() => {
afterEach(() => {
jest.clearAllMocks();
+ fetchMock.clearHistory().removeRoutes();
});
-test('refreshes the timeline when an overwrite save replaces the slice', () =>
{
+test('overwrite success followed by timestamp hydration refreshes only once',
() => {
const store = makeStore();
renderAdapter(store);
expect(refresh).not.toHaveBeenCalled();
+ act(() => {
+ store.dispatch(saveSliceSuccess({ id: 1 }));
+ });
act(() => {
store.setState({ explore: { slice: slice('2025-12-08T18:00:00') } });
});
@@ -327,7 +365,7 @@ test('a save landing mid-rehydration wins over the older
restore payload', async
// An overwrite save commits while the rehydration is still in flight.
act(() => {
- store.setState({ explore: { slice: slice('2025-12-09T09:00:00') } });
+ store.dispatch(saveSliceSuccess({ id: 1 }));
});
await act(async () => {
@@ -336,3 +374,293 @@ test('a save landing mid-rehydration wins over the older
restore payload', async
expect(mockedHydrateExplore).not.toHaveBeenCalled();
});
+
+const overwriteSlice: Slice = {
+ slice_id: 1,
+ slice_name: 'Example',
+ editors: [],
+ form_data: { datasource: '1__table', viz_type: 'table' },
+ description: '',
+ description_markdown: '',
+ slice_url: '',
+ viz_type: 'table',
+ thumbnail_url: '',
+ changed_on: 0,
+ changed_on_humanized: '',
+ modified: '',
+ datasource_id: 1,
+ datasource_type: DatasourceType.Table,
+ datasource_url: '',
+ datasource_name: '',
+ created_by: { id: 1 },
+};
+
+const makeSaveStore = () =>
+ makeTestStore({
+ versionHistory: versionHistoryState(),
+ explore: {
+ slice: slice('2025-12-08T17:18:00'),
+ form_data: overwriteSlice.form_data,
+ },
+ });
+
+test('real overwrite success refreshes the open panel on every save without
changed_on movement', async () => {
+ fetchMock.put('glob:*/api/v1/chart/1', { id: 1, result: {} });
+ const store = makeSaveStore();
+ renderAdapter(store);
+
+ for (let save = 1; save <= 2; save += 1) {
+ await act(async () => {
+ await updateSlice(
+ overwriteSlice,
+ 'Example',
+ [],
+ )(store.dispatch, store.getState);
+ });
+ expect(store.getState().explore.slice?.changed_on).toBe(
+ '2025-12-08T17:18:00',
+ );
+ expect(refresh).toHaveBeenCalledTimes(save);
+ }
+ expect(mockedHydrateExplore).not.toHaveBeenCalled();
+});
+
+test('a failed overwrite and a save-as result do not refresh the old chart',
async () => {
+ fetchMock.put('glob:*/api/v1/chart/1', 500);
+ fetchMock.post('glob:*/api/v1/chart/', { id: 2, result: {} });
+ const store = makeSaveStore();
+ renderAdapter(store);
+
+ await act(async () => {
+ await expect(
+ updateSlice(
+ overwriteSlice,
+ 'Example',
+ [],
+ )(store.dispatch, store.getState),
+ ).rejects.toBeDefined();
+ await createSlice('Copy', [])(store.dispatch, store.getState);
+ });
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test('overwrite completing after unmount does not refresh the panel', async ()
=> {
+ let finishSave: (value: { id: number }) => void = () => {};
+ fetchMock.put(
+ 'glob:*/api/v1/chart/1',
+ () =>
+ new Promise(resolve => {
+ finishSave = resolve;
+ }),
+ );
+ const store = makeSaveStore();
+ const { unmount } = renderAdapter(store);
+ const pending = updateSlice(
+ overwriteSlice,
+ 'Example',
+ [],
+ )(store.dispatch, store.getState);
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('glob:*/api/v1/chart/1')).toHaveLength(
+ 1,
+ ),
+ );
+ unmount();
+ await act(async () => {
+ finishSave({ id: 1 });
+ await pending;
+ });
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test('a real save with an unchanged timestamp invalidates an older restore
hydration', async () => {
+ let finishRestore: (value: object) => void = () => {};
+ mockedFetchRehydration.mockReturnValue(
+ new Promise(resolve => {
+ finishRestore = resolve;
+ }),
+ );
+ fetchMock.put('glob:*/api/v1/chart/1', { id: 1, result: {} });
+ const store = makeSaveStore();
+ renderAdapter(store);
+ act(() => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'chart-uuid',
+ }),
+ });
+ });
+ await act(async () => {
+ await updateSlice(
+ overwriteSlice,
+ 'Example',
+ [],
+ )(store.dispatch, store.getState);
+ finishRestore({});
+ });
+ expect(refresh).toHaveBeenCalledTimes(2);
+ expect(mockedHydrateExplore).not.toHaveBeenCalled();
+});
+
+test('a restore of another entity does not suppress a simultaneous real chart
save', async () => {
+ fetchMock.put('glob:*/api/v1/chart/1', { id: 1, result: {} });
+ const store = makeSaveStore();
+ renderAdapter(store);
+ await act(async () => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'another-uuid',
+ }),
+ });
+ await updateSlice(
+ overwriteSlice,
+ 'Example',
+ [],
+ )(store.dispatch, store.getState);
+ });
+ expect(refresh).toHaveBeenCalledTimes(1);
+ expect(mockedFetchRehydration).not.toHaveBeenCalled();
+});
+
+test('a late overwrite response after chart navigation does not refresh the
new chart', async () => {
+ let finishSave: (value: { id: number }) => void = () => {};
+ fetchMock.put(
+ 'glob:*/api/v1/chart/1',
+ () =>
+ new Promise(resolve => {
+ finishSave = resolve;
+ }),
+ );
+ const store = makeSaveStore();
+ renderAdapter(store);
+ const pending = updateSlice(
+ overwriteSlice,
+ 'Example',
+ [],
+ )(store.dispatch, store.getState);
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('glob:*/api/v1/chart/1')).toHaveLength(
+ 1,
+ ),
+ );
+ act(() => {
+ store.setState({
+ explore: {
+ slice: {
+ ...slice('2025-12-08T17:18:00'),
+ slice_id: 2,
+ uuid: 'chart-2',
+ },
+ },
+ });
+ });
+ await act(async () => {
+ finishSave({ id: 1 });
+ await pending;
+ });
+ expect(refresh).not.toHaveBeenCalled();
+ expect(mockedFetchRehydration).not.toHaveBeenCalled();
+});
+
+test('save-as navigation lets the new entity load without an extra save
refresh', async () => {
+ fetchMock.post('glob:*/api/v1/chart/', { id: 2, result: {} });
+ const store = makeSaveStore();
+ renderAdapter(store);
+ await act(async () => {
+ await createSlice('Copy', [])(store.dispatch, store.getState);
+ store.setState({
+ explore: {
+ slice: {
+ ...slice('2025-12-08T17:18:00'),
+ slice_id: 2,
+ uuid: 'chart-2',
+ },
+ },
+ });
+ });
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test.each([true, false])(
+ 'successful overwrite survives hydration with batched=%s without duplicate
refresh',
+ async batched => {
+ fetchMock.put('glob:*/api/v1/chart/1', { id: 1, result: {} });
+ const store = makeSaveStore();
+ renderAdapter(store);
+ await act(async () => {
+ await updateSlice(
+ overwriteSlice,
+ 'Renamed chart',
+ [],
+ )(store.dispatch, store.getState);
+ if (batched)
+ store.dispatch({ type: HYDRATE_EXPLORE, data: { saveModal: {} } });
+ });
+ if (!batched) {
+ act(() => {
+ store.dispatch({ type: HYDRATE_EXPLORE, data: { saveModal: {} } });
+ });
+ }
+ expect(store.getState().saveModal?.data).toBeUndefined();
+ expect(refresh).toHaveBeenCalledTimes(1);
+ },
+);
+
+test.each(['open', 'closed', 'other chart', 'failed'])(
+ 'properties title save with history %s respects persistence and scope',
+ async scenario => {
+ fetchMock.get('glob:*/api/v1/chart/1*', {
+ result: { ...overwriteSlice, editors: [], viewers: [], tags: [] },
+ });
+ fetchMock.put(
+ 'glob:*/api/v1/chart/1',
+ scenario === 'failed' ? 500 : { id: 1, result: {} },
+ );
+ const store = makeSaveStore();
+ if (scenario === 'closed')
+ store.setState({
+ versionHistory: versionHistoryState({ isPanelOpen: false }),
+ });
+ if (scenario === 'other chart')
+ store.setState({
+ explore: {
+ slice: { ...slice('unchanged'), slice_id: 2, uuid: 'other-chart' },
+ },
+ });
+ const onHide = jest.fn();
+ render(
+ <>
+ <ExploreVersionHistory />
+ <PropertiesModal
+ show
+ slice={overwriteSlice}
+ onHide={onHide}
+ onSave={(chart: Parameters<typeof sliceUpdated>[0]) =>
+ store.dispatch(sliceUpdated(chart))
+ }
+ />
+ </>,
+ { store: store as unknown as Store },
+ );
+ const name = await screen.findByRole('textbox', { name: 'Name' });
+ await userEvent.clear(name);
+ await userEvent.type(name, 'Renamed chart');
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+ if (scenario === 'failed') {
+ await waitFor(() =>
+ expect(store.actions).toContainEqual(
+ expect.objectContaining({
+ type: 'ADD_TOAST',
+ payload: expect.objectContaining({ toastType: ToastType.Danger }),
+ }),
+ ),
+ );
+ expect(onHide).not.toHaveBeenCalled();
+ } else {
+ await waitFor(() => expect(onHide).toHaveBeenCalled());
+ }
+ expect(refresh).toHaveBeenCalledTimes(scenario === 'open' ? 1 : 0);
+ },
+);
diff --git
a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
index 3b5ac7728f6..b0bed760489 100644
--- a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
+++ b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
@@ -17,8 +17,8 @@
* under the License.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useSelector } from 'react-redux';
-import { useAppDispatch } from 'src/views/store';
+import { useSelector, useStore } from 'react-redux';
+import { useAppDispatch, useAppSelector, RootState } from 'src/views/store';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
@@ -180,16 +180,13 @@ export default function ExploreVersionHistory() {
// shows up.
const restoreCount = useSelector(selectVersionRestoreCount);
const lastRestoredUuid = useSelector(selectVersionLastRestoredUuid);
- // An overwrite save re-hydrates explore in place (no remount), which
- // replaces the slice with a fresh server copy; watch its changed_on
- // so the save surfaces as a new timeline entry while the panel is
- // open. A "save as" navigates with PUSH and reloads the page, so it
- // needs no signal.
- const saveSignal = useSelector<ExplorePageState, string | undefined>(
- state => state.explore?.slice?.changed_on,
- );
+ // The success marker survives batched Explore hydration and same-second
+ // saves. Its id prevents save-as/late results refreshing another chart.
+ const reduxStore = useStore<RootState>();
+ const savedChart = useAppSelector(state => state.saveModal?.lastSavedChart);
+ const lastSavedChartRef = useRef(savedChart);
+ const lastSaveSliceIdRef = useRef(sliceId);
const lastRestoreCountRef = useRef(restoreCount);
- const lastSaveSignalRef = useRef(saveSignal);
const refreshActivity = activity.refresh;
// Invalidation token for the in-flight post-restore rehydration.
// hydrateExplore rewrites the whole explore store, so a fetch resolving
@@ -209,15 +206,24 @@ export default function ExploreVersionHistory() {
[sliceId, uuid],
);
useEffect(() => {
+ const savedThisChart =
+ sliceId === lastSaveSliceIdRef.current &&
+ savedChart !== lastSavedChartRef.current &&
+ savedChart !== undefined &&
+ savedChart.id === sliceId;
+ lastSavedChartRef.current = savedChart;
+ lastSaveSliceIdRef.current = sliceId;
+ if (savedThisChart) {
+ // A newer committed save supersedes an in-flight restore hydration,
+ // including saves whose changed_on value has not moved.
+ restoreHydrationIdRef.current += 1;
+ }
if (restoreCount !== lastRestoreCountRef.current) {
lastRestoreCountRef.current = restoreCount;
// Guard: a restore of some other entity, resolving after navigation.
// This chart did not change on the server; rehydrating would discard
// its state for someone else's restore.
if (lastRestoredUuid === uuid) {
- // The restore refresh covers any save-signal movement caused by
- // the same change; sync it so it does not refetch again.
- lastSaveSignalRef.current = saveSignal;
refreshActivity();
if (sliceId) {
restoreHydrationIdRef.current += 1;
@@ -226,10 +232,9 @@ export default function ExploreVersionHistory() {
// in hand older than the store: hydrating it would roll the chart
// back over the newer save. The save's own in-place hydration is
// already correct, so the stale restore payload is simply dropped.
- const saveSignalAtStart = lastSaveSignalRef.current;
const isCurrent = () =>
restoreHydrationIdRef.current === hydrationId &&
- lastSaveSignalRef.current === saveSignalAtStart;
+ reduxStore.getState().saveModal?.lastSavedChart === savedChart;
fetchExploreRehydrationData(sliceId)
.then(result => {
if (isCurrent()) {
@@ -244,23 +249,21 @@ export default function ExploreVersionHistory() {
}
});
}
+ return;
}
- } else if (saveSignal !== lastSaveSignalRef.current) {
- // A signal appearing where none existed is the page's initial
- // hydration, not a save.
- const isInitialHydration = lastSaveSignalRef.current === undefined;
- lastSaveSignalRef.current = saveSignal;
- if (!isInitialHydration) {
- refreshActivity();
- }
+ }
+ if (savedThisChart && isPanelOpen) {
+ refreshActivity();
}
}, [
addDangerToast,
dispatch,
+ isPanelOpen,
lastRestoredUuid,
refreshActivity,
+ reduxStore,
restoreCount,
- saveSignal,
+ savedChart,
sliceId,
uuid,
]);
diff --git
a/superset-frontend/src/features/versionHistory/SaveGroupItem.test.tsx
b/superset-frontend/src/features/versionHistory/SaveGroupItem.test.tsx
index 4a6fdd7bd6f..3ae0d14a2d3 100644
--- a/superset-frontend/src/features/versionHistory/SaveGroupItem.test.tsx
+++ b/superset-frontend/src/features/versionHistory/SaveGroupItem.test.tsx
@@ -23,7 +23,7 @@ import {
userEvent,
within,
} from 'spec/helpers/testing-library';
-import type { ActivityRecord, SaveGroup } from './types';
+import type { ActivityRecord, SaveGroup, VersionedEntityType } from './types';
import SaveGroupItem, { SaveGroupItemProps } from './SaveGroupItem';
const makeGroup = (overrides: Partial<SaveGroup> = {}): SaveGroup => ({
@@ -218,3 +218,93 @@ test('an empty group with no creationKind gets no preview
affordance', () => {
screen.queryByRole('button', { name: 'Preview this version' }),
).not.toBeInTheDocument();
});
+
+test.each<{ entityType: VersionedEntityType; isCurrent: boolean }>([
+ { entityType: 'chart', isCurrent: false },
+ { entityType: 'chart', isCurrent: true },
+ { entityType: 'dashboard', isCurrent: false },
+ { entityType: 'dashboard', isCurrent: true },
+])(
+ '$entityType change rows preview without duplicating group actions (current:
$isCurrent)',
+ async ({ entityType, isCurrent }) => {
+ const records: ActivityRecord[] = ['metrics', 'filters'].map(kind => ({
+ version_uuid: 'version-uuid',
+ entity_kind: entityType,
+ entity_uuid: 'entity-uuid',
+ entity_name: 'Example',
+ entity_deleted: false,
+ entity_deletion_state: null,
+ source: 'self',
+ transaction_id: 7,
+ action_kind: null,
+ issued_at: '2026-08-31T17:46:00',
+ changed_by: null,
+ kind,
+ operation: 'update',
+ path: [kind],
+ from_value: null,
+ to_value: null,
+ summary: '',
+ impact: null,
+ }));
+ const group = makeGroup({ records });
+ const onPreview = jest.fn();
+ const onExitPreview = jest.fn();
+ const onOpenAsNew = jest.fn();
+ const onRestore = jest.fn();
+ renderItem({
+ entityType,
+ isCurrent,
+ group,
+ onPreview,
+ onExitPreview,
+ onOpenAsNew,
+ onRestore,
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: /Aug 31, 2026/
}));
+ const rows = screen.getAllByTestId('version-history-action-row');
+ expect(rows).toHaveLength(2);
+ rows.forEach(row => {
+ expect(
+ within(row).queryByRole('button', { name: 'More actions' }),
+ ).not.toBeInTheDocument();
+ });
+ expect(
+ screen.getAllByRole('button', { name: 'More actions' }),
+ ).toHaveLength(1);
+
+ await userEvent.click(rows[0]);
+ rows[1].focus();
+ fireEvent.keyDown(rows[1], { key: 'Enter' });
+ fireEvent.keyDown(rows[1], { key: ' ' });
+ if (isCurrent) {
+ expect(onExitPreview).toHaveBeenCalledTimes(3);
+ expect(onPreview).not.toHaveBeenCalled();
+ } else {
+ expect(onPreview).toHaveBeenCalledTimes(3);
+ expect(onPreview).toHaveBeenLastCalledWith(group);
+ expect(onExitPreview).not.toHaveBeenCalled();
+ }
+
+ await userEvent.click(screen.getByRole('button', { name: 'More actions'
}));
+ if (isCurrent) {
+ expect(
+ screen.queryByRole('menuitem', { name: 'Restore this version' }),
+ ).not.toBeInTheDocument();
+ } else {
+ expect(
+ screen.getByRole('menuitem', { name: 'Restore this version' }),
+ ).toBeInTheDocument();
+ }
+ await userEvent.click(
+ screen.getByRole('menuitem', { name: `Open as new ${entityType}` }),
+ );
+ expect(onOpenAsNew).toHaveBeenCalledTimes(1);
+ expect(onOpenAsNew).toHaveBeenCalledWith(group);
+ expect(onRestore).not.toHaveBeenCalled();
+ expect(onPreview).toHaveBeenCalledTimes(isCurrent ? 0 : 3);
+ expect(onExitPreview).toHaveBeenCalledTimes(isCurrent ? 3 : 0);
+
expect(screen.getAllByTestId('version-history-action-row')).toHaveLength(2);
+ },
+);
diff --git a/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
b/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
index 0eed6401a68..599e0fd5add 100644
--- a/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
+++ b/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
@@ -396,15 +396,10 @@ export default function SaveGroupItem({
key={`${record.kind}-${record.operation}-${JSON.stringify(
record.path,
)}-${index}`}
- entityType={entityType}
record={record}
- showRestore={canRestore && !isCurrent}
- showActions={group.versionUuid != null}
isHighlighted={isHighlighted}
isLast={index === visibleRecords.length - 1 && hiddenCount === 0}
onPreview={previewIntent}
- onRestore={() => onRestore(group)}
- onOpenAsNew={() => onOpenAsNew(group)}
/>
))}
{hiddenCount > 0 && (
diff --git
a/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
index 9c6109ef9cb..a6711ac5f9f 100644
--- a/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
+++ b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
@@ -203,8 +203,7 @@ test('dashboard groups head with the save date and expand
to descriptive rows',
test('a group without a versionUuid offers no kebab at all', async () => {
// Restore and open-as-new both name a specific version; the container
// handlers silently no-op on a null versionUuid, so offering the menu
- // reads as breakage. No kebab beats a dead menu — and the expanded
- // action rows hide theirs for the same reason.
+ // reads as breakage. No kebab beats a dead menu.
const versionless = group({ transactionId: 3, versionUuid: null });
const props = defaultProps([versionless], 'dashboard');
render(<VersionHistoryPanel {...props} />);