blob: 12bf223094c69a09a9d74f89037c3af26bf94832 [file] [log] [blame]
/**
* 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);
});