Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Li
b253006ecb test: Add comprehensive SQL Lab tab state validation tests
Add test suite to validate SQL Lab tab state management and backend
persistence behavior, particularly around the interaction between
frontend-generated string IDs and backend integer ID expectations.

Related to issue #34997: These tests reproduce metadata update
failures when users modify queries in newly created tabs.

Test files:
- sqlLab.tabStateValidation.test.js: 8 tests for tab state edge cases
- sqlLab.immediateBackendPersistence.test.js: 6 tests for persistence

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 22:09:18 -07:00
6 changed files with 561 additions and 593 deletions

View File

@@ -0,0 +1,254 @@
/**
* 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.
*/
/**
* Tests for SQL Lab immediate backend persistence functionality
*
* This file tests the addQueryEditorWithBackendSync function that
* immediately creates backend tabs when SqllabBackendPersistence is enabled,
* ensuring consistent state management and preventing race conditions
* between frontend tab creation and backend synchronization.
*
* Related to issue #34997: Prevents metadata update failures by ensuring
* tabs have valid backend IDs before users can modify them.
*/
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import * as actions from './sqlLab';
// Mock feature flags
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
describe('SQL Lab immediate backend persistence', () => {
beforeEach(() => {
fetchMock.reset();
});
afterEach(() => {
fetchMock.reset();
isFeatureEnabled.mockRestore();
});
describe('with backend persistence enabled', () => {
beforeEach(() => {
isFeatureEnabled.mockImplementation(
feature => feature === 'SQLLAB_BACKEND_PERSISTENCE',
);
});
test('creates backend tab immediately and returns tabViewId', async () => {
const store = mockStore({});
const queryEditor = { name: 'Test Tab', sql: 'SELECT 1' };
// Mock successful backend creation
fetchMock.post('glob:*/tabstateview/', { id: 42 });
const result = await store.dispatch(
actions.addQueryEditorWithBackendSync(queryEditor),
);
// Verify backend call was made
const postCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'POST',
);
expect(postCalls).toHaveLength(1);
// Verify action was dispatched with backend ID
const addActions = store
.getActions()
.filter(action => action.type === 'ADD_QUERY_EDITOR');
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBe('42');
expect(addActions[0].queryEditor.inLocalStorage).toBe(false);
expect(addActions[0].queryEditor.loaded).toBe(true);
// Verify returned query editor
expect(result.tabViewId).toBe('42');
expect(result.inLocalStorage).toBe(false);
});
test('falls back to local storage on backend failure', async () => {
const store = mockStore({});
const queryEditor = { name: 'Test Tab', sql: 'SELECT 1' };
// Mock backend failure
fetchMock.post('glob:*/tabstateview/', { status: 500 });
const result = await store.dispatch(
actions.addQueryEditorWithBackendSync(queryEditor),
);
// Verify fallback action was dispatched
const actionsDispatched = store.getActions();
const addActions = actionsDispatched.filter(
action => action.type === 'ADD_QUERY_EDITOR',
);
const toastActions = actionsDispatched.filter(
action => action.type === 'ADD_TOAST',
);
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBeUndefined();
expect(addActions[0].queryEditor.inLocalStorage).toBe(true);
// Should have error toast
expect(toastActions).toHaveLength(1);
// Verify returned query editor (fallback)
expect(result.tabViewId).toBeUndefined();
expect(result.inLocalStorage).toBe(true);
});
test('SQL updates work immediately after tab creation', async () => {
const store = mockStore({
sqlLab: {
queryEditors: [],
unsavedQueryEditor: {},
},
});
// Mock backend tab creation
fetchMock.post('glob:*/tabstateview/', { id: 123 });
// Mock SQL update
fetchMock.put('glob:*/tabstateview/123', { status: 200 });
// Create tab with backend sync
const queryEditor = await store.dispatch(
actions.addQueryEditorWithBackendSync({
name: 'Test',
sql: 'SELECT 1',
}),
);
// Clear previous actions
store.clearActions();
// Update state for the SQL update call
const updatedStore = mockStore({
sqlLab: {
queryEditors: [queryEditor],
unsavedQueryEditor: {},
},
});
// Now SQL updates should work immediately
await updatedStore.dispatch(
actions.queryEditorSetAndSaveSql(queryEditor, 'SELECT 2'),
);
// Verify PUT call was made with correct integer ID
const putCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'PUT',
);
expect(putCalls).toHaveLength(1);
expect(putCalls[0][0]).toContain('/tabstateview/123');
});
});
describe('with backend persistence disabled', () => {
beforeEach(() => {
isFeatureEnabled.mockImplementation(() => false);
});
test('creates local tab without backend call', async () => {
const store = mockStore({});
const queryEditor = { name: 'Test Tab', sql: 'SELECT 1' };
const result = await store.dispatch(
actions.addQueryEditorWithBackendSync(queryEditor),
);
// Verify no backend calls were made
const calls = fetchMock.calls();
expect(calls).toHaveLength(0);
// Verify local action was dispatched
const addActions = store
.getActions()
.filter(action => action.type === 'ADD_QUERY_EDITOR');
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBeUndefined();
expect(addActions[0].queryEditor.inLocalStorage).toBe(true);
// Verify returned query editor
expect(result.tabViewId).toBeUndefined();
expect(result.inLocalStorage).toBe(true);
});
});
describe('integration with addNewQueryEditor', () => {
test('addNewQueryEditor uses backend sync', async () => {
isFeatureEnabled.mockImplementation(
feature => feature === 'SQLLAB_BACKEND_PERSISTENCE',
);
const store = mockStore({
sqlLab: {
queryEditors: [],
unsavedQueryEditor: {},
tabHistory: [],
databases: {},
},
common: {
conf: {
SQLLAB_DEFAULT_DBID: 1,
DEFAULT_SQLLAB_LIMIT: 1000,
},
},
});
// Mock backend creation
fetchMock.post('glob:*/tabstateview/', { id: 999 });
await store.dispatch(actions.addNewQueryEditor());
// Verify backend call was made
const postCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'POST',
);
expect(postCalls).toHaveLength(1);
// Verify resulting tab has backend ID
const addActions = store
.getActions()
.filter(action => action.type === 'ADD_QUERY_EDITOR');
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBe('999');
expect(addActions[0].queryEditor.inLocalStorage).toBe(false);
});
});
});

