mirror of
https://github.com/apache/superset.git
synced 2026-07-06 23:05:36 +00:00
Compare commits
3 Commits
chart-samp
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebdd3f29f7 | ||
|
|
ad8e6d4140 | ||
|
|
83d93b8b42 |
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -10,11 +10,7 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
ignore:
|
||||
- dependency-name: "react-error-boundary"
|
||||
- dependency-name: "@rjsf/*"
|
||||
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
|
||||
- dependency-name: "remark-gfm"
|
||||
- dependency-name: "react-markdown"
|
||||
# TODO: remove below entries until React >= 19.0.0
|
||||
- dependency-name: "react-icons"
|
||||
# JSDOM v30 doesn't play well with Jest v30
|
||||
@@ -78,8 +74,6 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
ignore:
|
||||
- dependency-name: "react-error-boundary"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
@@ -376,11 +370,6 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-core/"
|
||||
ignore:
|
||||
# not until React >= 18.0.0
|
||||
- dependency-name: "react-markdown"
|
||||
- dependency-name: "remark-gfm"
|
||||
- dependency-name: "react-error-boundary"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
labels:
|
||||
|
||||
@@ -140,7 +140,7 @@ d1 = [
|
||||
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
|
||||
databricks = [
|
||||
"databricks-sql-connector==4.2.6",
|
||||
"databricks-sqlalchemy==1.0.5",
|
||||
"databricks-sqlalchemy==2.0.9",
|
||||
]
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
|
||||
|
||||
2627
superset-frontend/package-lock.json
generated
2627
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -393,7 +393,6 @@
|
||||
"http-proxy-middleware": "^2.0.10",
|
||||
"tar": "^7.5.16",
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"underscore": "^1.13.7",
|
||||
"jspdf": "^4.2.0",
|
||||
"nwsapi": "^2.2.13",
|
||||
|
||||
@@ -53,16 +53,16 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-markdown": "^9.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"reselect": "^5.2.0",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
// TODO: Upgrade to remark-gfm v4+ after migrating to React 18.
|
||||
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
|
||||
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { cloneDeep, mergeWith } from 'lodash-es';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
||||
@@ -139,7 +136,7 @@ export function SafeMarkdown({
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml={false}
|
||||
transformLinkUri={transformLinkUri}
|
||||
urlTransform={transformLinkUri}
|
||||
>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -221,12 +221,6 @@ test('should render the error', async () => {
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('Something went wrong'));
|
||||
await waitForRender();
|
||||
// The error is wrapped in an Alert component with a stable headline and the
|
||||
// raw error text in the description — no more bare ``<pre>`` elements.
|
||||
expect(await screen.findByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText('Failed to load drill-to-detail rows'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
|
||||
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
|
||||
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import Table, {
|
||||
ColumnsType,
|
||||
@@ -363,18 +362,13 @@ export default function DrillDetailPane({
|
||||
if (responseError) {
|
||||
// Render error if page download failed
|
||||
tableContent = (
|
||||
<div
|
||||
<pre
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load drill-to-detail rows')}
|
||||
description={responseError}
|
||||
/>
|
||||
</div>
|
||||
{responseError}
|
||||
</pre>
|
||||
);
|
||||
} else if (bootstrapping) {
|
||||
// Render loading if first page hasn't loaded
|
||||
|
||||
@@ -50,7 +50,6 @@ const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
DATASOURCE: t('Drill to detail is not available for this datasource type.'),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
@@ -117,17 +116,6 @@ export const useDrillDetailMenuItems = ({
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
// Capability flag on the datasource itself. Datasources that don't model
|
||||
// raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail``
|
||||
// in the explore data payload.
|
||||
const datasourceSupportsDrillToDetail = useSelector<
|
||||
RootState,
|
||||
boolean | undefined
|
||||
>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.supports_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => {
|
||||
onClick(event);
|
||||
@@ -170,10 +158,7 @@ export const useDrillDetailMenuItems = ({
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (datasourceSupportsDrillToDetail === false) {
|
||||
drillDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
drillByDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
} else if (drillToDetailDisabled) {
|
||||
if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
|
||||
@@ -444,45 +444,3 @@ test('context menu renders <NULL> for null dimension values', async () => {
|
||||
await expectDrillToDetailByEnabled();
|
||||
await expectDrillToDetailByDimension(filterNull);
|
||||
});
|
||||
|
||||
const buildStateWithUnsupportedDatasource = () => {
|
||||
const baseState = getMockStoreWithNativeFilters().getState();
|
||||
const datasourceKey = defaultFormData.datasource as string;
|
||||
return {
|
||||
...baseState,
|
||||
datasources: {
|
||||
...baseState.datasources,
|
||||
[datasourceKey]: {
|
||||
...baseState.datasources[datasourceKey],
|
||||
supports_drill_to_detail: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
await expectDrillToDetailDisabled(
|
||||
'Drill to detail is not available for this datasource type.',
|
||||
);
|
||||
await expectNoDrillToDetailBy();
|
||||
});
|
||||
|
||||
test('context menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} isContextMenu />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
const message = 'Drill to detail is not available for this datasource type.';
|
||||
await expectDrillToDetailDisabled(message);
|
||||
await expectDrillToDetailByDisabled(message);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -231,10 +231,6 @@ export type Datasource = Dataset & {
|
||||
column_types: GenericDataType[];
|
||||
table_name: string;
|
||||
database?: Database;
|
||||
/** False when the datasource can't return row samples (e.g. semantic views). */
|
||||
supports_samples?: boolean;
|
||||
/** False when the datasource can't answer drill-to-detail requests. */
|
||||
supports_drill_to_detail?: boolean;
|
||||
};
|
||||
export type DatasourcesState = {
|
||||
[key: string]: Datasource;
|
||||
|
||||
@@ -234,44 +234,25 @@ export const DataTablesPane = ({
|
||||
}
|
||||
}, [resultsTabFallback]);
|
||||
|
||||
// Hide the Samples tab for datasources that don't expose raw rows
|
||||
// (e.g. semantic views). The check is intentionally ``=== false`` so that
|
||||
// datasources from older backends that don't send the flag still show the
|
||||
// tab and preserve current behavior.
|
||||
const showSamplesTab = datasource?.supports_samples !== false;
|
||||
|
||||
// If the datasource swaps to one that doesn't support samples while the
|
||||
// Samples tab is active (e.g. the user picks a semantic view), the tab
|
||||
// disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back
|
||||
// to Results so the panel keeps rendering content.
|
||||
useEffect(() => {
|
||||
if (!showSamplesTab && activeTabKey === ResultTypes.Samples) {
|
||||
setActiveTabKey(ResultTypes.Results);
|
||||
}
|
||||
}, [showSamplesTab, activeTabKey]);
|
||||
const tabItems = [
|
||||
...queryResultsPanes,
|
||||
...(showSamplesTab
|
||||
? [
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,6 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
@@ -36,7 +35,7 @@ import {
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -156,14 +155,7 @@ export const SamplesPane = ({
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load samples')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
<Error>{responseError}</Error>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,14 +27,13 @@ import {
|
||||
QueryData,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneProps, QueryResultInterface } from '../types';
|
||||
import { SingleQueryResultPane } from './SingleQueryResultPane';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -200,14 +199,7 @@ export const useResultsPane = ({
|
||||
isLoading={false}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load results')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
<Error>{responseError}</Error>
|
||||
</>
|
||||
);
|
||||
return Array(queryCount).fill(err);
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
import * as copyUtils from 'src/utils/copy';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
import { DataTablesPane } from '..';
|
||||
@@ -94,48 +89,6 @@ describe('DataTablesPane', () => {
|
||||
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Hides Samples tab when datasource opts out via supports_samples=false', async () => {
|
||||
const props = createDataTablesPaneProps(0);
|
||||
const propsWithoutSamples = {
|
||||
...props,
|
||||
datasource: { ...props.datasource, supports_samples: false },
|
||||
};
|
||||
render(<DataTablesPane {...propsWithoutSamples} />, { useRedux: true });
|
||||
expect(await screen.findByText('Results')).toBeVisible();
|
||||
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Falls back to Results when active Samples tab disappears mid-session', async () => {
|
||||
// Regression for codeant Major finding on PR #41509: a datasource swap
|
||||
// that hides the Samples tab while it was the active tab used to leave
|
||||
// ``activeTabKey === 'samples'`` orphaned, rendering a blank panel.
|
||||
const props = createDataTablesPaneProps(0);
|
||||
const { rerender } = render(<DataTablesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Open the panel and pick the Samples tab.
|
||||
userEvent.click(screen.getByLabelText('Expand data panel'));
|
||||
userEvent.click(await screen.findByText('Samples'));
|
||||
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
|
||||
|
||||
// Swap to a datasource that doesn't support samples (e.g. a semantic
|
||||
// view). The Samples tab should disappear and the panel should land on
|
||||
// Results with content still rendered.
|
||||
rerender(
|
||||
<DataTablesPane
|
||||
{...props}
|
||||
datasource={{ ...props.datasource, supports_samples: false }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Results')).toBeVisible();
|
||||
// Panel stays expanded and renders Results content rather than going blank.
|
||||
expect(screen.getByLabelText('Collapse data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Should copy data table content correctly', async () => {
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',
|
||||
|
||||
@@ -84,14 +84,10 @@ describe('SamplesPane', () => {
|
||||
const props = createSamplesPaneProps({
|
||||
datasourceId: 36,
|
||||
});
|
||||
const { findByText, findByRole } = render(<SamplesPane {...props} />, {
|
||||
const { findByText } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// The error is now rendered inside an Alert component, with a clear
|
||||
// headline message and the raw error text as the description.
|
||||
expect(await findByRole('alert')).toBeVisible();
|
||||
expect(await findByText('Failed to load samples')).toBeVisible();
|
||||
expect(await findByText('Error: Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,18 +78,6 @@ export type Datasource = Dataset & {
|
||||
schema?: string;
|
||||
is_sqllab_view?: boolean;
|
||||
extra?: string | object;
|
||||
/**
|
||||
* False when the datasource (e.g. a semantic view) doesn't model raw rows
|
||||
* and therefore can't return a row sample. Defaults to true on the server
|
||||
* side; missing here means the explore UI keeps current behavior.
|
||||
*/
|
||||
supports_samples?: boolean;
|
||||
/**
|
||||
* False when the datasource doesn't model raw rows and therefore can't
|
||||
* answer a drill-to-detail query. Tracked separately from
|
||||
* ``supports_samples`` so the two capabilities can diverge.
|
||||
*/
|
||||
supports_drill_to_detail?: boolean;
|
||||
};
|
||||
|
||||
export interface ExplorePageInitialData {
|
||||
|
||||
@@ -230,15 +230,6 @@ def _get_drill_detail(
|
||||
# todo(yongjie): Remove this function,
|
||||
# when determining whether samples should be applied to the time filter.
|
||||
datasource = _get_datasource(query_context, query_obj)
|
||||
# Refuse for datasource types that don't model raw rows (e.g. semantic
|
||||
# views). Mirrors the ``supports_samples`` gate on the ``/samples``
|
||||
# endpoint so drill-detail is hard-blocked on the backend, not just
|
||||
# hidden in the frontend menu. Defaults to ``True`` for any datasource
|
||||
# class that doesn't explicitly opt out.
|
||||
if not getattr(datasource, "supports_drill_to_detail", True):
|
||||
raise QueryObjectValidationError(
|
||||
_("Drill to detail is not available for this datasource type.")
|
||||
)
|
||||
query_obj = copy.copy(query_obj)
|
||||
query_obj.is_timeseries = False
|
||||
query_obj.metrics = None
|
||||
|
||||
@@ -194,16 +194,6 @@ class BaseDatasource(
|
||||
# Only some datasources support Row Level Security
|
||||
is_rls_supported: bool = False
|
||||
|
||||
# Datasources that can return raw row samples (anything backed by a SQL
|
||||
# table can; semantic-layer abstractions cannot, since they only expose
|
||||
# pre-defined metrics and dimensions).
|
||||
supports_samples: bool = True
|
||||
|
||||
# Datasources that can answer "drill to detail" requests — i.e. fetch the
|
||||
# raw rows underlying a chart cell. Conceptually similar to ``samples``
|
||||
# but kept as a separate capability so the two can diverge.
|
||||
supports_drill_to_detail: bool = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
# can be a Column or a property pointing to one
|
||||
@@ -511,8 +501,6 @@ class BaseDatasource(
|
||||
"owners": [owner.id for owner in self.owners],
|
||||
"verbose_map": self.verbose_map,
|
||||
"select_star": self.select_star,
|
||||
"supports_samples": self.supports_samples,
|
||||
"supports_drill_to_detail": self.supports_drill_to_detail,
|
||||
}
|
||||
|
||||
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901
|
||||
|
||||
@@ -196,12 +196,6 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
|
||||
__tablename__ = "semantic_views"
|
||||
|
||||
# Semantic views expose pre-defined metrics and dimensions, not raw rows,
|
||||
# so neither the "Samples" tab in Explore nor the "Drill to detail"
|
||||
# affordance from the chart 3-dots menu can return anything meaningful.
|
||||
supports_samples: bool = False
|
||||
supports_drill_to_detail: bool = False
|
||||
|
||||
# Use integer as the primary key for cross-database auto-increment
|
||||
# compatibility (sa.Identity() is not supported in MySQL or SQLite).
|
||||
# The uuid column is a secondary unique identifier used in URLs and perms.
|
||||
@@ -399,8 +393,6 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"filter_select_enabled": True,
|
||||
"sql": None,
|
||||
"select_star": None,
|
||||
"supports_samples": self.supports_samples,
|
||||
"supports_drill_to_detail": self.supports_drill_to_detail,
|
||||
"owners": [],
|
||||
"description": self.description,
|
||||
"table_name": self.name,
|
||||
|
||||
@@ -338,11 +338,6 @@ class ExplorableData(TypedDict, total=False):
|
||||
extra: str | None
|
||||
always_filter_main_dttm: bool
|
||||
normalize_columns: bool
|
||||
# Set by datasources that cannot return raw row samples (e.g. semantic
|
||||
# views, which only expose pre-defined metrics and dimensions).
|
||||
supports_samples: bool
|
||||
# Set by datasources that cannot answer drill-to-detail requests.
|
||||
supports_drill_to_detail: bool
|
||||
|
||||
|
||||
VizData: TypeAlias = list[Any] | dict[Any, Any] | None
|
||||
|
||||
@@ -208,23 +208,6 @@ class Datasource(BaseSupersetView):
|
||||
payload = SamplesPayloadSchema().load(request.json)
|
||||
except ValidationError as err:
|
||||
return json_error_response(err.messages, status=400)
|
||||
|
||||
# Refuse early for datasource types that don't model raw rows
|
||||
# (e.g. semantic views, which only expose pre-defined metrics and
|
||||
# dimensions). Without this gate the request would still go through
|
||||
# the standard query pipeline and fail with an opaque 500.
|
||||
# ``supports_samples`` defaults to True for any datasource class that
|
||||
# doesn't explicitly opt out, so SqlaTable/Query/SavedQuery continue
|
||||
# to work without needing the attribute declared on each class.
|
||||
ds_class = DatasourceDAO.sources.get(
|
||||
DatasourceType(params["datasource_type"]),
|
||||
)
|
||||
if ds_class is not None and not getattr(ds_class, "supports_samples", True):
|
||||
return json_error_response(
|
||||
_("Samples are not available for this datasource type."),
|
||||
status=400,
|
||||
)
|
||||
|
||||
dashboard_id = None
|
||||
if security_manager.is_guest_user():
|
||||
if not params["dashboard_id"]:
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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.
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.common.query_actions import _get_drill_detail
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
|
||||
|
||||
def test_get_drill_detail_refuses_datasource_that_opts_out() -> None:
|
||||
"""
|
||||
A datasource with ``supports_drill_to_detail = False`` (e.g. semantic
|
||||
views) must be hard-blocked on the server. Without this gate the request
|
||||
would fall through to ``_get_full`` and fail with an opaque error, and
|
||||
the flag would only be enforced by the frontend menu — leaving the
|
||||
chart-data API endpoint accepting drill-detail requests it shouldn't.
|
||||
"""
|
||||
datasource = MagicMock()
|
||||
datasource.supports_drill_to_detail = False
|
||||
|
||||
query_obj = MagicMock()
|
||||
query_obj.datasource = datasource
|
||||
|
||||
query_context = MagicMock()
|
||||
|
||||
with pytest.raises(
|
||||
QueryObjectValidationError,
|
||||
match="Drill to detail is not available",
|
||||
):
|
||||
_get_drill_detail(query_context, query_obj)
|
||||
|
||||
|
||||
def test_get_drill_detail_allows_datasource_without_flag() -> None:
|
||||
"""
|
||||
Datasources that don't declare the flag (e.g. legacy ``SqlaTable``
|
||||
subclasses via ``getattr`` default) must continue to work — the gate
|
||||
only fires when the flag is explicitly ``False``.
|
||||
"""
|
||||
datasource = MagicMock(spec=["columns"])
|
||||
column = MagicMock()
|
||||
column.column_name = "id"
|
||||
datasource.columns = [column]
|
||||
|
||||
query_obj = MagicMock()
|
||||
query_obj.datasource = datasource
|
||||
query_obj.columns = []
|
||||
|
||||
query_context = MagicMock()
|
||||
|
||||
expected_payload: dict[str, list[dict[str, str]]] = {"data": []}
|
||||
with patch(
|
||||
"superset.common.query_actions._get_full", return_value=expected_payload
|
||||
) as mock_get_full:
|
||||
assert _get_drill_detail(query_context, query_obj) is expected_payload
|
||||
mock_get_full.assert_called_once()
|
||||
@@ -653,20 +653,6 @@ def test_semantic_view_data(
|
||||
assert data["table_name"] == "Orders View"
|
||||
assert data["datasource_name"] == "Orders View"
|
||||
assert data["offset"] == 0
|
||||
# Semantic views don't model raw rows, so neither samples nor
|
||||
# drill-to-detail are available.
|
||||
assert data["supports_samples"] is False
|
||||
assert data["supports_drill_to_detail"] is False
|
||||
|
||||
|
||||
def test_semantic_view_supports_samples_is_false() -> None:
|
||||
"""The class-level flag opts SemanticView out of the Samples affordance."""
|
||||
assert SemanticView.supports_samples is False
|
||||
|
||||
|
||||
def test_semantic_view_supports_drill_to_detail_is_false() -> None:
|
||||
"""The class-level flag opts SemanticView out of Drill to detail."""
|
||||
assert SemanticView.supports_drill_to_detail is False
|
||||
|
||||
|
||||
def test_semantic_view_get_query_result(
|
||||
|
||||
@@ -312,66 +312,3 @@ def test_save_non_owner_with_owners_field_is_rejected(
|
||||
raw_save(_view_self())
|
||||
|
||||
mock_security_manager.raise_for_ownership.assert_called_once_with(mock_orm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Datasource.samples
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("superset.views.datasource.views._", lambda s: s)
|
||||
@patch("superset.views.datasource.views.get_samples")
|
||||
@patch("superset.views.datasource.views.json_error_response")
|
||||
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
|
||||
def test_samples_returns_400_for_unsupported_datasource_type(
|
||||
mock_security_manager: MagicMock,
|
||||
mock_json_error_response: MagicMock,
|
||||
mock_get_samples: MagicMock,
|
||||
) -> None:
|
||||
"""Semantic views can't return raw samples — endpoint should refuse with 400."""
|
||||
from flask import Flask
|
||||
|
||||
mock_security_manager.is_guest_user.return_value = False
|
||||
mock_json_error_response.return_value = "error-response"
|
||||
|
||||
raw_samples = _get_view_func("samples")
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/datasource/samples?datasource_type=semantic_view&datasource_id=1",
|
||||
method="POST",
|
||||
json={},
|
||||
):
|
||||
result = raw_samples(_view_self())
|
||||
|
||||
assert result == "error-response"
|
||||
mock_json_error_response.assert_called_once()
|
||||
_, kwargs = mock_json_error_response.call_args
|
||||
assert kwargs.get("status") == 400
|
||||
# The bail-out must happen before any sample fetching is attempted.
|
||||
mock_get_samples.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.views.datasource.views.get_samples")
|
||||
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
|
||||
def test_samples_proceeds_for_supported_datasource_type(
|
||||
mock_security_manager: MagicMock,
|
||||
mock_get_samples: MagicMock,
|
||||
) -> None:
|
||||
"""A `query` datasource (supports_samples=True) bypasses the 400 short-circuit."""
|
||||
from flask import Flask
|
||||
|
||||
mock_security_manager.is_guest_user.return_value = False
|
||||
mock_get_samples.return_value = {"rows": []}
|
||||
|
||||
view = _view_self()
|
||||
raw_samples = _get_view_func("samples")
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/datasource/samples?datasource_type=query&datasource_id=1",
|
||||
method="POST",
|
||||
json={},
|
||||
):
|
||||
raw_samples(view)
|
||||
|
||||
mock_get_samples.assert_called_once()
|
||||
view.json_response.assert_called_once_with({"result": {"rows": []}})
|
||||
|
||||
Reference in New Issue
Block a user