diff --git a/superset-frontend/src/features/home/DashboardTable.test.tsx b/superset-frontend/src/features/home/DashboardTable.test.tsx index d7400254ff6..b258794ff76 100644 --- a/superset-frontend/src/features/home/DashboardTable.test.tsx +++ b/superset-frontend/src/features/home/DashboardTable.test.tsx @@ -28,18 +28,35 @@ import { Router } from 'react-router-dom'; import { configureStore } from '@reduxjs/toolkit'; import fetchMock from 'fetch-mock'; import * as hooks from 'src/views/CRUD/hooks'; +import handleResourceExport from 'src/utils/export'; +import { handleDashboardDelete } from 'src/views/CRUD/utils'; import DashboardTable from './DashboardTable'; -jest.mock('src/views/CRUD/utils', () => ({ - ...jest.requireActual('src/views/CRUD/utils'), - handleDashboardDelete: jest - .fn() - .mockImplementation((dashboard, refreshData) => { - refreshData(); - return Promise.resolve(); - }), +const mockHandleDashboardDelete = handleDashboardDelete as jest.Mock; + +// Mock the export module +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), })); +const mockExport = handleResourceExport as jest.MockedFunction< + typeof handleResourceExport +>; + +jest.mock('src/views/CRUD/utils', () => { + const actual = jest.requireActual('src/views/CRUD/utils'); + return { + ...actual, + handleDashboardDelete: jest + .fn() + .mockImplementation((dashboard, refreshData) => { + refreshData(); + return Promise.resolve(); + }), + }; +}); + // Mock the CRUD hooks jest.mock('src/views/CRUD/hooks', () => ({ ...jest.requireActual('src/views/CRUD/hooks'), @@ -94,256 +111,400 @@ const defaultProps = { otherTabTitle: 'Examples', }; -describe('DashboardTable', () => { - const history = createMemoryHistory(); - const store = configureStore({ - reducer: { - dashboards: (state = { dashboards: [] }) => state, +const history = createMemoryHistory(); +const store = configureStore({ + reducer: { + dashboards: (state = { dashboards: [] }) => state, + }, + preloadedState: { + dashboards: { + dashboards: mockDashboards, }, - preloadedState: { - dashboards: { - dashboards: mockDashboards, - }, - }, - }); + }, +}); - beforeEach(() => { - jest.spyOn(SupersetClient, 'get').mockImplementation(() => - Promise.resolve({ - json: { - result: mockDashboards[0], - }, - response: new Response(), - }), - ); - - fetchMock.get( - 'glob:*/api/v1/dashboard/*', - { +beforeEach(() => { + jest.spyOn(SupersetClient, 'get').mockImplementation(() => + Promise.resolve({ + json: { result: mockDashboards[0], }, - { overwriteRoutes: true }, - ); // Add overwriteRoutes option + response: new Response(), + }), + ); - // Mock loading state for first render - jest.spyOn(hooks, 'useListViewResource').mockImplementationOnce(() => ({ - state: { - loading: true, - resourceCollection: [], - resourceCount: 0, - bulkSelectEnabled: false, - lastFetched: undefined, - }, - setResourceCollection: jest.fn(), - hasPerm: jest.fn().mockReturnValue(true), - refreshData: jest.fn(), - fetchData: jest.fn(), - toggleBulkSelect: jest.fn(), - })); + fetchMock.get( + 'glob:*/api/v1/dashboard/*', + { + result: mockDashboards[0], + }, + { overwriteRoutes: true }, + ); // Add overwriteRoutes option + + // Mock loading state for first render + jest.spyOn(hooks, 'useListViewResource').mockImplementationOnce(() => ({ + state: { + loading: true, + resourceCollection: [], + resourceCount: 0, + bulkSelectEnabled: false, + lastFetched: undefined, + }, + setResourceCollection: jest.fn(), + hasPerm: jest.fn().mockReturnValue(true), + refreshData: jest.fn(), + fetchData: jest.fn(), + toggleBulkSelect: jest.fn(), + })); +}); + +test('renders loading state initially', () => { + render( + + + , + { store }, + ); + expect(screen.getByRole('img', { name: 'empty' })).toBeInTheDocument(); +}); + +test('renders empty state when no dashboards', async () => { + render( + + + , + { store }, + ); + + await waitFor(() => { + expect(screen.getByText('No results')).toBeInTheDocument(); }); +}); - it('renders loading state initially', () => { - render( - - - , - { store }, - ); - expect(screen.getByRole('img', { name: 'empty' })).toBeInTheDocument(); - }); +test('renders dashboard cards when data is loaded', async () => { + jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({ + state: { + loading: false, + resourceCollection: mockDashboards, + resourceCount: mockDashboards.length, + bulkSelectEnabled: false, + lastFetched: new Date().toISOString(), + }, + setResourceCollection: jest.fn(), + hasPerm: jest.fn().mockReturnValue(true), + refreshData: jest.fn(), + fetchData: jest.fn(), + toggleBulkSelect: jest.fn(), + })); - it('renders empty state when no dashboards', async () => { - render( - - - , - { store }, - ); + render( + + + , + { store }, + ); - await waitFor(() => { - expect(screen.getByText('No results')).toBeInTheDocument(); + await waitFor(() => { + mockDashboards.forEach(dashboard => { + expect(screen.getByText(dashboard.dashboard_title)).toBeInTheDocument(); }); }); +}); - it('renders dashboard cards when data is loaded', async () => { - jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({ - state: { - loading: false, - resourceCollection: mockDashboards, - resourceCount: mockDashboards.length, - bulkSelectEnabled: false, - lastFetched: new Date().toISOString(), - }, - setResourceCollection: jest.fn(), - hasPerm: jest.fn().mockReturnValue(true), - refreshData: jest.fn(), - fetchData: jest.fn(), - toggleBulkSelect: jest.fn(), - })); +test('switches to Mine tab correctly', async () => { + const props = { + ...defaultProps, + mine: mockDashboards, + }; - render( - - - , - { store }, - ); + render( + + + , + { store }, + ); - await waitFor(() => { - mockDashboards.forEach(dashboard => { - expect(screen.getByText(dashboard.dashboard_title)).toBeInTheDocument(); - }); - }); + const mineTab = screen.getByRole('menuitem', { name: /mine/i }); + await userEvent.click(mineTab); + await waitFor(() => { + expect(mineTab).toHaveClass('ant-menu-item-selected'); + }); +}); + +test('handles create dashboard button click', async () => { + const assignMock = jest.fn(); + Object.defineProperty(window, 'location', { + value: { assign: assignMock }, + writable: true, }); - it('switches to Mine tab correctly', async () => { - const props = { - ...defaultProps, - mine: mockDashboards, - }; + render( + + + , + { store }, + ); - render( - - - , - { store }, - ); + const createButton = screen.getByRole('button', { name: /dashboard$/i }); + await userEvent.click(createButton); + expect(assignMock).toHaveBeenCalledWith('/dashboard/new'); +}); - const mineTab = screen.getByRole('menuitem', { name: /mine/i }); - await userEvent.click(mineTab); - await waitFor(() => { - expect(mineTab).toHaveClass('ant-menu-item-selected'); - }); +test('switches to Other tab when available', async () => { + const props = { + ...defaultProps, + otherTabData: mockDashboards, + otherTabTitle: 'Examples', + }; + + render( + + + , + { store }, + ); + + const otherTab = screen.getByRole('tab', { name: 'Examples' }); + await userEvent.click(otherTab); + expect(otherTab).toHaveClass('active'); +}); + +test('handles bulk dashboard export with correct ID and shows spinner', async () => { + // Mock export to take some time before calling the done callback + mockExport.mockImplementation( + (resource: string, ids: number[], done: () => void) => + new Promise(resolve => { + setTimeout(() => { + done(); + resolve(); + }, 100); + }), + ); + + const props = { + ...defaultProps, + mine: mockDashboards, + }; + + jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({ + state: { + loading: false, + resourceCollection: mockDashboards, + resourceCount: mockDashboards.length, + bulkSelectEnabled: false, + lastFetched: new Date().toISOString(), + }, + setResourceCollection: jest.fn(), + hasPerm: jest.fn().mockReturnValue(true), + refreshData: jest.fn(), + fetchData: jest.fn(), + toggleBulkSelect: jest.fn(), + })); + + render( + + + , + { store }, + ); + + const moreOptionsButton = screen.getAllByRole('img', { + name: 'more', + })[0]; + await userEvent.click(moreOptionsButton); + + // Wait for dropdown menu to appear + await waitFor(() => { + expect(screen.getByText('Export')).toBeInTheDocument(); }); - it('handles create dashboard button click', async () => { - const assignMock = jest.fn(); - Object.defineProperty(window, 'location', { - value: { assign: assignMock }, - writable: true, - }); - - render( - - - , - { store }, - ); - - const createButton = screen.getByRole('button', { name: /dashboard$/i }); - await userEvent.click(createButton); - expect(assignMock).toHaveBeenCalledWith('/dashboard/new'); - }); - - it('switches to Other tab when available', async () => { - const props = { - ...defaultProps, - otherTabData: mockDashboards, - otherTabTitle: 'Examples', - }; - - render( - - - , - { store }, - ); - - const otherTab = screen.getByRole('tab', { name: 'Examples' }); - await userEvent.click(otherTab); - expect(otherTab).toHaveClass('active'); - }); - - it('handles bulk dashboard export', async () => { - const props = { - ...defaultProps, - mine: mockDashboards, - }; - - render( - - - , - { store }, - ); - - const moreOptionsButton = screen.getAllByRole('img', { - name: 'more', - })[0]; - await userEvent.click(moreOptionsButton); - - // Wait for dropdown menu to appear - await waitFor(() => { - expect(screen.getByText('Export')).toBeInTheDocument(); - }); - - const exportOption = screen.getByText('Export'); - await userEvent.click(exportOption); + const exportOption = screen.getByText('Export'); + await userEvent.click(exportOption); + // Verify spinner shows up during export + await waitFor(() => { expect(screen.getByRole('status')).toBeInTheDocument(); }); - it('handles dashboard deletion confirmation', async () => { - const props = { - ...defaultProps, - mine: mockDashboards, - }; + // Verify the export was called with correct parameters + expect(mockExport).toHaveBeenCalledWith( + 'dashboard', + [1], + expect.any(Function), + ); - const refreshDataMock = jest.fn(); - jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({ - state: { - loading: false, - resourceCollection: mockDashboards, - resourceCount: mockDashboards.length, - bulkSelectEnabled: false, - lastFetched: new Date().toISOString(), - }, - setResourceCollection: jest.fn(), - hasPerm: jest.fn().mockReturnValue(true), - refreshData: refreshDataMock, - fetchData: jest.fn(), - toggleBulkSelect: jest.fn(), - })); + // Wait for export to complete and spinner to disappear + await waitFor( + () => { + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }, + { timeout: 3000 }, + ); +}); - render( - - - , - { store }, - ); +test('handles dashboard deletion confirmation', async () => { + const props = { + ...defaultProps, + mine: mockDashboards, + }; - const moreOptionsButton = screen.getAllByLabelText('more')[0]; - await userEvent.click(moreOptionsButton); + const refreshDataMock = jest.fn(); + jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({ + state: { + loading: false, + resourceCollection: mockDashboards, + resourceCount: mockDashboards.length, + bulkSelectEnabled: false, + lastFetched: new Date().toISOString(), + }, + setResourceCollection: jest.fn(), + hasPerm: jest.fn().mockReturnValue(true), + refreshData: refreshDataMock, + fetchData: jest.fn(), + toggleBulkSelect: jest.fn(), + })); - await waitFor(() => { - expect(screen.getByText('Delete')).toBeInTheDocument(); - }); + render( + + + , + { store }, + ); - const deleteOption = screen.getByText('Delete'); - await userEvent.click(deleteOption); + const moreOptionsButton = screen.getAllByLabelText('more')[0]; + await userEvent.click(moreOptionsButton); - // Verify Delete button is initially disabled - const confirmDeleteButton = screen.getByTestId('modal-confirm-button'); - expect(confirmDeleteButton).toBeDisabled(); + await waitFor(() => { + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); - // Type DELETE in the confirmation input - const deleteInput = screen.getByTestId('delete-modal-input'); - await userEvent.type(deleteInput, 'DELETE'); + const deleteOption = screen.getByText('Delete'); + await userEvent.click(deleteOption); - // Verify Delete button becomes enabled - await waitFor(() => { - expect(confirmDeleteButton).toBeEnabled(); - }); + // Verify Delete button is initially disabled + const confirmDeleteButton = screen.getByTestId('modal-confirm-button'); + expect(confirmDeleteButton).toBeDisabled(); - // Click the now-enabled Delete button - await userEvent.click(confirmDeleteButton); + // Type DELETE in the confirmation input + const deleteInput = screen.getByTestId('delete-modal-input'); + await userEvent.type(deleteInput, 'DELETE'); - await waitFor( - () => { - expect(refreshDataMock).toHaveBeenCalled(); - }, - { timeout: 3000 }, - ); + // Verify Delete button becomes enabled + await waitFor(() => { + expect(confirmDeleteButton).toBeEnabled(); + }); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + // Click the now-enabled Delete button + await userEvent.click(confirmDeleteButton); + + await waitFor( + () => { + expect(refreshDataMock).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + +test('passes correct parameters to handleDashboardDelete for Other tab', async () => { + mockHandleDashboardDelete.mockClear(); + + const refreshDataMock = jest.fn(); + const fetchDataMock = jest.fn().mockName('getData'); + + jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({ + state: { + loading: false, + resourceCollection: mockDashboards, + resourceCount: mockDashboards.length, + bulkSelectEnabled: false, + lastFetched: new Date().toISOString(), + }, + setResourceCollection: jest.fn(), + hasPerm: jest.fn().mockReturnValue(true), + refreshData: refreshDataMock, + fetchData: fetchDataMock, + toggleBulkSelect: jest.fn(), + })); + + const props = { + ...defaultProps, + otherTabData: mockDashboards, + otherTabTitle: 'All', + }; + + render( + + + , + { store }, + ); + + await waitFor(() => { + expect(screen.getByText('Test Dashboard 1')).toBeInTheDocument(); + }); + + const otherTab = screen.getByRole('tab', { name: 'All' }); + await userEvent.click(otherTab); + + await waitFor(() => { + expect(screen.getByText('Test Dashboard 1')).toBeInTheDocument(); + }); + + const moreOptionsButtons = screen.getAllByLabelText(/more|options/i); + expect(moreOptionsButtons.length).toBeGreaterThan(0); + + await userEvent.click(moreOptionsButtons[0]); + + await waitFor(() => { + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + const deleteOption = screen.getByText('Delete'); + await userEvent.click(deleteOption); + + await waitFor(() => { + expect(screen.getByText('Please confirm')).toBeInTheDocument(); + expect(screen.getByTestId('delete-modal-input')).toBeInTheDocument(); + }); + + const deleteInput = screen.getByTestId('delete-modal-input'); + await userEvent.type(deleteInput, 'DELETE'); + + const confirmDeleteButton = screen.getByTestId('modal-confirm-button'); + + await waitFor(() => { + expect(confirmDeleteButton).toBeEnabled(); + }); + + await userEvent.click(confirmDeleteButton); + + await waitFor(() => { + expect(mockHandleDashboardDelete).toHaveBeenCalled(); + }); + + expect(mockHandleDashboardDelete).toHaveBeenCalledWith( + expect.objectContaining({ + id: 1, + dashboard_title: 'Test Dashboard 1', + }), + expect.any(Function), + expect.any(Function), + expect.any(Function), + 'Other', + mockUser.userId, + expect.any(Function), + ); + + const lastCall = mockHandleDashboardDelete.mock.calls[0]; + const getDataParam = lastCall[6]; + + getDataParam('Other'); + expect(fetchDataMock).toHaveBeenCalledWith({ + filters: [], + pageIndex: 0, + pageSize: 5, + sortBy: [{ desc: true, id: 'changed_on_delta_humanized' }], }); }); diff --git a/superset-frontend/src/features/home/DashboardTable.tsx b/superset-frontend/src/features/home/DashboardTable.tsx index bd23d455480..9e685f242cd 100644 --- a/superset-frontend/src/features/home/DashboardTable.tsx +++ b/superset-frontend/src/features/home/DashboardTable.tsx @@ -237,6 +237,7 @@ function DashboardTable({ addDangerToast, activeTab, user?.userId, + getData, ); setDashboardToDelete(null); }} diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index f7e4082cfb7..5e3df011ebd 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -310,6 +310,7 @@ export function handleDashboardDelete( addDangerToast: (arg0: string) => void, dashboardFilter?: string, userId?: string | number, + getData?: (tab: TableTab) => void, ) { return SupersetClient.delete({ endpoint: `/api/v1/dashboard/${id}`, @@ -333,6 +334,8 @@ export function handleDashboardDelete( ], }; if (dashboardFilter === 'Mine') refreshData(filters); + else if (dashboardFilter === 'Other' && getData) + getData(dashboardFilter as TableTab); else refreshData(); addSuccessToast(t('Deleted: %s', dashboardTitle)); },