View File

@@ -0,0 +1,305 @@
/**
* 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.
*/
/**
* Tests for SQL Lab tab state management and validation
*
* This file validates the behavior of SQL Lab tab state management,
* particularly the interaction between frontend-generated IDs and backend
* persistence. Tests edge cases and race conditions that can occur when
* the SqllabBackendPersistence feature flag is enabled.
*
* Related to issue #34997: Ensures metadata updates work correctly
* when users modify queries in newly created tabs.
*/
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import * as actions from './sqlLab';
import { defaultQueryEditor } from '../fixtures';
// Mock feature flags
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
describe('SQL Lab tab state validation', () => {
const updateTabStateEndpoint = 'glob:*/tabstateview/*';
beforeEach(() => {
fetchMock.reset();
// Enable backend persistence for these tests
isFeatureEnabled.mockImplementation(
feature => feature === 'SQLLAB_BACKEND_PERSISTENCE',
);
});
afterEach(() => {
fetchMock.reset();
isFeatureEnabled.mockRestore();
});
describe('New tab creation with string IDs', () => {
test('addQueryEditor creates tabs with string nanoid IDs', () => {
const queryEditor = { name: 'Test Tab', sql: 'SELECT 1' };
const action = actions.addQueryEditor(queryEditor);
// Verify string ID was generated
expect(typeof action.queryEditor.id).toBe('string');
expect(action.queryEditor.id).toMatch(/^[a-zA-Z0-9_-]{11}$/);
expect(action.queryEditor.inLocalStorage).toBe(true);
expect(action.queryEditor.tabViewId).toBeUndefined();
});
test('multiple new tabs get unique string IDs', () => {
const editor1 = actions.addQueryEditor({ name: 'Tab 1' });
const editor2 = actions.addQueryEditor({ name: 'Tab 2' });
const editor3 = actions.addQueryEditor({ name: 'Tab 3' });
// All should have different string IDs
expect(editor1.queryEditor.id).not.toBe(editor2.queryEditor.id);
expect(editor2.queryEditor.id).not.toBe(editor3.queryEditor.id);
expect(editor1.queryEditor.id).not.toBe(editor3.queryEditor.id);
// All should be strings
expect(typeof editor1.queryEditor.id).toBe('string');
expect(typeof editor2.queryEditor.id).toBe('string');
expect(typeof editor3.queryEditor.id).toBe('string');
});
});
describe('Backend persistence edge cases', () => {
test('queryEditorSetAndSaveSql sends string ID to backend', async () => {
// Create new tab with string ID (simulating real nanoid output)
const newQueryEditor = {
...defaultQueryEditor,
id: 'FRRULMQgWHa', // Realistic nanoid(11) output
inLocalStorage: true,
// No tabViewId - simulates new tab before sync
};
const store = mockStore({
sqlLab: {
queryEditors: [newQueryEditor],
unsavedQueryEditor: {},
},
});
// Mock the endpoint to track what ID is sent
fetchMock.put(updateTabStateEndpoint, { status: 404 });
// This should attempt to use the string ID
const sql = 'SELECT * FROM test';
await store.dispatch(
actions.queryEditorSetAndSaveSql(newQueryEditor, sql),
);
// Verify the string ID was sent to backend
const putCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'PUT',
);
expect(putCalls).toHaveLength(1);
expect(putCalls[0][0]).toContain('/tabstateview/FRRULMQgWHa');
});
test('string IDs cause backend endpoint mismatch errors', async () => {
const newQueryEditor = {
...defaultQueryEditor,
id: 'abc123def456', // String ID
inLocalStorage: true,
};
const store = mockStore({
sqlLab: {
queryEditors: [newQueryEditor],
unsavedQueryEditor: {},
},
});
// Mock 404 response for string IDs (simulating real backend behavior)
fetchMock.put('glob:*/tabstateview/abc123def456', { status: 404 });
// The function should handle the 404 but dispatch a danger toast
await store.dispatch(
actions.queryEditorSetAndSaveSql(newQueryEditor, 'SELECT 1'),
);
// Verify error handling occurred (the function catches and handles 404 silently)
const actionsDispatched = store.getActions();
console.log(
'Actions dispatched:',
actionsDispatched.map(a => a.type),
);
// Check that the PUT call was attempted with string ID
const putCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'PUT',
);
expect(putCalls).toHaveLength(1);
expect(putCalls[0][0]).toContain('/abc123def456');
});
test('existing tabs with tabViewId work correctly', async () => {
// Existing tab with proper backend ID
const existingQueryEditor = {
...defaultQueryEditor,
id: 'frontend-id-123',
tabViewId: '42', // Integer ID from backend (as string)
inLocalStorage: false,
};
const store = mockStore({
sqlLab: {
queryEditors: [existingQueryEditor],
unsavedQueryEditor: {},
},
});
// Mock successful response for integer IDs
fetchMock.put('glob:*/tabstateview/42', { status: 200 });
await store.dispatch(
actions.queryEditorSetAndSaveSql(existingQueryEditor, 'SELECT 1'),
);
// Verify correct endpoint was called with integer ID
const putCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'PUT',
);
expect(putCalls).toHaveLength(1);
expect(putCalls[0][0]).toContain('/tabstateview/42');
});
});
describe('Race condition scenarios', () => {
test('rapid tab creation and SQL editing triggers string ID usage', async () => {
const store = mockStore({
sqlLab: {
queryEditors: [],
unsavedQueryEditor: {},
tabHistory: [],
databases: {},
},
common: {
conf: {
SQLLAB_DEFAULT_DBID: 1,
DEFAULT_SQLLAB_LIMIT: 1000,
},
},
});
// Mock endpoints for this specific test
fetchMock.put('glob:*/tabstateview/*', { status: 404 });
fetchMock.post('glob:*/tabstateview/', { id: 999 });
// Simulate rapid user actions: create tab then immediately edit SQL
await store.dispatch(actions.addNewQueryEditor());
const addAction = store
.getActions()
.find(a => a.type === 'ADD_QUERY_EDITOR');
const newTab = addAction.queryEditor;
// User immediately starts typing (before EditorAutoSync runs)
await store.dispatch(
actions.queryEditorSetAndSaveSql(newTab, 'SELECT NOW()'),
);
// Should attempt to use string ID
const putCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'PUT',
);
expect(putCalls).toHaveLength(1);
expect(putCalls[0][0]).toMatch(/\/tabstateview\/[a-zA-Z0-9_-]{11}$/);
});
test('feature flag disabled should not attempt backend updates', async () => {
// Disable backend persistence
isFeatureEnabled.mockImplementation(() => false);
const newQueryEditor = {
...defaultQueryEditor,
id: 'string-id-123',
inLocalStorage: true,
};
const store = mockStore({
sqlLab: {
queryEditors: [newQueryEditor],
unsavedQueryEditor: {},
},
});
await store.dispatch(
actions.queryEditorSetAndSaveSql(newQueryEditor, 'SELECT 1'),
);
// Should not make any backend calls
const putCalls = fetchMock
.calls()
.filter(call => call[0].includes('/tabstateview/'));
expect(putCalls).toHaveLength(0);
});
});
describe('Comparison: old vs new behavior', () => {
test('old addQueryEditor still creates local tabs', () => {
// The original function still works for backward compatibility
const action = actions.addQueryEditor({ name: 'New Tab' });
expect(action.queryEditor.tabViewId).toBeUndefined();
expect(action.queryEditor.inLocalStorage).toBe(true);
});
test('FIXED: addQueryEditorWithBackendSync creates backend tabs immediately', async () => {
// The new function fixes the race condition
const store = mockStore({});
// Mock successful tab creation
fetchMock.post('glob:*/tabstateview/', { id: 123 });
const queryEditor = await store.dispatch(
actions.addQueryEditorWithBackendSync({ name: 'New Tab' }),
);
// After fix: should have tabViewId set immediately
expect(queryEditor.tabViewId).toBe('123');
expect(queryEditor.inLocalStorage).toBe(false);
});
});
});

