mirror of
https://github.com/apache/superset.git
synced 2026-07-07 15:25:36 +00:00
Compare commits
3 Commits
chart-samp
...
fix-databa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985ebb796e | ||
|
|
b304f33a9b | ||
|
|
83d93b8b42 |
@@ -26,7 +26,12 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry, QueryData, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
getExtensionsRegistry,
|
||||
JsonObject,
|
||||
QueryData,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
css,
|
||||
styled,
|
||||
@@ -58,6 +63,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & {
|
||||
filters: object;
|
||||
handleToggleFullSize: () => void;
|
||||
formData: object;
|
||||
ownState?: JsonObject;
|
||||
width: number;
|
||||
height: number;
|
||||
queriedDttm?: string | null;
|
||||
@@ -174,6 +180,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
height,
|
||||
exportPivotExcel = () => ({}),
|
||||
chartHolderRef,
|
||||
ownState,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -381,6 +388,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
crossFiltersEnabled={isCrossFiltersEnabled}
|
||||
exportPivotExcel={exportPivotExcel}
|
||||
chartHolderRef={chartHolderRef}
|
||||
ownState={ownState}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getChartMetadataRegistry,
|
||||
VizType,
|
||||
BinaryQueryObjectFilterClause,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { css, useTheme, styled } from '@apache-superset/core/theme';
|
||||
@@ -140,6 +141,8 @@ export interface SliceHeaderControlsProps {
|
||||
supersetCanDownload?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
||||
RouteComponentProps;
|
||||
@@ -486,7 +489,12 @@ const SliceHeaderControls = (
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
|
||||
modalBody={
|
||||
<ViewQueryModal
|
||||
latestQueryFormData={props.formData}
|
||||
ownState={props.ownState}
|
||||
/>
|
||||
}
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
|
||||
export interface SliceHeaderControlsProps {
|
||||
slice: {
|
||||
@@ -60,4 +60,5 @@ export interface SliceHeaderControlsProps {
|
||||
supersetCanDownload?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
@@ -480,6 +480,26 @@ const Chart = (props: ChartProps) => {
|
||||
|
||||
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
||||
|
||||
// Memoize ownState so it keeps a stable reference across re-renders that
|
||||
// don't change its logical value. ViewQueryModal depends on ownState; a fresh
|
||||
// object on every render would refetch the query unnecessarily.
|
||||
const ownState = useMemo(
|
||||
() =>
|
||||
createOwnStateWithChartState(
|
||||
(dataMaskOwnState as JsonObject) || EMPTY_OBJECT,
|
||||
{
|
||||
state:
|
||||
getChartStateWithFallback(
|
||||
chartState as { state?: JsonObject } | undefined,
|
||||
formData as JsonObject,
|
||||
sliceVizType,
|
||||
) ?? undefined,
|
||||
},
|
||||
sliceVizType,
|
||||
),
|
||||
[dataMaskOwnState, chartState, formData, sliceVizType],
|
||||
);
|
||||
|
||||
const exportTable = useCallback(
|
||||
async (format: string, isFullCSV: boolean, isPivot = false) => {
|
||||
const logAction =
|
||||
@@ -727,6 +747,7 @@ const Chart = (props: ChartProps) => {
|
||||
height={getHeaderHeight()}
|
||||
exportPivotExcel={exportPivotExcel as unknown as (arg0: string) => void}
|
||||
chartHolderRef={props.chartHolderRef}
|
||||
ownState={ownState}
|
||||
/>
|
||||
|
||||
{/*
|
||||
@@ -777,18 +798,7 @@ const Chart = (props: ChartProps) => {
|
||||
formData={
|
||||
formData as unknown as import('@superset-ui/core').QueryFormData
|
||||
}
|
||||
ownState={createOwnStateWithChartState(
|
||||
(dataMask[props.id]?.ownState as JsonObject) || EMPTY_OBJECT,
|
||||
{
|
||||
state:
|
||||
getChartStateWithFallback(
|
||||
chartState as { state?: JsonObject } | undefined,
|
||||
formData as JsonObject,
|
||||
slice.viz_type,
|
||||
) ?? undefined,
|
||||
},
|
||||
slice.viz_type,
|
||||
)}
|
||||
ownState={ownState}
|
||||
queriesResponse={chart.queriesResponse ?? null}
|
||||
timeout={timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import { screen, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import * as chartAction from 'src/components/Chart/chartAction';
|
||||
import type { ChartDataRequestResponse } from 'src/components/Chart/chartAction';
|
||||
import ViewQueryModal from './ViewQueryModal';
|
||||
|
||||
const mockFormData = {
|
||||
@@ -26,9 +28,19 @@ const mockFormData = {
|
||||
viz_type: 'table',
|
||||
};
|
||||
|
||||
// Minimal, type-correct response that satisfies ChartDataRequestResponse.
|
||||
// A real Response instance avoids the 16 required Response fields that an
|
||||
// empty object ({}) fails to overlap. The assertions only inspect the call
|
||||
// arguments, never the resolved value's contents.
|
||||
const mockChartDataResponse: ChartDataRequestResponse = {
|
||||
response: new Response(),
|
||||
json: { result: [] },
|
||||
};
|
||||
|
||||
const chartDataEndpoint = 'glob:*/api/v1/chart/data*';
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
@@ -117,3 +129,91 @@ test('renders both Alert and SQL query when parsing error occurs', async () => {
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
test('passes ownState through to getChartDataRequest', async () => {
|
||||
/**
|
||||
* Regression test for PR #35208 - the ViewQueryModal must forward the
|
||||
* chart's ownState (e.g. table search text, order_by) to the data request
|
||||
* so that the displayed SQL reflects the same filters applied to the chart.
|
||||
*/
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(chartAction, 'getChartDataRequest')
|
||||
.mockResolvedValue(mockChartDataResponse);
|
||||
|
||||
const ownState = { searchText: 'foo', order_by: [['col', 'asc']] };
|
||||
|
||||
render(
|
||||
<ViewQueryModal latestQueryFormData={mockFormData} ownState={ownState} />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: mockFormData,
|
||||
ownState,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('strips clientView from ownState before the query request', async () => {
|
||||
/**
|
||||
* clientView holds the full client-side row/column snapshot (added by
|
||||
* TableChart) and is irrelevant to SQL generation. It must be stripped
|
||||
* before the request - matching ExploreViewContainer and Dashboard - to
|
||||
* avoid bloating the payload (or triggering 413) on large tables.
|
||||
*/
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(chartAction, 'getChartDataRequest')
|
||||
.mockResolvedValue(mockChartDataResponse);
|
||||
|
||||
const ownState = {
|
||||
searchText: 'foo',
|
||||
// Simulate a large client-side snapshot that TableChart writes
|
||||
clientView: { rows: [{ a: 1 }, { a: 2 }], columns: ['a'] },
|
||||
};
|
||||
|
||||
render(
|
||||
<ViewQueryModal latestQueryFormData={mockFormData} ownState={ownState} />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const calledOwnState = getChartDataRequestSpy.mock.calls[0][0].ownState;
|
||||
expect(calledOwnState).not.toHaveProperty('clientView');
|
||||
expect(calledOwnState).toEqual(
|
||||
expect.objectContaining({ searchText: 'foo' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to empty ownState when prop is omitted', async () => {
|
||||
/**
|
||||
* Covers the `ownState || {}` fallback branch in ViewQueryModal - when no
|
||||
* ownState is provided, the data request must still be called with an empty
|
||||
* object rather than undefined, matching getChartDataRequest's contract.
|
||||
*/
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(chartAction, 'getChartDataRequest')
|
||||
.mockResolvedValue(mockChartDataResponse);
|
||||
|
||||
render(<ViewQueryModal latestQueryFormData={mockFormData} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(getChartDataRequestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: mockFormData,
|
||||
ownState: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FC, Fragment, useEffect, useState } from 'react';
|
||||
import { FC, Fragment, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
ensureIsArray,
|
||||
getClientErrorObject,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
@@ -33,6 +35,7 @@ import ViewQuery from 'src/explore/components/controls/ViewQuery';
|
||||
|
||||
interface Props {
|
||||
latestQueryFormData: QueryFormData;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
type Result = {
|
||||
@@ -48,38 +51,47 @@ const ViewQueryModalContainer = styled.div`
|
||||
gap: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
|
||||
const ViewQueryModal: FC<Props> = ({ latestQueryFormData, ownState }) => {
|
||||
const [result, setResult] = useState<Result[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadChartData = (resultType: string) => {
|
||||
setIsLoading(true);
|
||||
getChartDataRequest({
|
||||
formData: latestQueryFormData,
|
||||
resultFormat: 'json',
|
||||
resultType,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setResult(ensureIsArray(json.result) as Result[]);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
const loadChartData = useCallback(
|
||||
(resultType: string) => {
|
||||
setIsLoading(true);
|
||||
// Strip clientView (client-side row/column snapshot) from ownState before
|
||||
// requesting the query, matching the chart query path in ExploreViewContainer
|
||||
// and Dashboard's activeAllDashboardFilters. clientView is irrelevant to SQL
|
||||
// generation and can bloat the payload (or trigger 413) on large tables.
|
||||
const ownStateForQuery = omit(ownState, ['clientView']) || {};
|
||||
getChartDataRequest({
|
||||
formData: latestQueryFormData,
|
||||
resultFormat: 'json',
|
||||
resultType,
|
||||
ownState: ownStateForQuery,
|
||||
})
|
||||
.catch(response => {
|
||||
getClientErrorObject(response).then(({ error, message }) => {
|
||||
setError(
|
||||
error ||
|
||||
message ||
|
||||
response.statusText ||
|
||||
t('Sorry, An error occurred'),
|
||||
);
|
||||
.then(({ json }) => {
|
||||
setResult(ensureIsArray(json.result) as Result[]);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
})
|
||||
.catch(response => {
|
||||
getClientErrorObject(response).then(({ error, message }) => {
|
||||
setError(
|
||||
error ||
|
||||
message ||
|
||||
response.statusText ||
|
||||
t('Sorry, An error occurred'),
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
[latestQueryFormData, ownState],
|
||||
);
|
||||
useEffect(() => {
|
||||
loadChartData('query');
|
||||
}, [JSON.stringify(latestQueryFormData)]);
|
||||
}, [loadChartData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
|
||||
@@ -1067,6 +1067,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
modalBody={
|
||||
<ViewQueryModal
|
||||
latestQueryFormData={latestQueryFormData as QueryFormData}
|
||||
ownState={ownState}
|
||||
/>
|
||||
}
|
||||
draggable
|
||||
|
||||
@@ -1804,8 +1804,17 @@ describe('DatabaseModal', () => {
|
||||
|
||||
userEvent.click(screen.getByTestId('sqla-connect-btn'));
|
||||
|
||||
expect(await screen.findByTestId('database-name-input')).toBeVisible();
|
||||
expect(screen.getByTestId('sqlalchemy-uri-input')).toBeVisible();
|
||||
// assert on presence rather than visibility: the SQLAlchemy form mounts
|
||||
// inside an animated tab pane, and rc-motion's animation state in jsdom
|
||||
// is nondeterministic, so toBeVisible flakes while the form is in fact
|
||||
// rendered (see the animated={{ tabPane: true }} Tabs in DatabaseModal)
|
||||
const nameInput = await screen.findByTestId('database-name-input');
|
||||
const uriInput = screen.getByTestId('sqlalchemy-uri-input');
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
expect(uriInput).toBeInTheDocument();
|
||||
// also confirm the form is actually usable, not just present
|
||||
expect(nameInput).toBeEnabled();
|
||||
expect(uriInput).toBeEnabled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
Reference in New Issue
Block a user