Compare commits

...

3 Commits

Author SHA1 Message Date
Evan
985ebb796e test(databases): assert form inputs are enabled after SQLAlchemy switch
Strengthens the presence check to also confirm the form fields are
usable, per bito-code-review feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:26:41 -07:00
Claude Code
b304f33a9b test(databases): stabilize flaky SQLAlchemy-form visibility assertion
The form mounts inside DatabaseModal's animated tab pane, and
rc-motion's animation state in jsdom is nondeterministic: toBeVisible
intermittently times out (observed on unrelated PRs' jest shards) even
though the form is rendered. Assert on document presence instead, which
is what the tab-switch regression check actually needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:17:11 -07:00
marun
83d93b8b42 fix(view-query): Add ownState parameter to ViewQueryModal for query consistency (#35208)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-06 11:16:55 -07:00
8 changed files with 190 additions and 41 deletions

View File

@@ -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}
/>
)}
</>

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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: {},
}),
);
});

View File

@@ -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 />;

View File

@@ -1067,6 +1067,7 @@ export const useExploreAdditionalActionsMenu = (
modalBody={
<ViewQueryModal
latestQueryFormData={latestQueryFormData as QueryFormData}
ownState={ownState}
/>
}
draggable

View File

@@ -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([