diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index 9326b63b485..100c3d2f9eb 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -50,7 +50,6 @@ import Table, { import { RootState } from 'src/dashboard/types'; import { usePermissions } from 'src/hooks/usePermissions'; import { useToasts } from 'src/components/MessageToasts/withToasts'; -import { ensureAppRoot } from 'src/utils/pathUtils'; import { safeStringify } from 'src/utils/safeStringify'; import HeaderWithRadioGroup from '@superset-ui/core/components/Table/header-renderers/HeaderWithRadioGroup'; import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar'; @@ -249,7 +248,7 @@ export default function DrillDetailPane({ if (dashboardId) { payload.form_data = { dashboardId }; } - SupersetClient.postForm(ensureAppRoot('/api/v1/chart/data'), { + SupersetClient.postForm('/api/v1/chart/data', { form_data: safeStringify(payload), }).catch(error => { addDangerToast( diff --git a/superset-frontend/src/components/Chart/chartAction.ts b/superset-frontend/src/components/Chart/chartAction.ts index 40d8ed79b16..e5d9345b1cf 100644 --- a/superset-frontend/src/components/Chart/chartAction.ts +++ b/superset-frontend/src/components/Chart/chartAction.ts @@ -48,7 +48,6 @@ import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils'; import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig'; import { updateDataMask } from 'src/dataMask/actions'; import { waitForAsyncData } from 'src/middleware/asyncEvent'; -import { ensureAppRoot } from 'src/utils/pathUtils'; import { safeStringify } from 'src/utils/safeStringify'; import { extendedDayjs } from '@superset-ui/core/utils/dates'; import type { Dispatch, Action, AnyAction } from 'redux'; @@ -934,7 +933,7 @@ export function redirectSQLLab( requestedQuery: payload, }); } else { - SupersetClient.postForm(ensureAppRoot(redirectUrl), { + SupersetClient.postForm(redirectUrl, { form_data: safeStringify(payload), }); } diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts index 74c905f347a..96428babe99 100644 --- a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts +++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts @@ -58,7 +58,9 @@ beforeEach(() => { }); }); -// Tests for exportChart URL prefix handling in streaming export +// Tests for exportChart URL prefix handling in streaming export. +// Streaming uses native fetch (not SupersetClient), so exportChart must apply +// ensureAppRoot before passing the URL to onStartStreamingExport. test('exportChart v1 API passes prefixed URL to onStartStreamingExport when app root is configured', async () => { const appRoot = '/superset'; ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); @@ -111,6 +113,24 @@ test('exportChart v1 API passes nested prefix for deeply nested deployments', as expect(callArgs.exportType).toBe('xlsx'); }); +// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot +// internally via getUrl(), so the URL passed must NOT already be prefixed. +test('exportChart v1 API calls postForm with unprefixed URL when app root is configured', async () => { + const { SupersetClient } = jest.requireMock('@superset-ui/core'); + const appRoot = '/analytics'; + ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); + + await exportChart({ + formData: baseFormData, + resultFormat: 'csv', + }); + + expect(SupersetClient.postForm).toHaveBeenCalledTimes(1); + const [url] = SupersetClient.postForm.mock.calls[0]; + expect(url).toBe('/api/v1/chart/data'); + expect(url).not.toContain(appRoot); +}); + test('exportChart passes csv exportType for CSV exports', async () => { const onStartStreamingExport = jest.fn(); @@ -143,7 +163,7 @@ test('exportChart passes xlsx exportType for Excel exports', async () => { ); }); -test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app root configured', async () => { +test('exportChart legacy API (useLegacyApi=true) passes prefixed URL to onStartStreamingExport when app root is configured', async () => { const appRoot = '/superset'; ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); @@ -165,6 +185,8 @@ test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app ro expect(onStartStreamingExport).toHaveBeenCalledTimes(1); const callArgs = onStartStreamingExport.mock.calls[0][0]; + // The legacy blueprint path is /superset/explore_json/; with appRoot=/superset the + // full streaming URL is /superset/superset/explore_json/ (appRoot + blueprint prefix). expect(callArgs.url).toBe('/superset/superset/explore_json/?csv=true'); expect(callArgs.exportType).toBe('csv'); }); diff --git a/superset-frontend/src/explore/exploreUtils/index.ts b/superset-frontend/src/explore/exploreUtils/index.ts index e0207f22dd7..23771a9ba97 100644 --- a/superset-frontend/src/explore/exploreUtils/index.ts +++ b/superset-frontend/src/explore/exploreUtils/index.ts @@ -76,6 +76,7 @@ interface GetExploreUrlParams { allowDomainSharding?: boolean; method?: 'GET' | 'POST'; relative?: boolean; + includeAppRoot?: boolean; } interface BuildV1ChartDataPayloadParams { @@ -223,6 +224,7 @@ export function getExploreUrl({ allowDomainSharding = false, method = 'POST', relative = false, + includeAppRoot = true, }: GetExploreUrlParams): string | null { if (!formData.datasource) { return null; @@ -242,7 +244,7 @@ export function getExploreUrl({ uri = URI(URI(curUrl).search()); } - const directory = getURIDirectory(endpointType); + const directory = getURIDirectory(endpointType, includeAppRoot); // Building the querystring (search) part of the URI const search = uri.search(true) as Record; @@ -370,10 +372,11 @@ export const exportChart = async ({ force, allowDomainSharding: false, relative: true, + includeAppRoot: false, }); payload = formData; } else { - url = ensureAppRoot('/api/v1/chart/data'); + url = '/api/v1/chart/data'; payload = await buildV1ChartDataPayload({ formData, force, @@ -385,14 +388,16 @@ export const exportChart = async ({ // Check if streaming export handler is provided (from dashboard Chart.jsx) if (onStartStreamingExport) { - // Streaming is handled by the caller - pass URL, payload, and export type + // Streaming uses native fetch — apply appRoot prefix here since useStreamingExport + // does not go through SupersetClient (which would add it automatically). onStartStreamingExport({ - url, + url: url ? ensureAppRoot(url) : url, payload, exportType: resultFormat, }); } else { - // Fallback to original behavior for non-streaming exports + // SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends + // appRoot — so the URL must NOT be pre-prefixed here. SupersetClient.postForm(url as string, { form_data: safeStringify(payload), });