Compare commits

..

3 Commits

Author SHA1 Message Date
Đỗ Trọng Hải
f4df5a15e1 Merge branch 'master' into fix/sanitize-client-error-message-when-shown-as-html-content 2026-07-05 11:58:30 +07:00
hainenber
74f815d09c fix: sanitize without probable check for HTML string
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-04 15:27:24 +07:00
hainenber
d250eacaee fix(frontend/setup): sanitize returned client error message when shown as HTML content
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-04 14:56:04 +07:00
25 changed files with 112 additions and 828 deletions

View File

@@ -19,7 +19,6 @@
import { Page, Locator } from '@playwright/test';
import { TIMEOUT } from '../utils/constants';
import { AgGrid } from '../components/core/AgGrid';
/**
* Explore Page object
@@ -31,11 +30,6 @@ export class ExplorePage {
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
CHART_CONTAINER: '[data-test="chart-container"]',
// The bottom data panel (DataTablesPane / SouthPane) with Results/Samples tabs
SOUTH_PANE: '[data-test="some-purposeful-instance"]',
EXPAND_DATA_PANEL: '[aria-label="Expand data panel"]',
RESULTS_TAB: '[data-node-key="results"]',
ACTIVE_TABPANE: '.ant-tabs-tabpane-active',
} as const;
constructor(page: Page) {
@@ -112,44 +106,4 @@ export class ExplorePage {
getVizSwitcher(): Locator {
return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
}
/**
* Expands the bottom data panel if it is currently collapsed.
* Safe to call when already expanded (no-op).
*/
async expandDataPanel(): Promise<void> {
const expandButton = this.page.locator(
ExplorePage.SELECTORS.EXPAND_DATA_PANEL,
);
if (await expandButton.isVisible().catch(() => false)) {
await expandButton.click();
}
}
/**
* Opens the bottom data panel and activates the "Results" tab. Clicking the
* already-active tab collapses the panel, so the click is guarded.
*/
async openResultsTab(): Promise<void> {
await this.expandDataPanel();
const resultsTab = this.page
.locator(ExplorePage.SELECTORS.SOUTH_PANE)
.locator(ExplorePage.SELECTORS.RESULTS_TAB);
const className = (await resultsTab.getAttribute('class')) ?? '';
if (!className.includes('ant-tabs-tab-active')) {
await resultsTab.click();
}
}
/**
* Returns an AgGrid wrapper around the currently active Results tab grid.
*/
getResultsGrid(): AgGrid {
const grid = this.page
.locator(ExplorePage.SELECTORS.SOUTH_PANE)
.locator(ExplorePage.SELECTORS.ACTIVE_TABPANE)
.locator('[role="grid"]')
.first();
return new AgGrid(this.page, grid);
}
}

View File

@@ -1,123 +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.
*/
/**
* Regression coverage for #38152 (PR #38165): the Explore "Results" tab reuses
* the chart's already-fetched query data instead of firing a second, identical
* request to /api/v1/chart/data. The duplicate only exists across the real
* backend round-trip; the reuse-vs-fallback and row-limit slicing logic is
* unit-tested in useResultsPane.test.tsx, so this E2E asserts only the
* end-to-end win: opening Results issues no extra chart/data request.
*
* Lives under tests/experimental/ until proven stable in CI; run with:
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
* tests/experimental/explore/results-tab-reuse.spec.ts -- --headed
*/
import { testWithAssets, expect } from '../../../helpers/fixtures';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
import { ExplorePage } from '../../../pages/ExplorePage';
import { TIMEOUT } from '../../../utils/constants';
const DATASET_NAME = 'birth_names';
const CHART_DATA_PATH = '/api/v1/chart/data';
testWithAssets(
'Results tab reuses chart data without a duplicate query (#38165)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
let chartDataRequests = 0;
page.on('request', request => {
if (
request.method() === 'POST' &&
request.url().includes(CHART_DATA_PATH)
) {
chartDataRequests += 1;
}
});
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
const params = {
datasource: `${datasetId}__table`,
viz_type: 'table',
query_mode: 'raw',
all_columns: ['name', 'gender', 'num'],
adhoc_filters: [],
order_by_cols: [],
row_limit: 1000,
server_pagination: false,
};
const chartResp = await apiPostChart(page, {
slice_name: `results_tab_reuse_${Date.now()}`,
viz_type: 'table',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(params),
});
expect(chartResp.ok()).toBe(true);
const chartBody = await chartResp.json();
const chartId: number = chartBody.result?.id ?? chartBody.id;
expect(chartId, 'chart creation should return an id').toBeTruthy();
testAssets.trackChart(chartId);
// Wait for the chart query to finish so its result is in Redux; opening
// Results before that races and falls back to its own request.
const explorePage = new ExplorePage(page);
const chartQueryFinished = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
response.url().includes(CHART_DATA_PATH),
{ timeout: TIMEOUT.API_RESPONSE },
);
await explorePage.goto(chartId);
await chartQueryFinished;
const chartContainer = explorePage.getChartContainer();
await chartContainer.waitFor({
state: 'visible',
timeout: TIMEOUT.PAGE_LOAD,
});
await chartContainer
.getByRole('status', { name: 'Loading' })
.waitFor({ state: 'hidden', timeout: TIMEOUT.API_RESPONSE })
.catch(() => {}); // spinner may have already cleared
expect(chartDataRequests).toBeGreaterThanOrEqual(1);
const requestsAfterChartLoad = chartDataRequests;
await explorePage.openResultsTab();
const grid = explorePage.getResultsGrid();
await grid.waitForRows({ timeout: TIMEOUT.API_RESPONSE });
expect(await grid.getRowCount()).toBeGreaterThan(0);
// Allow time for an unwanted duplicate request, then assert none fired.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1500);
expect(
chartDataRequests,
'opening Results must not trigger a second chart/data query',
).toBe(requestsAfterChartLoad);
},
);

