You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2025/11/15 18:22:08 UTC
(superset) 01/01: test: add comprehensive React Testing Library and integration tests for DatasetList
This is an automated email from the ASF dual-hosted git repository.
jli pushed a commit to branch feat/dataset-rtl-tests
in repository https://gitbox.apache.org/repos/asf/superset.git
commit d459ffb45c5d05830c8024200fe35889695afe86
Author: Joe Li <jo...@preset.io>
AuthorDate: Sat Nov 15 10:09:32 2025 -0800
test: add comprehensive React Testing Library and integration tests for DatasetList
Add complete test coverage for the DatasetList page including:
- Component tests: DatasetList main, behaviors, ListView, permissions, DuplicateDatasetModal
- Integration tests: multi-component orchestration and hook-level state management
- Test helpers: shared fixtures and utilities
Tests breakdown:
- DatasetList.test.tsx: 28 component tests
- DatasetList.behavior.test.tsx: 10 behavior tests
- DatasetList.listview.test.tsx: 36 ListView tests
- DatasetList.permissions.test.tsx: 14 permission tests
- DuplicateDatasetModal.test.tsx: 9 modal tests
- DatasetList.integration.test.tsx: 2 integration tests
Total: 99 tests (97 component + 2 integration)
Includes fixes for:
- antd 5.27.6 compatibility (semantic selectors)
- Async cleanup patterns for reliable test execution
- Test timing and synchronization issues
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <no...@anthropic.com>
---
.../datasets/DuplicateDatasetModal.test.tsx | 282 ++++
.../DatasetList/DatasetList.behavior.test.tsx | 485 +++++++
.../DatasetList/DatasetList.integration.test.tsx | 211 +++
.../DatasetList/DatasetList.listview.test.tsx | 1484 ++++++++++++++++++++
.../DatasetList/DatasetList.permissions.test.tsx | 394 ++++++
.../src/pages/DatasetList/DatasetList.test.tsx | 528 +++++++
.../pages/DatasetList/DatasetList.testHelpers.tsx | 539 +++++++
7 files changed, 3923 insertions(+)
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 0000000000..849bf5f9ad
--- /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;
+}) => (
+ <ThemeProvider theme={supersetTheme}>
+ <DuplicateDatasetModal
+ dataset={dataset}
+ onHide={onHide}
+ onDuplicate={onDuplicate}
+ />
+ </ThemeProvider>
+);
+
+const renderModal = (
+ dataset: VirtualDatasetFixture | null,
+ onHide: jest.Mock,
+ onDuplicate: jest.Mock,
+) =>
+ render(
+ <Wrapper dataset={dataset} onHide={onHide} onDuplicate={onDuplicate} />,
+ );
+
+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(
+ <Wrapper dataset={null} onHide={onHide} onDuplicate={onDuplicate} />,
+ );
+
+ // 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(
+ <Wrapper dataset={null} onHide={onHide} onDuplicate={onDuplicate} />,
+ );
+
+ // Modal should be hidden
+ await waitFor(() => {
+ expect(screen.queryByText('Duplicate dataset')).not.toBeVisible();
+ });
+
+ // Reopen with same dataset - input should be cleared
+ rerender(
+ <Wrapper dataset={mockDataset} onHide={onHide} onDuplicate={onDuplicate} />,
+ );
+
+ 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(
+ <Wrapper dataset={newDataset} onHide={onHide} onDuplicate={onDuplicate} />,
+ );
+
+ // Input should be cleared
+ await waitFor(() => {
+ expect(input).toHaveValue('');
+ });
+});
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 0000000000..c4cc015100
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -0,0 +1,485 @@
+/**
+ * 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, cleanup, 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: <P extends object>(Component: ComponentType<P>) => Component,
+}));
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(async () => {
+ // Wait for any pending state updates to complete before cleanup
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+ cleanup();
+ fetchMock.reset();
+ 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 = url.split('?q=')[1];
+ expect(risonPayload).toBeTruthy();
+ const decoded = rison.decode(decodeURIComponent(risonPayload!)) as Record<
+ string,
+ unknown
+ >;
+ const filters = Array.isArray(decoded?.filters) ? decoded.filters : [];
+ const hasSalesFilter = filters.some(
+ (filter: Record<string, unknown>) =>
+ 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 0000000000..b09c7bc41f
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
@@ -0,0 +1,211 @@
+/**
+ * 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 { cleanup, 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');
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(() => {
+ cleanup();
+ fetchMock.reset();
+ 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 });
+
+ await userEvent.click(nameHeader);
+
+ // 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
+ const risonPayload = url.split('?q=')[1];
+ expect(risonPayload).toBeTruthy();
+ const decoded = rison.decode(decodeURIComponent(risonPayload!)) as Record<
+ string,
+ unknown
+ >;
+
+ // 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<string, unknown>) =>
+ 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
+});
+
+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);
+
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ // Select first 2 items (skip select-all checkbox at index 0)
+ const checkboxes = screen.getAllByRole('checkbox');
+ await userEvent.click(checkboxes[1]);
+ await userEvent.click(checkboxes[2]);
+
+ // Wait for selections to register - assert on "selected" text which is what users see
+ await screen.findByText(/selected/i);
+
+ // 2. Execute bulk delete
+ // Multiple bulk actions share the same test ID, so filter by text content
+ const bulkActionButtons = await screen.findAllByTestId('bulk-select-action');
+ const bulkDeleteButton = bulkActionButtons.find(btn =>
+ btn.textContent?.includes('Delete'),
+ );
+ expect(bulkDeleteButton).toBeTruthy();
+ await userEvent.click(bulkDeleteButton!);
+
+ // Confirm 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');
+
+ // 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
+});
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 0000000000..12bf223094
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx
@@ -0,0 +1,1484 @@
+/**
+ * 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, cleanup, 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');
+
+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
+ renderDatasetList(mockAdminUser);
+};
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(async () => {
+ // Wait for any pending state updates to complete before cleanup
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+ cleanup();
+ fetchMock.reset();
+ jest.restoreAllMocks();
+});
+
+test('only expected API endpoints are called on initial render', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Verify only expected endpoints were called (no unmocked calls)
+ // These are the minimum required endpoints for initial dataset list render
+ 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('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 for all rows', 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);
+
+ // Checkboxes should appear
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ // Note: Bulk action buttons (Export, Delete) only appear after selecting items
+ // This test only verifies checkboxes appear - button visibility tested in other tests
+});
+
+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);
+
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ // 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`,
+ );
+ });
+
+ // Verify bulk action buttons are enabled when items are selected
+ const exportButton = screen.getByRole('button', { name: /export/i });
+ const deleteButton = screen.getByRole('button', { name: 'Delete' });
+ expect(exportButton).toBeEnabled();
+ expect(deleteButton).toBeEnabled();
+});
+
+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);
+
+ // Select checkbox
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(1);
+
+ // Click first data row checkbox (index 0 might be select-all)
+ await userEvent.click(checkboxes[1]);
+
+ // 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);
+
+ // Select checkbox
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(1);
+
+ await userEvent.click(checkboxes[1]);
+
+ // Find and click bulk delete button (use accessible name for specificity)
+ const deleteButton = await screen.findByRole('button', { name: 'Delete' });
+ await userEvent.click(deleteButton);
+
+ // Confirmation modal should appear
+ const modal = await screen.findByRole('dialog');
+ expect(modal).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);
+
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ // 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 using Ant Design's class
+ // Scoping to container prevents selecting close buttons from other components
+ const bulkSelectControls = screen.getByTestId('bulk-select-controls');
+ const closeButton = bulkSelectControls.querySelector(
+ '.ant-alert-close-icon',
+ ) as HTMLElement;
+ await userEvent.click(closeButton);
+
+ // Checkboxes should disappear
+ await waitFor(() => {
+ const checkboxes = screen.queryAllByRole('checkbox');
+ expect(checkboxes.length).toBe(0);
+ });
+
+ // Bulk action toolbar should be hidden, normal toolbar should return
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId('bulk-select-controls'),
+ ).not.toBeInTheDocument();
+ // Bulk select button should be back
+ expect(
+ screen.getByRole('button', { name: /bulk select/i }),
+ ).toBeInTheDocument();
+ });
+});
+
+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('delete action shows error toast on 403 forbidden', 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 error toast with combined assertion
+ await waitFor(() =>
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/error occurred while fetching dataset/i),
+ ),
+ );
+
+ // Verify modal did NOT open (error prevented it)
+ const modal = screen.queryByRole('dialog');
+ expect(modal).not.toBeInTheDocument();
+
+ // Verify dataset still in list (not removed)
+ expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+});
+
+test('delete action shows error toast on 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 error toast with combined assertion
+ await waitFor(() =>
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/error occurred while fetching dataset/i),
+ ),
+ );
+
+ // 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 modal to close (error handler closes it)
+ // antd modal close animation can be slow, increase timeout
+ await waitFor(
+ () => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ },
+ { timeout: 10000 },
+ );
+
+ // Wait for error toast
+ await waitFor(() =>
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/issue duplicating.*selected datasets/i),
+ ),
+ );
+
+ // Verify table state unchanged (no new dataset added)
+ const allDatasetRows = screen.getAllByRole('row');
+ // Header + 1 dataset row
+ expect(allDatasetRows.length).toBe(2);
+});
+
+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 modal to close (error handler closes it)
+ // antd modal close animation can be slow, increase timeout
+ await waitFor(
+ () => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ },
+ { timeout: 10000 },
+ );
+
+ // Wait for error toast
+ await waitFor(() =>
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/issue duplicating.*selected datasets/i),
+ ),
+ );
+
+ // Verify table state unchanged
+ expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument();
+});
+
+// Component "+1" Tests - State persistence through operations
+
+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);
+ });
+
+ // Now re-query the header and assert the sort indicators still exist
+ await waitFor(() => {
+ const carets = within(nameHeader.closest('th')!).getAllByLabelText(
+ /caret/i,
+ );
+ expect(carets.length).toBeGreaterThan(0);
+ });
+});
+
+// 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.
+
+test('bulk delete refreshes list with updated count', async () => {
+ setupBulkDeleteMocks();
+
+ renderDatasetList(mockAdminUser, {
+ addSuccessToast: mockAddSuccessToast,
+ 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);
+
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ // Select first 3 items (re-query checkboxes after each click to handle DOM updates)
+ let checkboxes = screen.getAllByRole('checkbox');
+ await userEvent.click(checkboxes[1]);
+
+ checkboxes = screen.getAllByRole('checkbox');
+ await userEvent.click(checkboxes[2]);
+
+ checkboxes = screen.getAllByRole('checkbox');
+ await userEvent.click(checkboxes[3]);
+
+ // Wait for selections to register
+ await waitFor(() => {
+ const selectionText = screen.getByText(/selected/i);
+ expect(selectionText).toBeInTheDocument();
+ expect(selectionText).toHaveTextContent('3');
+ });
+
+ // Verify bulk actions UI appears and click the bulk delete button
+ // Multiple bulk actions share the same test ID, so filter by text content
+ const bulkActionButtons = await screen.findAllByTestId('bulk-select-action');
+ const bulkDeleteButton = bulkActionButtons.find(btn =>
+ btn.textContent?.includes('Delete'),
+ );
+ expect(bulkDeleteButton).toBeTruthy();
+
+ await userEvent.click(bulkDeleteButton!);
+
+ // Confirm in modal - type DELETE to enable button
+ const modal = await screen.findByRole('dialog');
+
+ // Enable the danger button by typing DELETE
+ 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 modal to close first (defensive wait for CI stability)
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // Wait for success toast
+ await waitFor(() => {
+ expect(mockAddSuccessToast).toHaveBeenCalledWith(
+ expect.stringContaining('deleted'),
+ );
+ });
+
+ // Verify danger toast was not called
+ expect(mockAddDangerToast).not.toHaveBeenCalled();
+}, 30000); // 30 second timeout for slow bulk delete test
+
+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);
+
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ // Select first 2 items
+ const checkboxes = screen.getAllByRole('checkbox');
+ await userEvent.click(checkboxes[1]);
+ await userEvent.click(checkboxes[2]);
+
+ // Wait for selections to register - assert on "selected" text which is what users see
+ await screen.findByText(/selected/i);
+
+ // Record API call count before filter
+ const beforeFilterCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length;
+
+ // 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 = urlAfterFilter.split('?q=')[1];
+ const decodedAfterFilter = rison.decode(
+ decodeURIComponent(risonAfterFilter!),
+ ) as Record<string, any>;
+ expect(decodedAfterFilter.filters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ col: 'sql', value: false }),
+ ]),
+ );
+
+ // Verify selection was cleared - count should show "0 Selected"
+ await waitFor(() => {
+ expect(screen.getByText(/0 selected/i)).toBeInTheDocument();
+ });
+}, 30000); // 30 second timeout for slow CI environment
+
+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();
+ });
+
+ // Apply Type filter using selectOption helper
+ // Check if filter is already applied (from previous test state)
+ const typeFilterCombobox = screen.queryByRole('combobox', { name: /^Type:/ });
+ if (!typeFilterCombobox) {
+ // Filter not applied yet, apply it
+ await selectOption('Virtual', 'Type');
+ }
+
+ // Wait a moment for any pending filter operations to complete
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Verify filter is present by checking the latest API call
+ const urlAfterFilter = fetchMock
+ .calls(API_ENDPOINTS.DATASETS)
+ .at(-1)?.[0] as string;
+ const risonAfterFilter = urlAfterFilter.split('?q=')[1];
+ const decodedAfterFilter = rison.decode(
+ decodeURIComponent(risonAfterFilter!),
+ ) as Record<string, any>;
+ 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 = urlAfterDuplicate.split('?q=')[1];
+ const decodedAfterDuplicate = rison.decode(
+ decodeURIComponent(risonAfterDuplicate!),
+ ) as Record<string, any>;
+ expect(decodedAfterDuplicate.filters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ col: 'sql', value: false }),
+ ]),
+ );
+});
+
+test('type filter API call includes correct filter parameter', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Apply Type filter using selectOption helper
+ // Check if filter is already applied (from previous test state)
+ const typeFilterCombobox = screen.queryByRole('combobox', { name: /^Type:/ });
+ if (!typeFilterCombobox) {
+ // Filter not applied yet, apply it
+ await selectOption('Virtual', 'Type');
+ }
+
+ // Wait a moment for any pending filter operations to complete
+ await waitFor(() => {
+ expect(screen.getByTestId('listview-table')).toBeInTheDocument();
+ });
+
+ // Verify the latest API call includes the Type filter
+ const calls = fetchMock.calls(API_ENDPOINTS.DATASETS);
+ const latestCall = calls[calls.length - 1];
+ const url = latestCall[0] as string;
+
+ // URL should contain filters parameter
+ expect(url).toContain('filters');
+ const risonPayload = url.split('?q=')[1];
+ expect(risonPayload).toBeTruthy();
+ const decoded = rison.decode(decodeURIComponent(risonPayload!)) as Record<
+ string,
+ unknown
+ >;
+ const filters = Array.isArray(decoded?.filters) ? decoded.filters : [];
+
+ // Type filter should be present (sql=false for Virtual datasets)
+ const hasTypeFilter = filters.some(
+ (filter: Record<string, unknown>) =>
+ filter?.col === 'sql' && filter?.value === false,
+ );
+
+ expect(hasTypeFilter).toBe(true);
+});
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 0000000000..14a73e264a
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx
@@ -0,0 +1,394 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { cleanup, 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';
+
+beforeEach(() => {
+ setupMocks();
+ jest.clearAllMocks();
+});
+
+afterEach(() => {
+ cleanup();
+ fetchMock.reset();
+});
+
+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: /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
+ await waitFor(() => {
+ const table = screen.getByTestId('listview-table');
+ expect(
+ within(table).getByRole('columnheader', { name: /Actions/i }),
+ ).toBeInTheDocument();
+ });
+});
+
+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: /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: /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: /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 0000000000..d1aace18c6
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx
@@ -0,0 +1,528 @@
+/**
+ * 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 { cleanup, 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';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+
+beforeEach(() => {
+ setupMocks();
+});
+
+afterEach(() => {
+ cleanup();
+ fetchMock.reset();
+});
+
+test('renders page with "Datasets" title', async () => {
+ renderDatasetList(mockAdminUser);
+
+ await waitForDatasetsPageReady();
+});
+
+test('shows loading state during initial data fetch', () => {
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS,
+ new Promise(() => {}), // Never resolves to keep loading state
+ { overwriteRoutes: true },
+ );
+
+ renderDatasetList(mockAdminUser);
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+});
+
+test('maintains component structure during loading', () => {
+ fetchMock.get(API_ENDPOINTS.DATASETS, new Promise(() => {}), {
+ overwriteRoutes: true,
+ });
+
+ renderDatasetList(mockAdminUser);
+
+ expect(screen.getByText('Datasets')).toBeInTheDocument();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+});
+
+test('"New Dataset" button exists (when canCreate=true)', async () => {
+ renderDatasetList(mockAdminUser);
+
+ expect(
+ await screen.findByRole('button', { name: /dataset/i }),
+ ).toBeInTheDocument();
+});
+
+test('"New Dataset" button hidden (when canCreate=false)', async () => {
+ renderDatasetList(mockReadOnlyUser);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('button', { name: /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);
+
+ 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
+ const queryString = url.split('?q=')[1];
+ expect(queryString).toBeTruthy();
+
+ // Decode the rison payload
+ const decoded = rison.decode(decodeURIComponent(queryString)) as Record<
+ string,
+ unknown
+ >;
+
+ // 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);
+ });
+});
+
+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
+ await waitForDatasetsPageReady();
+});
+
+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
+ await waitForDatasetsPageReady();
+});
+
+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 0000000000..844ad8f916
--- /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<typeof handleResourceExport>;
+
+// 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<StoreState> = {}) =>
+ 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<DatasetListPropsOverrides> = {},
+ storeState: Partial<StoreState> = {},
+) => {
+ const defaultStoreState = createDefaultStoreState(user);
+ const storeStateWithUser = {
+ ...defaultStoreState,
+ user,
+ ...storeState,
+ };
+
+ const store = createMockStore(storeStateWithUser);
+
+ return render(
+ <Provider store={store}>
+ <MemoryRouter>
+ <QueryParamProvider>
+ <DatasetList user={user} {...props} />
+ </QueryParamProvider>
+ </MemoryRouter>
+ </Provider>,
+ );
+};
+
+/**
+ * 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,
+ });
+};