This is an automated email from the ASF dual-hosted git repository.
jli pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c25adbc395 test(DashboardList): migrate Cypress E2E tests to RTL
(#38368)
c25adbc395 is described below
commit c25adbc3953f09dc59730734c65326f9c42bbe9d
Author: Joe Li <[email protected]>
AuthorDate: Wed Mar 4 11:13:13 2026 -0800
test(DashboardList): migrate Cypress E2E tests to RTL (#38368)
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 | 394 +++++++++++++++++++
.../DashboardList/DashboardList.cardview.test.tsx | 417 +++++++++++++++++++++
.../DashboardList/DashboardList.listview.test.tsx | 402 ++++++++++++++++++++
.../DashboardList.permissions.test.tsx | 340 +++++++++++++++++
.../src/pages/DashboardList/DashboardList.test.tsx | 391 ++++++++++---------
.../DashboardList/DashboardList.testHelpers.tsx | 360 ++++++++++++++++++
8 files changed, 2137 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 854ea541c7..0000000000
--- 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 37a8a7ffa4..0000000000
--- 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 0000000000..997bca9fd8
--- /dev/null
+++ b/superset-frontend/src/pages/DashboardList/DashboardList.behavior.test.tsx
@@ -0,0 +1,394 @@
+/**
+ * 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 0000000000..c03299cfc4
--- /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 0000000000..1030faf96c
--- /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 0000000000..68d1229d97
--- /dev/null
+++
b/superset-frontend/src/pages/DashboardList/DashboardList.permissions.test.tsx
@@ -0,0 +1,340 @@
+/**
+ * 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('renders favorite stars even for anonymous user', async () => {
+ // Current behavior: Component renders favorites regardless of userId
+ // (matches ChartList behavior — antd hidden column + Cell guard
+ // do not prevent rendering in JSDOM)
+ await renderWithPermissions(PERMISSIONS.READ_ONLY, undefined);
+ await screen.findByTestId('dashboard-list-view');
+
+ const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
+ expect(favoriteStars).toHaveLength(mockDashboards.length);
+ });
+});
diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx
b/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx
index a5545b499b..2f4b8f9dc5 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 0000000000..b5cc1e4ea2
--- /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),
+ };
+};