This is an automated email from the ASF dual-hosted git repository.
villebro 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 599026a0e5a feat(standalone): granular Explore standalone modes and
iframe-safe dashboard fullscreen (#44165)
599026a0e5a is described below
commit 599026a0e5a82f9728a333aea0e07b6a7bf8f771
Author: anamitraadhikari <[email protected]>
AuthorDate: Thu Sep 24 16:29:56 2026 -0700
feat(standalone): granular Explore standalone modes and iframe-safe
dashboard fullscreen (#44165)
Co-authored-by: aadhikari <[email protected]>
---
.../src/components/Echart.test.tsx | 3 +-
.../plugin-chart-echarts/src/components/Echart.tsx | 5 +-
.../dashboard/components/Header/Header.test.tsx | 41 ++++++++++--
.../Header/useHeaderActionsDropdownMenu.tsx | 10 ++-
.../ExploreChartPanel/ExploreChartPanel.test.tsx | 11 ++--
.../explore/components/ExploreChartPanel/index.tsx | 5 +-
.../ExploreViewContainer.test.tsx | 77 +++++++++++++++++++---
.../components/ExploreViewContainer/index.tsx | 28 ++++++--
superset-frontend/src/explore/constants.ts | 6 ++
.../src/explore/exploreUtils/exploreUtils.test.tsx | 25 +++++++
.../src/explore/exploreUtils/index.ts | 8 ++-
.../src/explore/reducers/exploreReducer.ts | 2 +-
superset-frontend/src/explore/types.ts | 5 +-
superset-frontend/src/utils/urlUtils.test.ts | 23 +++++++
superset/utils/screenshots.py | 9 ++-
superset/utils/webdriver.py | 8 ++-
tests/unit_tests/utils/screenshot_test.py | 57 ++++++++++++++++
17 files changed, 284 insertions(+), 39 deletions(-)
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
index 3025164dd64..cdfc7987278 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
@@ -241,7 +241,8 @@ test('replaces stale query event handlers without clearing
regular event handler
});
test.each([
- // Report/thumbnail screenshots render in standalone "true" (charts) or 3
(reports)
+ // Report/thumbnail captures use standalone=3; "true" is the legacy capture
+ // value and stays supported for links created before numeric modes existed
['true', true],
['3', true],
// Live embeds use 1/2 and must keep animation
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index 3306f0a4e84..2ef66956bf7 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -125,8 +125,9 @@ use([
LegacyGridContainLabel,
]);
-// Report/thumbnail screenshots use standalone="true" (charts) or 3 (reports);
-// live embeds use 1/2 and keep animation. See superset/utils/screenshots.py.
+// Report/thumbnail screenshots use standalone=3. Legacy chart capture URLs may
+// still carry standalone="true"; live embeds use 1/2 and keep animation.
+// See superset/utils/screenshots.py.
export function isReportScreenshotMode(): boolean {
try {
const standalone = new URLSearchParams(window.location.search).get(
diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx
b/superset-frontend/src/dashboard/components/Header/Header.test.tsx
index 7b7312aefeb..ce68b3054c2 100644
--- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx
+++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx
@@ -36,6 +36,11 @@ import { UPDATE_COMPONENTS } from
'../../actions/dashboardLayout';
import { AutoRefreshStatus } from '../../types/autoRefresh';
const mockHistoryReplace = jest.fn();
+// Dashboards render top-level (not iframed) unless a test says otherwise.
+const mockIsInIframe = jest.fn(() => false);
+jest.mock('src/dashboard/util/isEmbedded', () => ({
+ isEmbedded: () => mockIsInIframe(),
+}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
@@ -244,6 +249,7 @@ beforeAll(() => {
beforeEach(() => {
jest.clearAllMocks();
+ mockIsInIframe.mockReturnValue(false);
const { useLocation } = jest.requireMock('react-router-dom');
useLocation.mockReturnValue({
pathname: '/dashboard',
@@ -863,18 +869,41 @@ test('should hide edit button and navbar, and show Exit
fullscreen when in fulls
expect(screen.queryByTestId('main-navigation')).not.toBeInTheDocument();
});
-test('should show Exit fullscreen when in fullscreen mode', async () => {
+test('should show Exit fullscreen when in standalone mode at top level', async
() => {
+ // Default setup URL carries standalone=1. A user who clicked "Enter
fullscreen"
+ // must be able to get back out, so this must not be hidden outside an
iframe.
setup();
+ await openActionsDropdown();
+ expect(screen.getByText('Exit fullscreen')).toBeInTheDocument();
+ expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
+});
- await userEvent.click(screen.getByTestId('actions-trigger'));
-
- expect(await screen.findByText('Exit fullscreen')).toBeInTheDocument();
+test('should show Enter fullscreen when not in standalone mode', async () => {
+ // Keep the router location and window.location in agreement. The shared mock
+ // reports `?standalone=1`; leaving it would let this test pass on a stale
+ // premise if the component ever read `location.search` instead of
+ // `window.location.search`.
+ const { useLocation } = jest.requireMock('react-router-dom');
+ useLocation.mockReturnValue({
+ pathname: '/dashboard',
+ search: '',
+ hash: '',
+ state: undefined,
+ });
+ window.history.pushState({}, 'Test page', '/dashboard');
+ setup();
+ await openActionsDropdown();
+ expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
+ expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
});
-test('should have fullscreen option in dropdown', async () => {
+test('should hide the fullscreen toggle entirely inside an iframe', async ()
=> {
+ // Exiting inside an iframe reloads without the standalone param and
restores the
+ // full Superset nav, breaking the embed — so neither direction is offered.
+ mockIsInIframe.mockReturnValue(true);
setup();
await openActionsDropdown();
- expect(screen.getByText('Exit fullscreen')).toBeInTheDocument();
+ expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
});
diff --git
a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
index d1585312ead..a2812f6d056 100644
---
a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++
b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -34,6 +34,7 @@ import { SAVE_TYPE_NEWDASHBOARD } from
'src/dashboard/util/constants';
import FilterScopeModal from
'src/dashboard/components/filterscope/FilterScopeModal';
import getDashboardUrl from 'src/dashboard/util/getDashboardUrl';
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
+import { isEmbedded as isInIframe } from 'src/dashboard/util/isEmbedded';
import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
@@ -290,8 +291,13 @@ export const useHeaderActionsMenu = ({
});
}
- // Toggle fullscreen (hide on mobile)
- if (!editMode && !isEmbedded && !isMobile) {
+ // Toggle fullscreen (hide on mobile). Hidden entirely inside an iframe:
there,
+ // "Exit fullscreen" reloads without the standalone param and brings back
the full
+ // Superset nav, breaking the embed. Note `isEmbedded` above is
+ // `!dashboardInfo.userId` (anonymous guest via the embedded SDK), which
does not
+ // cover an authenticated user whose dashboard is iframed, hence the
separate check.
+ // Top-level users keep both directions so fullscreen is not a one-way
door.
+ if (!editMode && !isEmbedded && !isMobile && !isInIframe()) {
menuItems.push({
key: MenuKeys.ToggleFullscreen,
label: getUrlParam(URL_PARAMS.standalone)
diff --git
a/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
b/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
index 82d83b23e43..edc848f0a68 100644
---
a/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
+++
b/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
@@ -29,6 +29,7 @@ import {
VizType,
} from '@superset-ui/core';
import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
+import { ExploreStandaloneMode } from 'src/explore/constants';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
jest.mock('./StandaloneDownloadControl', () => ({
@@ -203,7 +204,7 @@ describe('ChartContainer', () => {
test('does not render standalone download control when show_download is
absent', () => {
const props = createProps({
- standalone: true,
+ standalone: ExploreStandaloneMode.HideNav,
can_download: true,
});
@@ -218,7 +219,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=0');
const props = createProps({
- standalone: true,
+ standalone: ExploreStandaloneMode.HideNav,
can_download: true,
});
@@ -233,7 +234,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
- standalone: true,
+ standalone: ExploreStandaloneMode.HideNav,
can_download: true,
});
@@ -248,7 +249,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
- standalone: true,
+ standalone: ExploreStandaloneMode.HideNav,
can_download: false,
});
@@ -263,7 +264,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
- standalone: false,
+ standalone: ExploreStandaloneMode.None,
can_download: true,
});
diff --git
a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
index 4df280bc09f..ef1ea33b2c8 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
@@ -32,6 +32,7 @@ import {
import { logging } from '@apache-superset/core/utils';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
+import { ExploreStandaloneMode } from 'src/explore/constants';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import ChartContainer from 'src/components/Chart/ChartContainer';
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
@@ -89,7 +90,7 @@ export interface ExploreChartPanelProps {
vizType: string;
form_data: QueryFormData;
ownState?: JsonObject;
- standalone?: boolean;
+ standalone?: number;
force?: boolean;
timeout?: number;
chartIsStale?: boolean;
@@ -513,7 +514,7 @@ const ExploreChartPanel = ({
[gutterMargin],
);
- if (standalone) {
+ if (standalone === ExploreStandaloneMode.HideNav) {
// dom manipulation hack to get rid of the bootstrap theme's body
background
const standaloneClass = 'background-transparent';
const bodyClasses = document.body.className.split(' ');
diff --git
a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
index f07ccd78fec..3a1c93709f3 100644
---
a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
+++
b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
@@ -27,6 +27,8 @@ import {
VizType,
} from '@superset-ui/core';
import { QUERY_MODE_REQUISITES } from 'src/explore/constants';
+import { URL_PARAMS } from 'src/constants';
+import { getUrlParam } from 'src/utils/urlUtils';
import { MemoryRouter, Route, Router } from 'react-router-dom';
import type { RouteComponentProps } from 'react-router-dom';
import { createMemoryHistory } from 'history';
@@ -117,10 +119,13 @@ jest.mock('../ExploreChartPanel', () => ({
standalone,
onQuery,
}: {
- standalone?: boolean;
+ standalone?: number;
onQuery?: () => void;
}) => {
const { useEffect, useRef } = jest.requireActual('react');
+ const { ExploreStandaloneMode } = jest.requireActual(
+ 'src/explore/constants',
+ );
const hasQueried = useRef(false);
useEffect(() => {
@@ -130,9 +135,17 @@ jest.mock('../ExploreChartPanel', () => ({
}
}, [onQuery]);
- return (
- <div data-test={standalone ? 'standalone-app' : 'explore-chart-panel'} />
- );
+ // Distinct markers per mode so a test can tell mode 2 from mode 0: both
+ // render the editor, and asserting only the absence of `standalone-app`
+ // would pass for either.
+ const marker =
+ standalone === ExploreStandaloneMode.HideNav
+ ? 'standalone-app'
+ : standalone === ExploreStandaloneMode.HideNavShowControls
+ ? 'explore-chart-panel-hide-nav'
+ : 'explore-chart-panel';
+
+ return <div data-test={marker} />;
},
}));
@@ -285,16 +298,60 @@ test('generates a new form_data param when none is
available', async () => {
replaceSpy.mockRestore();
});
-test('renders chart in standalone mode', () => {
- const { queryByTestId } = renderWithRouter({
- initialState: {
- ...reduxState,
- explore: { ...reduxState.explore, standalone: true },
+// Mirrors production: hydrateExplore seeds `explore.standalone` from
+// getUrlParam(URL_PARAMS.standalone), so it holds the coerced numeric mode
('true'
+// arrives as 1) or null. Deriving it from the same search string here keeps
the
+// fixture honest instead of hardcoding a boolean the app never stores.
+const standaloneState = (search: string) => ({
+ search,
+ initialState: {
+ ...reduxState,
+ explore: {
+ ...reduxState.explore,
+ standalone: getUrlParam(URL_PARAMS.standalone, search),
},
- });
+ },
+});
+
+test('renders chart in standalone mode', () => {
+ const { queryByTestId } = renderWithRouter(standaloneState('?standalone=1'));
+ expect(queryByTestId('standalone-app')).toBeInTheDocument();
+});
+
+test('preserves legacy standalone=true as chart-only mode', () => {
+ // Backwards compatibility for links created before numeric modes existed.
+ // `standalone` is declared a number param, so getUrlParam maps 'true' to 1;
+ // the backend also still treats 'true' as standalone. Old bookmarks, embeds
+ // and report URLs must keep rendering chart-only rather than the full
editor.
+ const { queryByTestId } = renderWithRouter(
+ standaloneState('?standalone=true'),
+ );
+ expect(queryByTestId('standalone-app')).toBeInTheDocument();
+});
+
+test('renders chart-only for report captures (standalone=3)', () => {
+ // ChartStandaloneMode.REPORT. Report and thumbnail captures must stay
+ // chart-only; 3 is also what the backend's truthiness check accepts, so this
+ // doubles as coverage for any value it allows that is not the mode 2 opt-in.
+ const { queryByTestId } = renderWithRouter(standaloneState('?standalone=3'));
expect(queryByTestId('standalone-app')).toBeInTheDocument();
});
+test('renders full editor in standalone=2 mode (hide nav, show controls)', ()
=> {
+ const { queryByTestId } = renderWithRouter(standaloneState('?standalone=2'));
+ expect(queryByTestId('standalone-app')).not.toBeInTheDocument();
+ // Positive assertion on the mode-2 marker: without it this test would also
+ // pass for mode 0, since both render the editor.
+ expect(queryByTestId('explore-chart-panel-hide-nav')).toBeInTheDocument();
+});
+
+test('renders the normal editor when standalone is absent', () => {
+ const { queryByTestId } = renderWithRouter({ search: '' });
+ expect(queryByTestId('explore-chart-panel')).toBeInTheDocument();
+ expect(queryByTestId('standalone-app')).not.toBeInTheDocument();
+
expect(queryByTestId('explore-chart-panel-hide-nav')).not.toBeInTheDocument();
+});
+
test('generates a form_data param with datasource_id when mounting with
existing key', async () => {
const history = createMemoryHistory({
initialEntries: [`${defaultPath}${SEARCH}`],
diff --git
a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index c54a6b5c51e..d67dc1a380b 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -64,7 +64,10 @@ import {
LocalStorageKeys,
} from 'src/utils/localStorageHelpers';
import { RESERVED_CHART_URL_PARAMS, URL_PARAMS } from 'src/constants';
-import { QUERY_MODE_REQUISITES } from 'src/explore/constants';
+import {
+ QUERY_MODE_REQUISITES,
+ ExploreStandaloneMode,
+} from 'src/explore/constants';
import { areObjectsEqual } from 'src/reduxUtils';
import * as logActions from 'src/logger/actions';
import {
@@ -245,6 +248,8 @@ const updateHistory = debounce(
standalone ? URL_PARAMS.standalone.name : 'base',
{
[URL_PARAMS.formDataKey.name]: key ?? '',
+ // Carry the active mode through, so mode 2 is not rewritten to 1.
+ ...(standalone ? { [URL_PARAMS.standalone.name]: standalone } :
{}),
...additionalParam,
},
force,
@@ -333,7 +338,9 @@ interface ExploreRootState {
can_overwrite: boolean;
sliceName?: string;
triggerRender: boolean;
- standalone: boolean;
+ // Seeded by hydrateExplore from getUrlParam(URL_PARAMS.standalone), so
this is
+ // the coerced numeric mode, or null when the param is absent or
unparseable.
+ standalone: number | null;
force: boolean;
form_data?: QueryFormData;
saveAction?: SaveActionType | null;
@@ -380,7 +387,7 @@ interface StateProps {
form_data: QueryFormData;
table_name?: string;
vizType?: string;
- standalone: boolean;
+ standalone: number;
force: boolean;
chart: ChartState;
timeout: number;
@@ -1027,7 +1034,7 @@ function ExploreViewContainer(props:
ExploreViewContainerProps) {
);
}
- if (props.standalone) {
+ if (props.standalone === ExploreStandaloneMode.HideNav) {
return renderChartContainer();
}
@@ -1391,7 +1398,18 @@ function mapStateToProps(state: ExploreRootState) {
form_data: patchedFormData,
table_name: datasource.table_name,
vizType: form_data.viz_type,
- standalone: !!explore.standalone,
+ // `explore.standalone` is the URL param coerced by getUrlParam
(hydrateExplore
+ // sets it), so 'true' arrives as 1 and 'false' as 0. Mode 2 is the only
value
+ // that keeps the editor; every other truthy mode renders chart-only,
which is
+ // what any truthy `standalone` did before granular modes existed. Values
+ // getUrlParam cannot parse arrive as null and render normally, matching
the
+ // pre-existing behaviour for unparseable params.
+ standalone:
+ explore.standalone === ExploreStandaloneMode.HideNavShowControls
+ ? ExploreStandaloneMode.HideNavShowControls
+ : explore.standalone
+ ? ExploreStandaloneMode.HideNav
+ : ExploreStandaloneMode.None,
force: !!explore.force,
chart,
timeout: common.conf.SUPERSET_WEBSERVER_TIMEOUT,
diff --git a/superset-frontend/src/explore/constants.ts
b/superset-frontend/src/explore/constants.ts
index 93f56ceab08..73d11c15246 100644
--- a/superset-frontend/src/explore/constants.ts
+++ b/superset-frontend/src/explore/constants.ts
@@ -223,6 +223,12 @@ export const POPOVER_INITIAL_WIDTH = 320;
export const CONTROL_SECTIONS_ID = 'controlSections';
export const UNSAVED_CHART_ID = 0;
+export enum ExploreStandaloneMode {
+ None = 0,
+ HideNav = 1,
+ HideNavShowControls = 2,
+}
+
export const QUERY_MODE_REQUISITES = new Set([
'all_columns',
'groupby',
diff --git a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
index 478eba7ace4..ade6815594c 100644
--- a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
+++ b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
@@ -23,6 +23,7 @@ import {
getExploreUrl,
getSimpleSQLExpression,
getQuerySettings,
+ mountExploreUrl,
} from 'src/explore/exploreUtils';
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
import * as hostNamesConfig from 'src/utils/hostNamesConfig';
@@ -272,4 +273,28 @@ describe('exploreUtils', () => {
expect(postFormSpy).toHaveBeenCalledTimes(1);
});
});
+
+ // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
+ describe('.mountExploreUrl() standalone mode', () => {
+ // Explore calls history.replace with this URL after interactions. Writing
+ // HideNav unconditionally downgraded mode 2 to mode 1 on the first click,
+ // collapsing the editor to a bare chart.
+ test('preserves a caller-supplied mode 2', () => {
+ expect(mountExploreUrl('standalone', { standalone: 2 })).toContain(
+ 'standalone=2',
+ );
+ });
+
+ test('keeps mode 1 as mode 1', () => {
+ expect(mountExploreUrl('standalone', { standalone: 1 })).toContain(
+ 'standalone=1',
+ );
+ });
+
+ test('falls back to HideNav when the caller supplies nothing', () => {
+ expect(mountExploreUrl('standalone')).toContain(
+ `standalone=${DashboardStandaloneMode.HideNav}`,
+ );
+ });
+ });
});
diff --git a/superset-frontend/src/explore/exploreUtils/index.ts
b/superset-frontend/src/explore/exploreUtils/index.ts
index 08cbc27a848..1008aadc1e8 100644
--- a/superset-frontend/src/explore/exploreUtils/index.ts
+++ b/superset-frontend/src/explore/exploreUtils/index.ts
@@ -178,7 +178,13 @@ export function mountExploreUrl(
if (force) {
search.force = '1';
}
- search.standalone = DashboardStandaloneMode.HideNav;
+ // Callers pass the active mode through extraSearch (already merged above).
+ // Only default when absent — unconditionally writing HideNav downgrades
+ // mode 2 (hide nav, keep editor controls) to mode 1 (chart only) on every
+ // history.replace an Explore interaction triggers.
+ if (search.standalone === undefined) {
+ search.standalone = DashboardStandaloneMode.HideNav;
+ }
}
return uri.directory(directory).search(search).toString();
}
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts
b/superset-frontend/src/explore/reducers/exploreReducer.ts
index 2cbdc69c47c..8e08b5cdd2e 100644
--- a/superset-frontend/src/explore/reducers/exploreReducer.ts
+++ b/superset-frontend/src/explore/reducers/exploreReducer.ts
@@ -60,7 +60,7 @@ export interface ExploreState {
slice?: Slice | null;
sliceName?: string;
controlsTransferred?: string[];
- standalone?: boolean;
+ standalone?: number | null;
force?: boolean;
common?: {
conf: {
diff --git a/superset-frontend/src/explore/types.ts
b/superset-frontend/src/explore/types.ts
index 7d3344004db..1ec9973b37a 100644
--- a/superset-frontend/src/explore/types.ts
+++ b/superset-frontend/src/explore/types.ts
@@ -179,7 +179,10 @@ export interface ExplorePageState {
hiddenFormData?: Partial<QueryFormData>;
slice: Slice;
controlsTransferred: string[];
- standalone: boolean;
+ // Set by hydrateExplore from getUrlParam(URL_PARAMS.standalone), so it is
the
+ // coerced numeric mode (or null when absent/unparseable), not the
backend's
+ // boolean `is_standalone_mode()`. See ExploreViewContainer's
mapStateToProps.
+ standalone: number | null;
force: boolean;
common: JsonObject;
compatibility?: CompatibilityResult;
diff --git a/superset-frontend/src/utils/urlUtils.test.ts
b/superset-frontend/src/utils/urlUtils.test.ts
index a3a5d468ed4..15a58bf693e 100644
--- a/superset-frontend/src/utils/urlUtils.test.ts
+++ b/superset-frontend/src/utils/urlUtils.test.ts
@@ -137,6 +137,29 @@ test('getDashboardUrlParams should exclude multiple
parameters when provided', (
locationSpy.mockRestore();
});
+test.each([
+ // Legacy form. Links, bookmarks and report URLs created before numeric
+ // standalone modes existed carry `standalone=true`, and must keep resolving
to
+ // HideNav (1) rather than null, which callers would read as "not
standalone".
+ ['?standalone=true', 1],
+ ['?standalone=TRUE', 1],
+ ['?standalone=false', 0],
+ // Canonical numeric modes.
+ ['?standalone=0', 0],
+ ['?standalone=1', 1],
+ ['?standalone=2', 2],
+ // Report/thumbnail captures (ChartStandaloneMode.REPORT).
+ ['?standalone=3', 3],
+ // Absent or unparseable yields null so callers can apply their own default.
+ ['', null],
+ ['?standalone=banana', null],
+])(
+ 'getUrlParam coerces %s to %p for the numeric standalone param',
+ (search, expected) => {
+ expect(getUrlParam(URL_PARAMS.standalone, search)).toBe(expected);
+ },
+);
+
test('getUrlParam reads from window.location.search by default', () => {
const locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py
index cd331a8b3e6..4793bea6d6b 100644
--- a/superset/utils/screenshots.py
+++ b/superset/utils/screenshots.py
@@ -678,10 +678,15 @@ class ChartScreenshot(BaseScreenshot):
window_size: WindowSize | None = None,
thumb_size: WindowSize | None = None,
):
- # Chart reports are in standalone="true" mode
+ # Chart reports render chart-only, with no nav and no editor controls.
+ # REPORT rather than HIDE_NAV so the frontend can distinguish an
automated
+ # capture from a live standalone embed: `isReportScreenshotMode()` in
+ # plugin-chart-echarts suppresses animation for captures, which
prevents a
+ # screenshot catching a chart mid-draw. HIDE_NAV (1) is a live embed
there
+ # and deliberately keeps its animation.
url = modify_url_query(
url,
- standalone=ChartStandaloneMode.HIDE_NAV.value,
+ standalone=ChartStandaloneMode.REPORT.value,
)
super().__init__(url, digest)
self.window_size = window_size or DEFAULT_CHART_WINDOW_SIZE
diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py
index d7ae9618d05..e32066a5617 100644
--- a/superset/utils/webdriver.py
+++ b/superset/utils/webdriver.py
@@ -164,8 +164,14 @@ class DashboardStandaloneMode(Enum):
class ChartStandaloneMode(Enum):
- HIDE_NAV = "true"
SHOW_NAV = 0
+ HIDE_NAV = 1
+ HIDE_NAV_SHOW_CONTROLS = 2
+ # Report/thumbnail captures, mirroring DashboardStandaloneMode.REPORT. Kept
+ # distinct from HIDE_NAV so the frontend can tell an automated capture
from a
+ # live chart-only embed: ECharts suppresses animation for captures only,
and
+ # treating a live embed as a capture would disable its animation too.
+ REPORT = 3
# pylint: disable=too-few-public-methods
diff --git a/tests/unit_tests/utils/screenshot_test.py
b/tests/unit_tests/utils/screenshot_test.py
index 12ba5f64701..99c9ff60e7f 100644
--- a/tests/unit_tests/utils/screenshot_test.py
+++ b/tests/unit_tests/utils/screenshot_test.py
@@ -730,3 +730,60 @@ class TestScreenshotSubclassesDriverBehavior:
assert driver._window == custom_window_size
assert chart_screenshot.thumb_size == custom_thumb_size
+
+
+class TestScreenshotStandaloneMode:
+ """Pin the standalone mode each screenshot type requests.
+
+ The frontend distinguishes automated captures from live standalone embeds
by
+ this value: `isReportScreenshotMode()` in plugin-chart-echarts suppresses
+ animation for captures only, so a capture must not be indistinguishable
from
+ a live embed or screenshots can catch a chart mid-draw.
+ """
+
+ def test_chart_screenshot_requests_report_mode(self):
+ """Chart captures request standalone=3, not the live-embed value 1."""
+ from superset.utils.webdriver import ChartStandaloneMode
+
+ chart_screenshot = ChartScreenshot("http://example.com/chart",
"digest")
+
+ assert f"standalone={ChartStandaloneMode.REPORT.value}" in
chart_screenshot.url
+ assert "standalone=3" in chart_screenshot.url
+ assert "standalone=1" not in chart_screenshot.url
+
+ def test_dashboard_screenshot_requests_report_mode(self):
+ """Dashboard captures already used report mode; keep them aligned."""
+ from superset.utils.screenshots import DashboardScreenshot
+ from superset.utils.webdriver import DashboardStandaloneMode
+
+ dashboard_screenshot = DashboardScreenshot(
+ "http://example.com/dashboard/1/", "digest"
+ )
+
+ assert (
+ f"standalone={DashboardStandaloneMode.REPORT.value}"
+ in dashboard_screenshot.url
+ )
+
+ def test_standalone_modes_are_all_integers(self):
+ """Both enums stay integer-valued so URL modes read consistently.
+
+ `ChartStandaloneMode.HIDE_NAV` was previously the string "true"; the
+ numeric form is what `getUrlParam` normalises legacy "true" links onto.
+ """
+ from superset.utils.webdriver import (
+ ChartStandaloneMode,
+ DashboardStandaloneMode,
+ )
+
+ for mode in (*ChartStandaloneMode, *DashboardStandaloneMode):
+ assert isinstance(mode.value, int), f"{mode!r} is not an int"
+
+ def test_chart_and_dashboard_report_values_match(self):
+ """A single report sentinel keeps the frontend check simple."""
+ from superset.utils.webdriver import (
+ ChartStandaloneMode,
+ DashboardStandaloneMode,
+ )
+
+ assert ChartStandaloneMode.REPORT.value ==
DashboardStandaloneMode.REPORT.value