Skip to content

Commit 071858a

Browse files
knollengewaechsfm3
andauthored
Delete multiple datasets at once (#9107)
### Steps to test: - Prerequisite: duplicate some datasets on disk :) - Go to dashboard - Select multiple datasets and delete them - check that dashboard updates automatically (without reloading) - check that this is only possible when datasets are editable (e.g. when current user is an admin) ### TODOs: - [x] ds from multiple folders -> see code rabbit below ### Issues: - fixes #8817 ------ (Please delete unneeded items, merge only when none are left open) - [x] Added changelog entry (create a `$PR_NUMBER.md` file in `unreleased_changes` or use `./tools/create-changelog-entry.py`) - [ ] Added migration guide entry if applicable (edit the same file as for the changelog) - [ ] Updated [documentation](../blob/master/docs) if applicable - [ ] Adapted [wk-libs python client](https://github.com/scalableminds/webknossos-libs/tree/master/webknossos/webknossos/client) if relevant API parts change - [ ] Removed dev-only changes like prints and application.conf edits - [x] Considered [common edge cases](../blob/master/.github/common_edge_cases.md) - [ ] Needs datastore update after deployment --------- Co-authored-by: Florian M <[email protected]>
1 parent 29b09c1 commit 071858a

File tree

10 files changed

+164
-21
lines changed

10 files changed

+164
-21
lines changed

app/controllers/DatasetController.scala

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,6 @@ class DatasetController @Inject()(userService: UserService,
592592
dataset <- datasetDAO.findOne(datasetId) ?~> notFoundMessage(datasetId.toString) ~> NOT_FOUND
593593
_ <- Fox.fromBool(conf.Features.allowDeleteDatasets) ?~> "dataset.delete.disabled"
594594
_ <- Fox.assertTrue(datasetService.isEditableBy(dataset, Some(request.identity))) ?~> "notAllowed" ~> FORBIDDEN
595-
_ <- Fox.fromBool(request.identity.isAdminOf(dataset._organization)) ?~> "delete.mustBeOrganizationAdmin" ~> FORBIDDEN
596595
before = Instant.now
597596
_ = logger.info(
598597
s"Deleting dataset $datasetId (isVirtual=${dataset.isVirtual}) as requested by user ${request.identity._id}...")

docs/users/access_rights.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ For more information regarding (public) dataset sharing and access rights (espec
3131
| Access datasets of other teams | Yes | Yes | No | No |
3232
| Edit datasets of own teams | Yes | Yes | Yes | No |
3333
| Edit datasets of other teams | Yes | Yes | No | No |
34+
| Delete datasets of own teams | Yes | Yes | Yes | No |
35+
| Delete datasets of other teams | Yes | Yes | No | No |
3436
| Access all users of own teams | Yes | Yes | Yes | Yes |
3537
| Access all users of other teams | Yes | Yes | Yes | No |
3638
| Assign/remove team membership to own teams | Yes | No | Yes | No |

frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useIsMutating } from "@tanstack/react-query";
1+
import { useIsMutating, useQueryClient } from "@tanstack/react-query";
22
import { type DatasetUpdater, getDatastores, triggerDatasetCheck } from "admin/rest_api";
33
import { useEffectOnlyOnce, usePrevious, useWkSelector } from "libs/react_hooks";
44
import UserLocalStorage from "libs/user_local_storage";
@@ -81,7 +81,10 @@ export default function DatasetCollectionContextProvider({
8181
const [activeFolderId, setActiveFolderId] = useState<string | null>(
8282
UserLocalStorage.getItem(ACTIVE_FOLDER_ID_STORAGE_KEY) || null,
8383
);
84-
const mostRecentlyUsedActiveFolderId = usePrevious(activeFolderId, true);
84+
const [mostRecentlyUsedActiveFolderId, clearMostRecentlyUsedActiveFolderId] = usePrevious(
85+
activeFolderId,
86+
true,
87+
);
8588
const [isChecking, setIsChecking] = useState(false);
8689
const isMutating = useIsMutating() > 0;
8790
const { data: folder, isError: didFolderLoadingError } = useFolderQuery(activeFolderId);
@@ -95,6 +98,7 @@ export default function DatasetCollectionContextProvider({
9598
setGlobalSearchQueryInner(value ? value : null);
9699
}, []);
97100
const [searchRecursively, setSearchRecursively] = useState<boolean>(true);
101+
const queryClient = useQueryClient();
98102

99103
// Keep url GET parameters in sync with search and active folder
100104
useManagedUrlParams(
@@ -117,8 +121,18 @@ export default function DatasetCollectionContextProvider({
117121

118122
if (didFolderLoadingError) {
119123
setActiveFolderId(null);
124+
clearMostRecentlyUsedActiveFolderId();
125+
if (!folderHierarchyQuery.isFetching) {
126+
queryClient.invalidateQueries({ queryKey: ["folders"] });
127+
}
120128
}
121-
}, [folder, activeFolderId, didFolderLoadingError]);
129+
}, [
130+
folder,
131+
activeFolderId,
132+
didFolderLoadingError,
133+
clearMostRecentlyUsedActiveFolderId,
134+
queryClient,
135+
]);
122136

123137
const folderHierarchyQuery = useFolderHierarchyQuery();
124138
const datasetsInFolderQuery = useDatasetsInFolderQuery(

frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ const DatasetSettingsDeleteTab = () => {
6161
Note that annotations for the dataset stay downloadable and the name stays
6262
reserved.
6363
</p>
64-
<p>Only admins are allowed to delete datasets.</p>
64+
<p>
65+
Admins, dataset managers and team managers of the datasets' team(s) are allowed to
66+
delete datasets.
67+
</p>
6568
<Button danger loading={isDeleting} onClick={handleDeleteButtonClicked}>
6669
Delete Dataset on Disk
6770
</Button>

frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import {
1010
import { Alert, Breadcrumb, Button, Form, Layout, Menu, Tooltip } from "antd";
1111
import type { ItemType } from "antd/es/menu/interface";
1212
import { useDatasetSettingsContext } from "dashboard/dataset/dataset_settings_context";
13-
1413
import features from "features";
15-
import { useWkSelector } from "libs/react_hooks";
1614
import messages from "messages";
1715
import type React from "react";
1816
import { useCallback } from "react";
@@ -44,7 +42,6 @@ const DatasetSettingsView: React.FC = () => {
4442
getFormValidationSummary,
4543
hasFormErrors,
4644
} = useDatasetSettingsContext();
47-
const isUserAdmin = useWkSelector((state) => state.activeUser?.isAdmin || false);
4845
const location = useLocation();
4946
const navigate = useNavigate();
5047
const selectedKey =
@@ -154,7 +151,7 @@ const DatasetSettingsView: React.FC = () => {
154151
icon: formErrors.defaultConfig ? errorIcon : <SettingOutlined />,
155152
label: "View Configuration",
156153
},
157-
isUserAdmin && features().allowDeleteDatasets
154+
features().allowDeleteDatasets
158155
? {
159156
key: "delete",
160157
icon: <DeleteOutlined />,

frontend/javascripts/dashboard/folders/details_sidebar.tsx

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import {
2+
DeleteOutlined,
23
EditOutlined,
34
FileOutlined,
45
FolderOpenOutlined,
56
LoadingOutlined,
67
SearchOutlined,
78
} from "@ant-design/icons";
8-
import { useQuery } from "@tanstack/react-query";
9-
import { getOrganization } from "admin/rest_api";
10-
import { Result, Spin, Tag, Tooltip } from "antd";
9+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10+
import { deleteDatasetOnDisk, getOrganization } from "admin/rest_api";
11+
import { Button, Modal, Progress, Result, Space, Spin, Tag, Tooltip, Typography } from "antd";
1112
import FormattedId from "components/formatted_id";
1213
import { formatCountToDataAmountUnit, stringToColor } from "libs/format_utils";
1314
import Markdown from "libs/markdown_adapter";
1415
import { useWkSelector } from "libs/react_hooks";
16+
import Toast from "libs/toast";
1517
import { pluralize } from "libs/utils";
1618
import _ from "lodash";
17-
import { useEffect } from "react";
19+
import { useEffect, useState } from "react";
1820
import type { APIDatasetCompact, Folder } from "types/api_types";
1921
import {
2022
DatasetExtentRow,
@@ -214,10 +216,127 @@ function DatasetsDetails({
214216
selectedDatasets: APIDatasetCompact[];
215217
datasetCount: number;
216218
}) {
219+
const queryClient = useQueryClient();
220+
const [progressInPercent, setProgressInPercent] = useState(0);
221+
const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false);
222+
const deletableDatasets = selectedDatasets.filter((ds) => ds.isEditable);
223+
const numberOfUndeletableDatasets = selectedDatasets.length - deletableDatasets.length;
224+
225+
const updateAndInvalidateQueries = (deletedIds: string[]) => {
226+
const uniqueFolderIds = _.uniq(deletableDatasets.map((ds) => ds.folderId));
227+
uniqueFolderIds.forEach((folderId) => {
228+
queryClient.setQueryData(
229+
["datasetsByFolder", folderId],
230+
(oldItems: APIDatasetCompact[] | undefined) => {
231+
if (oldItems == null) {
232+
return oldItems;
233+
}
234+
return oldItems.filter((item) => !deletedIds.includes(item.id));
235+
},
236+
);
237+
});
238+
queryClient.invalidateQueries({ queryKey: ["dataset", "search"] });
239+
};
240+
241+
const deleteDatasetsMutation = useMutation({
242+
mutationFn: async (datasets: APIDatasetCompact[]) => {
243+
const deletedIds: string[] = [];
244+
for (let i = 0; i < datasets.length; i++) {
245+
const dataset = datasets[i];
246+
try {
247+
await deleteDatasetOnDisk(dataset.id);
248+
deletedIds.push(dataset.id);
249+
setProgressInPercent(Math.round(((i + 1) / datasets.length) * 100));
250+
} catch (_e) {
251+
Toast.error(`Failed to delete dataset ${dataset.name}.`);
252+
}
253+
}
254+
return deletedIds;
255+
},
256+
onSuccess: (deletedIds) => {
257+
updateAndInvalidateQueries(deletedIds);
258+
setShowConfirmDeleteModal(false);
259+
setProgressInPercent(0);
260+
261+
if (deletedIds.length > 0) {
262+
Toast.success(
263+
`Successfully deleted ${deletedIds.length} ${pluralize("dataset", deletedIds.length)}.`,
264+
);
265+
}
266+
},
267+
});
268+
269+
const deleteDatasets = () => {
270+
deleteDatasetsMutation.mutate(deletableDatasets);
271+
};
272+
273+
const okayButton = (
274+
<Button type="primary" danger onClick={deleteDatasets}>
275+
Delete
276+
</Button>
277+
);
278+
279+
const onCancel = () => {
280+
if (!deleteDatasetsMutation.isPending) {
281+
setShowConfirmDeleteModal(false);
282+
}
283+
};
284+
285+
const cancelButton = <Button onClick={onCancel}>Cancel</Button>;
286+
287+
// TODO delete once soft-delete is implemented: https://github.com/scalableminds/webknossos/issues/9061
288+
const cantBeUndoneMessage = (
289+
<Typography.Text type="warning" strong>
290+
This action cannot be undone.
291+
</Typography.Text>
292+
);
293+
294+
const deletableDatasetString = `${deletableDatasets.length} ${pluralize("dataset", deletableDatasets.length)}`;
295+
296+
const confirmModal = (
297+
<Modal
298+
open={showConfirmDeleteModal}
299+
title="Delete Datasets"
300+
footer={deleteDatasetsMutation.isPending ? null : [cancelButton, okayButton]}
301+
onCancel={onCancel}
302+
>
303+
{deleteDatasetsMutation.isPending ? (
304+
<Progress percent={progressInPercent} />
305+
) : (
306+
<>
307+
Are you sure you want to delete the following {deletableDatasetString}?
308+
<ul>
309+
{deletableDatasets.map((dataset) => (
310+
<li key={dataset.id}>{dataset.name}</li>
311+
))}
312+
</ul>
313+
{numberOfUndeletableDatasets > 0 && (
314+
<div>
315+
The remaining {numberOfUndeletableDatasets} selected{" "}
316+
{pluralize("dataset", numberOfUndeletableDatasets)} cannot be deleted, e.g. because
317+
you do not have sufficient permissions.
318+
</div>
319+
)}
320+
{cantBeUndoneMessage}
321+
</>
322+
)}
323+
</Modal>
324+
);
325+
217326
return (
218327
<div style={{ textAlign: "center" }}>
219-
Selected {selectedDatasets.length} of {datasetCount} datasets. Move them to another folder
220-
with drag and drop.
328+
<Space direction="vertical" size="large" style={{ display: "flex" }}>
329+
<div>
330+
Selected {selectedDatasets.length} of {datasetCount} datasets. Move them to another folder
331+
with drag and drop.
332+
</div>
333+
{deletableDatasets.length > 0 && (
334+
<Button onClick={() => setShowConfirmDeleteModal(true)} icon={<DeleteOutlined />}>
335+
Delete {deletableDatasetString}
336+
</Button>
337+
)}
338+
</Space>
339+
{confirmModal}
221340
</div>
222341
);
223342
}

frontend/javascripts/libs/react_hooks.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import { KEYBOARD_BUTTON_LOOP_INTERVAL } from "./input";
1010
* Hook that returns the previous value of a state or prop.
1111
* @param value - The current value to track
1212
* @param ignoreNullAndUndefined - If true, null/undefined values won't update the previous value
13-
* @returns The previous value, or null if no previous value exists
13+
* @returns The previous value, or null if no previous value exists; and a function to clear the stored previous value
1414
*/
15-
export function usePrevious<T>(value: T, ignoreNullAndUndefined: boolean = false): T | null {
15+
export function usePrevious<T>(
16+
value: T,
17+
ignoreNullAndUndefined: boolean = false,
18+
): [T | null, () => void] {
1619
// Adapted from: https://usehooks.com/usePrevious/
1720

1821
// The ref object is a generic container whose current property is mutable ...
@@ -24,9 +27,13 @@ export function usePrevious<T>(value: T, ignoreNullAndUndefined: boolean = false
2427
ref.current = value;
2528
}
2629
}, [value, ignoreNullAndUndefined]);
30+
31+
const clearFn = useCallback(() => {
32+
ref.current = null;
33+
}, []);
2734
// Only re-run if value changes
2835
// Return previous value (happens before update in useEffect above)
29-
return ref.current;
36+
return [ref.current, clearFn];
3037
}
3138

3239
const extractModifierState = <K extends keyof WindowEventMap>(event: WindowEventMap[K]) => ({

frontend/javascripts/libs/toast.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const Toast = {
7474
this.error(
7575
singleMessage.error,
7676
{
77-
sticky: true,
77+
timeout: 13000,
7878
key: singleMessage.key,
7979
},
8080
errorChainString,

frontend/javascripts/viewer/view/action-bar/tools/volume_specific_ui.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ export function OverwriteModeSwitch({
112112
// Only CTRL should modify the overwrite mode. CTRL + Shift can be used to switch to the
113113
// erase tool, which should not affect the default overwrite mode.
114114
const overwriteMode = useWkSelector((state) => state.userConfiguration.overwriteMode);
115-
const previousIsControlOrMetaPressed = usePrevious(isControlOrMetaPressed);
116-
const previousIsShiftPressed = usePrevious(isShiftPressed);
115+
const [previousIsControlOrMetaPressed] = usePrevious(isControlOrMetaPressed);
116+
const [previousIsShiftPressed] = usePrevious(isShiftPressed);
117117
// biome-ignore lint/correctness/useExhaustiveDependencies: overwriteMode does not need to be a dependency.
118118
useEffect(() => {
119119
// There are four possible states:

unreleased_changes/9107.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
### Added
2+
- Multiple datasets can be deleted at once. This can be done by selecting the datasets in the dashboard and clicking "Delete datasets" in the sidebar.

0 commit comments

Comments
 (0)