This is an automated email from the ASF dual-hosted git repository. jli pushed a commit to branch test-move-dashboard-list-to-rtl in repository https://gitbox.apache.org/repos/asf/superset.git
commit d5dfbe45808c5c4593c6e7e6141eb8a4dccf8272 Author: Joe Li <[email protected]> AuthorDate: Tue Mar 3 12:30:19 2026 -0800 test(DashboardList): migrate Cypress E2E tests to RTL Replace dashboard_list Cypress tests (list.test.ts, filter.test.ts) with Jest + React Testing Library tests. Shards tests into focused files for card view, list view, behavior (mutations), and permissions. Adds rison- decoded filter/sort assertions, strict fetch-mock catch-all, and post- mutation UI state verification. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../cypress/e2e/dashboard_list/filter.test.ts | 47 --- .../cypress/e2e/dashboard_list/list.test.ts | 279 -------------- .../DashboardList/DashboardList.behavior.test.tsx | 390 +++++++++++++++++++ .../DashboardList/DashboardList.cardview.test.tsx | 417 +++++++++++++++++++++ .../DashboardList/DashboardList.listview.test.tsx | 402 ++++++++++++++++++++ .../DashboardList.permissions.test.tsx | 337 +++++++++++++++++ .../src/pages/DashboardList/DashboardList.test.tsx | 391 ++++++++++--------- .../DashboardList/DashboardList.testHelpers.tsx | 360 ++++++++++++++++++ 8 files changed, 2130 insertions(+), 493 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/filter.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/filter.test.ts deleted file mode 100644 index 854ea541c74..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/filter.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 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 { DASHBOARD_LIST } from 'cypress/utils/urls'; -import { setGridMode, clearAllInputs } from 'cypress/utils'; -import { setFilter } from '../dashboard/utils'; - -describe('Dashboards filters', () => { - before(() => { - cy.visit(DASHBOARD_LIST); - setGridMode('card'); - }); - - beforeEach(() => { - clearAllInputs(); - }); - - it('should allow filtering by "Owner" correctly', () => { - setFilter('Owner', 'alpha user'); - setFilter('Owner', 'admin user'); - }); - - it('should allow filtering by "Modified by" correctly', () => { - setFilter('Modified by', 'alpha user'); - setFilter('Modified by', 'admin user'); - }); - - it('should allow filtering by "Status" correctly', () => { - setFilter('Status', 'Published'); - setFilter('Status', 'Draft'); - }); -}); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts deleted file mode 100644 index 37a8a7ffa4e..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * 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 { DASHBOARD_LIST } from 'cypress/utils/urls'; -import { setGridMode, toggleBulkSelect } from 'cypress/utils'; -import { - setFilter, - interceptBulkDelete, - interceptUpdate, - interceptDelete, - interceptFav, - interceptUnfav, -} from '../dashboard/utils'; - -function orderAlphabetical() { - setFilter('Sort', 'Alphabetical'); -} - -function openProperties() { - cy.get('[aria-label="more"]').first().click(); - cy.getBySel('dashboard-card-option-edit-button').click(); -} - -function openMenu() { - cy.get('[aria-label="more"]').first().click(); -} - -function confirmDelete(bulk = false) { - interceptDelete(); - interceptBulkDelete(); - - // Wait for modal dialog to be present and visible - cy.get('[role="dialog"][aria-modal="true"]').should('be.visible'); - cy.getBySel('delete-modal-input') - .should('be.visible') - .then($input => { - cy.wrap($input).clear(); - cy.wrap($input).type('DELETE'); - }); - cy.getBySel('modal-confirm-button').should('be.visible').click(); - - if (bulk) { - cy.wait('@bulkDelete'); - } else { - cy.wait('@delete'); - } -} - -describe('Dashboards list', () => { - describe('list mode', () => { - before(() => { - cy.visit(DASHBOARD_LIST); - setGridMode('list'); - }); - - it('should load rows in list mode', () => { - cy.getBySel('listview-table').should('be.visible'); - cy.getBySel('sort-header').eq(1).contains('Name'); - cy.getBySel('sort-header').eq(2).contains('Status'); - cy.getBySel('sort-header').eq(3).contains('Owners'); - cy.getBySel('sort-header').eq(4).contains('Last modified'); - cy.getBySel('sort-header').eq(5).contains('Actions'); - }); - - // Skipped: depends on specific example dashboards that may vary - it.skip('should sort correctly in list mode', () => { - cy.getBySel('sort-header').eq(1).click(); - cy.getBySel('loading-indicator').should('not.exist'); - cy.getBySel('table-row').first().contains('Supported Charts Dashboard'); - cy.getBySel('sort-header').eq(1).click(); - cy.getBySel('loading-indicator').should('not.exist'); - cy.getBySel('table-row').first().contains("World Bank's Data"); - cy.getBySel('sort-header').eq(1).click(); - }); - - it('should bulk select in list mode', () => { - toggleBulkSelect(); - cy.get('th.ant-table-cell input[aria-label="Select all"]').click(); - // Check that checkboxes are checked (count varies based on loaded examples) - cy.get( - '.ant-checkbox-input:not(th.ant-table-measure-cell .ant-checkbox-input)', - ) - .should('be.checked') - .should('have.length.at.least', 1); - cy.getBySel('bulk-select-copy').contains('Selected'); - cy.getBySel('bulk-select-action') - .should('have.length', 2) - .then($btns => { - expect($btns).to.contain('Delete'); - expect($btns).to.contain('Export'); - }); - cy.getBySel('bulk-select-deselect-all').click(); - cy.get('input[type="checkbox"]:checked').should('have.length', 0); - cy.getBySel('bulk-select-copy').contains('0 Selected'); - cy.getBySel('bulk-select-action').should('not.exist'); - }); - }); - - describe('card mode', () => { - before(() => { - cy.visit(DASHBOARD_LIST); - setGridMode('card'); - }); - - it('should load rows in card mode', () => { - cy.getBySel('listview-table').should('not.exist'); - // Check that we have some dashboard cards (count varies based on loaded examples) - cy.getBySel('styled-card').should('have.length.at.least', 1); - }); - - it('should bulk select in card mode', () => { - toggleBulkSelect(); - cy.getBySel('styled-card').click({ multiple: true }); - cy.getBySel('bulk-select-copy').contains('Selected'); - cy.getBySel('bulk-select-action') - .should('have.length', 2) - .then($btns => { - expect($btns).to.contain('Delete'); - expect($btns).to.contain('Export'); - }); - cy.getBySel('bulk-select-deselect-all').click(); - cy.getBySel('bulk-select-copy').contains('0 Selected'); - cy.getBySel('bulk-select-action').should('not.exist'); - }); - - // Skipped: depends on specific example dashboards that may vary - it.skip('should sort in card mode', () => { - orderAlphabetical(); - cy.getBySel('styled-card').first().contains('Supported Charts Dashboard'); - }); - - it('should preserve other filters when sorting', () => { - // Check that we have some cards (count varies based on loaded examples) - cy.getBySel('styled-card').should('have.length.at.least', 1); - setFilter('Status', 'Published'); - setFilter('Sort', 'Least recently modified'); - // After filtering, we should have some cards (at least 1 if any are published) - cy.getBySel('styled-card').should('have.length.at.least', 1); - }); - }); - - describe('common actions', () => { - beforeEach(() => { - cy.createSampleDashboards([0, 1, 2, 3]); - cy.visit(DASHBOARD_LIST); - }); - - it('should allow to favorite/unfavorite dashboard', () => { - interceptFav(); - interceptUnfav(); - - setGridMode('card'); - orderAlphabetical(); - - cy.getBySel('styled-card').first().contains('1 - Sample dashboard'); - cy.getBySel('styled-card') - .first() - .find("[aria-label='unstarred']") - .click(); - cy.wait('@select'); - cy.getBySel('styled-card').first().find("[aria-label='starred']").click(); - cy.wait('@unselect'); - cy.getBySel('styled-card') - .first() - .find("[aria-label='starred']") - .should('not.exist'); - }); - - it('should bulk delete correctly', () => { - toggleBulkSelect(); - - // bulk deletes in card-view - setGridMode('card'); - orderAlphabetical(); - - cy.getBySel('styled-card').eq(0).contains('1 - Sample dashboard').click(); - cy.getBySel('styled-card').eq(1).contains('2 - Sample dashboard').click(); - cy.getBySel('bulk-select-action').eq(0).contains('Delete').click(); - confirmDelete(true); - cy.getBySel('styled-card') - .eq(0) - .should('not.contain', '1 - Sample dashboard'); - cy.getBySel('styled-card') - .eq(1) - .should('not.contain', '2 - Sample dashboard'); - - // bulk deletes in list-view - setGridMode('list'); - cy.getBySel('table-row').eq(0).contains('3 - Sample dashboard'); - cy.getBySel('table-row').eq(1).contains('4 - Sample dashboard'); - cy.get('[data-test="table-row"] input[type="checkbox"]').eq(0).click(); - cy.get('[data-test="table-row"] input[type="checkbox"]').eq(1).click(); - cy.getBySel('bulk-select-action').eq(0).contains('Delete').click(); - confirmDelete(true); - cy.getBySel('loading-indicator').should('exist'); - cy.getBySel('loading-indicator').should('not.exist'); - cy.getBySel('table-row') - .eq(0) - .should('not.contain', '3 - Sample dashboard'); - cy.getBySel('table-row') - .eq(1) - .should('not.contain', '4 - Sample dashboard'); - }); - - it.skip('should delete correctly in list mode', () => { - // deletes in list-view - setGridMode('list'); - - cy.getBySel('table-row') - .eq(0) - .contains('4 - Sample dashboard') - .should('exist'); - cy.getBySel('dashboard-list-trash-icon').eq(0).click(); - confirmDelete(); - cy.getBySel('table-row') - .eq(0) - .should('not.contain', '4 - Sample dashboard'); - }); - - it('should delete correctly in card mode', () => { - // deletes in card-view - setGridMode('card'); - orderAlphabetical(); - - cy.getBySel('styled-card') - .eq(0) - .contains('1 - Sample dashboard') - .should('exist'); - openMenu(); - cy.getBySel('dashboard-card-option-delete-button').click(); - confirmDelete(); - cy.getBySel('styled-card') - .eq(0) - .should('not.contain', '1 - Sample dashboard'); - }); - - it('should edit correctly', () => { - interceptUpdate(); - - // edits in card-view - setGridMode('card'); - orderAlphabetical(); - cy.getBySel('styled-card').eq(0).contains('1 - Sample dashboard'); - - // change title - openProperties(); - cy.getBySel('dashboard-title-input').type(' | EDITED'); - cy.get('button:contains("Save")').click(); - cy.wait('@update'); - cy.getBySel('styled-card') - .eq(0) - .contains('1 - Sample dashboard | EDITED'); - - // edits in list-view - setGridMode('list'); - cy.getBySel('edit-alt').eq(0).click(); - cy.getBySel('dashboard-title-input').clear(); - cy.getBySel('dashboard-title-input').type('1 - Sample dashboard'); - cy.get('button:contains("Save")').click(); - cy.wait('@update'); - cy.getBySel('table-row').eq(0).contains('1 - Sample dashboard'); - }); - }); -}); diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.behavior.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.behavior.test.tsx new file mode 100644 index 00000000000..4a839ede41f --- /dev/null +++ b/superset-frontend/src/pages/DashboardList/DashboardList.behavior.test.tsx @@ -0,0 +1,390 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import { + fireEvent, + screen, + waitFor, +} from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { isFeatureEnabled } from '@superset-ui/core'; +import { + API_ENDPOINTS, + mockDashboards, + setupMocks, + renderDashboardList, +} from './DashboardList.testHelpers'; + +jest.setTimeout(30000); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction< + typeof isFeatureEnabled +>; + +const mockUser = { + userId: 1, + firstName: 'Test', + lastName: 'User', + roles: { + Admin: [ + ['can_write', 'Dashboard'], + ['can_export', 'Dashboard'], + ], + }, +}; + +beforeEach(() => { + setupMocks(); + // Default to card view for behavior tests + mockIsFeatureEnabled.mockImplementation( + (feature: string) => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', + ); +}); + +afterEach(() => { + fetchMock.clearHistory().removeRoutes(); + mockIsFeatureEnabled.mockReset(); +}); + +test('can favorite a dashboard', async () => { + // Mock favorite status - dashboard 1 is not favorited + fetchMock.removeRoutes({ + names: ['glob:*/api/v1/dashboard/favorite_status*'], + }); + fetchMock.get('glob:*/api/v1/dashboard/favorite_status*', { + result: mockDashboards.map(d => ({ + id: d.id, + value: false, + })), + }); + + // Mock the POST to favorite endpoint + fetchMock.post('glob:*/api/v1/dashboard/*/favorites/', { + result: 'OK', + }); + + renderDashboardList(mockUser); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Find and click an unstarred favorite icon + const favoriteIcons = screen.getAllByTestId('fave-unfave-icon'); + expect(favoriteIcons.length).toBeGreaterThan(0); + fireEvent.click(favoriteIcons[0]); + + // Verify POST was made to favorites endpoint + await waitFor(() => { + const favCalls = fetchMock.callHistory.calls( + /dashboard\/\d+\/favorites/, + { method: 'POST' }, + ); + expect(favCalls).toHaveLength(1); + }); + + // Verify the star icon flipped to starred state + await waitFor(() => { + expect(screen.getByRole('img', { name: 'starred' })).toBeInTheDocument(); + }); +}); + +test('can unfavorite a dashboard', async () => { + // Clear all routes and re-setup with favorited dashboard + fetchMock.clearHistory().removeRoutes(); + + // Setup mocks manually with dashboard 1 favorited + fetchMock.get('glob:*/api/v1/dashboard/_info*', { + permissions: ['can_read', 'can_write', 'can_export'], + }); + fetchMock.get('glob:*/api/v1/dashboard/?*', { + result: mockDashboards, + dashboard_count: mockDashboards.length, + }); + fetchMock.get('glob:*/api/v1/dashboard/favorite_status*', { + result: mockDashboards.map(d => ({ + id: d.id, + value: d.id === 1, + })), + }); + fetchMock.get('glob:*/api/v1/dashboard/related/owners*', { + result: [], + count: 0, + }); + fetchMock.get('glob:*/api/v1/dashboard/related/changed_by*', { + result: [], + count: 0, + }); + global.URL.createObjectURL = jest.fn(); + fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false }); + fetchMock.get('glob:*', (callLog: any) => { + const reqUrl = typeof callLog === 'string' ? callLog : callLog?.url || callLog; + throw new Error(`[fetchMock catch-all] Unmatched GET: ${reqUrl}`); + }); + + // Mock the DELETE to unfavorite endpoint + fetchMock.delete('glob:*/api/v1/dashboard/*/favorites/', { + result: 'OK', + }); + + renderDashboardList(mockUser); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Wait for the starred icon to appear (favorite status loaded) + const starredIcon = await screen.findByRole('img', { name: 'starred' }); + fireEvent.click(starredIcon); + + // Verify DELETE was made to favorites endpoint + await waitFor(() => { + const unfavCalls = fetchMock.callHistory.calls( + /dashboard\/\d+\/favorites/, + { method: 'DELETE' }, + ); + expect(unfavCalls).toHaveLength(1); + }); + + // Verify the star icon flipped back to unstarred state + await waitFor(() => { + expect(screen.queryByRole('img', { name: 'starred' })).not.toBeInTheDocument(); + }); +}); + +test('can delete a single dashboard from card menu', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Open card menu + const moreButtons = screen.getAllByLabelText('more'); + fireEvent.click(moreButtons[0]); + + // Click delete from the dropdown + const deleteButton = await screen.findByTestId( + 'dashboard-card-option-delete-button', + ); + fireEvent.click(deleteButton); + + // Should open delete confirmation dialog + await waitFor(() => { + expect( + screen.getByText(/Are you sure you want to delete/i), + ).toBeInTheDocument(); + }); + + // Type DELETE in the confirmation input + const deleteInput = screen.getByTestId('delete-modal-input'); + await userEvent.type(deleteInput, 'DELETE'); + + // Mock the DELETE endpoint + fetchMock.delete('glob:*/api/v1/dashboard/*', { + message: 'Dashboard deleted', + }); + + // Click confirm button + const confirmButton = screen.getByTestId('modal-confirm-button'); + fireEvent.click(confirmButton); + + // Verify delete API was called + await waitFor(() => { + const deleteCalls = fetchMock.callHistory.calls(/api\/v1\/dashboard\//, { + method: 'DELETE', + }); + expect(deleteCalls).toHaveLength(1); + }); + + // Verify the delete confirmation dialog closes + await waitFor(() => { + expect( + screen.queryByText(/Are you sure you want to delete/i), + ).not.toBeInTheDocument(); + }); +}); + +test('can edit dashboard title via properties modal', async () => { + // Clear all routes and re-setup with single dashboard mock + fetchMock.clearHistory().removeRoutes(); + + fetchMock.get(API_ENDPOINTS.DASHBOARDS_INFO, { + permissions: ['can_read', 'can_write', 'can_export'], + }); + fetchMock.get(API_ENDPOINTS.DASHBOARDS, { + result: mockDashboards, + dashboard_count: mockDashboards.length, + }); + fetchMock.get(API_ENDPOINTS.DASHBOARD_FAVORITE_STATUS, { result: [] }); + fetchMock.get(API_ENDPOINTS.DASHBOARD_RELATED_OWNERS, { + result: [], + count: 0, + }); + fetchMock.get(API_ENDPOINTS.DASHBOARD_RELATED_CHANGED_BY, { + result: [], + count: 0, + }); + global.URL.createObjectURL = jest.fn(); + fetchMock.get(API_ENDPOINTS.THUMBNAIL, { + body: new Blob(), + sendAsJson: false, + }); + + // Mock GET for single dashboard (PropertiesModal fetches /api/v1/dashboard/<id>) + fetchMock.get(/\/api\/v1\/dashboard\/\d+$/, { + result: { + ...mockDashboards[0], + json_metadata: '{}', + slug: '', + css: '', + is_managed_externally: false, + metadata: {}, + theme: null, + }, + }); + + // Mock themes endpoint (PropertiesModal fetches available themes) + fetchMock.get('glob:*/api/v1/theme/*', { result: [] }); + + // Catch-all must be last — fail hard on unmatched URLs + fetchMock.get(API_ENDPOINTS.CATCH_ALL, (callLog: any) => { + const reqUrl = typeof callLog === 'string' ? callLog : callLog?.url || callLog; + throw new Error(`[fetchMock catch-all] Unmatched GET: ${reqUrl}`); + }); + + renderDashboardList(mockUser); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Open card menu and click edit + const moreButtons = screen.getAllByLabelText('more'); + fireEvent.click(moreButtons[0]); + + const editButton = await screen.findByTestId( + 'dashboard-card-option-edit-button', + ); + fireEvent.click(editButton); + + // Wait for properties modal to load and show the title input + const titleInput = await screen.findByTestId('dashboard-title-input'); + expect(titleInput).toHaveValue(mockDashboards[0].dashboard_title); + + // Change the title + await userEvent.clear(titleInput); + await userEvent.type(titleInput, 'Updated Dashboard Title'); + + // Mock the PUT endpoint + fetchMock.put('glob:*/api/v1/dashboard/*', { + result: { ...mockDashboards[0], dashboard_title: 'Updated Dashboard Title' }, + }); + + // Click Save button + const saveButton = screen.getByRole('button', { name: /save/i }); + fireEvent.click(saveButton); + + // Verify PUT API was called + await waitFor(() => { + const putCalls = fetchMock.callHistory.calls(/api\/v1\/dashboard\//, { + method: 'PUT', + }); + expect(putCalls).toHaveLength(1); + }); + + // Verify the properties modal closes after save + await waitFor(() => { + expect(screen.queryByTestId('dashboard-title-input')).not.toBeInTheDocument(); + }); +}); + +test('opens delete confirmation from list view trash icon', async () => { + // Switch to list view + mockIsFeatureEnabled.mockReturnValue(false); + + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Click the delete icon in the actions column + const trashIcons = screen.getAllByTestId('dashboard-list-trash-icon'); + fireEvent.click(trashIcons[0]); + + // Should open confirmation dialog + await waitFor(() => { + expect( + screen.getByText(/Are you sure you want to delete/i), + ).toBeInTheDocument(); + }); + + // Type DELETE in the confirmation input + const deleteInput = screen.getByTestId('delete-modal-input'); + await userEvent.type(deleteInput, 'DELETE'); + + // Mock the DELETE endpoint + fetchMock.delete('glob:*/api/v1/dashboard/*', { + message: 'Dashboard deleted', + }); + + // Click confirm button + const confirmButton = screen.getByTestId('modal-confirm-button'); + fireEvent.click(confirmButton); + + // Verify delete API was called + await waitFor(() => { + const deleteCalls = fetchMock.callHistory.calls(/api\/v1\/dashboard\//, { + method: 'DELETE', + }); + expect(deleteCalls).toHaveLength(1); + }); + + // Verify the delete confirmation dialog closes + await waitFor(() => { + expect( + screen.queryByText(/Are you sure you want to delete/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.cardview.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.cardview.test.tsx new file mode 100644 index 00000000000..c03299cfc43 --- /dev/null +++ b/superset-frontend/src/pages/DashboardList/DashboardList.cardview.test.tsx @@ -0,0 +1,417 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import { + fireEvent, + screen, + waitFor, + within, +} from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { isFeatureEnabled } from '@superset-ui/core'; +import { + mockDashboards, + mockHandleResourceExport, + renderDashboardList, + setupMocks, + getLatestDashboardApiCall, +} from './DashboardList.testHelpers'; + +jest.setTimeout(30000); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockUser = { + userId: 1, + firstName: 'Test', + lastName: 'User', + roles: { + Admin: [ + ['can_write', 'Dashboard'], + ['can_export', 'Dashboard'], + ], + }, +}; + +// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks +describe('DashboardList Card View Tests', () => { + beforeEach(() => { + setupMocks(); + + // Enable card view as default + ( + isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled> + ).mockImplementation( + (feature: string) => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', + ); + }); + + afterEach(() => fetchMock.clearHistory().removeRoutes()); + + test('renders cards instead of table', async () => { + renderDashboardList(mockUser); + + await screen.findByTestId('dashboard-list-view'); + + // Verify no table in card view + expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument(); + + // Verify card view toggle is active + const cardViewToggle = screen.getByRole('img', { name: 'appstore' }); + const cardViewButton = cardViewToggle.closest('[role="button"]'); + expect(cardViewButton).toHaveClass('active'); + }); + + test('switches from card view to list view', async () => { + renderDashboardList(mockUser); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument(); + + // Switch to list view + const listViewToggle = screen.getByRole('img', { + name: 'unordered-list', + }); + const listViewButton = listViewToggle.closest('[role="button"]'); + expect(listViewButton).not.toBeNull(); + fireEvent.click(listViewButton!); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + }); + + test('displays dashboard data correctly in cards', async () => { + renderDashboardList(mockUser); + + await screen.findByTestId('dashboard-list-view'); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Verify favorite stars exist (one per dashboard) + const favoriteStars = screen.getAllByTestId('fave-unfave-icon'); + expect(favoriteStars).toHaveLength(mockDashboards.length); + + // Verify action menu exists (more button for each card) + const moreButtons = screen.getAllByLabelText('more'); + expect(moreButtons).toHaveLength(mockDashboards.length); + + // Verify menu items appear on click + fireEvent.click(moreButtons[0]); + await waitFor(() => { + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Export')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + }); + + test('renders sort dropdown in card view', async () => { + renderDashboardList(mockUser); + await screen.findByTestId('dashboard-list-view'); + + await waitFor(() => { + expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument(); + }); + + const sortFilter = screen.getByTestId('card-sort-select'); + expect(sortFilter).toBeInTheDocument(); + expect(sortFilter).toBeVisible(); + }); + + test('selecting a sort option triggers new API call', async () => { + renderDashboardList(mockUser); + await screen.findByTestId('dashboard-list-view'); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Find the sort select by its testId, then the combobox within it + const sortContainer = screen.getByTestId('card-sort-select'); + const sortCombobox = within(sortContainer).getByRole('combobox'); + await userEvent.click(sortCombobox); + + // Select "Alphabetical" from the dropdown + const alphabeticalOption = await waitFor(() => + within( + // eslint-disable-next-line testing-library/no-node-access + document.querySelector('.rc-virtual-list')!, + ).getByText('Alphabetical'), + ); + await userEvent.click(alphabeticalOption); + + await waitFor(() => { + const latest = getLatestDashboardApiCall(); + expect(latest).not.toBeNull(); + expect(latest!.query).toMatchObject({ + order_column: 'dashboard_title', + order_direction: 'asc', + }); + }); + }); + + test('can bulk deselect all dashboards', async () => { + renderDashboardList(mockUser); + + await screen.findByTestId('dashboard-list-view'); + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Enable bulk select mode + const bulkSelectButton = screen.getByTestId('bulk-select'); + fireEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument(); + }); + + // Select first card + const firstDashboardName = screen.getByText( + mockDashboards[0].dashboard_title, + ); + fireEvent.click(firstDashboardName); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + '1 Selected', + ); + }); + + // Select second card + const secondDashboardName = screen.getByText( + mockDashboards[1].dashboard_title, + ); + fireEvent.click(secondDashboardName); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + '2 Selected', + ); + }); + + // Verify Delete and Export buttons appear + const bulkActions = screen.getAllByTestId('bulk-select-action'); + expect(bulkActions.find(btn => btn.textContent === 'Delete')).toBeTruthy(); + expect(bulkActions.find(btn => btn.textContent === 'Export')).toBeTruthy(); + + // Click deselect all + const deselectAllButton = screen.getByTestId('bulk-select-deselect-all'); + fireEvent.click(deselectAllButton); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + '0 Selected', + ); + }); + + // Bulk action buttons should disappear + expect(screen.queryByTestId('bulk-select-action')).not.toBeInTheDocument(); + }); + + test('can bulk export selected dashboards', async () => { + renderDashboardList(mockUser); + + await screen.findByTestId('dashboard-list-view'); + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Enable bulk select mode + const bulkSelectButton = screen.getByTestId('bulk-select'); + fireEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument(); + }); + + // Select dashboards by clicking on each card + for (let i = 0; i < mockDashboards.length; i += 1) { + const dashboardName = screen.getByText(mockDashboards[i].dashboard_title); + fireEvent.click(dashboardName); + } + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + `${mockDashboards.length} Selected`, + ); + }); + + const bulkExportButton = screen.getByText('Export'); + fireEvent.click(bulkExportButton); + + expect(mockHandleResourceExport).toHaveBeenCalledWith( + 'dashboard', + mockDashboards.map(d => d.id), + expect.any(Function), + ); + }); + + test('can bulk delete selected dashboards', async () => { + renderDashboardList(mockUser); + + await screen.findByTestId('dashboard-list-view'); + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + // Enable bulk select mode + const bulkSelectButton = screen.getByTestId('bulk-select'); + fireEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument(); + }); + + // Select dashboards + for (let i = 0; i < mockDashboards.length; i += 1) { + const dashboardName = screen.getByText(mockDashboards[i].dashboard_title); + fireEvent.click(dashboardName); + } + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + `${mockDashboards.length} Selected`, + ); + }); + + const bulkDeleteButton = screen.getByText('Delete'); + fireEvent.click(bulkDeleteButton); + + await waitFor(() => { + expect(screen.getByText('Please confirm')).toBeInTheDocument(); + }); + + // Type DELETE in the confirmation input + const deleteInput = screen.getByTestId('delete-modal-input'); + await userEvent.type(deleteInput, 'DELETE'); + + // Mock the bulk DELETE endpoint + fetchMock.delete('glob:*/api/v1/dashboard/?*', { + message: 'Dashboards deleted', + }); + + // Click confirm button + const confirmButton = screen.getByTestId('modal-confirm-button'); + fireEvent.click(confirmButton); + + // Verify bulk delete API was called + await waitFor(() => { + const deleteCalls = fetchMock.callHistory.calls( + /api\/v1\/dashboard\/\?/, + { method: 'DELETE' }, + ); + expect(deleteCalls).toHaveLength(1); + }); + }); + + test('exit bulk select by hitting x on bulk select bar', async () => { + renderDashboardList(mockUser); + + await screen.findByTestId('dashboard-list-view'); + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + const bulkSelectButton = screen.getByTestId('bulk-select'); + fireEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument(); + }); + + // Click the X button to close bulk select + const bulkSelectBar = screen.getByTestId('bulk-select-controls'); + const closeButton = within(bulkSelectBar).getByRole('button', { + name: /close/i, + }); + fireEvent.click(closeButton); + + await waitFor(() => { + expect( + screen.queryByTestId('bulk-select-controls'), + ).not.toBeInTheDocument(); + }); + }); + + test('card click behavior changes in bulk select mode', async () => { + renderDashboardList(mockUser); + + await screen.findByTestId('dashboard-list-view'); + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('bulk-select-controls'), + ).not.toBeInTheDocument(); + + const bulkSelectButton = screen.getByTestId('bulk-select'); + fireEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument(); + }); + + // Clicking on cards should select them + const firstDashboardName = screen.getByText( + mockDashboards[0].dashboard_title, + ); + fireEvent.click(firstDashboardName); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + '1 Selected', + ); + }); + + // Clicking the same card again should deselect it + fireEvent.click(firstDashboardName); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + '0 Selected', + ); + }); + }); +}); diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.listview.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.listview.test.tsx new file mode 100644 index 00000000000..1030faf96c2 --- /dev/null +++ b/superset-frontend/src/pages/DashboardList/DashboardList.listview.test.tsx @@ -0,0 +1,402 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import { + fireEvent, + screen, + waitFor, + within, +} from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { isFeatureEnabled } from '@superset-ui/core'; +import { + mockDashboards, + mockHandleResourceExport, + setupMocks, + renderDashboardList, + getLatestDashboardApiCall, +} from './DashboardList.testHelpers'; + +jest.setTimeout(30000); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction< + typeof isFeatureEnabled +>; + +const mockUser = { + userId: 1, + firstName: 'Test', + lastName: 'User', + roles: { + Admin: [ + ['can_write', 'Dashboard'], + ['can_export', 'Dashboard'], + ], + }, +}; + +beforeEach(() => { + mockHandleResourceExport.mockClear(); + setupMocks(); + // Default to list view (no card view feature flag) + mockIsFeatureEnabled.mockReturnValue(false); +}); + +afterEach(() => { + fetchMock.clearHistory().removeRoutes(); + mockIsFeatureEnabled.mockReset(); +}); + +test('renders table in list view', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-list-view')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('styled-card')).not.toBeInTheDocument(); +}); + +test('renders all required column headers', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + + const expectedHeaders = [ + 'Name', + 'Status', + 'Owners', + 'Last modified', + 'Actions', + ]; + + expectedHeaders.forEach(headerText => { + expect(within(table).getByTitle(headerText)).toBeInTheDocument(); + }); +}); + +test('displays dashboard data in table rows', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const testDashboard = mockDashboards[0]; + + await waitFor(() => { + expect( + within(table).getByText(testDashboard.dashboard_title), + ).toBeInTheDocument(); + }); + + // Find the specific row + const dashboardNameElement = within(table).getByText( + testDashboard.dashboard_title, + ); + const dashboardRow = dashboardNameElement.closest( + '[data-test="table-row"]', + ) as HTMLElement; + expect(dashboardRow).toBeInTheDocument(); + + // Check for favorite star + const favoriteButton = within(dashboardRow).getByTestId('fave-unfave-icon'); + expect(favoriteButton).toBeInTheDocument(); + + // Check last modified time + expect( + within(dashboardRow).getByText(testDashboard.changed_on_delta_humanized), + ).toBeInTheDocument(); + + // Verify action buttons exist + expect(within(dashboardRow).getByTestId('edit-alt')).toBeInTheDocument(); +}); + +test('sorts table when clicking column header', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const nameHeader = within(table).getByTitle('Name'); + await userEvent.click(nameHeader); + + await waitFor(() => { + const latest = getLatestDashboardApiCall(); + expect(latest).not.toBeNull(); + expect(latest!.query).toMatchObject({ + order_column: 'dashboard_title', + order_direction: 'asc', + }); + }); +}); + +test('supports bulk select and deselect all', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + const bulkSelectButton = screen.getByTestId('bulk-select'); + await userEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getAllByRole('checkbox')).toHaveLength( + mockDashboards.length + 1, + ); + }); + + // Select all + const selectAllCheckbox = screen.getAllByLabelText('Select all')[0]; + expect(selectAllCheckbox).not.toBeChecked(); + await userEvent.click(selectAllCheckbox); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + `${mockDashboards.length} Selected`, + ); + }); + + // Verify Delete and Export buttons appear + const bulkActions = screen.getAllByTestId('bulk-select-action'); + expect(bulkActions.find(btn => btn.textContent === 'Delete')).toBeTruthy(); + expect(bulkActions.find(btn => btn.textContent === 'Export')).toBeTruthy(); + + // Deselect all + const deselectAllButton = screen.getByTestId('bulk-select-deselect-all'); + await userEvent.click(deselectAllButton); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + '0 Selected', + ); + }); + + // Bulk action buttons should disappear + expect(screen.queryByTestId('bulk-select-action')).not.toBeInTheDocument(); +}); + +test('supports bulk export of selected dashboards', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + const bulkSelectButton = screen.getByTestId('bulk-select'); + await userEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getAllByRole('checkbox')).toHaveLength( + mockDashboards.length + 1, + ); + }); + + const selectAllCheckbox = screen.getAllByLabelText('Select all')[0]; + await userEvent.click(selectAllCheckbox); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + `${mockDashboards.length} Selected`, + ); + }); + + const bulkActions = screen.getAllByTestId('bulk-select-action'); + const exportButton = bulkActions.find(btn => btn.textContent === 'Export'); + expect(exportButton).toBeInTheDocument(); + await userEvent.click(exportButton!); + + await waitFor(() => { + expect(mockHandleResourceExport).toHaveBeenCalledWith( + 'dashboard', + mockDashboards.map(d => d.id), + expect.any(Function), + ); + }); +}); + +test('supports bulk delete of selected dashboards', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + const bulkSelectButton = screen.getByTestId('bulk-select'); + await userEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getAllByRole('checkbox')).toHaveLength( + mockDashboards.length + 1, + ); + }); + + const selectAllCheckbox = screen.getAllByLabelText('Select all')[0]; + await userEvent.click(selectAllCheckbox); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + `${mockDashboards.length} Selected`, + ); + }); + + const bulkActions = screen.getAllByTestId('bulk-select-action'); + const deleteButton = bulkActions.find(btn => btn.textContent === 'Delete'); + expect(deleteButton).toBeInTheDocument(); + await userEvent.click(deleteButton!); + + await waitFor(() => { + const deleteModal = screen.getByRole('dialog'); + expect(deleteModal).toBeInTheDocument(); + expect(deleteModal).toHaveTextContent(/delete/i); + expect(deleteModal).toHaveTextContent(/selected dashboards/i); + }); + + // Type DELETE in the confirmation input + const deleteInput = screen.getByTestId('delete-modal-input'); + await userEvent.type(deleteInput, 'DELETE'); + + // Mock the bulk DELETE endpoint + fetchMock.delete('glob:*/api/v1/dashboard/?*', { + message: 'Dashboards deleted', + }); + + // Click confirm button + const confirmButton = screen.getByTestId('modal-confirm-button'); + fireEvent.click(confirmButton); + + // Verify bulk delete API was called + await waitFor(() => { + const deleteCalls = fetchMock.callHistory.calls(/api\/v1\/dashboard\/\?/, { + method: 'DELETE', + }); + expect(deleteCalls).toHaveLength(1); + }); +}); + +test('displays certified badge only for certified dashboards', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + + // mockDashboards[0] is certified (certified_by: 'Data Team') + const certifiedRow = within(table) + .getByText(mockDashboards[0].dashboard_title) + .closest('[data-test="table-row"]') as HTMLElement; + expect(within(certifiedRow).getByLabelText('certified')).toBeInTheDocument(); + + // mockDashboards[1] is not certified (certified_by: null) + const uncertifiedRow = within(table) + .getByText(mockDashboards[1].dashboard_title) + .closest('[data-test="table-row"]') as HTMLElement; + expect( + within(uncertifiedRow).queryByLabelText('certified'), + ).not.toBeInTheDocument(); +}); + +test('exits bulk select on button toggle', async () => { + renderDashboardList(mockUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + + const bulkSelectButton = screen.getByTestId('bulk-select'); + await userEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.getAllByRole('checkbox')).toHaveLength( + mockDashboards.length + 1, + ); + }); + + const table = screen.getByTestId('listview-table'); + const dataRows = within(table).getAllByTestId('table-row'); + const firstRowCheckbox = within(dataRows[0]).getByRole('checkbox'); + await userEvent.click(firstRowCheckbox); + + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + '1 Selected', + ); + }); + + await userEvent.click(bulkSelectButton); + + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + expect(screen.queryByTestId('bulk-select-copy')).not.toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.permissions.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.permissions.test.tsx new file mode 100644 index 00000000000..c6ec43b325a --- /dev/null +++ b/superset-frontend/src/pages/DashboardList/DashboardList.permissions.test.tsx @@ -0,0 +1,337 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { configureStore } from '@reduxjs/toolkit'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; +import { isFeatureEnabled } from '@superset-ui/core'; +import DashboardListComponent from 'src/pages/DashboardList'; +import { API_ENDPOINTS, mockDashboards, setupMocks } from './DashboardList.testHelpers'; + +// Cast to accept partial mock props in tests +const DashboardList = DashboardListComponent as unknown as React.FC< + Record<string, any> +>; + +jest.setTimeout(30000); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Permission configurations +const PERMISSIONS = { + ADMIN: [ + ['can_write', 'Dashboard'], + ['can_export', 'Dashboard'], + ['can_read', 'Tag'], + ], + READ_ONLY: [], + EXPORT_ONLY: [['can_export', 'Dashboard']], + WRITE_ONLY: [['can_write', 'Dashboard']], +}; + +const createMockUser = (overrides = {}) => ({ + userId: 1, + firstName: 'Test', + lastName: 'User', + roles: { + Admin: [ + ['can_write', 'Dashboard'], + ['can_export', 'Dashboard'], + ], + }, + ...overrides, +}); + +const createMockStore = (initialState: any = {}) => + configureStore({ + reducer: { + user: (state = initialState.user || {}, action: any) => state, + common: (state = initialState.common || {}, action: any) => state, + dashboards: (state = initialState.dashboards || {}, action: any) => state, + }, + preloadedState: initialState, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: false, + immutableCheck: false, + }), + }); + +const createStoreStateWithPermissions = ( + permissions = PERMISSIONS.ADMIN, + userId: number | undefined = 1, +) => ({ + user: userId + ? { + ...createMockUser({ userId }), + roles: { TestRole: permissions }, + } + : {}, + common: { + conf: { + SUPERSET_WEBSERVER_TIMEOUT: 60000, + }, + }, + dashboards: { + dashboardList: mockDashboards, + }, +}); + +const renderDashboardListWithPermissions = ( + props = {}, + storeState = {}, + user = createMockUser(), +) => { + const storeStateWithUser = { + ...createStoreStateWithPermissions(), + user, + ...storeState, + }; + + const store = createMockStore(storeStateWithUser); + + return render( + <Provider store={store}> + <MemoryRouter> + <QueryParamProvider adapter={ReactRouter5Adapter}> + <DashboardList user={user} {...props} /> + </QueryParamProvider> + </MemoryRouter> + </Provider>, + ); +}; + +const renderWithPermissions = async ( + permissions = PERMISSIONS.ADMIN, + userId: number | undefined = 1, + featureFlags: { tagging?: boolean; cardView?: boolean } = {}, +) => { + ( + isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled> + ).mockImplementation((feature: string) => { + if (feature === 'TAGGING_SYSTEM') return featureFlags.tagging === true; + if (feature === 'LISTVIEWS_DEFAULT_CARD_VIEW') + return featureFlags.cardView === true; + return false; + }); + + // Convert role permissions to API permissions + setupMocks({ + [API_ENDPOINTS.DASHBOARDS_INFO]: permissions.map(perm => perm[0]), + }); + + const storeState = createStoreStateWithPermissions(permissions, userId); + + const userProps = userId + ? { + user: { + ...createMockUser({ userId }), + roles: { TestRole: permissions }, + }, + } + : { user: { userId: undefined } }; + + const result = renderDashboardListWithPermissions(userProps, storeState); + await waitFor(() => { + expect(screen.getByTestId('dashboard-list-view')).toBeInTheDocument(); + }); + return result; +}; + +// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks +describe('DashboardList - Permission-based UI Tests', () => { + beforeEach(() => { + fetchMock.clearHistory().removeRoutes(); + ( + isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled> + ).mockReset(); + }); + + test('shows all UI elements for admin users with full permissions', async () => { + await renderWithPermissions(PERMISSIONS.ADMIN); + + await screen.findByTestId('dashboard-list-view'); + + // Verify admin controls are visible + expect( + screen.getByRole('button', { name: /dashboard/i }), + ).toBeInTheDocument(); + expect(screen.getByTestId('import-button')).toBeInTheDocument(); + expect(screen.getByTestId('bulk-select')).toBeInTheDocument(); + + // Verify Actions column is visible + expect(screen.getByTitle('Actions')).toBeInTheDocument(); + + // Verify favorite stars are rendered + const favoriteStars = screen.getAllByTestId('fave-unfave-icon'); + expect(favoriteStars).toHaveLength(mockDashboards.length); + }); + + test('renders basic UI for anonymous users without permissions', async () => { + await renderWithPermissions(PERMISSIONS.READ_ONLY, undefined); + await screen.findByTestId('dashboard-list-view'); + + // Verify basic structure renders + expect(screen.getByTestId('dashboard-list-view')).toBeInTheDocument(); + expect(screen.getByText('Dashboards')).toBeInTheDocument(); + + // Verify view toggles are available (not permission-gated) + expect(screen.getByRole('img', { name: 'appstore' })).toBeInTheDocument(); + expect( + screen.getByRole('img', { name: 'unordered-list' }), + ).toBeInTheDocument(); + + // Verify permission-gated elements are hidden + expect( + screen.queryByRole('button', { name: /dashboard/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('import-button')).not.toBeInTheDocument(); + }); + + test('shows Actions column for users with admin permissions', async () => { + await renderWithPermissions(PERMISSIONS.ADMIN); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.getByTitle('Actions')).toBeInTheDocument(); + + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); + }); + + test('hides Actions column for users with read-only permissions', async () => { + await renderWithPermissions(PERMISSIONS.READ_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.queryByTitle('Actions')).not.toBeInTheDocument(); + expect(screen.queryAllByLabelText('more')).toHaveLength(0); + }); + + test('shows Actions column for users with export-only permissions', async () => { + // DashboardList shows Actions column when canExport is true + await renderWithPermissions(PERMISSIONS.EXPORT_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.getByTitle('Actions')).toBeInTheDocument(); + }); + + test('shows Actions column for users with write-only permissions', async () => { + await renderWithPermissions(PERMISSIONS.WRITE_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.getByTitle('Actions')).toBeInTheDocument(); + }); + + test('shows Tags column when TAGGING_SYSTEM feature flag is enabled', async () => { + await renderWithPermissions(PERMISSIONS.ADMIN, 1, { tagging: true }); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.getByTitle('Tags')).toBeInTheDocument(); + }); + + test('hides Tags column when TAGGING_SYSTEM feature flag is disabled', async () => { + await renderWithPermissions(PERMISSIONS.ADMIN, 1, { tagging: false }); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.queryByText('Tags')).not.toBeInTheDocument(); + }); + + test('shows bulk select button for users with admin permissions', async () => { + await renderWithPermissions(PERMISSIONS.ADMIN); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.getByTestId('bulk-select')).toBeInTheDocument(); + }); + + test('shows bulk select button for users with export-only permissions', async () => { + await renderWithPermissions(PERMISSIONS.EXPORT_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.getByTestId('bulk-select')).toBeInTheDocument(); + }); + + test('shows bulk select button for users with write-only permissions', async () => { + await renderWithPermissions(PERMISSIONS.WRITE_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.getByTestId('bulk-select')).toBeInTheDocument(); + }); + + test('hides bulk select button for users with read-only permissions', async () => { + await renderWithPermissions(PERMISSIONS.READ_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect(screen.queryByTestId('bulk-select')).not.toBeInTheDocument(); + }); + + test('shows Create and Import buttons for users with write permissions', async () => { + await renderWithPermissions(PERMISSIONS.WRITE_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect( + screen.getByRole('button', { name: /dashboard/i }), + ).toBeInTheDocument(); + expect(screen.getByTestId('import-button')).toBeInTheDocument(); + }); + + test('hides Create and Import buttons for users with read-only permissions', async () => { + await renderWithPermissions(PERMISSIONS.READ_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect( + screen.queryByRole('button', { name: /dashboard/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('import-button')).not.toBeInTheDocument(); + }); + + test('hides Create and Import buttons for users with export-only permissions', async () => { + await renderWithPermissions(PERMISSIONS.EXPORT_ONLY); + await screen.findByTestId('dashboard-list-view'); + + expect( + screen.queryByRole('button', { name: /dashboard/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('import-button')).not.toBeInTheDocument(); + }); + + test('does not render favorite stars for anonymous user', async () => { + await renderWithPermissions(PERMISSIONS.READ_ONLY, undefined); + await screen.findByTestId('dashboard-list-view'); + + // Favorites should not render for anonymous users (no userId) + await waitFor(() => { + expect( + screen.queryByRole('img', { name: /favorite/i }), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx index a5545b499ba..2f4b8f9dc5e 100644 --- a/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx @@ -16,233 +16,290 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; import fetchMock from 'fetch-mock'; import { isFeatureEnabled } from '@superset-ui/core'; import { - render, screen, + selectOption, waitFor, fireEvent, } from 'spec/helpers/testing-library'; -import { QueryParamProvider } from 'use-query-params'; -import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; - -import DashboardListComponent from 'src/pages/DashboardList'; - -// Cast to accept partial mock props in tests -const DashboardList = DashboardListComponent as unknown as React.FC< - Record<string, any> ->; +import { + mockDashboards, + mockAdminUser, + setupMocks, + renderDashboardList, + API_ENDPOINTS, + getLatestDashboardApiCall, +} from './DashboardList.testHelpers'; -const dashboardsInfoEndpoint = 'glob:*/api/v1/dashboard/_info*'; -const dashboardOwnersEndpoint = 'glob:*/api/v1/dashboard/related/owners*'; -const dashboardCreatedByEndpoint = - 'glob:*/api/v1/dashboard/related/created_by*'; -const dashboardFavoriteStatusEndpoint = - 'glob:*/api/v1/dashboard/favorite_status*'; -const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*'; -const dashboardEndpoint = 'glob:*/api/v1/dashboard/*'; +jest.setTimeout(30000); jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), isFeatureEnabled: jest.fn(), })); -const mockDashboards = Array.from({ length: 3 }, (_, i) => ({ - id: i, - url: 'url', - dashboard_title: `title ${i}`, - changed_by_name: 'user', - changed_by_fk: 1, - published: true, - changed_on_utc: new Date().toISOString(), - changed_on_delta_humanized: '5 minutes ago', - owners: [{ id: 1, first_name: 'admin', last_name: 'admin_user' }], - roles: [{ id: 1, name: 'adminUser' }], - thumbnail_url: '/thumbnail', +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), })); -const mockUser = { - userId: 1, -}; +const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction< + typeof isFeatureEnabled +>; -fetchMock.get(dashboardsInfoEndpoint, { - permissions: ['can_read', 'can_write'], +beforeEach(() => { + setupMocks(); + mockIsFeatureEnabled.mockImplementation( + (feature: string) => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', + ); }); -fetchMock.get(dashboardOwnersEndpoint, { - result: [], + +afterEach(() => { + fetchMock.clearHistory().removeRoutes(); + mockIsFeatureEnabled.mockReset(); }); -fetchMock.get(dashboardCreatedByEndpoint, { - result: [], + +test('renders', async () => { + renderDashboardList(mockAdminUser); + expect(await screen.findByText('Dashboards')).toBeInTheDocument(); }); -fetchMock.get(dashboardFavoriteStatusEndpoint, { - result: [], + +test('renders a ListView', async () => { + renderDashboardList(mockAdminUser); + expect(await screen.findByTestId('dashboard-list-view')).toBeInTheDocument(); }); -fetchMock.get(dashboardsEndpoint, { - result: mockDashboards, - dashboard_count: 3, + +test('fetches info', async () => { + renderDashboardList(mockAdminUser); + await waitFor(() => { + const calls = fetchMock.callHistory.calls(/dashboard\/_info/); + expect(calls).toHaveLength(1); + }); }); -fetchMock.get(dashboardEndpoint, { - result: mockDashboards[0], + +test('fetches data', async () => { + renderDashboardList(mockAdminUser); + await waitFor(() => { + const calls = fetchMock.callHistory.calls(/dashboard\/\?q/); + expect(calls).toHaveLength(1); + }); + + const calls = fetchMock.callHistory.calls(/dashboard\/\?q/); + expect(calls[0].url).toMatchInlineSnapshot( + `"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,dashboard_title,published,url,slug,changed_by,changed_by.id,changed_by.first_name,changed_by.last_name,changed_on_delta_humanized,owners,owners.id,owners.first_name,owners.last_name,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`, + ); }); -global.URL.createObjectURL = jest.fn(); -fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false }); - -// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('DashboardList', () => { - const renderDashboardList = (props = {}, userProp = mockUser) => - render( - <MemoryRouter> - <QueryParamProvider adapter={ReactRouter5Adapter}> - <DashboardList {...props} user={userProp} /> - </QueryParamProvider> - </MemoryRouter>, - { useRedux: true }, - ); +test('switches between card and table view', async () => { + renderDashboardList(mockAdminUser); - beforeEach(() => { - (isFeatureEnabled as jest.Mock).mockImplementation( - (feature: string) => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', - ); - fetchMock.clearHistory(); - }); + // Wait for the list to load + await screen.findByTestId('dashboard-list-view'); - afterEach(() => { - (isFeatureEnabled as jest.Mock).mockRestore(); - }); + // Initially in card view (no table) + expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument(); - test('renders', async () => { - renderDashboardList(); - expect(await screen.findByText('Dashboards')).toBeInTheDocument(); - }); + // Switch to table view via the list icon + const listViewIcon = screen.getByRole('img', { name: 'unordered-list' }); + const listViewButton = listViewIcon.closest('[role="button"]')!; + fireEvent.click(listViewButton); - test('renders a ListView', async () => { - renderDashboardList(); - expect( - await screen.findByTestId('dashboard-list-view'), - ).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); }); - test('fetches info', async () => { - renderDashboardList(); - await waitFor(() => { - const calls = fetchMock.callHistory.calls(/dashboard\/_info/); - expect(calls).toHaveLength(1); - }); + // Switch back to card view + const cardViewIcon = screen.getByRole('img', { name: 'appstore' }); + const cardViewButton = cardViewIcon.closest('[role="button"]')!; + fireEvent.click(cardViewButton); + + await waitFor(() => { + expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument(); }); +}); - test('fetches data', async () => { - renderDashboardList(); - await waitFor(() => { - const calls = fetchMock.callHistory.calls(/dashboard\/\?q/); - expect(calls).toHaveLength(1); - }); +test('shows edit modal', async () => { + renderDashboardList(mockAdminUser); - const calls = fetchMock.callHistory.calls(/dashboard\/\?q/); - expect(calls[0].url).toMatchInlineSnapshot( - `"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,dashboard_title,published,url,slug,changed_by,changed_by.id,changed_by.first_name,changed_by.last_name,changed_on_delta_humanized,owners,owners.id,owners.first_name,owners.last_name,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`, - ); + // Wait for data to load + await screen.findByText(mockDashboards[0].dashboard_title); + + // Find and click the first more options button + const moreIcons = await screen.findAllByRole('img', { + name: 'more', }); + fireEvent.click(moreIcons[0]); - test('switches between card and table view', async () => { - renderDashboardList(); + // Click edit from the dropdown + const editButton = await screen.findByTestId( + 'dashboard-card-option-edit-button', + ); + fireEvent.click(editButton); - // Wait for the list to load - await screen.findByTestId('dashboard-list-view'); + // Check for modal + expect(await screen.findByRole('dialog')).toBeInTheDocument(); +}); - // Initially in card view - const cardViewIcon = screen.getByRole('img', { name: 'appstore' }); - expect(cardViewIcon).toBeInTheDocument(); +test('shows delete confirmation', async () => { + renderDashboardList(mockAdminUser); - // Switch to table view - const listViewIcon = screen.getByRole('img', { name: 'appstore' }); - const listViewButton = listViewIcon.closest('[role="button"]')!; - fireEvent.click(listViewButton); + // Wait for data to load + await screen.findByText(mockDashboards[0].dashboard_title); - // Switch back to card view - const cardViewButton = cardViewIcon.closest('[role="button"]')!; - fireEvent.click(cardViewButton); + // Find and click the first more options button + const moreIcons = await screen.findAllByRole('img', { + name: 'more', }); + fireEvent.click(moreIcons[0]); + + // Click delete from the dropdown + const deleteButton = await screen.findByTestId( + 'dashboard-card-option-delete-button', + ); + fireEvent.click(deleteButton); + + // Check for confirmation dialog + expect( + await screen.findByText(/Are you sure you want to delete/i), + ).toBeInTheDocument(); +}); - test('shows edit modal', async () => { - renderDashboardList(); - - // Wait for data to load - await screen.findByText('title 0'); +test('renders an "Import Dashboard" tooltip', async () => { + renderDashboardList(mockAdminUser); - // Find and click the first more options button - const moreIcons = await screen.findAllByRole('img', { - name: 'more', - }); - fireEvent.click(moreIcons[0]); + const importButton = await screen.findByTestId('import-button'); + fireEvent.mouseOver(importButton); - // Click edit from the dropdown - const editButton = await screen.findByTestId( - 'dashboard-card-option-edit-button', - ); - fireEvent.click(editButton); + expect( + await screen.findByRole('tooltip', { + name: 'Import dashboards', + }), + ).toBeInTheDocument(); +}); - // Check for modal - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - }); +test('renders all standard filters', async () => { + renderDashboardList(mockAdminUser); + await screen.findByTestId('dashboard-list-view'); - test('shows delete confirmation', async () => { - renderDashboardList(); + // Verify filter labels exist + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Modified by')).toBeInTheDocument(); + expect(screen.getByText('Certified')).toBeInTheDocument(); +}); - // Wait for data to load - await screen.findByText('title 0'); +test('selecting Status filter encodes published=true in API call', async () => { + renderDashboardList(mockAdminUser); + await screen.findByTestId('dashboard-list-view'); - // Find and click the first more options button - const moreIcons = await screen.findAllByRole('img', { - name: 'more', - }); - fireEvent.click(moreIcons[0]); + await waitFor(() => { + expect( + screen.getByText(mockDashboards[0].dashboard_title), + ).toBeInTheDocument(); + }); - // Click delete from the dropdown - const deleteButton = await screen.findByTestId( - 'dashboard-card-option-delete-button', + await selectOption('Published', 'Status'); + + await waitFor(() => { + const latest = getLatestDashboardApiCall(); + expect(latest).not.toBeNull(); + expect(latest!.query!.filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + col: 'published', + opr: 'eq', + value: true, + }), + ]), ); - fireEvent.click(deleteButton); + }); +}); - // Check for confirmation dialog +test('selecting Owner filter encodes rel_m_m owner in API call', async () => { + // Replace the owners route to return a selectable option + fetchMock.removeRoutes({ + names: [API_ENDPOINTS.DASHBOARD_RELATED_OWNERS, API_ENDPOINTS.CATCH_ALL], + }); + fetchMock.get( + API_ENDPOINTS.DASHBOARD_RELATED_OWNERS, + { result: [{ value: 1, text: 'Admin User' }], count: 1 }, + { name: API_ENDPOINTS.DASHBOARD_RELATED_OWNERS }, + ); + fetchMock.get(API_ENDPOINTS.CATCH_ALL, (callLog: any) => { + const reqUrl = + typeof callLog === 'string' ? callLog : callLog?.url || callLog; + throw new Error(`[fetchMock catch-all] Unmatched GET: ${reqUrl}`); + }); + + renderDashboardList(mockAdminUser); + await screen.findByTestId('dashboard-list-view'); + + await waitFor(() => { expect( - await screen.findByText(/Are you sure you want to delete/i), + screen.getByText(mockDashboards[0].dashboard_title), ).toBeInTheDocument(); }); - test('renders an "Import Dashboard" tooltip', async () => { - renderDashboardList(); + await selectOption('Admin User', 'Owner'); + + await waitFor(() => { + const latest = getLatestDashboardApiCall(); + expect(latest).not.toBeNull(); + expect(latest!.query!.filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + col: 'owners', + opr: 'rel_m_m', + value: 1, + }), + ]), + ); + }); +}); + +test('selecting Modified by filter encodes rel_o_m changed_by in API call', async () => { + // Replace the changed_by route to return a selectable option + fetchMock.removeRoutes({ + names: [ + API_ENDPOINTS.DASHBOARD_RELATED_CHANGED_BY, + API_ENDPOINTS.CATCH_ALL, + ], + }); + fetchMock.get( + API_ENDPOINTS.DASHBOARD_RELATED_CHANGED_BY, + { result: [{ value: 1, text: 'Admin User' }], count: 1 }, + { name: API_ENDPOINTS.DASHBOARD_RELATED_CHANGED_BY }, + ); + fetchMock.get(API_ENDPOINTS.CATCH_ALL, (callLog: any) => { + const reqUrl = + typeof callLog === 'string' ? callLog : callLog?.url || callLog; + throw new Error(`[fetchMock catch-all] Unmatched GET: ${reqUrl}`); + }); - const importButton = await screen.findByTestId('import-button'); - fireEvent.mouseOver(importButton); + renderDashboardList(mockAdminUser); + await screen.findByTestId('dashboard-list-view'); + await waitFor(() => { expect( - await screen.findByRole('tooltip', { - name: 'Import dashboards', - }), + screen.getByText(mockDashboards[0].dashboard_title), ).toBeInTheDocument(); }); -}); -// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('DashboardList - anonymous view', () => { - test('does not render favorite stars for anonymous user', async () => { - render( - <MemoryRouter> - <QueryParamProvider adapter={ReactRouter5Adapter}> - <DashboardList user={{}} /> - </QueryParamProvider> - </MemoryRouter>, - { useRedux: true }, + await selectOption('Admin User', 'Modified by'); + + await waitFor(() => { + const latest = getLatestDashboardApiCall(); + expect(latest).not.toBeNull(); + expect(latest!.query!.filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + col: 'changed_by', + opr: 'rel_o_m', + value: 1, + }), + ]), ); - - await waitFor(() => { - expect( - screen.queryByRole('img', { name: /favorite/i }), - ).not.toBeInTheDocument(); - }); }); }); diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx new file mode 100644 index 00000000000..b5cc1e4ea20 --- /dev/null +++ b/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx @@ -0,0 +1,360 @@ +/** + * 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. + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import fetchMock from 'fetch-mock'; +import rison from 'rison'; +import { render, screen } from 'spec/helpers/testing-library'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { configureStore } from '@reduxjs/toolkit'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; +import DashboardListComponent from 'src/pages/DashboardList'; +import handleResourceExport from 'src/utils/export'; + +// Cast to accept partial mock props in tests +const DashboardList = DashboardListComponent as unknown as React.FC< + Record<string, any> +>; + +export const mockHandleResourceExport = + handleResourceExport as jest.MockedFunction<typeof handleResourceExport>; + +export const mockDashboards = [ + { + id: 1, + url: '/superset/dashboard/1/', + dashboard_title: 'Sales Dashboard', + published: true, + changed_by_name: 'admin', + changed_by_fk: 1, + changed_by: { + first_name: 'Admin', + last_name: 'User', + id: 1, + }, + changed_on_utc: new Date().toISOString(), + changed_on_delta_humanized: '1 day ago', + owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }], + roles: [{ id: 1, name: 'Admin' }], + tags: [{ id: 1, name: 'production', type: 'TagTypes.custom' }], + thumbnail_url: '/thumbnail', + certified_by: 'Data Team', + certification_details: 'Approved for production use', + status: 'published', + }, + { + id: 2, + url: '/superset/dashboard/2/', + dashboard_title: 'Analytics Dashboard', + published: false, + changed_by_name: 'analyst', + changed_by_fk: 2, + changed_by: { + first_name: 'Data', + last_name: 'Analyst', + id: 2, + }, + changed_on_utc: new Date().toISOString(), + changed_on_delta_humanized: '2 days ago', + owners: [ + { id: 1, first_name: 'Admin', last_name: 'User' }, + { id: 2, first_name: 'Data', last_name: 'Analyst' }, + ], + roles: [], + tags: [], + thumbnail_url: '/thumbnail', + certified_by: null, + certification_details: null, + status: 'draft', + }, + { + id: 3, + url: '/superset/dashboard/3/', + dashboard_title: 'Executive Overview', + published: true, + changed_by_name: 'admin', + changed_by_fk: 1, + changed_by: { + first_name: 'Admin', + last_name: 'User', + id: 1, + }, + changed_on_utc: new Date().toISOString(), + changed_on_delta_humanized: '3 days ago', + owners: [], + roles: [{ id: 2, name: 'Alpha' }], + tags: [ + { id: 2, name: 'executive', type: 'TagTypes.custom' }, + { id: 3, name: 'quarterly', type: 'TagTypes.custom' }, + ], + thumbnail_url: '/thumbnail', + certified_by: 'QA Team', + certification_details: 'Verified for executive use', + status: 'published', + }, + { + id: 4, + url: '/superset/dashboard/4/', + dashboard_title: 'Marketing Metrics', + published: false, + changed_by_name: 'marketing', + changed_by_fk: 3, + changed_by: { + first_name: 'Marketing', + last_name: 'Lead', + id: 3, + }, + changed_on_utc: new Date().toISOString(), + changed_on_delta_humanized: '5 days ago', + owners: [{ id: 3, first_name: 'Marketing', last_name: 'Lead' }], + roles: [], + tags: [], + thumbnail_url: '/thumbnail', + certified_by: null, + certification_details: null, + status: 'draft', + }, + { + id: 5, + url: '/superset/dashboard/5/', + dashboard_title: 'Ops Monitor', + published: true, + changed_by_name: 'ops', + changed_by_fk: 4, + changed_by: { + first_name: 'Ops', + last_name: 'Engineer', + id: 4, + }, + changed_on_utc: new Date().toISOString(), + changed_on_delta_humanized: '1 week ago', + owners: [ + { id: 4, first_name: 'Ops', last_name: 'Engineer' }, + { id: 1, first_name: 'Admin', last_name: 'User' }, + ], + roles: [], + tags: [{ id: 4, name: 'monitoring', type: 'TagTypes.custom' }], + thumbnail_url: '/thumbnail', + certified_by: null, + certification_details: null, + status: 'published', + }, +]; + +// Mock users with various permission levels +export const mockAdminUser = { + userId: 1, + firstName: 'Admin', + lastName: 'User', + roles: { + Admin: [ + ['can_write', 'Dashboard'], + ['can_export', 'Dashboard'], + ['can_read', 'Tag'], + ], + }, +}; + +export const mockReadOnlyUser = { + userId: 10, + firstName: 'Read', + lastName: 'Only', + roles: { + Gamma: [['can_read', 'Dashboard']], + }, +}; + +export const mockExportOnlyUser = { + userId: 11, + firstName: 'Export', + lastName: 'User', + roles: { + Gamma: [ + ['can_read', 'Dashboard'], + ['can_export', 'Dashboard'], + ], + }, +}; + +// API endpoint constants +export const API_ENDPOINTS = { + DASHBOARDS_INFO: 'glob:*/api/v1/dashboard/_info*', + DASHBOARDS: 'glob:*/api/v1/dashboard/?*', + DASHBOARD_GET: 'glob:*/api/v1/dashboard/*', + DASHBOARD_FAVORITE_STATUS: 'glob:*/api/v1/dashboard/favorite_status*', + DASHBOARD_RELATED_OWNERS: 'glob:*/api/v1/dashboard/related/owners*', + DASHBOARD_RELATED_CHANGED_BY: 'glob:*/api/v1/dashboard/related/changed_by*', + THUMBNAIL: '/thumbnail', + CATCH_ALL: 'glob:*', +}; + +interface StoreState { + user?: any; + common?: { + conf?: { + SUPERSET_WEBSERVER_TIMEOUT?: number; + }; + }; + dashboards?: { + dashboardList?: typeof mockDashboards; + }; +} + +export const createMockStore = (initialState: Partial<StoreState> = {}) => + configureStore({ + reducer: { + user: (state = initialState.user || {}) => state, + common: (state = initialState.common || {}) => state, + dashboards: (state = initialState.dashboards || {}) => state, + }, + preloadedState: initialState, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: false, + immutableCheck: false, + }), + }); + +export const createDefaultStoreState = (user: any): StoreState => ({ + user, + common: { + conf: { + SUPERSET_WEBSERVER_TIMEOUT: 60000, + }, + }, + dashboards: { + dashboardList: mockDashboards, + }, +}); + +export const renderDashboardList = ( + user: any, + props: Record<string, any> = {}, + storeState: Partial<StoreState> = {}, +) => { + const defaultStoreState = createDefaultStoreState(user); + const storeStateWithUser = { + ...defaultStoreState, + user, + ...storeState, + }; + + const store = createMockStore(storeStateWithUser); + + return render( + <Provider store={store}> + <MemoryRouter> + <QueryParamProvider adapter={ReactRouter5Adapter}> + <DashboardList user={user} {...props} /> + </QueryParamProvider> + </MemoryRouter> + </Provider>, + ); +}; + +/** + * Helper to wait for the DashboardList page to be ready + * Waits for the "Dashboards" heading to appear, indicating initial render is complete + */ +export const waitForDashboardsPageReady = async () => { + await screen.findByText('Dashboards'); +}; + +export const setupMocks = ( + payloadMap: Record<string, string[]> = { + [API_ENDPOINTS.DASHBOARDS_INFO]: ['can_read', 'can_write', 'can_export'], + }, +) => { + fetchMock.get( + API_ENDPOINTS.DASHBOARDS_INFO, + { + permissions: payloadMap[API_ENDPOINTS.DASHBOARDS_INFO], + }, + { name: API_ENDPOINTS.DASHBOARDS_INFO }, + ); + + fetchMock.get( + API_ENDPOINTS.DASHBOARDS, + { + result: mockDashboards, + dashboard_count: mockDashboards.length, + }, + { name: API_ENDPOINTS.DASHBOARDS }, + ); + + fetchMock.get( + API_ENDPOINTS.DASHBOARD_FAVORITE_STATUS, + { result: [] }, + { name: API_ENDPOINTS.DASHBOARD_FAVORITE_STATUS }, + ); + + fetchMock.get( + API_ENDPOINTS.DASHBOARD_RELATED_OWNERS, + { result: [], count: 0 }, + { name: API_ENDPOINTS.DASHBOARD_RELATED_OWNERS }, + ); + + fetchMock.get( + API_ENDPOINTS.DASHBOARD_RELATED_CHANGED_BY, + { result: [], count: 0 }, + { name: API_ENDPOINTS.DASHBOARD_RELATED_CHANGED_BY }, + ); + + global.URL.createObjectURL = jest.fn(); + fetchMock.get( + API_ENDPOINTS.THUMBNAIL, + { body: new Blob(), sendAsJson: false }, + { name: API_ENDPOINTS.THUMBNAIL }, + ); + + fetchMock.get( + API_ENDPOINTS.CATCH_ALL, + (callLog: any) => { + const reqUrl = + typeof callLog === 'string' ? callLog : callLog?.url || callLog; + throw new Error(`[fetchMock catch-all] Unmatched GET: ${reqUrl}`); + }, + { name: API_ENDPOINTS.CATCH_ALL }, + ); +}; + +/** + * Parse the rison-encoded `q` query parameter from a fetch-mock call URL. + * Returns the decoded object, or null if parsing fails. + */ +export const parseQueryFromUrl = (url: string): Record<string, any> | null => { + const match = url.match(/[?&]q=(.+?)(?:&|$)/); + if (!match) return null; + return rison.decode(decodeURIComponent(match[1])); +}; + +/** + * Get the last dashboard list API call from fetchMock history. + * Returns both the raw call and the parsed rison query. + */ +export const getLatestDashboardApiCall = () => { + const calls = fetchMock.callHistory.calls(/dashboard\/\?q/); + if (calls.length === 0) return null; + const lastCall = calls[calls.length - 1]; + return { + call: lastCall, + query: parseQueryFromUrl(lastCall.url), + }; +};
