feat(table): Export table data with "Search box" enabled (#36281)

Co-authored-by: RebeccaH2003 <114100529+RebeccaH2003@users.noreply.github.com>
This commit is contained in:
Haoqian Zhang
2025-12-08 23:42:10 -05:00
committed by GitHub
parent 3940354120
commit f4b919bf7d
10 changed files with 765 additions and 44 deletions

View File

@@ -33,6 +33,9 @@ import * as exploreUtils from 'src/explore/exploreUtils';
import { FeatureFlag, VizType } from '@superset-ui/core';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import ExploreHeader from '.';
import { getChartMetadataRegistry } from '@superset-ui/core';
import fs from 'fs';
import path from 'path';
const chartEndpoint = 'glob:*api/v1/chart/*';
@@ -46,6 +49,13 @@ 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,
@@ -65,6 +75,7 @@ const createProps = (additionalProps = {}) => ({
link_length: '25',
x_axis_label: 'age',
y_axis_label: 'count',
server_pagination: false as any,
},
chartStatus: 'rendered',
},
@@ -407,7 +418,7 @@ describe('Additional actions tests', () => {
expect(
await screen.findByText('Edit chart properties'),
).toBeInTheDocument();
expect(screen.getByText('Download')).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();
@@ -418,7 +429,7 @@ describe('Additional actions tests', () => {
expect(screen.queryByText('Manage email report')).not.toBeInTheDocument();
});
test('Should open download submenu', async () => {
test('Should open all data download submenu', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
@@ -426,15 +437,45 @@ describe('Additional actions tests', () => {
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(screen.queryByText('Export to .CSV')).not.toBeInTheDocument();
expect(screen.queryByText('Export to .JSON')).not.toBeInTheDocument();
expect(screen.queryByText('Download as image')).not.toBeInTheDocument();
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
expect(screen.getByText('Download')).toBeInTheDocument();
userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(await screen.findByText('Export to .JSON')).toBeInTheDocument();
expect(await screen.findByText('Download as image')).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(screen.getByText('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 () => {
@@ -508,7 +549,7 @@ describe('Additional actions tests', () => {
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Download', () => {
describe('Export All Data', () => {
let spyDownloadAsImage = sinon.spy();
let spyExportChart = sinon.spy();
@@ -532,7 +573,7 @@ describe('Additional actions tests', () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
test('Should call downloadAsImage when click on "Download as image"', async () => {
test('Should call downloadAsImage when click on "Export screenshot (jpeg)"', async () => {
const props = createProps();
const spy = jest.spyOn(downloadAsImage, 'default');
render(<ExploreHeader {...props} />, {
@@ -546,10 +587,12 @@ describe('Additional actions tests', () => {
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const downloadAsImageElement =
await screen.findByText('Download as image');
const downloadAsImageElement = await screen.findByText(
'Export screenshot (jpeg)',
);
userEvent.click(downloadAsImageElement);
await waitFor(() => {
@@ -563,7 +606,8 @@ describe('Additional actions tests', () => {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(0);
@@ -578,7 +622,8 @@ describe('Additional actions tests', () => {
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(1);
@@ -591,7 +636,8 @@ describe('Additional actions tests', () => {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportJsonElement = await screen.findByText('Export to .JSON');
userEvent.click(exportJsonElement);
expect(spyExportChart.callCount).toBe(0);
@@ -606,7 +652,8 @@ describe('Additional actions tests', () => {
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportJsonElement = await screen.findByText('Export to .JSON');
userEvent.click(exportJsonElement);
expect(spyExportChart.callCount).toBe(1);
@@ -620,7 +667,8 @@ describe('Additional actions tests', () => {
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText(
'Export to pivoted .CSV',
);
@@ -637,7 +685,8 @@ describe('Additional actions tests', () => {
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportCSVElement = await screen.findByText(
'Export to pivoted .CSV',
);
@@ -651,7 +700,8 @@ describe('Additional actions tests', () => {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportExcelElement = await screen.findByText('Export to Excel');
userEvent.click(exportExcelElement);
expect(spyExportChart.callCount).toBe(0);
@@ -665,10 +715,272 @@ describe('Additional actions tests', () => {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
const exportExcelElement = await screen.findByText('Export to Excel');
userEvent.click(exportExcelElement);
expect(spyExportChart.callCount).toBe(1);
});
});
describe('Current View', () => {
let spyDownloadAsImage = sinon.spy();
let spyExportChart = sinon.spy();
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 = sinon.spy(downloadAsImage, 'default');
spyExportChart = sinon.spy(exploreUtils, 'exportChart');
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
afterEach(async () => {
spyDownloadAsImage.restore();
spyExportChart.restore();
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 });
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
// clear previous calls on the sinon spy you created in beforeEach
spyDownloadAsImage.resetHistory();
const item = await screen.findByText('Export screenshot (jpeg)');
userEvent.click(item);
await waitFor(() => {
expect(spyDownloadAsImage.called).toBe(true);
});
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(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.resetHistory();
userEvent.click(await screen.findByText('Export to .CSV'));
expect(spyExportChart.called).toBe(false); // or: expect(spyExportChart.callCount).toBe(0)
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(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.resetHistory();
userEvent.click(await screen.findByText('Export to .JSON'));
expect(spyExportChart.called).toBe(false);
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(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.resetHistory();
userEvent.click(await screen.findByText('Export to .CSV'));
expect(spyExportChart.callCount).toBe(1);
const args = spyExportChart.getCall(0).args[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(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.resetHistory();
userEvent.click(await screen.findByText(/Export to (Excel|\.XLSX)/i));
expect(spyExportChart.called).toBe(false);
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(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
spyExportChart.resetHistory();
userEvent.click(await screen.findByText(/Export to (Excel|\.XLSX)/i));
expect(spyExportChart.callCount).toBe(1);
const args = spyExportChart.getCall(0).args[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(screen.getByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
// server path expected → use the sinon spy and inspect call args
spyExportChart.resetHistory();
const jsonItem = await screen.findByText('Export to .JSON');
userEvent.click(jsonItem);
await waitFor(() => {
expect(spyExportChart.callCount).toBe(1);
});
const args = spyExportChart.getCall(0).args[0];
expect(args.resultType).toBe('results');
expect(args.resultFormat).toBe('json');
getSpy.mockRestore();
});
});
});