You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/03/03 20:36:20 UTC
(superset) branch test-move-dashboard-list-to-rtl created (now d5dfbe45808)
This is an automated email from the ASF dual-hosted git repository.
jli pushed a change to branch test-move-dashboard-list-to-rtl
in repository https://gitbox.apache.org/repos/asf/superset.git
at d5dfbe45808 test(DashboardList): migrate Cypress E2E tests to RTL
This branch includes the following new commits:
new d5dfbe45808 test(DashboardList): migrate Cypress E2E tests to RTL
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: test(DashboardList): migrate Cypress E2E tests to RTL
Posted by jl...@apache.org.
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 <jo...@preset.io>
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 <no...@anthropic.com>
---
.../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),
+ };
+};