Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions web/packages/studio/src/components/BulkDeleteModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal';
import { useState } from 'react';

export interface BulkDeleteModalProps<T> {
/** Items to delete. */
items: T[];
/** Whether the modal is open. */
open: boolean;
/**
* Called when the user confirms. Should perform all deletions and throw on
* failure — the generic surfaces the thrown message as inline error text.
*/
onDelete: (items: T[]) => Promise<void>;
/**
* Modal title. Pass a function to derive it from the count, e.g.
* (count) => `Delete ${count} Job${count !== 1 ? 's' : ''}`
*/
title: string | ((count: number) => string);
/** Called on both successful delete AND user cancel. */
onClose: () => void;
}

export const BulkDeleteModal = <T,>({
items,
open,
onDelete,
title,
onClose,
}: BulkDeleteModalProps<T>) => {
const [deleteError, setDeleteError] = useState<string | undefined>(undefined);

const resolvedTitle = typeof title === 'function' ? title(items.length) : title;

const handleDelete = async (): Promise<boolean> => {
setDeleteError(undefined);
try {
await onDelete(items);
onClose();
return true;
} catch (error) {
setDeleteError(error instanceof Error ? error.message : 'Failed to delete');
return false;
}
};

const handleClose = () => {
setDeleteError(undefined);
onClose();
};

if (!open) return null;

return (
<DeleteConfirmationModal
open={open}
onDelete={handleDelete}
simpleConfirm
title={resolvedTitle}
errorText={deleteError}
onClose={handleClose}
/>
);
};
57 changes: 37 additions & 20 deletions web/packages/studio/src/components/DatasetsTable/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,41 @@ import { TestProviders } from '@studio/tests/util/TestProviders';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import type { ComponentProps, ReactNode } from 'react';
import type { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';

vi.mock('use-debounce', () => ({
useDebounce: (value: unknown) => [value, () => {}],
}));

vi.mock('@studio/routes/FilesetListRoute/DatasetBulkDeleteModal', () => ({
DatasetBulkDeleteModal: vi.fn(
vi.mock('@studio/components/BulkDeleteModal', () => ({
BulkDeleteModal: vi.fn(
({
selectedDatasets,
onConfirmSuccess,
slotTrigger,
items,
open,
onDelete,
onClose,
}: {
selectedDatasets: FilesetOutput[];
onConfirmSuccess?: () => void;
slotTrigger?: ReactNode;
}) => (
<div data-testid="bulk-delete-modal">
<span data-testid="bulk-modal-count">{selectedDatasets.length}</span>
{slotTrigger}
<button type="button" data-testid="bulk-confirm" onClick={() => onConfirmSuccess?.()}>
Confirm
</button>
</div>
)
items: FilesetOutput[];
open: boolean;
onDelete: (items: FilesetOutput[]) => Promise<void>;
onClose: () => void;
}) =>
open ? (
<div data-testid="bulk-delete-modal">
<span data-testid="bulk-modal-count">{items.length}</span>
<button
type="button"
data-testid="bulk-confirm"
onClick={async () => {
await onDelete(items);
onClose();
}}
>
Confirm
</button>
</div>
) : null
),
}));

Expand Down Expand Up @@ -676,19 +685,25 @@ describe('DatasetsTable', () => {
});

describe('Bulk delete', () => {
it('does not render DatasetBulkDeleteModal when enableBulkDelete is false', async () => {
const clickBulkDeleteTrigger = async () => {
const trigger = await screen.findByRole('button', { name: /delete selected datasets/i });
await user.click(trigger);
};

it('does not render BulkDeleteModal when enableBulkDelete is false', async () => {
installListHandler();
renderTable({ enableSelection: true, enableBulkDelete: false });

await selectRow(0);
expect(screen.queryByTestId('bulk-delete-modal')).not.toBeInTheDocument();
});

it('renders DatasetBulkDeleteModal with the selected datasets when enableBulkDelete is true', async () => {
it('renders BulkDeleteModal with the selected datasets when enableBulkDelete is true', async () => {
installListHandler();
renderTable({ enableSelection: true, enableBulkDelete: true });

await selectRow(0);
await clickBulkDeleteTrigger();

const modal = await screen.findByTestId('bulk-delete-modal');
expect(within(modal).getByTestId('bulk-modal-count')).toHaveTextContent('1');
Expand All @@ -706,6 +721,8 @@ describe('DatasetsTable', () => {
await selectRow(0);
await waitFor(() => expect(onDatasetsSelected.mock.calls.at(-1)?.[0]).toHaveLength(1));

await clickBulkDeleteTrigger();

const countBeforeConfirm = state.requestCount;
await user.click(screen.getByTestId('bulk-confirm'));

Expand Down
53 changes: 40 additions & 13 deletions web/packages/studio/src/components/DatasetsTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,25 @@
import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage';
import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState';
import { getEntityReference } from '@nemo/common/src/namedEntity';
import { useFilesDeleteFileset } from '@nemo/sdk/generated/platform/api';
import { Button } from '@nvidia/foundations-react-core';
import { useMutateMany } from '@studio/api/common/useMutateMany';
import { DatasetCreateModal } from '@studio/components/DatasetCreateModal';
import { DatasetCreateModalMode } from '@studio/components/DatasetCreateModal/constants';
import { makeDatasetsTableColumns } from '@studio/components/DatasetsTable/columns';
import { type DatasetsTableProps } from '@studio/components/DatasetsTable/types';
import { type DatasetWithId, type DatasetsTableProps } from '@studio/components/DatasetsTable/types';
import { useDatasetsTable } from '@studio/components/DatasetsTable/useDatasetsTable';
import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal';
import { DocumentationButton } from '@studio/components/DocumentationButton';
import { Loading } from '@studio/components/Layouts/Loading';
import { NewDatasetButton } from '@studio/components/NewDatasetButton';
import { NewModelFilesetButton } from '@studio/components/NewModelFilesetButton';
import { BulkDeleteModal } from '@studio/components/BulkDeleteModal';

Check failure on line 21 in web/packages/studio/src/components/DatasetsTable/index.tsx

View workflow job for this annotation

GitHub Actions / Web lint

`@studio/components/BulkDeleteModal` import should occur before import of `@studio/components/DatasetCreateModal`
import { FILESET_DETAILS_ENABLED } from '@studio/constants/environment';
import { LINK_DOCS_DATASETS } from '@studio/constants/links';
import { DatasetBulkDeleteModal } from '@studio/routes/FilesetListRoute/DatasetBulkDeleteModal';
import { getNewFilesetRoute } from '@studio/routes/utils';
import { X, Database, Trash } from 'lucide-react';
import { type FC } from 'react';
import { type FC, useCallback, useState } from 'react';
import { Link } from 'react-router-dom';

export type { DatasetsTableProps } from '@studio/components/DatasetsTable/types';
Expand Down Expand Up @@ -72,6 +74,25 @@
purposeFilter,
});

const { mutateAsync: deleteDataset } = useFilesDeleteFileset();
const { mutateAsync: deleteDatasets } = useMutateMany(deleteDataset);
const [bulkDeleteDatasets, setBulkDeleteDatasets] = useState<DatasetWithId[]>([]);

const handleBulkDelete = async (items: DatasetWithId[]) => {
const valid = items.filter(
(d): d is DatasetWithId & { workspace: string; name: string } => !!d.workspace && !!d.name
);
await deleteDatasets(valid.map((d) => ({ workspace: d.workspace, name: d.name })));
};

const handleBulkDeleteClose = useCallback(() => {
const wasOpen = bulkDeleteDatasets.length > 0;
setBulkDeleteDatasets([]);
if (wasOpen) {
handleBulkDeleteSuccess();
}
}, [bulkDeleteDatasets.length, handleBulkDeleteSuccess]);

// Column definitions
const makeColumns = makeDatasetsTableColumns({
enableSelection,
Expand Down Expand Up @@ -115,16 +136,14 @@
renderBulkActions={
enableBulkDelete
? ({ selectedRows }) => (
<DatasetBulkDeleteModal
selectedDatasets={selectedRows}
onConfirmSuccess={handleBulkDeleteSuccess}
slotTrigger={
<Button kind="tertiary">
<Trash />
Delete
</Button>
}
/>
<Button
kind="tertiary"
aria-label="Delete selected datasets"
onClick={() => setBulkDeleteDatasets(selectedRows)}
>
<Trash />
Delete
</Button>
)
: undefined
}
Expand Down Expand Up @@ -176,6 +195,14 @@
}}
/>

<BulkDeleteModal
items={bulkDeleteDatasets}
open={bulkDeleteDatasets.length > 0}
onDelete={handleBulkDelete}
title={(count) => `Delete ${count} Dataset${count !== 1 ? 's' : ''}`}
onClose={handleBulkDeleteClose}
/>

{modalOpen === 'delete' && modalDataset && (
<DeleteConfirmationModal
open
Expand Down

This file was deleted.

Loading
Loading