diff --git a/superset-frontend/src/features/datasets/DuplicateDatasetModal.test.tsx b/superset-frontend/src/features/datasets/DuplicateDatasetModal.test.tsx
new file mode 100644
index 00000000000..849bf5f9ad9
--- /dev/null
+++ b/superset-frontend/src/features/datasets/DuplicateDatasetModal.test.tsx
@@ -0,0 +1,282 @@
+/**
+ * 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import { ThemeProvider, supersetTheme } from '@apache-superset/core';
+import DuplicateDatasetModal from './DuplicateDatasetModal';
+
+// Test-only fixture type that includes all fields from API responses
+// Matches VirtualDataset structure from DatasetList but defined locally for tests
+interface VirtualDatasetFixture {
+ id: number;
+ table_name: string;
+ kind: string;
+ schema: string;
+ database: {
+ id: string;
+ database_name: string;
+ };
+ owners: Array<{ first_name: string; last_name: string; id: number }>;
+ changed_by_name: string;
+ changed_by: string;
+ changed_on_delta_humanized: string;
+ explore_url: string;
+ extra: string;
+ sql: string | null;
+}
+
+// Test fixture with extra/sql fields that exist in actual API responses
+const mockDataset: VirtualDatasetFixture = {
+ id: 1,
+ table_name: 'original_dataset',
+ kind: 'virtual',
+ schema: 'public',
+ database: {
+ id: '1',
+ database_name: 'PostgreSQL',
+ },
+ owners: [],
+ changed_by_name: 'Admin',
+ changed_by: 'Admin User',
+ changed_on_delta_humanized: '1 day ago',
+ explore_url: '/explore/?datasource=1__table',
+ extra: '{}',
+ sql: 'SELECT * FROM table',
+};
+
+const Wrapper = ({
+ dataset,
+ onHide,
+ onDuplicate,
+}: {
+ dataset: VirtualDatasetFixture | null;
+ onHide: jest.Mock;
+ onDuplicate: jest.Mock;
+}) => (
+
+
+
+);
+
+const renderModal = (
+ dataset: VirtualDatasetFixture | null,
+ onHide: jest.Mock,
+ onDuplicate: jest.Mock,
+) =>
+ render(
+ ,
+ );
+
+test('modal opens when dataset is provided', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ renderModal(mockDataset, onHide, onDuplicate);
+
+ // Modal should be visible
+ expect(await screen.findByText('Duplicate dataset')).toBeInTheDocument();
+
+ // Input field should be present
+ expect(screen.getByTestId('duplicate-modal-input')).toBeInTheDocument();
+
+ // Duplicate button should be present
+ expect(
+ screen.getByRole('button', { name: /duplicate/i }),
+ ).toBeInTheDocument();
+});
+
+test('modal does not open when dataset is null', () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ renderModal(null, onHide, onDuplicate);
+
+ // Modal should not be visible
+ expect(screen.queryByText('Duplicate dataset')).not.toBeInTheDocument();
+});
+
+test('duplicate button disabled after clearing input', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ renderModal(mockDataset, onHide, onDuplicate);
+
+ const input = await screen.findByTestId('duplicate-modal-input');
+
+ // Type some text first
+ await userEvent.type(input, 'test');
+
+ // Then clear it
+ await userEvent.clear(input);
+
+ // Duplicate button should now be disabled (empty input)
+ const duplicateButton = screen.getByRole('button', { name: /duplicate/i });
+ expect(duplicateButton).toBeDisabled();
+});
+
+test('duplicate button enabled when name is entered', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ renderModal(mockDataset, onHide, onDuplicate);
+
+ const input = await screen.findByTestId('duplicate-modal-input');
+
+ // Type a new name
+ await userEvent.type(input, 'new_dataset_copy');
+
+ // Duplicate button should now be enabled
+ const duplicateButton = await screen.findByRole('button', {
+ name: /duplicate/i,
+ });
+ expect(duplicateButton).toBeEnabled();
+});
+
+test('clicking Duplicate calls onDuplicate with new name', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ renderModal(mockDataset, onHide, onDuplicate);
+
+ const input = await screen.findByTestId('duplicate-modal-input');
+
+ // Type a new name
+ await userEvent.type(input, 'new_dataset_copy');
+
+ // Click Duplicate button
+ const duplicateButton = await screen.findByRole('button', {
+ name: /duplicate/i,
+ });
+ await userEvent.click(duplicateButton);
+
+ // onDuplicate should be called with the new name
+ await waitFor(() => {
+ expect(onDuplicate).toHaveBeenCalledWith('new_dataset_copy');
+ });
+});
+
+test('pressing Enter key triggers duplicate action', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ renderModal(mockDataset, onHide, onDuplicate);
+
+ const input = await screen.findByTestId('duplicate-modal-input');
+
+ // Clear any existing value and type new name with Enter at end
+ await userEvent.clear(input);
+ await userEvent.type(input, 'new_dataset_copy{enter}');
+
+ // onDuplicate should be called by onPressEnter handler
+ await waitFor(() => {
+ expect(onDuplicate).toHaveBeenCalledWith('new_dataset_copy');
+ });
+});
+
+test('modal closes when onHide is called', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ const { rerender } = renderModal(mockDataset, onHide, onDuplicate);
+
+ expect(await screen.findByText('Duplicate dataset')).toBeInTheDocument();
+
+ // Simulate closing the modal by setting dataset to null
+ rerender(
+ ,
+ );
+
+ // Modal should no longer be visible (Ant Design keeps it in DOM but hides it)
+ await waitFor(() => {
+ expect(screen.queryByText('Duplicate dataset')).not.toBeVisible();
+ });
+});
+
+test('cancel button clears input and closes modal', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ const { rerender } = renderModal(mockDataset, onHide, onDuplicate);
+
+ const input = await screen.findByTestId('duplicate-modal-input');
+
+ // Type some text
+ await userEvent.type(input, 'test_name');
+
+ expect(input).toHaveValue('test_name');
+
+ // Click cancel button
+ const cancelButton = await screen.findByRole('button', { name: /cancel/i });
+ await userEvent.click(cancelButton);
+
+ // onHide should be called
+ expect(onHide).toHaveBeenCalled();
+
+ // Simulate closing the modal (parent sets dataset to null)
+ rerender(
+ ,
+ );
+
+ // Modal should be hidden
+ await waitFor(() => {
+ expect(screen.queryByText('Duplicate dataset')).not.toBeVisible();
+ });
+
+ // Reopen with same dataset - input should be cleared
+ rerender(
+ ,
+ );
+
+ const reopenedInput = await screen.findByTestId('duplicate-modal-input');
+ expect(reopenedInput).toHaveValue('');
+});
+
+test('input field clears when new dataset is provided', async () => {
+ const onHide = jest.fn();
+ const onDuplicate = jest.fn();
+
+ const { rerender } = renderModal(mockDataset, onHide, onDuplicate);
+
+ const input = await screen.findByTestId('duplicate-modal-input');
+
+ // Type a name
+ await userEvent.type(input, 'old_name');
+
+ expect(input).toHaveValue('old_name');
+
+ // Switch to different dataset
+ const newDataset: VirtualDatasetFixture = {
+ ...mockDataset,
+ id: 2,
+ table_name: 'different_dataset',
+ };
+
+ rerender(
+ ,
+ );
+
+ // Input should be cleared
+ await waitFor(() => {
+ expect(input).toHaveValue('');
+ });
+});
diff --git a/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts b/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts
index c1fbd772cf1..8ff86e95034 100644
--- a/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts
+++ b/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts
@@ -17,7 +17,8 @@
* under the License.
*/
import { renderHook } from '@testing-library/react-hooks';
-import { SupersetClient } from '@superset-ui/core';
+import { waitFor } from '@testing-library/dom';
+import { SupersetClient, JsonResponse } from '@superset-ui/core';
import rison from 'rison';
import useDatasetsList from './useDatasetLists';
@@ -52,15 +53,14 @@ test('useDatasetsList fetches first page of datasets successfully', async () =>
count: 2,
result: mockDatasets,
},
- } as any);
+ } as unknown as JsonResponse);
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(result.current.datasets).toEqual(mockDatasets);
+ });
- expect(result.current.datasets).toEqual(mockDatasets);
expect(result.current.datasetNames).toEqual(['table1', 'table2']);
expect(getSpy).toHaveBeenCalledTimes(1);
});
@@ -79,21 +79,20 @@ test('useDatasetsList fetches multiple pages (pagination) until count reached',
count: 3,
result: page1Data,
},
- } as any)
+ } as unknown as JsonResponse)
.mockResolvedValueOnce({
json: {
count: 3,
result: page2Data,
},
- } as any);
+ } as unknown as JsonResponse);
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(result.current.datasets).toEqual([...page1Data, ...page2Data]);
+ });
- expect(result.current.datasets).toEqual([...page1Data, ...page2Data]);
expect(result.current.datasetNames).toEqual(['table1', 'table2', 'table3']);
expect(getSpy).toHaveBeenCalledTimes(2);
});
@@ -108,15 +107,18 @@ test('useDatasetsList extracts dataset names correctly', async () => {
{ id: 3, table_name: 'products' },
],
},
- } as any);
+ } as unknown as JsonResponse);
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(result.current.datasetNames).toEqual([
+ 'users',
+ 'orders',
+ 'products',
+ ]);
+ });
- expect(result.current.datasetNames).toEqual(['users', 'orders', 'products']);
expect(getSpy).toHaveBeenCalledTimes(1);
});
@@ -126,17 +128,16 @@ test('useDatasetsList handles API 500 error gracefully', async () => {
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Internal Server Error'));
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ 'There was an error fetching dataset',
+ );
+ });
expect(result.current.datasets).toEqual([]);
expect(result.current.datasetNames).toEqual([]);
- expect(mockAddDangerToast).toHaveBeenCalledWith(
- 'There was an error fetching dataset',
- );
// Should only be called once - error causes break
expect(getSpy).toHaveBeenCalledTimes(1);
});
@@ -147,17 +148,16 @@ test('useDatasetsList handles empty dataset response', async () => {
count: 0,
result: [],
},
- } as any);
+ } as unknown as JsonResponse);
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(getSpy).toHaveBeenCalledTimes(1);
+ });
expect(result.current.datasets).toEqual([]);
expect(result.current.datasetNames).toEqual([]);
- expect(getSpy).toHaveBeenCalledTimes(1);
});
test('useDatasetsList stops pagination when results reach count', async () => {
@@ -172,21 +172,20 @@ test('useDatasetsList stops pagination when results reach count', async () => {
{ id: 2, table_name: 'table2' },
],
},
- } as any)
+ } as unknown as JsonResponse)
.mockResolvedValueOnce({
json: {
count: 2,
result: [], // No more results
},
- } as any);
+ } as unknown as JsonResponse);
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(result.current.datasets).toHaveLength(2);
+ });
- expect(result.current.datasets).toHaveLength(2);
expect(result.current.datasetNames).toEqual(['table1', 'table2']);
// Should stop after results.length >= count
expect(getSpy).toHaveBeenCalledTimes(1);
@@ -203,58 +202,60 @@ test('useDatasetsList resets datasets when schema changes', async () => {
{ id: 2, table_name: 'public_table2' },
],
},
- } as any)
+ } as unknown as JsonResponse)
.mockResolvedValueOnce({
json: {
count: 1,
result: [{ id: 3, table_name: 'private_table1' }],
},
- } as any);
+ } as unknown as JsonResponse);
- const { result, waitForNextUpdate, rerender } = renderHook(
+ const { result, rerender } = renderHook(
({ db, schema }) => useDatasetsList(db, schema),
{
initialProps: { db: mockDb, schema: 'public' },
},
);
- await waitForNextUpdate();
-
- expect(result.current.datasetNames).toEqual([
- 'public_table1',
- 'public_table2',
- ]);
+ await waitFor(() => {
+ expect(result.current.datasetNames).toEqual([
+ 'public_table1',
+ 'public_table2',
+ ]);
+ });
// Change schema
rerender({ db: mockDb, schema: 'private' });
- await waitForNextUpdate();
+ await waitFor(() => {
+ // Should have new datasets from private schema
+ expect(result.current.datasetNames).toEqual(['private_table1']);
+ });
- // Should have new datasets from private schema
- expect(result.current.datasetNames).toEqual(['private_table1']);
expect(getSpy).toHaveBeenCalledTimes(2);
});
test('useDatasetsList handles network timeout gracefully', async () => {
// Mock timeout/abort error (status: 0)
- const timeoutError = new Error('Network timeout');
- (timeoutError as any).status = 0;
+ const timeoutError = new Error('Network timeout') as Error & {
+ status: number;
+ };
+ timeoutError.status = 0;
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(timeoutError);
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ 'There was an error fetching dataset',
+ );
+ });
expect(result.current.datasets).toEqual([]);
expect(result.current.datasetNames).toEqual([]);
- expect(mockAddDangerToast).toHaveBeenCalledWith(
- 'There was an error fetching dataset',
- );
// Should only be called once - error causes break
expect(getSpy).toHaveBeenCalledTimes(1);
});
@@ -265,19 +266,18 @@ test('useDatasetsList breaks pagination loop on persistent API errors', async ()
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Persistent server error'));
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ 'There was an error fetching dataset',
+ );
+ });
// Should only attempt once, then break (not infinite loop)
expect(getSpy).toHaveBeenCalledTimes(1);
expect(result.current.datasets).toEqual([]);
expect(result.current.datasetNames).toEqual([]);
- expect(mockAddDangerToast).toHaveBeenCalledWith(
- 'There was an error fetching dataset',
- );
expect(mockAddDangerToast).toHaveBeenCalledTimes(1);
});
@@ -290,23 +290,22 @@ test('useDatasetsList handles error on second page gracefully', async () => {
count: 3, // Indicates more data exists
result: [{ id: 1, table_name: 'table1' }],
},
- } as any)
+ } as unknown as JsonResponse)
.mockRejectedValue(new Error('Second page error'));
- const { result, waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'public'),
- );
+ const { result } = renderHook(() => useDatasetsList(mockDb, 'public'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ 'There was an error fetching dataset',
+ );
+ });
// Should have first page data, then stop on error
expect(getSpy).toHaveBeenCalledTimes(2);
expect(result.current.datasets).toHaveLength(1);
expect(result.current.datasets[0].table_name).toBe('table1');
expect(result.current.datasetNames).toEqual(['table1']);
- expect(mockAddDangerToast).toHaveBeenCalledWith(
- 'There was an error fetching dataset',
- );
expect(mockAddDangerToast).toHaveBeenCalledTimes(1);
});
@@ -316,7 +315,7 @@ test('useDatasetsList skips fetching when schema is null or undefined', () => {
// Test with null schema
const { result: resultNull, rerender } = renderHook(
({ db, schema }) => useDatasetsList(db, schema),
- { initialProps: { db: mockDb, schema: null as any } },
+ { initialProps: { db: mockDb, schema: null as unknown as string } },
);
// Schema is null - should NOT call API
@@ -325,7 +324,7 @@ test('useDatasetsList skips fetching when schema is null or undefined', () => {
expect(resultNull.current.datasetNames).toEqual([]);
// Change to undefined - still should NOT call API
- rerender({ db: mockDb, schema: undefined as any });
+ rerender({ db: mockDb, schema: undefined as unknown as string });
expect(getSpy).not.toHaveBeenCalled();
expect(resultNull.current.datasets).toEqual([]);
expect(resultNull.current.datasetNames).toEqual([]);
@@ -349,7 +348,7 @@ test('useDatasetsList skips fetching when db.id is undefined', () => {
const dbWithoutId = {
database_name: 'test_db',
owners: [1] as [number],
- } as any;
+ } as typeof mockDb;
const { result } = renderHook(() => useDatasetsList(dbWithoutId, 'public'));
@@ -362,16 +361,15 @@ test('useDatasetsList skips fetching when db.id is undefined', () => {
test('useDatasetsList encodes schemas with spaces and special characters in endpoint URL', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { count: 0, result: [] },
- } as any);
+ } as unknown as JsonResponse);
- const { waitForNextUpdate } = renderHook(() =>
- useDatasetsList(mockDb, 'sales analytics'),
- );
+ renderHook(() => useDatasetsList(mockDb, 'sales analytics'));
- await waitForNextUpdate();
+ await waitFor(() => {
+ expect(getSpy).toHaveBeenCalledTimes(1);
+ });
// Verify API was called with encoded schema
- expect(getSpy).toHaveBeenCalledTimes(1);
const callArg = getSpy.mock.calls[0]?.[0]?.endpoint;
expect(callArg).toBeDefined();
@@ -379,9 +377,15 @@ test('useDatasetsList encodes schemas with spaces and special characters in endp
// Schema 'sales analytics' -> encodeURIComponent -> 'sales%20analytics' -> rison.encode_uri -> 'sales%2520analytics'
expect(callArg).toContain('sales%2520analytics');
- // Decode rison to verify filter structure
- const risonParam = callArg!.split('?q=')[1];
- const decoded = rison.decode(decodeURIComponent(risonParam)) as any;
+ // Decode rison to verify filter structure using URL parser
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const risonParam = new URL(callArg!, 'http://localhost').searchParams.get(
+ 'q',
+ );
+ expect(risonParam).toBeTruthy();
+ const decoded = rison.decode(risonParam!) as {
+ filters: Array<{ col: string; opr: string; value: string }>;
+ };
// After rison decoding, the schema should be the encoded version (encodeURIComponent output)
expect(decoded.filters[1]).toEqual({
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
new file mode 100644
index 00000000000..af73876de11
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -0,0 +1,493 @@
+/**
+ * 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 { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+import rison from 'rison';
+import { ComponentType } from 'react';
+import {
+ setupMocks,
+ renderDatasetList,
+ waitForDatasetsPageReady,
+ mockAdminUser,
+ mockDatasets,
+ setupDeleteMocks,
+ mockRelatedCharts,
+ mockRelatedDashboards,
+ mockHandleResourceExport,
+ API_ENDPOINTS,
+} from './DatasetList.testHelpers';
+
+jest.mock('src/utils/export');
+
+// Mock withToasts HOC to be a passthrough so we can spy on toast calls
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+ __esModule: true,
+ default:
(Component: ComponentType
) => Component,
+}));
+
+// Increase default timeout for tests that involve multiple async operations
+jest.setTimeout(15000);
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(async () => {
+ // Flush pending React state updates within act() to prevent warnings
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ // Restore real timers in case a test threw early
+ jest.useRealTimers();
+
+ // Reset browser history state to prevent query params leaking between tests
+ window.history.replaceState({}, '', '/');
+
+ fetchMock.resetHistory();
+ fetchMock.restore();
+ jest.restoreAllMocks();
+});
+
+test('typing in search updates the input value correctly', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('search-filter-container')).toBeInTheDocument();
+ });
+
+ const searchContainer = screen.getByTestId('search-filter-container');
+ const searchInput = within(searchContainer).getByRole('textbox');
+
+ // Type search query
+ await userEvent.type(searchInput, 'sales');
+
+ // Verify input value is updated
+ await waitFor(() => {
+ expect(searchInput).toHaveValue('sales');
+ });
+});
+
+test('typing in search triggers debounced API call with search filter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('search-filter-container')).toBeInTheDocument();
+ });
+
+ const searchContainer = screen.getByTestId('search-filter-container');
+ const searchInput = within(searchContainer).getByRole('textbox');
+
+ // Record initial API calls
+ const initialCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Type search query and submit with Enter to trigger the debounced fetch
+ await userEvent.type(searchInput, 'sales{enter}');
+
+ // Wait for debounced API call
+ await waitFor(
+ () => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(initialCallCount);
+ },
+ { timeout: 5000 },
+ );
+
+ // Verify the latest API call includes search filter in URL
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const latestCall = calls[calls.length - 1];
+ const url = latestCall[0] as string;
+
+ // URL should contain filters parameter with search term
+ expect(url).toContain('filters');
+ const risonPayload = new URL(url, 'http://localhost').searchParams.get('q');
+ expect(risonPayload).toBeTruthy();
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const decoded = rison.decode(risonPayload!) as Record;
+ const filters = Array.isArray(decoded?.filters) ? decoded.filters : [];
+ const hasSalesFilter = filters.some(
+ (filter: Record) =>
+ typeof filter?.value === 'string' &&
+ filter.value.toLowerCase().includes('sales'),
+ );
+ expect(hasSalesFilter).toBe(true);
+});
+
+test('500 error triggers danger toast with error message', async () => {
+ const addDangerToast = jest.fn();
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ {
+ status: 500,
+ body: { message: 'Internal Server Error' },
+ },
+ { overwriteRoutes: true },
+ );
+
+ // Pass toast spy directly via props to bypass withToasts HOC
+ renderDatasetList(mockAdminUser, {
+ addDangerToast,
+ addSuccessToast: jest.fn(),
+ });
+
+ // Verify component renders despite error
+ await waitForDatasetsPageReady();
+
+ // Verify danger toast called with error information
+ await waitFor(
+ () => {
+ expect(addDangerToast).toHaveBeenCalled();
+ },
+ { timeout: 5000 },
+ );
+
+ // Verify toast message contains error keywords
+ expect(addDangerToast.mock.calls.length).toBeGreaterThan(0);
+ const toastMessage = String(addDangerToast.mock.calls[0][0]);
+ expect(
+ toastMessage.includes('error') ||
+ toastMessage.includes('Error') ||
+ toastMessage.includes('500') ||
+ toastMessage.includes('Internal Server'),
+ ).toBe(true);
+});
+
+test('network timeout triggers danger toast', async () => {
+ const addDangerToast = jest.fn();
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { throws: new Error('Network timeout') },
+ { overwriteRoutes: true },
+ );
+
+ // Pass toast spy directly via props to bypass withToasts HOC
+ renderDatasetList(mockAdminUser, {
+ addDangerToast,
+ addSuccessToast: jest.fn(),
+ });
+
+ // Verify component renders despite error
+ await waitForDatasetsPageReady();
+
+ // Verify danger toast called with timeout message
+ await waitFor(
+ () => {
+ expect(addDangerToast).toHaveBeenCalled();
+ },
+ { timeout: 5000 },
+ );
+
+ // Verify toast message contains timeout/network keywords
+ expect(addDangerToast.mock.calls.length).toBeGreaterThan(0);
+ const toastMessage = String(addDangerToast.mock.calls[0][0]);
+ expect(
+ toastMessage.includes('timeout') ||
+ toastMessage.includes('Timeout') ||
+ toastMessage.includes('network') ||
+ toastMessage.includes('Network') ||
+ toastMessage.includes('error'),
+ ).toBe(true);
+});
+
+test('clicking delete opens modal with related objects count', async () => {
+ const datasetToDelete = mockDatasets[0];
+
+ // Set up delete mocks
+ setupDeleteMocks(datasetToDelete.id);
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetToDelete], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ // Wait for dataset to render
+ await waitFor(() => {
+ expect(screen.getByText(datasetToDelete.table_name)).toBeInTheDocument();
+ });
+
+ // Find and click delete button in the row
+ const table = screen.getByTestId('listview-table');
+ const datasetRow = within(table)
+ .getAllByRole('row')
+ .find(row => within(row).queryByText(datasetToDelete.table_name));
+ expect(datasetRow).toBeTruthy();
+ await userEvent.hover(datasetRow!);
+ const deleteButton = within(datasetRow!).getByTestId('delete');
+
+ await userEvent.click(deleteButton);
+
+ // Verify modal opens with related objects
+ const modal = await screen.findByRole('dialog');
+ expect(modal).toBeInTheDocument();
+
+ // Check for related charts count
+ expect(modal).toHaveTextContent(
+ new RegExp(mockRelatedCharts.count.toString()),
+ );
+ // Check for related dashboards count
+ expect(modal).toHaveTextContent(
+ new RegExp(mockRelatedDashboards.count.toString()),
+ );
+});
+
+test('clicking export calls handleResourceExport with dataset ID', async () => {
+ const datasetToExport = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetToExport], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(datasetToExport.table_name)).toBeInTheDocument();
+ });
+
+ // Find and click export button
+ const table = screen.getByTestId('listview-table');
+ const exportButton = await within(table).findByTestId('upload');
+
+ await userEvent.click(exportButton);
+
+ // Verify export was called with correct ID
+ await waitFor(() => {
+ expect(mockHandleResourceExport).toHaveBeenCalledWith(
+ 'dataset',
+ [datasetToExport.id],
+ expect.any(Function),
+ );
+ });
+});
+
+test('clicking duplicate opens modal and submits duplicate request', async () => {
+ const datasetToDuplicate = {
+ ...mockDatasets[1],
+ kind: 'virtual',
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetToDuplicate], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ fetchMock.post(
+ API_ENDPOINTS.DATASET_DUPLICATE,
+ { id: 999, table_name: 'Copy of Dataset' },
+ { overwriteRoutes: true },
+ );
+
+ const addSuccessToast = jest.fn();
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: jest.fn(),
+ addSuccessToast,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(datasetToDuplicate.table_name)).toBeInTheDocument();
+ });
+
+ // Track initial dataset list API calls BEFORE duplicate action
+ const initialDatasetCallCount = fetchMock.calls(
+ API_ENDPOINTS.DATASETS,
+ ).length;
+
+ const row = screen.getByText(datasetToDuplicate.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ const duplicateIcon = await within(row!).findByTestId('copy');
+ const duplicateButton = duplicateIcon.closest(
+ '[role="button"]',
+ ) as HTMLElement | null;
+ expect(duplicateButton).toBeTruthy();
+
+ await userEvent.click(duplicateButton!);
+
+ const modal = await screen.findByRole('dialog');
+ const modalInput = within(modal).getByRole('textbox');
+ await userEvent.clear(modalInput);
+ await userEvent.type(modalInput, 'Copy of Dataset');
+
+ const confirmButton = within(modal).getByRole('button', {
+ name: /duplicate/i,
+ });
+ await userEvent.click(confirmButton);
+
+ // Verify duplicate API was called with correct payload
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASET_DUPLICATE);
+ expect(calls.length).toBeGreaterThan(0);
+
+ // Verify POST body contains correct dataset info
+ const requestBody = JSON.parse(calls[0][1]?.body as string);
+ expect(requestBody.base_model_id).toBe(datasetToDuplicate.id);
+ expect(requestBody.table_name).toBe('Copy of Dataset');
+ });
+
+ // Verify modal closes after successful duplicate
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // Verify refreshData() is called (observable via new dataset list API call)
+ await waitFor(
+ () => {
+ const datasetCalls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(datasetCalls.length).toBeGreaterThan(initialDatasetCallCount);
+ },
+ { timeout: 3000 },
+ );
+
+ // Note: Success toast feature not implemented (see index.tsx:718-721)
+ expect(addSuccessToast).not.toHaveBeenCalled();
+});
+
+test('certified dataset shows badge and tooltip with certification details', async () => {
+ const certifiedDataset = {
+ ...mockDatasets[1],
+ extra: JSON.stringify({
+ certification: {
+ certified_by: 'Data Team',
+ details: 'Approved for production use',
+ },
+ }),
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [certifiedDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(certifiedDataset.table_name)).toBeInTheDocument();
+ });
+
+ // Verify the row renders with the dataset
+ const row = screen.getByText(certifiedDataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Find certification badge within the row (fail-fast if not found)
+ const certBadge = await within(row!).findByRole('img', {
+ name: /certified/i,
+ });
+ expect(certBadge).toBeInTheDocument();
+
+ // Hover to reveal tooltip
+ await userEvent.hover(certBadge);
+
+ // Wait for tooltip content to appear
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toBeInTheDocument();
+ expect(tooltip).toHaveTextContent(/Data Team/i);
+ expect(tooltip).toHaveTextContent(/Approved for production/i);
+});
+
+test('dataset with warning shows icon and tooltip with markdown content', async () => {
+ const warningMessage = 'This dataset contains PII. Handle with care.';
+ const datasetWithWarning = {
+ ...mockDatasets[2],
+ extra: JSON.stringify({
+ warning_markdown: warningMessage,
+ }),
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetWithWarning], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(datasetWithWarning.table_name)).toBeInTheDocument();
+ });
+
+ // Verify row exists
+ const row = screen.getByText(datasetWithWarning.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Find warning icon within the row (fail-fast if not found)
+ const warningIcon = await within(row!).findByRole('img', {
+ name: /warning/i,
+ });
+ expect(warningIcon).toBeInTheDocument();
+
+ // Hover to reveal tooltip with markdown content
+ await userEvent.hover(warningIcon);
+
+ // Wait for tooltip to appear with warning text
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toBeInTheDocument();
+ expect(tooltip).toHaveTextContent(/PII/i);
+ expect(tooltip).toHaveTextContent(/Handle with care/i);
+});
+
+test('dataset name links to Explore with correct URL and accessible label', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ // Find the dataset row and scope the link query to it
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Find the internal link within the dataset row (fail-fast if not found)
+ const exploreLink = within(row!).getByTestId('internal-link');
+ expect(exploreLink).toBeInTheDocument();
+
+ // Verify link has correct href to Explore page
+ expect(exploreLink).toHaveAttribute('href', dataset.explore_url);
+ expect(exploreLink).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/explore/'),
+ );
+
+ // Verify link contains dataset ID
+ expect(exploreLink).toHaveAttribute(
+ 'href',
+ expect.stringContaining(`${dataset.id}__table`),
+ );
+});
+
+// Note: Component "+1" tests for state persistence through operations have been
+// moved to DatasetList.listview.test.tsx where they can use the reliable selectOption helper.
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
new file mode 100644
index 00000000000..0b228509b2a
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
@@ -0,0 +1,252 @@
+/**
+ * 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 { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+import rison from 'rison';
+import { selectOption } from 'spec/helpers/testing-library';
+import {
+ setupMocks,
+ renderDatasetList,
+ mockAdminUser,
+ mockDatasets,
+ setupBulkDeleteMocks,
+ API_ENDPOINTS,
+} from './DatasetList.testHelpers';
+
+/**
+ * Integration Contract Tests
+ *
+ * These tests verify multi-component orchestration that cannot be tested
+ * in component isolation. Unlike component tests which mock all dependencies,
+ * integration tests use real Redux/React Query/Router state management.
+ *
+ * Only 2 tests are needed here - most workflows are covered by component "+1" tests.
+ */
+
+jest.mock('src/utils/export');
+
+// Increase default timeout for tests that involve multiple async operations
+jest.setTimeout(15000);
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(async () => {
+ // Flush pending React state updates within act() to prevent warnings
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ // Restore real timers in case a test threw early
+ jest.useRealTimers();
+
+ // Reset browser history state to prevent query params leaking between tests
+ window.history.replaceState({}, '', '/');
+
+ fetchMock.resetHistory();
+ fetchMock.restore();
+ jest.restoreAllMocks();
+});
+
+test('ListView provider correctly merges filter + sort + pagination state on refetch', async () => {
+ // This test verifies that when multiple state sources are combined,
+ // the ListView provider correctly merges them for the API call.
+ // Component tests verify individual pieces persist; this verifies they COMBINE correctly.
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: mockDatasets, count: mockDatasets.length },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // 1. Apply a sort by clicking Name header
+ const table = screen.getByTestId('listview-table');
+ const nameHeader = within(table).getByRole('columnheader', {
+ name: /Name/i,
+ });
+
+ const callsBeforeSort = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+ await userEvent.click(nameHeader);
+
+ // Wait for sort-triggered refetch to complete before applying filter
+ await waitFor(() => {
+ expect(fetchMock.calls(API_ENDPOINTS.DATASETS).length).toBeGreaterThan(
+ callsBeforeSort,
+ );
+ });
+
+ // 2. Apply a filter using selectOption helper
+ const beforeFilterCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+ await selectOption('Virtual', 'Type');
+
+ // Wait for filter API call to complete
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(beforeFilterCallCount);
+ });
+
+ // 3. Verify the final API call contains ALL three state pieces merged correctly
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const latestCall = calls[calls.length - 1];
+ const url = latestCall[0] as string;
+
+ // Decode the rison payload using URL parser
+ const risonPayload = new URL(url, 'http://localhost').searchParams.get('q');
+ expect(risonPayload).toBeTruthy();
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const decoded = rison.decode(risonPayload!) as Record;
+
+ // Verify ALL three pieces of state are present and merged:
+ // 1. Sort (order_column)
+ expect(decoded?.order_column).toBeTruthy();
+
+ // 2. Filter (filters array)
+ const filters = Array.isArray(decoded?.filters) ? decoded.filters : [];
+ const hasTypeFilter = filters.some(
+ (filter: Record) =>
+ filter?.col === 'sql' && filter?.value === false,
+ );
+ expect(hasTypeFilter).toBe(true);
+
+ // 3. Pagination (page_size is present with default value)
+ expect(decoded?.page_size).toBeTruthy();
+
+ // This confirms ListView provider merges state from multiple sources correctly
+}, 30000);
+
+test('bulk action orchestration: selection → action → cleanup cycle works correctly', async () => {
+ // This test verifies the full bulk operation cycle across multiple components:
+ // 1. Bulk mode UI (selection state)
+ // 2. Bulk action handler (delete operation)
+ // 3. Selection cleanup (state reset)
+
+ setupBulkDeleteMocks();
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: mockDatasets, count: mockDatasets.length },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // 1. Enter bulk mode and select items
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls container to appear first (fast query)
+ const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select first dataset by name (scoped to table, async to avoid race)
+ const firstCell = await within(table).findByText(mockDatasets[0].table_name);
+ const firstRow = firstCell.closest('tr');
+ expect(firstRow).toBeInTheDocument();
+ await userEvent.click(within(firstRow!).getByRole('checkbox'));
+
+ // Wait for first selection to register before clicking second (prevents stale node)
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Select second dataset (scoped to table, async to avoid race)
+ const secondCell = await within(table).findByText(mockDatasets[1].table_name);
+ const secondRow = secondCell.closest('tr');
+ expect(secondRow).toBeInTheDocument();
+ await userEvent.click(within(secondRow!).getByRole('checkbox'));
+
+ // Wait for both selections to register
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /2 Selected/i,
+ );
+ });
+
+ // 2. Execute bulk delete - scoped to toolbar to avoid row delete buttons
+ const bulkDeleteButton = await within(bulkSelectControls).findByRole(
+ 'button',
+ { name: 'Delete' },
+ );
+ await userEvent.click(bulkDeleteButton);
+
+ // Confirm in modal - verify by stable anchor (delete-modal-input is unique to delete modals)
+ const modal = await screen.findByRole('dialog');
+ const confirmInput = within(modal).getByTestId('delete-modal-input');
+ expect(confirmInput).toBeInTheDocument();
+ await userEvent.clear(confirmInput);
+ await userEvent.type(confirmInput, 'DELETE');
+
+ // Capture datasets call count before confirming
+ const datasetsCallCountBeforeDelete = fetchMock.calls(
+ API_ENDPOINTS.DATASETS,
+ ).length;
+
+ const confirmButton = within(modal)
+ .getAllByRole('button', { name: /^delete$/i })
+ .pop();
+ await userEvent.click(confirmButton!);
+
+ // 3. Wait for bulk delete API call to be made
+ await waitFor(() => {
+ const deleteCalls = fetchMock.calls(API_ENDPOINTS.DATASET_BULK_DELETE);
+ expect(deleteCalls.length).toBeGreaterThan(0);
+ });
+
+ // Wait for modal to close
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // Wait for datasets refetch after delete
+ await waitFor(() => {
+ const datasetsCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+ expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDelete);
+ });
+
+ // 4. Verify selection count shows 0 (selections cleared but still in bulk mode)
+ // After bulk delete, items are deselected but bulk mode may remain active
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /0 selected/i,
+ );
+ });
+
+ // This confirms the full bulk operation cycle coordinates correctly:
+ // selection state → action handler → list refresh → state cleanup
+}, 45000);
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx
new file mode 100644
index 00000000000..b43f78dc9c4
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx
@@ -0,0 +1,2215 @@
+/**
+ * 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 { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+import rison from 'rison';
+import { SupersetClient } from '@superset-ui/core';
+import { selectOption } from 'spec/helpers/testing-library';
+import {
+ setupMocks,
+ renderDatasetList,
+ mockAdminUser,
+ mockDatasets,
+ setupDeleteMocks,
+ setupBulkDeleteMocks,
+ setupDuplicateMocks,
+ mockHandleResourceExport,
+ assertOnlyExpectedCalls,
+ API_ENDPOINTS,
+} from './DatasetList.testHelpers';
+
+const mockAddDangerToast = jest.fn();
+const mockAddSuccessToast = jest.fn();
+
+jest.mock('src/components/MessageToasts/actions', () => ({
+ addDangerToast: (msg: string) => {
+ mockAddDangerToast(msg);
+ return () => ({ type: '@@toast/danger' });
+ },
+ addSuccessToast: (msg: string) => {
+ mockAddSuccessToast(msg);
+ return () => ({ type: '@@toast/success' });
+ },
+}));
+
+jest.mock('src/utils/export');
+
+// Increase default timeout for tests that involve multiple async operations
+jest.setTimeout(15000);
+
+const buildSupersetClientError = ({
+ status,
+ message,
+}: {
+ status: number;
+ message: string;
+}) => ({
+ message,
+ error: message,
+ status,
+ response: {
+ status,
+ json: async () => ({ message }),
+ text: async () => message,
+ clone() {
+ return {
+ ...this,
+ json: async () => ({ message }),
+ text: async () => message,
+ };
+ },
+ },
+});
+
+/**
+ * Helper to set up error test scenarios with SupersetClient spy
+ * Reduces boilerplate for error toast tests
+ */
+const setupErrorTestScenario = ({
+ dataset,
+ method,
+ endpoint,
+ errorStatus,
+ errorMessage,
+}: {
+ dataset: (typeof mockDatasets)[0];
+ method: 'get' | 'post';
+ endpoint: string;
+ errorStatus: number;
+ errorMessage: string;
+}) => {
+ // Spy on SupersetClient method and throw error for specific endpoint
+ const originalMethod =
+ method === 'get'
+ ? SupersetClient.get.bind(SupersetClient)
+ : SupersetClient.post.bind(SupersetClient);
+
+ jest.spyOn(SupersetClient, method).mockImplementation(async request => {
+ if (request.endpoint?.includes(endpoint)) {
+ throw buildSupersetClientError({
+ status: errorStatus,
+ message: errorMessage,
+ });
+ }
+ return originalMethod(request);
+ });
+
+ // Configure fetchMock to return single dataset
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ // Render component with toast mocks
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: mockAddDangerToast,
+ addSuccessToast: mockAddSuccessToast,
+ });
+};
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(async () => {
+ // Flush pending React state updates within act() to prevent warnings
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ // Restore real timers in case a test threw early
+ jest.useRealTimers();
+
+ // Reset browser history state to prevent query params leaking between tests
+ // QueryParamProvider reads from window.history, which persists across renders
+ window.history.replaceState({}, '', '/');
+
+ fetchMock.resetHistory();
+ fetchMock.restore();
+ jest.restoreAllMocks();
+});
+
+test('required API endpoints are called and no unmocked calls on initial render', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Verify expected endpoints were called and no unmocked calls
+ // assertOnlyExpectedCalls checks: 1) no unmatched calls, 2) each expected endpoint was called
+ assertOnlyExpectedCalls([
+ API_ENDPOINTS.DATASETS_INFO, // Permission check
+ API_ENDPOINTS.DATASETS, // Main dataset list data
+ ]);
+});
+
+test('renders all required column headers', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+
+ // Verify all column headers are present
+ expect(
+ within(table).getByRole('columnheader', { name: /Name/i }),
+ ).toBeInTheDocument();
+ expect(
+ within(table).getByRole('columnheader', { name: /Type/i }),
+ ).toBeInTheDocument();
+ expect(
+ within(table).getByRole('columnheader', { name: /Database/i }),
+ ).toBeInTheDocument();
+ expect(
+ within(table).getByRole('columnheader', { name: /Schema/i }),
+ ).toBeInTheDocument();
+ expect(
+ within(table).getByRole('columnheader', { name: /Owners/i }),
+ ).toBeInTheDocument();
+ expect(
+ within(table).getByRole('columnheader', { name: /Last modified/i }),
+ ).toBeInTheDocument();
+ expect(
+ within(table).getByRole('columnheader', { name: /Actions/i }),
+ ).toBeInTheDocument();
+});
+
+test('displays dataset name in Name column', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+});
+
+test('displays dataset type as Physical or Virtual', async () => {
+ const physicalDataset = mockDatasets[0]; // kind: 'physical'
+ const virtualDataset = mockDatasets[1]; // kind: 'virtual'
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [physicalDataset, virtualDataset], count: 2 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+});
+
+test('displays database name in Database column', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(dataset.database.database_name),
+ ).toBeInTheDocument();
+ });
+});
+
+test('displays schema name in Schema column', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.schema)).toBeInTheDocument();
+ });
+});
+
+test('displays last modified date in humanized format', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(dataset.changed_on_delta_humanized),
+ ).toBeInTheDocument();
+ });
+});
+
+test('sorting by Name column updates API call with sort parameter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const nameHeader = within(table).getByRole('columnheader', {
+ name: /Name/i,
+ });
+
+ // Record initial calls
+ const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Click Name header to sort
+ await userEvent.click(nameHeader);
+
+ // Wait for new API call
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(initialCalls);
+ });
+
+ // Verify latest call includes sort parameter
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const latestCall = calls[calls.length - 1];
+ const url = latestCall[0] as string;
+
+ // URL should contain order_column for sorting
+ expect(url).toMatch(/order_column|sort/);
+});
+
+test('sorting by Database column updates sort parameter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const databaseHeader = within(table).getByRole('columnheader', {
+ name: /Database/i,
+ });
+
+ const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ await userEvent.click(databaseHeader);
+
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(initialCalls);
+ });
+
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const url = calls[calls.length - 1][0] as string;
+ expect(url).toMatch(/order_column|sort/);
+});
+
+test('sorting by Last modified column updates sort parameter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const modifiedHeader = within(table).getByRole('columnheader', {
+ name: /Last modified/i,
+ });
+
+ const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ await userEvent.click(modifiedHeader);
+
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(initialCalls);
+ });
+
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const url = calls[calls.length - 1][0] as string;
+ expect(url).toMatch(/order_column|sort/);
+});
+
+test('export button triggers handleResourceExport with dataset ID', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ // Find export button in actions column (fail-fast if not found)
+ const table = screen.getByTestId('listview-table');
+ const exportButton = await within(table).findByTestId('upload');
+
+ await userEvent.click(exportButton);
+
+ await waitFor(() => {
+ expect(mockHandleResourceExport).toHaveBeenCalledWith(
+ 'dataset',
+ [dataset.id],
+ expect.any(Function),
+ );
+ });
+});
+
+test('delete button opens modal with dataset details', async () => {
+ const dataset = mockDatasets[0];
+
+ setupDeleteMocks(dataset.id);
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+
+ await userEvent.click(deleteButton);
+
+ // Verify delete modal appears
+ const modal = await screen.findByRole('dialog');
+ expect(modal).toBeInTheDocument();
+});
+
+test('delete action successfully deletes dataset and refreshes list', async () => {
+ const datasetToDelete = mockDatasets[0];
+ setupDeleteMocks(datasetToDelete.id);
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetToDelete], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addSuccessToast: mockAddSuccessToast,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+ await userEvent.click(deleteButton);
+
+ // Modal opens with dataset info
+ const modal = await screen.findByRole('dialog');
+
+ // Type DELETE to enable confirm button
+ const confirmInput = within(modal).getByTestId('delete-modal-input');
+ await userEvent.type(confirmInput, 'DELETE');
+
+ // Track API calls before confirm
+ const callsBefore = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Click confirm - find the danger button (last delete button in modal)
+ const confirmButton = within(modal)
+ .getAllByRole('button', { name: /^delete$/i })
+ .pop();
+ await userEvent.click(confirmButton!);
+
+ // Wait for delete API call
+ await waitFor(() => {
+ const deleteCalls = fetchMock.calls(
+ `glob:*/api/v1/dataset/${datasetToDelete.id}`,
+ );
+ const hasDelete = deleteCalls.some(
+ call => (call[1] as RequestInit)?.method === 'DELETE',
+ );
+ expect(hasDelete).toBe(true);
+ });
+
+ // Success toast shown and modal closes
+ await waitFor(() => {
+ expect(mockAddSuccessToast).toHaveBeenCalled();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // List refreshes
+ await waitFor(() => {
+ expect(fetchMock.calls(API_ENDPOINTS.DATASETS).length).toBeGreaterThan(
+ callsBefore,
+ );
+ });
+});
+
+test('delete action cancel closes modal without deleting', async () => {
+ const dataset = mockDatasets[0];
+ setupDeleteMocks(dataset.id);
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+ await userEvent.click(deleteButton);
+
+ const modal = await screen.findByRole('dialog');
+
+ // Click Cancel button
+ const cancelButton = within(modal).getByRole('button', { name: /cancel/i });
+ await userEvent.click(cancelButton);
+
+ // Modal closes
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // No delete API call made (only related_objects GET was called)
+ const deleteCalls = fetchMock.calls(`glob:*/api/v1/dataset/${dataset.id}`);
+ const hasDeleteMethod = deleteCalls.some(
+ call => (call[1] as RequestInit)?.method === 'DELETE',
+ );
+ expect(hasDeleteMethod).toBe(false);
+
+ // Dataset still in list
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+});
+
+test('duplicate action successfully duplicates virtual dataset', async () => {
+ const virtualDataset = mockDatasets[1]; // Virtual dataset (kind: 'virtual')
+ setupDuplicateMocks();
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [virtualDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addSuccessToast: mockAddSuccessToast,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const duplicateButton = await within(table).findByTestId('copy');
+ await userEvent.click(duplicateButton);
+
+ const modal = await screen.findByRole('dialog');
+
+ // Enter new name
+ const input = within(modal).getByRole('textbox');
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Copy of Analytics');
+
+ // Track API calls before submit
+ const callsBefore = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Submit
+ const submitButton = within(modal).getByRole('button', {
+ name: /duplicate/i,
+ });
+ await userEvent.click(submitButton);
+
+ // Wait for duplicate API call and modal closes
+ await waitFor(() => {
+ const dupCalls = fetchMock.calls(API_ENDPOINTS.DATASET_DUPLICATE);
+ expect(dupCalls.length).toBeGreaterThan(0);
+ // Modal closes (duplicate success doesn't show toast, just closes modal)
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // List refreshes
+ await waitFor(() => {
+ expect(fetchMock.calls(API_ENDPOINTS.DATASETS).length).toBeGreaterThan(
+ callsBefore,
+ );
+ });
+});
+
+test('duplicate button visible only for virtual datasets', async () => {
+ const physicalDataset = mockDatasets[0]; // kind: 'physical'
+ const virtualDataset = mockDatasets[1]; // kind: 'virtual'
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [physicalDataset, virtualDataset], count: 2 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument();
+ });
+
+ // Find both dataset rows
+ const physicalRow = screen
+ .getByText(physicalDataset.table_name)
+ .closest('tr');
+ const virtualRow = screen.getByText(virtualDataset.table_name).closest('tr');
+
+ expect(physicalRow).toBeInTheDocument();
+ expect(virtualRow).toBeInTheDocument();
+
+ // Check physical dataset row - should NOT have duplicate button
+ const physicalDuplicateButton = within(physicalRow!).queryByTestId('copy');
+ expect(physicalDuplicateButton).not.toBeInTheDocument();
+
+ // Check virtual dataset row - should have duplicate button (copy icon)
+ const virtualDuplicateButton = within(virtualRow!).getByTestId('copy');
+ expect(virtualDuplicateButton).toBeInTheDocument();
+
+ // Verify the duplicate button is visible and clickable for virtual datasets
+ expect(virtualDuplicateButton).toBeVisible();
+});
+
+test('bulk select enables checkboxes', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Verify no checkboxes before bulk select
+ expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
+
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls container to appear first (fast query)
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Note: Bulk action buttons (Export, Delete) only appear after selecting items
+ // This test only verifies checkboxes appear - button visibility tested in other tests
+}, 30000);
+
+test('selecting all datasets shows correct count in toolbar', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: mockDatasets, count: mockDatasets.length },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls container to appear first (fast query)
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select all checkbox using semantic selector
+ // Note: antd renders multiple checkboxes with same aria-label, use first one (table header)
+ const selectAllCheckboxes = screen.getAllByLabelText('Select all');
+ await userEvent.click(selectAllCheckboxes[0]);
+
+ // Should show selected count in toolbar (use data-test for reliability)
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ `${mockDatasets.length} Selected`,
+ );
+ });
+ // Note: Button enable state is tested in bulk export/delete tests
+}, 30000);
+
+test('bulk export triggers export with selected IDs', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [mockDatasets[0]], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', { name: /bulk select/i });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls container to appear first (fast query)
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select row by dataset name (scoped to table, async to avoid race)
+ const datasetCell = await within(table).findByText(
+ mockDatasets[0].table_name,
+ );
+ const datasetRow = datasetCell.closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+ await userEvent.click(within(datasetRow!).getByRole('checkbox'));
+
+ // Find and click bulk export button (fail-fast if not found)
+ const exportButton = await screen.findByRole('button', { name: /export/i });
+ await userEvent.click(exportButton);
+
+ await waitFor(() => {
+ expect(mockHandleResourceExport).toHaveBeenCalled();
+ });
+});
+
+test('bulk delete opens confirmation modal', async () => {
+ setupBulkDeleteMocks();
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [mockDatasets[0]], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', { name: /bulk select/i });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls container to appear first (fast query)
+ const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select row by dataset name (scoped to table, async to avoid race)
+ const datasetCell = await within(table).findByText(
+ mockDatasets[0].table_name,
+ );
+ const datasetRow = datasetCell.closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+ await userEvent.click(within(datasetRow!).getByRole('checkbox'));
+
+ // Wait for selection to register before clicking Delete
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Find bulk delete button scoped to toolbar (avoids matching row delete buttons)
+ const deleteButton = await within(bulkSelectControls).findByRole('button', {
+ name: 'Delete',
+ });
+ await userEvent.click(deleteButton);
+
+ // Confirmation modal should appear - verify by stable anchors (delete-modal-input + bulk context)
+ const modal = await screen.findByRole('dialog');
+ expect(modal).toBeInTheDocument();
+ // delete-modal-input is unique to ConfirmStatusChange delete modals
+ expect(within(modal).getByTestId('delete-modal-input')).toBeInTheDocument();
+});
+
+test('exit bulk select via close button returns to normal view', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls container to appear first (fast query)
+ const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Note: Not verifying export/delete buttons here as they only appear after selection
+ // This test focuses on the close button functionality
+
+ // Find close button within the bulk select container
+ // antd 5.x Alert component renders close button with aria-label="Close"
+ const closeButton = within(bulkSelectControls).getByRole('button', {
+ name: /close/i,
+ });
+ await userEvent.click(closeButton);
+
+ // Wait for bulk select controls to be removed
+ // Using waitFor with queryBy - handles both "still visible" and "already gone" cases
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId('bulk-select-controls'),
+ ).not.toBeInTheDocument();
+ });
+
+ // Then verify normal toolbar is restored
+ expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
+ expect(
+ screen.getByRole('button', { name: /bulk select/i }),
+ ).toBeInTheDocument();
+}, 30000);
+
+test('certified badge appears for certified datasets', async () => {
+ const certifiedDataset = {
+ ...mockDatasets[1],
+ extra: JSON.stringify({
+ certification: {
+ certified_by: 'Data Team',
+ details: 'Approved for production',
+ },
+ }),
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [certifiedDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(certifiedDataset.table_name)).toBeInTheDocument();
+ });
+
+ // Find the dataset row
+ const row = screen.getByText(certifiedDataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Verify certified badge icon is present in the row
+ const certBadge = await within(row!).findByRole('img', {
+ name: /certified/i,
+ });
+ expect(certBadge).toBeInTheDocument();
+});
+
+test('warning icon appears for datasets with warnings', async () => {
+ const datasetWithWarning = {
+ ...mockDatasets[2],
+ extra: JSON.stringify({
+ warning_markdown: 'Contains PII',
+ }),
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetWithWarning], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(datasetWithWarning.table_name)).toBeInTheDocument();
+ });
+
+ // Find the dataset row
+ const row = screen.getByText(datasetWithWarning.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Verify warning icon is present in the row
+ const warningIcon = await within(row!).findByRole('img', {
+ name: /warning/i,
+ });
+ expect(warningIcon).toBeInTheDocument();
+});
+
+test('info tooltip appears for datasets with descriptions', async () => {
+ const datasetWithDescription = {
+ ...mockDatasets[0],
+ description: 'Sales data from Q4 2024',
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetWithDescription], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(datasetWithDescription.table_name),
+ ).toBeInTheDocument();
+ });
+
+ // Find the dataset row
+ const row = screen.getByText(datasetWithDescription.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Verify info tooltip icon is present in the row
+ const infoIcon = await within(row!).findByRole('img', { name: /info/i });
+ expect(infoIcon).toBeInTheDocument();
+});
+
+test('dataset name links to Explore page', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ // Find the dataset row and scope the link query to it
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Dataset name should be a link to Explore within the row
+ const link = within(row!).getByTestId('internal-link');
+ expect(link).toHaveAttribute('href', dataset.explore_url);
+});
+
+test('physical dataset shows delete, export, and edit actions (no duplicate)', async () => {
+ const physicalDataset = mockDatasets[0]; // kind: 'physical'
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [physicalDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument();
+ });
+
+ const row = screen.getByText(physicalDataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Physical datasets should have: delete, export, edit
+ const deleteButton = within(row!).getByTestId('delete');
+ const exportButton = within(row!).getByTestId('upload');
+ const editButton = within(row!).getByTestId('edit');
+
+ expect(deleteButton).toBeInTheDocument();
+ expect(exportButton).toBeInTheDocument();
+ expect(editButton).toBeInTheDocument();
+
+ // Should NOT have duplicate button
+ const duplicateButton = within(row!).queryByTestId('copy');
+ expect(duplicateButton).not.toBeInTheDocument();
+});
+
+test('virtual dataset shows delete, export, edit, and duplicate actions', async () => {
+ const virtualDataset = mockDatasets[1]; // kind: 'virtual'
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [virtualDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+ });
+
+ const row = screen.getByText(virtualDataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Virtual datasets should have: delete, export, edit, duplicate
+ const deleteButton = within(row!).getByTestId('delete');
+ const exportButton = within(row!).getByTestId('upload');
+ const editButton = within(row!).getByTestId('edit');
+ const duplicateButton = within(row!).getByTestId('copy');
+
+ expect(deleteButton).toBeInTheDocument();
+ expect(exportButton).toBeInTheDocument();
+ expect(editButton).toBeInTheDocument();
+ expect(duplicateButton).toBeInTheDocument();
+});
+
+test('edit action is enabled for dataset owner', async () => {
+ const dataset = {
+ ...mockDatasets[0],
+ owners: [{ id: mockAdminUser.userId, username: 'admin' }],
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ const editIcon = within(row!).getByTestId('edit');
+ const editButton = editIcon.closest('.action-button, .disabled');
+
+ // Should have action-button class (not disabled)
+ expect(editButton).toHaveClass('action-button');
+ expect(editButton).not.toHaveClass('disabled');
+});
+
+test('edit action is disabled for non-owner', async () => {
+ const dataset = {
+ ...mockDatasets[0],
+ owners: [{ id: 999, username: 'other_user' }], // Different user
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ // Use a non-admin user to test ownership check
+ const regularUser = {
+ ...mockAdminUser,
+ roles: { Admin: [['can_read', 'Dataset']] },
+ };
+
+ renderDatasetList(regularUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ const editIcon = within(row!).getByTestId('edit');
+ const editButton = editIcon.closest('.action-button, .disabled');
+
+ // Should have disabled class (disabled buttons still have 'action-button' class)
+ expect(editButton).toHaveClass('disabled');
+ expect(editButton).toHaveClass('action-button');
+});
+
+test('all action buttons are clickable and enabled for admin user', async () => {
+ const virtualDataset = {
+ ...mockDatasets[1],
+ owners: [{ id: mockAdminUser.userId, username: 'admin' }],
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [virtualDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+ });
+
+ const row = screen.getByText(virtualDataset.table_name).closest('tr');
+
+ // Get icons and their parent button elements
+ const deleteIcon = within(row!).getByTestId('delete');
+ const exportIcon = within(row!).getByTestId('upload');
+ const editIcon = within(row!).getByTestId('edit');
+ const duplicateIcon = within(row!).getByTestId('copy');
+
+ const deleteButton = deleteIcon.closest('.action-button, .disabled');
+ const exportButton = exportIcon.closest('.action-button, .disabled');
+ const editButton = editIcon.closest('.action-button, .disabled');
+ const duplicateButton = duplicateIcon.closest('.action-button, .disabled');
+
+ // All should have action-button class (enabled)
+ expect(deleteButton).toHaveClass('action-button');
+ expect(exportButton).toHaveClass('action-button');
+ expect(editButton).toHaveClass('action-button');
+ expect(duplicateButton).toHaveClass('action-button');
+
+ // None should be disabled
+ expect(deleteButton).not.toHaveClass('disabled');
+ expect(exportButton).not.toHaveClass('disabled');
+ expect(editButton).not.toHaveClass('disabled');
+ expect(duplicateButton).not.toHaveClass('disabled');
+});
+
+test('displays error when initial dataset fetch fails with 500', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { status: 500, body: { message: 'Internal Server Error' } },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: mockAddDangerToast,
+ });
+
+ // Error toast should be shown
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalled();
+ });
+
+ // No dataset names from mockDatasets should appear in the document
+ mockDatasets.forEach(dataset => {
+ expect(screen.queryByText(dataset.table_name)).not.toBeInTheDocument();
+ });
+});
+
+test('displays error when initial dataset fetch fails with 403 permission denied', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { status: 403, body: { message: 'Access Denied' } },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: mockAddDangerToast,
+ });
+
+ // Error toast should be shown for permission denied
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalled();
+ });
+
+ // Verify toast message contains the 403-specific "Access Denied" text
+ const toastMessage = String(mockAddDangerToast.mock.calls[0][0]);
+ expect(toastMessage).toContain('Access Denied');
+
+ // No dataset names from mockDatasets should appear in the document
+ mockDatasets.forEach(dataset => {
+ expect(screen.queryByText(dataset.table_name)).not.toBeInTheDocument();
+ });
+});
+
+test('dataset links use internal routing when PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET is enabled', async () => {
+ renderDatasetList(
+ mockAdminUser,
+ {},
+ {
+ common: {
+ conf: {
+ PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: true,
+ },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // When flag is enabled, links should use internal-link data-test attribute
+ const internalLinks = screen.getAllByTestId('internal-link');
+ expect(internalLinks.length).toBeGreaterThan(0);
+
+ // Each link should be a React Router Link (has href attribute)
+ internalLinks.forEach(link => {
+ expect(link).toHaveAttribute('href');
+ });
+});
+
+// Note: These delete error tests verify that the modal doesn't open when fetching
+// related_objects fails. The component's openDatasetDeleteModal error handler
+// (index.tsx:262-268) returns a string but doesn't call addDangerToast(), so no
+// error toast is shown. This is a component bug documented for a separate fix.
+// The tests correctly verify current behavior: API call made, modal prevented.
+
+test('delete action gracefully handles 403 forbidden error', async () => {
+ const dataset = mockDatasets[0];
+
+ setupErrorTestScenario({
+ dataset,
+ method: 'get',
+ endpoint: '/related_objects',
+ errorStatus: 403,
+ errorMessage: 'Failed to fetch related objects',
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+
+ await userEvent.click(deleteButton);
+
+ // Wait for SupersetClient.get to be called (deterministic anchor - API was attempted)
+ await waitFor(() => {
+ expect(SupersetClient.get).toHaveBeenCalledWith(
+ expect.objectContaining({
+ endpoint: expect.stringContaining('/related_objects'),
+ }),
+ );
+ });
+
+ // Verify modal did NOT open (error prevented it)
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ // Verify dataset still in list (not removed)
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+});
+
+test('delete action gracefully handles 500 internal server error', async () => {
+ const dataset = mockDatasets[0];
+
+ setupErrorTestScenario({
+ dataset,
+ method: 'get',
+ endpoint: '/related_objects',
+ errorStatus: 500,
+ errorMessage: 'Internal Server Error',
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+
+ await userEvent.click(deleteButton);
+
+ // Wait for SupersetClient.get to be called (deterministic anchor - API was attempted)
+ await waitFor(() => {
+ expect(SupersetClient.get).toHaveBeenCalledWith(
+ expect.objectContaining({
+ endpoint: expect.stringContaining('/related_objects'),
+ }),
+ );
+ });
+
+ // Verify modal did NOT open
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ // Verify table state unchanged
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+});
+
+test('duplicate action shows error toast on 403 forbidden', async () => {
+ const virtualDataset = {
+ ...mockDatasets[1],
+ owners: [
+ {
+ first_name: mockAdminUser.firstName,
+ last_name: mockAdminUser.lastName,
+ id: mockAdminUser.userId as number,
+ },
+ ],
+ };
+
+ setupErrorTestScenario({
+ dataset: virtualDataset,
+ method: 'post',
+ endpoint: '/duplicate',
+ errorStatus: 403,
+ errorMessage: 'Failed to duplicate dataset',
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const duplicateButton = await within(table).findByTestId('copy');
+
+ await userEvent.click(duplicateButton);
+
+ // Wait for duplicate modal to appear
+ const modal = await screen.findByRole('dialog');
+ expect(modal).toBeInTheDocument();
+
+ // Enter new dataset name
+ const input = within(modal).getByRole('textbox');
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Copy of Analytics Query');
+
+ // Submit duplicate
+ const submitButton = within(modal).getByRole('button', {
+ name: /duplicate/i,
+ });
+ await userEvent.click(submitButton);
+
+ // Wait for error toast first (deterministic anchor)
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/issue duplicating.*selected datasets/i),
+ );
+ });
+
+ // Then verify modal stays open on error (component doesn't close it on failure)
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Verify original dataset still in list
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+});
+
+test('duplicate action shows error toast on 500 internal server error', async () => {
+ const virtualDataset = {
+ ...mockDatasets[1],
+ owners: [
+ {
+ first_name: mockAdminUser.firstName,
+ last_name: mockAdminUser.lastName,
+ id: mockAdminUser.userId as number,
+ },
+ ],
+ };
+
+ setupErrorTestScenario({
+ dataset: virtualDataset,
+ method: 'post',
+ endpoint: '/duplicate',
+ errorStatus: 500,
+ errorMessage: 'Internal Server Error',
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const duplicateButton = await within(table).findByTestId('copy');
+
+ await userEvent.click(duplicateButton);
+
+ // Wait for duplicate modal
+ const modal = await screen.findByRole('dialog');
+
+ // Enter new dataset name
+ const input = within(modal).getByRole('textbox');
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Copy of Analytics Query');
+
+ // Submit
+ const submitButton = within(modal).getByRole('button', {
+ name: /duplicate/i,
+ });
+ await userEvent.click(submitButton);
+
+ // Wait for error toast first (deterministic anchor)
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/issue duplicating.*selected datasets/i),
+ );
+ });
+
+ // Then verify modal stays open on error (component doesn't close it on failure)
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Verify table state unchanged
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+});
+
+// Component "+1" Tests - State persistence through operations
+
+// This test is inherently slow due to userEvent.type() typing DELETE character-by-character
+// 30s timeout to handle CI variability
+test('sort order persists after deleting a dataset', async () => {
+ const datasetToDelete = mockDatasets[0];
+ setupDeleteMocks(datasetToDelete.id);
+
+ renderDatasetList(mockAdminUser, {
+ addSuccessToast: mockAddSuccessToast,
+ addDangerToast: mockAddDangerToast,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const nameHeader = within(table).getByRole('columnheader', {
+ name: /Name/i,
+ });
+
+ // Record initial API calls count
+ const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Click Name header to sort
+ await userEvent.click(nameHeader);
+
+ // Wait for new API call with sort parameter
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(initialCalls);
+ });
+
+ // Record the sort parameter from the API call after sorting
+ const callsAfterSort = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const sortedUrl = callsAfterSort[callsAfterSort.length - 1][0] as string;
+ expect(sortedUrl).toMatch(/order_column|sort/);
+
+ // Delete a dataset - get delete button from first row only
+ const firstRow = screen.getAllByRole('row')[1];
+ const deleteButton = within(firstRow).getByTestId('delete');
+ await userEvent.click(deleteButton);
+
+ // Confirm delete in modal - type DELETE to enable button
+ const modal = await screen.findByRole('dialog');
+ await within(modal).findByText(datasetToDelete.table_name);
+
+ // Enable the danger button by typing DELETE
+ const confirmInput = within(modal).getByTestId('delete-modal-input');
+ await userEvent.clear(confirmInput);
+ await userEvent.type(confirmInput, 'DELETE');
+
+ // Record call count before delete to track refetch
+ const callsBeforeDelete = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ const confirmButton = within(modal)
+ .getAllByRole('button', { name: /^delete$/i })
+ .pop();
+ await userEvent.click(confirmButton!);
+
+ // Confirm the delete request fired
+ await waitFor(() => {
+ expect(mockAddSuccessToast).toHaveBeenCalled();
+ });
+
+ // Wait for modal to close completely
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // Wait for list refetch to complete (prevents async cleanup error)
+ await waitFor(() => {
+ const currentCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+ expect(currentCalls).toBeGreaterThan(callsBeforeDelete);
+ });
+
+ // Re-query the header fresh (DOM may have been replaced on re-render)
+ // and assert the sort indicators still exist
+ await waitFor(() => {
+ const freshHeader = screen.getByRole('columnheader', { name: /Name/i });
+ const carets = within(freshHeader.closest('th')!).getAllByLabelText(
+ /caret/i,
+ );
+ expect(carets.length).toBeGreaterThan(0);
+ });
+}, 30000);
+
+// Note: "deleting last item on page 2 fetches page 1" is a hook-level pagination
+// concern (useListViewResource handles page reset logic). This is covered by
+// integration tests where we can verify the full pagination cycle.
+
+// Note: Full bulk delete workflow (select → delete → confirm → verify toast) is
+// tested in DatasetList.integration.test.tsx as it's a multi-component orchestration
+// test. Component tests here focus on individual behaviors.
+
+test('bulk selection clears when filter changes', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: mockDatasets, count: mockDatasets.length },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls container to appear first (fast query)
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select first dataset by name (scoped to table, async to avoid race)
+ const firstCell = await within(table).findByText(mockDatasets[0].table_name);
+ const firstRow = firstCell.closest('tr');
+ expect(firstRow).toBeInTheDocument();
+ await userEvent.click(within(firstRow!).getByRole('checkbox'));
+
+ // Wait for first selection to register before clicking second (prevents stale node)
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Select second dataset by name (scoped to table, async to avoid race)
+ const secondCell = await within(table).findByText(mockDatasets[1].table_name);
+ const secondRow = secondCell.closest('tr');
+ expect(secondRow).toBeInTheDocument();
+ await userEvent.click(within(secondRow!).getByRole('checkbox'));
+
+ // Wait for selections to register - assert specific count to avoid matching "0 Selected"
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /2 Selected/i,
+ );
+ });
+
+ // Record API call count before filter
+ const beforeFilterCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Wait for filter combobox to be ready before applying filter
+ await screen.findByRole('combobox', { name: 'Type' });
+
+ // Apply a filter using selectOption helper
+ await selectOption('Virtual', 'Type');
+
+ // Wait for filter API call to complete
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(beforeFilterCallCount);
+ });
+
+ // Verify filter was applied by decoding URL payload
+ const urlAfterFilter = fetchMock
+ .calls(API_ENDPOINTS.DATASETS)
+ .at(-1)?.[0] as string;
+ const risonAfterFilter = new URL(
+ urlAfterFilter,
+ 'http://localhost',
+ ).searchParams.get('q');
+ expect(risonAfterFilter).toBeTruthy();
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const decodedAfterFilter = rison.decode(risonAfterFilter!) as Record<
+ string,
+ unknown
+ >;
+ expect(decodedAfterFilter.filters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ col: 'sql', value: false }),
+ ]),
+ );
+
+ // Verify selection was cleared - count should show "0 Selected"
+ // Use findByText for better async handling
+ await screen.findByText(/0 selected/i);
+}, 45000); // Complex test with filter + selection operations
+
+test('type filter API call includes correct filter parameter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Wait for Type filter combobox
+ await screen.findByRole('combobox', { name: 'Type' });
+
+ // Snapshot call count before filter
+ const callsBeforeFilter = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Apply Type filter
+ await selectOption('Virtual', 'Type');
+
+ // Wait for filter API call to complete
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(callsBeforeFilter);
+ });
+
+ // Verify the latest API call includes the Type filter
+ const url = fetchMock.calls(API_ENDPOINTS.DATASETS).at(-1)?.[0] as string;
+ expect(url).toContain('filters');
+
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const risonPayload = new URL(url, 'http://localhost').searchParams.get('q');
+ expect(risonPayload).toBeTruthy();
+ const decoded = rison.decode(risonPayload!) as Record;
+ const filters = Array.isArray(decoded?.filters) ? decoded.filters : [];
+
+ // Type filter should be present (sql=false for Virtual datasets)
+ expect(filters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ col: 'sql', value: false }),
+ ]),
+ );
+});
+
+test('type filter persists after duplicating a dataset', async () => {
+ const datasetToDuplicate = mockDatasets.find(d => d.kind === 'virtual')!;
+
+ setupDuplicateMocks();
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Wait for Type filter combobox
+ await screen.findByRole('combobox', { name: 'Type' });
+
+ // Snapshot call count before filter
+ const callsBeforeFilter = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Apply Type filter
+ await selectOption('Virtual', 'Type');
+
+ // Wait for filter API call to complete
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(callsBeforeFilter);
+ });
+
+ // Verify filter is present by checking the latest API call
+ const urlAfterFilter = fetchMock
+ .calls(API_ENDPOINTS.DATASETS)
+ .at(-1)?.[0] as string;
+ const risonAfterFilter = new URL(
+ urlAfterFilter,
+ 'http://localhost',
+ ).searchParams.get('q');
+ expect(risonAfterFilter).toBeTruthy();
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const decodedAfterFilter = rison.decode(risonAfterFilter!) as Record<
+ string,
+ unknown
+ >;
+ expect(decodedAfterFilter.filters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ col: 'sql', value: false }),
+ ]),
+ );
+
+ // Capture datasets API call count BEFORE any duplicate operations
+ const datasetsCallCountBeforeDuplicate = fetchMock.calls(
+ API_ENDPOINTS.DATASETS,
+ ).length;
+
+ // Now duplicate the dataset
+ const row = screen.getByText(datasetToDuplicate.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ const duplicateIcon = await within(row!).findByTestId('copy');
+ const duplicateButton = duplicateIcon.closest(
+ '[role="button"]',
+ ) as HTMLElement | null;
+ expect(duplicateButton).toBeTruthy();
+
+ await userEvent.click(duplicateButton!);
+
+ const modal = await screen.findByRole('dialog');
+ const modalInput = within(modal).getByRole('textbox');
+ await userEvent.clear(modalInput);
+ await userEvent.type(modalInput, 'Copy of Dataset');
+
+ const confirmButton = within(modal).getByRole('button', {
+ name: /duplicate/i,
+ });
+ await userEvent.click(confirmButton);
+
+ // Wait for duplicate API call to be made
+ await waitFor(() => {
+ const duplicateCalls = fetchMock.calls(API_ENDPOINTS.DATASET_DUPLICATE);
+ expect(duplicateCalls.length).toBeGreaterThan(0);
+ });
+
+ // Wait for datasets refetch to occur (proves duplicate triggered a refresh)
+ await waitFor(() => {
+ const datasetsCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+ expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDuplicate);
+ });
+
+ // Verify Type filter persisted in the NEW datasets API call after duplication
+ const urlAfterDuplicate = fetchMock
+ .calls(API_ENDPOINTS.DATASETS)
+ .at(-1)?.[0] as string;
+ const risonAfterDuplicate = new URL(
+ urlAfterDuplicate,
+ 'http://localhost',
+ ).searchParams.get('q');
+ expect(risonAfterDuplicate).toBeTruthy();
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const decodedAfterDuplicate = rison.decode(risonAfterDuplicate!) as Record<
+ string,
+ unknown
+ >;
+ expect(decodedAfterDuplicate.filters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ col: 'sql', value: false }),
+ ]),
+ );
+});
+
+// Error Path Tests - Missing coverage for error handling flows
+
+test('edit action shows error toast when dataset fetch fails', async () => {
+ const dataset = mockDatasets[0];
+ // Make the dataset owned by admin so edit button is enabled
+ const ownedDataset = {
+ ...dataset,
+ owners: [
+ {
+ first_name: mockAdminUser.firstName,
+ last_name: mockAdminUser.lastName,
+ id: mockAdminUser.userId as number,
+ },
+ ],
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [ownedDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ // Mock SupersetClient.get to fail for the specific dataset endpoint
+ jest.spyOn(SupersetClient, 'get').mockImplementation(async request => {
+ if (request.endpoint?.includes(`/api/v1/dataset/${dataset.id}`)) {
+ throw buildSupersetClientError({
+ status: 500,
+ message: 'Failed to fetch dataset',
+ });
+ }
+ // Let other calls go through normally via fetchMock
+ const response = await fetch(request.endpoint!, { method: 'GET' });
+ return { json: await response.json(), response };
+ });
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: mockAddDangerToast,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(ownedDataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const editButton = await within(table).findByTestId('edit');
+
+ await userEvent.click(editButton);
+
+ // Wait for error toast
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/error.*fetching dataset/i),
+ );
+ });
+
+ // Verify edit modal did NOT open
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+test('bulk export error shows toast and clears loading state', async () => {
+ // Mock handleResourceExport to throw an error
+ mockHandleResourceExport.mockRejectedValueOnce(new Error('Export failed'));
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [mockDatasets[0]], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: mockAddDangerToast,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select row by dataset name (row-scoped query is more robust than array index)
+ const datasetRow = screen.getByText(mockDatasets[0].table_name).closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+ const rowCheckbox = within(datasetRow!).getByRole('checkbox');
+ await userEvent.click(rowCheckbox);
+
+ // Wait for selection to register before clicking Export (prevents race condition)
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Click bulk export
+ const exportButton = await screen.findByRole('button', { name: /export/i });
+ await userEvent.click(exportButton);
+
+ // Wait for error toast
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/issue exporting.*selected datasets/i),
+ );
+ });
+
+ // Verify loading state was cleared (preparingExport = false)
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+
+ // Verify export was called
+ expect(mockHandleResourceExport).toHaveBeenCalled();
+}, 30000);
+
+test('bulk delete error shows toast without refreshing list', async () => {
+ // Mock bulk delete to fail
+ fetchMock.delete(
+ API_ENDPOINTS.DATASET_BULK_DELETE,
+ { status: 500, body: { message: 'Bulk delete failed' } },
+ { overwriteRoutes: true },
+ );
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [mockDatasets[0]], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: mockAddDangerToast,
+ addSuccessToast: mockAddSuccessToast,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', { name: /bulk select/i });
+ await userEvent.click(bulkSelectButton);
+
+ // Wait for bulk select controls
+ const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select row by dataset name (row-scoped query is more robust than array index)
+ const datasetRow = screen.getByText(mockDatasets[0].table_name).closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+ const rowCheckbox = within(datasetRow!).getByRole('checkbox');
+ await userEvent.click(rowCheckbox);
+
+ // Wait for selection to register before clicking Delete (prevents race condition)
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Click bulk delete - scoped to toolbar to avoid row delete buttons
+ const bulkDeleteButton = await within(bulkSelectControls).findByRole(
+ 'button',
+ { name: 'Delete' },
+ );
+ await userEvent.click(bulkDeleteButton);
+
+ // Confirm delete in modal - type DELETE to enable button
+ const modal = await screen.findByRole('dialog');
+ const confirmInput = within(modal).getByTestId('delete-modal-input');
+ await userEvent.clear(confirmInput);
+ await userEvent.type(confirmInput, 'DELETE');
+
+ const confirmButton = within(modal)
+ .getAllByRole('button', { name: /^delete$/i })
+ .pop();
+ await userEvent.click(confirmButton!);
+
+ // Wait for error toast
+ await waitFor(() => {
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/issue deleting.*selected datasets/i),
+ );
+ });
+
+ // Verify success toast was NOT called
+ expect(mockAddSuccessToast).not.toHaveBeenCalled();
+
+ // Verify original dataset still in list
+ expect(screen.getByText(mockDatasets[0].table_name)).toBeInTheDocument();
+}, 30000);
+
+// Bulk Select Copy Tests - Verify count labels for different selection types
+
+test('bulk select shows "N Selected (Virtual)" for virtual-only selection', async () => {
+ // Use only virtual datasets
+ const virtualDatasets = mockDatasets.filter(d => d.kind === 'virtual');
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: virtualDatasets, count: virtualDatasets.length },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select first virtual dataset by name (row-scoped query is more robust than array index)
+ const virtualDataset = mockDatasets.find(d => d.kind === 'virtual')!;
+ const datasetRow = screen.getByText(virtualDataset.table_name).closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+ await userEvent.click(within(datasetRow!).getByRole('checkbox'));
+
+ // Wait for selection state to update, then verify label
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Verify the specific text shows "Virtual"
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected \(Virtual\)/i,
+ );
+}, 30000);
+
+test('bulk select shows "N Selected (Physical)" for physical-only selection', async () => {
+ // Use only physical datasets
+ const physicalDatasets = mockDatasets.filter(d => d.kind === 'physical');
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: physicalDatasets, count: physicalDatasets.length },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select first physical dataset by name (row-scoped query is more robust than array index)
+ const datasetRow = screen
+ .getByText(physicalDatasets[0].table_name)
+ .closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+ await userEvent.click(within(datasetRow!).getByRole('checkbox'));
+
+ // Wait for selection state to update, then verify label
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Verify the specific text shows "Physical"
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected \(Physical\)/i,
+ );
+}, 30000);
+
+test('bulk select shows mixed count for virtual and physical selection', async () => {
+ // Use a small mixed set - 1 physical + 1 virtual
+ const mixedDatasets = [
+ mockDatasets.find(d => d.kind === 'physical')!,
+ mockDatasets.find(d => d.kind === 'virtual')!,
+ ];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: mixedDatasets, count: mixedDatasets.length },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Enter bulk select mode
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ await userEvent.click(bulkSelectButton);
+
+ await screen.findByTestId('bulk-select-controls');
+
+ // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll)
+ const table = screen.getByTestId('listview-table');
+ await within(table).findAllByRole('checkbox');
+
+ // Select first dataset by name (row-scoped query is more robust than array index)
+ const firstRow = screen.getByText(mixedDatasets[0].table_name).closest('tr');
+ expect(firstRow).toBeInTheDocument();
+ await userEvent.click(within(firstRow!).getByRole('checkbox'));
+
+ // Wait for first selection to register before clicking second (prevents stale node)
+ await waitFor(() => {
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ /1 Selected/i,
+ );
+ });
+
+ // Select second dataset by name (row-scoped query)
+ const secondRow = screen.getByText(mixedDatasets[1].table_name).closest('tr');
+ expect(secondRow).toBeInTheDocument();
+ await userEvent.click(within(secondRow!).getByRole('checkbox'));
+
+ // Wait for second selection and verify mixed count
+ await waitFor(() => {
+ const bulkSelectCopy = screen.getByTestId('bulk-select-copy');
+ expect(bulkSelectCopy).toHaveTextContent(/2 Selected/i);
+ });
+
+ // Verify label shows both Physical and Virtual
+ const bulkSelectCopy = screen.getByTestId('bulk-select-copy');
+ expect(bulkSelectCopy).toHaveTextContent(/Physical/i);
+ expect(bulkSelectCopy).toHaveTextContent(/Virtual/i);
+}, 30000);
+
+// Delete Modal Related Objects Tests
+
+test('delete modal shows affected dashboards with overflow for >10 items', async () => {
+ const dataset = mockDatasets[0];
+
+ // Create mock with more than 10 dashboards
+ const manyDashboards = Array.from({ length: 15 }, (_, i) => ({
+ id: 200 + i,
+ title: `Dashboard ${i + 1}`,
+ }));
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ fetchMock.get(
+ `glob:*/api/v1/dataset/${dataset.id}/related_objects*`,
+ {
+ charts: { count: 0, result: [] },
+ dashboards: { count: 15, result: manyDashboards },
+ },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+ await userEvent.click(deleteButton);
+
+ // Wait for modal
+ const modal = await screen.findByRole('dialog');
+
+ // Verify "Affected Dashboards" header
+ expect(within(modal).getByText('Affected Dashboards')).toBeInTheDocument();
+
+ // Verify first 10 dashboards are shown
+ expect(within(modal).getByText('Dashboard 1')).toBeInTheDocument();
+ expect(within(modal).getByText('Dashboard 10')).toBeInTheDocument();
+
+ // Verify overflow message
+ expect(within(modal).getByText(/\.\.\. and 5 others/)).toBeInTheDocument();
+
+ // Verify Dashboard 11+ are NOT shown
+ expect(within(modal).queryByText('Dashboard 11')).not.toBeInTheDocument();
+});
+
+test('delete modal hides affected dashboards section when count is zero', async () => {
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ fetchMock.get(
+ `glob:*/api/v1/dataset/${dataset.id}/related_objects*`,
+ {
+ charts: { count: 2, result: [{ id: 1, slice_name: 'Chart 1' }] },
+ dashboards: { count: 0, result: [] },
+ },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+ await userEvent.click(deleteButton);
+
+ // Wait for modal
+ const modal = await screen.findByRole('dialog');
+
+ // Verify "Affected Dashboards" header is NOT present
+ expect(
+ within(modal).queryByText('Affected Dashboards'),
+ ).not.toBeInTheDocument();
+
+ // But "Affected Charts" should still be shown
+ expect(within(modal).getByText('Affected Charts')).toBeInTheDocument();
+});
+
+test('delete modal shows affected charts with overflow for >10 items', async () => {
+ const dataset = mockDatasets[0];
+
+ // Create mock with more than 10 charts
+ const manyCharts = Array.from({ length: 12 }, (_, i) => ({
+ id: 100 + i,
+ slice_name: `Chart ${i + 1}`,
+ }));
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ fetchMock.get(
+ `glob:*/api/v1/dataset/${dataset.id}/related_objects*`,
+ {
+ charts: { count: 12, result: manyCharts },
+ dashboards: { count: 0, result: [] },
+ },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const table = screen.getByTestId('listview-table');
+ const deleteButton = await within(table).findByTestId('delete');
+ await userEvent.click(deleteButton);
+
+ // Wait for modal
+ const modal = await screen.findByRole('dialog');
+
+ // Verify "Affected Charts" header
+ expect(within(modal).getByText('Affected Charts')).toBeInTheDocument();
+
+ // Verify first 10 charts are shown
+ expect(within(modal).getByText('Chart 1')).toBeInTheDocument();
+ expect(within(modal).getByText('Chart 10')).toBeInTheDocument();
+
+ // Verify overflow message
+ expect(within(modal).getByText(/\.\.\. and 2 others/)).toBeInTheDocument();
+
+ // Verify Chart 11+ are NOT shown
+ expect(within(modal).queryByText('Chart 11')).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx
new file mode 100644
index 00000000000..a997ff9a551
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx
@@ -0,0 +1,414 @@
+/**
+ * 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 { act, screen, waitFor, within } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+import {
+ setupMocks,
+ setupApiPermissions,
+ renderDatasetList,
+ mockAdminUser,
+ mockReadOnlyUser,
+ mockWriteUser,
+ mockExportOnlyUser,
+ mockDatasets,
+ API_ENDPOINTS,
+} from './DatasetList.testHelpers';
+
+// Increase default timeout for tests that involve multiple async operations
+// CI parallel load can cause timeouts with default 15s
+jest.setTimeout(30000);
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(async () => {
+ // Flush pending React state updates within act() to prevent warnings
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ // Restore real timers in case a test threw early
+ jest.useRealTimers();
+
+ // Reset browser history to prevent query param leakage
+ window.history.replaceState({}, '', '/');
+
+ fetchMock.resetHistory();
+ fetchMock.restore();
+ jest.restoreAllMocks();
+});
+
+test('admin users see all UI elements', async () => {
+ // Setup API with full admin permissions
+ setupApiPermissions(['can_read', 'can_write', 'can_export', 'can_duplicate']);
+
+ renderDatasetList(mockAdminUser);
+
+ expect(await screen.findByText('Datasets')).toBeInTheDocument();
+
+ // Admin should see create button
+ expect(
+ screen.getByRole('button', { name: /(?:plus\s*)?Dataset$/i }),
+ ).toBeInTheDocument();
+
+ // Admin should see import button
+ // Note: Using testId - import button lacks accessible text content
+ // TODO: Add aria-label or text to import button
+ expect(screen.getByTestId('import-button')).toBeInTheDocument();
+
+ // Admin should see bulk select button
+ expect(
+ screen.getByRole('button', { name: /bulk select/i }),
+ ).toBeInTheDocument();
+
+ // Admin should see actions column - wait for table first, then check column
+ const table = await screen.findByTestId('listview-table');
+ await waitFor(() => {
+ expect(
+ within(table).getByRole('columnheader', { name: /Actions/i }),
+ ).toBeInTheDocument();
+ });
+}, 45000);
+
+test('read-only users cannot see Actions column', async () => {
+ // Setup API with read-only permissions
+ setupApiPermissions(['can_read']);
+
+ renderDatasetList(mockReadOnlyUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ const table = screen.getByTestId('listview-table');
+ // Actions column should not be present
+ expect(within(table).queryByText(/Actions/i)).not.toBeInTheDocument();
+ });
+});
+
+test('read-only users cannot see bulk select button', async () => {
+ // Setup API with read-only permissions
+ setupApiPermissions(['can_read']);
+
+ renderDatasetList(mockReadOnlyUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ // Bulk select should not be visible
+ expect(
+ screen.queryByRole('button', { name: /bulk select/i }),
+ ).not.toBeInTheDocument();
+});
+
+test('read-only users cannot see Create/Import buttons', async () => {
+ // Setup API with read-only permissions
+ setupApiPermissions(['can_read']);
+
+ renderDatasetList(mockReadOnlyUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ // Create button should not be visible
+ expect(
+ screen.queryByRole('button', { name: /(?:plus\s*)?Dataset$/i }),
+ ).not.toBeInTheDocument();
+
+ // Import button should not be visible
+ // Note: Using testId - import button lacks accessible text content
+ // TODO: Add aria-label or text to import button
+ expect(screen.queryByTestId('import-button')).not.toBeInTheDocument();
+});
+
+test('write users see Actions column', async () => {
+ // Setup API with write permissions
+ setupApiPermissions(['can_read', 'can_write', 'can_export']);
+
+ renderDatasetList(mockWriteUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ const table = screen.getByTestId('listview-table');
+ expect(
+ within(table).getByRole('columnheader', { name: /Actions/i }),
+ ).toBeInTheDocument();
+ });
+});
+
+test('write users see bulk select button', async () => {
+ // Setup API with write permissions
+ setupApiPermissions(['can_read', 'can_write', 'can_export']);
+
+ renderDatasetList(mockWriteUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByRole('button', { name: /bulk select/i }),
+ ).toBeInTheDocument();
+});
+
+test('write users see Create/Import buttons', async () => {
+ // Setup API with write permissions
+ setupApiPermissions(['can_read', 'can_write', 'can_export']);
+
+ renderDatasetList(mockWriteUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ // Create button should be visible
+ expect(
+ screen.getByRole('button', { name: /(?:plus\s*)?Dataset$/i }),
+ ).toBeInTheDocument();
+
+ // Import button should be visible
+ // Note: Using testId - import button lacks accessible text content
+ // TODO: Add aria-label or text to import button
+ expect(screen.getByTestId('import-button')).toBeInTheDocument();
+});
+
+test('export-only users see bulk select (for export only)', async () => {
+ // Setup API with export-only permissions
+ setupApiPermissions(['can_read', 'can_export']);
+
+ renderDatasetList(mockExportOnlyUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ // Export users should see bulk select for export functionality
+ expect(
+ screen.getByRole('button', { name: /bulk select/i }),
+ ).toBeInTheDocument();
+});
+
+test('export-only users cannot see Create/Import buttons', async () => {
+ // Setup API with export-only permissions
+ setupApiPermissions(['can_read', 'can_export']);
+
+ renderDatasetList(mockExportOnlyUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ });
+
+ // Create and Import should not be visible for export-only users
+ expect(
+ screen.queryByRole('button', { name: /(?:plus\s*)?Dataset$/i }),
+ ).not.toBeInTheDocument();
+ // Note: Using testId - import button lacks accessible text content
+ // TODO: Add aria-label or text to import button
+ expect(screen.queryByTestId('import-button')).not.toBeInTheDocument();
+});
+
+test('action buttons respect user permissions', async () => {
+ // Setup API with full admin permissions
+ setupApiPermissions(['can_read', 'can_write', 'can_export', 'can_duplicate']);
+
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ // Admin should see action buttons in the row
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Verify specific action buttons are present
+ const deleteButton = within(row!).queryByTestId('delete');
+ const exportButton = within(row!).queryByTestId('upload');
+
+ expect(deleteButton).toBeInTheDocument();
+ expect(exportButton).toBeInTheDocument();
+});
+
+test('read-only user sees no delete or duplicate buttons in row', async () => {
+ // Setup API with read-only permissions
+ setupApiPermissions(['can_read']);
+
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockReadOnlyUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ // Find the dataset row
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Verify no delete button in the row
+ const deleteButton = within(row!).queryByTestId('delete');
+ expect(deleteButton).not.toBeInTheDocument();
+
+ // Verify no duplicate button (Actions column should not exist)
+ const duplicateButton = within(row!).queryByTestId('copy');
+ expect(duplicateButton).not.toBeInTheDocument();
+
+ // Verify no edit button
+ const editButton = within(row!).queryByTestId('edit');
+ expect(editButton).not.toBeInTheDocument();
+});
+
+test('write user sees edit, delete, and export actions', async () => {
+ // Setup API with write permissions (includes delete)
+ // Note: can_write grants both edit and delete permissions in DatasetList
+ setupApiPermissions(['can_read', 'can_write', 'can_export']);
+
+ const dataset = {
+ ...mockDatasets[0],
+ owners: [{ id: mockWriteUser.userId, username: 'writeuser' }],
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockWriteUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Should have delete button (can_write includes delete)
+ const deleteButton = within(row!).getByTestId('delete');
+ expect(deleteButton).toBeInTheDocument();
+
+ // Should have export button
+ const exportButton = within(row!).getByTestId('upload');
+ expect(exportButton).toBeInTheDocument();
+
+ // Should have edit button (user is owner)
+ const editButton = within(row!).getByTestId('edit');
+ expect(editButton).toBeInTheDocument();
+
+ // Should NOT have duplicate button (no can_duplicate permission)
+ const duplicateButton = within(row!).queryByTestId('copy');
+ expect(duplicateButton).not.toBeInTheDocument();
+});
+
+test('export-only user has no Actions column (no write/duplicate permissions)', async () => {
+ // Setup API with export-only permissions
+ // Note: Export action alone doesn't render Actions column - it's in toolbar/bulk select
+ setupApiPermissions(['can_read', 'can_export']);
+
+ const dataset = mockDatasets[0];
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockExportOnlyUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ const row = screen.getByText(dataset.table_name).closest('tr');
+ expect(row).toBeInTheDocument();
+
+ // Actions column is hidden when user only has export permission
+ // (export is available via bulk select toolbar, not row actions)
+ const deleteButton = within(row!).queryByTestId('delete');
+ expect(deleteButton).not.toBeInTheDocument();
+
+ const editButton = within(row!).queryByTestId('edit');
+ expect(editButton).not.toBeInTheDocument();
+
+ const duplicateButton = within(row!).queryByTestId('copy');
+ expect(duplicateButton).not.toBeInTheDocument();
+
+ const exportButton = within(row!).queryByTestId('upload');
+ expect(exportButton).not.toBeInTheDocument();
+});
+
+test('user with can_duplicate sees duplicate button only for virtual datasets', async () => {
+ // Setup API with duplicate permission
+ setupApiPermissions(['can_read', 'can_duplicate']);
+
+ const physicalDataset = mockDatasets[0]; // kind: 'physical'
+ const virtualDataset = mockDatasets[1]; // kind: 'virtual'
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [physicalDataset, virtualDataset], count: 2 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument();
+ });
+
+ // Check physical dataset row
+ const physicalRow = screen
+ .getByText(physicalDataset.table_name)
+ .closest('tr');
+ expect(physicalRow).toBeInTheDocument();
+
+ // Physical dataset should NOT have duplicate button
+ const physicalDuplicateButton = within(physicalRow!).queryByTestId('copy');
+ expect(physicalDuplicateButton).not.toBeInTheDocument();
+
+ // Check virtual dataset row
+ const virtualRow = screen.getByText(virtualDataset.table_name).closest('tr');
+ expect(virtualRow).toBeInTheDocument();
+
+ // Virtual dataset SHOULD have duplicate button
+ const virtualDuplicateButton = within(virtualRow!).getByTestId('copy');
+ expect(virtualDuplicateButton).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx
new file mode 100644
index 00000000000..51ad4cbab7a
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx
@@ -0,0 +1,567 @@
+/**
+ * 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 { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import rison from 'rison';
+import fetchMock from 'fetch-mock';
+import {
+ setupMocks,
+ renderDatasetList,
+ waitForDatasetsPageReady,
+ mockAdminUser,
+ mockReadOnlyUser,
+ mockExportOnlyUser,
+ mockDatasets,
+ mockApiError403,
+ API_ENDPOINTS,
+ RisonFilter,
+} from './DatasetList.testHelpers';
+
+// Increase default timeout for tests that involve multiple async operations
+jest.setTimeout(15000);
+
+beforeEach(() => {
+ setupMocks();
+});
+
+afterEach(async () => {
+ // Flush pending React state updates within act() to prevent warnings
+ // and "document global undefined" errors from async operations
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ // Restore real timers in case a test using fake timers threw early
+ jest.useRealTimers();
+
+ // Reset browser history state to prevent query params leaking between tests
+ window.history.replaceState({}, '', '/');
+
+ fetchMock.resetHistory();
+ fetchMock.restore();
+ jest.restoreAllMocks();
+});
+
+test('renders page with "Datasets" title', async () => {
+ renderDatasetList(mockAdminUser);
+
+ const title = await screen.findByText('Datasets');
+ expect(title).toBeInTheDocument();
+});
+
+test('shows loading state during initial data fetch', () => {
+ // Use fake timers to avoid leaving real timers running after test
+ jest.useFakeTimers();
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ new Promise(resolve =>
+ setTimeout(() => resolve({ result: [], count: 0 }), 10000),
+ ),
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+
+ jest.useRealTimers();
+});
+
+test('maintains component structure during loading', () => {
+ // Use fake timers to avoid leaving real timers running after test
+ jest.useFakeTimers();
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ new Promise(resolve =>
+ setTimeout(() => resolve({ result: [], count: 0 }), 10000),
+ ),
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+
+ jest.useRealTimers();
+});
+
+test('"New Dataset" button exists (when canCreate=true)', async () => {
+ renderDatasetList(mockAdminUser);
+
+ // Button has plus icon and "Dataset" text. Pattern handles both:
+ // - "plus Dataset" (if icon contributes to accessible name)
+ // - "Dataset" (if icon is aria-hidden)
+ // The $ anchor prevents matching future "Import Dataset" button.
+ expect(
+ await screen.findByRole('button', { name: /(?:plus\s*)?Dataset$/i }),
+ ).toBeInTheDocument();
+});
+
+test('"New Dataset" button hidden (when canCreate=false)', async () => {
+ renderDatasetList(mockReadOnlyUser);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('button', { name: /(?:plus\s*)?Dataset$/i }),
+ ).not.toBeInTheDocument();
+ });
+});
+
+test('"Import" button exists (when canCreate=true)', async () => {
+ renderDatasetList(mockAdminUser);
+
+ // Note: Using testId - import button lacks accessible text content
+ // TODO: Add aria-label or text to import button
+ expect(await screen.findByTestId('import-button')).toBeInTheDocument();
+});
+
+test('"Import" button opens import modal', async () => {
+ renderDatasetList(mockAdminUser);
+
+ // Note: Using testId - import button lacks accessible text content
+ // TODO: Add aria-label or text to import button
+ const importButton = await screen.findByTestId('import-button');
+ expect(importButton).toBeInTheDocument();
+
+ await userEvent.click(importButton);
+
+ // Modal should appear with title - using semantic query here
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Import dataset')).toBeInTheDocument();
+});
+
+test('"Bulk select" button exists (when canDelete || canExport)', async () => {
+ renderDatasetList(mockAdminUser);
+
+ expect(
+ await screen.findByRole('button', { name: /bulk select/i }),
+ ).toBeInTheDocument();
+});
+
+test('"Bulk select" button exists for export-only users', async () => {
+ renderDatasetList(mockExportOnlyUser);
+
+ // Wait for table to render first (deterministic readiness check)
+ await screen.findByTestId('listview-table');
+
+ expect(
+ await screen.findByRole('button', { name: /bulk select/i }),
+ ).toBeInTheDocument();
+});
+
+test('"Bulk select" button hidden for read-only users', async () => {
+ renderDatasetList(mockReadOnlyUser);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('button', { name: /bulk select/i }),
+ ).not.toBeInTheDocument();
+ });
+});
+
+test('renders Name search filter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ // Note: Using testId - search input lacks accessible label
+ // TODO: Add aria-label to search input
+ expect(
+ await screen.findByTestId('search-filter-container'),
+ ).toBeInTheDocument();
+});
+
+test('renders Type filter (Virtual/Physical dropdown)', async () => {
+ renderDatasetList(mockAdminUser);
+
+ // Filter dropdowns should be present
+ const filters = await screen.findAllByRole('combobox');
+ expect(filters.length).toBeGreaterThan(0);
+});
+
+test('handles datasets with missing fields and renders gracefully', async () => {
+ const datasetWithMissingFields = {
+ id: 999,
+ table_name: 'Incomplete Dataset',
+ kind: 'physical',
+ schema: null,
+ database: {
+ id: '1',
+ database_name: 'PostgreSQL',
+ },
+ owners: [],
+ changed_by_name: 'Unknown',
+ changed_by: null,
+ changed_on_delta_humanized: 'Unknown',
+ explore_url: '/explore/?datasource=999__table',
+ extra: JSON.stringify({}),
+ sql: null,
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetWithMissingFields], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText('Incomplete Dataset')).toBeInTheDocument();
+ });
+
+ // Verify empty owners renders without crashing (no FacePile)
+ const table = screen.getByRole('table');
+ expect(table).toBeInTheDocument();
+
+ // Verify the row exists even with missing data
+ const datasetRow = screen.getByText('Incomplete Dataset').closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+
+ // Verify no certification badge or warning icon (extra is empty)
+ expect(
+ screen.queryByRole('img', { name: /certified/i }),
+ ).not.toBeInTheDocument();
+});
+
+test('handles empty results (shows empty state)', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [], count: 0 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ // Datasets heading should still be present
+ expect(await screen.findByText('Datasets')).toBeInTheDocument();
+});
+
+test('makes correct initial API call on load', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(0);
+ });
+});
+
+test('API call includes correct page size', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(0);
+ const url = calls[0][0] as string;
+ expect(url).toContain('page_size');
+ });
+});
+
+test('typing in name filter updates input value and triggers API with decoded search filter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ const searchContainer = await screen.findByTestId('search-filter-container');
+ const searchInput = within(searchContainer).getByRole('textbox');
+
+ // Record initial API calls
+ const initialCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // Type in search box and press Enter to trigger search
+ await userEvent.type(searchInput, 'sales{enter}');
+
+ // Verify input value updated
+ await waitFor(() => {
+ expect(searchInput).toHaveValue('sales');
+ });
+
+ // Wait for API call after Enter key press
+ await waitFor(
+ () => {
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ expect(calls.length).toBeGreaterThan(initialCallCount);
+
+ // Get latest API call
+ const url = calls[calls.length - 1][0] as string;
+
+ // Verify URL contains search filter
+ expect(url).toContain('filters');
+
+ // Extract and decode rison query param using URL parser
+ const queryString = new URL(url, 'http://localhost').searchParams.get(
+ 'q',
+ );
+ expect(queryString).toBeTruthy();
+
+ // searchParams.get() already URL-decodes, so pass directly to rison.decode
+ const decoded = rison.decode(queryString!) as Record;
+
+ // Verify filter structure contains table_name search
+ expect(decoded.filters).toBeDefined();
+ expect(Array.isArray(decoded.filters)).toBe(true);
+
+ // Check for sales filter in the filters array
+ const filters = decoded.filters as RisonFilter[];
+ const hasSalesFilter = filters.some(
+ (filter: RisonFilter) =>
+ filter.col === 'table_name' &&
+ filter.opr === 'ct' &&
+ typeof filter.value === 'string' &&
+ filter.value.toLowerCase().includes('sales'),
+ );
+ expect(hasSalesFilter).toBe(true);
+ },
+ { timeout: 5000 },
+ );
+});
+
+test('toggling bulk select mode shows checkboxes', async () => {
+ renderDatasetList(mockAdminUser);
+
+ const bulkSelectButton = await screen.findByRole('button', {
+ name: /bulk select/i,
+ });
+
+ await userEvent.click(bulkSelectButton);
+
+ await waitFor(() => {
+ // When bulk select is active, checkboxes should appear
+ const checkboxes = screen.queryAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+}, 30000);
+
+test('handles 500 error on initial load without crashing', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { throws: new Error('Internal Server Error') },
+ {
+ overwriteRoutes: true,
+ },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: jest.fn(),
+ addSuccessToast: jest.fn(),
+ });
+
+ // Component should still render without crashing
+ const title = await screen.findByText('Datasets');
+ expect(title).toBeInTheDocument();
+});
+
+test('handles 403 error on _info endpoint and disables create actions', async () => {
+ const addDangerToast = jest.fn();
+
+ fetchMock.get(API_ENDPOINTS.DATASETS_INFO, mockApiError403, {
+ overwriteRoutes: true,
+ });
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast,
+ addSuccessToast: jest.fn(),
+ });
+
+ await waitForDatasetsPageReady();
+
+ // Verify bulk actions are disabled/hidden when permissions fail
+ await waitFor(() => {
+ const bulkSelectButton = screen.queryByRole('button', {
+ name: /bulk select/i,
+ });
+ // Bulk select should not appear without proper permissions
+ expect(bulkSelectButton).not.toBeInTheDocument();
+ });
+});
+
+test('handles network timeout without crashing', async () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { throws: new Error('Network timeout') },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: jest.fn(),
+ addSuccessToast: jest.fn(),
+ });
+
+ // Component should not crash
+ const title = await screen.findByText('Datasets');
+ expect(title).toBeInTheDocument();
+});
+
+test('component requires explicit mocks for all API endpoints', async () => {
+ // Use standard mocks
+ setupMocks();
+
+ // Clear call history to start fresh
+ fetchMock.resetHistory();
+
+ // Render component with standard setup
+ renderDatasetList(mockAdminUser);
+
+ // Wait for initial data load
+ await waitForDatasetsPageReady();
+
+ // Verify that critical endpoints were called and had mocks available
+ const newDatasetsCalls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const newInfoCalls = fetchMock.calls(API_ENDPOINTS.DATASETS_INFO);
+
+ // These should have been called during render
+ expect(newDatasetsCalls.length).toBeGreaterThan(0);
+ expect(newInfoCalls.length).toBeGreaterThan(0);
+
+ // Verify no unmatched calls (all endpoints were mocked)
+ const unmatchedCalls = fetchMock.calls(false); // false = unmatched only
+ expect(unmatchedCalls.length).toBe(0);
+});
+
+test('selecting Database filter triggers API call with database relation filter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitForDatasetsPageReady();
+
+ const filtersContainers = screen.getAllByRole('combobox');
+ expect(filtersContainers.length).toBeGreaterThan(0);
+});
+
+test('renders datasets with certification data', async () => {
+ const certifiedDataset = {
+ ...mockDatasets[1], // mockDatasets[1] has certification
+ extra: JSON.stringify({
+ certification: {
+ certified_by: 'Data Team',
+ details: 'Approved for production',
+ },
+ }),
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [certifiedDataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(certifiedDataset.table_name)).toBeInTheDocument();
+ });
+
+ // Verify the dataset row renders successfully
+ const datasetRow = screen
+ .getByText(certifiedDataset.table_name)
+ .closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+});
+
+test('displays datasets with warning_markdown', async () => {
+ const warningText = 'This dataset contains PII. Handle with care.';
+ const datasetWithWarning = {
+ ...mockDatasets[2], // mockDatasets[2] has warning
+ extra: JSON.stringify({
+ warning_markdown: warningText,
+ }),
+ };
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetWithWarning], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(datasetWithWarning.table_name)).toBeInTheDocument();
+ });
+
+ // Verify the dataset row exists
+ const datasetRow = screen
+ .getByText(datasetWithWarning.table_name)
+ .closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+});
+
+test('displays dataset with multiple owners', async () => {
+ const datasetWithOwners = mockDatasets[1]; // Has 2 owners: Jane Smith, Bob Jones
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetWithOwners], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(datasetWithOwners.table_name)).toBeInTheDocument();
+ });
+
+ // Verify row exists with the dataset
+ const datasetRow = screen
+ .getByText(datasetWithOwners.table_name)
+ .closest('tr');
+ expect(datasetRow).toBeInTheDocument();
+});
+
+test('displays ModifiedInfo with humanized date', async () => {
+ const datasetWithModified = mockDatasets[0]; // changed_by_name: 'John Doe', changed_on: '1 day ago'
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [datasetWithModified], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(datasetWithModified.table_name),
+ ).toBeInTheDocument();
+ });
+
+ // Verify humanized date appears (ModifiedInfo component renders it)
+ expect(
+ screen.getByText(datasetWithModified.changed_on_delta_humanized),
+ ).toBeInTheDocument();
+});
+
+test('dataset name links to Explore with correct explore_url', async () => {
+ const dataset = mockDatasets[0]; // explore_url: '/explore/?datasource=1__table'
+
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ { result: [dataset], count: 1 },
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+ });
+
+ // Find the dataset name link (should be a link role)
+ const exploreLink = screen.getByRole('link', { name: dataset.table_name });
+ expect(exploreLink).toBeInTheDocument();
+ expect(exploreLink).toHaveAttribute('href', dataset.explore_url);
+});
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.testHelpers.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.testHelpers.tsx
new file mode 100644
index 00000000000..844ad8f9163
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.testHelpers.tsx
@@ -0,0 +1,539 @@
+/**
+ * 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 { 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 DatasetList from 'src/pages/DatasetList';
+import handleResourceExport from 'src/utils/export';
+
+export const mockHandleResourceExport =
+ handleResourceExport as jest.MockedFunction;
+
+// Type definitions for test helpers
+export interface UserState {
+ userId: string | number;
+ firstName: string;
+ lastName: string;
+ [key: string]: unknown; // Allow additional properties like roles
+}
+
+export interface RisonFilter {
+ col: string;
+ opr: string;
+ value: string | number | boolean;
+}
+
+// Test-only dataset type that matches the VirtualDataset interface from index.tsx
+// Includes extra/sql fields that exist in actual API responses
+export interface DatasetFixture {
+ id: number;
+ table_name: string;
+ kind: string;
+ schema: string;
+ database: {
+ id: string;
+ database_name: string;
+ };
+ owners: Array<{ first_name: string; last_name: string; id: number }>;
+ changed_by_name: string;
+ changed_by: {
+ first_name: string;
+ last_name: string;
+ id: number;
+ };
+ changed_on_delta_humanized: string;
+ explore_url: string;
+ extra: string; // JSON-serialized metadata (always present in API)
+ sql: string | null; // SQL query for virtual datasets
+ description?: string; // Optional description field
+}
+
+interface StoreState {
+ user?: UserState;
+ common?: {
+ conf?: {
+ SUPERSET_WEBSERVER_TIMEOUT?: number;
+ PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET?: boolean;
+ };
+ };
+ datasets?: {
+ datasetList?: typeof mockDatasets;
+ };
+}
+
+interface DatasetListPropsOverrides {
+ addDangerToast?: (msg: string) => void;
+ addSuccessToast?: (msg: string) => void;
+ user?: UserState;
+}
+
+export const mockDatasets: DatasetFixture[] = [
+ {
+ id: 1,
+ table_name: 'public.sales_data',
+ kind: 'physical',
+ schema: 'public',
+ database: {
+ id: '1',
+ database_name: 'PostgreSQL',
+ },
+ owners: [{ first_name: 'John', last_name: 'Doe', id: 1 }],
+ changed_by_name: 'John Doe',
+ changed_by: {
+ first_name: 'John',
+ last_name: 'Doe',
+ id: 1,
+ },
+ changed_on_delta_humanized: '1 day ago',
+ explore_url: '/explore/?datasource=1__table',
+ extra: JSON.stringify({}),
+ sql: null,
+ },
+ {
+ id: 2,
+ table_name: 'Analytics Query',
+ kind: 'virtual',
+ schema: 'analytics',
+ database: {
+ id: '2',
+ database_name: 'MySQL',
+ },
+ owners: [
+ { first_name: 'Jane', last_name: 'Smith', id: 2 },
+ { first_name: 'Bob', last_name: 'Jones', id: 3 },
+ ],
+ changed_by_name: 'Jane Smith',
+ changed_by: {
+ first_name: 'Jane',
+ last_name: 'Smith',
+ id: 2,
+ },
+ changed_on_delta_humanized: '2 hours ago',
+ explore_url: '/explore/?datasource=2__table',
+ extra: JSON.stringify({
+ certification: {
+ certified_by: 'Data Team',
+ details: 'Approved for production use',
+ },
+ }),
+ sql: 'SELECT * FROM analytics_table WHERE date >= current_date - 30',
+ },
+ {
+ id: 3,
+ table_name: 'Customer Metrics',
+ kind: 'virtual',
+ schema: 'metrics',
+ database: {
+ id: '1',
+ database_name: 'PostgreSQL',
+ },
+ owners: [],
+ changed_by_name: 'System',
+ changed_by: {
+ first_name: 'System',
+ last_name: 'User',
+ id: 999,
+ },
+ changed_on_delta_humanized: '5 days ago',
+ explore_url: '/explore/?datasource=3__table',
+ extra: JSON.stringify({
+ warning_markdown: 'This dataset contains PII. Handle with care.',
+ }),
+ sql: 'SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id',
+ },
+ {
+ id: 4,
+ table_name: 'public.product_catalog',
+ kind: 'physical',
+ schema: 'public',
+ database: {
+ id: '3',
+ database_name: 'Redshift',
+ },
+ owners: [{ first_name: 'Alice', last_name: 'Johnson', id: 4 }],
+ changed_by_name: 'Alice Johnson',
+ changed_by: {
+ first_name: 'Alice',
+ last_name: 'Johnson',
+ id: 4,
+ },
+ changed_on_delta_humanized: '3 weeks ago',
+ explore_url: '/explore/?datasource=4__table',
+ extra: JSON.stringify({
+ certification: {
+ certified_by: 'QA Team',
+ details: 'Verified data quality',
+ },
+ warning_markdown: 'Data refreshed weekly on Sundays',
+ }),
+ sql: null,
+ },
+ {
+ id: 5,
+ table_name: 'Quarterly Report',
+ kind: 'virtual',
+ schema: 'reports',
+ database: {
+ id: '2',
+ database_name: 'MySQL',
+ },
+ owners: [
+ { first_name: 'Charlie', last_name: 'Brown', id: 5 },
+ { first_name: 'David', last_name: 'Lee', id: 6 },
+ { first_name: 'Eve', last_name: 'Taylor', id: 7 },
+ { first_name: 'Frank', last_name: 'Wilson', id: 8 },
+ ],
+ changed_by_name: 'Charlie Brown',
+ changed_by: {
+ first_name: 'Charlie',
+ last_name: 'Brown',
+ id: 5,
+ },
+ changed_on_delta_humanized: '1 month ago',
+ explore_url: '/explore/?datasource=5__table',
+ extra: JSON.stringify({}),
+ sql: 'SELECT quarter, SUM(revenue) FROM sales GROUP BY quarter',
+ },
+];
+
+// Mock users with various permission levels
+export const mockAdminUser = {
+ userId: 1,
+ firstName: 'Admin',
+ lastName: 'User',
+ roles: {
+ Admin: [
+ ['can_read', 'Dataset'],
+ ['can_write', 'Dataset'],
+ ['can_export', 'Dataset'],
+ ['can_duplicate', 'Dataset'],
+ ],
+ },
+};
+
+export const mockOwnerUser = {
+ userId: 1,
+ firstName: 'John',
+ lastName: 'Doe',
+ roles: {
+ Alpha: [
+ ['can_read', 'Dataset'],
+ ['can_write', 'Dataset'],
+ ['can_export', 'Dataset'],
+ ['can_duplicate', 'Dataset'],
+ ],
+ },
+};
+
+export const mockReadOnlyUser = {
+ userId: 10,
+ firstName: 'Read',
+ lastName: 'Only',
+ roles: {
+ Gamma: [['can_read', 'Dataset']],
+ },
+};
+
+export const mockExportOnlyUser = {
+ userId: 11,
+ firstName: 'Export',
+ lastName: 'User',
+ roles: {
+ Gamma: [
+ ['can_read', 'Dataset'],
+ ['can_export', 'Dataset'],
+ ],
+ },
+};
+
+export const mockWriteUser = {
+ userId: 9,
+ firstName: 'Write',
+ lastName: 'User',
+ roles: {
+ Alpha: [
+ ['can_read', 'Dataset'],
+ ['can_write', 'Dataset'],
+ ['can_export', 'Dataset'],
+ ],
+ },
+};
+
+// Mock related objects for delete modal
+export const mockRelatedCharts = {
+ count: 3,
+ result: [
+ { id: 101, slice_name: 'Sales Chart' },
+ { id: 102, slice_name: 'Revenue Chart' },
+ { id: 103, slice_name: 'Analytics Chart' },
+ ],
+};
+
+export const mockRelatedDashboards = {
+ count: 2,
+ result: [
+ { id: 201, title: 'Executive Dashboard' },
+ { id: 202, title: 'Sales Dashboard' },
+ ],
+};
+
+// Mock API error responses
+export const mockApiError500 = {
+ status: 500,
+ body: { message: 'Internal Server Error' },
+};
+
+export const mockApiError403 = {
+ status: 403,
+ body: { message: 'Forbidden' },
+};
+
+export const mockApiError404 = {
+ status: 404,
+ body: { message: 'Not Found' },
+};
+
+// API endpoint constants
+export const API_ENDPOINTS = {
+ DATASETS_INFO: 'glob:*/api/v1/dataset/_info*',
+ DATASETS: 'glob:*/api/v1/dataset/?*',
+ DATASET_GET: 'glob:*/api/v1/dataset/[0-9]*',
+ DATASET_RELATED_OBJECTS: 'glob:*/api/v1/dataset/*/related_objects*',
+ DATASET_DELETE: 'glob:*/api/v1/dataset/[0-9]*',
+ DATASET_BULK_DELETE: 'glob:*/api/v1/dataset/?q=*', // Matches DELETE /api/v1/dataset/?q=...
+ DATASET_DUPLICATE: 'glob:*/api/v1/dataset/duplicate*',
+ DATASET_FAVORITE_STATUS: 'glob:*/api/v1/dataset/favorite_status*',
+ DATASET_RELATED_DATABASE: 'glob:*/api/v1/dataset/related/database*',
+ DATASET_RELATED_SCHEMA: 'glob:*/api/v1/dataset/distinct/schema*',
+ DATASET_RELATED_OWNERS: 'glob:*/api/v1/dataset/related/owners*',
+ DATASET_RELATED_CHANGED_BY: 'glob:*/api/v1/dataset/related/changed_by*',
+};
+
+// Setup API permissions mock (for permission-based testing)
+export const setupApiPermissions = (permissions: string[]) => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS_INFO,
+ { permissions },
+ { overwriteRoutes: true },
+ );
+};
+
+// Store utilities
+export const createMockStore = (initialState: Partial = {}) =>
+ configureStore({
+ reducer: {
+ user: (state = initialState.user || {}) => state,
+ common: (state = initialState.common || {}) => state,
+ datasets: (state = initialState.datasets || {}) => state,
+ },
+ preloadedState: initialState,
+ middleware: getDefaultMiddleware =>
+ getDefaultMiddleware({
+ serializableCheck: false,
+ immutableCheck: false,
+ }),
+ });
+
+export const createDefaultStoreState = (user: UserState): StoreState => ({
+ user,
+ common: {
+ conf: {
+ SUPERSET_WEBSERVER_TIMEOUT: 60000,
+ PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: false,
+ },
+ },
+ datasets: {
+ datasetList: mockDatasets,
+ },
+});
+
+export const renderDatasetList = (
+ user: UserState,
+ props: Partial = {},
+ storeState: Partial = {},
+) => {
+ const defaultStoreState = createDefaultStoreState(user);
+ const storeStateWithUser = {
+ ...defaultStoreState,
+ user,
+ ...storeState,
+ };
+
+ const store = createMockStore(storeStateWithUser);
+
+ return render(
+
+
+
+
+
+
+ ,
+ );
+};
+
+/**
+ * Helper to wait for the DatasetList page to be ready
+ * Waits for the "Datasets" heading to appear, indicating initial render is complete
+ */
+export const waitForDatasetsPageReady = async () => {
+ await screen.findByText('Datasets');
+};
+
+// Helper functions for specific operations
+export const setupDeleteMocks = (datasetId: number) => {
+ fetchMock.get(
+ `glob:*/api/v1/dataset/${datasetId}/related_objects*`,
+ {
+ charts: mockRelatedCharts,
+ dashboards: mockRelatedDashboards,
+ },
+ { overwriteRoutes: true },
+ );
+
+ fetchMock.delete(
+ `glob:*/api/v1/dataset/${datasetId}`,
+ { message: 'Dataset deleted successfully' },
+ { overwriteRoutes: true },
+ );
+};
+
+export const setupDuplicateMocks = () => {
+ fetchMock.post(
+ API_ENDPOINTS.DATASET_DUPLICATE,
+ { id: 999, table_name: 'Copy of Dataset' },
+ { overwriteRoutes: true },
+ );
+};
+
+export const setupBulkDeleteMocks = () => {
+ fetchMock.delete(
+ API_ENDPOINTS.DATASET_BULK_DELETE,
+ { message: '3 datasets deleted successfully' },
+ { overwriteRoutes: true },
+ );
+};
+
+// Setup error mocks for negative flow testing
+export const setupDeleteErrorMocks = (
+ datasetId: number,
+ statusCode: number,
+) => {
+ fetchMock.get(
+ `glob:*/api/v1/dataset/${datasetId}/related_objects*`,
+ {
+ status: statusCode,
+ body: { message: 'Failed to fetch related objects' },
+ },
+ { overwriteRoutes: true },
+ );
+};
+
+export const setupDuplicateErrorMocks = (statusCode: number) => {
+ fetchMock.post(
+ API_ENDPOINTS.DATASET_DUPLICATE,
+ {
+ status: statusCode,
+ body: { message: 'Failed to duplicate dataset' },
+ },
+ { overwriteRoutes: true },
+ );
+};
+
+/**
+ * Helper function to verify only expected API calls were made
+ * Replaces global fail-fast fetchMock.catch() with test-specific assertions
+ *
+ * @param expectedEndpoints - Array of endpoint glob patterns that should have been called
+ * @throws If any unmocked endpoints were called or expected endpoints weren't called
+ */
+export const assertOnlyExpectedCalls = (expectedEndpoints: string[]) => {
+ const allCalls = fetchMock.calls(true); // Get all calls including unmatched
+ const unmatchedCalls = allCalls.filter(call => call.isUnmatched);
+
+ if (unmatchedCalls.length > 0) {
+ const unmatchedUrls = unmatchedCalls.map(call => call[0]);
+ throw new Error(
+ `Unmocked endpoints called: ${unmatchedUrls.join(', ')}. ` +
+ 'Add explicit mocks in setupMocks() or test setup.',
+ );
+ }
+
+ // Verify expected endpoints were called
+ expectedEndpoints.forEach(endpoint => {
+ const calls = fetchMock.calls(endpoint);
+ if (calls.length === 0) {
+ throw new Error(
+ `Expected endpoint not called: ${endpoint}. ` +
+ 'Check if component logic changed or mock is incorrectly configured.',
+ );
+ }
+ });
+};
+
+// MSW setup using fetch-mock (following ChartList pattern)
+export const setupMocks = () => {
+ fetchMock.reset();
+
+ fetchMock.get(API_ENDPOINTS.DATASETS_INFO, {
+ permissions: ['can_read', 'can_write', 'can_export', 'can_duplicate'],
+ });
+
+ fetchMock.get(API_ENDPOINTS.DATASETS, {
+ result: mockDatasets,
+ count: mockDatasets.length,
+ });
+
+ fetchMock.get(API_ENDPOINTS.DATASET_FAVORITE_STATUS, {
+ result: [],
+ });
+
+ fetchMock.get(API_ENDPOINTS.DATASET_RELATED_DATABASE, {
+ result: [
+ { value: 1, text: 'PostgreSQL' },
+ { value: 2, text: 'MySQL' },
+ { value: 3, text: 'Redshift' },
+ ],
+ count: 3,
+ });
+
+ fetchMock.get(API_ENDPOINTS.DATASET_RELATED_SCHEMA, {
+ result: [
+ { value: 'public', text: 'public' },
+ { value: 'analytics', text: 'analytics' },
+ { value: 'metrics', text: 'metrics' },
+ { value: 'reports', text: 'reports' },
+ ],
+ count: 4,
+ });
+
+ fetchMock.get(API_ENDPOINTS.DATASET_RELATED_OWNERS, {
+ result: [],
+ count: 0,
+ });
+
+ fetchMock.get(API_ENDPOINTS.DATASET_RELATED_CHANGED_BY, {
+ result: [],
+ count: 0,
+ });
+};