View File

@@ -1,259 +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 fetchMock from 'fetch-mock';
import { screen, waitFor } from 'spec/helpers/testing-library';
import {
API_ENDPOINTS,
renderChartList,
setupMocks,
} from './ChartList.testHelpers';
jest.setTimeout(30000);
const mockUser = {
userId: 1,
firstName: 'Test',
lastName: 'User',
roles: {
Admin: [
['can_read', 'Chart'],
['can_write', 'Chart'],
],
},
};
describe('ChartList - Favorite Column Visibility', () => {
beforeEach(() => {
setupMocks();
});
afterEach(() => {
fetchMock.resetHistory();
fetchMock.restore();
});
test('hides favorite column when no charts are favorited', async () => {
// Mock favorite status API to return all false
fetchMock.get(
API_ENDPOINTS.CHART_FAVORITE_STATUS,
{
result: [
{ id: 0, value: false },
{ id: 1, value: false },
{ id: 2, value: false },
{ id: 3, value: false },
],
},
{ overwriteRoutes: true },
);
renderChartList(mockUser);
await waitFor(() => {
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Test Chart 0')).toBeInTheDocument();
});
// Favorite column should be hidden - check that favorite stars are not present
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
// Verify that other columns are still present (check table headers)
expect(
screen.getByRole('columnheader', { name: 'Name' }),
).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: 'Type' }),
).toBeInTheDocument();
});
test('shows favorite column when at least one chart is favorited', async () => {
// Mock favorite status API to return mixed favorites
fetchMock.get(
API_ENDPOINTS.CHART_FAVORITE_STATUS,
{
result: [
{ id: 0, value: true }, // This chart is favorited
{ id: 1, value: false },
{ id: 2, value: false },
{ id: 3, value: false },
],
},
{ overwriteRoutes: true },
);
renderChartList(mockUser);
await waitFor(() => {
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Test Chart 0')).toBeInTheDocument();
});
// Favorite column should be visible - wait for stars to appear
await waitFor(
() => {
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
expect(favoriteStars.length).toBeGreaterThan(0);
},
{ timeout: 10000 },
);
});
test('shows favorite column when all charts are favorited', async () => {
// Mock favorite status API to return all true
fetchMock.get(
API_ENDPOINTS.CHART_FAVORITE_STATUS,
{
result: [
{ id: 0, value: true },
{ id: 1, value: true },
{ id: 2, value: true },
{ id: 3, value: true },
],
},
{ overwriteRoutes: true },
);
renderChartList(mockUser);
await waitFor(() => {
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Test Chart 0')).toBeInTheDocument();
});
// Favorite column should be visible
await waitFor(
() => {
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
expect(favoriteStars.length).toBeGreaterThan(0);
},
{ timeout: 10000 },
);
});
test('hides favorite column when user is not logged in', async () => {
// Mock favorite status API
fetchMock.get(
API_ENDPOINTS.CHART_FAVORITE_STATUS,
{
result: [
{ id: 0, value: true },
{ id: 1, value: false },
],
},
{ overwriteRoutes: true },
);
// Render without userId (user not logged in)
const noUser = {
userId: null,
firstName: '',
lastName: '',
roles: {},
};
renderChartList(noUser);
await waitFor(() => {
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Test Chart 0')).toBeInTheDocument();
});
// Favorite column should be hidden when user is not logged in
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
});
test('hides favorite column when chart list is empty', async () => {
// Mock empty charts response
fetchMock.get(
API_ENDPOINTS.CHARTS,
{
result: [],
chart_count: 0,
},
{ overwriteRoutes: true },
);
// Mock empty favorite status
fetchMock.get(
API_ENDPOINTS.CHART_FAVORITE_STATUS,
{
result: [],
},
{ overwriteRoutes: true },
);
renderChartList(mockUser);
await waitFor(() => {
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
});
// No favorite stars should be present when there are no charts
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
});
test('handles partial favorite status loading gracefully', async () => {
// Mock partial favorite status (fewer items than charts)
fetchMock.get(
API_ENDPOINTS.CHART_FAVORITE_STATUS,
{
result: [
{ id: 0, value: false },
{ id: 1, value: false },
// Missing status for charts 2 and 3
],
},
{ overwriteRoutes: true },
);
renderChartList(mockUser);
await waitFor(() => {
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Test Chart 0')).toBeInTheDocument();
});
// Should hide column when favorite status is incomplete
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
});
});

