Compare commits

..

2 Commits

Author SHA1 Message Date
Elizabeth Thompson
c2355c7162 test: add comprehensive tests for favorite column hiding behavior
Add test suites to verify that the favorite column is properly hidden
when no items are favorited and shown when favorites exist.

Tests cover:
- Column hiding when no items are favorited
- Column showing when at least one item is favorited
- Column showing when all items are favorited
- Column hiding for unauthenticated users
- Column hiding for empty lists
- Graceful handling of partial favorite status loading

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 10:34:44 -07:00
Elizabeth Thompson
320cc6831c feat: hide favorite column when no items are favorited
Hide the favorite column in chart and dashboard list views when none
of the items on the current page have been marked as favorites. This
improves the UI by removing empty, non-functional columns.

Changes:
- Add memoized hasFavoritesOnPage helper to detect when favorites exist
- Update column hidden condition to include favorite availability check
- Apply consistently to both ChartList and DashboardList components

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

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

View File

@@ -1,254 +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.
*/
/**
* 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

@@ -1,305 +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.
*/
/**
* 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

@@ -0,0 +1,259 @@
/**
* 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,6 +315,14 @@ 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(
() => [
{
@@ -334,7 +342,7 @@ function ChartList(props: ChartListProps) {
id: 'id',
disableSortBy: true,
size: 'xs',
hidden: !userId,
hidden: !userId || !hasFavoritesOnPage,
},
{
Cell: ({

View File

@@ -0,0 +1,316 @@
/**
* 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,6 +300,14 @@ 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(
() => [
{
@@ -319,7 +327,7 @@ function DashboardList(props: DashboardListProps) {
id: 'id',
disableSortBy: true,
size: 'xs',
hidden: !user?.userId,
hidden: !user?.userId || !hasFavoritesOnPage,
},
{
Cell: ({