test(DashboardList): migrate Cypress E2E tests to RTL (#38368)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-03-04 11:13:13 -08:00
committed by GitHub
parent 3b656f9cc2
commit c25adbc395
8 changed files with 2161 additions and 517 deletions

View File

@@ -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');
});
});

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});

View File

@@ -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',
);
});
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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 {
mockDashboards,
mockAdminUser,
setupMocks,
renderDashboardList,
API_ENDPOINTS,
getLatestDashboardApiCall,
} from './DashboardList.testHelpers';
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>
>;
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'],
});
fetchMock.get(dashboardOwnersEndpoint, {
result: [],
});
fetchMock.get(dashboardCreatedByEndpoint, {
result: [],
});
fetchMock.get(dashboardFavoriteStatusEndpoint, {
result: [],
});
fetchMock.get(dashboardsEndpoint, {
result: mockDashboards,
dashboard_count: 3,
});
fetchMock.get(dashboardEndpoint, {
result: mockDashboards[0],
beforeEach(() => {
setupMocks();
mockIsFeatureEnabled.mockImplementation(
(feature: string) => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW',
);
});
global.URL.createObjectURL = jest.fn();
fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false });
afterEach(() => {
fetchMock.clearHistory().removeRoutes();
mockIsFeatureEnabled.mockReset();
});
// 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('renders', async () => {
renderDashboardList(mockAdminUser);
expect(await screen.findByText('Dashboards')).toBeInTheDocument();
});
beforeEach(() => {
(isFeatureEnabled as jest.Mock).mockImplementation(
(feature: string) => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW',
);
fetchMock.clearHistory();
test('renders a ListView', async () => {
renderDashboardList(mockAdminUser);
expect(await screen.findByTestId('dashboard-list-view')).toBeInTheDocument();
});
test('fetches info', async () => {
renderDashboardList(mockAdminUser);
await waitFor(() => {
const calls = fetchMock.callHistory.calls(/dashboard\/_info/);
expect(calls).toHaveLength(1);
});
});
afterEach(() => {
(isFeatureEnabled as jest.Mock).mockRestore();
});
test('renders', async () => {
renderDashboardList();
expect(await screen.findByText('Dashboards')).toBeInTheDocument();
});
test('renders a ListView', async () => {
renderDashboardList();
expect(
await screen.findByTestId('dashboard-list-view'),
).toBeInTheDocument();
});
test('fetches info', async () => {
renderDashboardList();
await waitFor(() => {
const calls = fetchMock.callHistory.calls(/dashboard\/_info/);
expect(calls).toHaveLength(1);
});
});
test('fetches data', async () => {
renderDashboardList();
await waitFor(() => {
const calls = fetchMock.callHistory.calls(/dashboard\/\?q/);
expect(calls).toHaveLength(1);
});
test('fetches data', async () => {
renderDashboardList(mockAdminUser);
await waitFor(() => {
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))"`,
);
expect(calls).toHaveLength(1);
});
test('switches between card and table view', async () => {
renderDashboardList();
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 the list to load
await screen.findByTestId('dashboard-list-view');
test('switches between card and table view', async () => {
renderDashboardList(mockAdminUser);
// Initially in card view
const cardViewIcon = screen.getByRole('img', { name: 'appstore' });
expect(cardViewIcon).toBeInTheDocument();
// Wait for the list to load
await screen.findByTestId('dashboard-list-view');
// Switch to table view
const listViewIcon = screen.getByRole('img', { name: 'appstore' });
const listViewButton = listViewIcon.closest('[role="button"]')!;
fireEvent.click(listViewButton);
// Initially in card view (no table)
expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument();
// Switch back to card view
const cardViewButton = cardViewIcon.closest('[role="button"]')!;
fireEvent.click(cardViewButton);
// 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);
await waitFor(() => {
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
});
test('shows edit modal', async () => {
renderDashboardList();
// Switch back to card view
const cardViewIcon = screen.getByRole('img', { name: 'appstore' });
const cardViewButton = cardViewIcon.closest('[role="button"]')!;
fireEvent.click(cardViewButton);
// Wait for data to load
await screen.findByText('title 0');
// Find and click the first more options button
const moreIcons = await screen.findAllByRole('img', {
name: 'more',
});
fireEvent.click(moreIcons[0]);
// Click edit from the dropdown
const editButton = await screen.findByTestId(
'dashboard-card-option-edit-button',
);
fireEvent.click(editButton);
// Check for modal
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
test('shows delete confirmation', async () => {
renderDashboardList();
// Wait for data to load
await screen.findByText('title 0');
// 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('renders an "Import Dashboard" tooltip', async () => {
renderDashboardList();
const importButton = await screen.findByTestId('import-button');
fireEvent.mouseOver(importButton);
expect(
await screen.findByRole('tooltip', {
name: 'Import dashboards',
}),
).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByTestId('listview-table')).not.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 },
);
test('shows edit modal', async () => {
renderDashboardList(mockAdminUser);
await waitFor(() => {
expect(
screen.queryByRole('img', { name: /favorite/i }),
).not.toBeInTheDocument();
});
// 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]);
// Click edit from the dropdown
const editButton = await screen.findByTestId(
'dashboard-card-option-edit-button',
);
fireEvent.click(editButton);
// Check for modal
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
test('shows delete confirmation', async () => {
renderDashboardList(mockAdminUser);
// 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]);
// 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('renders an "Import Dashboard" tooltip', async () => {
renderDashboardList(mockAdminUser);
const importButton = await screen.findByTestId('import-button');
fireEvent.mouseOver(importButton);
expect(
await screen.findByRole('tooltip', {
name: 'Import dashboards',
}),
).toBeInTheDocument();
});
test('renders all standard filters', async () => {
renderDashboardList(mockAdminUser);
await screen.findByTestId('dashboard-list-view');
// 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();
});
test('selecting Status filter encodes published=true in API call', async () => {
renderDashboardList(mockAdminUser);
await screen.findByTestId('dashboard-list-view');
await waitFor(() => {
expect(
screen.getByText(mockDashboards[0].dashboard_title),
).toBeInTheDocument();
});
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,
}),
]),
);
});
});
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(
screen.getByText(mockDashboards[0].dashboard_title),
).toBeInTheDocument();
});
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}`);
});
renderDashboardList(mockAdminUser);
await screen.findByTestId('dashboard-list-view');
await waitFor(() => {
expect(
screen.getByText(mockDashboards[0].dashboard_title),
).toBeInTheDocument();
});
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,
}),
]),
);
});
});

View File

@@ -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),
};
};