diff --git a/src/components/CrateTabsShad/CrateTabsShad.tsx b/src/components/CrateTabsShad/CrateTabsShad.tsx index b713623f..8c066a90 100644 --- a/src/components/CrateTabsShad/CrateTabsShad.tsx +++ b/src/components/CrateTabsShad/CrateTabsShad.tsx @@ -23,9 +23,6 @@ function CrateTabsShad({ stickyTabBar = false, onChange, }: CrateTabsShadProps) { - const [activeTab, setActiveTab] = useState(undefined); - const hideTabs = hideWhenSingleTab && items.length === 1; - const getDefaultTab = (): string => { if (initialActiveTab && items.map(item => item.key).includes(initialActiveTab)) { return initialActiveTab; @@ -33,6 +30,9 @@ function CrateTabsShad({ return items[0].key; }; + const [activeTab, setActiveTab] = useState(getDefaultTab); + const hideTabs = hideWhenSingleTab && items.length === 1; + const onTabChange = (value: string) => { if (onChange) { onChange(value); @@ -54,7 +54,6 @@ function CrateTabsShad({ diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index bec2f390..fce1324c 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -144,6 +144,7 @@ export function DataTable({ onGlobalFilterChange: setSearchTerm, onPaginationChange: setPagination, globalFilterFn: 'includesString', + autoResetPageIndex: false, getRowId, }; diff --git a/src/components/SQLResults/SQLResultsTable.test.tsx b/src/components/SQLResults/SQLResultsTable.test.tsx index 61f26dd7..2bfc09cf 100644 --- a/src/components/SQLResults/SQLResultsTable.test.tsx +++ b/src/components/SQLResults/SQLResultsTable.test.tsx @@ -140,13 +140,14 @@ describe('The SQLResultsTable component', () => { }); describe('clicking on "Export as .csv"', () => { - it('downloads the CSV file', async () => { + it('triggers a CSV file download', async () => { const { user } = setup(); await user.click(screen.getByText('Download')); + await user.click(screen.getByText('Export as .csv')); - expect(screen.getByText('Export as .csv').getAttribute('href')).toMatch( - /^data:text\/csv;charset=utf-8,/, + expect(URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ type: 'text/csv' }), ); }); @@ -154,10 +155,11 @@ describe('The SQLResultsTable component', () => { const { user } = setup(); await user.click(screen.getByText('Download')); + await user.click(screen.getByText('Export as .csv')); - expect(screen.getByText('Export as .csv').getAttribute('download')).toMatch( - /^query-results-\d+\.csv/, - ); + const call = (URL.createObjectURL as jest.Mock).mock.calls[0]?.[0] as Blob; + expect(call).toBeDefined(); + expect(call.type).toBe('text/csv'); }); it('calls the onDownloadResult callback with format = csv', async () => { @@ -171,13 +173,14 @@ describe('The SQLResultsTable component', () => { }); describe('clicking on "Export as .json"', () => { - it('downloads the JSON file', async () => { + it('triggers a JSON file download', async () => { const { user } = setup(); await user.click(screen.getByText('Download')); + await user.click(screen.getByText('Export as .json')); - expect(screen.getByText('Export as .json').getAttribute('href')).toMatch( - /^data:application\/json;charset=utf-8,/, + expect(URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ type: 'application/json' }), ); }); @@ -185,10 +188,11 @@ describe('The SQLResultsTable component', () => { const { user } = setup(); await user.click(screen.getByText('Download')); + await user.click(screen.getByText('Export as .json')); - expect(screen.getByText('Export as .json').getAttribute('download')).toMatch( - /^query-results-\d+\.json/, - ); + const call = (URL.createObjectURL as jest.Mock).mock.calls[0]?.[0] as Blob; + expect(call).toBeDefined(); + expect(call.type).toBe('application/json'); }); it('calls the onDownloadResult callback with format = json', async () => { diff --git a/src/components/SQLResults/SQLResultsTable.tsx b/src/components/SQLResults/SQLResultsTable.tsx index a9cb77e7..59128148 100644 --- a/src/components/SQLResults/SQLResultsTable.tsx +++ b/src/components/SQLResults/SQLResultsTable.tsx @@ -37,6 +37,16 @@ type DataTableColumnData = { data: T[]; }; +function triggerDownload(content: string, mimeType: string, filename: string) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + function SQLResultsTable({ result, onDownloadResult }: SQLResultsTableProps) { const { showErrorTrace, tableResultsFormatPretty } = useSessionStore(); const { showSuccessMessage } = useMessage(); @@ -307,33 +317,23 @@ function SQLResultsTable({ result, onDownloadResult }: SQLResultsTableProps) { - - { - if (onDownloadResult) { - onDownloadResult('csv'); - } - }} - > - Export as .csv - + { + triggerDownload(generateCsv(result), 'text/csv', 'query-results-' + Date.now() + '.csv'); + if (onDownloadResult) onDownloadResult('csv'); + }} + > + Export as .csv - - { - if (onDownloadResult) { - onDownloadResult('json'); - } - }} - > - Export as .json - + { + triggerDownload(generateJson(result), 'application/json', 'query-results-' + Date.now() + '.json'); + if (onDownloadResult) onDownloadResult('json'); + }} + > + Export as .json diff --git a/test/setup.ts b/test/setup.ts index b1d87820..ccf4b39d 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -3,9 +3,16 @@ import '@testing-library/jest-dom'; // polyfill window.fetch import 'whatwg-fetch'; -import { act } from 'react'; +import { act } from '@testing-library/react'; import { createRoot } from 'react-dom/client'; import { unstableSetRender } from 'antd'; +import { actWrapper as messageActWrapper } from 'antd/lib/message'; +import { actWrapper as notificationActWrapper } from 'antd/lib/notification'; + +// Wire antd's static message/notification APIs to RTL's act(), which sets +// IS_REACT_ACT_ENVIRONMENT=true so React 19 doesn't warn about out-of-act updates. +messageActWrapper(act); +notificationActWrapper(act); // Make antd static APIs (message, notification) work in React 19's test environment. // Without this, antd renders its floating UI outside act() and React 19 never @@ -54,10 +61,31 @@ global.window.open = jest.fn(); global.window.ResizeObserver = ResizeObserver; global.window.scrollTo = jest.fn(); +// URL.createObjectURL / revokeObjectURL are not implemented in jsdom. +// Used by triggerDownload in SQLResultsTable and similar programmatic downloads. +global.URL.createObjectURL = jest.fn(() => 'blob:mock'); +global.URL.revokeObjectURL = jest.fn(); + +// Prevent programmatic a.click() calls from triggering jsdom navigation. +HTMLAnchorElement.prototype.click = jest.fn(); + Object.defineProperty(window, 'localStorage', { value: mockLocalStorage, }); +const originalConsoleError = console.error; +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation((...args) => { + if ( + typeof args[0] === 'string' && + args[0].includes('trigger element and popup element should in same shadow root') + ) { + return; + } + originalConsoleError(...args); + }); +}); + beforeEach(() => { server.listen(); useLocation.mockReturnValue({