You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/04 19:37:52 UTC
(superset) branch master updated: feat(versioning): version-history UI (#41551)
This is an automated email from the ASF dual-hosted git repository.
rusackas 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 e103d92b489 feat(versioning): version-history UI (#41551)
e103d92b489 is described below
commit e103d92b4894807b46c975f8574debd2ede4b106
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Tue Aug 4 20:37:46 2026 +0100
feat(versioning): version-history UI (#41551)
Co-authored-by: Kamil Gabryjelski <ka...@gmail.com>
Co-authored-by: Claude Opus 4.7 <no...@anthropic.com>
Co-authored-by: Mike Bridge <mi...@ext.preset.io>
---
docs/docs/using-superset/version-history.mdx | 120 ++++
docs/static/feature-flags.json | 6 +
.../superset-ui-core/src/utils/featureFlags.ts | 1 +
.../tests/version-history/activity-log.spec.ts | 175 +++++
superset-frontend/spec/helpers/reducerIndex.ts | 2 +
superset-frontend/src/constants.ts | 6 +
superset-frontend/src/dashboard/actions/hydrate.ts | 14 +-
.../DashboardBuilder/DashboardBuilder.test.tsx | 179 +++++-
.../DashboardBuilder/DashboardBuilder.tsx | 235 ++++++-
.../DashboardBuilder/DashboardWrapper.tsx | 2 +-
.../src/dashboard/components/Header/index.tsx | 19 +-
.../Header/useHeaderActionsDropdownMenu.tsx | 20 +-
.../components/gridComponents/Tab/Tab.tsx | 11 +-
superset-frontend/src/dashboard/types.ts | 3 +
.../src/explore/actions/exploreActions.ts | 17 +-
.../src/explore/components/Control.tsx | 8 +-
.../explore/components/ControlPanelsContainer.tsx | 9 +-
.../components/ExploreChartHeader/index.tsx | 27 +-
.../ExploreViewContainer.test.tsx | 36 +-
.../components/ExploreViewContainer/index.tsx | 142 +++-
.../src/explore/components/SaveModal.test.tsx | 35 +-
.../src/explore/components/SaveModal.tsx | 44 +-
.../controls/AnnotationLayerControl/index.tsx | 7 +-
.../DatasourceControl/DatasourceControl.test.tsx | 48 ++
.../controls/DatasourceControl/index.tsx | 17 +-
.../useExploreAdditionalActionsMenu/index.tsx | 36 ++
.../useExploreAdditionalActionsMenu.test.tsx | 68 ++
.../explore/exploreUtils/canOverwriteSlice.test.ts | 109 ++++
.../src/explore/exploreUtils/canOverwriteSlice.ts | 71 ++
.../src/features/versionHistory/ActionRow.tsx | 246 +++++++
.../versionHistory/ChartVersionPreview.test.tsx | 247 +++++++
.../versionHistory/ChartVersionPreview.tsx | 257 ++++++++
.../versionHistory/CurrentVersionSection.tsx | 163 +++++
.../DashboardVersionHistory.test.tsx | 255 ++++++++
.../versionHistory/DashboardVersionHistory.tsx | 282 ++++++++
.../versionHistory/ExploreVersionHistory.test.tsx | 338 ++++++++++
.../versionHistory/ExploreVersionHistory.tsx | 361 +++++++++++
.../features/versionHistory/PreviewBanner.test.tsx | 247 +++++++
.../src/features/versionHistory/PreviewBanner.tsx | 129 ++++
.../features/versionHistory/RelatedUpdateRow.tsx | 183 ++++++
.../versionHistory/RestoreConfirmModal.tsx | 72 +++
.../src/features/versionHistory/SaveGroupItem.tsx | 386 +++++++++++
.../versionHistory/VersionHistoryPanel.test.tsx | 498 ++++++++++++++
.../versionHistory/VersionHistoryPanel.tsx | 343 ++++++++++
.../src/features/versionHistory/api.test.ts | 232 +++++++
.../src/features/versionHistory/api.ts | 396 ++++++++++++
.../features/versionHistory/canRestoreDashboard.ts | 41 ++
.../src/features/versionHistory/display.test.ts | 421 ++++++++++++
.../src/features/versionHistory/display.ts | 398 ++++++++++++
.../src/features/versionHistory/grouping.test.ts | 440 +++++++++++++
.../src/features/versionHistory/grouping.ts | 276 ++++++++
.../features/versionHistory/openRelated.test.ts | 130 ++++
.../src/features/versionHistory/openRelated.ts | 64 ++
.../versionHistory/previewReselect.test.tsx | 134 ++++
.../src/features/versionHistory/reducer.test.ts | 147 +++++
.../src/features/versionHistory/reducer.ts | 294 +++++++++
.../versionHistory/sessionLogMiddleware.test.ts | 226 +++++++
.../versionHistory/sessionLogMiddleware.ts | 155 +++++
.../src/features/versionHistory/types.ts | 231 +++++++
.../useDashboardVersionPreview.test.tsx | 716 +++++++++++++++++++++
.../versionHistory/useDashboardVersionPreview.ts | 470 ++++++++++++++
.../versionHistory/useVersionActions.test.tsx | 338 ++++++++++
.../features/versionHistory/useVersionActions.tsx | 323 ++++++++++
.../versionHistory/useVersionActivity.test.ts | 424 ++++++++++++
.../features/versionHistory/useVersionActivity.ts | 281 ++++++++
.../src/hooks/apiResources/dashboards.ts | 2 +-
superset-frontend/src/types/Chart.ts | 2 +
superset-frontend/src/utils/navigationUtils.ts | 38 ++
superset-frontend/src/views/store.ts | 18 +-
superset/config.py | 5 +
tests/integration_tests/superset_test_config.py | 3 +
71 files changed, 11548 insertions(+), 131 deletions(-)
diff --git a/docs/docs/using-superset/version-history.mdx b/docs/docs/using-superset/version-history.mdx
new file mode 100644
index 00000000000..59490e7b984
--- /dev/null
+++ b/docs/docs/using-superset/version-history.mdx
@@ -0,0 +1,120 @@
+---
+title: Version History
+hide_title: true
+sidebar_position: 8
+version: 1
+---
+
+# Version History
+
+Superset can record the history of a chart or dashboard as you edit it, and let
+you look at, restore, or branch from any earlier version.
+
+The panel lists each saved version with its author, timestamp, and a plain
+description of what changed — "Chart renamed to Q3 Revenue", "Added filter on
+'Region'" — rather than a raw diff. You can search the history and filter it
+down to changes on the entity itself or on the things it depends on.
+
+## Enabling it
+
+Two switches are involved, and both matter.
+
+| Setting | Type | Effect |
+| --- | --- | --- |
+| `VERSION_HISTORY` | Feature flag | Shows the version history UI |
+| `ENABLE_VERSIONING_CAPTURE` | Config value | Records versions as entities are saved |
+
+```python
+# superset_config.py
+FEATURE_FLAGS = {"VERSION_HISTORY": True}
+ENABLE_VERSIONING_CAPTURE = True
+```
+
+Both default to off. They are separate because capture is the expensive half:
+an operator may want to start recording history before exposing the UI, so that
+there is something to show when they do.
+
+Turning the UI on without capture gives a panel that reports "No history yet"
+and never fills, so enable capture first — or at the same time. History only
+accrues from the moment capture is switched on; earlier edits are not
+reconstructed.
+
+## Viewing history
+
+On a chart, open **… (actions) → View version history** in Explore. A dashboard
+uses the same entry in its own **… (actions)** menu.
+
+The entry appears for users who may edit the entity — the same rule the server
+applies to overwriting it: administrators, plus anyone whose subjects are among
+the entity's editors. Users who cannot edit do not see the panel.
+
+## Previewing a version
+
+Selecting a version loads it into the page so you can see it before deciding
+anything. While a preview is active:
+
+- A banner across the top marks the page as historical and names the version.
+- The dashboard or chart itself is inert — filters, controls, and chart
+ interactions are disabled, so nothing you click can be mistaken for editing
+ the live entity. You can still scroll, and still move between tabs.
+- Saving is unavailable. Preview never writes.
+
+Close the preview to return to the live version, along with whatever filter
+selections you had before you opened it.
+
+A dashboard preview reconstructs the layout as it was, including charts that
+have since been removed from the dashboard. A chart that has been deleted
+outright cannot be reconstructed; its slot shows a placeholder instead.
+
+Preview is a close approximation of the version, not a pixel-faithful replay.
+The dashboard's own saved properties — title, CSS, metadata, description,
+slug, certification, draft or published state, and theme — are shown as they
+were. Charts, though, render with their current definitions rather than the
+definitions they had at that version, and a dashboard's chart membership is
+reconstructed from the saved layout, so charts that belonged to the dashboard
+without appearing in the layout are not represented. Restore applies the full
+captured state, so a restored dashboard can differ from its preview in these
+respects.
+
+## Restoring a version
+
+**Restore** rewrites the entity back to the selected version. Two things are
+worth knowing:
+
+- **History stays append-only.** A restore is itself recorded as a new version,
+ so restoring never destroys the states you moved away from — you can always
+ restore forward again.
+- **A dashboard restore may be partial.** If the version referenced charts that
+ have since been deleted, the layout is restored without them and Superset
+ tells you how many were not reattached.
+
+Restoring is offered to the same users who may edit the entity, and is withheld
+from externally managed entities, whose source of truth lives outside Superset
+and would overwrite the restore on the next sync.
+
+## Opening a version as something new
+
+**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.
+
+How much of the version comes across differs by entity, because the two copy
+paths do. A new chart is built from the version itself: its name,
+visualization type, dataset, controls, query context, description, and cache
+timeout. A new dashboard is a copy of the *live* dashboard with the version's
+title, CSS, metadata, and layout applied over it — the copy endpoint accepts
+only those fields, so properties such as description, slug, certification, and
+theme come from the dashboard as it stands today, not from the version.
+
+## What gets recorded
+
+Versions capture the entity's own saved state — a chart's name, visualization
+type, and controls; a dashboard's title, layout, filters, and properties.
+
+The timeline also shows **related** activity: changes to things the entity
+depends on, such as the dataset behind a chart. These are shown for context and
+cannot be restored from the entity's own history — restore acts on the entity
+you are looking at.
+
+Changes made outside a save, such as ownership or permission edits, are not part
+of version history.
diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json
index 7a7bcad85e5..1cf3c7fd0dd 100644
--- a/docs/static/feature-flags.json
+++ b/docs/static/feature-flags.json
@@ -104,6 +104,12 @@
"default": false,
"lifecycle": "development",
"description": "Enables the tagging system for organizing assets"
+ },
+ {
+ "name": "VERSION_HISTORY",
+ "default": false,
+ "lifecycle": "development",
+ "description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty."
}
],
"testing": [
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index 35eb81cd7b4..ebc5ed9a532 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -66,6 +66,7 @@ export enum FeatureFlag {
TaggingSystem = 'TAGGING_SYSTEM',
Thumbnails = 'THUMBNAILS',
UseAnalogousColors = 'USE_ANALOGOUS_COLORS',
+ VersionHistory = 'VERSION_HISTORY',
ForceSqlLabRunAsync = 'SQLLAB_FORCE_RUN_ASYNC',
SlackEnableAvatars = 'SLACK_ENABLE_AVATARS',
EnableDashboardScreenshotEndpoints = 'ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS',
diff --git a/superset-frontend/playwright/tests/version-history/activity-log.spec.ts b/superset-frontend/playwright/tests/version-history/activity-log.spec.ts
new file mode 100644
index 00000000000..c1beff202f4
--- /dev/null
+++ b/superset-frontend/playwright/tests/version-history/activity-log.spec.ts
@@ -0,0 +1,175 @@
+/**
+ * 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.
+ */
+
+/**
+ * Activity-log (version history) coverage for charts.
+ *
+ * Requires the running instance to have VERSION_HISTORY +
+ * ENABLE_VERSIONING_CAPTURE enabled (the docker dev stack does).
+ *
+ * The test creates its own chart and renames it twice via the authenticated
+ * REST API to guarantee a deterministic, non-"first tracked save" entry, then
+ * opens the Explore version-history panel and asserts the rendered entries —
+ * exercising the descriptive-row rendering (no raw layout node ids / UUIDs
+ * leaking into the timeline).
+ *
+ * It deliberately does not touch the seeded example charts. Renaming those
+ * made the test order-dependent (it picked the most recently changed charts),
+ * unsafe to run on parallel workers, and left version records behind that no
+ * revert could remove — version history being append-only is the point.
+ */
+import { APIRequestContext, Page } from '@playwright/test';
+import rison from 'rison';
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import { apiGet } from '../../helpers/api/requests';
+import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
+import { getDatasetByName } from '../../helpers/api/dataset';
+import { TIMEOUT } from '../../utils/constants';
+
+const DATASET_NAME = 'birth_names';
+
+// Visible row text must never expose synthetic identifiers (layout node
+// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
+// these to human names or kind-only phrasing.
+const OPAQUE_ID =
+ /\b(CHART|ROW|COLUMN|TAB|TABS|HEADER|MARKDOWN|DIVIDER|GRID)-|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
+
+/**
+ * Resolve the current user's USER-type subject id. Chart `editors` (and the
+ * `can_overwrite` gate derived from them) live in the Subject id space, not
+ * the user id space.
+ */
+async function currentUserSubjectId(
+ request: APIRequestContext,
+): Promise<number> {
+ const meRes = await request.get('/api/v1/me/');
+ expect(meRes.ok(), 'current user request').toBeTruthy();
+ const userId = (await meRes.json()).result.id;
+
+ const q = encodeURIComponent(
+ `(filters:!((col:user_id,opr:eq,value:${userId})))`,
+ );
+ const res = await request.get(`/api/v1/security/subject/?q=${q}`);
+ expect(res.ok(), 'subject lookup request').toBeTruthy();
+ const subjects = (await res.json()).result;
+ expect(
+ subjects.length,
+ `USER subject exists for user ${userId}`,
+ ).toBeGreaterThan(0);
+ return subjects[0].id;
+}
+
+/**
+ * Any dataset will do: the chart exists only to carry version records, and
+ * nothing here asserts on rendered data. Prefer the conventional example so
+ * this reads like its sibling specs, but fall back to whatever the instance
+ * has rather than requiring a particular fixture to be loaded.
+ */
+async function anyDatasetId(page: Page): Promise<number> {
+ const named = await getDatasetByName(page, DATASET_NAME);
+ if (named) {
+ return named.id;
+ }
+ const res = await apiGet(
+ page,
+ `api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
+ );
+ expect(res.ok(), 'dataset list request').toBeTruthy();
+ const [first] = (await res.json()).result;
+ expect(first, 'the instance has at least one dataset').toBeTruthy();
+ return first.id;
+}
+
+/** Open the Explore "Additional actions → View version history" panel. */
+async function openVersionHistory(page: Page): Promise<void> {
+ await page.locator('[data-test="actions-trigger"]').click();
+ await page.getByRole('menuitem', { name: 'View version history' }).click();
+ await expect(
+ page.locator('[aria-label="Version history"]'),
+ 'version history panel opens',
+ ).toBeVisible();
+}
+
+testWithAssets(
+ 'chart activity log renders deterministic descriptive entries',
+ async ({ page, testAssets }) => {
+ testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+ const datasetId = await anyDatasetId(page);
+
+ const baseName = `version_history_${Date.now()}`;
+ const chartResp = await apiPostChart(page, {
+ slice_name: baseName,
+ viz_type: 'table',
+ datasource_id: datasetId,
+ datasource_type: 'table',
+ // Raw mode with no column references, so the chart is valid against any
+ // dataset.
+ params: JSON.stringify({
+ datasource: `${datasetId}__table`,
+ viz_type: 'table',
+ query_mode: 'raw',
+ all_columns: [],
+ adhoc_filters: [],
+ row_limit: 10,
+ }),
+ });
+ expect(chartResp.ok(), 'chart creation').toBeTruthy();
+ const chartBody = await chartResp.json();
+ const chartId: number = chartBody.result?.id ?? chartBody.id;
+ expect(chartId, 'chart creation should return an id').toBeTruthy();
+ testAssets.trackChart(chartId);
+
+ // Version history is only offered on charts the user can overwrite, i.e.
+ // can edit — so claim editorship. A chart created through the API starts
+ // without editors.
+ // Two renames: the first edit on an as-yet-untracked chart collapses into
+ // "first tracked save"; the second is a normal descriptive save.
+ const adminSubjectId = await currentUserSubjectId(page.request);
+ await apiPutChart(page, chartId, {
+ editors: [adminSubjectId],
+ slice_name: `${baseName} ·vh1`,
+ });
+ await apiPutChart(page, chartId, { slice_name: `${baseName} ·vh2` });
+
+ await page.goto(`explore/?slice_id=${chartId}`);
+ await openVersionHistory(page);
+
+ const panel = page.locator('[aria-label="Version history"]');
+ const groups = panel.locator('[data-test="version-history-save-group"]');
+ await expect(groups.first(), 'shows a save group').toBeVisible();
+
+ // The newest save is the live one and is tagged "Current".
+ await expect(panel.getByText('Current')).toBeVisible();
+
+ // Expand the newest group and confirm the rename rendered as a descriptive
+ // action row — not a raw field path.
+ await groups.first().getByRole('button').first().click();
+ const rows = panel.locator('[data-test="version-history-action-row"]');
+ await expect(rows.first(), 'shows action rows').toBeVisible();
+ await expect(panel.getByText(/Chart renamed to /).first()).toBeVisible();
+
+ // No synthetic identifiers may surface in any visible timeline row.
+ const panelText = (await panel.innerText()) ?? '';
+ expect(
+ OPAQUE_ID.test(panelText),
+ `panel leaks a raw id/uuid:\n${panelText}`,
+ ).toBeFalsy();
+ },
+);
diff --git a/superset-frontend/spec/helpers/reducerIndex.ts b/superset-frontend/spec/helpers/reducerIndex.ts
index a4e0021839a..034305ca5a0 100644
--- a/superset-frontend/spec/helpers/reducerIndex.ts
+++ b/superset-frontend/spec/helpers/reducerIndex.ts
@@ -30,6 +30,7 @@ import saveModal from 'src/explore/reducers/saveModalReducer';
import explore from 'src/explore/reducers/exploreReducer';
import sqlLab from 'src/SqlLab/reducers/sqlLab';
import reports from 'src/features/reports/ReportModal/reducer';
+import versionHistory from 'src/features/versionHistory/reducer';
import getBootstrapData from 'src/utils/getBootstrapData';
const impressionId = (state = '') => state;
@@ -60,6 +61,7 @@ export default {
sqlLab,
localStorageUsageInKilobytes: noopReducer(0),
reports,
+ versionHistory,
common: noopReducer(common),
user: noopReducer(user),
};
diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts
index 6e2a5ca64a4..271e6890d47 100644
--- a/superset-frontend/src/constants.ts
+++ b/superset-frontend/src/constants.ts
@@ -111,6 +111,10 @@ export const URL_PARAMS = {
name: 'edit',
type: 'boolean',
},
+ versionHistory: {
+ name: 'version_history',
+ type: 'boolean',
+ },
} as const;
export const RESERVED_CHART_URL_PARAMS: string[] = [
@@ -119,6 +123,7 @@ export const RESERVED_CHART_URL_PARAMS: string[] = [
URL_PARAMS.datasourceId.name,
URL_PARAMS.datasourceType.name,
URL_PARAMS.datasetId.name,
+ URL_PARAMS.versionHistory.name,
];
export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
URL_PARAMS.nativeFilters.name,
@@ -126,6 +131,7 @@ export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
URL_PARAMS.permalinkKey.name,
URL_PARAMS.preselectFilters.name,
URL_PARAMS.editMode.name,
+ URL_PARAMS.versionHistory.name,
];
export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
diff --git a/superset-frontend/src/dashboard/actions/hydrate.ts b/superset-frontend/src/dashboard/actions/hydrate.ts
index 527b754e83c..d943ae5f80b 100644
--- a/superset-frontend/src/dashboard/actions/hydrate.ts
+++ b/superset-frontend/src/dashboard/actions/hydrate.ts
@@ -74,7 +74,7 @@ import {
export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
-interface HydrateChartData {
+export interface HydrateChartData {
slice_id: number;
slice_url: string;
slice_name: string;
@@ -86,7 +86,7 @@ interface HydrateChartData {
changed_on: string;
}
-interface HydrateDashboardData extends Dashboard {
+export interface HydrateDashboardData extends Dashboard {
metadata: JsonObject;
position_data: Record<string, LayoutItem> | null;
[key: string]: unknown;
@@ -99,6 +99,13 @@ interface HydrateDashboardParams {
dataMask: DataMaskStateWithId;
activeTabs: string[] | null;
chartStates: DashboardChartStates | null;
+ /**
+ * Forces edit mode rather than deriving it from the `edit` URL param.
+ * Version preview passes `false`: the param outlives the navigation that
+ * set it, so a dashboard opened with `?edit=true` would otherwise keep an
+ * live Save toolbar over a historical snapshot.
+ */
+ editMode?: boolean;
}
export const hydrateDashboard =
@@ -109,6 +116,7 @@ export const hydrateDashboard =
dataMask,
activeTabs,
chartStates,
+ editMode: editModeOverride,
}: HydrateDashboardParams) =>
(dispatch: AppDispatch, getState: GetState): AnyAction => {
const { user, common, dashboardState } = getState();
@@ -396,7 +404,7 @@ export const hydrateDashboard =
css: dashboard.css || '',
colorNamespace: metadata?.color_namespace || null,
colorScheme: metadata?.color_scheme || null,
- editMode: canEdit && editMode,
+ editMode: editModeOverride ?? (canEdit && editMode),
isPublished: dashboard.published,
hasUnsavedChanges: false,
dashboardIsSaving: false,
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
index ef98369be1d..0bbc5850a13 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
@@ -42,7 +42,14 @@ import {
} from 'spec/fixtures/mockDashboardLayout';
import { storeWithState } from 'spec/fixtures/mockStore';
import mockState from 'spec/fixtures/mockState';
-import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
+import {
+ DASHBOARD_GRID_ID,
+ DASHBOARD_ROOT_ID,
+} from 'src/dashboard/util/constants';
+import {
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_ROOT_TYPE,
+} from 'src/dashboard/util/componentTypes';
import * as useNativeFiltersModule from './state';
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
@@ -786,3 +793,173 @@ test('should maintain layout when switching between tabs', async () => {
expect(gridContainer).toBeInTheDocument();
expect(tabPanels.length).toBeGreaterThan(0);
});
+
+const dashboardPreviewState = {
+ isPanelOpen: true,
+ entityType: 'dashboard',
+ include: 'all',
+ preview: {
+ entityUuid: 'dash-uuid',
+ versionUuid: 'version-uuid',
+ transactionId: 7,
+ headline: 'Dec 5, 2025, 12:18 PM',
+ issuedAt: '2025-12-05T17:18:00',
+ },
+ sessionLog: [],
+ restoreCount: 0,
+ lastRestoredEntityUuid: null,
+};
+
+test('gates the dashboard grid while a version preview is active', async () => {
+ (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+ 100,
+ jest.fn(),
+ ]);
+ (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+ (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+ const { findByTestId } = render(<DashboardBuilder />, {
+ useRedux: true,
+ store: storeWithState({
+ ...mockState,
+ dashboardLayout: undoableDashboardLayout,
+ versionHistory: dashboardPreviewState,
+ }),
+ useDnd: true,
+ useRouter: true,
+ useTheme: true,
+ });
+
+ const gate = await findByTestId('dashboard-grid-gate');
+ expect(gate).toHaveAttribute('aria-disabled', 'true');
+ expect(gate).toHaveStyleRule('pointer-events', 'none');
+ // Tab navigation is deliberately carved out of the gate so tabbed
+ // dashboards remain navigable while previewing.
+ expect(gate).toHaveStyleRule('pointer-events', 'auto', {
+ target: '.ant-tabs-nav',
+ });
+});
+
+test('does not gate the dashboard grid without an active preview', async () => {
+ (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+ 100,
+ jest.fn(),
+ ]);
+ (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+ (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+ const { findByTestId } = render(<DashboardBuilder />, {
+ useRedux: true,
+ store: storeWithState({
+ ...mockState,
+ dashboardLayout: undoableDashboardLayout,
+ }),
+ useDnd: true,
+ useRouter: true,
+ useTheme: true,
+ });
+
+ const gate = await findByTestId('dashboard-grid-gate');
+ expect(gate).toHaveAttribute('aria-disabled', 'false');
+ expect(gate).not.toHaveStyleRule('pointer-events', 'none');
+});
+
+test('lets a keyboard user scroll a gated preview but not activate it', async () => {
+ // pointer-events: none stops the mouse but not the keyboard, so the gate
+ // suppresses keys. Suppressing *every* key made a long previewed dashboard
+ // unreadable without a mouse (WCAG 2.1.1): scrolling is not interaction.
+ (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+ 100,
+ jest.fn(),
+ ]);
+ (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+ (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+ const { findByTestId } = render(<DashboardBuilder />, {
+ useRedux: true,
+ store: storeWithState({
+ ...mockState,
+ dashboardLayout: undoableDashboardLayout,
+ versionHistory: dashboardPreviewState,
+ }),
+ useDnd: true,
+ useRouter: true,
+ useTheme: true,
+ });
+ const gate = await findByTestId('dashboard-grid-gate');
+
+ ['PageDown', 'ArrowDown', 'Home', 'End'].forEach(key => {
+ expect(fireEvent.keyDown(gate, { key })).toBe(true);
+ });
+
+ // Activation still blocked — the gate's whole purpose.
+ expect(fireEvent.keyDown(gate, { key: 'Enter' })).toBe(false);
+});
+
+// The empty-state call to action renders above DashboardContentWrapper, so the
+// grid gate does not cover it. Previewing a version whose layout is empty would
+// otherwise offer a route into edit mode over a historical snapshot.
+const emptyDashboardLayout = {
+ past: [],
+ future: [],
+ present: {
+ [DASHBOARD_ROOT_ID]: {
+ type: DASHBOARD_ROOT_TYPE,
+ id: DASHBOARD_ROOT_ID,
+ children: [DASHBOARD_GRID_ID],
+ },
+ [DASHBOARD_GRID_ID]: {
+ type: DASHBOARD_GRID_TYPE,
+ id: DASHBOARD_GRID_ID,
+ children: [],
+ meta: {},
+ },
+ },
+};
+
+const renderEmptyDashboard = (
+ versionHistory?: typeof dashboardPreviewState,
+) => {
+ (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+ 100,
+ jest.fn(),
+ ]);
+ (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+ (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+ return render(<DashboardBuilder />, {
+ useRedux: true,
+ store: storeWithState({
+ ...mockState,
+ dashboardInfo: { ...mockState.dashboardInfo, dash_edit_perm: true },
+ dashboardLayout: emptyDashboardLayout,
+ ...(versionHistory ? { versionHistory } : {}),
+ }),
+ useDnd: true,
+ useRouter: true,
+ useTheme: true,
+ });
+};
+
+test('offers the empty-state edit action to an editor', async () => {
+ const { findByRole } = renderEmptyDashboard();
+
+ expect(
+ await findByRole('button', { name: 'Edit the dashboard' }),
+ ).toBeVisible();
+});
+
+test('withholds the empty-state edit action while previewing a version', async () => {
+ const { findByText, queryByRole } = renderEmptyDashboard(
+ dashboardPreviewState,
+ );
+
+ // The empty-state message itself still describes what is on screen; only the
+ // edit affordance is withdrawn.
+ expect(
+ await findByText('There are no charts added to this dashboard'),
+ ).toBeInTheDocument();
+ expect(
+ queryByRole('button', { name: 'Edit the dashboard' }),
+ ).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
index cc5926f6ac6..e293bac2167 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
@@ -18,9 +18,24 @@
*/
/* eslint-env browser */
import cx from 'classnames';
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Suspense,
+ lazy,
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { t } from '@apache-superset/core/translation';
-import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core';
+import {
+ addAlpha,
+ isFeatureEnabled,
+ FeatureFlag,
+ JsonObject,
+ useElementOnScreen,
+} from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { useDispatch, useSelector } from 'react-redux';
import { EmptyState, Loading } from '@superset-ui/core/components';
@@ -69,11 +84,22 @@ import {
OPEN_FILTER_BAR_WIDTH,
EMPTY_CONTAINER_Z_INDEX,
} from 'src/dashboard/constants';
+import { selectCanRestoreDashboard } from 'src/features/versionHistory/canRestoreDashboard';
+import { selectIsDashboardVersionPreviewActive } from 'src/features/versionHistory/reducer';
import { getRootLevelTabsComponent, shouldFocusTabs } from './utils';
import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
import DashboardWrapper from './DashboardWrapper';
+// Lazy-loaded so deployments with the VersionHistory flag off never pay
+// the bundle cost of the feature's component graph.
+const DashboardVersionHistory = lazy(
+ () => import('src/features/versionHistory/DashboardVersionHistory'),
+);
+const PreviewBanner = lazy(
+ () => import('src/features/versionHistory/PreviewBanner'),
+);
+
// @z-index-above-dashboard-charts + 1 = 11
const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
background-color: ${({ theme }) => theme.colorBgContainer};
@@ -129,6 +155,17 @@ const StyledContent = styled.div<{
${({ fullSizeChartId }) => fullSizeChartId && `z-index: 101;`}
`;
+// Sticks alongside the page scroll so the panel stays fully visible.
+const VersionHistoryColumn = styled.div`
+ grid-column: 3;
+ grid-row: 1 / span 2;
+ position: sticky;
+ top: 0;
+ align-self: start;
+ height: 100vh;
+ z-index: 99;
+`;
+
const DashboardContentWrapper = styled.div`
${({ theme }) => css`
&.dashboard {
@@ -142,8 +179,9 @@ const DashboardContentWrapper = styled.div`
& .dashboard-component-tabs {
box-shadow: 0 ${theme.sizeUnit}px ${theme.sizeUnit}px 0
${addAlpha(theme.colorBorderSecondary, 0.1)};
- padding-left: ${theme.sizeUnit *
- 2}px; /* note this is added to tab-level padding, to match header */
+ padding-left: ${
+ theme.sizeUnit * 2
+ }px; /* note this is added to tab-level padding, to match header */
}
.dropdown-toggle.btn.btn-primary .caret {
@@ -274,8 +312,9 @@ const DashboardContentWrapper = styled.div`
const StyledDashboardContent = styled.div<{
editMode: boolean;
marginLeft: number;
+ previewGated: boolean;
}>`
- ${({ theme, editMode, marginLeft }) => css`
+ ${({ theme, editMode, marginLeft, previewGated }) => css`
background-color: ${theme.colorBgLayout};
display: flex;
flex-direction: row;
@@ -283,6 +322,22 @@ const StyledDashboardContent = styled.div<{
height: auto;
flex: 1;
+ ${
+ previewGated &&
+ `
+ /* Block chart interactions (context menus, cross-filters, drills)
+ while previewing a historical version. Tab bars deliberately stay
+ clickable: previewing a tabbed dashboard requires navigating its
+ tabs, and tab state is ephemeral — exiting the preview re-hydrates
+ and wipes it. Page scrolling is unaffected because the page, not
+ this subtree, owns the scroll. */
+ pointer-events: none;
+ .ant-tabs-nav {
+ pointer-events: auto;
+ }
+ `
+ }
+
.grid-container .dashboard-component-tabs {
box-shadow: none;
padding-left: 0;
@@ -296,12 +351,14 @@ const StyledDashboardContent = styled.div<{
margin: ${theme.sizeUnit * 4}px;
margin-left: ${marginLeft}px;
- ${editMode &&
- `
+ ${
+ editMode &&
+ `
max-width: calc(100% - ${
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
}px);
- `}
+ `
+ }
/* this is the ParentSize wrapper */
& > div:first-of-type {
@@ -338,8 +395,10 @@ const StyledDashboardContent = styled.div<{
}
&.fade-out {
- box-shadow: ${theme.dashboardTileBoxShadow ??
- `0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`};
+ box-shadow: ${
+ theme.dashboardTileBoxShadow ??
+ `0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`
+ };
}
& .missing-chart-container {
@@ -359,6 +418,20 @@ const StyledDashboardContent = styled.div<{
`}
`;
+/** Keys that only move the viewport. A mouse user can still scroll a gated
+ * preview with the wheel, so blocking the keyboard equivalent would leave a
+ * long dashboard unreadable by keyboard alone (WCAG 2.1.1). */
+const SCROLL_KEYS = new Set([
+ 'ArrowUp',
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'PageUp',
+ 'PageDown',
+ 'Home',
+ 'End',
+]);
+
const ELEMENT_ON_SCREEN_OPTIONS = {
threshold: [1],
};
@@ -380,6 +453,11 @@ const DashboardBuilder = () => {
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
+ // Deliberately not canEdit: restoring an externally managed dashboard would
+ // be undone by the next sync, and unlike editing there is no server-side
+ // check to fall back on. Shared with the history panel so the two cannot
+ // drift.
+ const canRestoreVersion = useSelector(selectCanRestoreDashboard);
const dashboardIsSaving = useSelector<RootState, boolean>(
({ dashboardState }) => dashboardState.dashboardIsSaving,
);
@@ -389,6 +467,14 @@ const DashboardBuilder = () => {
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
);
+ const isVersionPreviewActive = useSelector(
+ selectIsDashboardVersionPreviewActive,
+ );
+ // The empty-state call to action sits outside the preview gate below, so it
+ // needs its own term: a previewed version whose layout happens to be empty
+ // would otherwise offer "Edit the dashboard" over a historical snapshot and
+ // clear the undo history on the way in. Mirrors userCanEdit in the Header.
+ const canEnterEditMode = canEdit && !isVersionPreviewActive;
const handleChangeTab = useCallback(
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
@@ -529,14 +615,37 @@ const DashboardBuilder = () => {
{hideDashboardHeader && !isReport && <HeadlessAutoRefresh />}
{showFilterBar &&
filterBarOrientation === FilterBarOrientation.Horizontal && (
- <FilterBar
- orientation={FilterBarOrientation.Horizontal}
- hidden={hideFilterBar}
- />
+ <div
+ data-test="dashboard-filter-bar-gate"
+ aria-disabled={isVersionPreviewActive}
+ css={css`
+ ${
+ isVersionPreviewActive
+ ? `
+ pointer-events: none;
+ opacity: 0.5;
+ `
+ : ''
+ }
+ `}
+ // inert blocks keyboard focus too; React 18 needs the spread form
+ {...(isVersionPreviewActive ? { inert: '' } : {})}
+ >
+ <FilterBar
+ orientation={FilterBarOrientation.Horizontal}
+ hidden={hideFilterBar}
+ />
+ </div>
)}
</>
),
- [hideDashboardHeader, showFilterBar, filterBarOrientation, hideFilterBar],
+ [
+ hideDashboardHeader,
+ showFilterBar,
+ filterBarOrientation,
+ hideFilterBar,
+ isVersionPreviewActive,
+ ],
);
const renderDraggableContent = useCallback(
@@ -603,16 +712,34 @@ const DashboardBuilder = () => {
>
<StickyPanel ref={containerRef} width={filterBarWidth}>
<ErrorBoundary>
- <FilterBar
- orientation={FilterBarOrientation.Vertical}
- verticalConfig={{
- filtersOpen: dashboardFiltersOpen,
- toggleFiltersBar: toggleDashboardFiltersOpen,
- width: filterBarWidth,
- height: filterBarHeight,
- offset: filterBarOffset,
- }}
- />
+ <div
+ data-test="dashboard-filter-bar-gate"
+ aria-disabled={isVersionPreviewActive}
+ css={css`
+ height: 100%;
+ ${
+ isVersionPreviewActive
+ ? `
+ pointer-events: none;
+ opacity: 0.5;
+ `
+ : ''
+ }
+ `}
+ // inert blocks keyboard focus too; React 18 needs the spread form
+ {...(isVersionPreviewActive ? { inert: '' } : {})}
+ >
+ <FilterBar
+ orientation={FilterBarOrientation.Vertical}
+ verticalConfig={{
+ filtersOpen: dashboardFiltersOpen,
+ toggleFiltersBar: toggleDashboardFiltersOpen,
+ width: filterBarWidth,
+ height: filterBarHeight,
+ offset: filterBarOffset,
+ }}
+ />
+ </div>
</ErrorBoundary>
</StickyPanel>
</FiltersPanel>
@@ -624,6 +751,7 @@ const DashboardBuilder = () => {
filterBarHeight,
filterBarOffset,
hideFilterBar,
+ isVersionPreviewActive,
],
);
@@ -670,6 +798,14 @@ const DashboardBuilder = () => {
</Droppable>
</StyledHeader>
<StyledContent fullSizeChartId={fullSizeChartId}>
+ {isFeatureEnabled(FeatureFlag.VersionHistory) && (
+ <Suspense fallback={null}>
+ <PreviewBanner
+ entityType="dashboard"
+ canRestore={canRestoreVersion}
+ />
+ </Suspense>
+ )}
{!editMode &&
!topLevelTabs &&
dashboardLayout[DASHBOARD_GRID_ID]?.children?.length === 0 && (
@@ -677,12 +813,12 @@ const DashboardBuilder = () => {
title={t('There are no charts added to this dashboard')}
size="large"
description={
- canEdit &&
+ canEnterEditMode &&
t(
'Go to the edit mode to configure the dashboard and add charts',
)
}
- buttonText={canEdit && t('Edit the dashboard')}
+ buttonText={canEnterEditMode && t('Edit the dashboard')}
buttonAction={() => {
dispatch(setEditMode(true));
dispatch(clearDashboardHistory());
@@ -698,6 +834,44 @@ const DashboardBuilder = () => {
className="dashboard-content"
editMode={editMode}
marginLeft={dashboardContentMarginLeft}
+ data-test="dashboard-grid-gate"
+ aria-disabled={isVersionPreviewActive}
+ previewGated={isVersionPreviewActive}
+ onKeyDownCapture={event => {
+ if (!isVersionPreviewActive) {
+ return;
+ }
+ // pointer-events: none leaves the subtree tab-focusable, so
+ // focus-navigation keys must pass through — swallowing Tab
+ // would trap keyboard focus inside the gated grid
+ // (WCAG 2.1.2). Activation/typing keys stay blocked outside
+ // the tab navs, which remain interactive during preview.
+ if (event.key === 'Tab' || event.key === 'Escape') {
+ return;
+ }
+ // Scrolling is not interaction. A mouse user can still scroll a
+ // gated preview with the wheel, so blocking the keyboard
+ // equivalent would leave a long dashboard unreadable by
+ // keyboard alone (WCAG 2.1.1).
+ if (SCROLL_KEYS.has(event.key)) {
+ return;
+ }
+ // Space scrolls too, but only where it would not also press
+ // something: the gate exists to stop activation, and
+ // pointer-events does not cover the keyboard.
+ if (
+ event.key === ' ' &&
+ !(event.target as HTMLElement).closest(
+ 'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])',
+ )
+ ) {
+ return;
+ }
+ if (!(event.target as HTMLElement).closest('.ant-tabs-nav')) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }}
>
{showDashboard ? (
missingInitialFilters.length > 0 ? (
@@ -733,6 +907,13 @@ const DashboardBuilder = () => {
</StyledDashboardContent>
</DashboardContentWrapper>
</StyledContent>
+ {isFeatureEnabled(FeatureFlag.VersionHistory) && (
+ <VersionHistoryColumn>
+ <Suspense fallback={null}>
+ <DashboardVersionHistory />
+ </Suspense>
+ </VersionHistoryColumn>
+ )}
{dashboardIsSaving && (
<Loading
css={css`
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx
index 8fd07069aff..f94490c9b15 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx
@@ -31,7 +31,7 @@ const StyledDiv = styled.div`
background-color: ${theme.colorBgLayout};
position: relative;
display: grid;
- grid-template-columns: auto 1fr;
+ grid-template-columns: auto 1fr auto;
grid-template-rows: auto 1fr;
flex: 1;
/* Special cases */
diff --git a/superset-frontend/src/dashboard/components/Header/index.tsx b/superset-frontend/src/dashboard/components/Header/index.tsx
index 283ff04a112..c0ad92a2e69 100644
--- a/superset-frontend/src/dashboard/components/Header/index.tsx
+++ b/superset-frontend/src/dashboard/components/Header/index.tsx
@@ -60,6 +60,7 @@ import {
} from 'src/features/reports/ReportModal/actions';
import { PageHeaderWithActions } from '@superset-ui/core/components/PageHeaderWithActions';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
+import { selectIsDashboardVersionPreviewActive } from 'src/features/versionHistory/reducer';
import DashboardEmbedModal from '../EmbeddedModal';
import OverwriteConfirm from '../OverwriteConfirm';
import {
@@ -542,10 +543,21 @@ const Header = (): JSX.Element => {
const metadataBar = useDashboardMetadataBar(dashboardInfo);
+ const isVersionPreviewActive = useSelector(
+ selectIsDashboardVersionPreviewActive,
+ );
const userCanEdit =
- dashboardInfo.dash_edit_perm && !dashboardInfo.is_managed_externally;
- const userCanShare = !!dashboardInfo.dash_share_perm;
- const userCanSaveAs = !!dashboardInfo.dash_save_perm;
+ dashboardInfo.dash_edit_perm &&
+ !dashboardInfo.is_managed_externally &&
+ !isVersionPreviewActive;
+ const userCanShare =
+ !!dashboardInfo.dash_share_perm && !isVersionPreviewActive;
+ // Gated on preview like userCanEdit above: the Save/Discard toolbar acts on
+ // whatever is currently hydrated, and during a preview that is the
+ // snapshot's layout -- so leaving this ungated is a route to writing a
+ // historical version over the live dashboard.
+ const userCanSaveAs =
+ !!dashboardInfo.dash_save_perm && !isVersionPreviewActive;
const userCanCurate =
isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
findPermission('can_set_embedded', 'Dashboard', user.roles);
@@ -840,6 +852,7 @@ const Header = (): JSX.Element => {
menuDropdownProps={{
open: isDropdownVisible,
onOpenChange: setIsDropdownVisible,
+ disabled: isVersionPreviewActive,
}}
additionalActionsMenu={menu}
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
index 8a1c09249f2..6aa8ab247ab 100644
--- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -18,8 +18,9 @@
*/
import type { Dispatch, ReactElement, SetStateAction } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
+import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { t } from '@apache-superset/core/translation';
import { isEmpty } from 'lodash-es';
@@ -37,6 +38,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
+import { openVersionHistoryPanel } from 'src/features/versionHistory/reducer';
export const useHeaderActionsMenu = ({
customCss,
@@ -73,6 +75,7 @@ export const useHeaderActionsMenu = ({
Dispatch<SetStateAction<boolean>>,
] => {
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
+ const dispatch = useDispatch();
const { canExportImage } = usePermissions();
const history = useHistory();
const location = useLocation();
@@ -117,6 +120,9 @@ export const useHeaderActionsMenu = ({
case MenuKeys.ManageEmbedded:
manageEmbedded();
break;
+ case MenuKeys.VersionHistory:
+ dispatch(openVersionHistoryPanel('dashboard'));
+ break;
default:
break;
}
@@ -128,6 +134,7 @@ export const useHeaderActionsMenu = ({
showPropertiesModal,
showRefreshModal,
manageEmbedded,
+ dispatch,
history,
location,
],
@@ -317,6 +324,17 @@ export const useHeaderActionsMenu = ({
);
}
+ if (
+ isFeatureEnabled(FeatureFlag.VersionHistory) &&
+ userCanEdit &&
+ !editMode
+ ) {
+ menuItems.push({
+ key: MenuKeys.VersionHistory,
+ label: t('View version history'),
+ });
+ }
+
return (
<Menu
selectable={false}
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
index 04d6bb537e4..403f5145d1f 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
@@ -46,6 +46,7 @@ import {
Droppable,
} from 'src/dashboard/components/dnd/DragDroppable';
import { TAB_TYPE } from 'src/dashboard/util/componentTypes';
+import { selectIsDashboardVersionPreviewActive } from 'src/features/versionHistory/reducer';
import type { LayoutItem, RootState } from 'src/dashboard/types';
import type {
DropResult,
@@ -155,6 +156,14 @@ const Tab = (props: TabProps): ReactElement => {
const canEdit = useSelector(
(state: RootState) => state.dashboardInfo.dash_edit_perm,
);
+ // The empty-state call to action offers a route into edit mode; during a
+ // version preview the grid gate blocks its activation, but it would still
+ // render as a live-looking link that silently does nothing. Withhold the
+ // affordance instead, as DashboardBuilder's empty state does.
+ const isVersionPreviewActive = useSelector(
+ selectIsDashboardVersionPreviewActive,
+ );
+ const canEnterEditMode = canEdit && !isVersionPreviewActive;
const dashboardLayout = useSelector(
(state: RootState) => state.dashboardLayout.present,
);
@@ -329,7 +338,7 @@ const Tab = (props: TabProps): ReactElement => {
: t('There are no components added to this tab')
}
description={
- canEdit &&
+ canEnterEditMode &&
(editMode ? (
<span>
{t('You can')}{' '}
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 06310d0fe77..084e557bbf8 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -123,6 +123,7 @@ export type DashboardState = {
isFiltersRefreshing: boolean;
hasUnsavedChanges: boolean;
dashboardIsSaving: boolean;
+ lastModifiedTime?: number;
colorScheme: string;
sliceIds: number[];
directPathLastUpdated: number;
@@ -169,6 +170,7 @@ export type DashboardState = {
};
export type DashboardInfo = {
id: number;
+ uuid?: string;
common: {
conf: JsonObject;
};
@@ -403,4 +405,5 @@ export enum MenuKeys {
ManageEmailReports = 'manage_email_reports',
ExportPivotXlsx = 'export_pivot_xlsx',
EmbedCode = 'embed_code',
+ VersionHistory = 'version_history',
}
diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts
index f1c2dda2818..5dc9098a554 100644
--- a/superset-frontend/src/explore/actions/exploreActions.ts
+++ b/superset-frontend/src/explore/actions/exploreActions.ts
@@ -98,8 +98,23 @@ export function setControlValue(
controlName: string,
value: any,
validationErrors?: any[],
+ options?: {
+ /**
+ * Marks a dispatch that no user gesture produced — effects rewriting
+ * transferred controls, derived values set alongside another control,
+ * and similar. The version-history session log skips these so an
+ * untouched chart never reports unsaved changes the user didn't make.
+ */
+ programmatic?: boolean;
+ },
) {
- return { type: SET_FIELD_VALUE, controlName, value, validationErrors };
+ return {
+ type: SET_FIELD_VALUE,
+ controlName,
+ value,
+ validationErrors,
+ programmatic: options?.programmatic ?? false,
+ };
}
export const SET_EXPLORE_CONTROLS = 'UPDATE_EXPLORE_CONTROLS';
diff --git a/superset-frontend/src/explore/components/Control.tsx b/superset-frontend/src/explore/components/Control.tsx
index eeb2be1ee91..4046da337e0 100644
--- a/superset-frontend/src/explore/components/Control.tsx
+++ b/superset-frontend/src/explore/components/Control.tsx
@@ -84,8 +84,12 @@ export default function Control(props: ControlProps) {
!isEqual(props.value, props.default) &&
resetOnHide
) {
- // reset control value if setting to invisible
- setControlValue?.(name, props.default);
+ // reset control value if setting to invisible. Programmatic: the
+ // user's gesture was whatever hid this control, not an edit of it —
+ // the version-history session log must not attribute it to them.
+ setControlValue?.(name, props.default, undefined, {
+ programmatic: true,
+ });
}
}, [
name,
diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
index df15a4a1ac1..22618cd821d 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
@@ -403,7 +403,14 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
props.controls[controlName].value,
);
if (shouldUpdateControls) {
- props.actions.setControlValue(controlName, alteredControls);
+ props.actions.setControlValue(
+ controlName,
+ alteredControls,
+ undefined,
+ {
+ programmatic: true,
+ },
+ );
}
});
}
diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
index 7f3c734d255..4cd3a4ceab1 100644
--- a/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
@@ -19,7 +19,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 { useDispatch, useSelector } from 'react-redux';
import {
Tooltip,
Button,
@@ -58,6 +58,7 @@ import { Slice } from 'src/types/Chart';
import { ReportObject } from 'src/features/reports/types';
import { User } from 'src/types/bootstrapTypes';
import getBootstrapData from 'src/utils/getBootstrapData';
+import { selectIsChartVersionPreviewActive } from 'src/features/versionHistory/reducer';
import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
import { useExploreMetadataBar } from './useExploreMetadataBar';
@@ -125,6 +126,7 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
isSaveModalVisible,
}) => {
const dispatch = useDispatch();
+ const isVersionPreviewActive = useSelector(selectIsChartVersionPreviewActive);
const { latestQueryFormData, sliceFormData } = chart;
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
@@ -298,14 +300,22 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
() => ({
title: sliceName ?? '',
canEdit:
- !slice ||
- canOverwrite ||
- Boolean(slice?.editors?.some(editor => userSubjects.has(editor))),
+ !isVersionPreviewActive &&
+ (!slice ||
+ canOverwrite ||
+ Boolean(slice?.editors?.some(editor => userSubjects.has(editor)))),
onSave: actions.updateChartTitle,
placeholder: t('Add the name of the chart'),
label: t('Chart title'),
}),
- [actions.updateChartTitle, canOverwrite, slice, sliceName, userSubjects],
+ [
+ actions.updateChartTitle,
+ canOverwrite,
+ isVersionPreviewActive,
+ slice,
+ sliceName,
+ userSubjects,
+ ],
);
const certificatiedBadgeProps = useMemo(
@@ -366,7 +376,7 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
<Button
buttonStyle="secondary"
onClick={showModal}
- disabled={saveDisabled}
+ disabled={saveDisabled || isVersionPreviewActive}
data-test="query-save-button"
css={saveButtonStyles}
icon={<Icons.SaveOutlined />}
@@ -376,15 +386,16 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
</div>
</Tooltip>
),
- [saveDisabled, showModal],
+ [isVersionPreviewActive, saveDisabled, showModal],
);
const menuDropdownProps = useMemo(
() => ({
open: isDropdownVisible,
onOpenChange: setIsDropdownVisible,
+ disabled: isVersionPreviewActive,
}),
- [isDropdownVisible, setIsDropdownVisible],
+ [isDropdownVisible, isVersionPreviewActive, setIsDropdownVisible],
);
return (
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
index 65378889e34..f32f3b2ee5b 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
@@ -842,6 +842,8 @@ test('automatic axis title margin adjustment sets X axis margin to 30 when title
expect(setControlValueSpy).toHaveBeenCalledWith(
'x_axis_title_margin',
30,
+ undefined,
+ { programmatic: true },
);
});
} finally {
@@ -887,6 +889,8 @@ test('automatic axis title margin adjustment sets Y axis margin to 30 when title
expect(setControlValueSpy).toHaveBeenCalledWith(
'y_axis_title_margin',
30,
+ undefined,
+ { programmatic: true },
);
});
} finally {
@@ -927,7 +931,12 @@ test('automatic axis title margin adjustment resets X axis margin to 0 when titl
store.dispatch(exploreActions.setControlValue('x_axis_title', ''));
await waitFor(() => {
- expect(setControlValueSpy).toHaveBeenCalledWith('x_axis_title_margin', 0);
+ expect(setControlValueSpy).toHaveBeenCalledWith(
+ 'x_axis_title_margin',
+ 0,
+ undefined,
+ { programmatic: true },
+ );
});
} finally {
getChartControlPanelRegistry().remove('table');
@@ -967,7 +976,12 @@ test('automatic axis title margin adjustment resets Y axis margin to 0 when titl
store.dispatch(exploreActions.setControlValue('y_axis_title', ''));
await waitFor(() => {
- expect(setControlValueSpy).toHaveBeenCalledWith('y_axis_title_margin', 0);
+ expect(setControlValueSpy).toHaveBeenCalledWith(
+ 'y_axis_title_margin',
+ 0,
+ undefined,
+ { programmatic: true },
+ );
});
} finally {
getChartControlPanelRegistry().remove('table');
@@ -1067,7 +1081,12 @@ test('automatic axis title margin adjustment changes X axis margin when title is
});
// Should call setControlValue since margin is less than 30
- expect(setControlValueSpy).toHaveBeenCalledWith('x_axis_title_margin', 30);
+ expect(setControlValueSpy).toHaveBeenCalledWith(
+ 'x_axis_title_margin',
+ 30,
+ undefined,
+ { programmatic: true },
+ );
} finally {
getChartControlPanelRegistry().remove('table');
jest.restoreAllMocks();
@@ -1166,7 +1185,12 @@ test('automatic axis title margin adjustment changes Y axis margin when title is
});
// Should call setControlValue since margin is less than 30
- expect(setControlValueSpy).toHaveBeenCalledWith('y_axis_title_margin', 30);
+ expect(setControlValueSpy).toHaveBeenCalledWith(
+ 'y_axis_title_margin',
+ 30,
+ undefined,
+ { programmatic: true },
+ );
} finally {
getChartControlPanelRegistry().remove('table');
jest.restoreAllMocks();
@@ -1215,10 +1239,14 @@ test('automatic axis title margin adjustment handles both X and Y axis titles be
expect(setControlValueSpy).toHaveBeenCalledWith(
'x_axis_title_margin',
30,
+ undefined,
+ { programmatic: true },
);
expect(setControlValueSpy).toHaveBeenCalledWith(
'y_axis_title_margin',
30,
+ undefined,
+ { programmatic: true },
);
});
} finally {
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index fa52e944e58..ce1a2d77fae 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -19,6 +19,8 @@
/* eslint camelcase: 0 */
import {
ComponentType,
+ Suspense,
+ lazy,
memo,
useCallback,
useEffect,
@@ -33,6 +35,8 @@ import {
useComponentDidMount,
usePrevious,
isMatrixifyEnabled,
+ isFeatureEnabled,
+ FeatureFlag,
QueryFormData,
JsonObject,
MatrixifyFormData,
@@ -96,6 +100,7 @@ import {
} from 'src/explore/types';
import { Slice } from 'src/types/Chart';
import { User } from 'src/types/bootstrapTypes';
+import { selectIsChartVersionPreviewActive } from 'src/features/versionHistory/reducer';
import ExploreChartPanel from '../ExploreChartPanel';
import ConnectedControlPanelsContainer from '../ControlPanelsContainer';
import SaveModal from '../SaveModal';
@@ -103,6 +108,15 @@ import DataSourcePanel from '../DatasourcePanel';
import ConnectedExploreChartHeader from '../ExploreChartHeader';
import ExploreContainer from '../ExploreContainer';
+// Lazy-loaded so deployments with the VersionHistory flag off never pay
+// the bundle cost of the feature's component graph.
+const ExploreVersionHistory = lazy(
+ () => import('src/features/versionHistory/ExploreVersionHistory'),
+);
+const ChartVersionPreview = lazy(
+ () => import('src/features/versionHistory/ChartVersionPreview'),
+);
+
const ExplorePanelContainer = styled.div`
${({ theme }) => css`
text-align: left;
@@ -414,6 +428,9 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
);
const tabId = useTabId();
const history = useHistory();
+ const isChartVersionPreviewActive = useSelector(
+ selectIsChartVersionPreviewActive,
+ );
const theme = useTheme();
@@ -521,6 +538,9 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
);
const onQuery = useCallback(() => {
+ if (isChartVersionPreviewActive) {
+ return;
+ }
props.actions.setForceQuery(false);
// Skip main query if Matrixify is enabled
@@ -545,6 +565,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
props.actions,
props.chart.id,
props.form_data,
+ isChartVersionPreviewActive,
]);
const handleKeydown = useCallback(
@@ -748,7 +769,12 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
(currentTemplate ? ' ' : '') +
newVariables.join(' ');
- props.actions.setControlValue('tooltip_template', updatedTemplate);
+ props.actions.setControlValue(
+ 'tooltip_template',
+ updatedTemplate,
+ undefined,
+ { programmatic: true },
+ );
}
}
}
@@ -761,9 +787,13 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
);
if (xAxisTitle && currentMargin < 30) {
- props.actions.setControlValue('x_axis_title_margin', 30);
+ props.actions.setControlValue('x_axis_title_margin', 30, undefined, {
+ programmatic: true,
+ });
} else if (!xAxisTitle && currentMargin !== 0) {
- props.actions.setControlValue('x_axis_title_margin', 0);
+ props.actions.setControlValue('x_axis_title_margin', 0, undefined, {
+ programmatic: true,
+ });
}
}
@@ -774,9 +804,13 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
);
if (yAxisTitle && currentMargin < 30) {
- props.actions.setControlValue('y_axis_title_margin', 30);
+ props.actions.setControlValue('y_axis_title_margin', 30, undefined, {
+ programmatic: true,
+ });
} else if (!yAxisTitle && currentMargin !== 0) {
- props.actions.setControlValue('y_axis_title_margin', 0);
+ props.actions.setControlValue('y_axis_title_margin', 0, undefined, {
+ programmatic: true,
+ });
}
}
@@ -1072,15 +1106,32 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
onClick={toggleCollapse}
/>
</div>
- {/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */}
- <DataSourcePanel
- formData={props.form_data}
- datasource={props.datasource as any}
- controls={props.controls as any}
- actions={props.actions as any}
- width={width}
- />
- {/* eslint-enable @typescript-eslint/no-explicit-any */}
+ <div
+ data-test="explore-datasource-gate"
+ aria-disabled={isChartVersionPreviewActive}
+ css={css`
+ height: 100%;
+ ${
+ isChartVersionPreviewActive
+ ? `
+ pointer-events: none;
+ opacity: 0.5;
+ `
+ : ''
+ }
+ `}
+ {...(isChartVersionPreviewActive ? { inert: '' } : {})}
+ >
+ {/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */}
+ <DataSourcePanel
+ formData={props.form_data}
+ datasource={props.datasource as any}
+ controls={props.controls as any}
+ actions={props.actions as any}
+ width={width}
+ />
+ {/* eslint-enable @typescript-eslint/no-explicit-any */}
+ </div>
</Resizable>
{isCollapsed ? (
<button
@@ -1125,22 +1176,40 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
enable={{ right: true }}
className="col-sm-3 explore-column controls-column"
>
- <ConnectedControlPanelsContainer
- exploreState={props.exploreState}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
- actions={props.actions as any}
- form_data={props.form_data}
- controls={props.controls}
- chart={props.chart}
- datasource_type={props.datasource_type}
- isDatasourceMetaLoading={props.isDatasourceMetaLoading}
- onQuery={onQuery}
- onStop={onStop}
- canStopQuery={props.can_add || props.can_overwrite}
- errorMessage={dataTabErrorMessage}
- buttonErrorMessage={errorMessage}
- chartIsStale={chartIsStale}
- />
+ <div
+ data-test="explore-controls-gate"
+ aria-disabled={isChartVersionPreviewActive}
+ css={css`
+ height: 100%;
+ ${
+ isChartVersionPreviewActive
+ ? `
+ pointer-events: none;
+ opacity: 0.5;
+ `
+ : ''
+ }
+ `}
+ // inert blocks keyboard focus too; React 18 needs the spread form
+ {...(isChartVersionPreviewActive ? { inert: '' } : {})}
+ >
+ <ConnectedControlPanelsContainer
+ exploreState={props.exploreState}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
+ actions={props.actions as any}
+ form_data={props.form_data}
+ controls={props.controls}
+ chart={props.chart}
+ datasource_type={props.datasource_type}
+ isDatasourceMetaLoading={props.isDatasourceMetaLoading}
+ onQuery={onQuery}
+ onStop={onStop}
+ canStopQuery={props.can_add || props.can_overwrite}
+ errorMessage={dataTabErrorMessage}
+ buttonErrorMessage={errorMessage}
+ chartIsStale={chartIsStale}
+ />
+ </div>
</Resizable>
<div
className={cx(
@@ -1148,8 +1217,19 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
isCollapsed ? 'col-sm-9' : 'col-sm-7',
)}
>
- {renderChartContainer()}
+ {isChartVersionPreviewActive ? (
+ <Suspense fallback={null}>
+ <ChartVersionPreview />
+ </Suspense>
+ ) : (
+ renderChartContainer()
+ )}
</div>
+ {isFeatureEnabled(FeatureFlag.VersionHistory) && (
+ <Suspense fallback={null}>
+ <ExploreVersionHistory />
+ </Suspense>
+ )}
</ExplorePanelContainer>
{props.isSaveModalVisible && (
<SaveModal
diff --git a/superset-frontend/src/explore/components/SaveModal.test.tsx b/superset-frontend/src/explore/components/SaveModal.test.tsx
index 20c3c7977dd..74f1b4da840 100644
--- a/superset-frontend/src/explore/components/SaveModal.test.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.test.tsx
@@ -358,10 +358,17 @@ test('enables overwrite option for admin non-editor', () => {
expect(getByRole('radio', { name: 'Save (Overwrite)' })).toBeEnabled();
});
-test('enables overwrite option for owner when owners are objects', () => {
- // The Slice type declares `owners` as `number[]`, but the explore
- // bootstrap can deliver owners as `{ id: number }` objects at runtime.
- // A chart owner must still be able to overwrite their own chart in that case.
+test('disables overwrite for an owner who is not an editor', () => {
+ // Ownership alone does not confer overwrite. UpdateChartCommand gates on
+ // raise_for_editorship, and is_editor resolves to admin or the user's
+ // subjects intersected with editors/extra_editors -- owners are not
+ // consulted -- so an owner who is not an editor is answered 403 by the API.
+ // The modal previously enabled Save (Overwrite) for them anyway and only
+ // surfaced the refusal after the request.
+ //
+ // editors is cleared explicitly: the shared fixture grants editorship to
+ // subject 1, which would satisfy the gate by a different route and leave
+ // this assertion passing whatever the owner handling did.
const { getByRole } = setup(
{},
mockStore({
@@ -370,6 +377,26 @@ test('enables overwrite option for owner when owners are objects', () => {
...initialState.explore,
slice: {
...initialState.explore.slice,
+ editors: [],
+ owners: [{ id: 1 }],
+ },
+ },
+ user: { userId: 1 },
+ }),
+ );
+ expect(getByRole('radio', { name: 'Save (Overwrite)' })).toBeDisabled();
+});
+
+test('enables overwrite for an owner who is also an editor', () => {
+ const { getByRole } = setup(
+ {},
+ mockStore({
+ ...initialState,
+ explore: {
+ ...initialState.explore,
+ slice: {
+ ...initialState.explore.slice,
+ editors: [{ id: 1 }],
owners: [{ id: 1 }],
},
},
diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx
index 1d0bf9e2740..aaa80a4472c 100755
--- a/superset-frontend/src/explore/components/SaveModal.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.tsx
@@ -52,10 +52,7 @@ import { css, styled, useTheme } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import { Radio } from '@superset-ui/core/components/Radio';
import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
-import {
- canUserEditDashboard,
- isUserAdmin,
-} from 'src/dashboard/util/permissionUtils';
+import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils';
import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions';
import {
SaveActionType,
@@ -68,7 +65,7 @@ import {
updateChartState,
} from 'src/dashboard/actions/dashboardState';
import { Dashboard } from 'src/types/Dashboard';
-import getBootstrapData from 'src/utils/getBootstrapData';
+import { canOverwriteSlice as canOverwriteSliceFor } from 'src/explore/exploreUtils/canOverwriteSlice';
import { TabNode, TabTreeNode } from '../types';
import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants';
@@ -228,38 +225,11 @@ const SaveModal = ({
const history = useHistory();
const theme = useTheme();
- const isCurrentUserOwner = useCallback((): boolean => {
- const userId = user?.userId;
- if (userId === undefined) {
- return false;
- }
- // The Slice type declares `owners` as `number[]`, but the explore
- // bootstrap payload can carry owners as API `Owner` objects (`{ id }`)
- // instead, so normalize to the numeric id before comparing.
- return Boolean(
- slice?.owners?.some((owner: number | { id?: number }) =>
- typeof owner === 'number' ? owner === userId : owner?.id === userId,
- ),
- );
- }, [slice, user]);
-
- const canOverwriteSlice = useCallback((): boolean => {
- const userSubjects = getBootstrapData()?.common?.user_subjects ?? [];
- const canEditSlice = Boolean(
- slice?.editors?.some((editor: { id: number } | number) =>
- userSubjects.includes(typeof editor === 'number' ? editor : editor.id),
- ),
- );
-
- return (
- !!slice &&
- (can_overwrite ||
- isUserAdmin(user) ||
- canEditSlice ||
- isCurrentUserOwner()) &&
- !slice?.is_managed_externally
- );
- }, [can_overwrite, slice, user, isCurrentUserOwner]);
+ const canOverwriteSlice = useCallback(
+ (): boolean =>
+ canOverwriteSliceFor({ slice, user, canOverwrite: can_overwrite }),
+ [can_overwrite, slice, user],
+ );
const [newSliceName, setNewSliceName] = useState<string | undefined>(
sliceName,
diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx
index 880f97af682..dffdd8e62f9 100644
--- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx
@@ -111,7 +111,12 @@ function AnnotationLayerControl({
(Object.keys(annotationError).length && !validationErrors.length) ||
(!Object.keys(annotationError).length && validationErrors.length)
) {
- actions.setControlValue(name, value, Object.keys(annotationError));
+ actions.setControlValue(name, value, Object.keys(annotationError), {
+ // Re-publishes the same `value` with fresh validation errors after an
+ // annotation query resolves; no user gesture produced it, so it must
+ // not report an unsaved edit on an untouched chart.
+ programmatic: true,
+ });
}
}, [annotationError, validationErrors, value, actions, name]);
diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
index 41335f43d7b..98debbb906e 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
@@ -462,6 +462,8 @@ test('should set the default temporal column', async () => {
expect(props.actions.setControlValue).toHaveBeenCalledWith(
'granularity_sqla',
'test-default',
+ undefined,
+ { programmatic: true },
);
});
});
@@ -498,6 +500,8 @@ test('should set the first available temporal column', async () => {
expect(props.actions.setControlValue).toHaveBeenCalledWith(
'granularity_sqla',
'test-first',
+ undefined,
+ { programmatic: true },
);
});
});
@@ -534,10 +538,54 @@ test('should not set the temporal column', async () => {
expect(props.actions.setControlValue).toHaveBeenCalledWith(
'granularity_sqla',
null,
+ undefined,
+ { programmatic: true },
);
});
});
+test('editing a dataset still emits a dirty signal for the restore gate', async () => {
+ // The derived granularity_sqla write is programmatic, so it is invisible to
+ // the version-history session log. On the *swap* route that is fine —
+ // ChangeDatasourceModal's own onChange emits a recorded control change. The
+ // Edit Dataset route has no such change, so the reconciliation dispatched by
+ // changeDatasource is the only thing standing between an edited chart and a
+ // restore that silently discards the reconciled value.
+ const props = createProps();
+ const overrideProps = {
+ ...props,
+ form_data: { granularity_sqla: 'test-col' },
+ datasource: {
+ ...props.datasource,
+ main_dttm_col: 'test-default',
+ columns: [
+ { column_name: 'test-col', is_dttm: false },
+ { column_name: 'test-default', is_dttm: true },
+ ],
+ },
+ };
+ render(<DatasourceControl {...props} {...overrideProps} />, {
+ useRedux: true,
+ useRouter: true,
+ });
+
+ await openAndSaveChanges(overrideProps.datasource);
+
+ await waitFor(() => {
+ expect(props.actions.setControlValue).toHaveBeenCalledWith(
+ 'granularity_sqla',
+ 'test-default',
+ undefined,
+ { programmatic: true },
+ );
+ });
+ // changeDatasource dispatches UPDATE_FORM_DATA_BY_DATASOURCE (pinned in
+ // datasourcesActions.test.ts), which the session-log middleware records.
+ expect(props.actions.changeDatasource).toHaveBeenCalledWith(
+ expect.objectContaining({ id: overrideProps.datasource.id }),
+ );
+});
+
test('should show missing params state', () => {
const props = createProps({ datasource: fallbackExploreInitialData.dataset });
render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
index 1f0a75f5a87..bd0468134f0 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
@@ -85,7 +85,12 @@ interface User {
interface DatasourceControlActions {
changeDatasource: (datasource: ExtendedDatasource) => void;
- setControlValue: (name: string, value: unknown) => void;
+ setControlValue: (
+ name: string,
+ value: unknown,
+ validationErrors?: unknown[],
+ options?: { programmatic?: boolean },
+ ) => void;
}
interface FormData {
@@ -262,7 +267,15 @@ export default function DatasourceControl({
const temporalColumn = isDefaultTemporal
? defaultTemporalColumn
: temporalColumns?.[0];
- actions.setControlValue('granularity_sqla', temporalColumn || null);
+ actions.setControlValue(
+ 'granularity_sqla',
+ temporalColumn || null,
+ undefined,
+ {
+ // Derived alongside the datasource change, not a gesture of its own.
+ programmatic: true,
+ },
+ );
}
if (onDatasourceSave) {
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index a40bc890917..b182d0bfeaa 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -54,6 +54,9 @@ import copyTextToClipboard from 'src/utils/copy';
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
import { logEvent } from 'src/logger/actions';
+import { openVersionHistoryPanel } from 'src/features/versionHistory/reducer';
+import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { canOverwriteSlice } from 'src/explore/exploreUtils/canOverwriteSlice';
import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG,
@@ -109,6 +112,7 @@ const MENU_KEYS = {
DELETE_REPORT: 'delete_report',
VIEW_QUERY: 'view_query',
RUN_IN_SQL_LAB: 'run_in_sql_lab',
+ VERSION_HISTORY: 'version_history',
};
const VIZ_TYPES_PIVOTABLE = [VizType.PivotTable];
@@ -274,12 +278,14 @@ interface ExploreState {
explore?: ExploreSlice & {
chartStates?: Record<number, JsonObject>;
can_export_image?: boolean;
+ can_overwrite?: boolean;
};
common?: {
conf?: {
CSV_STREAMING_ROW_THRESHOLD?: number;
};
};
+ user?: UserWithPermissionsAndRoles;
}
export type UseExploreAdditionalActionsMenuReturn = [
@@ -326,6 +332,20 @@ export const useExploreAdditionalActionsMenu = (
const canExportImage = useSelector<ExploreState, boolean>(
state => state.explore?.can_export_image ?? false,
);
+ const canOverwrite = useSelector<ExploreState, boolean>(
+ state => state.explore?.can_overwrite ?? false,
+ );
+ const user = useSelector<
+ ExploreState,
+ UserWithPermissionsAndRoles | undefined
+ >(state => state.user);
+ // `can_overwrite` alone hides version history on any chart without explicit
+ // editors — every seeded chart — even from admins. Same predicate SaveModal
+ // uses, so a user who can save a chart can also see its history.
+ const canModifySlice = useMemo(
+ () => canOverwriteSlice({ slice, user, canOverwrite }),
+ [slice, user, canOverwrite],
+ );
const dataExportDisabled = !canDownloadCSV;
const imageExportDisabled = !canExportImage;
@@ -1007,6 +1027,21 @@ export const useExploreAdditionalActionsMenu = (
menuItems.push(reportMenuItem);
}
+ if (
+ isFeatureEnabled(FeatureFlag.VersionHistory) &&
+ canModifySlice &&
+ slice?.slice_id
+ ) {
+ menuItems.push({
+ key: MENU_KEYS.VERSION_HISTORY,
+ label: t('View version history'),
+ onClick: () => {
+ dispatch(openVersionHistoryPanel('chart'));
+ setIsDropdownVisible(false);
+ },
+ });
+ }
+
// View query
menuItems.push({
key: MENU_KEYS.VIEW_QUERY,
@@ -1049,6 +1084,7 @@ export const useExploreAdditionalActionsMenu = (
}, [
addDangerToast,
canDownloadCSV,
+ canModifySlice,
copyLink,
dashboards,
dashboardMenuItems,
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
index d4d57c7a043..34f0efd2c7f 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import { ComponentType } from 'react';
+import { FeatureFlag } from '@superset-ui/core';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import downloadAsImage from 'src/utils/downloadAsImage';
@@ -249,3 +250,70 @@ test('getExportScreenshotMenuItems PDF option calls downloadAsPdf and dispatches
expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
expect(dispatch).toHaveBeenCalledTimes(1);
});
+
+/**
+ * The version-history gate, exercised against the state shape hydrateExplore
+ * actually produces rather than a mocked can_overwrite. Every seeded chart
+ * ships with an empty editors list, so a membership-only gate hid the action
+ * from everyone on a fresh install.
+ */
+const renderMenuFor = (
+ slice: Record<string, unknown>,
+ user: Record<string, unknown>,
+) =>
+ render(<TestComponent {...defaultProps} slice={slice as never} />, {
+ useRedux: true,
+ initialState: {
+ user,
+ explore: { can_overwrite: false, slice },
+ },
+ });
+
+const adminUser = {
+ userId: 1,
+ username: 'admin',
+ permissions: {},
+ roles: { Admin: [] },
+};
+
+const gammaUser = {
+ userId: 2,
+ username: 'gamma',
+ permissions: {},
+ roles: { Gamma: [] },
+};
+
+test('an admin sees version history on a chart that has no editors', async () => {
+ window.featureFlags = { [FeatureFlag.VersionHistory]: true };
+
+ renderMenuFor(
+ { slice_id: 1, slice_name: 'Test Chart', editors: [] },
+ adminUser,
+ );
+
+ expect(await screen.findByText('View version history')).toBeInTheDocument();
+});
+
+test('a non-editor without the admin role does not', async () => {
+ window.featureFlags = { [FeatureFlag.VersionHistory]: true };
+
+ renderMenuFor(
+ { slice_id: 1, slice_name: 'Test Chart', editors: [] },
+ gammaUser,
+ );
+
+ await screen.findByText('View query');
+ expect(screen.queryByText('View version history')).not.toBeInTheDocument();
+});
+
+test('the item stays hidden while the feature flag is off', async () => {
+ window.featureFlags = { [FeatureFlag.VersionHistory]: false };
+
+ renderMenuFor(
+ { slice_id: 1, slice_name: 'Test Chart', editors: [] },
+ adminUser,
+ );
+
+ await screen.findByText('View query');
+ expect(screen.queryByText('View version history')).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.test.ts b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.test.ts
new file mode 100644
index 00000000000..3eaff8ee3da
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.test.ts
@@ -0,0 +1,109 @@
+/**
+ * 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 { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { canOverwriteSlice } from './canOverwriteSlice';
+
+// The viewer's subject ids: subject 7 is them, 8 is a role or group they hold.
+jest.mock('src/utils/getBootstrapData', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({ common: { user_subjects: [7, 8] } })),
+}));
+
+// isUserAdmin only recognises a fully-shaped bootstrap user: username,
+// permissions and roles must all be present or the type guard rejects it.
+const admin = {
+ userId: 1,
+ username: 'admin',
+ permissions: {},
+ roles: { Admin: [] },
+} as unknown as UserWithPermissionsAndRoles;
+
+const gamma = {
+ userId: 2,
+ username: 'gamma',
+ permissions: {},
+ roles: { Gamma: [] },
+} as unknown as UserWithPermissionsAndRoles;
+
+test('an admin may act on a chart that has no explicit editors', () => {
+ // The regression this guards: every seeded and example chart ships with an
+ // empty editors list, so a membership-only check hid the action from
+ // everyone, admins included, on a fresh install.
+ expect(canOverwriteSlice({ slice: { editors: [] }, user: admin })).toBe(true);
+});
+
+test('a non-editor without the admin role may not', () => {
+ expect(canOverwriteSlice({ slice: { editors: [] }, user: gamma })).toBe(
+ false,
+ );
+});
+
+test('a subject listed among the editors may, whether by id or object', () => {
+ expect(canOverwriteSlice({ slice: { editors: [7] }, user: gamma })).toBe(
+ true,
+ );
+ expect(
+ canOverwriteSlice({ slice: { editors: [{ id: 8 }] }, user: gamma }),
+ ).toBe(true);
+});
+
+test('editorship granted indirectly through extra_editors counts', () => {
+ // The server's is_editor unions extra_editors into the editor set, so the
+ // UI must too or it hides an action the API would allow.
+ expect(
+ canOverwriteSlice({
+ slice: { editors: [], extra_editors: [7] },
+ user: gamma,
+ }),
+ ).toBe(true);
+});
+
+test('a subject that matches neither list may not', () => {
+ expect(
+ canOverwriteSlice({
+ slice: { editors: [99], extra_editors: [98] },
+ user: gamma,
+ }),
+ ).toBe(false);
+});
+
+test('the store value alone is sufficient', () => {
+ expect(
+ canOverwriteSlice({
+ slice: { editors: [] },
+ user: gamma,
+ canOverwrite: true,
+ }),
+ ).toBe(true);
+});
+
+test('an externally managed chart is excluded even from an admin', () => {
+ expect(
+ canOverwriteSlice({
+ slice: { editors: [], is_managed_externally: true },
+ user: admin,
+ canOverwrite: true,
+ }),
+ ).toBe(false);
+});
+
+test('a missing slice is not modifiable', () => {
+ expect(canOverwriteSlice({ slice: undefined, user: admin })).toBe(false);
+ expect(canOverwriteSlice({ slice: null, user: admin })).toBe(false);
+});
diff --git a/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.ts b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.ts
new file mode 100644
index 00000000000..f3088078232
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.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 {
+ isUserAdmin,
+ isUserInSubjects,
+ type SubjectRef,
+} from 'src/dashboard/util/permissionUtils';
+import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+
+interface SliceLike {
+ editors?: SubjectRef[] | null;
+ extra_editors?: SubjectRef[] | null;
+ is_managed_externally?: boolean | null;
+}
+
+/**
+ * Whether *user* may modify *slice*, in the sense that governs both saving
+ * over a chart and acting on its version history.
+ *
+ * `canOverwrite` is the value the explore store holds, which `hydrateExplore`
+ * computes purely as "the current user is among the slice's editors". That is
+ * necessary but not sufficient: a chart with no explicit editors — which
+ * includes every seeded and example chart — yields false for everyone,
+ * administrators included. So the store value is treated as one of several
+ * routes to permission rather than the whole answer.
+ *
+ * Externally managed slices are excluded: their source of truth lives outside
+ * Superset, so overwriting or reverting one would be overwritten again by the
+ * next sync.
+ */
+export function canOverwriteSlice({
+ slice,
+ user,
+ canOverwrite = false,
+}: {
+ slice?: SliceLike | null;
+ user?: UserWithPermissionsAndRoles;
+ canOverwrite?: boolean;
+}): boolean {
+ if (!slice || slice.is_managed_externally) {
+ return false;
+ }
+ if (canOverwrite || isUserAdmin(user)) {
+ return true;
+ }
+
+ // Editors are Subjects — the viewer's user, roles and groups — so compare
+ // against subject ids rather than the user id. `extra_editors` carries
+ // editorship granted indirectly by a deployment's EXTRA_EDITORS_RESOLVER;
+ // the server counts it, so leaving it out here would hide the action from
+ // someone the API would happily let through.
+ return isUserInSubjects(slice.editors, slice.extra_editors);
+}
+
+export default canOverwriteSlice;
diff --git a/superset-frontend/src/features/versionHistory/ActionRow.tsx b/superset-frontend/src/features/versionHistory/ActionRow.tsx
new file mode 100644
index 00000000000..a89468779cc
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ActionRow.tsx
@@ -0,0 +1,246 @@
+/**
+ * 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 { 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 {
+ describeRecord,
+ formatAuthor,
+ formatVersionDateTimeShort,
+} from './display';
+
+const Row = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: stretch;
+ gap: ${theme.sizeUnit * 4}px;
+ cursor: pointer;
+ border-radius: ${theme.borderRadius}px;
+ &:hover {
+ background-color: ${theme.colorBgTextHover};
+ }
+ `}
+`;
+
+const Rail = styled.div`
+ ${({ theme }) => `
+ width: ${theme.sizeUnit * 4}px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: ${theme.sizeUnit * 1.5}px;
+ `}
+`;
+
+const Dot = styled.span<{ isActive: boolean }>`
+ ${({ theme, isActive }) => `
+ width: ${theme.sizeUnit * 2.5}px;
+ height: ${theme.sizeUnit * 2.5}px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ border: 2px solid ${isActive ? theme.colorPrimary : theme.colorBorder};
+ background-color: ${theme.colorBgContainer};
+ `}
+`;
+
+const Connector = styled.span`
+ ${({ theme }) => `
+ flex: 1;
+ width: 1px;
+ margin-top: ${theme.sizeUnit / 2}px;
+ background-color: ${theme.colorSplit};
+ `}
+`;
+
+const Content = styled.div`
+ ${({ theme }) => `
+ flex: 1;
+ min-width: 0;
+ padding-bottom: ${theme.sizeUnit * 5}px;
+ `}
+`;
+
+const Title = styled.div`
+ ${({ theme }) => `
+ font-size: ${theme.fontSize}px;
+ line-height: ${theme.lineHeight};
+ color: ${theme.colorText};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ `}
+`;
+
+const Meta = styled.div`
+ ${({ theme }) => `
+ color: ${theme.colorTextTertiary};
+ font-size: ${theme.fontSizeSM}px;
+ line-height: ${theme.lineHeightSM};
+ `}
+`;
+
+// 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: neutral icon color instead of the link-button blue.
+const KebabButton = styled(Button)`
+ ${({ theme }) => `
+ && {
+ color: ${theme.colorTextTertiary};
+ }
+ &&:hover,
+ &&:focus {
+ color: ${theme.colorText};
+ }
+ `}
+`;
+
+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;
+ isPreviewed: boolean;
+ isLast: boolean;
+ onPreview: () => void;
+ onRestore: () => void;
+ onOpenAsNew: () => void;
+}
+
+export default function ActionRow({
+ entityType,
+ record,
+ showRestore,
+ showActions = true,
+ isPreviewed,
+ 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.
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onPreview();
+ }
+ };
+
+ return (
+ <Row
+ // eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- row contains a nested kebab <button>, which a real <button> cannot
+ role="button"
+ tabIndex={0}
+ onClick={onPreview}
+ onKeyDown={handleKeyDown}
+ data-test="version-history-action-row"
+ >
+ <Rail>
+ <Dot isActive={isPreviewed} />
+ {!isLast && <Connector />}
+ </Rail>
+ <Content>
+ <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/ChartVersionPreview.test.tsx b/superset-frontend/src/features/versionHistory/ChartVersionPreview.test.tsx
new file mode 100644
index 00000000000..c554f05065e
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ChartVersionPreview.test.tsx
@@ -0,0 +1,247 @@
+/**
+ * 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 {
+ createStore,
+ render,
+ screen,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import type { VersionHistoryState } from './types';
+import type { VersionHistoryRootState } from './reducer';
+import { fetchDatasourceMetadata, fetchVersionSnapshot } from './api';
+import ChartVersionPreview from './ChartVersionPreview';
+
+jest.mock('./api', () => ({
+ fetchDatasourceMetadata: jest.fn(),
+ fetchVersionSnapshot: jest.fn(),
+}));
+jest.mock('./PreviewBanner', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('src/components/Chart/chartAction', () => ({
+ getChartDataRequest: jest.fn(),
+ handleChartDataResponse: jest.fn(),
+}));
+jest.mock('src/explore/exploreUtils', () => ({
+ ...jest.requireActual('src/explore/exploreUtils'),
+ getQuerySettings: () => [false],
+}));
+// SuperChart would need a registered viz plugin; the assertions here are
+// about what the effect resolves to, not how it renders.
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ SuperChart: ({ formData }: { formData: { datasource: string } }) => (
+ <div data-test="super-chart">{formData.datasource}</div>
+ ),
+}));
+
+const { handleChartDataResponse } = jest.requireMock(
+ 'src/components/Chart/chartAction',
+);
+
+const previewState = (): VersionHistoryState => ({
+ isPanelOpen: true,
+ entityType: 'chart',
+ include: 'all',
+ // SET_VERSION_PREVIEW always enters the applying state; starting from the
+ // real post-dispatch shape keeps these tests honest about the lifecycle —
+ // the component itself must announce completion.
+ isPreviewApplying: true,
+ preview: {
+ entityUuid: 'chart-uuid',
+ versionUuid: 'version-uuid',
+ transactionId: 1,
+ headline: 'A save',
+ issuedAt: '2026-07-01T00:00:00Z',
+ },
+ sessionLog: [],
+ restoreCount: 0,
+ lastRestoredEntityUuid: null,
+});
+
+const LIVE_DATASOURCE = { id: 5, type: 'table', columns: [], metrics: [] };
+
+const renderPreview = (liveDatasource: unknown = LIVE_DATASOURCE) => {
+ const store = createStore(
+ {
+ versionHistory: previewState(),
+ explore: { datasource: liveDatasource, slice: { editors: [{ id: 1 }] } },
+ user: { userId: 1, roles: {} },
+ },
+ reducerIndex,
+ );
+ render(<ChartVersionPreview />, { useTheme: true, store });
+ return store;
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+ params: '{"metric":"count"}',
+ viz_type: 'table',
+ datasource_id: 5,
+ datasource_type: 'table',
+ });
+ (getChartDataRequest as jest.Mock).mockResolvedValue({
+ response: {},
+ json: {},
+ });
+ (handleChartDataResponse as jest.Mock).mockResolvedValue([{ data: [] }]);
+});
+
+test('renders the snapshot against the datasource the version was built on', async () => {
+ renderPreview();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('super-chart')).toHaveTextContent('5__table');
+ });
+ // The live datasource already matches, so no extra metadata round trip.
+ expect(fetchDatasourceMetadata).not.toHaveBeenCalled();
+});
+
+test('announces the applied preview so the banner can offer Restore', async () => {
+ // The store enters isPreviewApplying on SET_VERSION_PREVIEW and only this
+ // component's announcement leaves it; without it the banner reads "Loading
+ // historical version" forever and never renders the Restore button.
+ const store = renderPreview();
+
+ await waitFor(() => {
+ expect(
+ (store.getState() as unknown as VersionHistoryRootState).versionHistory
+ .isPreviewApplying,
+ ).toBe(false);
+ });
+});
+
+test('announces completion even when the preview fails to load', async () => {
+ // A failed load shows the error alert in place of the chart; the applying
+ // state must still clear so the banner is not stuck reporting a load that
+ // has already settled.
+ (getChartDataRequest as jest.Mock).mockRejectedValue(
+ new Response(JSON.stringify({ message: 'boom' }), { status: 500 }),
+ );
+ const store = renderPreview();
+
+ await waitFor(() => {
+ expect(
+ (store.getState() as unknown as VersionHistoryRootState).versionHistory
+ .isPreviewApplying,
+ ).toBe(false);
+ });
+});
+
+test('fetches metadata when the version used a different datasource', async () => {
+ // A chart repointed at another dataset since the snapshot: the preview must
+ // describe itself with the dataset the version actually used, not the
+ // current one.
+ (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+ params: '{}',
+ viz_type: 'table',
+ datasource_id: 9,
+ datasource_type: 'table',
+ });
+ (fetchDatasourceMetadata as jest.Mock).mockResolvedValue({
+ id: 9,
+ type: 'table',
+ columns: [],
+ metrics: [],
+ });
+ renderPreview();
+
+ await waitFor(() => {
+ expect(fetchDatasourceMetadata).toHaveBeenCalledWith(9, 'table');
+ });
+ expect(screen.getByTestId('super-chart')).toHaveTextContent('9__table');
+});
+
+test('explains a version whose datasource is gone rather than failing generically', async () => {
+ (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+ params: '{}',
+ viz_type: 'table',
+ datasource_id: 9,
+ datasource_type: 'table',
+ });
+ (fetchDatasourceMetadata as jest.Mock).mockRejectedValue(new Error('404'));
+ renderPreview();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/data this version was built on is no longer available/),
+ ).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+});
+
+test('explains a version that records no viz type or dataset', async () => {
+ // Every version-table column is nullable — a row written for a delete
+ // carries nulls throughout. Without the guard the preview requested
+ // `undefined__undefined` and surfaced whatever the API said about it.
+ (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+ params: '{}',
+ viz_type: null,
+ datasource_id: null,
+ datasource_type: null,
+ });
+ renderPreview();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ /does not record a visualization type and dataset, so it cannot be previewed/,
+ ),
+ ).toBeInTheDocument();
+ });
+ expect(fetchDatasourceMetadata).not.toHaveBeenCalled();
+ expect(getChartDataRequest).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+});
+
+test('surfaces a chart-data failure instead of rendering an empty chart', async () => {
+ (getChartDataRequest as jest.Mock).mockRejectedValue(
+ new Response(JSON.stringify({ message: 'Query timed out' }), {
+ status: 422,
+ }),
+ );
+ renderPreview();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Query timed out/)).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+});
+
+test('malformed snapshot params surface as an error, not a crash', async () => {
+ // params is free-form JSON stored per version; a snapshot written by an
+ // older or hand-edited record must not take the page down.
+ (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+ params: '{not json',
+ viz_type: 'table',
+ datasource_id: 5,
+ datasource_type: 'table',
+ });
+ renderPreview();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+ });
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/features/versionHistory/ChartVersionPreview.tsx b/superset-frontend/src/features/versionHistory/ChartVersionPreview.tsx
new file mode 100644
index 00000000000..b226cc92203
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ChartVersionPreview.tsx
@@ -0,0 +1,257 @@
+/**
+ * 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 { useEffect, useMemo, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { canOverwriteSlice } from 'src/explore/exploreUtils/canOverwriteSlice';
+import { Slice } from 'src/types/Chart';
+import {
+ getClientErrorObject,
+ QueryData,
+ QueryFormData,
+ SuperChart,
+} from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Alert } from '@apache-superset/core/components';
+import { Loading } from '@superset-ui/core/components';
+import {
+ getChartDataRequest,
+ handleChartDataResponse,
+} from 'src/components/Chart/chartAction';
+import { getQuerySettings } from 'src/explore/exploreUtils';
+import type { Dataset } from 'src/components/Chart/types';
+import type { ExplorePageState } from 'src/explore/types';
+import { selectVersionPreview, versionPreviewApplied } from './reducer';
+import { fetchDatasourceMetadata, fetchVersionSnapshot } from './api';
+import PreviewBanner from './PreviewBanner';
+
+const Container = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 2}px;
+ height: 100%;
+ padding: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+const ChartArea = styled.div`
+ flex: 1;
+ min-height: 0;
+`;
+
+function buildPreviewFormData(
+ params: string | null,
+ vizType: string,
+ datasourceId: number,
+ datasourceType: string,
+): QueryFormData {
+ const parsed = params ? JSON.parse(params) : {};
+ return {
+ ...parsed,
+ viz_type: vizType,
+ datasource: `${datasourceId}__${datasourceType}`,
+ // Use an unsaved-chart id so the preview never collides with the
+ // live chart's cached queries.
+ slice_id: 0,
+ } as QueryFormData;
+}
+
+export default function ChartVersionPreview() {
+ const dispatch = useDispatch();
+ const preview = useSelector(selectVersionPreview);
+ const slice = useSelector<ExplorePageState, Slice | undefined>(
+ state => state.explore?.slice ?? undefined,
+ );
+ const user = useSelector<ExplorePageState, ExplorePageState['user']>(
+ state => state.user,
+ );
+ const canOverwrite = useSelector<ExplorePageState, boolean>(
+ state => state.explore?.can_overwrite ?? false,
+ );
+ // Same predicate as the panel and the menu, so an admin who can open the
+ // history can also restore from it.
+ const canRestore = useMemo(
+ () => canOverwriteSlice({ slice, user, canOverwrite }),
+ [slice, user, canOverwrite],
+ );
+ const datasource = useSelector<ExplorePageState, Dataset | undefined>(
+ state => state.explore?.datasource as unknown as Dataset | undefined,
+ );
+ const [formData, setFormData] = useState<QueryFormData | null>(null);
+ const [queriesData, setQueriesData] = useState<QueryData[] | null>(null);
+ const [previewDatasource, setPreviewDatasource] = useState<
+ Dataset | undefined
+ >(undefined);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const fetchIdRef = useRef(0);
+ // Read the live datasource through a ref so re-hydrations don't
+ // retrigger the preview fetch. Synced in an effect — render-phase ref
+ // writes are unsafe under concurrent rendering.
+ const liveDatasourceRef = useRef(datasource);
+ useEffect(() => {
+ liveDatasourceRef.current = datasource;
+ }, [datasource]);
+
+ const entityUuid = preview?.entityUuid;
+ const versionUuid = preview?.versionUuid;
+
+ useEffect(() => {
+ if (!entityUuid || !versionUuid) {
+ return;
+ }
+ fetchIdRef.current += 1;
+ const fetchId = fetchIdRef.current;
+ setIsLoading(true);
+ setError(null);
+ setQueriesData(null);
+ setFormData(null);
+ setPreviewDatasource(undefined);
+
+ const load = async () => {
+ const snapshot = await fetchVersionSnapshot(
+ 'chart',
+ entityUuid,
+ versionUuid,
+ );
+ // Every version-table column is nullable, so a version that records no
+ // visualization type or dataset cannot be rendered at all — say so
+ // rather than requesting `undefined__undefined`.
+ const { viz_type: vizType, datasource_id: datasourceId } = snapshot;
+ const { datasource_type: datasourceType } = snapshot;
+ if (vizType == null || datasourceId == null || datasourceType == null) {
+ if (fetchId === fetchIdRef.current) {
+ setError(
+ t(
+ 'This version does not record a visualization type and dataset, so it cannot be previewed',
+ ),
+ );
+ }
+ return;
+ }
+ // The snapshot may reference a different datasource than the live
+ // chart (e.g. the chart was switched to another dataset since);
+ // fetch the metadata the preview should label itself with.
+ const live = liveDatasourceRef.current as
+ | (Dataset & { id?: number; type?: string })
+ | undefined;
+ let datasourceMeta: Dataset | undefined = live;
+ if (!live || live.id !== datasourceId || live.type !== datasourceType) {
+ try {
+ datasourceMeta = (await fetchDatasourceMetadata(
+ datasourceId,
+ datasourceType,
+ )) as unknown as Dataset;
+ } catch {
+ if (fetchId === fetchIdRef.current) {
+ setError(
+ t(
+ 'The data this version was built on is no longer available, so it cannot be previewed',
+ ),
+ );
+ }
+ return;
+ }
+ }
+ const previewFormData = buildPreviewFormData(
+ snapshot.params,
+ vizType,
+ datasourceId,
+ datasourceType,
+ );
+ const [useLegacyApi] = getQuerySettings(previewFormData);
+ const { response, json } = await getChartDataRequest({
+ formData: previewFormData,
+ });
+ const result = await handleChartDataResponse(
+ response,
+ json,
+ useLegacyApi,
+ );
+ if (fetchId !== fetchIdRef.current) {
+ return;
+ }
+ setFormData(previewFormData);
+ setQueriesData(result);
+ setPreviewDatasource(datasourceMeta);
+ };
+
+ load()
+ .catch(async response => {
+ if (fetchId !== fetchIdRef.current) {
+ return;
+ }
+ const { error: clientError, message } = await getClientErrorObject(
+ response as Parameters<typeof getClientErrorObject>[0],
+ );
+ setError(clientError || message || t('Failed to load version preview'));
+ })
+ .finally(() => {
+ if (fetchId === fetchIdRef.current) {
+ setIsLoading(false);
+ // SET_VERSION_PREVIEW put the store into the applying state; only
+ // this announcement leaves it, and the banner withholds Restore
+ // until it fires. Announce on failure too: the error alert replaces
+ // the chart, so the page is not mislabeling live content as
+ // historical, and Restore stays available exactly as it is from the
+ // panel kebab — a version whose data can't render is still a valid
+ // restore target.
+ dispatch(versionPreviewApplied());
+ }
+ });
+
+ // Invalidate the in-flight request when the preview closes or the
+ // component unmounts, so a late /chart/data response cannot set state
+ // afterwards.
+ return () => {
+ fetchIdRef.current += 1;
+ };
+ }, [dispatch, entityUuid, versionUuid]);
+
+ if (!preview) {
+ return null;
+ }
+
+ return (
+ <Container data-test="chart-version-preview">
+ <PreviewBanner entityType="chart" canRestore={canRestore} />
+ {isLoading && <Loading />}
+ {!isLoading && error && (
+ <Alert type="error" closable={false} message={error} />
+ )}
+ {!isLoading && !error && formData && queriesData && (
+ <ChartArea>
+ {/* keep SuperChart's own error boundary so a render failure on
+ stale metadata degrades to a contained error, not a broken
+ page */}
+ <SuperChart
+ chartType={formData.viz_type}
+ enableNoResults
+ datasource={previewDatasource}
+ formData={formData}
+ queriesData={queriesData}
+ height="100%"
+ width="100%"
+ />
+ </ChartArea>
+ )}
+ </Container>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/CurrentVersionSection.tsx b/superset-frontend/src/features/versionHistory/CurrentVersionSection.tsx
new file mode 100644
index 00000000000..2c672bb7c50
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/CurrentVersionSection.tsx
@@ -0,0 +1,163 @@
+/**
+ * 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 { useState } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { extendedDayjs } from '@superset-ui/core/utils/dates';
+import { Icons } from '@superset-ui/core/components';
+import type { SessionLogEntry } from './types';
+import { SHORT_DATETIME_FORMAT } from './display';
+
+const Container = styled.div`
+ ${({ theme }) => `
+ border-bottom: 1px solid ${theme.colorBorderSecondary};
+ padding: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
+ `}
+`;
+
+const Header = styled.button`
+ ${({ theme }) => `
+ display: flex;
+ align-items: flex-start;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit * 3}px 0;
+ cursor: pointer;
+ border: none;
+ background: none;
+ width: 100%;
+ text-align: left;
+ font: inherit;
+ color: inherit;
+ `}
+`;
+
+const Title = styled.div`
+ ${({ theme }) => `
+ flex: 1;
+ font-size: ${theme.fontSize}px;
+ line-height: ${theme.lineHeight};
+ color: ${theme.colorText};
+ `}
+`;
+
+// Icons center within the first text line (one line-height tall) so
+// they track the title, consistent with the other timeline rows.
+const IconWrapper = styled.span`
+ ${({ theme }) => `
+ color: ${theme.colorTextSecondary};
+ display: flex;
+ align-items: center;
+ height: ${theme.fontSize * theme.lineHeight}px;
+ `}
+`;
+
+const ChevronWrapper = styled.span`
+ ${({ theme }) => `
+ color: ${theme.colorTextTertiary};
+ display: flex;
+ align-items: center;
+ height: ${theme.fontSize * theme.lineHeight}px;
+ `}
+`;
+
+const EntriesBlock = styled.div`
+ ${({ theme }) => `
+ background-color: ${theme.colorPrimaryBg};
+ border-radius: ${theme.borderRadius}px;
+ padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+const EntryLabel = styled.div`
+ ${({ theme }) => `
+ font-size: ${theme.fontSize}px;
+ line-height: ${theme.lineHeight};
+ color: ${theme.colorText};
+ `}
+`;
+
+const EntryMeta = styled.div`
+ ${({ theme }) => `
+ color: ${theme.colorTextTertiary};
+ font-size: ${theme.fontSizeSM}px;
+ line-height: ${theme.lineHeightSM};
+ `}
+`;
+
+export interface CurrentVersionSectionProps {
+ entries: SessionLogEntry[];
+ /** e.g. "Restored version · Dec 5, 2025, 5:18 PM" */
+ restoreNotice: string | null;
+}
+
+export default function CurrentVersionSection({
+ entries,
+ restoreNotice,
+}: CurrentVersionSectionProps) {
+ const [expanded, setExpanded] = useState(false);
+
+ if (!restoreNotice && entries.length === 0) {
+ return null;
+ }
+
+ const toggle = () => setExpanded(value => !value);
+
+ return (
+ <Container data-test="version-history-current-version">
+ <Header
+ type="button"
+ onClick={toggle}
+ aria-expanded={expanded}
+ aria-label={t('Current version')}
+ >
+ <IconWrapper>
+ <Icons.CheckCircleOutlined iconSize="l" />
+ </IconWrapper>
+ <Title>{t('Current version')}</Title>
+ <ChevronWrapper>
+ {expanded ? (
+ <Icons.UpOutlined iconSize="m" />
+ ) : (
+ <Icons.DownOutlined iconSize="m" />
+ )}
+ </ChevronWrapper>
+ </Header>
+ {expanded && (
+ <EntriesBlock>
+ {restoreNotice && <EntryLabel>{restoreNotice}</EntryLabel>}
+ {entries.map(entry => {
+ // Session log timestamps are local epoch milliseconds.
+ const time = extendedDayjs(entry.ts).format(SHORT_DATETIME_FORMAT);
+ return (
+ <div key={`${entry.controlName}-${entry.ts}`}>
+ <EntryLabel>{entry.label}</EntryLabel>
+ <EntryMeta>
+ {entry.user ? `${entry.user} · ${time}` : time}
+ </EntryMeta>
+ </div>
+ );
+ })}
+ </EntriesBlock>
+ )}
+ </Container>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
new file mode 100644
index 00000000000..9080ad8e27e
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
@@ -0,0 +1,255 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { AnyAction, Store } from 'redux';
+import { act, render } from 'spec/helpers/testing-library';
+import type { VersionHistoryState } from './types';
+import { useVersionActivity } from './useVersionActivity';
+import DashboardVersionHistory from './DashboardVersionHistory';
+
+const mockPanelProps = jest.fn();
+jest.mock('./VersionHistoryPanel', () => ({
+ __esModule: true,
+ default: (props: unknown) => {
+ mockPanelProps(props);
+ return null;
+ },
+}));
+jest.mock('./useDashboardVersionPreview', () => ({
+ useDashboardVersionPreview: jest.fn(),
+}));
+jest.mock('./useVersionActions', () => ({
+ useVersionActions: () => ({
+ requestRestore: jest.fn(),
+ openAsNew: jest.fn(),
+ restoreModal: null,
+ }),
+}));
+jest.mock('./useVersionActivity', () => ({
+ useVersionActivity: jest.fn(),
+}));
+const mockAddDangerToast = jest.fn();
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+ useToasts: () => ({ addDangerToast: mockAddDangerToast }),
+}));
+
+const mockedUseVersionActivity = useVersionActivity as jest.Mock;
+const refresh = jest.fn();
+
+const versionHistoryState = (
+ overrides: Partial<VersionHistoryState> = {},
+): VersionHistoryState => ({
+ isPanelOpen: true,
+ entityType: 'dashboard',
+ include: 'all',
+ preview: null,
+ isPreviewApplying: false,
+ sessionLog: [],
+ restoreCount: 0,
+ lastRestoredEntityUuid: null,
+ ...overrides,
+});
+
+interface TestState {
+ versionHistory: VersionHistoryState;
+ dashboardInfo: {
+ uuid: string;
+ last_modified_time: number;
+ dash_edit_perm?: boolean;
+ is_managed_externally?: boolean;
+ };
+ dashboardState: { hasUnsavedChanges: boolean; lastModifiedTime: number };
+}
+
+/** Minimal recording store: dispatched actions are captured, never reduced,
+ * so tests drive state transitions explicitly via setState. */
+function makeTestStore(initial: TestState) {
+ let state = initial;
+ const actions: AnyAction[] = [];
+ const listeners = new Set<() => void>();
+ return {
+ actions,
+ getState: () => state,
+ setState(partial: Partial<TestState>) {
+ state = { ...state, ...partial };
+ listeners.forEach(listener => listener());
+ },
+ dispatch(action: AnyAction) {
+ actions.push(action);
+ return action;
+ },
+ subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
+
+const makeStore = () =>
+ makeTestStore({
+ versionHistory: versionHistoryState(),
+ dashboardInfo: {
+ uuid: 'dash-uuid',
+ last_modified_time: 100,
+ dash_edit_perm: true,
+ },
+ dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 500 },
+ });
+
+const renderAdapter = (store: ReturnType<typeof makeTestStore>) =>
+ render(<DashboardVersionHistory />, { store: store as unknown as Store });
+
+beforeEach(() => {
+ mockedUseVersionActivity.mockReturnValue({
+ records: [],
+ timeline: [],
+ newestGroup: null,
+ currentVersionStatus: 'empty',
+ count: 0,
+ isLoading: false,
+ error: null,
+ hasMore: false,
+ truncated: false,
+ loadMore: jest.fn(),
+ refresh,
+ });
+});
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+test('refreshes the timeline when an edit-mode save bumps lastModifiedTime', () => {
+ const store = makeStore();
+ renderAdapter(store);
+ expect(refresh).not.toHaveBeenCalled();
+
+ act(() => {
+ store.setState({
+ dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 600 },
+ });
+ });
+
+ expect(refresh).toHaveBeenCalledTimes(1);
+});
+
+test('refreshes the timeline when a filter or properties save bumps last_modified_time', () => {
+ const store = makeStore();
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({
+ dashboardInfo: { uuid: 'dash-uuid', last_modified_time: 200 },
+ });
+ });
+
+ expect(refresh).toHaveBeenCalledTimes(1);
+});
+
+test('does not refresh when unrelated state changes leave the save signals untouched', () => {
+ const store = makeStore();
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({
+ dashboardState: { hasUnsavedChanges: true, lastModifiedTime: 500 },
+ dashboardInfo: { uuid: 'dash-uuid', last_modified_time: 100 },
+ });
+ });
+
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test('a restore that also moves the save signal refreshes exactly once', () => {
+ const store = makeStore();
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'dash-uuid',
+ }),
+ dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 700 },
+ });
+ });
+
+ expect(refresh).toHaveBeenCalledTimes(1);
+});
+
+test('preview is blocked while the dashboard has unsaved edit-mode changes', () => {
+ const store = makeTestStore({
+ versionHistory: versionHistoryState(),
+ dashboardInfo: { uuid: 'dash-uuid', last_modified_time: 100 },
+ dashboardState: { hasUnsavedChanges: true, lastModifiedTime: 500 },
+ });
+ renderAdapter(store);
+
+ const { onPreview } = mockPanelProps.mock.lastCall[0];
+ act(() => {
+ onPreview({
+ type: 'group',
+ transactionId: 10,
+ versionUuid: 'v-1',
+ issuedAt: '2025-12-05T17:18:00',
+ changedBy: null,
+ actionKind: null,
+ records: [],
+ });
+ });
+
+ // Previewing would rehydrate the dashboard and wipe the unsaved work;
+ // the user is told to save or discard instead.
+ expect(mockAddDangerToast).toHaveBeenCalled();
+ expect(
+ store.actions.some(action => action.type === 'SET_VERSION_PREVIEW'),
+ ).toBe(false);
+});
+
+test('offers restore to an editor of a normally managed dashboard', () => {
+ const store = makeStore();
+ renderAdapter(store);
+
+ expect(mockPanelProps).toHaveBeenCalledWith(
+ expect.objectContaining({ canRestore: true }),
+ );
+});
+
+test('withholds restore on an externally managed dashboard', () => {
+ // The header menu hides the entry for these, but the panel also opens from
+ // ?version_history=true and the restore endpoint has no
+ // is_managed_externally check -- so this gate is the only thing standing
+ // between a URL param and a restore that the next sync would undo.
+ const store = makeTestStore({
+ versionHistory: versionHistoryState(),
+ dashboardInfo: {
+ uuid: 'dash-uuid',
+ last_modified_time: 100,
+ dash_edit_perm: true,
+ is_managed_externally: true,
+ },
+ dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 500 },
+ });
+ renderAdapter(store);
+
+ expect(mockPanelProps).toHaveBeenCalledWith(
+ expect.objectContaining({ canRestore: false }),
+ );
+});
diff --git a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
new file mode 100644
index 00000000000..7b67ec011dd
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
@@ -0,0 +1,282 @@
+/**
+ * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
+import { t } from '@apache-superset/core/translation';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { getUrlParam } from 'src/utils/urlUtils';
+import { URL_PARAMS } from 'src/constants';
+import type { RootState } from 'src/dashboard/types';
+import type {
+ ActivityInclude,
+ ActivityRecord,
+ SaveGroup,
+ SessionLogEntry,
+} from './types';
+import {
+ clearVersionPreview,
+ closeVersionHistoryPanel,
+ openVersionHistoryPanel,
+ selectIsVersionHistoryPanelOpen,
+ selectVersionHistoryInclude,
+ selectVersionPreview,
+ selectVersionLastRestoredUuid,
+ selectVersionRestoreCount,
+ setVersionHistoryInclude,
+ setVersionPreview,
+} from './reducer';
+import { selectCanRestoreDashboard } from './canRestoreDashboard';
+import { openRelatedEntity } from './openRelated';
+import { useVersionActivity } from './useVersionActivity';
+import { useVersionActions } from './useVersionActions';
+import { useDashboardVersionPreview } from './useDashboardVersionPreview';
+import { groupHeadline } from './display';
+import VersionHistoryPanel from './VersionHistoryPanel';
+
+export default function DashboardVersionHistory() {
+ const dispatch = useDispatch();
+ const { addDangerToast } = useToasts();
+ const uuid = useSelector<RootState, string | undefined>(
+ state => state.dashboardInfo?.uuid,
+ );
+ const canRestore = useSelector(selectCanRestoreDashboard);
+ const isPanelOpen = useSelector(selectIsVersionHistoryPanelOpen);
+ const include = useSelector(selectVersionHistoryInclude);
+ const preview = useSelector(selectVersionPreview);
+ const hasUnsavedChanges = useSelector<RootState, boolean>(
+ state => !!state.dashboardState?.hasUnsavedChanges,
+ );
+ // Dashboard edits are tracked coarsely (no per-control log like
+ // explore): a single "unsaved edits" entry while edit mode is dirty.
+ // The timestamp is captured when the dashboard first turns dirty —
+ // stamping it during render would drift on every re-render.
+ const [dirtySince, setDirtySince] = useState<number | null>(null);
+ useEffect(() => {
+ if (hasUnsavedChanges) {
+ setDirtySince(current => current ?? Date.now());
+ } else {
+ setDirtySince(null);
+ }
+ }, [hasUnsavedChanges]);
+ const sessionEntries = useMemo<SessionLogEntry[]>(
+ () =>
+ hasUnsavedChanges && dirtySince !== null
+ ? [
+ {
+ label: t('Unsaved dashboard edits'),
+ controlName: 'dashboard',
+ ts: dirtySince,
+ user: null,
+ },
+ ]
+ : [],
+ [dirtySince, hasUnsavedChanges],
+ );
+
+ // The URL param is honoured once per mount. It persists for the whole
+ // visit, and this effect re-runs whenever `canRestore` moves — a late
+ // false→true flip (dashboardInfo refetch after a properties save) would
+ // otherwise re-open a panel the user explicitly closed.
+ const urlParamHandledRef = useRef(false);
+ useEffect(() => {
+ // Match the menu entry's gating: version history is only offered to
+ // users who could restore (sc-107604) — the URL param must not open
+ // it for read-only viewers.
+ if (
+ !urlParamHandledRef.current &&
+ getUrlParam(URL_PARAMS.versionHistory) &&
+ canRestore
+ ) {
+ urlParamHandledRef.current = true;
+ dispatch(openVersionHistoryPanel('dashboard'));
+ }
+ }, [canRestore, dispatch]);
+
+ // Leaving the page should not carry panel/preview state to other pages.
+ useEffect(
+ () => () => {
+ dispatch(closeVersionHistoryPanel());
+ },
+ [dispatch],
+ );
+
+ // Server-side search over the full history; debounce so each keystroke
+ // doesn't refetch.
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearch = useDebounceValue(searchTerm);
+ const activity = useVersionActivity(
+ 'dashboard',
+ isPanelOpen ? uuid : undefined,
+ include,
+ debouncedSearch,
+ );
+
+ useDashboardVersionPreview(uuid);
+
+ const { requestRestore, openAsNew, restoreModal } = useVersionActions(
+ 'dashboard',
+ uuid,
+ );
+
+ // Page rehydration after a restore happens in useDashboardVersionPreview;
+ // here only the activity timeline needs a refresh so the new
+ // "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 =>
+ [
+ state.dashboardState?.lastModifiedTime ?? '',
+ state.dashboardInfo?.last_modified_time ?? '',
+ ].join('|'),
+ );
+ const lastRestoreCountRef = useRef(restoreCount);
+ const lastSaveSignalRef = useRef(saveSignal);
+ const refreshActivity = activity.refresh;
+ useEffect(() => {
+ 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.
+ 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();
+ }
+ }
+ }, [lastRestoredUuid, refreshActivity, restoreCount, saveSignal, uuid]);
+
+ const handleClose = useCallback(() => {
+ dispatch(closeVersionHistoryPanel());
+ }, [dispatch]);
+
+ const handleIncludeChange = useCallback(
+ (value: ActivityInclude) => {
+ dispatch(setVersionHistoryInclude(value));
+ },
+ [dispatch],
+ );
+
+ const handlePreview = useCallback(
+ (group: SaveGroup) => {
+ if (!group.versionUuid || !uuid) {
+ return;
+ }
+ if (hasUnsavedChanges) {
+ // Previewing rehydrates the whole dashboard state, which would
+ // silently wipe in-progress edit-mode work and its undo history.
+ addDangerToast(
+ t('Save or discard your unsaved changes to preview a version.'),
+ );
+ return;
+ }
+ dispatch(
+ setVersionPreview({
+ entityUuid: uuid,
+ versionUuid: group.versionUuid,
+ transactionId: group.transactionId,
+ headline: groupHeadline(group),
+ issuedAt: group.issuedAt,
+ }),
+ );
+ },
+ [addDangerToast, dispatch, hasUnsavedChanges, uuid],
+ );
+
+ const handleExitPreview = useCallback(() => {
+ dispatch(clearVersionPreview(uuid));
+ }, [dispatch, uuid]);
+
+ const handleOpenRelated = useCallback(
+ (record: ActivityRecord) => {
+ openRelatedEntity(record, addDangerToast);
+ },
+ [addDangerToast],
+ );
+
+ const handleRestore = useCallback(
+ (group: SaveGroup) => {
+ if (group.versionUuid) {
+ requestRestore({
+ versionUuid: group.versionUuid,
+ headline: groupHeadline(group),
+ issuedAt: group.issuedAt,
+ });
+ }
+ },
+ [requestRestore],
+ );
+
+ const handleOpenAsNew = useCallback(
+ (group: SaveGroup) => {
+ if (group.versionUuid) {
+ openAsNew({
+ versionUuid: group.versionUuid,
+ headline: groupHeadline(group),
+ issuedAt: group.issuedAt,
+ });
+ }
+ },
+ [openAsNew],
+ );
+
+ if (!isPanelOpen) {
+ return restoreModal;
+ }
+
+ return (
+ <>
+ <VersionHistoryPanel
+ entityType="dashboard"
+ canRestore={canRestore}
+ activity={activity}
+ include={include}
+ onIncludeChange={handleIncludeChange}
+ searchTerm={searchTerm}
+ onSearchChange={setSearchTerm}
+ previewedTransactionId={preview?.transactionId ?? null}
+ onClose={handleClose}
+ onPreview={handlePreview}
+ onExitPreview={handleExitPreview}
+ onRestore={handleRestore}
+ onOpenAsNew={handleOpenAsNew}
+ onOpenRelated={handleOpenRelated}
+ sessionEntries={sessionEntries}
+ />
+ {restoreModal}
+ </>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
new file mode 100644
index 00000000000..922d9807ce2
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
@@ -0,0 +1,338 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { AnyAction, Store } from 'redux';
+import { act, render, waitFor } from 'spec/helpers/testing-library';
+import { hydrateExplore } from 'src/explore/actions/hydrateExplore';
+import type { VersionHistoryState } from './types';
+import { fetchExploreRehydrationData } from './api';
+import { useVersionActivity } from './useVersionActivity';
+import ExploreVersionHistory from './ExploreVersionHistory';
+
+jest.mock('./VersionHistoryPanel', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('./useVersionActions', () => ({
+ useVersionActions: () => ({
+ requestRestore: jest.fn(),
+ openAsNew: jest.fn(),
+ restoreModal: null,
+ }),
+}));
+jest.mock('./useVersionActivity', () => ({
+ useVersionActivity: jest.fn(),
+}));
+jest.mock('./api', () => ({
+ ...jest.requireActual('./api'),
+ fetchChartUuid: jest.fn(),
+ fetchExploreRehydrationData: jest.fn(),
+}));
+jest.mock('src/explore/actions/hydrateExplore', () => ({
+ hydrateExplore: jest.fn(),
+}));
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+ useToasts: () => ({ addDangerToast: jest.fn() }),
+}));
+
+const HYDRATE_EXPLORE_TEST = 'HYDRATE_EXPLORE_TEST_ACTION';
+
+const mockedUseVersionActivity = useVersionActivity as jest.Mock;
+const mockedHydrateExplore = hydrateExplore as unknown as jest.Mock;
+const mockedFetchRehydration =
+ fetchExploreRehydrationData as unknown as jest.Mock;
+const refresh = jest.fn();
+
+const versionHistoryState = (
+ overrides: Partial<VersionHistoryState> = {},
+): VersionHistoryState => ({
+ isPanelOpen: true,
+ entityType: 'chart',
+ include: 'all',
+ preview: null,
+ isPreviewApplying: false,
+ sessionLog: [],
+ restoreCount: 0,
+ lastRestoredEntityUuid: null,
+ ...overrides,
+});
+
+interface TestSlice {
+ slice_id: number;
+ uuid: string;
+ changed_on: string;
+}
+
+interface TestState {
+ versionHistory: VersionHistoryState;
+ explore: { slice?: TestSlice };
+}
+
+const slice = (changedOn: string): TestSlice => ({
+ slice_id: 1,
+ uuid: 'chart-uuid',
+ changed_on: changedOn,
+});
+
+/** Minimal recording store: dispatched actions are captured, never reduced,
+ * so tests drive state transitions explicitly via setState. */
+function makeTestStore(initial: TestState) {
+ let state = initial;
+ const actions: AnyAction[] = [];
+ const listeners = new Set<() => void>();
+ return {
+ actions,
+ getState: () => state,
+ setState(partial: Partial<TestState>) {
+ state = { ...state, ...partial };
+ listeners.forEach(listener => listener());
+ },
+ dispatch(action: AnyAction) {
+ actions.push(action);
+ return action;
+ },
+ subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
+
+const makeStore = () =>
+ makeTestStore({
+ versionHistory: versionHistoryState(),
+ explore: { slice: slice('2025-12-08T17:18:00') },
+ });
+
+const renderAdapter = (store: ReturnType<typeof makeTestStore>) =>
+ render(<ExploreVersionHistory />, { store: store as unknown as Store });
+
+const activityResult = {
+ records: [],
+ timeline: [],
+ newestGroup: null,
+ currentVersionStatus: 'empty',
+ count: 0,
+ isLoading: false,
+ error: null,
+ hasMore: false,
+ truncated: false,
+ loadMore: jest.fn(),
+ refresh,
+};
+
+beforeEach(() => {
+ mockedUseVersionActivity.mockReturnValue(activityResult);
+ mockedFetchRehydration.mockResolvedValue({});
+ mockedHydrateExplore.mockImplementation(payload => ({
+ type: HYDRATE_EXPLORE_TEST,
+ payload,
+ }));
+});
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+test('refreshes the timeline when an overwrite save replaces the slice', () => {
+ const store = makeStore();
+ renderAdapter(store);
+ expect(refresh).not.toHaveBeenCalled();
+
+ act(() => {
+ store.setState({ explore: { slice: slice('2025-12-08T18:00:00') } });
+ });
+
+ expect(refresh).toHaveBeenCalledTimes(1);
+ expect(mockedHydrateExplore).not.toHaveBeenCalled();
+});
+
+test('a restore refreshes exactly once even when it also moves the slice', async () => {
+ const store = makeStore();
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'chart-uuid',
+ }),
+ explore: { slice: slice('2025-12-08T18:00:00') },
+ });
+ });
+
+ expect(refresh).toHaveBeenCalledTimes(1);
+ // The restore branch also reloads the explore page state in place.
+ await waitFor(() =>
+ expect(
+ store.actions.some(action => action.type === HYDRATE_EXPLORE_TEST),
+ ).toBe(true),
+ );
+ expect(mockedHydrateExplore).toHaveBeenCalledWith(
+ expect.objectContaining({ saveAction: 'overwrite' }),
+ );
+});
+
+test('a restore rehydration resolving after unmount is discarded', async () => {
+ // hydrateExplore rewrites the whole explore store; a rehydration fetch
+ // resolving after the user navigated away would overwrite the next page's
+ // chart with this one's payload.
+ let resolveRehydration: (value: object) => void = () => {};
+ mockedFetchRehydration.mockReturnValue(
+ new Promise(resolve => {
+ resolveRehydration = resolve;
+ }),
+ );
+ const store = makeStore();
+ const { unmount } = renderAdapter(store);
+
+ act(() => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'chart-uuid',
+ }),
+ });
+ });
+ await waitFor(() => expect(mockedFetchRehydration).toHaveBeenCalled());
+
+ unmount();
+ await act(async () => {
+ resolveRehydration({});
+ });
+
+ expect(mockedHydrateExplore).not.toHaveBeenCalled();
+ expect(
+ store.actions.some(action => action.type === HYDRATE_EXPLORE_TEST),
+ ).toBe(false);
+});
+
+test('a search keystroke mid-rehydration does not drop the restored version', async () => {
+ // `refreshActivity` is `useVersionActivity`'s `refresh`, whose identity
+ // moves whenever the debounced search term or the include filter does.
+ // A guard bound to the effect run would cancel the in-flight rehydration
+ // on that re-run and never re-issue it — the timeline would show the
+ // restore while the chart still rendered its pre-restore state, with the
+ // failure toast suppressed too.
+ let resolveRehydration: (value: object) => void = () => {};
+ mockedFetchRehydration.mockReturnValue(
+ new Promise(resolve => {
+ resolveRehydration = resolve;
+ }),
+ );
+ mockedUseVersionActivity.mockImplementation(() => ({
+ ...activityResult,
+ // A fresh identity per render, as the real hook produces when `q` moves.
+ refresh: () => refresh(),
+ }));
+ const store = makeStore();
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'chart-uuid',
+ }),
+ });
+ });
+ await waitFor(() => expect(mockedFetchRehydration).toHaveBeenCalled());
+
+ // The debounce fires mid-flight and re-renders the panel.
+ act(() => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'chart-uuid',
+ include: 'self',
+ }),
+ });
+ });
+
+ await act(async () => {
+ resolveRehydration({});
+ });
+
+ expect(mockedHydrateExplore).toHaveBeenCalledWith(
+ expect.objectContaining({ saveAction: 'overwrite' }),
+ );
+ // ...and it was not re-issued to compensate.
+ expect(mockedFetchRehydration).toHaveBeenCalledTimes(1);
+});
+
+test('the initial slice hydration does not trigger a refresh', () => {
+ const store = makeTestStore({
+ versionHistory: versionHistoryState(),
+ explore: {},
+ });
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({ explore: { slice: slice('2025-12-08T17:18:00') } });
+ });
+
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test('does not refresh when state changes leave changed_on untouched', () => {
+ const store = makeStore();
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({ explore: { slice: slice('2025-12-08T17:18:00') } });
+ });
+
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test('a save landing mid-rehydration wins over the older restore payload', async () => {
+ // The restore payload was fetched before the save committed, so hydrating
+ // it would roll the chart back over the newer state. The save's own
+ // in-place hydration is already correct; the stale payload is dropped.
+ let resolveRehydration: (value: object) => void = () => {};
+ mockedFetchRehydration.mockReturnValue(
+ new Promise(resolve => {
+ resolveRehydration = resolve;
+ }),
+ );
+ const store = makeStore();
+ renderAdapter(store);
+
+ act(() => {
+ store.setState({
+ versionHistory: versionHistoryState({
+ restoreCount: 1,
+ lastRestoredEntityUuid: 'chart-uuid',
+ }),
+ });
+ });
+ await waitFor(() => expect(mockedFetchRehydration).toHaveBeenCalled());
+
+ // An overwrite save commits while the rehydration is still in flight.
+ act(() => {
+ store.setState({ explore: { slice: slice('2025-12-09T09:00:00') } });
+ });
+
+ await act(async () => {
+ resolveRehydration({});
+ });
+
+ expect(mockedHydrateExplore).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
new file mode 100644
index 00000000000..6e7e9359690
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
@@ -0,0 +1,361 @@
+/**
+ * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { getUrlParam } from 'src/utils/urlUtils';
+import { canOverwriteSlice } from 'src/explore/exploreUtils/canOverwriteSlice';
+import { URL_PARAMS } from 'src/constants';
+import { hydrateExplore } from 'src/explore/actions/hydrateExplore';
+import type { Slice } from 'src/types/Chart';
+import type { ExplorePageState } from 'src/explore/types';
+import type { ActivityInclude, ActivityRecord, SaveGroup } from './types';
+import {
+ clearVersionPreview,
+ closeVersionHistoryPanel,
+ openVersionHistoryPanel,
+ selectIsVersionHistoryPanelOpen,
+ selectVersionHistoryInclude,
+ selectVersionPreview,
+ selectVersionLastRestoredUuid,
+ selectVersionRestoreCount,
+ selectVersionSessionLog,
+ setVersionHistoryInclude,
+ setVersionPreview,
+} from './reducer';
+import { fetchChartUuid, fetchExploreRehydrationData } from './api';
+import { openRelatedEntity } from './openRelated';
+import { useVersionActivity } from './useVersionActivity';
+import { useVersionActions } from './useVersionActions';
+import { groupHeadline } from './display';
+import VersionHistoryPanel from './VersionHistoryPanel';
+
+/**
+ * The explore flex row (datasource rail + control rail + chart) cannot give
+ * up enough width for the panel on narrow viewports; below the XL breakpoint
+ * the panel overlays the page (anchored to the relatively-positioned explore
+ * container) instead of being pushed past the viewport edge.
+ */
+const PanelHost = styled.div`
+ ${({ theme }) => `
+ height: 100%;
+ flex-shrink: 0;
+ @media (max-width: ${theme.screenXL}px) {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ height: auto;
+ z-index: 20;
+ box-shadow: ${theme.boxShadow};
+ }
+ `}
+`;
+
+export default function ExploreVersionHistory() {
+ const dispatch = useDispatch();
+ const { addDangerToast } = useToasts();
+ const slice = useSelector<ExplorePageState, Slice | undefined>(
+ state => state.explore?.slice ?? undefined,
+ );
+ const user = useSelector<ExplorePageState, ExplorePageState['user']>(
+ state => state.user,
+ );
+ const canOverwrite = useSelector<ExplorePageState, boolean>(
+ state => state.explore?.can_overwrite ?? false,
+ );
+ // Same predicate as the menu that opens this panel: can_overwrite alone
+ // excludes admins and extra editors on charts with no explicit editors.
+ const canRestore = useMemo(
+ () => canOverwriteSlice({ slice, user, canOverwrite }),
+ [slice, user, canOverwrite],
+ );
+ const isPanelOpen = useSelector(selectIsVersionHistoryPanelOpen);
+ const include = useSelector(selectVersionHistoryInclude);
+ const preview = useSelector(selectVersionPreview);
+ const sessionLog = useSelector(selectVersionSessionLog);
+ const sliceId = slice?.slice_id;
+ // Key the fetched uuid by slice id so a "save as" (which swaps the slice
+ // in place) invalidates it instead of keeping the old chart's uuid.
+ const [fetchedUuid, setFetchedUuid] = useState<{
+ sliceId: number;
+ uuid: string;
+ } | null>(null);
+ const uuid =
+ slice?.uuid ??
+ (fetchedUuid && fetchedUuid.sliceId === sliceId
+ ? fetchedUuid.uuid
+ : undefined);
+
+ // The URL param is honoured once per mount. It persists for the whole
+ // visit, and this effect re-runs whenever `canRestore` moves — a late
+ // false→true flip (slice metadata refetch, an editors change landing)
+ // would otherwise re-open a panel the user explicitly closed.
+ const urlParamHandledRef = useRef(false);
+ useEffect(() => {
+ // Match the menu entry's gating: version history is only offered to
+ // users who could restore (sc-107604) — the URL param must not open
+ // it for read-only viewers.
+ if (
+ !urlParamHandledRef.current &&
+ getUrlParam(URL_PARAMS.versionHistory) &&
+ canRestore
+ ) {
+ urlParamHandledRef.current = true;
+ dispatch(openVersionHistoryPanel('chart'));
+ }
+ }, [canRestore, dispatch]);
+
+ // Leaving the page should not carry panel/preview state to other pages.
+ useEffect(
+ () => () => {
+ dispatch(closeVersionHistoryPanel());
+ },
+ [dispatch],
+ );
+
+ useEffect(() => {
+ if (uuid || !isPanelOpen || !sliceId) {
+ return undefined;
+ }
+ let cancelled = false;
+ fetchChartUuid(sliceId)
+ .then(value => {
+ if (!cancelled) {
+ setFetchedUuid({ sliceId, uuid: value });
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ addDangerToast(t('Failed to load version history'));
+ // Without a uuid the panel would sit on a misleading
+ // "No history yet" empty state; close it instead.
+ dispatch(closeVersionHistoryPanel());
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [uuid, isPanelOpen, sliceId, addDangerToast, dispatch]);
+
+ // Server-side search over the full history; debounce so each keystroke
+ // doesn't refetch.
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearch = useDebounceValue(searchTerm);
+ const activity = useVersionActivity(
+ 'chart',
+ isPanelOpen ? uuid : undefined,
+ include,
+ debouncedSearch,
+ );
+
+ const { requestRestore, openAsNew, restoreModal } = useVersionActions(
+ 'chart',
+ uuid,
+ );
+
+ // After a restore the server-side chart changed; reload the explore
+ // page state in place (same payload the page hydrates from) and
+ // refresh the activity timeline so the new "Restored version" entry
+ // 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,
+ );
+ 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
+ // after the page unmounted — or after a save-as swapped the slice in
+ // place — would overwrite the newly loaded chart's state with the old
+ // chart's payload. The token belongs to the fetch, not to the effect:
+ // the effect's identity moves whenever the debounced search term or the
+ // include filter does (both feed `refreshActivity`), and cancelling on
+ // those re-runs would drop a live rehydration that nothing re-issues.
+ const restoreHydrationIdRef = useRef(0);
+ // Only unmount and a slice/uuid swap supersede a rehydration here; a
+ // newer restore bumps the token where it starts its own fetch.
+ useEffect(
+ () => () => {
+ restoreHydrationIdRef.current += 1;
+ },
+ [sliceId, uuid],
+ );
+ useEffect(() => {
+ 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;
+ const hydrationId = restoreHydrationIdRef.current;
+ // A save landing while this fetch is in flight leaves the payload
+ // 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;
+ fetchExploreRehydrationData(sliceId)
+ .then(result => {
+ if (isCurrent()) {
+ dispatch(
+ hydrateExplore({ ...result, saveAction: 'overwrite' }),
+ );
+ }
+ })
+ .catch(() => {
+ if (isCurrent()) {
+ addDangerToast(t('Failed to reload the restored version'));
+ }
+ });
+ }
+ }
+ } 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();
+ }
+ }
+ }, [
+ addDangerToast,
+ dispatch,
+ lastRestoredUuid,
+ refreshActivity,
+ restoreCount,
+ saveSignal,
+ sliceId,
+ uuid,
+ ]);
+
+ const handleClose = useCallback(() => {
+ dispatch(closeVersionHistoryPanel());
+ }, [dispatch]);
+
+ const handleIncludeChange = useCallback(
+ (value: ActivityInclude) => {
+ dispatch(setVersionHistoryInclude(value));
+ },
+ [dispatch],
+ );
+
+ const handlePreview = useCallback(
+ (group: SaveGroup) => {
+ if (!group.versionUuid || !uuid) {
+ return;
+ }
+ dispatch(
+ setVersionPreview({
+ entityUuid: uuid,
+ versionUuid: group.versionUuid,
+ transactionId: group.transactionId,
+ headline: groupHeadline(group),
+ issuedAt: group.issuedAt,
+ }),
+ );
+ },
+ [dispatch, uuid],
+ );
+
+ const handleExitPreview = useCallback(() => {
+ dispatch(clearVersionPreview(uuid));
+ }, [dispatch, uuid]);
+
+ const handleOpenRelated = useCallback(
+ (record: ActivityRecord) => {
+ openRelatedEntity(record, addDangerToast);
+ },
+ [addDangerToast],
+ );
+
+ const handleRestore = useCallback(
+ (group: SaveGroup) => {
+ if (group.versionUuid) {
+ requestRestore({
+ versionUuid: group.versionUuid,
+ headline: groupHeadline(group),
+ issuedAt: group.issuedAt,
+ });
+ }
+ },
+ [requestRestore],
+ );
+
+ const handleOpenAsNew = useCallback(
+ (group: SaveGroup) => {
+ if (group.versionUuid) {
+ openAsNew({
+ versionUuid: group.versionUuid,
+ headline: groupHeadline(group),
+ issuedAt: group.issuedAt,
+ });
+ }
+ },
+ [openAsNew],
+ );
+
+ if (!isPanelOpen) {
+ return restoreModal;
+ }
+
+ return (
+ <>
+ <PanelHost>
+ <VersionHistoryPanel
+ entityType="chart"
+ canRestore={canRestore}
+ activity={activity}
+ include={include}
+ onIncludeChange={handleIncludeChange}
+ searchTerm={searchTerm}
+ onSearchChange={setSearchTerm}
+ previewedTransactionId={preview?.transactionId ?? null}
+ onClose={handleClose}
+ onPreview={handlePreview}
+ onExitPreview={handleExitPreview}
+ onRestore={handleRestore}
+ onOpenAsNew={handleOpenAsNew}
+ onOpenRelated={handleOpenRelated}
+ sessionEntries={sessionLog}
+ />
+ </PanelHost>
+ {restoreModal}
+ </>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/PreviewBanner.test.tsx b/superset-frontend/src/features/versionHistory/PreviewBanner.test.tsx
new file mode 100644
index 00000000000..714110bea0f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/PreviewBanner.test.tsx
@@ -0,0 +1,247 @@
+/**
+ * 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 fetchMock from 'fetch-mock';
+import {
+ createStore,
+ render,
+ screen,
+ userEvent,
+ waitFor,
+ within,
+} from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
+import type { VersionHistoryState } from './types';
+import type { VersionHistoryRootState } from './reducer';
+import PreviewBanner from './PreviewBanner';
+
+// the test-helper store is untyped; recover the slice type for assertions
+const versionHistoryOf = (store: { getState: () => unknown }) =>
+ (store.getState() as VersionHistoryRootState).versionHistory;
+
+const versionHistoryState = (
+ overrides: Partial<VersionHistoryState> = {},
+): VersionHistoryState => ({
+ isPanelOpen: true,
+ entityType: 'chart',
+ include: 'all',
+ isPreviewApplying: false,
+ preview: {
+ entityUuid: 'entity-uuid',
+ versionUuid: 'version-uuid',
+ transactionId: 7,
+ headline: 'Dec 5, 2025, 12:18 PM',
+ issuedAt: '2025-12-05T17:18:00',
+ },
+ sessionLog: [],
+ restoreCount: 0,
+ lastRestoredEntityUuid: null,
+ ...overrides,
+});
+
+const renderBanner = (
+ entityType: 'chart' | 'dashboard' = 'chart',
+ state: VersionHistoryState = versionHistoryState(),
+ canRestore = true,
+) => {
+ const store = createStore({ versionHistory: state }, reducerIndex);
+ render(<PreviewBanner entityType={entityType} canRestore={canRestore} />, {
+ store,
+ });
+ return store;
+};
+
+const RESTORE_ENDPOINT =
+ 'glob:*/api/v1/chart/entity-uuid/versions/version-uuid/restore';
+const ACTIVITY_ENDPOINT = 'glob:*/api/v1/chart/entity-uuid/activity/*';
+
+test('shows the preview date once when the headline is the save date', () => {
+ renderBanner();
+ const banner = screen.getByTestId('version-preview-banner');
+ expect(banner).toHaveTextContent(
+ 'Previewing historical version · Dec 5, 2025, 12:18 PM',
+ );
+ expect(banner).not.toHaveTextContent(
+ 'Dec 5, 2025, 12:18 PM · Dec 5, 2025, 12:18 PM',
+ );
+ expect(
+ screen.getByRole('button', { name: 'Restore this version' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Open as new chart' }),
+ ).toBeInTheDocument();
+});
+
+test('hides restore when the user cannot edit the entity', () => {
+ renderBanner('chart', versionHistoryState(), false);
+
+ expect(
+ screen.queryByRole('button', { name: 'Restore this version' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Open as new chart' }),
+ ).toBeInTheDocument();
+});
+
+test('omitting canRestore hides restore rather than offering it', () => {
+ // canRestore is a permission term, so the default has to fail closed: a call
+ // site that forgets it should lose the affordance, not gain an unguarded one.
+ const store = createStore(
+ { versionHistory: versionHistoryState() },
+ reducerIndex,
+ );
+ render(<PreviewBanner entityType="chart" />, { store });
+
+ expect(
+ screen.queryByRole('button', { name: 'Restore this version' }),
+ ).not.toBeInTheDocument();
+});
+
+test('shows headline and date when the headline carries its own label', () => {
+ renderBanner(
+ 'chart',
+ versionHistoryState({
+ preview: {
+ entityUuid: 'entity-uuid',
+ versionUuid: 'version-uuid',
+ transactionId: 7,
+ headline: 'Restored version',
+ issuedAt: '2025-12-05T17:18:00',
+ },
+ }),
+ );
+ expect(screen.getByTestId('version-preview-banner')).toHaveTextContent(
+ 'Previewing historical version · Restored version · Dec 5, 2025, 12:18 PM',
+ );
+});
+
+test('renders nothing when the preview belongs to another entity type', () => {
+ renderBanner('dashboard');
+ expect(
+ screen.queryByTestId('version-preview-banner'),
+ ).not.toBeInTheDocument();
+});
+
+test('renders nothing when no preview is active', () => {
+ renderBanner('chart', versionHistoryState({ preview: null }));
+ expect(
+ screen.queryByTestId('version-preview-banner'),
+ ).not.toBeInTheDocument();
+});
+
+test('close preview clears the preview from the store', async () => {
+ const store = renderBanner();
+ await userEvent.click(screen.getByRole('button', { name: 'Close preview' }));
+ expect(versionHistoryOf(store).preview).toBeNull();
+ expect(
+ screen.queryByTestId('version-preview-banner'),
+ ).not.toBeInTheDocument();
+});
+
+test('restoring from the banner confirms, posts, and exits the preview', async () => {
+ fetchMock.post(
+ RESTORE_ENDPOINT,
+ { result: { version_uuid: 'version-uuid' } },
+ { name: 'post-restore' },
+ );
+ // The restore flow probes the newest transaction before/after to
+ // detect no-op restores.
+ fetchMock.get(
+ ACTIVITY_ENDPOINT,
+ { result: [], count: 0 },
+ {
+ name: 'get-activity',
+ },
+ );
+ const store = renderBanner();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Restore this version' }),
+ );
+ const dialog = await screen.findByRole('dialog');
+ expect(within(dialog).getByText('Restore this version?')).toBeInTheDocument();
+ expect(within(dialog).getByText('Dec 5, 2025, 12:18 PM')).toBeInTheDocument();
+
+ await userEvent.click(
+ within(dialog).getByRole('button', { name: 'Restore this version' }),
+ );
+
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('post-restore')).toHaveLength(1),
+ );
+ await waitFor(() => {
+ expect(versionHistoryOf(store).restoreCount).toBe(1);
+ expect(versionHistoryOf(store).preview).toBeNull();
+ });
+ expect(
+ screen.queryByTestId('version-preview-banner'),
+ ).not.toBeInTheDocument();
+
+ fetchMock.removeRoute('post-restore');
+ fetchMock.removeRoute('get-activity');
+});
+
+test('a failed restore keeps the preview active', async () => {
+ fetchMock.post(RESTORE_ENDPOINT, 500, { name: 'post-restore-fail' });
+ fetchMock.get(
+ ACTIVITY_ENDPOINT,
+ { result: [], count: 0 },
+ {
+ name: 'get-activity-fail',
+ },
+ );
+ const store = renderBanner();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Restore this version' }),
+ );
+ const dialog = await screen.findByRole('dialog');
+ await userEvent.click(
+ within(dialog).getByRole('button', { name: 'Restore this version' }),
+ );
+
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('post-restore-fail')).toHaveLength(1),
+ );
+ await waitFor(() => expect(versionHistoryOf(store).restoreCount).toBe(0));
+ expect(versionHistoryOf(store).preview).not.toBeNull();
+
+ fetchMock.removeRoute('post-restore-fail');
+ fetchMock.removeRoute('get-activity-fail');
+});
+
+test('does not call the page historical while the snapshot is still loading', () => {
+ // Applying a snapshot takes several round trips; until they land the page
+ // under this banner is the live dashboard. Labelling it "Previewing
+ // historical version" would tell the user they are looking at the past
+ // when they are looking at the present -- and restore acts on what they
+ // believe is on screen.
+ renderBanner('chart', versionHistoryState({ isPreviewApplying: true }));
+ const banner = screen.getByTestId('version-preview-banner');
+
+ expect(banner).toHaveTextContent('Loading historical version');
+ expect(banner).not.toHaveTextContent('Previewing historical version');
+});
+
+test('withholds restore until the previewed version is actually on screen', () => {
+ renderBanner('chart', versionHistoryState({ isPreviewApplying: true }));
+
+ expect(
+ screen.queryByRole('button', { name: 'Restore this version' }),
+ ).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/features/versionHistory/PreviewBanner.tsx b/superset-frontend/src/features/versionHistory/PreviewBanner.tsx
new file mode 100644
index 00000000000..e2a43fc7409
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/PreviewBanner.tsx
@@ -0,0 +1,129 @@
+/**
+ * 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 { useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Alert } from '@apache-superset/core/components';
+import { Button } from '@superset-ui/core/components';
+import type { VersionedEntityType } from './types';
+import { clearVersionPreview, selectVersionHistory } from './reducer';
+import { useVersionActions } from './useVersionActions';
+import { formatVersionDateTime } from './display';
+
+const Actions = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+export interface PreviewBannerProps {
+ entityType: VersionedEntityType;
+ /**
+ * Whether to offer Restore. Defaults to false: this is a permission term,
+ * and a call site that forgets to pass it should lose the affordance rather
+ * than gain an unguarded one.
+ */
+ canRestore?: boolean;
+}
+
+export default function PreviewBanner({
+ entityType,
+ canRestore = false,
+}: PreviewBannerProps) {
+ const dispatch = useDispatch();
+ const {
+ entityType: activeEntityType,
+ preview,
+ isPreviewApplying: isApplying,
+ } = useSelector(selectVersionHistory);
+ const { requestRestore, openAsNew, isCreating, restoreModal } =
+ useVersionActions(entityType, preview?.entityUuid);
+
+ const handleClose = useCallback(() => {
+ dispatch(clearVersionPreview(preview?.entityUuid));
+ }, [dispatch, preview?.entityUuid]);
+
+ if (!preview || activeEntityType !== entityType) {
+ return null;
+ }
+
+ const target = {
+ versionUuid: preview.versionUuid,
+ headline: preview.headline,
+ issuedAt: preview.issuedAt,
+ };
+
+ // Chart save headlines are themselves the save datetime; avoid
+ // rendering the same timestamp twice.
+ const issuedAtLabel = formatVersionDateTime(preview.issuedAt);
+ // Applying a snapshot takes several round trips, and until they land the
+ // page below this banner is still the *live* dashboard. Saying "Previewing"
+ // then would label live data as historical -- the one reading a user must
+ // not be given, since restore acts on what they think they are looking at.
+ const message = isApplying
+ ? t('Loading historical version · %s', issuedAtLabel)
+ : preview.headline === issuedAtLabel
+ ? t('Previewing historical version · %s', issuedAtLabel)
+ : t(
+ 'Previewing historical version · %s · %s',
+ preview.headline,
+ issuedAtLabel,
+ );
+
+ return (
+ <>
+ <Alert
+ type="info"
+ closable={false}
+ data-test="version-preview-banner"
+ message={message}
+ action={
+ <Actions>
+ {canRestore && !isApplying && (
+ <Button
+ buttonSize="small"
+ buttonStyle="primary"
+ onClick={() => requestRestore(target)}
+ >
+ {t('Restore this version')}
+ </Button>
+ )}
+ <Button
+ buttonSize="small"
+ buttonStyle="secondary"
+ loading={isCreating}
+ onClick={() => openAsNew(target)}
+ >
+ {entityType === 'chart'
+ ? t('Open as new chart')
+ : t('Open as new dashboard')}
+ </Button>
+ <Button buttonSize="small" buttonStyle="link" onClick={handleClose}>
+ {t('Close preview')}
+ </Button>
+ </Actions>
+ }
+ />
+ {restoreModal}
+ </>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/RelatedUpdateRow.tsx b/superset-frontend/src/features/versionHistory/RelatedUpdateRow.tsx
new file mode 100644
index 00000000000..e5417cfaea3
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/RelatedUpdateRow.tsx
@@ -0,0 +1,183 @@
+/**
+ * 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 { ComponentType } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Icons, Tooltip } from '@superset-ui/core/components';
+import type { IconType } from '@superset-ui/core/components/Icons/types';
+import type { ActivityEntityKind, ActivityRecord } from './types';
+import {
+ entityDisplayName,
+ formatAuthor,
+ formatVersionDateTimeShort,
+ relatedHeadline,
+ relatedRollupHeadline,
+} from './display';
+
+const ENTITY_ICON: Record<ActivityEntityKind, ComponentType<IconType>> = {
+ chart: Icons.BarChartOutlined,
+ dashboard: Icons.DashboardOutlined,
+ dataset: Icons.TableOutlined,
+};
+
+const Row = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: flex-start;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
+ border-bottom: 1px solid ${theme.colorBorderSecondary};
+ `}
+`;
+
+// The icon centers within the first text line (one line-height tall)
+// so it tracks the headline, not the middle of the two-line block.
+const IconWrapper = styled.span`
+ ${({ theme }) => `
+ color: ${theme.colorTextSecondary};
+ display: flex;
+ align-items: center;
+ height: ${theme.fontSize * theme.lineHeight}px;
+ `}
+`;
+
+const Content = styled.div`
+ ${({ theme }) => `
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+const Headline = styled.div`
+ ${({ theme }) => `
+ font-size: ${theme.fontSize}px;
+ line-height: ${theme.lineHeight};
+ color: ${theme.colorText};
+ overflow-wrap: anywhere;
+ `}
+`;
+
+const NameLink = styled.button`
+ ${({ theme }) => `
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ font: inherit;
+ color: ${theme.colorPrimary};
+ &:hover {
+ text-decoration: underline;
+ }
+ `}
+`;
+
+const Meta = styled.div`
+ ${({ theme }) => `
+ color: ${theme.colorTextTertiary};
+ font-size: ${theme.fontSizeSM}px;
+ line-height: ${theme.lineHeightSM};
+ `}
+`;
+
+export interface RelatedUpdateRowProps {
+ record: ActivityRecord;
+ /** Entity names when several same-kind entities rolled into this row. */
+ rollupEntityNames?: string[];
+ onOpen?: (record: ActivityRecord) => void;
+}
+
+export default function RelatedUpdateRow({
+ record,
+ rollupEntityNames,
+ onOpen,
+}: RelatedUpdateRowProps) {
+ const Icon = ENTITY_ICON[record.entity_kind] ?? Icons.FileOutlined;
+
+ if (rollupEntityNames && rollupEntityNames.length > 1) {
+ // No single target to link to; a tooltip lists the rolled-up names.
+ return (
+ <Row data-test="version-history-related-row">
+ <IconWrapper>
+ <Icon iconSize="l" />
+ </IconWrapper>
+ <Content>
+ <Tooltip
+ title={rollupEntityNames.map((name, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ <div key={index}>{name || t('Untitled')}</div>
+ ))}
+ >
+ <Headline>
+ {relatedRollupHeadline(
+ record.entity_kind,
+ rollupEntityNames.length,
+ )}
+ </Headline>
+ </Tooltip>
+ <Meta>
+ {formatAuthor(record.changed_by)} ·{' '}
+ {formatVersionDateTimeShort(record.issued_at)}
+ </Meta>
+ </Content>
+ </Row>
+ );
+ }
+
+ const headline = relatedHeadline(record);
+ const entityName = entityDisplayName(record);
+ const linkable = !record.entity_deleted && Boolean(onOpen);
+ // Both the server summary and the impact-aware phrasing end with the
+ // entity name; split it out so the name can render as a link. Records
+ // without a name can't be split (the empty string matches anywhere).
+ const nameIndex =
+ linkable && record.entity_name
+ ? headline.lastIndexOf(record.entity_name)
+ : -1;
+
+ return (
+ <Row data-test="version-history-related-row">
+ <IconWrapper>
+ <Icon iconSize="l" />
+ </IconWrapper>
+ <Content>
+ <Headline>
+ {nameIndex >= 0 ? (
+ <>
+ {headline.slice(0, nameIndex)}
+ <NameLink type="button" onClick={() => onOpen?.(record)}>
+ {entityName}
+ </NameLink>
+ {headline.slice(nameIndex + record.entity_name.length)}
+ </>
+ ) : (
+ headline
+ )}
+ {record.entity_deleted && ` (${t('deleted')})`}
+ </Headline>
+ <Meta>
+ {formatAuthor(record.changed_by)} ·{' '}
+ {formatVersionDateTimeShort(record.issued_at)}
+ </Meta>
+ </Content>
+ </Row>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/RestoreConfirmModal.tsx b/superset-frontend/src/features/versionHistory/RestoreConfirmModal.tsx
new file mode 100644
index 00000000000..562a3e7015f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/RestoreConfirmModal.tsx
@@ -0,0 +1,72 @@
+/**
+ * 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 { t } from '@apache-superset/core/translation';
+import { Modal } from '@superset-ui/core/components';
+import type { VersionedEntityType } from './types';
+import type { VersionActionTarget } from './useVersionActions';
+import { formatVersionDateTime } from './display';
+
+export interface RestoreConfirmModalProps {
+ entityType: VersionedEntityType;
+ target: VersionActionTarget | null;
+ isRestoring: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+export default function RestoreConfirmModal({
+ entityType,
+ target,
+ isRestoring,
+ onCancel,
+ onConfirm,
+}: RestoreConfirmModalProps) {
+ if (!target) {
+ return null;
+ }
+ const dateLabel = formatVersionDateTime(target.issuedAt);
+ return (
+ <Modal
+ show
+ onHide={onCancel}
+ title={t('Restore this version?')}
+ width="480px"
+ responsive={false}
+ primaryButtonName={t('Restore this version')}
+ primaryButtonLoading={isRestoring}
+ onHandledPrimaryAction={onConfirm}
+ name="restore-version-modal"
+ >
+ <p>
+ {entityType === 'chart'
+ ? t('This chart will be restored to the following version:')
+ : t('This dashboard will be restored to the following version:')}
+ </p>
+ <p data-test="restore-version-name">
+ <strong>{target.headline}</strong>
+ {dateLabel !== target.headline && <> · {dateLabel}</>}
+ </p>
+ <p>
+ {t(
+ 'Restoring creates a new version. Your existing version history will not be lost.',
+ )}
+ </p>
+ </Modal>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx b/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
new file mode 100644
index 00000000000..f3d4e4b0f8b
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
@@ -0,0 +1,386 @@
+/**
+ * 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 { KeyboardEvent, useState } from 'react';
+import { t, tn } from '@apache-superset/core/translation';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { Button, Dropdown, Icons, Tag } from '@superset-ui/core/components';
+import type { SaveGroup, VersionedEntityType } from './types';
+import {
+ formatAuthor,
+ formatVersionDateTimeShort,
+ groupHeadline,
+} from './display';
+import ActionRow from './ActionRow';
+
+/**
+ * The first chart save serializes the full form_data and can fan out
+ * into dozens of records; cap the initially visible rows per group.
+ */
+// TODO(version-history): backend workaround — remove when upstream stops
+// exploding the full form_data into per-field records on the first save.
+const VISIBLE_RECORD_LIMIT = 10;
+
+// The highlighted container gains inner padding but extends outward by
+// the same amount (negative margin) so its text stays column-aligned
+// with non-highlighted neighbors.
+const Container = styled.div<{ isPreviewed: boolean }>`
+ ${({ theme, isPreviewed }) => {
+ const inset = isPreviewed ? theme.sizeUnit * 3 : 0;
+ return `
+ border-bottom: 1px solid ${theme.colorBorderSecondary};
+ background-color: ${isPreviewed ? theme.colorPrimaryBg : 'transparent'};
+ border-radius: ${isPreviewed ? theme.borderRadius : 0}px;
+ padding: ${theme.sizeUnit * 2}px ${inset}px ${theme.sizeUnit * 4}px;
+ margin: 0 ${-inset}px;
+ `;
+ }}
+`;
+
+const Header = styled.div<{ hasRecords: boolean }>`
+ ${({ theme, hasRecords }) => `
+ display: flex;
+ align-items: flex-start;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit * 3}px 0;
+ cursor: ${hasRecords ? 'pointer' : 'default'};
+ `}
+`;
+
+const HeaderText = styled.div`
+ ${({ theme }) => `
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+const HeadlineRow = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ min-width: 0;
+ `}
+`;
+
+const Headline = styled.div`
+ ${({ theme }) => `
+ font-size: ${theme.fontSize}px;
+ line-height: ${theme.lineHeight};
+ color: ${theme.colorText};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ `}
+`;
+
+const Meta = styled.div`
+ ${({ theme }) => `
+ color: ${theme.colorTextTertiary};
+ font-size: ${theme.fontSizeSM}px;
+ line-height: ${theme.lineHeightSM};
+ `}
+`;
+
+// Icons and trailing controls center within the first text line (one
+// line-height tall) so they track the headline, not the middle of a
+// two-line header block.
+const IconWrapper = styled.span`
+ ${({ theme }) => `
+ color: ${theme.colorTextSecondary};
+ display: flex;
+ align-items: center;
+ height: ${theme.fontSize * theme.lineHeight}px;
+ `}
+`;
+
+const ChevronWrapper = styled.span`
+ ${({ theme }) => `
+ color: ${theme.colorTextTertiary};
+ display: flex;
+ align-items: center;
+ height: ${theme.fontSize * theme.lineHeight}px;
+ `}
+`;
+
+const KebabSlot = styled.span`
+ ${({ theme }) => `
+ display: flex;
+ align-items: center;
+ height: ${theme.fontSize * theme.lineHeight}px;
+ `}
+`;
+
+// Icon-only trigger: neutral icon color instead of the link-button blue.
+const KebabButton = styled(Button)`
+ ${({ theme }) => `
+ && {
+ color: ${theme.colorTextTertiary};
+ }
+ &&:hover,
+ &&:focus {
+ color: ${theme.colorText};
+ }
+ `}
+`;
+
+const ExpanderRow = styled.div`
+ ${({ theme }) => `
+ padding-left: ${theme.sizeUnit * 8}px;
+ `}
+`;
+
+export interface SaveGroupItemProps {
+ entityType: VersionedEntityType;
+ group: SaveGroup;
+ /** The newest self save: it IS the live state, not a historical one. */
+ isCurrent: boolean;
+ canRestore: boolean;
+ isPreviewed: boolean;
+ onPreview: (group: SaveGroup) => void;
+ /** Leave an active historical preview (back to the live version). */
+ onExitPreview?: () => void;
+ onRestore: (group: SaveGroup) => void;
+ onOpenAsNew: (group: SaveGroup) => void;
+}
+
+function GroupKebab({
+ entityType,
+ group,
+ isCurrent,
+ canRestore,
+ onRestore,
+ onOpenAsNew,
+}: Pick<
+ SaveGroupItemProps,
+ | 'entityType'
+ | 'group'
+ | 'isCurrent'
+ | 'canRestore'
+ | 'onRestore'
+ | 'onOpenAsNew'
+>) {
+ const theme = useTheme();
+ if (group.versionUuid == null) {
+ // Both actions name a specific version; a group the server returned
+ // without one has nothing for them to act on, and the container
+ // handlers would silently no-op. No kebab beats a dead menu.
+ return null;
+ }
+ const itemStyle = {
+ height: theme.controlHeightLG,
+ paddingLeft: theme.sizeUnit * 6,
+ paddingRight: theme.sizeUnit * 6,
+ display: 'flex',
+ alignItems: 'center',
+ };
+ const menuItems = [
+ // Restoring the live version is a no-op; offer it only on history.
+ ...(isCurrent || !canRestore
+ ? []
+ : [
+ {
+ key: 'restore',
+ label: t('Restore this version'),
+ style: itemStyle,
+ onClick: ({
+ domEvent,
+ }: {
+ domEvent: { stopPropagation: () => void };
+ }) => {
+ domEvent.stopPropagation();
+ onRestore(group);
+ },
+ },
+ ]),
+ {
+ 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(group);
+ },
+ },
+ ];
+ return (
+ <KebabSlot>
+ <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>
+ </KebabSlot>
+ );
+}
+
+export default function SaveGroupItem({
+ entityType,
+ group,
+ isCurrent,
+ canRestore,
+ isPreviewed,
+ onPreview,
+ onExitPreview,
+ onRestore,
+ onOpenAsNew,
+}: SaveGroupItemProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [showAll, setShowAll] = useState(false);
+ const headline = groupHeadline(group);
+ const meta = `${formatAuthor(group.changedBy)} · ${formatVersionDateTimeShort(
+ group.issuedAt,
+ )}`;
+
+ // The current version is the live state: there is nothing to preview,
+ // and selecting it while previewing an older version exits the preview.
+ const previewIntent = () => {
+ if (isCurrent) {
+ onExitPreview?.();
+ } else {
+ onPreview(group);
+ }
+ };
+
+ const activate =
+ (handler: () => void) => (event: KeyboardEvent<HTMLDivElement>) => {
+ // Keydowns from the nested kebab <button> bubble up here; only
+ // activate when the header itself has focus, so Enter/Space on the
+ // kebab opens its menu instead of toggling the group.
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handler();
+ }
+ };
+
+ const currentTag = isCurrent ? <Tag>{t('Current')}</Tag> : null;
+
+ const hasRecords = group.records.length > 0;
+ const toggleExpanded = () => {
+ if (hasRecords) {
+ setExpanded(value => !value);
+ }
+ };
+
+ const visibleRecords = showAll
+ ? group.records
+ : group.records.slice(0, VISIBLE_RECORD_LIMIT);
+ const hiddenCount = group.records.length - visibleRecords.length;
+
+ return (
+ <Container isPreviewed={isPreviewed} data-test="version-history-save-group">
+ <Header
+ // A group with no records has nothing to expand — render it as a
+ // plain container instead of a focusable no-op button.
+ // eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- header contains a nested kebab <button>, which a real <button> cannot
+ role={hasRecords ? 'button' : undefined}
+ tabIndex={hasRecords ? 0 : undefined}
+ onClick={hasRecords ? toggleExpanded : undefined}
+ onKeyDown={hasRecords ? activate(toggleExpanded) : undefined}
+ aria-expanded={hasRecords ? expanded : undefined}
+ aria-label={hasRecords ? headline : undefined}
+ hasRecords={hasRecords}
+ >
+ <IconWrapper>
+ <Icons.CalendarOutlined iconSize="l" />
+ </IconWrapper>
+ <HeaderText>
+ <HeadlineRow>
+ <Headline title={headline}>{headline}</Headline>
+ {currentTag}
+ </HeadlineRow>
+ <Meta>{meta}</Meta>
+ </HeaderText>
+ <GroupKebab
+ entityType={entityType}
+ group={group}
+ isCurrent={isCurrent}
+ canRestore={canRestore}
+ onRestore={onRestore}
+ onOpenAsNew={onOpenAsNew}
+ />
+ {hasRecords && (
+ <ChevronWrapper>
+ {expanded ? (
+ <Icons.UpOutlined iconSize="m" />
+ ) : (
+ <Icons.DownOutlined iconSize="m" />
+ )}
+ </ChevronWrapper>
+ )}
+ </Header>
+ {expanded && (
+ <>
+ {visibleRecords.map((record, index) => (
+ <ActionRow
+ // kind/operation/path alone can repeat within one save — the
+ // same collision grouping's recordKey guards against with the
+ // record values; the index breaks any remaining ties here.
+ key={`${record.kind}-${record.operation}-${JSON.stringify(
+ record.path,
+ )}-${index}`}
+ entityType={entityType}
+ record={record}
+ showRestore={canRestore && !isCurrent}
+ showActions={group.versionUuid != null}
+ isPreviewed={isPreviewed}
+ isLast={index === visibleRecords.length - 1 && hiddenCount === 0}
+ onPreview={previewIntent}
+ onRestore={() => onRestore(group)}
+ onOpenAsNew={() => onOpenAsNew(group)}
+ />
+ ))}
+ {hiddenCount > 0 && (
+ <ExpanderRow>
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ onClick={() => setShowAll(true)}
+ >
+ {tn(
+ 'Show %s more change',
+ 'Show %s more changes',
+ hiddenCount,
+ hiddenCount,
+ )}
+ </Button>
+ </ExpanderRow>
+ )}
+ </>
+ )}
+ </Container>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
new file mode 100644
index 00000000000..36c57e2419c
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
@@ -0,0 +1,498 @@
+/**
+ * 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 {
+ render,
+ screen,
+ selectOption,
+ userEvent,
+} from 'spec/helpers/testing-library';
+import type {
+ ActivityRecord,
+ SaveGroup,
+ TimelineEntry,
+ VersionedEntityType,
+} from './types';
+import { buildTimeline } from './grouping';
+import type { UseVersionActivityResult } from './useVersionActivity';
+import VersionHistoryPanel, {
+ VersionHistoryPanelProps,
+} from './VersionHistoryPanel';
+
+const record = (overrides: Partial<ActivityRecord> = {}): ActivityRecord => ({
+ version_uuid: 'v-1',
+ entity_kind: 'chart',
+ entity_uuid: 'e-1',
+ entity_name: 'My chart',
+ entity_deleted: false,
+ entity_deletion_state: null,
+ source: 'self',
+ transaction_id: 10,
+ action_kind: null,
+ issued_at: '2025-12-05T17:18:00',
+ changed_by: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+ kind: 'metric',
+ operation: 'add',
+ path: ['params', 'metrics', 'Revenue'],
+ from_value: null,
+ to_value: { label: 'Revenue' },
+ summary: '',
+ impact: null,
+ ...overrides,
+});
+
+const group = (overrides: Partial<SaveGroup> = {}): SaveGroup => ({
+ type: 'group',
+ transactionId: 10,
+ versionUuid: 'v-1',
+ issuedAt: '2025-12-05T17:18:00',
+ changedBy: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+ actionKind: null,
+ records: [record()],
+ ...overrides,
+});
+
+const activity = (
+ timeline: TimelineEntry[],
+ overrides: Partial<UseVersionActivityResult> = {},
+): UseVersionActivityResult => ({
+ records: [],
+ timeline,
+ // Mirrors the hook's default: the newest group of an unfiltered timeline.
+ newestGroup:
+ (timeline.find(entry => entry.type === 'group') as SaveGroup | undefined) ??
+ null,
+ currentVersionStatus: timeline.some(entry => entry.type === 'group')
+ ? 'known'
+ : 'empty',
+ count: timeline.length,
+ isLoading: false,
+ error: null,
+ hasMore: false,
+ truncated: false,
+ loadMore: jest.fn(),
+ refresh: jest.fn(),
+ ...overrides,
+});
+
+const defaultProps = (
+ timeline: TimelineEntry[],
+ entityType: VersionedEntityType = 'chart',
+): VersionHistoryPanelProps => ({
+ entityType,
+ activity: activity(timeline),
+ include: 'all',
+ // Explicit rather than relying on the default, which fails closed.
+ canRestore: true,
+ onIncludeChange: jest.fn(),
+ searchTerm: '',
+ onSearchChange: jest.fn(),
+ previewedTransactionId: null,
+ onClose: jest.fn(),
+ onPreview: jest.fn(),
+ onExitPreview: jest.fn(),
+ onRestore: jest.fn(),
+ onOpenAsNew: jest.fn(),
+});
+
+/** A pair of dashboard saves: the newest (current) and an older one. */
+const dashboardPair = () => {
+ const older = group({
+ records: [record({ entity_kind: 'dashboard', kind: 'chart' })],
+ });
+ const newest = group({
+ transactionId: 20,
+ versionUuid: 'v-2',
+ issuedAt: '2025-12-08T17:18:00',
+ records: [
+ record({
+ entity_kind: 'dashboard',
+ kind: 'chart',
+ transaction_id: 20,
+ issued_at: '2025-12-08T17:18:00',
+ }),
+ ],
+ });
+ return { newest, older };
+};
+
+test('chart groups expand to show granular action rows', async () => {
+ const props = defaultProps([group()]);
+ render(<VersionHistoryPanel {...props} />);
+
+ const header = screen.getByRole('button', {
+ name: 'Dec 5, 2025, 12:18 PM',
+ });
+ expect(header).toHaveAttribute('aria-expanded', 'false');
+ expect(
+ screen.queryByText("Applied 'Revenue' metric"),
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(header);
+ expect(header).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText("Applied 'Revenue' metric")).toBeInTheDocument();
+});
+
+test('long groups cap visible rows behind a show-more expander', async () => {
+ const records = Array.from({ length: 12 }, (_, i) =>
+ record({
+ kind: 'field',
+ operation: 'edit',
+ path: ['params', `field_${i}`],
+ }),
+ );
+ const props = defaultProps([group({ records })]);
+ render(<VersionHistoryPanel {...props} />);
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Dec 5, 2025, 12:18 PM' }),
+ );
+ expect(screen.getAllByTestId('version-history-action-row')).toHaveLength(10);
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Show 2 more changes' }),
+ );
+ expect(screen.getAllByTestId('version-history-action-row')).toHaveLength(12);
+});
+
+// Dashboards share the chart container model (sc-107283 guide, 2026-06-12):
+// the save's date/time heads the group and expands to descriptive per-change
+// rows — no compact "Filters"/"Edit mode" category headline.
+test('dashboard groups head with the save date and expand to descriptive rows', async () => {
+ const props = defaultProps(
+ [
+ group({
+ records: [
+ record({
+ entity_kind: 'dashboard',
+ kind: 'filter',
+ path: ['json_metadata', 'native_filter_configuration', 'NATIVE_1'],
+ }),
+ ],
+ }),
+ ],
+ 'dashboard',
+ );
+ render(<VersionHistoryPanel {...props} />);
+
+ const header = screen.getByRole('button', { name: 'Dec 5, 2025, 12:18 PM' });
+ expect(header).toBeInTheDocument();
+ expect(
+ screen.queryByText("Added filter on 'Revenue'"),
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(header);
+ expect(screen.getByText("Added filter on 'Revenue'")).toBeInTheDocument();
+});
+
+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.
+ const versionless = group({ transactionId: 3, versionUuid: null });
+ const props = defaultProps([versionless], 'dashboard');
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(
+ screen.queryByRole('button', { name: 'More actions' }),
+ ).not.toBeInTheDocument();
+
+ // Expanding the group must not surface per-row kebabs either.
+ await userEvent.click(screen.getByText('Ada Lovelace', { exact: false }));
+ expect(
+ screen.queryByRole('button', { name: 'More actions' }),
+ ).not.toBeInTheDocument();
+});
+
+test('the group kebab restores and forks the version', async () => {
+ const { newest, older } = dashboardPair();
+ const props = defaultProps([newest, older], 'dashboard');
+ render(<VersionHistoryPanel {...props} />);
+
+ // The newest save is the live one; act on the older save's kebab.
+ const olderKebab = () =>
+ screen.getAllByRole('button', { name: 'More actions' })[1];
+ await userEvent.click(olderKebab());
+ await userEvent.click(await screen.findByText('Restore this version'));
+ expect(props.onRestore).toHaveBeenCalledWith(older);
+
+ await userEvent.click(olderKebab());
+ await userEvent.click(await screen.findByText('Open as new dashboard'));
+ expect(props.onOpenAsNew).toHaveBeenCalledWith(older);
+});
+
+test('previewing a change row in an older dashboard group calls onPreview', async () => {
+ const { newest, older } = dashboardPair();
+ const props = defaultProps([newest, older], 'dashboard');
+ render(<VersionHistoryPanel {...props} />);
+
+ // Expand the older save (the newest is the live one), then click one of its
+ // change rows: the row, not the header, drives the preview.
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Dec 5, 2025, 12:18 PM' }),
+ );
+ await userEvent.click(screen.getByTestId('version-history-action-row'));
+ expect(props.onPreview).toHaveBeenCalledWith(older);
+});
+
+test('an active search never mis-tags the newest match as Current', () => {
+ // The search filtered out the true newest save; the visible newest match
+ // is an older version and must not be treated as Current.
+ const trueNewest = group({ transactionId: 99, versionUuid: 'v-99' });
+ const olderMatch = group({ transactionId: 5, versionUuid: 'v-5' });
+ const props = defaultProps([olderMatch]);
+ props.searchTerm = 'rename';
+ props.activity = activity([olderMatch], { newestGroup: trueNewest });
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(screen.queryByText('Current')).not.toBeInTheDocument();
+});
+
+test('the newest save shows a Current tag and older saves do not', () => {
+ const { newest, older } = dashboardPair();
+ const props = defaultProps([newest, older], 'dashboard');
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(screen.getAllByText('Current')).toHaveLength(1);
+ const groups = screen.getAllByTestId('version-history-save-group');
+ expect(groups[0]).toHaveTextContent('Current');
+ expect(groups[1]).not.toHaveTextContent('Current');
+});
+
+test('previewing a change row in the current version exits an active preview', async () => {
+ const { newest, older } = dashboardPair();
+ const props = defaultProps([newest, older], 'dashboard');
+ props.previewedTransactionId = older.transactionId;
+ render(<VersionHistoryPanel {...props} />);
+
+ // Expand the current (newest) save and click a change row: the live version
+ // has nothing to preview, so it exits the active preview instead.
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Dec 8, 2025, 12:18 PM' }),
+ );
+ await userEvent.click(screen.getByTestId('version-history-action-row'));
+ expect(props.onPreview).not.toHaveBeenCalled();
+ expect(props.onExitPreview).toHaveBeenCalled();
+});
+
+test('the current version kebab omits restore but keeps open as new', async () => {
+ const { newest, older } = dashboardPair();
+ const props = defaultProps([newest, older], 'dashboard');
+ render(<VersionHistoryPanel {...props} />);
+
+ await userEvent.click(
+ screen.getAllByRole('button', { name: 'More actions' })[0],
+ );
+ expect(await screen.findByText('Open as new dashboard')).toBeInTheDocument();
+ expect(screen.queryByText('Restore this version')).not.toBeInTheDocument();
+});
+
+test('an unavailable current-version probe explains and withholds restore', async () => {
+ const { newest, older } = dashboardPair();
+ const props = defaultProps([newest, older], 'dashboard');
+ props.activity = activity([newest, older], {
+ newestGroup: null,
+ currentVersionStatus: 'unavailable',
+ });
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(
+ screen.getByText(
+ 'The current version could not be identified. Restore is temporarily unavailable.',
+ ),
+ ).toBeInTheDocument();
+
+ await userEvent.click(
+ screen.getAllByRole('button', { name: 'More actions' })[1],
+ );
+ expect(await screen.findByText('Open as new dashboard')).toBeInTheDocument();
+ expect(screen.queryByText('Restore this version')).not.toBeInTheDocument();
+});
+
+test('a loading current-version probe withholds restore without an error', async () => {
+ const { newest, older } = dashboardPair();
+ const props = defaultProps([newest, older], 'dashboard');
+ props.activity = activity([newest, older], {
+ newestGroup: null,
+ currentVersionStatus: 'loading',
+ });
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(
+ screen.queryByText(/current version could not be identified/i),
+ ).not.toBeInTheDocument();
+ await userEvent.click(
+ screen.getAllByRole('button', { name: 'More actions' })[1],
+ );
+ expect(screen.queryByText('Restore this version')).not.toBeInTheDocument();
+});
+
+// Search is server-side: the panel forwards the term to the container, which
+// debounces it and refetches; the panel renders whatever matches come back
+// (sc-107283 guide, 2026-06-12).
+test('typing in the search box forwards the term to the container', async () => {
+ const props = defaultProps([group()]);
+ render(<VersionHistoryPanel {...props} />);
+
+ await userEvent.type(
+ screen.getByRole('textbox', { name: 'Search actions' }),
+ 'z',
+ );
+ expect(props.onSearchChange).toHaveBeenCalledWith('z');
+});
+
+test('an empty result while searching reports no matches', () => {
+ const props = defaultProps([]);
+ props.searchTerm = 'zzz';
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(screen.getByText('No actions found')).toBeInTheDocument();
+ expect(screen.getByText('Try a different search term')).toBeInTheDocument();
+});
+
+test('the include filter calls back with the selected scope', async () => {
+ const props = defaultProps([group()]);
+ render(<VersionHistoryPanel {...props} />);
+
+ await selectOption('Related items only', 'Filter version history');
+ expect(props.onIncludeChange).toHaveBeenCalledWith('related');
+});
+
+test('an empty timeline shows the no-history empty state', () => {
+ const props = defaultProps([]);
+ render(<VersionHistoryPanel {...props} />);
+ expect(screen.getByText('No history yet')).toBeInTheDocument();
+ expect(
+ screen.getByText('Saved changes will appear here'),
+ ).toBeInTheDocument();
+});
+
+const relatedEntry = (overrides: Partial<ActivityRecord>): TimelineEntry => {
+ const relatedRecord = record({ source: 'related', ...overrides });
+ return { type: 'related', record: relatedRecord, records: [relatedRecord] };
+};
+
+test('related rows link to the entity unless it was deleted', () => {
+ const props = defaultProps([
+ relatedEntry({
+ entity_kind: 'dataset',
+ entity_name: 'Sales',
+ entity_uuid: 'ds-1',
+ transaction_id: 11,
+ summary: 'Dataset updated: Sales',
+ }),
+ relatedEntry({
+ entity_kind: 'chart',
+ entity_name: 'Trend',
+ entity_uuid: 'c-2',
+ entity_deleted: true,
+ transaction_id: 12,
+ summary: 'Chart updated: Trend',
+ }),
+ ]);
+ props.onOpenRelated = jest.fn();
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(screen.getByRole('button', { name: 'Sales' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Trend' }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText(/\(deleted\)/)).toBeInTheDocument();
+});
+
+test('same-transaction related cascades roll up into one pluralized row', async () => {
+ const timeline = buildTimeline(
+ Array.from({ length: 10 }, (_, i) =>
+ record({
+ source: 'related',
+ entity_kind: 'chart',
+ entity_uuid: `c-${i}`,
+ entity_name: `Chart ${i}`,
+ transaction_id: 30,
+ summary: `Chart updated: Chart ${i}`,
+ }),
+ ),
+ );
+ const props = defaultProps(timeline);
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(screen.getAllByTestId('version-history-related-row')).toHaveLength(1);
+ const headline = screen.getByText('10 charts updated');
+
+ // The rolled-up entity names are listed in a tooltip.
+ await userEvent.hover(headline);
+ expect(await screen.findByText('Chart 7')).toBeInTheDocument();
+});
+
+test('a clipped history says so once the last page is reached', () => {
+ const props = defaultProps([group()]);
+ props.activity = activity([group()], { hasMore: false, truncated: true });
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(
+ screen.getByText(/Older history exists but is beyond/),
+ ).toBeInTheDocument();
+});
+
+test('a complete history shows no truncation notice', () => {
+ const props = defaultProps([group()]);
+ render(<VersionHistoryPanel {...props} />);
+
+ expect(
+ screen.queryByText(/Older history exists but is beyond/),
+ ).not.toBeInTheDocument();
+});
+
+test('load more requests the next page', async () => {
+ const loadMore = jest.fn();
+ const props = defaultProps([group()]);
+ props.activity = activity([group()], { hasMore: true, loadMore });
+ render(<VersionHistoryPanel {...props} />);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
+ expect(loadMore).toHaveBeenCalled();
+});
+
+test('a restore at the head of the timeline surfaces a notice', async () => {
+ const props = defaultProps([
+ group({
+ transactionId: 20,
+ actionKind: 'restore',
+ issuedAt: '2025-12-08T17:18:00',
+ }),
+ group(),
+ ]);
+ render(<VersionHistoryPanel {...props} />);
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Current version' }),
+ );
+ expect(
+ screen.getByText('Restored version · Dec 8, 2025, 12:18 PM'),
+ ).toBeInTheDocument();
+});
+
+test('the close button dismisses the panel', async () => {
+ const props = defaultProps([group()]);
+ render(<VersionHistoryPanel {...props} />);
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Close version history' }),
+ );
+ expect(props.onClose).toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx
new file mode 100644
index 00000000000..fa638bdfe65
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx
@@ -0,0 +1,343 @@
+/**
+ * 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 { useMemo } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Alert } from '@apache-superset/core/components';
+import {
+ Button,
+ EmptyState,
+ Icons,
+ Input,
+ Select,
+ Skeleton,
+} from '@superset-ui/core/components';
+import type {
+ ActivityInclude,
+ ActivityRecord,
+ SaveGroup,
+ SessionLogEntry,
+ VersionedEntityType,
+} from './types';
+import type { UseVersionActivityResult } from './useVersionActivity';
+import { relatedEntryKey } from './grouping';
+import { formatVersionDateTime } from './display';
+import SaveGroupItem from './SaveGroupItem';
+import RelatedUpdateRow from './RelatedUpdateRow';
+import CurrentVersionSection from './CurrentVersionSection';
+
+const VERSION_HISTORY_PANEL_WIDTH = 320;
+
+const Panel = styled.aside`
+ ${({ theme }) => `
+ width: ${VERSION_HISTORY_PANEL_WIDTH}px;
+ min-width: ${VERSION_HISTORY_PANEL_WIDTH}px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ border-left: 1px solid ${theme.colorSplit};
+ background-color: ${theme.colorBgContainer};
+ `}
+`;
+
+const PanelHeader = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px;
+ border-bottom: 1px solid ${theme.colorSplit};
+ `}
+`;
+
+const PanelTitle = styled.span`
+ ${({ theme }) => `
+ font-size: ${theme.fontSize}px;
+ line-height: ${theme.lineHeight};
+ color: ${theme.colorText};
+ `}
+`;
+
+const Controls = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px;
+ > * {
+ flex: 1 1 0;
+ min-width: 0;
+ }
+ `}
+`;
+
+const Body = styled.div`
+ ${({ theme }) => `
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 ${theme.sizeUnit * 4}px;
+ `}
+`;
+
+// Icon-only trigger: neutral icon color instead of the link-button blue.
+const CloseButton = styled(Button)`
+ ${({ theme }) => `
+ && {
+ color: ${theme.colorText};
+ }
+ &&:hover,
+ &&:focus {
+ color: ${theme.colorTextSecondary};
+ }
+ `}
+`;
+
+const Footer = styled.div`
+ ${({ theme }) => `
+ padding: ${theme.sizeUnit * 2}px;
+ text-align: center;
+ `}
+`;
+
+const TruncationNotice = styled.div`
+ ${({ theme }) => `
+ padding: ${theme.sizeUnit * 2}px;
+ text-align: center;
+ color: ${theme.colorTextTertiary};
+ font-size: ${theme.fontSizeSM}px;
+ `}
+`;
+
+const PaddedContent = styled.div`
+ ${({ theme }) => `
+ padding: ${theme.sizeUnit * 4}px;
+ `}
+`;
+
+export interface VersionHistoryPanelProps {
+ entityType: VersionedEntityType;
+ activity: UseVersionActivityResult;
+ include: ActivityInclude;
+ /**
+ * Whether to offer Restore. Defaults to false: this is a permission term,
+ * and a call site that forgets to pass it should lose the affordance rather
+ * than gain an unguarded one.
+ */
+ canRestore?: boolean;
+ onIncludeChange: (include: ActivityInclude) => void;
+ /**
+ * Free-text search is controlled by the container, which debounces it
+ * and feeds it to the activity hook so the server filters the full
+ * history (not just the loaded pages). The panel renders the matches
+ * verbatim.
+ */
+ searchTerm: string;
+ onSearchChange: (value: string) => void;
+ previewedTransactionId: number | null;
+ onClose: () => void;
+ onPreview: (group: SaveGroup) => void;
+ /** Leave an active historical preview (back to the live version). */
+ onExitPreview?: () => void;
+ onRestore: (group: SaveGroup) => void;
+ onOpenAsNew: (group: SaveGroup) => void;
+ onOpenRelated?: (record: ActivityRecord) => void;
+ sessionEntries?: SessionLogEntry[];
+}
+
+export default function VersionHistoryPanel({
+ entityType,
+ activity,
+ include,
+ canRestore = false,
+ onIncludeChange,
+ searchTerm,
+ onSearchChange,
+ previewedTransactionId,
+ onClose,
+ onPreview,
+ onExitPreview,
+ onRestore,
+ onOpenAsNew,
+ onOpenRelated,
+ sessionEntries = [],
+}: VersionHistoryPanelProps) {
+ const {
+ timeline,
+ newestGroup,
+ currentVersionStatus,
+ isLoading,
+ error,
+ hasMore,
+ truncated,
+ loadMore,
+ } = activity;
+
+ const includeOptions = useMemo(
+ () => [
+ { value: 'all', label: t('All changes') },
+ {
+ value: 'self',
+ label:
+ entityType === 'chart'
+ ? t('This chart only')
+ : t('This dashboard only'),
+ },
+ { value: 'related', label: t('Related items only') },
+ ],
+ [entityType],
+ );
+
+ const hasSearch = searchTerm.trim().length > 0;
+
+ // The newest self save IS the live state: it gets a "Current" tag,
+ // no preview affordances, and no restore action. The newest save
+ // being a restore additionally means the live entity matches an
+ // older version; surface that in the "Current version" section.
+ // newestGroup comes from the hook's last unfiltered fetch — the visible
+ // timeline is search-filtered, so its first group may be an older save.
+ const currentTransactionId = newestGroup?.transactionId ?? null;
+ const canRestoreKnownVersion = canRestore && currentVersionStatus === 'known';
+ const restoreNotice =
+ newestGroup?.actionKind === 'restore'
+ ? t('Restored version · %s', formatVersionDateTime(newestGroup.issuedAt))
+ : null;
+
+ const isInitialLoading = isLoading && timeline.length === 0;
+
+ return (
+ <Panel aria-label={t('Version history')}>
+ <PanelHeader>
+ <PanelTitle>{t('Version history')}</PanelTitle>
+ <CloseButton
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Close version history')}
+ onClick={onClose}
+ >
+ <Icons.CloseOutlined iconSize="xl" />
+ </CloseButton>
+ </PanelHeader>
+ <Controls>
+ <Input
+ allowClear
+ placeholder={t('Search actions')}
+ suffix={<Icons.SearchOutlined iconSize="l" />}
+ value={searchTerm}
+ onChange={event => onSearchChange(event.target.value)}
+ aria-label={t('Search actions')}
+ />
+ <Select
+ ariaLabel={t('Filter version history')}
+ value={include}
+ onChange={value => onIncludeChange(value as ActivityInclude)}
+ options={includeOptions}
+ />
+ </Controls>
+ <Body>
+ <CurrentVersionSection
+ entries={sessionEntries}
+ restoreNotice={restoreNotice}
+ />
+ {error && (
+ <PaddedContent>
+ <Alert type="error" message={error} closable={false} />
+ </PaddedContent>
+ )}
+ {currentVersionStatus === 'unavailable' && timeline.length > 0 && (
+ <PaddedContent>
+ <Alert
+ type="warning"
+ message={t(
+ 'The current version could not be identified. Restore is temporarily unavailable.',
+ )}
+ closable={false}
+ />
+ </PaddedContent>
+ )}
+ {isInitialLoading && (
+ <PaddedContent>
+ <Skeleton active />
+ </PaddedContent>
+ )}
+ {!isInitialLoading && !error && timeline.length === 0 && (
+ <EmptyState
+ image="empty.svg"
+ size="small"
+ title={hasSearch ? t('No actions found') : t('No history yet')}
+ description={
+ hasSearch
+ ? t('Try a different search term')
+ : t('Saved changes will appear here')
+ }
+ />
+ )}
+ {timeline.map(entry =>
+ entry.type === 'group' ? (
+ <SaveGroupItem
+ key={`group-${entry.transactionId}`}
+ entityType={entityType}
+ group={entry}
+ isCurrent={entry.transactionId === currentTransactionId}
+ canRestore={canRestoreKnownVersion}
+ isPreviewed={
+ entry.transactionId === previewedTransactionId &&
+ entry.transactionId !== currentTransactionId
+ }
+ onPreview={onPreview}
+ onExitPreview={onExitPreview}
+ onRestore={onRestore}
+ onOpenAsNew={onOpenAsNew}
+ />
+ ) : (
+ <RelatedUpdateRow
+ key={
+ entry.rollupEntityNames
+ ? `related-rollup-${entry.record.transaction_id}-${entry.record.entity_kind}`
+ : `related-${relatedEntryKey(entry.record)}`
+ }
+ record={entry.record}
+ rollupEntityNames={entry.rollupEntityNames}
+ onOpen={onOpenRelated}
+ />
+ ),
+ )}
+ {hasMore && (
+ <Footer>
+ <Button
+ buttonStyle="link"
+ onClick={loadMore}
+ loading={isLoading && timeline.length > 0}
+ >
+ {t('Load more')}
+ </Button>
+ </Footer>
+ )}
+ {!hasMore &&
+ truncated &&
+ timeline.length > 0 && (
+ // The server clips very long histories at a fetch ceiling; without
+ // saying so, the last loaded page reads as the beginning of time.
+ <TruncationNotice role="note">
+ {t('Older history exists but is beyond what can be shown here.')}
+ </TruncationNotice>
+ )}
+ </Body>
+ </Panel>
+ );
+}
diff --git a/superset-frontend/src/features/versionHistory/api.test.ts b/superset-frontend/src/features/versionHistory/api.test.ts
new file mode 100644
index 00000000000..9372e1a82a1
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/api.test.ts
@@ -0,0 +1,232 @@
+/**
+ * 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 fetchMock from 'fetch-mock';
+import {
+ createChartFromSnapshot,
+ createDashboardFromSnapshot,
+ fetchActivity,
+} from './api';
+import type {
+ ChartVersionSnapshot,
+ DashboardVersionSnapshot,
+ VersionMeta,
+} from './types';
+
+const versionMeta: VersionMeta = {
+ version_uuid: 'version-uuid',
+ version_number: 3,
+ transaction_id: 12,
+ operation_type: 'update',
+ issued_at: '2025-12-05T17:18:00',
+ changed_by: null,
+ changes: [],
+};
+
+const positions = {
+ ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
+ 'CHART-abc': {
+ type: 'CHART',
+ id: 'CHART-abc',
+ children: [],
+ meta: { chartId: 101, uuid: 'chart-101-uuid', width: 4, height: 50 },
+ },
+};
+
+const snapshot: DashboardVersionSnapshot = {
+ dashboard_title: 'Sales overview',
+ position_json: JSON.stringify(positions),
+ json_metadata: JSON.stringify({ color_scheme: 'supersetColors' }),
+ css: '.dashboard { color: red; }',
+ description: null,
+ slug: null,
+ certified_by: null,
+ certification_details: null,
+ published: true,
+ theme_id: null,
+ uuid: 'dash-uuid',
+ _version: versionMeta,
+};
+
+const LIST_ENDPOINT = 'glob:*/api/v1/dashboard/?q=*';
+const CHART_LIST_ENDPOINT = 'glob:*/api/v1/chart/?q=*';
+const COPY_ENDPOINT = 'glob:*/api/v1/dashboard/42/copy/';
+
+afterEach(() => {
+ fetchMock.removeRoutes();
+ fetchMock.clearHistory();
+});
+
+const ACTIVITY_ENDPOINT = 'glob:*/api/v1/chart/chart-uuid/activity/?*';
+
+test('fetchActivity sends the base pagination params and omits empty filters', async () => {
+ fetchMock.get(ACTIVITY_ENDPOINT, { result: [], count: 0 });
+
+ await fetchActivity('chart', 'chart-uuid', { include: 'self', page: 2 });
+
+ const url = new URL(fetchMock.callHistory.calls(ACTIVITY_ENDPOINT)[0].url);
+ expect(url.searchParams.get('include')).toBe('self');
+ expect(url.searchParams.get('page')).toBe('2');
+ expect(url.searchParams.get('page_size')).toBe('25');
+ // No search filter was supplied, so it must not be sent.
+ expect(url.searchParams.has('q')).toBe(false);
+});
+
+test('fetchActivity forwards a trimmed q filter', async () => {
+ fetchMock.get(ACTIVITY_ENDPOINT, { result: [], count: 0 });
+
+ await fetchActivity('chart', 'chart-uuid', { q: ' revenue ' });
+
+ const url = new URL(fetchMock.callHistory.calls(ACTIVITY_ENDPOINT)[0].url);
+ expect(url.searchParams.get('q')).toBe('revenue');
+});
+
+test('fetchActivity omits q when it is whitespace only', async () => {
+ fetchMock.get(ACTIVITY_ENDPOINT, { result: [], count: 0 });
+
+ await fetchActivity('chart', 'chart-uuid', { q: ' ' });
+
+ const url = new URL(fetchMock.callHistory.calls(ACTIVITY_ENDPOINT)[0].url);
+ expect(url.searchParams.has('q')).toBe(false);
+});
+
+test('createDashboardFromSnapshot copies via the source dashboard with snapshot layout', async () => {
+ fetchMock.get(LIST_ENDPOINT, { result: [{ id: 42 }], count: 1 });
+ fetchMock.get(CHART_LIST_ENDPOINT, { result: [{ id: 101 }], count: 1 });
+ fetchMock.post(
+ COPY_ENDPOINT,
+ { result: { id: 77, last_modified_time: 1 } },
+ { name: 'post-copy' },
+ );
+
+ const newId = await createDashboardFromSnapshot(
+ 'dash-uuid',
+ snapshot,
+ 'Sales overview (copy from Dec 5)',
+ );
+
+ expect(newId).toBe(77);
+ const calls = fetchMock.callHistory.calls('post-copy');
+ expect(calls).toHaveLength(1);
+ const payload = JSON.parse(calls[0].options.body as string);
+ expect(payload.dashboard_title).toBe('Sales overview (copy from Dec 5)');
+ expect(payload.css).toBe('.dashboard { color: red; }');
+ // The copy endpoint derives the new dashboard's chart associations
+ // from the `positions` key of json_metadata; the snapshot layout must
+ // be embedded there so the fork references the snapshot's charts.
+ const metadata = JSON.parse(payload.json_metadata);
+ expect(metadata.color_scheme).toBe('supersetColors');
+ expect(metadata.positions).toEqual(positions);
+});
+
+test('createDashboardFromSnapshot swaps deleted-chart slots for placeholders', async () => {
+ const positionsWithDeleted = {
+ ...positions,
+ 'CHART-dead': {
+ type: 'CHART',
+ id: 'CHART-dead',
+ children: [],
+ meta: { chartId: 999, uuid: 'chart-999-uuid', width: 4, height: 50 },
+ },
+ };
+ fetchMock.get(LIST_ENDPOINT, { result: [{ id: 42 }], count: 1 });
+ // Only chart 101 still resolves; 999 was deleted since the snapshot.
+ fetchMock.get(CHART_LIST_ENDPOINT, { result: [{ id: 101 }], count: 1 });
+ fetchMock.post(
+ COPY_ENDPOINT,
+ { result: { id: 79, last_modified_time: 1 } },
+ { name: 'post-copy-deleted' },
+ );
+
+ await createDashboardFromSnapshot(
+ 'dash-uuid',
+ { ...snapshot, position_json: JSON.stringify(positionsWithDeleted) },
+ 'Copy with deleted chart',
+ );
+
+ const calls = fetchMock.callHistory.calls('post-copy-deleted');
+ const metadata = JSON.parse(
+ JSON.parse(calls[0].options.body as string).json_metadata,
+ );
+ // The copy endpoint silently skips chart associations it cannot
+ // resolve, which would leave a dead slot in the forked layout; the
+ // payload must carry the same placeholder the preview renders instead
+ // of the dead chart reference.
+ expect(metadata.positions['CHART-dead']).toEqual({
+ type: 'MARKDOWN',
+ id: 'CHART-dead',
+ children: [],
+ meta: { width: 4, height: 50, code: 'This chart no longer exists.' },
+ });
+ expect(metadata.positions['CHART-abc']).toEqual(positions['CHART-abc']);
+});
+
+test('createDashboardFromSnapshot sends an explicit empty layout, not no layout', async () => {
+ fetchMock.get(LIST_ENDPOINT, { result: [{ id: 42 }], count: 1 });
+ fetchMock.post(
+ COPY_ENDPOINT,
+ { result: { id: 78, last_modified_time: 1 } },
+ { name: 'post-copy-empty' },
+ );
+
+ await createDashboardFromSnapshot(
+ 'dash-uuid',
+ { ...snapshot, position_json: null, json_metadata: null, css: null },
+ 'Bare copy',
+ );
+
+ const calls = fetchMock.callHistory.calls('post-copy-empty');
+ const payload = JSON.parse(calls[0].options.body as string);
+ expect(payload.css).toBe('');
+ // `positions` must be present even when the snapshot had no layout: the
+ // copy endpoint rebuilds layout and chart associations from this key, so
+ // omitting it leaves the fork carrying the *source's current* charts —
+ // today's dashboard under a historical name.
+ expect(JSON.parse(payload.json_metadata)).toEqual({ positions: {} });
+});
+
+test('createDashboardFromSnapshot rejects when the source dashboard is gone', async () => {
+ fetchMock.get(LIST_ENDPOINT, { result: [], count: 0 });
+
+ await expect(
+ createDashboardFromSnapshot('dash-uuid', snapshot, 'Orphan copy'),
+ ).rejects.toThrow('No dashboard found for uuid dash-uuid');
+});
+
+test('createChartFromSnapshot rejects a version with no viz type or dataset', async () => {
+ // Every version-table column is nullable — a row written for a delete
+ // carries nulls throughout. Posting it would fail validation server-side
+ // with a message the user cannot act on.
+ const chartSnapshot = {
+ slice_name: 'Sales',
+ params: null,
+ viz_type: null,
+ query_context: null,
+ description: null,
+ cache_timeout: null,
+ datasource_id: null,
+ datasource_type: null,
+ uuid: 'chart-uuid',
+ _version: versionMeta,
+ } as ChartVersionSnapshot;
+
+ await expect(
+ createChartFromSnapshot(chartSnapshot, 'Sales (copy)'),
+ ).rejects.toThrow(/does not record a visualization type and dataset/);
+ expect(fetchMock.callHistory.calls('glob:*/api/v1/chart/')).toHaveLength(0);
+});
diff --git a/superset-frontend/src/features/versionHistory/api.ts b/superset-frontend/src/features/versionHistory/api.ts
new file mode 100644
index 00000000000..1971cf5298f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/api.ts
@@ -0,0 +1,396 @@
+/**
+ * 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, SupersetClient } from '@superset-ui/core';
+import rison from 'rison';
+import { t } from '@apache-superset/core/translation';
+import { DASHBOARD_GET_COLUMNS } from 'src/hooks/apiResources/dashboards';
+import { CHART_TYPE, MARKDOWN_TYPE } from 'src/dashboard/util/componentTypes';
+import type { ExploreResponsePayload } from 'src/explore/types';
+import type {
+ HydrateChartData,
+ HydrateDashboardData,
+} from 'src/dashboard/actions/hydrate';
+import type { Dashboard } from 'src/types/Dashboard';
+import type {
+ ActivityEntityKind,
+ ActivityInclude,
+ ActivityResponse,
+ ChartVersionSnapshot,
+ DashboardVersionSnapshot,
+ VersionedEntityType,
+ VersionSnapshot,
+} from './types';
+
+const API_RESOURCE: Record<VersionedEntityType, string> = {
+ chart: 'chart',
+ dashboard: 'dashboard',
+};
+
+export interface FetchActivityOptions {
+ include?: ActivityInclude;
+ page?: number;
+ pageSize?: number;
+ /**
+ * Case-insensitive free-text search over the full history (not just the
+ * loaded pages) — the server filters before paginating, so `count`
+ * reflects matches. Debounced upstream.
+ */
+ q?: string;
+}
+
+export async function fetchActivity(
+ entityType: VersionedEntityType,
+ uuid: string,
+ { include = 'all', page = 0, pageSize = 25, q }: FetchActivityOptions = {},
+): Promise<ActivityResponse> {
+ const params = new URLSearchParams({
+ include,
+ page: String(page),
+ page_size: String(pageSize),
+ });
+ const trimmedQ = q?.trim();
+ if (trimmedQ) {
+ params.set('q', trimmedQ);
+ }
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/activity/?${params.toString()}`,
+ });
+ return json as ActivityResponse;
+}
+
+export async function fetchVersionSnapshot(
+ entityType: 'chart',
+ uuid: string,
+ versionUuid: string,
+): Promise<ChartVersionSnapshot>;
+export async function fetchVersionSnapshot(
+ entityType: 'dashboard',
+ uuid: string,
+ versionUuid: string,
+): Promise<DashboardVersionSnapshot>;
+export async function fetchVersionSnapshot(
+ entityType: VersionedEntityType,
+ uuid: string,
+ versionUuid: string,
+): Promise<VersionSnapshot>;
+export async function fetchVersionSnapshot(
+ entityType: VersionedEntityType,
+ uuid: string,
+ versionUuid: string,
+): Promise<VersionSnapshot> {
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/versions/${encodeURIComponent(versionUuid)}/`,
+ });
+ return (json as { result: VersionSnapshot }).result;
+}
+
+export async function restoreVersion(
+ entityType: VersionedEntityType,
+ uuid: string,
+ versionUuid: string,
+): Promise<{ message: string }> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/versions/${encodeURIComponent(versionUuid)}/restore`,
+ });
+ return json as { message: string };
+}
+
+/** Creates a new chart from a version snapshot; returns the new chart id. */
+export async function createChartFromSnapshot(
+ snapshot: ChartVersionSnapshot,
+ name: string,
+): Promise<number> {
+ // The chart POST requires all three; the version table allows null for each
+ // (a delete version carries nulls throughout). Fail here with something the
+ // caller can turn into a toast rather than sending a payload the API will
+ // reject with a validation error the user cannot act on.
+ if (
+ snapshot.viz_type == null ||
+ snapshot.datasource_id == null ||
+ snapshot.datasource_type == null
+ ) {
+ throw new Error(
+ 'This version does not record a visualization type and dataset, so a new chart cannot be built from it',
+ );
+ }
+ const { json } = await SupersetClient.post({
+ endpoint: '/api/v1/chart/',
+ jsonPayload: {
+ slice_name: name,
+ viz_type: snapshot.viz_type,
+ datasource_id: snapshot.datasource_id,
+ datasource_type: snapshot.datasource_type,
+ ...(snapshot.params != null && { params: snapshot.params }),
+ ...(snapshot.query_context != null && {
+ query_context: snapshot.query_context,
+ }),
+ ...(snapshot.description != null && {
+ description: snapshot.description,
+ }),
+ ...(snapshot.cache_timeout != null && {
+ cache_timeout: snapshot.cache_timeout,
+ }),
+ },
+ });
+ return (json as { id: number }).id;
+}
+
+/** The theme shape `dashboardInfo` holds, keyed by id in the snapshot. */
+export type DashboardTheme = NonNullable<Dashboard['theme']>;
+
+/**
+ * Resolves a snapshot's `theme_id` to the theme object hydration expects.
+ * The version table stores the foreign key, not the theme, so a snapshot
+ * taken under a different theme than the live dashboard needs one lookup.
+ */
+export async function fetchDashboardTheme(
+ themeId: number,
+): Promise<DashboardTheme> {
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/theme/${themeId}`,
+ });
+ const { id, result } = json as {
+ id: number;
+ result: Omit<DashboardTheme, 'id'>;
+ };
+ return { ...result, id };
+}
+
+/**
+ * Activity records identify related entities by uuid only; resolve the
+ * numeric id (needed for page urls) at click time via the list API.
+ */
+export async function resolveEntityId(
+ kind: ActivityEntityKind,
+ uuid: string,
+): Promise<number | null> {
+ const resource: Record<ActivityEntityKind, string> = {
+ chart: 'chart',
+ dashboard: 'dashboard',
+ dataset: 'dataset',
+ };
+ const q = rison.encode({
+ columns: ['id'],
+ filters: [{ col: 'uuid', opr: 'eq', value: uuid }],
+ page_size: 1,
+ });
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/${resource[kind]}/?q=${q}`,
+ });
+ const { result } = json as { result: Array<{ id: number }> };
+ return result.length > 0 ? result[0].id : null;
+}
+
+/** The chart id a layout slot references, or null for non-chart slots. */
+export const layoutChartId = (item: JsonObject): number | null => {
+ const meta = item?.meta as JsonObject | undefined;
+ return item?.type === CHART_TYPE && typeof meta?.chartId === 'number'
+ ? (meta.chartId as number)
+ : null;
+};
+
+/**
+ * Swaps layout slots whose chart is unreachable (deleted, or not visible
+ * to the current user) for a markdown placeholder, preserving the slot's
+ * footprint so the rest of the layout is unaffected.
+ */
+export function swapUnreachableChartSlots(
+ positionData: JsonObject,
+ unreachableIds: Set<number>,
+): JsonObject {
+ if (unreachableIds.size === 0) {
+ return positionData;
+ }
+ const layout: JsonObject = { ...positionData };
+ Object.entries(layout).forEach(([key, item]) => {
+ const chartId = layoutChartId(item as JsonObject);
+ if (chartId !== null && unreachableIds.has(chartId)) {
+ const meta = (item as JsonObject).meta as JsonObject;
+ layout[key] = {
+ ...(item as JsonObject),
+ type: MARKDOWN_TYPE,
+ meta: {
+ width: meta?.width,
+ height: meta?.height,
+ code: t('This chart no longer exists.'),
+ },
+ };
+ }
+ });
+ return layout;
+}
+
+// FAB list endpoints clamp page_size server-side; batches must stay under
+// that cap or reachable charts past it would silently be reported missing.
+const REACHABLE_CHART_BATCH_SIZE = 100;
+
+/** The subset of the given chart ids that the list API can resolve. */
+async function fetchReachableChartIds(
+ chartIds: number[],
+): Promise<Set<number>> {
+ const batches: number[][] = [];
+ for (let i = 0; i < chartIds.length; i += REACHABLE_CHART_BATCH_SIZE) {
+ batches.push(chartIds.slice(i, i + REACHABLE_CHART_BATCH_SIZE));
+ }
+ const results = await Promise.all(
+ batches.map(async batch => {
+ const q = rison.encode({
+ columns: ['id'],
+ filters: [{ col: 'id', opr: 'in', value: batch }],
+ page_size: batch.length,
+ });
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/chart/?q=${q}`,
+ });
+ const { result } = json as { result: Array<{ id: number }> };
+ return result.map(({ id }) => id);
+ }),
+ );
+ return new Set(results.flat());
+}
+
+/**
+ * Forks a dashboard version into a new dashboard via the copy endpoint;
+ * returns the new dashboard id. The copy endpoint derives the new
+ * dashboard's chart associations from the `positions` key of
+ * `json_metadata`, so the fork references (shares, not duplicates)
+ * exactly the charts present in the snapshot's layout. Slots whose chart
+ * no longer resolves are swapped for the same markdown placeholder the
+ * preview renders — the copy endpoint would silently skip their chart
+ * associations, leaving dead slots in the forked layout.
+ */
+export async function createDashboardFromSnapshot(
+ sourceUuid: string,
+ snapshot: DashboardVersionSnapshot,
+ name: string,
+): Promise<number> {
+ const sourceId = await resolveEntityId('dashboard', sourceUuid);
+ if (sourceId === null) {
+ throw new Error(`No dashboard found for uuid ${sourceUuid}`);
+ }
+ const metadata: JsonObject = snapshot.json_metadata
+ ? JSON.parse(snapshot.json_metadata)
+ : {};
+ // Always send `positions`, even empty. The copy endpoint rebuilds the new
+ // dashboard's layout and chart associations from this key; omitting it
+ // leaves the copy with the *source's current* charts, so forking a version
+ // that had no layout would produce today's dashboard under a historical
+ // name.
+ metadata.positions = {};
+ if (snapshot.position_json) {
+ let positions: JsonObject = JSON.parse(snapshot.position_json);
+ const chartIds = new Set<number>();
+ Object.values(positions).forEach(item => {
+ const chartId = layoutChartId(item as JsonObject);
+ if (chartId !== null) {
+ chartIds.add(chartId);
+ }
+ });
+ if (chartIds.size > 0) {
+ const reachable = await fetchReachableChartIds([...chartIds]);
+ const unreachable = new Set(
+ [...chartIds].filter(id => !reachable.has(id)),
+ );
+ positions = swapUnreachableChartSlots(positions, unreachable);
+ }
+ metadata.positions = positions;
+ }
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/dashboard/${encodeURIComponent(sourceId)}/copy/`,
+ jsonPayload: {
+ dashboard_title: name,
+ css: snapshot.css ?? '',
+ json_metadata: JSON.stringify(metadata),
+ },
+ });
+ return (json as { result: { id: number } }).result.id;
+}
+
+/**
+ * Fetches the same explore payload the chart page uses to hydrate, so a
+ * restored chart can be reloaded in place without a full page refresh.
+ */
+export async function fetchExploreRehydrationData(
+ sliceId: number,
+): Promise<ExploreResponsePayload['result']> {
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/explore/?slice_id=${sliceId}`,
+ });
+ return (json as ExploreResponsePayload).result;
+}
+
+/**
+ * Fetches datasource metadata (same shape the explore page hydrates with)
+ * for the datasource a chart version snapshot references, which may no
+ * longer match the live chart's datasource.
+ */
+export async function fetchDatasourceMetadata(
+ datasourceId: number,
+ datasourceType: string,
+): Promise<ExploreResponsePayload['result']['dataset']> {
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/explore/?datasource_id=${datasourceId}&datasource_type=${encodeURIComponent(datasourceType)}`,
+ });
+ return (json as ExploreResponsePayload).result.dataset;
+}
+
+export interface DashboardHydrationData {
+ dashboard: HydrateDashboardData;
+ charts: HydrateChartData[];
+}
+
+/**
+ * Fetches the same dashboard + charts payload the dashboard page uses to
+ * hydrate, so a version preview can re-hydrate (and later un-hydrate)
+ * without unmounting the page.
+ */
+export async function fetchDashboardHydrationData(
+ id: number,
+): Promise<DashboardHydrationData> {
+ const q = rison.encode({ columns: DASHBOARD_GET_COLUMNS });
+ const [dashboardResponse, chartsResponse] = await Promise.all([
+ SupersetClient.get({ endpoint: `/api/v1/dashboard/${id}?q=${q}` }),
+ SupersetClient.get({ endpoint: `/api/v1/dashboard/${id}/charts` }),
+ ]);
+ const dashboard = (dashboardResponse.json as { result: JsonObject }).result;
+ return {
+ dashboard: {
+ ...dashboard,
+ metadata: dashboard.json_metadata
+ ? JSON.parse(dashboard.json_metadata as string)
+ : {},
+ position_data: dashboard.position_json
+ ? JSON.parse(dashboard.position_json as string)
+ : null,
+ } as unknown as HydrateDashboardData,
+ charts: (chartsResponse.json as { result: HydrateChartData[] }).result,
+ };
+}
+
+/**
+ * The explore redux state only carries the chart's numeric id; resolve
+ * its uuid lazily when the version history panel first opens.
+ */
+export async function fetchChartUuid(sliceId: number): Promise<string> {
+ const q = rison.encode({ columns: ['uuid'] });
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/chart/${sliceId}?q=${q}`,
+ });
+ return (json as { result: { uuid: string } }).result.uuid;
+}
diff --git a/superset-frontend/src/features/versionHistory/canRestoreDashboard.ts b/superset-frontend/src/features/versionHistory/canRestoreDashboard.ts
new file mode 100644
index 00000000000..1d76833ebe6
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/canRestoreDashboard.ts
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { RootState } from 'src/dashboard/types';
+
+/**
+ * Whether the current user may restore a version of this dashboard.
+ *
+ * The dashboard counterpart of `canOverwriteSlice`, and it excludes
+ * externally managed dashboards for the same reason: their source of truth
+ * lives outside Superset, so a restore would be overwritten again by the next
+ * sync.
+ *
+ * Exported as one selector because the gate has more than one consumer — the
+ * history panel and the preview banner — and the header menu's own
+ * `is_managed_externally` check already showed how easily those drift apart.
+ * The panel is reachable directly via `?version_history=true`, so a gate
+ * applied only where the menu entry renders is not applied at all. The restore
+ * endpoint checks editorship but not external management, which leaves this as
+ * the only guard.
+ */
+export const selectCanRestoreDashboard = (state: RootState): boolean =>
+ (state.dashboardInfo?.dash_edit_perm ?? false) &&
+ !state.dashboardInfo?.is_managed_externally;
+
+export default selectCanRestoreDashboard;
diff --git a/superset-frontend/src/features/versionHistory/display.test.ts b/superset-frontend/src/features/versionHistory/display.test.ts
new file mode 100644
index 00000000000..541af9da7b6
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/display.test.ts
@@ -0,0 +1,421 @@
+/**
+ * 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 {
+ describeRecord,
+ formatAuthor,
+ formatVersionDateTime,
+ formatVersionDateTimeShort,
+ formatVersionMonthDay,
+ groupHeadline,
+ relatedHeadline,
+} from './display';
+import type { ActivityRecord, SaveGroup } from './types';
+
+const record = (overrides: Partial<ActivityRecord> = {}): ActivityRecord => ({
+ version_uuid: 'v-1',
+ entity_kind: 'chart',
+ entity_uuid: 'e-1',
+ entity_name: 'My chart',
+ entity_deleted: false,
+ entity_deletion_state: null,
+ source: 'self',
+ transaction_id: 10,
+ action_kind: null,
+ issued_at: '2025-12-05T17:18:00',
+ changed_by: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+ kind: 'metric',
+ operation: 'add',
+ path: ['params', 'metrics', 'Revenue'],
+ from_value: null,
+ to_value: { label: 'Revenue' },
+ summary: '',
+ impact: null,
+ ...overrides,
+});
+
+const group = (overrides: Partial<SaveGroup> = {}): SaveGroup => ({
+ type: 'group',
+ transactionId: 10,
+ versionUuid: 'v-1',
+ issuedAt: '2025-12-05T17:18:00',
+ changedBy: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+ actionKind: null,
+ records: [record()],
+ ...overrides,
+});
+
+test('timestamps are parsed as UTC and rendered in the local timezone', () => {
+ // jest pins TZ=America/New_York (UTC-5 in December)
+ expect(formatVersionDateTime('2025-12-05T17:18:00')).toBe(
+ 'Dec 5, 2025, 12:18 PM',
+ );
+ expect(formatVersionMonthDay('2025-12-05T17:18:00')).toBe('Dec 5');
+ expect(formatVersionDateTimeShort('2025-12-05T17:18:00')).toBe(
+ '12/5/2025 12:18PM',
+ );
+});
+
+test('describeRecord labels metric changes with the metric name', () => {
+ expect(describeRecord(record())).toBe("Applied 'Revenue' metric");
+ expect(describeRecord(record({ operation: 'remove' }))).toBe(
+ "Removed 'Revenue' metric",
+ );
+});
+
+test('describeRecord labels dimension changes', () => {
+ const dimension = record({
+ kind: 'dimension',
+ path: ['params', 'groupby', 'country'],
+ to_value: { column_name: 'country' },
+ });
+ expect(describeRecord(dimension)).toBe("Added 'country' dimension");
+ expect(describeRecord({ ...dimension, operation: 'remove' })).toBe(
+ "Removed 'country' dimension",
+ );
+});
+
+test('describeRecord labels filter changes with their subject', () => {
+ const filter = record({
+ kind: 'filter',
+ path: ['params', 'adhoc_filters', 'country'],
+ to_value: { subject: 'country' },
+ });
+ expect(describeRecord(filter)).toBe("Added filter on 'country'");
+});
+
+test('describeRecord labels time range and color palette changes', () => {
+ expect(
+ describeRecord(
+ record({
+ kind: 'time_range',
+ operation: 'edit',
+ path: ['params', 'time_range'],
+ to_value: 'Last month',
+ }),
+ ),
+ ).toBe("Changed time range to 'Last month'");
+ expect(
+ describeRecord(
+ record({
+ kind: 'color_palette',
+ operation: 'edit',
+ path: ['params', 'color_scheme'],
+ to_value: 'supersetColors',
+ }),
+ ),
+ ).toBe('Changed color palette');
+});
+
+test('describeRecord labels dashboard layout changes', () => {
+ expect(
+ describeRecord(
+ record({
+ kind: 'chart',
+ operation: 'move',
+ path: ['position_json', 'CHART-1'],
+ to_value: null,
+ from_value: null,
+ }),
+ ),
+ ).toBe('Moved a chart');
+ expect(
+ describeRecord(
+ record({
+ kind: 'tab',
+ operation: 'add',
+ path: ['position_json', 'TAB-1'],
+ to_value: { label: 'Overview' },
+ }),
+ ),
+ ).toBe("Added tab 'Overview'");
+});
+
+test('describeRecord names a layout chart from its node payload', () => {
+ // Dashboard layout chart-add records carry the chart title as `name`;
+ // the path holds only the opaque node id.
+ expect(
+ describeRecord(
+ record({
+ kind: 'chart',
+ operation: 'add',
+ path: ['CHART-hyUmCvEiZE223-o-fCcQC'],
+ to_value: {
+ id: 'CHART-hyUmCvEiZE223-o-fCcQC',
+ type: 'CHART',
+ name: 'Deck.gl Grid',
+ chartId: 11,
+ },
+ }),
+ ),
+ ).toBe("Added chart 'Deck.gl Grid'");
+});
+
+test('describeRecord drops opaque node ids in favour of kind-only phrasing', () => {
+ // ROW / COLUMN nodes have no name; never surface the raw node id.
+ expect(
+ describeRecord(
+ record({
+ kind: 'row',
+ operation: 'add',
+ path: ['ROW-s61oGzKbowiZcjMnNPuAd'],
+ to_value: { id: 'ROW-s61oGzKbowiZcjMnNPuAd', type: 'ROW' },
+ }),
+ ),
+ ).toBe('Added a row');
+ expect(
+ describeRecord(
+ record({
+ kind: 'column',
+ operation: 'add',
+ path: ['COLUMN-OfJojKvt2OeeVc_IREkIK'],
+ to_value: { id: 'COLUMN-OfJojKvt2OeeVc_IREkIK', type: 'COLUMN' },
+ }),
+ ),
+ ).toBe('Added a column');
+});
+
+test('describeRecord drops a bare uuid value (M2M slice membership)', () => {
+ expect(
+ describeRecord(
+ record({
+ kind: 'chart',
+ operation: 'add',
+ path: ['slices', '33f0c99f-66fa-40f3-919b-1617f5ee60fa'],
+ to_value: '33f0c99f-66fa-40f3-919b-1617f5ee60fa',
+ }),
+ ),
+ ).toBe('Added a chart');
+});
+
+test('describeRecord falls back to a humanized field name', () => {
+ expect(
+ describeRecord(
+ record({
+ kind: 'field',
+ operation: 'edit',
+ path: ['params', 'row_limit'],
+ to_value: 100,
+ from_value: 10,
+ }),
+ ),
+ ).toBe("Changed 'row limit'");
+ expect(
+ describeRecord(
+ record({
+ kind: 'field',
+ operation: 'remove',
+ path: ['description'],
+ to_value: null,
+ from_value: 'old',
+ }),
+ ),
+ ).toBe("Cleared 'description'");
+});
+
+test('describeRecord frames a name/title change as a rename', () => {
+ expect(
+ describeRecord(
+ record({
+ entity_kind: 'chart',
+ kind: 'field',
+ operation: 'edit',
+ path: ['slice_name'],
+ from_value: 'Old name',
+ to_value: 'Quarterly Revenue',
+ }),
+ ),
+ ).toBe("Chart renamed to 'Quarterly Revenue'");
+ expect(
+ describeRecord(
+ record({
+ entity_kind: 'dashboard',
+ kind: 'field',
+ operation: 'edit',
+ path: ['dashboard_title'],
+ to_value: 'Exec Overview',
+ }),
+ ),
+ ).toBe("Dashboard renamed to 'Exec Overview'");
+ // A name field nested deeper than the top level is a different field,
+ // not a rename of the entity itself.
+ expect(
+ describeRecord(
+ record({
+ kind: 'field',
+ operation: 'edit',
+ path: ['params', 'slice_name'],
+ to_value: 'x',
+ }),
+ ),
+ ).toBe("Changed 'slice name'");
+});
+
+test('describeRecord humanizes camelCase field names', () => {
+ expect(
+ describeRecord(
+ record({
+ kind: 'field',
+ operation: 'edit',
+ path: ['params', 'markerSize'],
+ to_value: 8,
+ from_value: 4,
+ }),
+ ),
+ ).toBe("Changed 'marker size'");
+});
+
+test('groupHeadline prefers the transaction action kind', () => {
+ expect(groupHeadline(group({ actionKind: 'restore' }))).toBe(
+ 'Restored version',
+ );
+ expect(
+ groupHeadline(group({ actionKind: 'restore', restoredToVersion: 3 })),
+ ).toBe('Restored to version 3');
+ expect(groupHeadline(group({ actionKind: 'import' }))).toBe('Imported');
+ expect(groupHeadline(group({ actionKind: 'clone' }))).toBe('Cloned');
+});
+
+test('groupHeadline uses the save date for charts', () => {
+ expect(groupHeadline(group())).toBe('Dec 5, 2025, 12:18 PM');
+});
+
+// Dashboards share the chart headline model (sc-107283 guide, 2026-06-12):
+// the save's date/time heads the group and descriptive per-change rows render
+// beneath it — no entity-specific "Filters"/"Edit mode" category headline.
+test('groupHeadline heads dashboard saves with the save date', () => {
+ const filterRecord = record({
+ entity_kind: 'dashboard',
+ kind: 'filter',
+ path: ['json_metadata', 'native_filter_configuration', 'NATIVE_1'],
+ });
+ expect(groupHeadline(group({ records: [filterRecord, filterRecord] }))).toBe(
+ 'Dec 5, 2025, 12:18 PM',
+ );
+ expect(
+ groupHeadline(
+ group({
+ records: [
+ filterRecord,
+ record({ entity_kind: 'dashboard', kind: 'chart' }),
+ ],
+ }),
+ ),
+ ).toBe('Dec 5, 2025, 12:18 PM');
+});
+
+test('groupHeadline labels the first tracked save', () => {
+ expect(groupHeadline(group({ firstTrackedSave: true }))).toBe(
+ 'First tracked save',
+ );
+});
+
+test('groupHeadline falls back to a label when a save has no records', () => {
+ expect(groupHeadline(group({ records: [] }))).toBe('Properties updated');
+});
+
+test('groupHeadline labels a scaffolding-only save as a layout rearrangement', () => {
+ expect(groupHeadline(group({ records: [], hasSuppressedLayout: true }))).toBe(
+ 'Rearranged layout',
+ );
+});
+
+test('relatedHeadline prefers impact-aware phrasing for shared datasets', () => {
+ expect(
+ relatedHeadline(
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_name: 'Sales',
+ summary: 'Dataset updated: Sales',
+ impact: { charts: 4 },
+ }),
+ ),
+ ).toBe('Dataset used by 4 charts updated: Sales');
+ expect(
+ relatedHeadline(
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_name: 'Sales',
+ summary: 'Dataset updated: Sales',
+ impact: { charts: 1 },
+ }),
+ ),
+ ).toBe('Dataset updated: Sales');
+});
+
+test('relatedHeadline falls back when the server summary is empty', () => {
+ expect(
+ relatedHeadline(
+ record({
+ source: 'related',
+ entity_kind: 'chart',
+ entity_name: 'Trend',
+ summary: '',
+ }),
+ ),
+ ).toBe('Item updated: Trend');
+});
+
+test('formatAuthor handles system, named, and nameless users', () => {
+ expect(formatAuthor(null)).toBe('System');
+ expect(
+ formatAuthor({ id: 1, first_name: 'Ada', last_name: 'Lovelace' }),
+ ).toBe('Ada Lovelace');
+ expect(formatAuthor({ id: 2, first_name: 'Ada', last_name: null })).toBe(
+ 'Ada',
+ );
+ expect(formatAuthor({ id: 3, first_name: null, last_name: null })).toBe(
+ 'Unknown user',
+ );
+});
+
+test('the shared-dataset headline goes through the plural machinery', () => {
+ // English resolves this branch identically for every count above one, so a
+ // hardcoded plural passes any English assertion. It is routed through tn so
+ // a locale with several plural forms can pick the right one; asserting both
+ // the count and the substituted name pins that the plural call is the one
+ // producing the string.
+ expect(
+ relatedHeadline(
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_name: 'Sales',
+ summary: 'Dataset updated: Sales',
+ impact: { charts: 2 },
+ }),
+ ),
+ ).toBe('Dataset used by 2 charts updated: Sales');
+});
+
+test('an unlabelled related record does not assemble its sentence from fragments', () => {
+ // The noun and the frame used to be translated separately and concatenated,
+ // which leaves a translator unable to inflect the noun for the sentence.
+ expect(
+ relatedHeadline(
+ record({
+ source: 'related',
+ entity_kind: 'chart',
+ entity_name: 'Revenue',
+ summary: '',
+ }),
+ ),
+ ).toBe('Item updated: Revenue');
+});
diff --git a/superset-frontend/src/features/versionHistory/display.ts b/superset-frontend/src/features/versionHistory/display.ts
new file mode 100644
index 00000000000..19113c6b95b
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/display.ts
@@ -0,0 +1,398 @@
+/**
+ * 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 { t, tn } from '@apache-superset/core/translation';
+import { extendedDayjs } from '@superset-ui/core/utils/dates';
+import type {
+ ActivityChangedBy,
+ ActivityEntityKind,
+ ActivityRecord,
+ SaveGroup,
+} from './types';
+
+/** Activity timestamps are naive UTC; parse as UTC, render local. */
+export function parseIssuedAt(issuedAt: string) {
+ return extendedDayjs.utc(issuedAt).local();
+}
+
+/** e.g. "Dec 5, 2025, 5:18 PM" */
+export function formatVersionDateTime(issuedAt: string): string {
+ return parseIssuedAt(issuedAt).format('MMM D, YYYY, h:mm A');
+}
+
+/** e.g. "Dec 5" — used for fork (copy) names. */
+export function formatVersionMonthDay(issuedAt: string): string {
+ return parseIssuedAt(issuedAt).format('MMM D');
+}
+
+/** Compact datetime for row meta lines, per design spec. */
+export const SHORT_DATETIME_FORMAT = 'M/D/YYYY h:mmA';
+
+/** e.g. "12/5/2025 2:35PM" */
+export function formatVersionDateTimeShort(issuedAt: string): string {
+ return parseIssuedAt(issuedAt).format(SHORT_DATETIME_FORMAT);
+}
+
+export function formatAuthor(changedBy: ActivityChangedBy | null): string {
+ if (!changedBy) {
+ return t('System');
+ }
+ const name = [changedBy.first_name, changedBy.last_name]
+ .filter(Boolean)
+ .join(' ');
+ return name || t('Unknown user');
+}
+
+/**
+ * Synthetic identifiers that carry no meaning for a reader: dashboard
+ * layout node ids (`CHART-xyz`, `ROW-abc`, …) and bare UUIDs (e.g. an
+ * M2M slice-membership record whose value is just the chart's uuid).
+ * These must never surface as a record's subject.
+ */
+const OPAQUE_ID =
+ /^(CHART|ROW|COLUMN|TAB|TABS|HEADER|MARKDOWN|DIVIDER|GRID|ROOT)-|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+function isOpaqueId(value: string): boolean {
+ return OPAQUE_ID.test(value);
+}
+
+/** A human name nested in a layout node's `meta`, if any. */
+function metaLabel(meta: unknown): string | null {
+ if (!meta || typeof meta !== 'object') {
+ return null;
+ }
+ const candidate = meta as Record<string, unknown>;
+ for (const key of ['sliceName', 'text', 'code']) {
+ const value = candidate[key];
+ if (typeof value === 'string' && value) {
+ return value;
+ }
+ }
+ return null;
+}
+
+function valueLabel(value: unknown): string | null {
+ if (typeof value === 'string' && value) {
+ return value;
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ if (value && typeof value === 'object') {
+ const candidate = value as Record<string, unknown>;
+ if (typeof candidate.label === 'string' && candidate.label) {
+ return candidate.label;
+ }
+ // Dashboard layout chart nodes carry the chart title as `name`.
+ if (typeof candidate.name === 'string' && candidate.name) {
+ return candidate.name;
+ }
+ if (
+ typeof candidate.column_name === 'string' &&
+ candidate.column_name !== ''
+ ) {
+ return candidate.column_name;
+ }
+ if (typeof candidate.subject === 'string' && candidate.subject !== '') {
+ return candidate.subject;
+ }
+ return metaLabel(candidate.meta);
+ }
+ return null;
+}
+
+/**
+ * The natural key of the changed item is the last path segment for
+ * list-diff records (e.g. ['params', 'metrics', 'Revenue']). Opaque
+ * identifiers (layout node ids, bare UUIDs) are rejected so the caller
+ * falls back to the kind-only phrasing ("Added a chart") rather than
+ * rendering "Added chart 'CHART-hyUmCv…'".
+ */
+function recordSubject(record: ActivityRecord): string | null {
+ const fromValues =
+ valueLabel(record.to_value) ?? valueLabel(record.from_value);
+ if (fromValues && !isOpaqueId(fromValues)) {
+ return fromValues;
+ }
+ const last = record.path[record.path.length - 1];
+ if (typeof last === 'string' && last !== '' && !isOpaqueId(last)) {
+ return last;
+ }
+ return null;
+}
+
+/** e.g. "color_scheme" → "color scheme", "markerSize" → "marker size". */
+function humanizeFieldName(field: string): string {
+ return field
+ .replace(/_/g, ' ')
+ .replace(
+ /([a-z\d])([A-Z])/g,
+ (_, before, upper) => `${before} ${upper.toLowerCase()}`,
+ );
+}
+
+function fieldSubject(record: ActivityRecord): string {
+ const path = record.path.filter(
+ (segment): segment is string => typeof segment === 'string',
+ );
+ const meaningful =
+ path[0] === 'params' || path[0] === 'json_metadata' ? path[1] : path[0];
+ return humanizeFieldName(String(meaningful ?? t('setting')));
+}
+
+function layoutKindLabel(kind: string): string | null {
+ switch (kind) {
+ case 'chart':
+ return t('chart');
+ case 'row':
+ return t('row');
+ case 'column':
+ return t('column');
+ case 'tab':
+ return t('tab');
+ case 'tabs':
+ return t('tabs');
+ case 'header':
+ return t('header');
+ case 'markdown':
+ return t('markdown');
+ case 'divider':
+ return t('divider');
+ default:
+ return null;
+ }
+}
+
+/**
+ * A change to the entity's own name/title reads better as a rename than as
+ * a generic field edit ("Changed 'slice name'"). Returns the friendly
+ * headline, or null when the record isn't a top-level name change.
+ */
+function renameHeadline(record: ActivityRecord): string | null {
+ const field = record.path.length === 1 ? record.path[0] : null;
+ const to = valueLabel(record.to_value);
+ switch (field) {
+ case 'slice_name':
+ return to ? t("Chart renamed to '%s'", to) : t('Chart renamed');
+ case 'dashboard_title':
+ return to ? t("Dashboard renamed to '%s'", to) : t('Dashboard renamed');
+ case 'table_name':
+ return to ? t("Dataset renamed to '%s'", to) : t('Dataset renamed');
+ default:
+ return null;
+ }
+}
+
+/**
+ * Human-readable label for a `source='self'` activity record. The
+ * backend leaves `summary` empty for self records, so the client
+ * renders one from kind / operation / path / values.
+ */
+export function describeRecord(record: ActivityRecord): string {
+ const { kind, operation } = record;
+ const subject = recordSubject(record);
+
+ const renamed = renameHeadline(record);
+ if (renamed) {
+ return renamed;
+ }
+
+ if (kind === 'metric') {
+ if (operation === 'add') {
+ return subject
+ ? t("Applied '%s' metric", subject)
+ : t('Applied a metric');
+ }
+ if (operation === 'remove') {
+ return subject
+ ? t("Removed '%s' metric", subject)
+ : t('Removed a metric');
+ }
+ return subject ? t("Changed '%s' metric", subject) : t('Changed a metric');
+ }
+
+ if (kind === 'dimension') {
+ if (operation === 'add') {
+ return subject
+ ? t("Added '%s' dimension", subject)
+ : t('Added a dimension');
+ }
+ if (operation === 'remove') {
+ return subject
+ ? t("Removed '%s' dimension", subject)
+ : t('Removed a dimension');
+ }
+ return subject
+ ? t("Changed '%s' dimension", subject)
+ : t('Changed a dimension');
+ }
+
+ if (kind === 'filter') {
+ if (operation === 'add') {
+ return subject ? t("Added filter on '%s'", subject) : t('Added a filter');
+ }
+ if (operation === 'remove') {
+ return subject
+ ? t("Removed filter on '%s'", subject)
+ : t('Removed a filter');
+ }
+ return subject
+ ? t("Changed filter on '%s'", subject)
+ : t('Changed a filter');
+ }
+
+ if (kind === 'time_range') {
+ const to = valueLabel(record.to_value);
+ return to
+ ? t("Changed time range to '%s'", to)
+ : t('Changed the time range');
+ }
+
+ if (kind === 'color_palette') {
+ return t('Changed color palette');
+ }
+
+ const label = layoutKindLabel(kind);
+ if (label) {
+ if (operation === 'add') {
+ return subject
+ ? t("Added %s '%s'", label, subject)
+ : t('Added a %s', label);
+ }
+ if (operation === 'remove') {
+ return subject
+ ? t("Removed %s '%s'", label, subject)
+ : t('Removed a %s', label);
+ }
+ if (operation === 'move') {
+ return subject
+ ? t("Moved %s '%s'", label, subject)
+ : t('Moved a %s', label);
+ }
+ return subject
+ ? t("Changed %s '%s'", label, subject)
+ : t('Changed a %s', label);
+ }
+
+ // Generic scalar ("field") changes and unknown kinds.
+ const field = fieldSubject(record);
+ if (operation === 'add') {
+ return t("Set '%s'", field);
+ }
+ if (operation === 'remove') {
+ return t("Cleared '%s'", field);
+ }
+ return t("Changed '%s'", field);
+}
+
+/**
+ * Headline for a save container, identical across entity types. The
+ * transaction `action_kind` drives an action-specific headline; otherwise
+ * the save's date/time heads the group and the descriptive per-change rows
+ * render beneath it.
+ */
+export function groupHeadline(group: SaveGroup): string {
+ if (group.actionKind === 'restore') {
+ return group.restoredToVersion != null
+ ? t('Restored to version %s', group.restoredToVersion)
+ : t('Restored version');
+ }
+ if (group.actionKind === 'import') {
+ return t('Imported');
+ }
+ if (group.actionKind === 'clone') {
+ return t('Cloned');
+ }
+ // The entity's first tracked save replays a params-normalization flood
+ // that isn't meaningful user edits — collapse it under one label.
+ if (group.firstTrackedSave) {
+ return t('First tracked save');
+ }
+ // A save whose only changes were layout-container scaffolding (dropped
+ // from the change list) reads as a layout rearrangement.
+ if (group.records.length === 0 && group.hasSuppressedLayout) {
+ return t('Rearranged layout');
+ }
+ // Defensive: a save that produced no renderable change records
+ // (e.g. properties-only metadata edits) still deserves a label.
+ if (group.records.length === 0) {
+ return t('Properties updated');
+ }
+ return formatVersionDateTime(group.issuedAt);
+}
+
+/** Entity name with a fallback for records that arrive without one. */
+export function entityDisplayName(record: ActivityRecord): string {
+ if (record.entity_name) {
+ return record.entity_name;
+ }
+ switch (record.entity_kind) {
+ case 'chart':
+ return t('Untitled chart');
+ case 'dashboard':
+ return t('Untitled dashboard');
+ case 'dataset':
+ return t('Untitled dataset');
+ default:
+ return t('Untitled');
+ }
+}
+
+/**
+ * Headline for a related row that rolled up several distinct entities
+ * of one kind from a single transaction, e.g. "10 charts updated".
+ */
+export function relatedRollupHeadline(
+ kind: ActivityEntityKind,
+ count: number,
+): string {
+ switch (kind) {
+ case 'chart':
+ return tn('%s chart updated', '%s charts updated', count, count);
+ case 'dashboard':
+ return tn('%s dashboard updated', '%s dashboards updated', count, count);
+ case 'dataset':
+ default:
+ return tn('%s dataset updated', '%s datasets updated', count, count);
+ }
+}
+
+/**
+ * Text for a `source='related'` row. The server renders `summary`
+ * ("Dataset metric changed: Sales"); when a dashboard-path dataset
+ * change affects multiple charts, prefer the impact-aware phrasing.
+ */
+export function relatedHeadline(record: ActivityRecord): string {
+ const chartCount = record.impact?.charts ?? 0;
+ if (record.entity_kind === 'dataset' && chartCount > 1) {
+ // tn even though this branch only runs for counts above one: English has
+ // a single plural form, but languages with several (2-4 vs 5+ and so on)
+ // still need the count to pick between them.
+ return tn(
+ 'Dataset used by %s chart updated: %s',
+ 'Dataset used by %s charts updated: %s',
+ chartCount,
+ chartCount,
+ entityDisplayName(record),
+ );
+ }
+ // One whole sentence rather than t('Item') interpolated into t('%s updated'):
+ // a translator given the fragments separately cannot inflect the noun for
+ // the sentence it lands in, and many languages require that.
+ return record.summary || t('Item updated: %s', entityDisplayName(record));
+}
diff --git a/superset-frontend/src/features/versionHistory/grouping.test.ts b/superset-frontend/src/features/versionHistory/grouping.test.ts
new file mode 100644
index 00000000000..a1e933196a9
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/grouping.test.ts
@@ -0,0 +1,440 @@
+/**
+ * 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 {
+ buildTimeline,
+ mergeActivityPages,
+ recordKey,
+ relatedEntryKey,
+} from './grouping';
+import type { ActivityRecord, RelatedEntry, SaveGroup } from './types';
+
+const baseRecord: ActivityRecord = {
+ version_uuid: 'v-1',
+ entity_kind: 'chart',
+ entity_uuid: 'e-1',
+ entity_name: 'My chart',
+ entity_deleted: false,
+ entity_deletion_state: null,
+ source: 'self',
+ transaction_id: 10,
+ action_kind: null,
+ issued_at: '2025-12-05T17:18:00',
+ changed_by: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+ kind: 'metric',
+ operation: 'add',
+ path: ['params', 'metrics', 'Revenue'],
+ from_value: null,
+ to_value: { label: 'Revenue' },
+ summary: '',
+ impact: null,
+};
+
+const record = (overrides: Partial<ActivityRecord>): ActivityRecord => ({
+ ...baseRecord,
+ ...overrides,
+});
+
+test('buildTimeline groups self records by transaction id', () => {
+ const entries = buildTimeline([
+ record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+ record({
+ transaction_id: 12,
+ issued_at: '2025-12-06T10:00:00',
+ kind: 'filter',
+ path: ['params', 'adhoc_filters', 'country'],
+ }),
+ record({ transaction_id: 10 }),
+ ]);
+
+ expect(entries).toHaveLength(2);
+ expect(entries[0]).toMatchObject({
+ type: 'group',
+ transactionId: 12,
+ });
+ expect((entries[0] as SaveGroup).records).toHaveLength(2);
+ expect(entries[1]).toMatchObject({ type: 'group', transactionId: 10 });
+});
+
+test('buildTimeline interleaves related records chronologically', () => {
+ const entries = buildTimeline([
+ record({ transaction_id: 14, issued_at: '2025-12-07T09:00:00' }),
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_name: 'Sales',
+ transaction_id: 13,
+ issued_at: '2025-12-06T12:00:00',
+ summary: 'Dataset updated: Sales',
+ }),
+ record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+ ]);
+
+ expect(entries.map(entry => entry.type)).toEqual([
+ 'group',
+ 'related',
+ 'group',
+ ]);
+});
+
+test('buildTimeline orders newest first even with shuffled input', () => {
+ const entries = buildTimeline([
+ record({ transaction_id: 10, issued_at: '2025-12-05T17:18:00' }),
+ record({ transaction_id: 14, issued_at: '2025-12-07T09:00:00' }),
+ record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+ ]);
+
+ expect(entries.map(entry => (entry as SaveGroup).transactionId)).toEqual([
+ 14, 12, 10,
+ ]);
+});
+
+test('buildTimeline propagates restore action_kind to the group', () => {
+ const entries = buildTimeline([
+ record({
+ transaction_id: 15,
+ action_kind: 'restore',
+ issued_at: '2025-12-08T09:00:00',
+ }),
+ record({
+ transaction_id: 15,
+ action_kind: 'restore',
+ issued_at: '2025-12-08T09:00:00',
+ kind: 'field',
+ path: ['slice_name'],
+ }),
+ ]);
+
+ expect(entries).toHaveLength(1);
+ expect((entries[0] as SaveGroup).actionKind).toBe('restore');
+});
+
+test('buildTimeline drops layout-container scaffolding but keeps chart changes', () => {
+ // Dragging a chart to a new spot spawns a new ROW: the backend emits a
+ // row add + the chart's own move. Only the chart change should surface.
+ const [group] = buildTimeline([
+ record({
+ transaction_id: 9,
+ entity_kind: 'dashboard',
+ kind: 'row',
+ operation: 'add',
+ path: ['ROW-lwk_58lo6fSWnbv6CIKnV'],
+ to_value: { id: 'ROW-lwk_58lo6fSWnbv6CIKnV', type: 'ROW' },
+ }),
+ record({
+ transaction_id: 9,
+ entity_kind: 'dashboard',
+ kind: 'chart',
+ operation: 'move',
+ path: ['CHART-GPLeOv026-Vh0mve_tyh9'],
+ to_value: { id: 'CHART-GPLeOv026-Vh0mve_tyh9', name: 'Trend' },
+ }),
+ ]) as SaveGroup[];
+
+ expect(group.records).toHaveLength(1);
+ expect(group.records[0].kind).toBe('chart');
+ expect(group.hasSuppressedLayout).toBe(true);
+});
+
+test('buildTimeline flags a scaffolding-only save without dropping it', () => {
+ const [group] = buildTimeline([
+ record({
+ transaction_id: 12,
+ entity_kind: 'dashboard',
+ kind: 'column',
+ operation: 'add',
+ path: ['COLUMN-OfJojKvt2OeeVc_IREkIK'],
+ to_value: { id: 'COLUMN-OfJojKvt2OeeVc_IREkIK', type: 'COLUMN' },
+ }),
+ ]) as SaveGroup[];
+
+ expect(group.records).toHaveLength(0);
+ expect(group.hasSuppressedLayout).toBe(true);
+});
+
+test('buildTimeline collapses one related save into a single entry', () => {
+ // One dataset save can fan out into many change records; the panel
+ // should show one row per (transaction, entity), upgraded with the
+ // most informative summary/impact across its records.
+ const entries = buildTimeline(
+ Array.from({ length: 5 }, (_, index) =>
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-1',
+ entity_name: 'Sales',
+ transaction_id: 13,
+ issued_at: `2025-12-06T12:00:0${index}`,
+ kind: 'metric',
+ path: ['metrics', `metric_${index}`],
+ summary: index === 2 ? 'Dataset metric changed: Sales' : '',
+ impact: index === 3 ? { charts: 4 } : null,
+ }),
+ ),
+ );
+
+ expect(entries).toHaveLength(1);
+ const [entry] = entries as RelatedEntry[];
+ expect(entry.type).toBe('related');
+ expect(entry.record.summary).toBe('Dataset metric changed: Sales');
+ expect(entry.record.impact).toEqual({ charts: 4 });
+ expect(entry.record.issued_at).toBe('2025-12-06T12:00:04');
+ // every collapsed record is retained so search can match summaries
+ // that only appear on non-representative records
+ expect(entry.records).toHaveLength(5);
+});
+
+test('buildTimeline keeps distinct related entities apart within one transaction', () => {
+ const entries = buildTimeline([
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-1',
+ entity_name: 'Sales',
+ transaction_id: 13,
+ issued_at: '2025-12-06T12:00:00',
+ }),
+ record({
+ source: 'related',
+ entity_kind: 'chart',
+ entity_uuid: 'ch-1',
+ entity_name: 'Revenue trend',
+ transaction_id: 13,
+ issued_at: '2025-12-06T12:00:00',
+ }),
+ ]) as RelatedEntry[];
+
+ expect(entries).toHaveLength(2);
+ const keys = entries.map(entry => relatedEntryKey(entry.record));
+ expect(new Set(keys).size).toBe(2);
+});
+
+test('buildTimeline merges adjacent related entries from one split save', () => {
+ // The backend split one logical dataset save into two transactions
+ // with the same timestamp; the panel must show a single row whose
+ // records union both saves (so search keeps matching all of them).
+ const related = (transactionId: number, field: string) =>
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-1',
+ entity_name: 'birth_names',
+ transaction_id: transactionId,
+ issued_at: '2026-06-12T14:50:35',
+ kind: 'metric',
+ path: ['metrics', field],
+ summary: 'Dataset used by 11 charts updated: birth_names',
+ });
+
+ const entries = buildTimeline([
+ related(50, 'a'),
+ related(50, 'b'),
+ related(49, 'c'),
+ related(49, 'd'),
+ ]) as RelatedEntry[];
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0].type).toBe('related');
+ // the newer transaction stays representative (stable React key)
+ expect(entries[0].record.transaction_id).toBe(50);
+ expect(entries[0].records).toHaveLength(4);
+});
+
+test('buildTimeline keeps related entries for the same entity apart when issued far apart', () => {
+ const related = (transactionId: number, issuedAt: string) =>
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-1',
+ entity_name: 'birth_names',
+ transaction_id: transactionId,
+ issued_at: issuedAt,
+ });
+
+ const entries = buildTimeline([
+ related(50, '2026-06-12T16:50:35'),
+ related(49, '2026-06-12T14:50:35'),
+ ]);
+
+ expect(entries).toHaveLength(2);
+});
+
+test('buildTimeline keeps simultaneous related entries for different entities apart', () => {
+ const entries = buildTimeline([
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-1',
+ entity_name: 'birth_names',
+ transaction_id: 50,
+ issued_at: '2026-06-12T14:50:35',
+ }),
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-2',
+ entity_name: 'cleaned_sales',
+ transaction_id: 49,
+ issued_at: '2026-06-12T14:50:35',
+ }),
+ ]);
+
+ expect(entries).toHaveLength(2);
+});
+
+test('buildTimeline rolls up same-transaction related entries per entity kind', () => {
+ // One dataset save cascades into a related record for every chart
+ // built on it; the panel should show one pluralized row, not ten.
+ const entries = buildTimeline(
+ Array.from({ length: 10 }, (_, i) =>
+ record({
+ source: 'related',
+ entity_kind: 'chart',
+ entity_uuid: `c-${i}`,
+ entity_name: `Chart ${i}`,
+ transaction_id: 53,
+ issued_at: '2026-06-12T15:00:00',
+ summary: `Chart updated: Chart ${i}`,
+ }),
+ ),
+ ) as RelatedEntry[];
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0].type).toBe('related');
+ expect(entries[0].rollupEntityNames).toHaveLength(10);
+ expect(entries[0].rollupEntityNames).toContain('Chart 0');
+ expect(entries[0].rollupEntityNames).toContain('Chart 9');
+ // every absorbed entry's records are retained for search
+ expect(entries[0].records).toHaveLength(10);
+});
+
+test('buildTimeline leaves single-entity related transactions un-rolled', () => {
+ const entries = buildTimeline([
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-1',
+ entity_name: 'Sales',
+ transaction_id: 53,
+ issued_at: '2026-06-12T15:00:00',
+ summary: 'Dataset updated: Sales',
+ }),
+ ]) as RelatedEntry[];
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0].rollupEntityNames).toBeUndefined();
+});
+
+test('buildTimeline rolls up mixed kinds in one transaction per kind', () => {
+ const related = (
+ kind: 'chart' | 'dataset',
+ uuid: string,
+ name: string,
+ ): ActivityRecord =>
+ record({
+ source: 'related',
+ entity_kind: kind,
+ entity_uuid: uuid,
+ entity_name: name,
+ transaction_id: 53,
+ issued_at: '2026-06-12T15:00:00',
+ });
+
+ const entries = buildTimeline([
+ related('chart', 'c-1', 'Trend'),
+ related('chart', 'c-2', 'Breakdown'),
+ related('dataset', 'ds-1', 'Sales'),
+ related('dataset', 'ds-2', 'Costs'),
+ ]) as RelatedEntry[];
+
+ expect(entries).toHaveLength(2);
+ const byKind = new Map(
+ entries.map(entry => [entry.record.entity_kind, entry]),
+ );
+ expect(byKind.get('chart')?.rollupEntityNames).toEqual([
+ 'Trend',
+ 'Breakdown',
+ ]);
+ expect(byKind.get('dataset')?.rollupEntityNames).toEqual(['Sales', 'Costs']);
+});
+
+test('a self save between two related entries blocks their merge', () => {
+ const related = (transactionId: number, issuedAt: string) =>
+ record({
+ source: 'related',
+ entity_kind: 'dataset',
+ entity_uuid: 'ds-1',
+ entity_name: 'birth_names',
+ transaction_id: transactionId,
+ issued_at: issuedAt,
+ });
+
+ const entries = buildTimeline([
+ related(30, '2026-06-12T10:00:50'),
+ record({ transaction_id: 29, issued_at: '2026-06-12T10:00:30' }),
+ related(28, '2026-06-12T10:00:10'),
+ ]);
+
+ expect(entries.map(entry => entry.type)).toEqual([
+ 'related',
+ 'group',
+ 'related',
+ ]);
+});
+
+test('mergeActivityPages appends new rows and drops duplicates', () => {
+ const pageOne = [
+ record({ transaction_id: 14, issued_at: '2025-12-07T09:00:00' }),
+ record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+ ];
+ const pageTwo = [
+ // duplicate of the last row of page one (offset shifted by a new save)
+ record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+ record({ transaction_id: 10 }),
+ ];
+
+ const merged = mergeActivityPages(pageOne, pageTwo);
+
+ expect(merged).toHaveLength(3);
+ expect(new Set(merged.map(recordKey)).size).toBe(3);
+});
+
+test('recordKey distinguishes records within one transaction', () => {
+ const a = record({ path: ['params', 'metrics', 'Revenue'] });
+ const b = record({ path: ['params', 'metrics', 'Profit'] });
+ expect(recordKey(a)).not.toBe(recordKey(b));
+});
+
+test('recordKey separates same-path records that differ only by value', () => {
+ // One save touching the same path twice, or two entries under one collection
+ // path, share kind/operation/path. Keyed on those alone the second row reads
+ // as a re-served copy of the first and is dropped when pages merge.
+ const first = record({
+ path: ['params', 'adhoc_filters'],
+ from_value: null,
+ to_value: 'region = US',
+ });
+ const second = record({
+ path: ['params', 'adhoc_filters'],
+ from_value: null,
+ to_value: 'channel = web',
+ });
+
+ expect(recordKey(first)).not.toBe(recordKey(second));
+ expect(mergeActivityPages([first], [second])).toHaveLength(2);
+});
diff --git a/superset-frontend/src/features/versionHistory/grouping.ts b/superset-frontend/src/features/versionHistory/grouping.ts
new file mode 100644
index 00000000000..87c0e6a7336
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/grouping.ts
@@ -0,0 +1,276 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type {
+ ActivityRecord,
+ RelatedEntry,
+ SaveGroup,
+ TimelineEntry,
+} from './types';
+
+/**
+ * Stable identity for one activity record, used to deduplicate rows
+ * when merging pages (offset pagination can re-serve rows if a new
+ * save lands between page fetches).
+ */
+export function recordKey(record: ActivityRecord): string {
+ return [
+ record.transaction_id,
+ record.entity_kind,
+ record.entity_uuid ?? record.entity_name,
+ record.source,
+ record.kind,
+ record.operation,
+ JSON.stringify(record.path),
+ // kind/operation/path can repeat within one transaction — the same field
+ // touched twice, or two entries under one collection path. Without the
+ // values, the second record is taken for a re-served copy of the first and
+ // dropped on page merge, so the timeline silently under-reports the save.
+ JSON.stringify(record.from_value ?? null),
+ JSON.stringify(record.to_value ?? null),
+ ].join('|');
+}
+
+/** Merge a newly fetched page into already loaded records, deduplicated. */
+export function mergeActivityPages(
+ existing: ActivityRecord[],
+ incoming: ActivityRecord[],
+): ActivityRecord[] {
+ const seen = new Set(existing.map(recordKey));
+ const merged = [...existing];
+ incoming.forEach(record => {
+ const key = recordKey(record);
+ if (!seen.has(key)) {
+ seen.add(key);
+ merged.push(record);
+ }
+ });
+ return merged;
+}
+
+/** One related row per save of a related entity, regardless of how many
+ * change records that save produced. */
+export function relatedEntryKey(record: ActivityRecord): string {
+ return [
+ record.transaction_id,
+ record.entity_kind,
+ record.entity_uuid ?? record.entity_name,
+ ].join('|');
+}
+
+/** Identity of the entity a related record belongs to, ignoring the
+ * transaction. */
+function relatedEntityKey(record: ActivityRecord): string {
+ return [record.entity_kind, record.entity_uuid ?? record.entity_name].join(
+ '|',
+ );
+}
+
+const RELATED_MERGE_WINDOW_MS = 60_000;
+
+/**
+ * The backend can split one logical save of a related entity into
+ * several transactions issued at (nearly) the same instant, which
+ * would render as duplicate-looking rows. Collapse adjacent related
+ * entries — no self save between them — for the same entity issued
+ * within a short window into the newer entry: its representative
+ * record is kept and the older save's records are absorbed so search
+ * over collapsed records keeps working.
+ */
+// TODO(version-history): backend workaround — remove when upstream emits
+// one transaction per logical save.
+function mergeAdjacentRelatedEntries(
+ entries: TimelineEntry[],
+): TimelineEntry[] {
+ const merged: TimelineEntry[] = [];
+ entries.forEach(entry => {
+ const previous = merged[merged.length - 1];
+ if (
+ entry.type === 'related' &&
+ previous?.type === 'related' &&
+ relatedEntityKey(previous.record) === relatedEntityKey(entry.record) &&
+ Math.abs(
+ Date.parse(previous.record.issued_at) -
+ Date.parse(entry.record.issued_at),
+ ) <= RELATED_MERGE_WINDOW_MS
+ ) {
+ previous.records.push(...entry.records);
+ } else {
+ merged.push(entry);
+ }
+ });
+ return merged;
+}
+
+/**
+ * One save can cascade into related records for many distinct entities
+ * of the same kind within a single transaction (e.g. a dataset edit
+ * touching every chart built on it), which would render one row per
+ * entity. Roll those entries up into one row per (transaction, kind):
+ * the newest entry keeps the row, absorbed entries contribute their
+ * entity names (for the tooltip) and records (so search keeps
+ * matching). Single-entity transactions are left untouched.
+ */
+// TODO(version-history): backend workaround — remove when upstream emits
+// a dataset-level impact record instead of per-chart cascade records.
+function rollupSameTransactionRelated(
+ entries: TimelineEntry[],
+): TimelineEntry[] {
+ const buckets = new Map<string, RelatedEntry[]>();
+ entries.forEach(entry => {
+ if (entry.type !== 'related') {
+ return;
+ }
+ const key = `${entry.record.transaction_id}|${entry.record.entity_kind}`;
+ const bucket = buckets.get(key);
+ if (bucket) {
+ bucket.push(entry);
+ } else {
+ buckets.set(key, [entry]);
+ }
+ });
+
+ const absorbed = new Set<RelatedEntry>();
+ buckets.forEach(bucket => {
+ if (bucket.length < 2) {
+ return;
+ }
+ // Entries are ordered newest-first; the first one keeps the row.
+ const [head, ...rest] = bucket;
+ head.rollupEntityNames = bucket.map(entry => entry.record.entity_name);
+ rest.forEach(entry => {
+ head.records.push(...entry.records);
+ absorbed.add(entry);
+ });
+ });
+
+ return absorbed.size > 0
+ ? entries.filter(entry => entry.type !== 'related' || !absorbed.has(entry))
+ : entries;
+}
+
+/**
+ * Dashboard layout containers — rows, columns, and the tab strip — are
+ * scaffolding Superset creates and destroys automatically as charts are
+ * dragged around (a single chart move can spawn a fresh ROW). They carry
+ * no user-meaningful name, so they're kept out of the descriptive change
+ * list; the chart add / move records tell the real story.
+ */
+const LAYOUT_SCAFFOLDING_KINDS = new Set(['row', 'column', 'tabs']);
+
+function isLayoutScaffolding(record: ActivityRecord): boolean {
+ return LAYOUT_SCAFFOLDING_KINDS.has(record.kind);
+}
+
+/**
+ * Build the timeline from a flat newest-first activity stream:
+ * `source='self'` records are grouped into one save container per
+ * transaction, `source='related'` records collapse into a single entry
+ * per (transaction, entity), adjacent related entries from one split
+ * save merge into a single row, and multi-entity cascades roll up into
+ * one row per (transaction, kind). The result is ordered newest first.
+ */
+export function buildTimeline(records: ActivityRecord[]): TimelineEntry[] {
+ const groupsByTransaction = new Map<number, SaveGroup>();
+ const relatedByKey = new Map<string, RelatedEntry>();
+ const entries: TimelineEntry[] = [];
+
+ records.forEach(record => {
+ if (record.source === 'self') {
+ let group = groupsByTransaction.get(record.transaction_id);
+ if (!group) {
+ group = {
+ type: 'group',
+ transactionId: record.transaction_id,
+ versionUuid: record.version_uuid,
+ issuedAt: record.issued_at,
+ changedBy: record.changed_by,
+ actionKind: record.action_kind,
+ records: [],
+ };
+ groupsByTransaction.set(record.transaction_id, group);
+ entries.push(group);
+ }
+ // The synthetic __meta__ headline (operation 'announce') is an
+ // annotation, not a user edit: keep it out of the change list. For a
+ // restore it carries the target version number for the headline.
+ if (record.kind === '__meta__' || record.operation === 'announce') {
+ const meta = record.to_value as { version_number?: number } | null;
+ if (meta && typeof meta.version_number === 'number') {
+ group.restoredToVersion = meta.version_number;
+ }
+ } else if (isLayoutScaffolding(record)) {
+ // Dropping a chart into empty space makes Superset spawn a new ROW;
+ // such container churn isn't a user action. Hide it — the chart's
+ // own add/move record carries the meaning — but remember it so a
+ // scaffolding-only save can still head with "Rearranged layout".
+ group.hasSuppressedLayout = true;
+ } else {
+ group.records.push(record);
+ }
+ if (record.first_tracked_save) {
+ group.firstTrackedSave = true;
+ }
+ if (record.issued_at > group.issuedAt) {
+ group.issuedAt = record.issued_at;
+ }
+ group.versionUuid = group.versionUuid ?? record.version_uuid;
+ group.changedBy = group.changedBy ?? record.changed_by;
+ group.actionKind = group.actionKind ?? record.action_kind;
+ } else {
+ const key = relatedEntryKey(record);
+ const existing = relatedByKey.get(key);
+ if (!existing) {
+ const entry: RelatedEntry = {
+ type: 'related',
+ record,
+ records: [record],
+ };
+ relatedByKey.set(key, entry);
+ entries.push(entry);
+ } else {
+ existing.records.push(record);
+ // Keep the row, upgrading it with the most informative fields
+ // seen across the save's records.
+ const current = existing.record;
+ existing.record = {
+ ...current,
+ summary: current.summary || record.summary,
+ impact: current.impact ?? record.impact,
+ issued_at:
+ record.issued_at > current.issued_at
+ ? record.issued_at
+ : current.issued_at,
+ };
+ }
+ }
+ });
+
+ entries.sort((a, b) => {
+ const issuedA = a.type === 'group' ? a.issuedAt : a.record.issued_at;
+ const issuedB = b.type === 'group' ? b.issuedAt : b.record.issued_at;
+ if (issuedA !== issuedB) {
+ return issuedA < issuedB ? 1 : -1;
+ }
+ const txA = a.type === 'group' ? a.transactionId : a.record.transaction_id;
+ const txB = b.type === 'group' ? b.transactionId : b.record.transaction_id;
+ return txB - txA;
+ });
+
+ return rollupSameTransactionRelated(mergeAdjacentRelatedEntries(entries));
+}
diff --git a/superset-frontend/src/features/versionHistory/openRelated.test.ts b/superset-frontend/src/features/versionHistory/openRelated.test.ts
new file mode 100644
index 00000000000..9886a59b6b1
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/openRelated.test.ts
@@ -0,0 +1,130 @@
+/**
+ * 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 {
+ closeOpenedTab,
+ navigateOpenedTab,
+ openBlankTab,
+} from 'src/utils/navigationUtils';
+import { resolveEntityId } from './api';
+import { openRelatedEntity } from './openRelated';
+import type { ActivityRecord } from './types';
+
+jest.mock('src/utils/navigationUtils', () => ({
+ openBlankTab: jest.fn(),
+ navigateOpenedTab: jest.fn(),
+ closeOpenedTab: jest.fn(),
+}));
+jest.mock('./api', () => ({
+ resolveEntityId: jest.fn(),
+}));
+
+const mockedResolveEntityId = resolveEntityId as jest.Mock;
+const mockedOpenBlankTab = openBlankTab as jest.Mock;
+const mockedNavigateOpenedTab = navigateOpenedTab as jest.Mock;
+const mockedCloseOpenedTab = closeOpenedTab as jest.Mock;
+
+const claimedTab = { location: { replace: jest.fn() } } as unknown as Window;
+
+const record = (overrides: Partial<ActivityRecord> = {}): ActivityRecord =>
+ ({
+ entity_kind: 'chart',
+ entity_uuid: 'uuid-1',
+ entity_name: 'My chart',
+ ...overrides,
+ }) as ActivityRecord;
+
+beforeEach(() => {
+ mockedOpenBlankTab.mockReturnValue(claimedTab);
+});
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+test('claims the tab before the id resolves, then navigates it', async () => {
+ // window.open after the resolve await is silently refused on Safari (and
+ // on any browser once transient activation lapses) — the tab must be
+ // claimed synchronously in the click's task.
+ let resolveId: (value: number) => void = () => {};
+ mockedResolveEntityId.mockReturnValue(
+ new Promise(resolve => {
+ resolveId = resolve;
+ }),
+ );
+ const onError = jest.fn();
+
+ const pending = openRelatedEntity(record(), onError);
+ expect(mockedOpenBlankTab).toHaveBeenCalledTimes(1);
+ expect(mockedNavigateOpenedTab).not.toHaveBeenCalled();
+
+ resolveId(42);
+ await pending;
+
+ expect(mockedNavigateOpenedTab).toHaveBeenCalledWith(
+ claimedTab,
+ '/explore/?slice_id=42',
+ );
+ expect(onError).not.toHaveBeenCalled();
+});
+
+test('opens the resolved dashboard with a post-route_base path', async () => {
+ mockedResolveEntityId.mockResolvedValue(7);
+ const onError = jest.fn();
+
+ await openRelatedEntity(record({ entity_kind: 'dashboard' }), onError);
+
+ expect(mockedNavigateOpenedTab).toHaveBeenCalledWith(
+ claimedTab,
+ '/dashboard/7/',
+ );
+});
+
+test('reports an error when the record has no uuid', async () => {
+ const onError = jest.fn();
+
+ await openRelatedEntity(record({ entity_uuid: null }), onError);
+
+ expect(onError).toHaveBeenCalledWith('Could not find My chart');
+ expect(mockedResolveEntityId).not.toHaveBeenCalled();
+ // Nothing was claimed, so nothing to close.
+ expect(mockedOpenBlankTab).not.toHaveBeenCalled();
+});
+
+test('closes the claimed tab when the uuid does not resolve to an id', async () => {
+ // Leaving it open would strand the user on about:blank.
+ mockedResolveEntityId.mockResolvedValue(null);
+ const onError = jest.fn();
+
+ await openRelatedEntity(record(), onError);
+
+ expect(onError).toHaveBeenCalledWith('Could not find My chart');
+ expect(mockedNavigateOpenedTab).not.toHaveBeenCalled();
+ expect(mockedCloseOpenedTab).toHaveBeenCalledWith(claimedTab);
+});
+
+test('closes the claimed tab when the id lookup fails', async () => {
+ mockedResolveEntityId.mockRejectedValue(new Error('boom'));
+ const onError = jest.fn();
+
+ await openRelatedEntity(record(), onError);
+
+ expect(onError).toHaveBeenCalledWith('Could not find My chart');
+ expect(mockedNavigateOpenedTab).not.toHaveBeenCalled();
+ expect(mockedCloseOpenedTab).toHaveBeenCalledWith(claimedTab);
+});
diff --git a/superset-frontend/src/features/versionHistory/openRelated.ts b/superset-frontend/src/features/versionHistory/openRelated.ts
new file mode 100644
index 00000000000..c585e73d765
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/openRelated.ts
@@ -0,0 +1,64 @@
+/**
+ * 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 { t } from '@apache-superset/core/translation';
+import {
+ closeOpenedTab,
+ navigateOpenedTab,
+ openBlankTab,
+} from 'src/utils/navigationUtils';
+import type { ActivityEntityKind, ActivityRecord } from './types';
+import { resolveEntityId } from './api';
+
+function entityUrl(kind: ActivityEntityKind, id: number): string {
+ if (kind === 'chart') {
+ return `/explore/?slice_id=${id}`;
+ }
+ if (kind === 'dashboard') {
+ return `/dashboard/${id}/`;
+ }
+ return `/explore/?datasource_type=table&datasource_id=${id}`;
+}
+
+/** Open a related entity's page in a new tab, resolving its uuid to an id. */
+export async function openRelatedEntity(
+ record: ActivityRecord,
+ onError: (message: string) => void,
+): Promise<void> {
+ if (!record.entity_uuid) {
+ onError(t('Could not find %s', record.entity_name));
+ return;
+ }
+ // Claim the tab now, while the click's transient activation is still live:
+ // window.open after the resolve await is silently refused on Safari (and on
+ // any browser once activation lapses) — the same pitfall openAsNew handles.
+ const tab = openBlankTab();
+ try {
+ const id = await resolveEntityId(record.entity_kind, record.entity_uuid);
+ if (id === null) {
+ closeOpenedTab(tab);
+ onError(t('Could not find %s', record.entity_name));
+ return;
+ }
+ navigateOpenedTab(tab, entityUrl(record.entity_kind, id));
+ } catch {
+ // Leaving the claimed tab open would strand the user on about:blank.
+ closeOpenedTab(tab);
+ onError(t('Could not find %s', record.entity_name));
+ }
+}
diff --git a/superset-frontend/src/features/versionHistory/previewReselect.test.tsx b/superset-frontend/src/features/versionHistory/previewReselect.test.tsx
new file mode 100644
index 00000000000..a7f16ab59a3
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/previewReselect.test.tsx
@@ -0,0 +1,134 @@
+/**
+ * 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.
+ */
+
+/**
+ * Regression coverage for re-selecting the version already being previewed
+ * (every change row is clickable, including rows of the previewed group).
+ * SET_VERSION_PREVIEW used to re-enter the applying state unconditionally,
+ * but the appliers' effects key on versionUuid and never re-run for an
+ * identical value — so nothing announced completion again and the banner
+ * reported "Loading historical version" forever, with Restore withheld.
+ *
+ * Test contributed by @kgabryje in review; assertions flipped to pin the
+ * fixed behaviour (the reducer treats the re-selection as a no-op).
+ */
+import { act } from 'react';
+import { createStore, render, waitFor } from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import type { VersionHistoryState } from './types';
+import { setVersionPreview, type VersionHistoryRootState } from './reducer';
+import { fetchDatasourceMetadata, fetchVersionSnapshot } from './api';
+import ChartVersionPreview from './ChartVersionPreview';
+
+jest.mock('./api', () => ({
+ fetchDatasourceMetadata: jest.fn(),
+ fetchVersionSnapshot: jest.fn(),
+}));
+jest.mock('./PreviewBanner', () => ({ __esModule: true, default: () => null }));
+jest.mock('src/components/Chart/chartAction', () => ({
+ getChartDataRequest: jest.fn(),
+ handleChartDataResponse: jest.fn(),
+}));
+jest.mock('src/explore/exploreUtils', () => ({
+ ...jest.requireActual('src/explore/exploreUtils'),
+ getQuerySettings: () => [false],
+}));
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ SuperChart: () => <div data-test="super-chart" />,
+}));
+
+const { handleChartDataResponse } = jest.requireMock(
+ 'src/components/Chart/chartAction',
+);
+
+const PREVIEW = {
+ entityUuid: 'chart-uuid',
+ versionUuid: 'version-uuid',
+ transactionId: 1,
+ headline: 'A save',
+ issuedAt: '2026-07-01T00:00:00Z',
+};
+
+const previewState = (): VersionHistoryState => ({
+ isPanelOpen: true,
+ entityType: 'chart',
+ include: 'all',
+ isPreviewApplying: true,
+ preview: PREVIEW,
+ sessionLog: [],
+ restoreCount: 0,
+ lastRestoredEntityUuid: null,
+});
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+ params: '{"metric":"count"}',
+ viz_type: 'table',
+ datasource_id: 5,
+ datasource_type: 'table',
+ });
+ (fetchDatasourceMetadata as jest.Mock).mockResolvedValue({});
+ (getChartDataRequest as jest.Mock).mockResolvedValue({
+ response: {},
+ json: {},
+ });
+ (handleChartDataResponse as jest.Mock).mockResolvedValue([{ data: [] }]);
+});
+
+test('re-selecting the already-previewed version does not re-enter applying', async () => {
+ const store = createStore(
+ {
+ versionHistory: previewState(),
+ explore: {
+ datasource: { id: 5, type: 'table', columns: [], metrics: [] },
+ slice: { editors: [{ id: 1 }] },
+ },
+ user: { userId: 1, roles: {} },
+ },
+ reducerIndex,
+ );
+ render(<ChartVersionPreview />, { useTheme: true, store });
+
+ const applying = () =>
+ (store.getState() as unknown as VersionHistoryRootState).versionHistory
+ .isPreviewApplying;
+
+ await waitFor(() => expect(applying()).toBe(false)); // first apply settles
+
+ // The user clicks a change row inside the group they are already
+ // previewing (every row is clickable, including the previewed group's).
+ act(() => {
+ store.dispatch(setVersionPreview({ ...PREVIEW }));
+ });
+ // The reducer treats it as a no-op: the applier effects key on versionUuid
+ // and would never re-run for an identical value, so re-entering the
+ // applying state here could never be left again.
+ expect(applying()).toBe(false);
+
+ // Flush pending microtasks to show nothing re-enters it later either.
+ await act(async () => {
+ await new Promise(resolve => {
+ setTimeout(resolve, 50);
+ });
+ });
+ expect(applying()).toBe(false);
+});
diff --git a/superset-frontend/src/features/versionHistory/reducer.test.ts b/superset-frontend/src/features/versionHistory/reducer.test.ts
new file mode 100644
index 00000000000..8e434cbccfc
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/reducer.test.ts
@@ -0,0 +1,147 @@
+/**
+ * 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 versionHistoryReducer, {
+ appendVersionSessionLog,
+ clearVersionPreview,
+ clearVersionSessionLog,
+ closeVersionHistoryPanel,
+ openVersionHistoryPanel,
+ selectIsChartVersionPreviewActive,
+ selectIsDashboardVersionPreviewActive,
+ selectVersionHistory,
+ setVersionHistoryInclude,
+ setVersionPreview,
+ versionRestored,
+ VersionHistoryRootState,
+} from './reducer';
+import type { SessionLogEntry, VersionPreviewState } from './types';
+
+const initial = versionHistoryReducer(undefined, { type: 'INIT' } as never);
+
+const preview: VersionPreviewState = {
+ entityUuid: 'entity-uuid',
+ versionUuid: 'version-uuid',
+ transactionId: 7,
+ headline: 'Dec 5, 2025, 5:18 PM',
+ issuedAt: '2025-12-05T17:18:00',
+};
+
+const entry = (overrides: Partial<SessionLogEntry> = {}): SessionLogEntry => ({
+ label: "Changed 'Metrics'",
+ controlName: 'metrics',
+ ts: 1,
+ user: 'Ada Lovelace',
+ ...overrides,
+});
+
+test('opening the panel records the entity type', () => {
+ const state = versionHistoryReducer(
+ initial,
+ openVersionHistoryPanel('dashboard'),
+ );
+ expect(state.isPanelOpen).toBe(true);
+ expect(state.entityType).toBe('dashboard');
+});
+
+test('closing the panel also exits any active preview', () => {
+ let state = versionHistoryReducer(initial, openVersionHistoryPanel('chart'));
+ state = versionHistoryReducer(state, setVersionPreview(preview));
+ state = versionHistoryReducer(state, closeVersionHistoryPanel());
+ expect(state.isPanelOpen).toBe(false);
+ expect(state.preview).toBeNull();
+});
+
+test('set and clear preview', () => {
+ let state = versionHistoryReducer(initial, setVersionPreview(preview));
+ expect(state.preview).toEqual(preview);
+ state = versionHistoryReducer(state, clearVersionPreview(preview.entityUuid));
+ expect(state.preview).toBeNull();
+});
+
+test("another entity's clear leaves this preview alone", () => {
+ // The slice is global and outlives any one page. A restore (or a failed
+ // apply) settling after the user moved to another entity and previewed a
+ // version there must not clear the preview they just opened — the guard
+ // lives here so every dispatcher inherits it, rather than at each of the
+ // five call sites.
+ let state = versionHistoryReducer(initial, setVersionPreview(preview));
+ state = versionHistoryReducer(state, clearVersionPreview('other-entity'));
+ expect(state.preview).toEqual(preview);
+
+ // ...and the owning entity still clears it.
+ state = versionHistoryReducer(state, clearVersionPreview(preview.entityUuid));
+ expect(state.preview).toBeNull();
+});
+
+test('a clear with no entity cannot exit another entity’s preview', () => {
+ // An undefined uuid identifies nothing, so it must not act as a wildcard.
+ let state = versionHistoryReducer(initial, setVersionPreview(preview));
+ state = versionHistoryReducer(state, clearVersionPreview(undefined));
+ expect(state.preview).toEqual(preview);
+});
+
+test('include filter persists', () => {
+ const state = versionHistoryReducer(
+ initial,
+ setVersionHistoryInclude('related'),
+ );
+ expect(state.include).toBe('related');
+});
+
+test('versionRestored increments the counter and records the entity', () => {
+ let state = versionHistoryReducer(initial, versionRestored('uuid-a'));
+ state = versionHistoryReducer(state, versionRestored('uuid-b'));
+ expect(state.restoreCount).toBe(2);
+ // Consumers use this to ignore restores of entities other than the one
+ // their page shows — the store outlives SPA navigation.
+ expect(state.lastRestoredEntityUuid).toBe('uuid-b');
+});
+
+test('session log collapses consecutive edits of the same control', () => {
+ let state = versionHistoryReducer(
+ initial,
+ appendVersionSessionLog(entry({ ts: 1 })),
+ );
+ state = versionHistoryReducer(
+ state,
+ appendVersionSessionLog(entry({ ts: 2 })),
+ );
+ state = versionHistoryReducer(
+ state,
+ appendVersionSessionLog(entry({ controlName: 'row_limit', ts: 3 })),
+ );
+ expect(state.sessionLog).toHaveLength(2);
+ expect(state.sessionLog[0].ts).toBe(2);
+ state = versionHistoryReducer(state, clearVersionSessionLog());
+ expect(state.sessionLog).toHaveLength(0);
+});
+
+test('selectors fall back to the initial state when the slice is missing', () => {
+ const state = {} as VersionHistoryRootState;
+ expect(selectVersionHistory(state).isPanelOpen).toBe(false);
+ expect(selectIsChartVersionPreviewActive(state)).toBe(false);
+});
+
+test('per-entity preview selectors only match their own entity type', () => {
+ let slice = versionHistoryReducer(initial, openVersionHistoryPanel('chart'));
+ slice = versionHistoryReducer(slice, setVersionPreview(preview));
+ const state: VersionHistoryRootState = { versionHistory: slice };
+ expect(selectIsChartVersionPreviewActive(state)).toBe(true);
+ expect(selectIsDashboardVersionPreviewActive(state)).toBe(false);
+});
diff --git a/superset-frontend/src/features/versionHistory/reducer.ts b/superset-frontend/src/features/versionHistory/reducer.ts
new file mode 100644
index 00000000000..b1e2931c7a4
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/reducer.ts
@@ -0,0 +1,294 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type {
+ ActivityInclude,
+ SessionLogEntry,
+ VersionedEntityType,
+ VersionHistoryState,
+ VersionPreviewState,
+} from './types';
+
+export const OPEN_VERSION_HISTORY_PANEL = 'OPEN_VERSION_HISTORY_PANEL';
+export const CLOSE_VERSION_HISTORY_PANEL = 'CLOSE_VERSION_HISTORY_PANEL';
+export const SET_VERSION_HISTORY_INCLUDE = 'SET_VERSION_HISTORY_INCLUDE';
+export const SET_VERSION_PREVIEW = 'SET_VERSION_PREVIEW';
+export const CLEAR_VERSION_PREVIEW = 'CLEAR_VERSION_PREVIEW';
+export const VERSION_PREVIEW_APPLIED = 'VERSION_PREVIEW_APPLIED';
+export const VERSION_RESTORED = 'VERSION_RESTORED';
+export const APPEND_VERSION_SESSION_LOG = 'APPEND_VERSION_SESSION_LOG';
+export const CLEAR_VERSION_SESSION_LOG = 'CLEAR_VERSION_SESSION_LOG';
+
+/** Upper bound on retained unsaved-edit entries; older ones drop off. */
+export const MAX_SESSION_LOG_ENTRIES = 50;
+
+interface OpenPanelAction {
+ type: typeof OPEN_VERSION_HISTORY_PANEL;
+ entityType: VersionedEntityType;
+}
+
+interface ClosePanelAction {
+ type: typeof CLOSE_VERSION_HISTORY_PANEL;
+}
+
+interface SetIncludeAction {
+ type: typeof SET_VERSION_HISTORY_INCLUDE;
+ include: ActivityInclude;
+}
+
+interface SetPreviewAction {
+ type: typeof SET_VERSION_PREVIEW;
+ preview: VersionPreviewState;
+}
+
+interface ClearPreviewAction {
+ type: typeof CLEAR_VERSION_PREVIEW;
+ entityUuid: string | undefined;
+}
+
+interface PreviewAppliedAction {
+ type: typeof VERSION_PREVIEW_APPLIED;
+}
+
+interface VersionRestoredAction {
+ type: typeof VERSION_RESTORED;
+ /**
+ * The entity the restore happened to. The store outlives SPA navigation
+ * and a restore is a multi-request sequence, so an unscoped broadcast
+ * resolving after the user moved on would make the *next* page rehydrate —
+ * clearing its filters and edits for a restore that happened elsewhere.
+ * Consumers compare this against their own entity before reacting.
+ */
+ entityUuid: string;
+}
+
+interface AppendSessionLogAction {
+ type: typeof APPEND_VERSION_SESSION_LOG;
+ entry: SessionLogEntry;
+}
+
+interface ClearSessionLogAction {
+ type: typeof CLEAR_VERSION_SESSION_LOG;
+}
+
+export type VersionHistoryAction =
+ | OpenPanelAction
+ | ClosePanelAction
+ | SetIncludeAction
+ | SetPreviewAction
+ | ClearPreviewAction
+ | PreviewAppliedAction
+ | VersionRestoredAction
+ | AppendSessionLogAction
+ | ClearSessionLogAction;
+
+export const openVersionHistoryPanel = (
+ entityType: VersionedEntityType,
+): OpenPanelAction => ({
+ type: OPEN_VERSION_HISTORY_PANEL,
+ entityType,
+});
+
+export const closeVersionHistoryPanel = (): ClosePanelAction => ({
+ type: CLOSE_VERSION_HISTORY_PANEL,
+});
+
+export const setVersionHistoryInclude = (
+ include: ActivityInclude,
+): SetIncludeAction => ({
+ type: SET_VERSION_HISTORY_INCLUDE,
+ include,
+});
+
+export const setVersionPreview = (
+ preview: VersionPreviewState,
+): SetPreviewAction => ({
+ type: SET_VERSION_PREVIEW,
+ preview,
+});
+
+/**
+ * Exits the preview of *entityUuid*. The uuid is required rather than
+ * optional so an asynchronous dispatcher has to think about identity: the
+ * slice is global and outlives any one page, so a restore or a failed apply
+ * settling after the user moved to another entity would otherwise clear the
+ * preview they just opened there. The reducer no-ops on a mismatch.
+ */
+export const clearVersionPreview = (
+ entityUuid: string | undefined,
+): ClearPreviewAction => ({
+ type: CLEAR_VERSION_PREVIEW,
+ entityUuid,
+});
+
+/**
+ * Marks the requested preview as fully applied. Dispatched when the snapshot
+ * has been hydrated, or when applying it failed -- either way the page has
+ * stopped being in-between states.
+ */
+export const versionPreviewApplied = (): PreviewAppliedAction => ({
+ type: VERSION_PREVIEW_APPLIED,
+});
+
+export const versionRestored = (entityUuid: string): VersionRestoredAction => ({
+ type: VERSION_RESTORED,
+ entityUuid,
+});
+
+export const appendVersionSessionLog = (
+ entry: SessionLogEntry,
+): AppendSessionLogAction => ({
+ type: APPEND_VERSION_SESSION_LOG,
+ entry,
+});
+
+export const clearVersionSessionLog = (): ClearSessionLogAction => ({
+ type: CLEAR_VERSION_SESSION_LOG,
+});
+
+const initialState: VersionHistoryState = {
+ isPanelOpen: false,
+ entityType: null,
+ include: 'all',
+ preview: null,
+ isPreviewApplying: false,
+ sessionLog: [],
+ restoreCount: 0,
+ lastRestoredEntityUuid: null,
+};
+
+export default function versionHistoryReducer(
+ state: VersionHistoryState = initialState,
+ action: VersionHistoryAction,
+): VersionHistoryState {
+ switch (action.type) {
+ case OPEN_VERSION_HISTORY_PANEL:
+ return { ...state, isPanelOpen: true, entityType: action.entityType };
+ case CLOSE_VERSION_HISTORY_PANEL:
+ // Closing the panel also exits any active preview.
+ return {
+ ...state,
+ isPanelOpen: false,
+ preview: null,
+ isPreviewApplying: false,
+ };
+ case SET_VERSION_HISTORY_INCLUDE:
+ return { ...state, include: action.include };
+ case SET_VERSION_PREVIEW:
+ // Re-selecting the version already being previewed must be a no-op.
+ // The appliers' effects key on versionUuid and would never re-run for
+ // an identical value, so re-entering the applying state here would
+ // leave it stuck: the banner reporting a load forever and Restore
+ // withheld until the preview is exited.
+ if (action.preview.versionUuid === state.preview?.versionUuid) {
+ return state;
+ }
+ // A newly requested preview is by definition not on screen yet: the
+ // snapshot takes several round trips to fetch and hydrate, and until it
+ // lands the page still shows live data. Starting in the applying state
+ // means no caller can forget to announce it.
+ return { ...state, preview: action.preview, isPreviewApplying: true };
+ case VERSION_PREVIEW_APPLIED:
+ return { ...state, isPreviewApplying: false };
+ case CLEAR_VERSION_PREVIEW:
+ // Only the entity whose preview this is may exit it. A restore (or a
+ // failed apply) resolving after the user moved to another entity and
+ // opened a preview there must leave that preview alone.
+ if (state.preview && state.preview.entityUuid !== action.entityUuid) {
+ return state;
+ }
+ return { ...state, preview: null, isPreviewApplying: false };
+ case VERSION_RESTORED:
+ return {
+ ...state,
+ restoreCount: state.restoreCount + 1,
+ lastRestoredEntityUuid: action.entityUuid,
+ };
+ case APPEND_VERSION_SESSION_LOG: {
+ const last = state.sessionLog[state.sessionLog.length - 1];
+ // Collapse consecutive edits of the same control into one entry.
+ if (last && last.controlName === action.entry.controlName) {
+ return {
+ ...state,
+ sessionLog: [...state.sessionLog.slice(0, -1), action.entry],
+ };
+ }
+ // Alternating edits never collapse, so cap the log — the middleware
+ // appends on every control change for the lifetime of the page, and
+ // only the most recent entries are meaningful in the panel.
+ return {
+ ...state,
+ sessionLog: [...state.sessionLog, action.entry].slice(
+ -MAX_SESSION_LOG_ENTRIES,
+ ),
+ };
+ }
+ case CLEAR_VERSION_SESSION_LOG:
+ return { ...state, sessionLog: [] };
+ default:
+ return state;
+ }
+}
+
+export interface VersionHistoryRootState {
+ versionHistory: VersionHistoryState;
+}
+
+export const selectVersionHistory = (state: VersionHistoryRootState) =>
+ state.versionHistory ?? initialState;
+
+export const selectIsVersionHistoryPanelOpen = (
+ state: VersionHistoryRootState,
+) => selectVersionHistory(state).isPanelOpen;
+
+export const selectVersionHistoryInclude = (state: VersionHistoryRootState) =>
+ selectVersionHistory(state).include;
+
+export const selectVersionPreview = (state: VersionHistoryRootState) =>
+ selectVersionHistory(state).preview;
+
+/**
+ * True while a requested preview is still being fetched and hydrated -- the
+ * window in which the banner is up but the page below it is still live data.
+ */
+export const selectIsVersionPreviewApplying = (
+ state: VersionHistoryRootState,
+) => selectVersionHistory(state).isPreviewApplying;
+
+export const selectIsChartVersionPreviewActive = (
+ state: VersionHistoryRootState,
+) => {
+ const { entityType, preview } = selectVersionHistory(state);
+ return entityType === 'chart' && preview !== null;
+};
+
+export const selectIsDashboardVersionPreviewActive = (
+ state: VersionHistoryRootState,
+) => {
+ const { entityType, preview } = selectVersionHistory(state);
+ return entityType === 'dashboard' && preview !== null;
+};
+
+export const selectVersionRestoreCount = (state: VersionHistoryRootState) =>
+ selectVersionHistory(state).restoreCount;
+
+export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
+ selectVersionHistory(state).lastRestoredEntityUuid;
+
+export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
+ selectVersionHistory(state).sessionLog;
diff --git a/superset-frontend/src/features/versionHistory/sessionLogMiddleware.test.ts b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.test.ts
new file mode 100644
index 00000000000..fb05297003f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.test.ts
@@ -0,0 +1,226 @@
+/**
+ * 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 { isFeatureEnabled } from '@superset-ui/core';
+import { versionSessionLogMiddleware } from './sessionLogMiddleware';
+import {
+ APPEND_VERSION_SESSION_LOG,
+ CLEAR_VERSION_SESSION_LOG,
+} from './reducer';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ isFeatureEnabled: jest.fn(),
+}));
+
+const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
+
+const buildStore = (state: object = {}) => ({
+ getState: jest.fn(() => state),
+ dispatch: jest.fn(),
+});
+
+const run = (store: ReturnType<typeof buildStore>, action: object) => {
+ const next = jest.fn(value => value);
+ versionSessionLogMiddleware(store)(next)(action);
+ return next;
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockedIsFeatureEnabled.mockReturnValue(true);
+});
+
+test('logs a session entry with the control label on SET_FIELD_VALUE', () => {
+ const store = buildStore({
+ user: { firstName: 'Ada', lastName: 'Lovelace' },
+ explore: { controls: { metrics: { label: 'Metrics' } } },
+ });
+ run(store, { type: 'SET_FIELD_VALUE', controlName: 'metrics', value: [] });
+ expect(store.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: APPEND_VERSION_SESSION_LOG,
+ entry: expect.objectContaining({
+ label: "Changed 'Metrics'",
+ controlName: 'metrics',
+ user: 'Ada Lovelace',
+ }),
+ }),
+ );
+});
+
+test('falls back to a humanized control name when no label exists', () => {
+ const store = buildStore({ explore: { controls: {} } });
+ run(store, { type: 'SET_FIELD_VALUE', controlName: 'row_limit', value: 10 });
+ expect(store.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entry: expect.objectContaining({
+ label: "Changed 'row limit'",
+ user: null,
+ }),
+ }),
+ );
+});
+
+test('skips programmatic control writes so untouched charts stay clean', async () => {
+ // Effects rewrite controls with no user gesture (transferred-control
+ // cleanup after load, derived margins); logging them would report unsaved
+ // edits the user never made. Built with the REAL action creator so the
+ // `programmatic` key is pinned across modules — a rename on either side
+ // fails here rather than silently reviving phantom entries.
+ const { setControlValue } =
+ await import('src/explore/actions/exploreActions');
+ const store = buildStore({
+ explore: { controls: { metrics: { label: 'Metrics' } } },
+ });
+ run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ // The same creator without the mark still logs.
+ run(store, setControlValue('metrics', []));
+ expect(store.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({ type: APPEND_VERSION_SESSION_LOG }),
+ );
+});
+
+test('clears the session log when the explore page hydrates', () => {
+ const store = buildStore();
+ run(store, { type: 'HYDRATE_EXPLORE', data: {} });
+ expect(store.dispatch).toHaveBeenCalledWith({
+ type: CLEAR_VERSION_SESSION_LOG,
+ });
+});
+
+test('a chart rename is logged as an unsaved change', () => {
+ // Renames travel through UPDATE_CHART_TITLE, not SET_FIELD_VALUE; without
+ // this the restore gate's dirty signal missed them and a restore silently
+ // discarded the rename.
+ const store = buildStore({
+ user: { firstName: 'Ada', lastName: 'Lovelace' },
+ });
+ run(store, { type: 'UPDATE_CHART_TITLE', sliceName: 'New name' });
+ expect(store.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: APPEND_VERSION_SESSION_LOG,
+ entry: expect.objectContaining({
+ label: 'Renamed chart',
+ controlName: 'slice_name',
+ user: 'Ada Lovelace',
+ }),
+ }),
+ );
+});
+
+test('a dataset reconciliation is logged as an unsaved change', async () => {
+ // Editing a dataset in place reconciles the chart's form data against the
+ // new columns but dispatches nothing the control branches record — only a
+ // *swap* emits its own control change. Without this the restore gate saw
+ // an empty log while the form had changed, and a restore discarded the
+ // reconciled values with no warning. Built with the REAL action creator so
+ // the shape is pinned across modules.
+ const { updateFormDataByDatasource } =
+ await import('src/explore/actions/exploreActions');
+ const store = buildStore({
+ user: { firstName: 'Ada', lastName: 'Lovelace' },
+ explore: { controls: { datasource: { label: 'Dataset' } } },
+ });
+ const datasource = { id: 1, type: 'table' } as never;
+ run(store, updateFormDataByDatasource(datasource, datasource));
+ expect(store.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: APPEND_VERSION_SESSION_LOG,
+ entry: expect.objectContaining({
+ label: "Changed 'Dataset'",
+ controlName: 'datasource',
+ user: 'Ada Lovelace',
+ }),
+ }),
+ );
+});
+
+test('a browser history step is logged as an unsaved change', async () => {
+ // Explore's own history entries carry chart state, so a back/forward
+ // between them is an undo/redo that rebuilds the whole control map without
+ // emitting a single control change. Unrecorded, a save (which clears the
+ // log) followed by a step back to the pre-save controls left the restore
+ // gate seeing a clean form that had in fact moved — and the restore
+ // discarded it silently. Built with the REAL action creator: the constant
+ // is named SET_EXPLORE_CONTROLS but its literal is
+ // 'UPDATE_EXPLORE_CONTROLS'.
+ const { setExploreControls } =
+ await import('src/explore/actions/exploreActions');
+ const store = buildStore({
+ user: { firstName: 'Ada', lastName: 'Lovelace' },
+ });
+ run(store, setExploreControls({ viz_type: 'table' } as never));
+ expect(store.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: APPEND_VERSION_SESSION_LOG,
+ entry: expect.objectContaining({
+ label: 'Undid or redid a change',
+ controlName: '__history__',
+ user: 'Ada Lovelace',
+ }),
+ }),
+ );
+});
+
+test('a history step cannot collapse into an adjacent control entry', async () => {
+ // Collapsing is by controlName, so the sentinel must not collide with a
+ // real control — otherwise a history step would silently replace the
+ // preceding genuine edit rather than adding to it.
+ const { setExploreControls } =
+ await import('src/explore/actions/exploreActions');
+ const store = buildStore({ explore: { controls: {} } });
+ run(store, setExploreControls({} as never));
+ const { entry } = store.dispatch.mock.calls[0][0];
+ expect(entry.controlName).not.toMatch(/^[a-z]/);
+});
+
+test('does nothing when the feature flag is disabled', () => {
+ mockedIsFeatureEnabled.mockReturnValue(false);
+ const store = buildStore();
+ run(store, { type: 'SET_FIELD_VALUE', controlName: 'metrics', value: [] });
+ expect(store.dispatch).not.toHaveBeenCalled();
+});
+
+test('passes every action through to the next middleware', () => {
+ const store = buildStore();
+ const action = { type: 'UNRELATED' };
+ const next = run(store, action);
+ expect(next).toHaveBeenCalledWith(action);
+ expect(store.dispatch).not.toHaveBeenCalled();
+});
+
+test('inlined action-type literals match the real explore constants', async () => {
+ // The middleware inlines these to keep explore out of the global
+ // bundle; a rename in explore must fail here, not silently kill the
+ // session log. Test code can afford the real imports.
+ const middleware = await import('./sessionLogMiddleware');
+ const exploreActions = await import('src/explore/actions/exploreActions');
+ const hydrateExplore = await import('src/explore/actions/hydrateExplore');
+ expect(middleware.SET_FIELD_VALUE).toBe(exploreActions.SET_FIELD_VALUE);
+ expect(middleware.UPDATE_CHART_TITLE).toBe(exploreActions.UPDATE_CHART_TITLE);
+ expect(middleware.UPDATE_FORM_DATA_BY_DATASOURCE).toBe(
+ exploreActions.UPDATE_FORM_DATA_BY_DATASOURCE,
+ );
+ expect(middleware.SET_EXPLORE_CONTROLS).toBe(
+ exploreActions.SET_EXPLORE_CONTROLS,
+ );
+ expect(middleware.HYDRATE_EXPLORE).toBe(hydrateExplore.HYDRATE_EXPLORE);
+});
diff --git a/superset-frontend/src/features/versionHistory/sessionLogMiddleware.ts b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.ts
new file mode 100644
index 00000000000..b139cf09b15
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.ts
@@ -0,0 +1,155 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { Middleware } from 'redux';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
+
+// Action types are inlined (rather than imported from the explore
+// module) so this middleware does not pull explore code into every
+// page's bundle; the store applies it globally. Exported so the test
+// suite can assert they match the real explore constants — a rename
+// there would otherwise silently kill the session log.
+export const SET_FIELD_VALUE = 'SET_FIELD_VALUE';
+export const UPDATE_CHART_TITLE = 'UPDATE_CHART_TITLE';
+export const UPDATE_FORM_DATA_BY_DATASOURCE = 'UPDATE_FORM_DATA_BY_DATASOURCE';
+// Note the literal: explore names this constant SET_EXPLORE_CONTROLS but its
+// value is 'UPDATE_EXPLORE_CONTROLS'. The pinning test guards the mismatch.
+export const SET_EXPLORE_CONTROLS = 'UPDATE_EXPLORE_CONTROLS';
+export const HYDRATE_EXPLORE = 'HYDRATE_EXPLORE';
+
+// The control whose label names a datasource change in the log. Both the
+// swap and the Edit Dataset routes report under it, so the two entries
+// collapse into one when a swap emits both.
+const DATASOURCE_CONTROL_NAME = 'datasource';
+// Sentinel, not a real control: a history step rewrites the whole control
+// map, so it belongs to no single control. Namespaced so it can never
+// collapse into an adjacent genuine control entry.
+const HISTORY_CONTROL_NAME = '__history__';
+
+interface SessionLogState {
+ user?: { firstName?: string; lastName?: string };
+ explore?: {
+ controls?: Record<string, { label?: unknown } | undefined>;
+ };
+}
+
+function controlLabel(state: SessionLogState, controlName: string): string {
+ const label = state.explore?.controls?.[controlName]?.label;
+ return typeof label === 'string' && label
+ ? label
+ : controlName.replace(/_/g, ' ');
+}
+
+function userName(state: SessionLogState): string | null {
+ const name = [state.user?.firstName, state.user?.lastName]
+ .filter(Boolean)
+ .join(' ');
+ return name || null;
+}
+
+/**
+ * Records unsaved explore control changes in the version history
+ * session log ("Current version" section) and resets the log whenever
+ * the explore page (re)hydrates — initial load, save, or restore.
+ */
+export const versionSessionLogMiddleware: Middleware =
+ store => next => action => {
+ const result = next(action);
+ if (!isFeatureEnabled(FeatureFlag.VersionHistory)) {
+ return result;
+ }
+ if (action.type === HYDRATE_EXPLORE) {
+ store.dispatch(clearVersionSessionLog());
+ } else if (action.type === UPDATE_CHART_TITLE) {
+ // Renaming the chart is an unsaved change like any control edit, but it
+ // travels through its own action — without this branch the restore
+ // gate's dirty signal missed it and a restore silently discarded the
+ // rename.
+ const state = store.getState() as SessionLogState;
+ store.dispatch(
+ appendVersionSessionLog({
+ label: t('Renamed chart'),
+ controlName: 'slice_name',
+ ts: Date.now(),
+ user: userName(state),
+ }),
+ );
+ } else if (action.type === SET_EXPLORE_CONTROLS) {
+ // Explore pushes its chart state into history entries, so a browser
+ // back/forward between them is an undo/redo: it rebuilds the entire
+ // control map without emitting a single control change. Left
+ // unrecorded, a user who saved, stepped back to the pre-save controls
+ // and then restored a version had that step silently discarded — the
+ // restore gate saw an empty log while the form had moved.
+ //
+ // Recording it does mean stepping back to a state matching the saved
+ // chart still latches an entry, so restore is blocked where nothing
+ // actually differs. That is the append-only log's known failure
+ // direction, and it fails closed; the missing entry failed open.
+ // Baseline diffing replaces both.
+ const state = store.getState() as SessionLogState;
+ store.dispatch(
+ appendVersionSessionLog({
+ label: t('Undid or redid a change'),
+ controlName: HISTORY_CONTROL_NAME,
+ ts: Date.now(),
+ user: userName(state),
+ }),
+ );
+ } else if (action.type === UPDATE_FORM_DATA_BY_DATASOURCE) {
+ // Swapping the dataset and editing it in place both reconcile the
+ // chart's form data against the new columns, but only the swap emits
+ // a control change of its own (ChangeDatasourceModal's `onChange`).
+ // Editing a dataset dispatches nothing the branches below record, so
+ // without this the restore gate saw an empty log while the form had
+ // changed — a restore then discarded the reconciled values silently.
+ const state = store.getState() as SessionLogState;
+ store.dispatch(
+ appendVersionSessionLog({
+ label: t(
+ "Changed '%s'",
+ controlLabel(state, DATASOURCE_CONTROL_NAME),
+ ),
+ controlName: DATASOURCE_CONTROL_NAME,
+ ts: Date.now(),
+ user: userName(state),
+ }),
+ );
+ } else if (
+ action.type === SET_FIELD_VALUE &&
+ typeof action.controlName === 'string' &&
+ // Effects rewrite controls with no user gesture (transferred-control
+ // cleanup after load, derived values set alongside another control);
+ // logging those would tell the user they have unsaved edits they never
+ // made. Only user-initiated changes belong in the session log.
+ !action.programmatic
+ ) {
+ const state = store.getState() as SessionLogState;
+ store.dispatch(
+ appendVersionSessionLog({
+ label: t("Changed '%s'", controlLabel(state, action.controlName)),
+ controlName: action.controlName,
+ ts: Date.now(),
+ user: userName(state),
+ }),
+ );
+ }
+ return result;
+ };
diff --git a/superset-frontend/src/features/versionHistory/types.ts b/superset-frontend/src/features/versionHistory/types.ts
new file mode 100644
index 00000000000..0ef7b254d70
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/types.ts
@@ -0,0 +1,231 @@
+/**
+ * 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.
+ */
+
+export type VersionedEntityType = 'chart' | 'dashboard';
+
+export type ActivityInclude = 'self' | 'related' | 'all';
+
+export type ActivityEntityKind = 'chart' | 'dashboard' | 'dataset';
+
+export type ActivityOperation =
+ | 'add'
+ | 'remove'
+ | 'move'
+ | 'edit'
+ // The server's summary verb for a field group collapsed at the record cap.
+ | 'update'
+ | 'announce';
+
+export type ActivityActionKind = 'restore' | 'import' | 'clone' | null;
+
+export interface ActivityChangedBy {
+ id: number;
+ first_name: string | null;
+ last_name: string | null;
+}
+
+export interface ActivityRecord {
+ version_uuid: string | null;
+ entity_kind: ActivityEntityKind;
+ entity_uuid: string | null;
+ entity_name: string;
+ entity_deleted: boolean;
+ entity_deletion_state: string | null;
+ source: 'self' | 'related';
+ transaction_id: number;
+ action_kind: ActivityActionKind;
+ issued_at: string;
+ changed_by: ActivityChangedBy | null;
+ kind: string;
+ operation: ActivityOperation;
+ path: Array<string | number>;
+ from_value: unknown;
+ to_value: unknown;
+ summary: string;
+ impact: { charts: number } | null;
+ /**
+ * True only on records whose transaction is the entity's first tracked
+ * save (the first update after its retroactive baseline). Such saves can
+ * carry dozens of params-normalization records that aren't meaningful user
+ * edits, so the container is collapsed. Always false for hard-deleted
+ * entities. Optional for resilience against
+ * older backend responses that predate the field.
+ */
... 2899 lines suppressed ...