mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
test(DashboardList): migrate Cypress E2E tests to RTL (#38368)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user