View File

@@ -315,14 +315,6 @@ function ChartList(props: ChartListProps) {
};
};
const hasFavoritesOnPage = useMemo(
() =>
charts.length > 0 &&
Object.keys(favoriteStatus).length === charts.length &&
Object.values(favoriteStatus).some(status => status === true),
[charts.length, favoriteStatus],
);
const columns = useMemo(
() => [
{
@@ -342,7 +334,7 @@ function ChartList(props: ChartListProps) {
id: 'id',
disableSortBy: true,
size: 'xs',
hidden: !userId || !hasFavoritesOnPage,
hidden: !userId,
},
{
Cell: ({

View File

@@ -1,316 +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 { MemoryRouter } from 'react-router-dom';
import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { QueryParamProvider } from 'use-query-params';
import DashboardList from 'src/pages/DashboardList';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
jest.setTimeout(30000);
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 mockDashboards = [...new Array(3)].map((_, i) => ({
id: i,
url: `url-${i}`,
dashboard_title: `Dashboard ${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',
}));
const mockUser = {
userId: 1,
firstName: 'Test',
lastName: 'User',
};
const setupBasicMocks = () => {
fetchMock.reset();
fetchMock.get(dashboardsInfoEndpoint, {
permissions: ['can_read', 'can_write'],
});
fetchMock.get(dashboardOwnersEndpoint, {
result: [],
});
fetchMock.get(dashboardCreatedByEndpoint, {
result: [],
});
fetchMock.get(dashboardsEndpoint, {
result: mockDashboards,
dashboard_count: 3,
});
global.URL.createObjectURL = jest.fn();
fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false });
};
describe('DashboardList - Favorite Column Visibility', () => {
const renderDashboardList = (props = {}, userProp = mockUser) =>
render(
<MemoryRouter>
<QueryParamProvider>
<DashboardList {...props} user={userProp} />
</QueryParamProvider>
</MemoryRouter>,
{ useRedux: true },
);
beforeEach(() => {
setupBasicMocks();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockImplementation(feature => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW');
});
afterEach(() => {
fetchMock.resetHistory();
fetchMock.restore();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
});
test('hides favorite column when no dashboards are favorited', async () => {
// Mock favorite status API to return all false
fetchMock.get(
dashboardFavoriteStatusEndpoint,
{
result: [
{ id: 0, value: false },
{ id: 1, value: false },
{ id: 2, value: false },
],
},
{ overwriteRoutes: true },
);
renderDashboardList();
// Wait for component to load
await waitFor(() => {
expect(screen.getByText('Dashboards')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Dashboard 0')).toBeInTheDocument();
});
// Favorite column should be hidden - check that favorite stars are not present
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
// Verify that other columns are still present
expect(screen.getByText('Title')).toBeInTheDocument();
});
test('shows favorite column when at least one dashboard is favorited', async () => {
// Mock favorite status API to return mixed favorites
fetchMock.get(
dashboardFavoriteStatusEndpoint,
{
result: [
{ id: 0, value: true }, // This dashboard is favorited
{ id: 1, value: false },
{ id: 2, value: false },
],
},
{ overwriteRoutes: true },
);
renderDashboardList();
// Wait for component to load
await waitFor(() => {
expect(screen.getByText('Dashboards')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Dashboard 0')).toBeInTheDocument();
});
// Favorite column should be visible - check that favorite stars are present
await waitFor(
() => {
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
expect(favoriteStars.length).toBeGreaterThan(0);
},
{ timeout: 10000 },
);
});
test('shows favorite column when all dashboards are favorited', async () => {
// Mock favorite status API to return all true
fetchMock.get(
dashboardFavoriteStatusEndpoint,
{
result: [
{ id: 0, value: true },
{ id: 1, value: true },
{ id: 2, value: true },
],
},
{ overwriteRoutes: true },
);
renderDashboardList();
// Wait for component to load
await waitFor(() => {
expect(screen.getByText('Dashboards')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Dashboard 0')).toBeInTheDocument();
});
// Favorite column should be visible
await waitFor(
() => {
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
expect(favoriteStars.length).toBeGreaterThan(0);
},
{ timeout: 10000 },
);
});
test('hides favorite column when user is not logged in', async () => {
// Mock favorite status API
fetchMock.get(
dashboardFavoriteStatusEndpoint,
{
result: [
{ id: 0, value: true },
{ id: 1, value: false },
],
},
{ overwriteRoutes: true },
);
// Render without userId (user not logged in)
const noUser = {
userId: 0, // Use 0 instead of null to satisfy type requirements
firstName: '',
lastName: '',
};
renderDashboardList({}, noUser);
// Wait for component to load
await waitFor(() => {
expect(screen.getByText('Dashboards')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Dashboard 0')).toBeInTheDocument();
});
// Favorite column should be hidden when user is not logged in
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
});
test('hides favorite column when dashboard list is empty', async () => {
// Mock empty dashboards response
fetchMock.get(
dashboardsEndpoint,
{
result: [],
dashboard_count: 0,
},
{ overwriteRoutes: true },
);
// Mock empty favorite status
fetchMock.get(
dashboardFavoriteStatusEndpoint,
{
result: [],
},
{ overwriteRoutes: true },
);
renderDashboardList();
// Wait for component to load
await waitFor(() => {
expect(screen.getByText('Dashboards')).toBeInTheDocument();
});
// No favorite stars should be present when there are no dashboards
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
});
test('handles partial favorite status loading gracefully', async () => {
// Mock partial favorite status (fewer items than dashboards)
fetchMock.get(
dashboardFavoriteStatusEndpoint,
{
result: [
{ id: 0, value: false },
// Missing status for dashboards 1 and 2
],
},
{ overwriteRoutes: true },
);
renderDashboardList();
// Wait for component to load
await waitFor(() => {
expect(screen.getByText('Dashboards')).toBeInTheDocument();
});
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Dashboard 0')).toBeInTheDocument();
});
// Should hide column when favorite status is incomplete
const favoriteStars = screen.queryAllByTestId('fave-unfave-icon');
expect(favoriteStars).toHaveLength(0);
});
});

View File

@@ -300,14 +300,6 @@ function DashboardList(props: DashboardListProps) {
);
}
const hasFavoritesOnPage = useMemo(
() =>
dashboards.length > 0 &&
Object.keys(favoriteStatus).length === dashboards.length &&
Object.values(favoriteStatus).some(status => status === true),
[dashboards.length, favoriteStatus],
);
const columns = useMemo(
() => [
{
@@ -327,7 +319,7 @@ function DashboardList(props: DashboardListProps) {
id: 'id',
disableSortBy: true,
size: 'xs',
hidden: !user?.userId || !hasFavoritesOnPage,
hidden: !user?.userId,
},
{
Cell: ({