Files
superset2/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
Hugh A. Miles II b76080e291 feat(security): add granular export controls - Phase 2 + 3 (#38581)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
Co-authored-by: Daniel Vaz Gaspar <danielvazgaspar@gmail.com>
2026-04-15 10:24:59 -04:00

1173 lines
38 KiB
TypeScript

/**
* 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,
within,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import * as chartAction from 'src/components/Chart/chartAction';
import * as saveModalActions from 'src/explore/actions/saveModalActions';
import * as downloadAsImage from 'src/utils/downloadAsImage';
import * as exploreUtils from 'src/explore/exploreUtils';
import { FeatureFlag, VizType } from '@superset-ui/core';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import ExploreHeader, { ExploreChartHeaderProps } from '.';
import { getChartMetadataRegistry } from '@superset-ui/core';
import fs from 'fs';
import path from 'path';
const chartEndpoint = 'glob:*api/v1/chart/*';
fetchMock.get(chartEndpoint, { json: 'foo' });
window.featureFlags = {
[FeatureFlag.EmbeddableCharts]: true,
};
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
useUnsavedChangesPrompt: jest.fn(),
}));
const mockExportCurrentViewBehavior = () => {
const registry = getChartMetadataRegistry();
return jest.spyOn(registry, 'get').mockReturnValue({
behaviors: ['EXPORT_CURRENT_VIEW'],
} as any);
};
const createProps = (additionalProps = {}) =>
({
chart: {
id: 1,
latestQueryFormData: {
viz_type: VizType.Histogram,
datasource: '49__table',
slice_id: 318,
url_params: {},
granularity_sqla: 'time_start',
time_range: 'No filter',
all_columns_x: ['age'],
adhoc_filters: [],
row_limit: 10000,
groupby: null,
color_scheme: 'supersetColors',
label_colors: {},
link_length: '25',
x_axis_label: 'age',
y_axis_label: 'count',
server_pagination: false,
},
chartStatus: 'rendered' as const,
chartAlert: null,
chartUpdateEndTime: null,
chartUpdateStartTime: 0,
lastRendered: 0,
sliceFormData: null,
queryController: null,
queriesResponse: null,
triggerQuery: false,
},
slice: {
cache_timeout: null,
changed_on: '2021-03-19T16:30:56.750230',
changed_on_humanized: '7 days ago',
datasource: 'FCC 2018 Survey',
description: 'Simple description',
description_markeddown: '',
edit_url: '/chart/edit/318',
form_data: {
adhoc_filters: [],
all_columns_x: ['age'],
color_scheme: 'supersetColors',
datasource: '49__table',
granularity_sqla: 'time_start',
groupby: null,
label_colors: {},
link_length: '25',
queryFields: { groupby: 'groupby' },
row_limit: 10000,
slice_id: 318,
time_range: 'No filter',
url_params: {},
viz_type: VizType.Histogram,
x_axis_label: 'age',
y_axis_label: 'count',
},
modified: '<span class="no-wrap">7 days ago</span>',
owners: [
{
text: 'Superset Admin',
value: 1,
},
],
slice_id: 318,
slice_name: 'Age distribution of respondents',
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20318%7D',
},
sliceName: 'Age distribution of respondents',
actions: {
postChartFormData: jest.fn(),
updateChartTitle: jest.fn(),
fetchFaveStar: jest.fn(),
saveFaveStar: jest.fn(),
redirectSQLLab: jest.fn(),
},
user: {
userId: 1,
},
metadata: {
created_on_humanized: 'a week ago',
changed_on_humanized: '2 days ago',
owners: ['John Doe'],
created_by: 'John Doe',
changed_by: 'John Doe',
dashboards: [{ id: 1, dashboard_title: 'Test' }],
},
canOverwrite: false,
canDownload: false,
isStarred: false,
...additionalProps,
}) as unknown as ExploreChartHeaderProps;
fetchMock.post(
'http://api/v1/chart/data?form_data=%7B%22slice_id%22%3A318%7D',
{ body: {} },
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('ExploreChartHeader', () => {
jest.setTimeout(15000); // ✅ Applies to all tests in this suite
beforeEach(() => {
jest.clearAllMocks();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
test('Cancelling changes to the properties should reset previous properties', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const newChartName = 'New chart name';
const prevChartName = props.sliceName;
// Wait for the component to render with the chart title
expect(
await screen.findByDisplayValue(prevChartName ?? ''),
).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
await userEvent.click(screen.getByText('Edit chart properties'));
const nameInput = await screen.findByRole('textbox', { name: 'Name' });
await userEvent.clear(nameInput);
await userEvent.type(nameInput, newChartName);
expect(screen.getByDisplayValue(newChartName)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
// Wait for the modal to close
await waitFor(() => {
expect(
screen.queryByRole('textbox', { name: 'Name' }),
).not.toBeInTheDocument();
});
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
await userEvent.click(screen.getByText('Edit chart properties'));
// Wait for the modal to reopen and verify the name was reset
const reopenedNameInput = await screen.findByRole('textbox', {
name: 'Name',
});
expect(reopenedNameInput).toHaveValue(prevChartName ?? '');
});
test('renders the metadata bar when saved', async () => {
const props = createProps({ showTitlePanelItems: true });
render(<ExploreHeader {...props} />, { useRedux: true });
expect(await screen.findByText('Added to 1 dashboard')).toBeInTheDocument();
expect(await screen.findByText('Simple description')).toBeInTheDocument();
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(await screen.findByText('2 days ago')).toBeInTheDocument();
});
test('Changes "Added to X dashboards" to plural when more than 1 dashboard', async () => {
const props = createProps({ showTitlePanelItems: true });
render(
<ExploreHeader
{...props}
metadata={{
...props.metadata!,
dashboards: [
{ id: 1, dashboard_title: 'Test' },
{ id: 2, dashboard_title: 'Test2' },
],
}}
/>,
{ useRedux: true },
);
expect(
await screen.findByText('Added to 2 dashboards'),
).toBeInTheDocument();
});
test('does not render the metadata bar when not saved', async () => {
const props = createProps({ showTitlePanelItems: true, slice: null });
render(<ExploreHeader {...props} />, { useRedux: true });
await waitFor(() =>
expect(
screen.queryByText('Added to 1 dashboard'),
).not.toBeInTheDocument(),
);
});
test('does not show unsaved changes for new charts on initial load', async () => {
const props = createProps({
slice: null,
sliceName: '',
chart: {
...createProps().chart,
sliceFormData: null,
},
formData: {
viz_type: VizType.Histogram,
datasource: '49__table',
},
});
render(<ExploreHeader {...props} />, { useRedux: true });
expect(
await screen.findByText(/add the name of the chart/i),
).toBeInTheDocument();
expect(useUnsavedChangesPrompt).toHaveBeenCalledWith(
expect.objectContaining({
hasUnsavedChanges: false,
}),
);
});
test('shows unsaved changes for new charts when user makes changes', async () => {
const initialFormData = {
viz_type: VizType.Histogram,
datasource: '49__table',
metrics: ['count'],
};
const modifiedFormData = {
...initialFormData,
metrics: ['count', 'sum'],
};
const props = createProps({
slice: null,
sliceName: '',
chart: {
...createProps().chart,
sliceFormData: null,
},
formData: initialFormData,
});
const { rerender } = render(<ExploreHeader {...props} />, {
useRedux: true,
});
// Initial render should not have unsaved changes
expect(useUnsavedChangesPrompt).toHaveBeenLastCalledWith(
expect.objectContaining({
hasUnsavedChanges: false,
}),
);
// Simulate user making changes
const modifiedProps = {
...props,
formData: modifiedFormData,
};
rerender(<ExploreHeader {...modifiedProps} />);
await waitFor(() => {
expect(useUnsavedChangesPrompt).toHaveBeenLastCalledWith(
expect.objectContaining({
hasUnsavedChanges: true,
}),
);
});
});
test('shows unsaved changes for existing charts when form data differs from saved', async () => {
const savedFormData = {
viz_type: VizType.Histogram,
datasource: '49__table',
metrics: ['count'],
};
const currentFormData = {
...savedFormData,
metrics: ['sum'],
};
const props = createProps({
formData: currentFormData,
chart: {
...createProps().chart,
sliceFormData: savedFormData,
},
});
render(<ExploreHeader {...props} />, { useRedux: true });
expect(useUnsavedChangesPrompt).toHaveBeenCalledWith(
expect.objectContaining({
hasUnsavedChanges: true,
}),
);
});
test('does not show unsaved changes for existing charts when form data matches saved', async () => {
const baseFormData = {
viz_type: VizType.Histogram,
datasource: '49__table',
slice_id: 318,
url_params: {},
granularity_sqla: 'time_start',
time_range: 'No filter',
all_columns_x: ['age'],
adhoc_filters: [],
row_limit: 10000,
groupby: null,
color_scheme: 'supersetColors',
label_colors: {},
link_length: '25',
x_axis_label: 'age',
y_axis_label: 'count',
};
const props = createProps({
formData: baseFormData,
sliceName: 'Age distribution of respondents',
chart: {
...createProps().chart,
sliceFormData: { ...baseFormData },
},
});
render(<ExploreHeader {...props} />, { useRedux: true });
expect(useUnsavedChangesPrompt).toHaveBeenCalledWith(
expect.objectContaining({
hasUnsavedChanges: false,
}),
);
});
test('Save chart', async () => {
const setSaveChartModalVisibilitySpy = jest.spyOn(
saveModalActions,
'setSaveChartModalVisibility',
);
const setSaveChartModalVisibilityMock =
setSaveChartModalVisibilitySpy as jest.Mock;
const triggerManualSave = jest.fn(() => {
setSaveChartModalVisibilityMock(true);
});
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave,
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const saveButton: HTMLElement = await screen.findByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(triggerManualSave).toHaveBeenCalled();
expect(setSaveChartModalVisibilityMock).toHaveBeenCalledWith(true);
setSaveChartModalVisibilityMock.mockClear();
});
test('Save disabled', async () => {
const triggerManualSave = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave,
});
const props = createProps();
render(<ExploreHeader {...props} saveDisabled />, { useRedux: true });
const saveButton: HTMLElement = await screen.findByRole('button', {
name: /save/i,
});
expect(saveButton).toBeDisabled();
userEvent.click(saveButton);
expect(triggerManualSave).not.toHaveBeenCalled();
});
test('should render UnsavedChangesModal when showModal is true', async () => {
const props = createProps();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
render(<ExploreHeader {...props} />, { useRedux: true });
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(
await screen.findByText('Save changes to your chart?'),
).toBeInTheDocument();
expect(
await screen.findByText("If you don't save, changes will be lost."),
).toBeInTheDocument();
});
test('should call handleSaveAndCloseModal when clicking Save in UnsavedChangesModal', async () => {
const handleSaveAndCloseModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal,
triggerManualSave: jest.fn(),
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const modal: HTMLElement = await screen.findByRole('dialog');
const saveButton: HTMLElement = within(modal).getByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(handleSaveAndCloseModal).toHaveBeenCalled();
});
test('should call handleConfirmNavigation when clicking Discard in UnsavedChangesModal', async () => {
const handleConfirmNavigation = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation,
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const modal: HTMLElement = await screen.findByRole('dialog');
const discardButton: HTMLElement = within(modal).getByRole('button', {
name: /discard/i,
});
userEvent.click(discardButton);
expect(handleConfirmNavigation).toHaveBeenCalled();
});
test('should call setShowModal(false) when clicking close button in UnsavedChangesModal', async () => {
const setShowModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal,
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const closeButton: HTMLElement = await screen.findByRole('button', {
name: /close/i,
});
userEvent.click(closeButton);
expect(setShowModal).toHaveBeenCalledWith(false);
});
test('renders Matrixify tag when matrixify is enabled', async () => {
const props = createProps({
formData: {
...createProps().chart.latestQueryFormData,
matrixify_enable: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [{ label: 'COUNT(*)', expressionType: 'SIMPLE' }],
},
});
render(<ExploreHeader {...props} />, { useRedux: true });
const matrixifyTag = await screen.findByText('Matrixified');
expect(matrixifyTag).toBeInTheDocument();
});
test('does not render Matrixify tag when matrixify is disabled', async () => {
const props = createProps({
formData: {
...createProps().chart.latestQueryFormData,
},
});
render(<ExploreHeader {...props} />, { useRedux: true });
await waitFor(() => {
expect(screen.queryByText('Matrixified')).not.toBeInTheDocument();
});
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Additional actions tests', () => {
jest.setTimeout(15000); // ✅ Applies to all tests in this suite
beforeEach(() => {
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
test('Should render a button', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
expect(
await screen.findByLabelText('Menu actions trigger'),
).toBeInTheDocument();
});
test('Should open a menu', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(
await screen.findByText('Edit chart properties'),
).toBeInTheDocument();
expect(screen.getByText('Data Export Options')).toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
expect(screen.getByText('View query')).toBeInTheDocument();
expect(screen.getByText('Run in SQL Lab')).toBeInTheDocument();
expect(
screen.queryByText('Set up an email report'),
).not.toBeInTheDocument();
expect(screen.queryByText('Manage email report')).not.toBeInTheDocument();
});
test('Should open all data download submenu', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(await screen.findByText('Export to .JSON')).toBeInTheDocument();
expect(await screen.findByText('Export to Excel')).toBeInTheDocument();
expect(
await screen.findByText('Export screenshot (jpeg)'),
).toBeInTheDocument();
});
test('Should open current view data download submenu', async () => {
const props = createProps();
props.chart.latestQueryFormData.viz_type = VizType.Table;
// Force-enable EXPORT_CURRENT_VIEW for this viz in this test
const registry = getChartMetadataRegistry();
const getSpy = jest.spyOn(registry, 'get').mockReturnValue({
behaviors: ['EXPORT_CURRENT_VIEW'],
} as any);
render(<ExploreHeader {...props} />, { useRedux: true });
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
// Now the submenu should exist
userEvent.hover(await screen.findByText('Export Current View'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(await screen.findByText('Export to .JSON')).toBeInTheDocument();
expect(
await screen.findByText(/Export to (Excel|\.XLSX)/i),
).toBeInTheDocument();
expect(
await screen.findByText('Export screenshot (jpeg)'),
).toBeInTheDocument();
getSpy.mockRestore();
});
test('Should open share submenu', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(
screen.queryByText('Copy permalink to clipboard'),
).not.toBeInTheDocument();
expect(screen.queryByText('Embed code')).not.toBeInTheDocument();
expect(screen.queryByText('Share chart by email')).not.toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
userEvent.hover(screen.getByText('Share'));
expect(
await screen.findByText('Copy permalink to clipboard'),
).toBeInTheDocument();
expect(await screen.findByText('Embed code')).toBeInTheDocument();
expect(await screen.findByText('Share chart by email')).toBeInTheDocument();
});
test('Should call onOpenPropertiesModal when click on "Edit chart properties"', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
expect(props.actions.redirectSQLLab).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.click(
screen.getByRole('menuitem', { name: 'Edit chart properties' }),
);
expect(
await screen.findByText('Edit chart properties'),
).toBeInTheDocument();
});
test('Should call getChartDataRequest when click on "View query"', async () => {
const props = createProps();
const getChartDataRequest = jest.spyOn(chartAction, 'getChartDataRequest');
render(<ExploreHeader {...props} />, {
useRedux: true,
});
expect(getChartDataRequest).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(getChartDataRequest).toHaveBeenCalledTimes(0);
const menuItem = screen.getByText('View query').parentElement!;
userEvent.click(menuItem);
await waitFor(() => expect(getChartDataRequest).toHaveBeenCalledTimes(1));
});
test('Should call onOpenInEditor when click on "Run in SQL Lab"', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
expect(await screen.findByText('Save')).toBeInTheDocument();
expect(props.actions.redirectSQLLab).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(props.actions.redirectSQLLab).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByRole('menuitem', { name: 'Run in SQL Lab' }));
expect(props.actions.redirectSQLLab).toHaveBeenCalledTimes(1);
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Export All Data', () => {
let spyDownloadAsImage: jest.SpyInstance;
let spyExportChart: jest.SpyInstance;
beforeEach(() => {
spyDownloadAsImage = jest.spyOn(downloadAsImage, 'default');
spyExportChart = jest.spyOn(exploreUtils, 'exportChart');
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
afterEach(async () => {
spyDownloadAsImage.mockRestore();
spyExportChart.mockRestore();
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
test('Should call downloadAsImage when click on "Export screenshot (jpeg)"', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: { explore: { can_export_image: true } },
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const downloadAsImageElement = await screen.findByText(
'Export screenshot (jpeg)',
);
userEvent.click(downloadAsImageElement);
await waitFor(() => {
expect(spyDownloadAsImage.mock.calls.length).toBe(1);
});
});
test('Should not export to CSV if canDownload=false', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.mock.calls.length).toBe(0);
spyExportChart.mockRestore();
});
test('Should export to CSV if canDownload=true', async () => {
const props = createProps();
props.canDownload = true;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.mock.calls.length).toBe(1);
spyExportChart.mockRestore();
});
test('Should not export to JSON if canDownload=false', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportJsonElement = await screen.findByText('Export to .JSON');
userEvent.click(exportJsonElement);
expect(spyExportChart.mock.calls.length).toBe(0);
spyExportChart.mockRestore();
});
test('Should export to JSON if canDownload=true', async () => {
const props = createProps();
props.canDownload = true;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportJsonElement = await screen.findByText('Export to .JSON');
userEvent.click(exportJsonElement);
expect(spyExportChart.mock.calls.length).toBe(1);
});
test('Should not export to pivoted CSV if canDownloadCSV=false and viz_type=pivot_table_v2', async () => {
const props = createProps();
props.chart.latestQueryFormData.viz_type = VizType.PivotTable;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText(
'Export to pivoted .CSV',
);
userEvent.click(exportCSVElement);
expect(spyExportChart.mock.calls.length).toBe(0);
});
test('Should export to pivoted CSV if canDownloadCSV=true and viz_type=pivot_table_v2', async () => {
const props = createProps();
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = VizType.PivotTable;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText(
'Export to pivoted .CSV',
);
userEvent.click(exportCSVElement);
expect(spyExportChart.mock.calls.length).toBe(1);
});
test('Should not export to Excel if canDownload=false', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportExcelElement = await screen.findByText('Export to Excel');
userEvent.click(exportExcelElement);
expect(spyExportChart.mock.calls.length).toBe(0);
spyExportChart.mockRestore();
});
test('Should export to Excel if canDownload=true', async () => {
const props = createProps();
props.canDownload = true;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportExcelElement = await screen.findByText('Export to Excel');
userEvent.click(exportExcelElement);
expect(spyExportChart.mock.calls.length).toBe(1);
});
});
describe('Current View', () => {
let spyDownloadAsImage: jest.SpyInstance;
let spyExportChart: jest.SpyInstance;
let originalURL: typeof URL;
let anchorClickSpy: jest.SpyInstance;
beforeAll(() => {
originalURL = global.URL;
// Replace global.URL with a version that has the blob helpers
const mockedURL = {
...originalURL,
createObjectURL: jest.fn(() => 'blob:mock-url'),
revokeObjectURL: jest.fn(),
} as unknown as typeof URL;
Object.defineProperty(global, 'URL', {
writable: true,
value: mockedURL,
});
// Avoid jsdom navigation side-effects on <a>.click()
anchorClickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
});
afterAll(() => {
// restore URL
Object.defineProperty(global, 'URL', {
writable: true,
value: originalURL,
});
anchorClickSpy.mockRestore();
});
beforeEach(() => {
spyDownloadAsImage = jest.spyOn(downloadAsImage, 'default');
spyExportChart = jest.spyOn(exploreUtils, 'exportChart');
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
afterEach(async () => {
spyDownloadAsImage.mockRestore();
spyExportChart.mockRestore();
await new Promise(r => setTimeout(r, 0));
});
test('Screenshot (Current View) calls downloadAsImage', async () => {
const props = createProps();
props.chart.latestQueryFormData.viz_type = VizType.Table;
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: { explore: { can_export_image: true } },
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
// clear previous calls on the jest spy created in beforeEach
spyDownloadAsImage.mockClear();
const item = await screen.findByText('Export screenshot (jpeg)');
userEvent.click(item);
await waitFor(() => {
expect(spyDownloadAsImage).toHaveBeenCalled();
});
getSpy.mockRestore();
});
test('CSV (Current View) uses client-side export when pagination disabled & clientView present', async () => {
const props = createProps({
ownState: {
clientView: {
columns: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
],
rows: [
{ a: 1, b: 'x' },
{ a: 2, b: 'y' },
],
},
},
});
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = VizType.Table;
props.chart.latestQueryFormData.server_pagination = false;
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.mockClear();
userEvent.click(await screen.findByText('Export to .CSV'));
expect(spyExportChart).not.toHaveBeenCalled();
getSpy.mockRestore();
});
test('JSON (Current View) uses client-side export when pagination disabled & clientView present', async () => {
const props = createProps({
ownState: {
clientView: {
columns: [{ key: 'a', label: 'A' }],
rows: [{ a: 123 }],
},
},
});
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = VizType.Table;
props.chart.latestQueryFormData.server_pagination = false;
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.mockClear();
userEvent.click(await screen.findByText('Export to .JSON'));
expect(spyExportChart).not.toHaveBeenCalled();
getSpy.mockRestore();
});
test('CSV (Current View) falls back to server export when server_pagination is true', async () => {
const props = createProps();
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = VizType.Table;
props.chart.latestQueryFormData.server_pagination = true;
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.mockClear();
userEvent.click(await screen.findByText('Export to .CSV'));
expect(spyExportChart.mock.calls.length).toBe(1);
const args = spyExportChart.mock.calls[0][0];
expect(args.resultType).toBe('results');
expect(args.resultFormat).toBe('csv');
getSpy.mockRestore();
});
test('Excel (Current View) uses client-side export when pagination disabled & clientView present', async () => {
const props = createProps({
ownState: {
clientView: {
columns: [{ key: 'c', label: 'C' }],
rows: [{ c: 'foo' }],
},
},
});
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = VizType.Table;
props.chart.latestQueryFormData.server_pagination = false;
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
userEvent.click(await screen.findByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.mockClear();
userEvent.click(await screen.findByText(/Export to (Excel|\.XLSX)/i));
expect(spyExportChart).not.toHaveBeenCalled();
getSpy.mockRestore();
});
test('Excel (Current View) falls back to server export when server_pagination is true', async () => {
const props = createProps();
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = VizType.Table;
props.chart.latestQueryFormData.server_pagination = true;
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
userEvent.click(await screen.findByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.mockClear();
userEvent.click(await screen.findByText(/Export to (Excel|\.XLSX)/i));
expect(spyExportChart.mock.calls.length).toBe(1);
const args = spyExportChart.mock.calls[0][0];
expect(args.resultType).toBe('results');
expect(args.resultFormat).toBe('xlsx');
getSpy.mockRestore();
// delete test excel files
const cwd = process.cwd();
for (const file of fs.readdirSync(cwd)) {
if (file.endsWith('.xlsx')) {
fs.unlinkSync(path.join(cwd, file));
}
}
});
test('JSON (Current View) falls back to server export when server_pagination is true', async () => {
const props = createProps();
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = VizType.Table;
props.chart.latestQueryFormData.server_pagination = true;
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
// server path expected - use the jest spy and inspect call args
spyExportChart.mockClear();
const jsonItem = await screen.findByText('Export to .JSON');
userEvent.click(jsonItem);
await waitFor(() => {
expect(spyExportChart.mock.calls.length).toBe(1);
});
const args = spyExportChart.mock.calls[0][0];
expect(args.resultType).toBe('results');
expect(args.resultFormat).toBe('json');
getSpy.mockRestore();
});
});
});