diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js index fdede555bded..029827b5e773 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js @@ -38,9 +38,13 @@ describe('Download Chart > Bar chart', () => { cy.visitChartByParams(formData); cy.get('.header-with-actions .ant-dropdown-trigger').click(); cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click(); + cy.get( + '.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(1) > .ant-dropdown-menu-submenu-title', + ).click(); cy.get( '.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)', ).click(); + cy.verifyDownload('.jpg', { contains: true, timeout: 25000, diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx index 1757dd74d83b..553a26085993 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx @@ -90,6 +90,7 @@ export interface DataTableProps extends TableOptions { onSearchColChange: (searchCol: string) => void; searchOptions: SearchOption[]; onFilteredDataChange?: (rows: Row[], filterValue?: string) => void; + onFilteredRowsChange?: (rows: D[]) => void; } export interface RenderHTMLCellProps extends HTMLProps { @@ -133,6 +134,7 @@ export default typedMemo(function DataTable({ onSearchColChange, searchOptions, onFilteredDataChange, + onFilteredRowsChange, ...moreUseTableOptions }: DataTableProps): JSX.Element { const tableHooks: PluginHook[] = [ @@ -204,6 +206,7 @@ export default typedMemo(function DataTable({ ); const { + rows, // filtered/sorted rows before pagination getTableProps, getTableBodyProps, prepareRow, @@ -218,7 +221,6 @@ export default typedMemo(function DataTable({ wrapStickyTable, setColumnOrder, allColumns, - rows, state: { pageIndex, pageSize, @@ -452,6 +454,83 @@ export default typedMemo(function DataTable({ onServerPaginationChange(pageNumber, serverPageSize); } + // Emit filtered rows to parent in client-side mode (debounced via RAF) + const isMountedRef = useRef(true); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const rafRef = useRef(null); + const lastSigRef = useRef(''); + + // Prefer a stable identifier from original row data; otherwise use a deterministic + // concatenation of visible values (keys sorted so column order changes are detected). + function stableRowKey(r: Row): string { + const orig = r.original as Record | undefined; + if (orig) { + const idLike = + (orig as any).id ?? + (orig as any).ID ?? + (orig as any).key ?? + (orig as any).uuid; + if (idLike != null) return String(idLike); + } + + // Fallback: derive from row.values, but make it stable against column order changes. + const v = r.values as Record; + const keys = Object.keys(v).sort(); // detect column order changes + return keys.map(k => String(v[k] ?? '')).join('|'); + } + + // Very small, fast hash for strings (no crypto dependency). + function hashString(s: string): string { + let h = 0; + for (let i = 0; i < s.length; i += 1) { + h = (h * 31 + s.charCodeAt(i)) | 0; + } + return String(h); + } + + function signatureOfRows(rs: Row[]): string { + const keys = rs.map(stableRowKey); + const len = keys.length; + const first = keys[0] ?? ''; + const last = keys[len - 1] ?? ''; + const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions + return `${len}|${first}|${last}|${digest}`; + } + + useEffect(() => { + if (serverPagination || typeof onFilteredRowsChange !== 'function') { + return; + } + + const sig = signatureOfRows(rows); + + if (sig !== lastSigRef.current) { + lastSigRef.current = sig; + if (rafRef.current != null) { + cancelAnimationFrame(rafRef.current); + } + rafRef.current = requestAnimationFrame(() => { + if (isMountedRef.current) { + // Only emit originals when the signature truly changed + onFilteredRowsChange(rows.map(r => r.original as D)); + } + }); + } + + return () => { + if (rafRef.current != null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [rows, serverPagination, onFilteredRowsChange]); + return (
{}, pageNumber: number, pageSize: number, -) => +) => { setDataMask({ ownState: { currentPage: pageNumber, pageSize, }, }); +}; export const updateTableOwnState = ( setDataMask: SetDataMaskHook = () => {}, modifiedOwnState: TableOwnState, -) => +) => { setDataMask({ ownState: modifiedOwnState, }); +}; diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index c2061d738d11..7d4f5c386f8b 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -25,6 +25,7 @@ import { MouseEvent, KeyboardEvent as ReactKeyboardEvent, useEffect, + useRef, } from 'react'; import { @@ -1358,6 +1359,50 @@ export default function TableChart( } }; + // collect client-side filtered rows for export & push snapshot to ownState (guarded) + const [clientViewRows, setClientViewRows] = useState([]); + + const exportColumns = useMemo( + () => + visibleColumnsMeta.map(col => ({ + key: col.key, + label: col.config?.customColumnName || col.originalLabel || col.key, + })), + [visibleColumnsMeta], + ); + + // Use a ref to store previous clientViewRows and exportColumns for robust change detection + const prevClientViewRef = useRef<{ + rows: DataRecord[]; + columns: typeof exportColumns; + } | null>(null); + useEffect(() => { + if (serverPagination) return; // only for client-side mode + const prev = prevClientViewRef.current; + const rowsChanged = !prev || !isEqual(prev.rows, clientViewRows); + const columnsChanged = !prev || !isEqual(prev.columns, exportColumns); + if (rowsChanged || columnsChanged) { + prevClientViewRef.current = { + rows: clientViewRows, + columns: exportColumns, + }; + updateTableOwnState(setDataMask, { + ...serverPaginationData, + clientView: { + rows: clientViewRows, + columns: exportColumns, + count: clientViewRows.length, + }, + }); + } + }, [ + clientViewRows, + exportColumns, + serverPagination, + setDataMask, + serverPaginationData, + ]); + return ( @@ -1395,6 +1440,7 @@ export default function TableChart( onSearchChange={debouncedSearch} searchOptions={searchOptions} onFilteredDataChange={handleFilteredDataChange} + onFilteredRowsChange={setClientViewRows} /> ); diff --git a/superset-frontend/plugins/plugin-chart-table/src/index.ts b/superset-frontend/plugins/plugin-chart-table/src/index.ts index 45cf7d8084fc..0518281d9cf2 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/index.ts @@ -39,6 +39,7 @@ const metadata = new ChartMetadata({ Behavior.InteractiveChart, Behavior.DrillToDetail, Behavior.DrillBy, + 'EXPORT_CURRENT_VIEW' as any, ], category: t('Table'), canBeAnnotationTypes: ['EVENT', 'INTERVAL'], diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index f5512cdba463..d3c899efe30d 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -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(, { 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(, { 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(, { @@ -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 .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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(); + }); + }); }); diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index c4372862778a..e49066165c29 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -600,8 +600,11 @@ function ExploreViewContainer(props) { } }); + const previousOwnState = usePrevious(props.ownState); useEffect(() => { - if (props.ownState !== undefined) { + const strip = s => + s && typeof s === 'object' ? omit(s, ['clientView']) : s; + if (!isEqual(strip(previousOwnState), strip(props.ownState))) { onQuery(); reRenderChart(); } @@ -938,10 +941,14 @@ function mapStateToProps(state) { const form_data = isDeckGLChart ? getDeckGLFormData() : controlsBasedFormData; const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart + + // exclude clientView from extra_form_data; keep other ownState pieces + const ownStateForQuery = omit(dataMask[slice_id]?.ownState, ['clientView']); + form_data.extra_form_data = mergeExtraFormData( { ...form_data.extra_form_data }, { - ...dataMask[slice_id]?.ownState, + ...ownStateForQuery, }, ); const chart = charts[slice_id]; diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index b85817f698e8..48f5b27a727c 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -27,6 +27,7 @@ import { Button, Input, } from '@superset-ui/core/components'; +import { getChartMetadataRegistry } from '@superset-ui/core'; import { Menu } from '@superset-ui/core/components/Menu'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants'; @@ -55,11 +56,19 @@ const MENU_KEYS = { EDIT_PROPERTIES: 'edit_properties', DASHBOARDS_ADDED_TO: 'dashboards_added_to', DOWNLOAD_SUBMENU: 'download_submenu', - EXPORT_TO_CSV: 'export_to_csv', + DATA_EXPORT_OPTIONS: 'data_export_options', + EXPORT_ALL_DATA_GROUP: 'export_all_data_group', + EXPORT_CURRENT_VIEW_GROUP: 'export_current_view_group', EXPORT_TO_CSV_PIVOTED: 'export_to_csv_pivoted', + EXPORT_TO_PIVOT_XLSX: 'export_to_pivot_xlsx', + EXPORT_TO_CSV: 'export_to_csv', EXPORT_TO_JSON: 'export_to_json', EXPORT_TO_XLSX: 'export_to_xlsx', - DOWNLOAD_AS_IMAGE: 'download_as_image', + EXPORT_ALL_SCREENSHOT: 'export_all_screenshot', + EXPORT_CURRENT_TO_CSV: 'export_current_to_csv', + EXPORT_CURRENT_TO_JSON: 'export_current_to_json', + EXPORT_CURRENT_SCREENSHOT: 'export_current_screenshot', + EXPORT_CURRENT_XLSX: 'export_current_xlsx', SHARE_SUBMENU: 'share_submenu', COPY_PERMALINK: 'copy_permalink', EMBED_CODE: 'embed_code', @@ -71,7 +80,6 @@ const MENU_KEYS = { DELETE_REPORT: 'delete_report', VIEW_QUERY: 'view_query', RUN_IN_SQL_LAB: 'run_in_sql_lab', - EXPORT_TO_PIVOT_XLSX: 'export_to_pivot_xlsx', }; const VIZ_TYPES_PIVOTABLE = [VizType.PivotTable]; @@ -185,6 +193,13 @@ export const useExploreAdditionalActionsMenu = ( }); const showDashboardSearch = dashboards?.length > SEARCH_THRESHOLD; + const vizType = latestQueryFormData?.viz_type; + const meta = vizType ? getChartMetadataRegistry().get(vizType) : undefined; + + // Detect if the chart plugin exposes the export-current-view behavior + const hasExportCurrentView = !!meta?.behaviors?.includes( + 'EXPORT_CURRENT_VIEW', + ); const shareByEmail = useCallback(async () => { try { @@ -307,6 +322,116 @@ export const useExploreAdditionalActionsMenu = ( } }, [addDangerToast, addSuccessToast, latestQueryFormData]); + // Minimal client-side CSV builder used for "Current View" when pagination is disabled + const downloadClientCSV = (rows, columns, filename) => { + if (!rows?.length || !columns?.length) return; + const esc = v => { + if (v === null || v === undefined) return ''; + const s = String(v); + const wrapped = /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + return wrapped; + }; + const header = columns.map(c => esc(c.label ?? c.key ?? '')).join(','); + const body = rows + .map(r => columns.map(c => esc(r[c.key])).join(',')) + .join('\n'); + const csv = `${header}\n${body}`; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${filename || 'current_view'}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + }; + + // Robust client-side JSON for "Current View" + const downloadClientJSON = (rows, columns, filename) => { + if (!rows?.length || !columns?.length) return; + + const norm = v => { + if (v instanceof Date) return v.toISOString(); + if (v && typeof v === 'object' && 'input' in v && 'formatter' in v) { + const dv = v.input ?? v.value ?? v.toString?.() ?? ''; + return dv instanceof Date ? dv.toISOString() : dv; + } + return v; + }; + + const data = rows.map(r => { + const out = {}; + columns.forEach(c => { + out[c.key] = norm(r[c.key]); + }); + return out; + }); + + const meta = { + columns: columns.map(c => ({ + key: c.key, + label: c.label ?? c.key, + })), + count: rows.length, + }; + + const payload = { meta, data }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: 'application/json;charset=utf-8;', + }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${filename || 'current_view'}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + }; + + // NEW: Client-side XLSX for "Current View" (uses 'xlsx' already in deps) + const downloadClientXLSX = async (rows, columns, filename) => { + if (!rows?.length || !columns?.length) return; + try { + const XLSX = (await import(/* webpackChunkName: "xlsx" */ 'xlsx')) + .default; + + // Build a flat array of objects keyed by backend column key + const data = rows.map(r => { + const o = {}; + columns.forEach(c => { + const v = r[c.key]; + o[c.label ?? c.key] = + v && typeof v === 'object' && 'input' in v && 'formatter' in v + ? v.input instanceof Date + ? v.input.toISOString() + : (v.input ?? v.value ?? '') + : v instanceof Date + ? v.toISOString() + : v; + }); + return o; + }); + + const ws = XLSX.utils.json_to_sheet(data, { skipHeader: false }); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Current View'); + + // Autosize columns (roughly) by header length + const colWidths = Object.keys(data[0] || {}).map(h => ({ + wch: Math.max(10, String(h).length + 2), + })); + ws['!cols'] = colWidths; + + XLSX.writeFile(wb, `${filename || 'current_view'}.xlsx`); + } catch (e) { + // If xlsx isn’t available for some reason, fall back to CSV + downloadClientCSV(rows, columns, filename || 'current_view'); + addDangerToast?.( + t('Falling back to CSV; Excel export library not available.'), + ); + } + }; + const menu = useMemo(() => { const menuItems = []; @@ -367,10 +492,10 @@ export const useExploreAdditionalActionsMenu = ( menuItems.push({ type: 'divider' }); // Download submenu - const downloadChildren = []; + const allDataChildren = []; if (VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type)) { - downloadChildren.push( + allDataChildren.push( { key: MENU_KEYS.EXPORT_TO_CSV, label: t('Export to original .CSV'), @@ -425,7 +550,7 @@ export const useExploreAdditionalActionsMenu = ( }, ); } else { - downloadChildren.push({ + allDataChildren.push({ key: MENU_KEYS.EXPORT_TO_CSV, label: t('Export to .CSV'), icon: , @@ -443,7 +568,7 @@ export const useExploreAdditionalActionsMenu = ( }); } - downloadChildren.push( + allDataChildren.push( { key: MENU_KEYS.EXPORT_TO_JSON, label: t('Export to .JSON'), @@ -461,8 +586,8 @@ export const useExploreAdditionalActionsMenu = ( }, }, { - key: MENU_KEYS.DOWNLOAD_AS_IMAGE, - label: t('Download as image'), + key: MENU_KEYS.EXPORT_ALL_SCREENSHOT, + label: t('Export screenshot (jpeg)'), icon: , onClick: e => { downloadAsImage( @@ -498,11 +623,147 @@ export const useExploreAdditionalActionsMenu = ( }, ); + const currentViewChildren = [ + { + key: MENU_KEYS.EXPORT_CURRENT_TO_CSV, + label: t('Export to .CSV'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { + // Use 'results' to export the *current view* (as opposed to 'full'). + // Pass ownState so client/UI state (e.g., filters) can be respected when supported. + if ( + !latestQueryFormData?.server_pagination && + ownState?.clientView?.rows?.length && + ownState?.clientView?.columns?.length + ) { + const { rows, columns } = ownState.clientView; + downloadClientCSV( + rows, + columns, + slice?.slice_name || 'current_view', + ); + } else { + exportChart({ + formData: latestQueryFormData, + ownState, + resultType: 'results', + resultFormat: 'csv', + }); + } + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + { + key: MENU_KEYS.EXPORT_CURRENT_TO_JSON, + label: t('Export to .JSON'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { + if ( + !latestQueryFormData?.server_pagination && + ownState?.clientView?.rows?.length && + ownState?.clientView?.columns?.length + ) { + const { rows, columns } = ownState.clientView; + downloadClientJSON( + rows, + columns, + slice?.slice_name || 'current_view', + ); + } else { + exportJson(); + } + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_JSON, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + { + key: MENU_KEYS.EXPORT_CURRENT_SCREENSHOT, + label: t('Export screenshot (jpeg)'), + icon: , + onClick: e => { + downloadAsImage( + '.panel-body .chart-container', + slice?.slice_name ?? t('New chart'), + true, + theme, + )(e.domEvent); + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + { + key: MENU_KEYS.EXPORT_CURRENT_XLSX, + label: t('Export to Excel'), + icon: , + disabled: !canDownloadCSV, + onClick: async () => { + if ( + !latestQueryFormData?.server_pagination && + ownState?.clientView?.rows?.length && + ownState?.clientView?.columns?.length + ) { + // Client-side filtered view → XLSX + const { rows, columns } = ownState.clientView; + await downloadClientXLSX( + rows, + columns, + slice?.slice_name || 'current_view', + ); + } else { + // Server path (respects backend filters/pagination) + await exportExcel(); + } + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + ]; + menuItems.push({ - key: MENU_KEYS.DOWNLOAD_SUBMENU, + key: MENU_KEYS.DATA_EXPORT_OPTIONS, type: 'submenu', - label: t('Download'), - children: downloadChildren, + label: t('Data Export Options'), + children: [ + { + key: MENU_KEYS.EXPORT_ALL_DATA_GROUP, + type: 'submenu', + label: t('Export All Data'), + children: allDataChildren, + }, + ...(hasExportCurrentView + ? [ + { + key: MENU_KEYS.EXPORT_CURRENT_VIEW_GROUP, + type: 'submenu', + label: t('Export Current View'), + children: currentViewChildren, + }, + ] + : []), + ], }); // Share submenu @@ -590,7 +851,7 @@ export const useExploreAdditionalActionsMenu = ( key: MENU_KEYS.RUN_IN_SQL_LAB, label: t('Run in SQL Lab'), onClick: e => { - onOpenInEditor(latestQueryFormData, e.domEvent.metaKey); + onOpenInEditor(latestQueryFormData, e.domEvent?.metaKey); setIsDropdownVisible(false); }, }); @@ -619,6 +880,8 @@ export const useExploreAdditionalActionsMenu = ( showDashboardSearch, slice, theme.sizeUnit, + ownState, + hasExportCurrentView, ]); // Return streaming modal state and handlers for parent to render diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 9342e6b7a0f7..cfad96158936 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -3346,7 +3346,9 @@ def test_available(self, get_available_engine_specs): "parameters": { "properties": { "credentials_info": { - "description": "Contents of BigQuery JSON credentials.", + "description": ( + "Contents of BigQuery JSON credentials." + ), "type": "string", "x-encrypted-extra": True, }, @@ -3436,7 +3438,8 @@ def test_available(self, get_available_engine_specs): "scope": ( "https://www.googleapis.com/auth/" "drive.readonly " - "https://www.googleapis.com/auth/spreadsheets " + "https://www.googleapis.com/auth/" + "spreadsheets " "https://spreadsheets.google.com/feeds" ), "token_request_uri": "https://oauth2.googleapis.com/token", @@ -3447,7 +3450,9 @@ def test_available(self, get_available_engine_specs): "x-encrypted-extra": True, }, "service_account_info": { - "description": "Contents of GSheets JSON credentials.", + "description": ( + "Contents of GSheets JSON credentials." + ), "type": "string", "x-encrypted-extra": True, },