mirror of
https://github.com/apache/superset.git
synced 2026-06-01 13:49:21 +00:00
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>
1173 lines
38 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|