View File

@@ -38,7 +38,6 @@ import { css, styled } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { type ItemType } from '@superset-ui/core/components/Menu';
import { RootState } from 'src/dashboard/types';
import { NULL_STRING } from 'src/utils/common';
import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { TruncatedMenuLabel } from '../MenuItemWithTruncation';
@@ -220,40 +219,33 @@ export const useDrillDetailMenuItems = ({
popupOffset: [0, submenuYOffset],
popupClassName: 'chart-context-submenu',
children: [
...filters.map((filter, i) => {
const isNullVal =
filter.val === null || filter.formattedVal === NULL_STRING;
const formattedVal = isNullVal
? NULL_STRING
: filter.formattedVal;
return {
key: `drill-detail-filter-${i}`,
onClick: openModal.bind(null, [filter]),
label: (
<div
css={css`
max-width: 200px;
`}
...filters.map((filter, i) => ({
key: `drill-detail-filter-${i}`,
onClick: openModal.bind(null, [filter]),
label: (
<div
css={css`
max-width: 200px;
`}
>
<TruncatedMenuLabel
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
aria-label={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
>
<TruncatedMenuLabel
tooltipText={`${DRILL_TO_DETAIL_BY} ${formattedVal}`}
aria-label={`${DRILL_TO_DETAIL_BY} ${formattedVal}`}
<span
css={css`
display: inline;
`}
>
<span
css={css`
display: inline;
`}
>
{DRILL_TO_DETAIL_BY}{' '}
<StyledFilter stripHTML={!isNullVal}>
{formattedVal}
</StyledFilter>
</span>
</TruncatedMenuLabel>
</div>
),
};
}),
{DRILL_TO_DETAIL_BY}{' '}
<StyledFilter stripHTML>
{filter.formattedVal}
</StyledFilter>
</span>
</TruncatedMenuLabel>
</div>
),
})),
...(filters.length > 1
? [
{

View File

@@ -74,13 +74,6 @@ const filterB: BinaryQueryObjectFilterClause = {
formattedVal: 'Two days ago',
};
const filterNull: BinaryQueryObjectFilterClause = {
col: 'sample_column_3',
op: '==',
val: null as unknown as string,
formattedVal: '<NULL>',
};
const MockRenderChart = ({
chartId,
formData,
@@ -433,14 +426,3 @@ test.skip('context menu for supported chart, dimensions, all filters', async ()
await setupMenu(filters);
await expectDrillToDetailByAll(filters);
});
test('context menu renders <NULL> for null dimension values', async () => {
renderMenu({
formData: defaultFormData,
isContextMenu: true,
filters: [filterNull],
});
await expectDrillToDetailByEnabled();
await expectDrillToDetailByDimension(filterNull);
});

View File

@@ -1,90 +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.
*/
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import type { MenuItem } from '@superset-ui/core/components/Menu';
import { KebabMenuButton } from '.';
const menuItems: MenuItem[] = [
{
key: 'edit',
label: 'Edit',
},
{
key: 'delete',
label: 'Delete',
},
];
test('opens the menu on click', async () => {
render(<KebabMenuButton menuItems={menuItems} />);
await userEvent.click(screen.getByRole('button', { name: 'More Options' }));
expect(await screen.findByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
});
test('does not open the menu on hover', async () => {
render(<KebabMenuButton menuItems={menuItems} />);
await userEvent.hover(screen.getByRole('button', { name: 'More Options' }));
await waitFor(() => {
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
});
});
test('sets accessibility and test attributes on the trigger button', () => {
render(
<KebabMenuButton
menuItems={menuItems}
ariaLabel="Open actions"
buttonId="actions-menu"
dataTest="actions-menu-trigger"
/>,
);
const button = screen.getByRole('button', { name: 'Open actions' });
expect(button).toHaveAttribute('id', 'actions-menu');
expect(button).toHaveAttribute('aria-haspopup', 'true');
expect(button).toHaveAttribute('data-test', 'actions-menu-trigger');
});
test('renders a custom icon', () => {
render(
<KebabMenuButton
menuItems={menuItems}
icon={<span data-test="custom-menu-icon">Actions</span>}
/>,
);
expect(screen.getByTestId('custom-menu-icon')).toBeInTheDocument();
});
test('renders a rotated vertical icon when requested', () => {
render(<KebabMenuButton menuItems={menuItems} iconOrientation="vertical" />);
const button = screen.getByRole('button', { name: 'More Options' });
expect(button.querySelector('[style*="rotate(90deg)"]')).toBeInTheDocument();
});

View File

@@ -1,83 +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.
*/
import { ReactNode } from 'react';
import {
Button,
Dropdown,
Icons,
type ButtonProps,
type DropdownProps,
} from '@superset-ui/core/components';
import type { MenuItem } from '@superset-ui/core/components/Menu';
export interface KebabMenuButtonProps {
menuItems: MenuItem[];
icon?: ReactNode;
iconOrientation?: 'horizontal' | 'vertical';
ariaLabel?: string;
dataTest?: string;
buttonId?: string;
buttonSize?: ButtonProps['buttonSize'];
buttonStyle?: ButtonProps['buttonStyle'];
placement?: DropdownProps['placement'];
overlayStyle?: DropdownProps['overlayStyle'];
}
export function KebabMenuButton({
menuItems,
icon,
iconOrientation = 'horizontal',
ariaLabel = 'More Options',
dataTest,
buttonId,
buttonSize = 'xsmall',
buttonStyle = 'link',
placement,
overlayStyle,
}: KebabMenuButtonProps) {
const defaultIcon =
iconOrientation === 'vertical' ? (
<Icons.EllipsisOutlined
iconSize="xl"
style={{ transform: 'rotate(90deg)' }}
/>
) : (
<Icons.MoreOutlined iconSize="xl" />
);
return (
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
placement={placement}
overlayStyle={overlayStyle}
>
<Button
id={buttonId}
buttonSize={buttonSize}
buttonStyle={buttonStyle}
aria-label={ariaLabel}
aria-haspopup="true"
data-test={dataTest}
>
{icon || defaultIcon}
</Button>
</Dropdown>
);
}

View File

@@ -49,4 +49,3 @@ export {
type PluginContextType,
} from './DynamicPlugins';
export * from './FacePile';
export { KebabMenuButton, type KebabMenuButtonProps } from './KebabMenuButton';

View File

@@ -23,7 +23,7 @@ import {
within,
screen,
} from 'spec/helpers/testing-library';
import { addAlpha, FeatureFlag } from '@superset-ui/core';
import { FeatureFlag } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import {
OPEN_FILTER_BAR_WIDTH,
@@ -588,50 +588,6 @@ test('should apply min-height to the top-level tab drop target so tabs can be dr
);
});
test('should render chart tiles with a theme-driven border at rest, see https://github.com/apache/superset/issues/41618', () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
const { container } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayout,
}),
useDnd: true,
useTheme: true,
useRouter: true,
});
// StyledDashboardContent (className "dashboard-content") owns the nested
// `.dashboard-component-chart-holder` CSS, so it's the element to assert
// style rules against, not the individual chart holder nodes it renders.
const dashboardContent = container.querySelector('.dashboard-content');
expect(dashboardContent).toHaveStyleRule(
'border',
`1px solid ${supersetTheme.colorBorder}`,
{ target: '.dashboard-component-chart-holder' },
);
expect(dashboardContent).toHaveStyleRule(
'border-radius',
`${supersetTheme.borderRadius}px`,
{ target: '.dashboard-component-chart-holder' },
);
// .fade-out no longer re-declares border/border-radius (it inherits the
// base rule above); it should still layer its own hairline box-shadow.
expect(dashboardContent).toHaveStyleRule(
'box-shadow',
`0 0 0 1px ${addAlpha(supersetTheme.colorBorder, 0.5)}`,
{ target: '.dashboard-component-chart-holder.fade-out' },
);
});
test('should maintain layout when switching between tabs', async () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,

View File

@@ -317,8 +317,6 @@ const StyledDashboardContent = styled.div<{
width: 100%;
height: 100%;
background-color: ${theme.dashboardTileBg ?? theme.colorBgContainer};
border: ${theme.dashboardTileBorder ?? `1px solid ${theme.colorBorder}`};
border-radius: ${theme.dashboardTileBorderRadius ?? theme.borderRadius}px;
position: relative;
padding: ${theme.sizeUnit * 4}px;
box-sizing: border-box;
@@ -331,12 +329,16 @@ const StyledDashboardContent = styled.div<{
box-shadow ${theme.motionDurationMid} ease-in-out;
&.fade-in {
border-radius: ${theme.borderRadius}px;
box-shadow:
inset 0 0 0 2px ${theme.colorPrimary},
0 0 0 3px ${addAlpha(theme.colorPrimary, 0.1)};
}
&.fade-out {
border: ${theme.dashboardTileBorder ?? 'none'};
border-radius: ${theme.dashboardTileBorderRadius ??
theme.borderRadius}px;
box-shadow: ${theme.dashboardTileBoxShadow ??
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`};
}

View File

@@ -82,18 +82,22 @@ const RefreshTooltip = styled.div`
const getScreenshotNodeSelector = (chartId: string | number) =>
`.dashboard-chart-id-${chartId}`;
const VerticalDotsTrigger = () => (
<Icons.EllipsisOutlined
css={css`
transform: rotate(90deg);
&:hover {
cursor: pointer;
}
`}
iconSize="xl"
className="dot"
/>
);
const VerticalDotsTrigger = () => {
const theme = useTheme();
return (
<Icons.EllipsisOutlined
css={css`
transform: rotate(90deg);
&:hover {
cursor: pointer;
}
`}
iconSize="xl"
iconColor={theme.colorTextLabel}
className="dot"
/>
);
};
export interface SliceHeaderControlsProps {
chartHolderRef?: RefObject<HTMLDivElement>;

View File

@@ -100,7 +100,7 @@ export function getAppliedFilterValues(
// use cached data if possible
if (!(chartId in appliedFilterValuesByChart)) {
const applicableFilters = Object.entries(filters || activeFilters).filter(
([, activeFilter]) => activeFilter?.scope?.includes(chartId),
([, { scope: chartIds }]) => chartIds.includes(chartId),
);
appliedFilterValuesByChart[chartId] = flow(
keyBy(

View File

@@ -459,7 +459,7 @@ export default function getFormDataWithExtraFilters({
let extraData: JsonObject = {};
const filterIdsAppliedOnChart = Object.entries(activeFilters)
.filter(([, activeFilter]) => activeFilter?.scope?.includes(chart.id))
.filter(([, activeFilter]) => activeFilter.scope.includes(chart.id))
.map(([filterId]) => filterId);
if (filterIdsAppliedOnChart.length) {
@@ -474,7 +474,7 @@ export default function getFormDataWithExtraFilters({
const isDeckMultiChart = chart.form_data?.viz_type === 'deck_multi';
const hasLayerScopeInActiveFilters =
passedActiveFilters &&
Object.values(passedActiveFilters).some(filter => filter?.layerScope);
Object.values(passedActiveFilters).some(filter => filter.layerScope);
if (isDeckMultiChart || hasLayerScopeInActiveFilters) {
const filterDataMapping = createFilterDataMapping(

View File

@@ -1,66 +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.
*/
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
jest.mock('src/public-path', () => ({}));
jest.mock('query-string', () => ({}));
jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
setupAGGridModules: jest.fn(),
}));
jest.mock('src/preamble', () => jest.fn().mockResolvedValue(true));
jest.mock('src/setup/setupPlugins', () => jest.fn(), { virtual: true });
jest.mock('src/setup/setupCodeOverrides', () => jest.fn(), { virtual: true });
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => ({
embedded: { dashboard_id: '123' },
common: {
application_root: '/',
static_assets_prefix: '/',
conf: {
SQLLAB_DEFAULT_DBID: 1,
DEFAULT_SQLLAB_LIMIT: 1000,
},
},
}),
applicationRoot: () => '/',
staticAssetsPrefix: () => '/',
}));
describe('embedded/index.tsx', () => {
beforeAll(() => {
document.body.innerHTML = '<div id="app"></div>';
});
test('initializes AG Grid modules on bootstrap', async () => {
require('./index');
// index.tsx uses initPreamble().then(...) to initialize plugins and AG grid
// Wait for the promise chain to resolve
await new Promise(resolve => setTimeout(resolve, 0));
expect(setupAGGridModules).toHaveBeenCalled();
});
});

View File

@@ -33,7 +33,6 @@ import {
import Switchboard from '@superset-ui/switchboard';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import initPreamble from 'src/preamble';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import setupClient from 'src/setup/setupClient';
import { useUiConfig } from 'src/components/UiConfigContext';
import { store, USER_LOADED } from 'src/views/store';
@@ -70,7 +69,6 @@ const pluginsReady = initPreamble()
]);
setupPlugins();
setupCodeOverrides({ embedded: true });
setupAGGridModules();
});
const debugMode = process.env.WEBPACK_MODE === 'development';

View File

@@ -103,7 +103,6 @@ export const DataTablesPane = ({
errorMessage,
setForceQuery,
canDownload,
queriesResponse,
}: DataTablesPaneProps) => {
const [activeTabKey, setActiveTabKey] = useState<string>(ResultTypes.Results);
const [isRequest, setIsRequest] = useState<Record<ResultTypes, boolean>>({
@@ -129,10 +128,6 @@ export const DataTablesPane = ({
});
}
if (panelOpen && chartStatus === 'loading') {
setIsRequest(prev => ({ ...prev, results: false }));
}
if (
panelOpen &&
activeTabKey.startsWith(ResultTypes.Results) &&
@@ -211,7 +206,6 @@ export const DataTablesPane = ({
isRequest: isRequest.results,
setForceQuery,
canDownload,
queriesResponse,
}).map((pane, idx) => {
const tabKey =
idx === 0 ? ResultTypes.Results : `${ResultTypes.Results} ${idx + 1}`;

View File

@@ -56,7 +56,6 @@ export const ResultsPaneOnDashboard = ({
dataSize = 50,
canDownload,
columnDisplayNames,
queriesResponse,
}: ResultsPaneProps) => {
const resultsPanes = useResultsPane({
errorMessage,
@@ -69,7 +68,6 @@ export const ResultsPaneOnDashboard = ({
isVisible,
canDownload,
columnDisplayNames,
queriesResponse,
});
if (resultsPanes.length === 1) {

View File

@@ -20,11 +20,9 @@ import { useState, useEffect, useMemo, ReactElement, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import {
ChartDataResponseResult,
ensureIsArray,
getChartMetadataRegistry,
getClientErrorObject,
QueryData,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { EmptyState, Loading } from '@superset-ui/core/components';
@@ -47,13 +45,6 @@ const StyledDiv = styled.div`
const cache = new WeakMap();
// `queriesResponse` is the loose `QueryData`; only reuse it when every entry is
// a full v1 result (colnames/coltypes/data arrays), else fall back to the API.
const isV1QueryResult = (query: QueryData): query is ChartDataResponseResult =>
Array.isArray((query as ChartDataResponseResult).colnames) &&
Array.isArray((query as ChartDataResponseResult).coltypes) &&
Array.isArray((query as ChartDataResponseResult).data);
export const useResultsPane = ({
isRequest,
queryFormData,
@@ -64,7 +55,6 @@ export const useResultsPane = ({
isVisible,
canDownload,
columnDisplayNames,
queriesResponse,
}: ResultsPaneProps): ReactElement[] => {
const metadata = getChartMetadataRegistry().get(
queryFormData?.viz_type || queryFormData?.vizType,
@@ -99,38 +89,7 @@ export const useResultsPane = ({
useEffect(() => {
// it's an invalid formData when gets a errorMessage
if (errorMessage) return;
if (!isRequest) return;
// The chart query and the results query produce identical SQL, so reuse the
// chart's data instead of a second request. The chart always ran with a
// row_limit >= effectiveRowLimit, so its first `effectiveRowLimit` rows
// match what a dedicated results query would return — slice locally to keep
// the row-limit dropdown working without a duplicate query.
if (queriesResponse?.length && queriesResponse.every(isV1QueryResult)) {
const mapped = queriesResponse.map(q => {
const result = q as ChartDataResponseResult;
const limitedData = ensureIsArray(result.data).slice(
0,
effectiveRowLimit,
);
return {
colnames: result.colnames,
coltypes: result.coltypes,
data: limitedData,
rowcount: limitedData.length,
};
}) as unknown as QueryResultInterface[];
setResultResp(mapped);
setResponseError('');
if (queryForce) {
setForceQuery?.(false);
}
setIsLoading(false);
return;
}
// Fallback: use cached data
if (cache.has(cappedFormData)) {
if (isRequest && cache.has(cappedFormData)) {
setResultResp(
ensureIsArray(cache.get(cappedFormData)) as QueryResultInterface[],
);
@@ -139,35 +98,34 @@ export const useResultsPane = ({
setForceQuery?.(false);
}
setIsLoading(false);
return;
}
// Fallback: fetch from API (legacy charts without queriesResponse)
setIsLoading(true);
getChartDataRequest({
formData: cappedFormData,
force: queryForce,
resultFormat: 'json',
resultType: 'results',
ownState,
})
.then(({ json }) => {
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
setResponseError('');
cache.set(cappedFormData, json.result);
if (queryForce) {
setForceQuery?.(false);
}
if (isRequest && !cache.has(cappedFormData)) {
setIsLoading(true);
getChartDataRequest({
formData: cappedFormData,
force: queryForce,
resultFormat: 'json',
resultType: 'results',
ownState,
})
.catch(response => {
getClientErrorObject(response).then(({ error, message }) => {
setResponseError(error || message || t('Sorry, an error occurred'));
.then(({ json }) => {
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
setResponseError('');
cache.set(cappedFormData, json.result);
if (queryForce) {
setForceQuery?.(false);
}
})
.catch(response => {
getClientErrorObject(response).then(({ error, message }) => {
setResponseError(error || message || t('Sorry, an error occurred'));
});
})
.finally(() => {
setIsLoading(false);
});
})
.finally(() => {
setIsLoading(false);
});
}, [cappedFormData, isRequest, queriesResponse, effectiveRowLimit]);
}
}, [cappedFormData, isRequest]);
useEffect(() => {
if (errorMessage) {

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { ReactElement } from 'react';
import { DatasourceType, QueryData, VizType } from '@superset-ui/core';
import { DatasourceType, VizType } from '@superset-ui/core';
import { ChartStatus } from 'src/explore/types';
import {
DataTablesPaneProps,
@@ -106,16 +106,12 @@ export const createResultsPaneOnDashboardProps = ({
vizType = VizType.Table,
queryForce = false,
isRequest = true,
queriesResponse,
rowLimit,
}: {
sliceId: number;
vizType?: string;
errorMessage?: ReactElement;
queryForce?: boolean;
isRequest?: boolean;
queriesResponse?: QueryData[] | null;
rowLimit?: number;
}) =>
({
isRequest,
@@ -123,12 +119,10 @@ export const createResultsPaneOnDashboardProps = ({
...queryFormData,
slice_id: sliceId,
viz_type: vizType,
...(rowLimit !== undefined ? { row_limit: rowLimit } : {}),
},
queryForce,
isVisible: true,
setForceQuery: jest.fn(),
errorMessage,
canDownload: true,
queriesResponse,
}) as ResultsPaneProps;

View File

@@ -1,201 +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.
*/
import { screen, render, waitFor } from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ResultsPaneOnDashboard } from '../components';
import { createResultsPaneOnDashboardProps } from './fixture';
// Mocked so each test can assert whether the results branch hit the network.
jest.mock('src/components/Chart/chartAction', () => ({
getChartDataRequest: jest.fn(),
}));
const mockedGetChartDataRequest = getChartDataRequest as jest.Mock;
beforeAll(() => {
setupAGGridModules();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('useResultsPane query data reuse', () => {
beforeEach(() => {
mockedGetChartDataRequest.mockReset();
});
test('reuses queriesResponse from Redux and skips the results API call (v1 charts)', async () => {
const props = createResultsPaneOnDashboardProps({
sliceId: 201,
queriesResponse: [
{
colnames: ['genre'],
coltypes: [1],
data: [{ genre: 'Action' }, { genre: 'Horror' }],
rowcount: 2,
},
],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
expect(await screen.findByText('Action')).toBeVisible();
expect(screen.getByText('Horror')).toBeVisible();
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
});
test('falls back to the results API for legacy charts without a typed queriesResponse', async () => {
mockedGetChartDataRequest.mockResolvedValue({
json: {
result: [
{
colnames: ['genre'],
coltypes: [1],
data: [{ genre: 'Drama' }],
rowcount: 1,
},
],
},
});
const props = createResultsPaneOnDashboardProps({ sliceId: 202 });
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
expect(await screen.findByText('Drama')).toBeVisible();
expect(mockedGetChartDataRequest).toHaveBeenCalledTimes(1);
});
test('falls back to the results API when queriesResponse has no colnames', async () => {
mockedGetChartDataRequest.mockResolvedValue({
json: {
result: [
{
colnames: ['genre'],
coltypes: [1],
data: [{ genre: 'Thriller' }],
rowcount: 1,
},
],
},
});
const props = createResultsPaneOnDashboardProps({
sliceId: 203,
queriesResponse: [{ data: [{ genre: 'ignored' }] }],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
expect(await screen.findByText('Thriller')).toBeVisible();
expect(mockedGetChartDataRequest).toHaveBeenCalledTimes(1);
});
test('falls back to the API when queriesResponse has colnames but not the full v1 shape', async () => {
mockedGetChartDataRequest.mockResolvedValue({
json: {
result: [
{
colnames: ['genre'],
coltypes: [1],
data: [{ genre: 'Indie' }],
rowcount: 1,
},
],
},
});
const props = createResultsPaneOnDashboardProps({
sliceId: 207,
// colnames present but coltypes/data missing: not a real v1 result
queriesResponse: [{ colnames: ['genre'] }],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
expect(await screen.findByText('Indie')).toBeVisible();
expect(mockedGetChartDataRequest).toHaveBeenCalledTimes(1);
});
test('reuse path honors the effective row limit (slices Redux data, no extra query)', async () => {
const props = createResultsPaneOnDashboardProps({
sliceId: 204,
rowLimit: 2,
queriesResponse: [
{
colnames: ['genre'],
coltypes: [1],
data: [
{ genre: 'Action' },
{ genre: 'Horror' },
{ genre: 'Comedy' },
{ genre: 'Drama' },
{ genre: 'Sci-Fi' },
],
rowcount: 5,
},
],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
expect(await screen.findByText('Action')).toBeVisible();
expect(screen.getByText('Horror')).toBeVisible();
expect(screen.queryByText('Comedy')).not.toBeInTheDocument();
expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
expect(screen.getByText('2 rows')).toBeVisible();
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
});
test('renders an empty (0 rows) result from reused data without an API call', async () => {
const props = createResultsPaneOnDashboardProps({
sliceId: 205,
queriesResponse: [
{ colnames: ['genre'], coltypes: [1], data: [], rowcount: 0 },
],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
expect(await screen.findByText('0 rows')).toBeVisible();
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
});
test('clears the force-query flag when reusing Redux data', async () => {
const props = createResultsPaneOnDashboardProps({
sliceId: 206,
queryForce: true,
queriesResponse: [
{
colnames: ['genre'],
coltypes: [1],
data: [{ genre: 'Action' }],
rowcount: 1,
},
],
});
const setForceQuery = jest.fn();
render(
<ResultsPaneOnDashboard {...props} setForceQuery={setForceQuery} />,
{
useRedux: true,
},
);
await waitFor(() => expect(setForceQuery).toHaveBeenCalledWith(false));
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
});
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { JsonObject, LatestQueryFormData, QueryData } from '@superset-ui/core';
import { JsonObject, LatestQueryFormData } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import type { ChartStatus, Datasource } from 'src/explore/types';
@@ -36,7 +36,6 @@ export interface DataTablesPaneProps {
errorMessage?: React.ReactNode;
setForceQuery: SetForceQueryAction;
canDownload: boolean;
queriesResponse?: QueryData[] | null;
}
export interface ResultsPaneProps {
@@ -52,7 +51,6 @@ export interface ResultsPaneProps {
canDownload: boolean;
// Optional map of column/metric name -> verbose label
columnDisplayNames?: Record<string, string>;
queriesResponse?: QueryData[] | null;
}
export interface SamplesPaneProps {

View File

@@ -560,7 +560,6 @@ const ExploreChartPanel = ({
errorMessage={errorMessage}
setForceQuery={actions.setForceQuery}
canDownload={canDownload}
queriesResponse={chart.queriesResponse}
/>
</Split>
{showDatasetModal && (

View File

@@ -22,14 +22,16 @@ import { css } from '@apache-superset/core/theme';
import { Link, useHistory } from 'react-router-dom';
import {
ConfirmStatusChange,
Button,
Dropdown,
FaveStar,
Icons,
Label,
ListViewCard,
Icons,
MenuItem,
} from '@superset-ui/core/components';
import Chart from 'src/types/Chart';
import { FacePile, KebabMenuButton } from 'src/components';
import { FacePile } from 'src/components';
import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils';
import { assetUrl } from 'src/utils/assetUrl';
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
@@ -205,7 +207,11 @@ export default function ChartCard({
isStarred={favoriteStatus}
/>
)}
<KebabMenuButton menuItems={menuItems} dataTest="chart-card-menu" />
<Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
<Button buttonSize="xsmall" type="link" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>

View File

@@ -21,15 +21,17 @@ import { t } from '@apache-superset/core/translation';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { CardStyles } from 'src/views/CRUD/utils';
import {
Dropdown,
Button,
FaveStar,
Icons,
PublishedLabel,
ListViewCard,
} from '@superset-ui/core/components';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { Icons } from '@superset-ui/core/components/Icons';
import { Dashboard } from 'src/views/CRUD/types';
import { assetUrl } from 'src/utils/assetUrl';
import { FacePile, KebabMenuButton } from 'src/components';
import { FacePile } from 'src/components';
interface DashboardCardProps {
isChart?: boolean;
@@ -162,10 +164,11 @@ function DashboardCard({
isStarred={favoriteStatus}
/>
)}
<KebabMenuButton
menuItems={menuItems}
dataTest="dashboard-card-menu"
/>
<Dropdown menu={{ items: menuItems }} trigger={['hover', 'click']}>
<Button buttonSize="xsmall" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>

View File

@@ -27,11 +27,16 @@ import CodeSyntaxHighlighter, {
import { LoadingCards } from 'src/pages/Home';
import { TableTab } from 'src/views/CRUD/types';
import withToasts from 'src/components/MessageToasts/withToasts';
import { DeleteModal, Icons, ListViewCard } from '@superset-ui/core/components';
import {
Dropdown,
DeleteModal,
Button,
ListViewCard,
} from '@superset-ui/core/components';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { copyQueryLink, useListViewResource } from 'src/views/CRUD/hooks';
import { Icons } from '@superset-ui/core/components/Icons';
import { User } from 'src/types/bootstrapTypes';
import { KebabMenuButton } from 'src/components';
import {
CardContainer,
createErrorHandler,
@@ -339,10 +344,14 @@ export const SavedQueries = ({
e.preventDefault();
}}
>
<KebabMenuButton
menuItems={menuItems(q)}
dataTest="saved-query-card-menu"
/>
<Dropdown
menu={{ items: menuItems(q) }}
trigger={['click', 'hover']}
>
<Button buttonSize="xsmall" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>

View File

@@ -22,6 +22,7 @@ import {
SupersetClient,
getClientErrorObject,
ClientErrorObject,
sanitizeHtml,
} from '@superset-ui/core';
import setupErrorMessages from 'src/setup/setupErrorMessages';
@@ -41,7 +42,7 @@ function showApiMessage(resp: ClientErrorObject) {
const severity = resp.severity || 'info';
$(template)
.addClass(`alert-${severity}`)
.append(resp.message || '')
.append(sanitizeHtml(resp.message || ''))
.appendTo($('#alert-container'));
}