diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 00037bfc055..22f69d7415e 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -14,6 +14,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Added sign in via OIDC. [#6534](https://github.com/scalableminds/webknossos/pull/6534) - Added a new datasets tab to the dashboard which supports managing datasets in folders. Folders can be organized hierarchically and datasets can be moved into these folders. Selecting a dataset will show dataset details in a sidebar. [#6591](https://github.com/scalableminds/webknossos/pull/6591) - Added the option to search a specific folder in the new datasets tab. [#6677](https://github.com/scalableminds/webknossos/pull/6677) +- The new datasets tab in the dashboard allows multi-selection of datasets so that multiple datasets can be moved to a folder at once. As in typical file explorers, CTRL + left click adds individual datasets to the current selection. Shift + left click selects a range of datasets. [#6683](https://github.com/scalableminds/webknossos/pull/6683) ### Changed - webKnossos is now able to recover from a lost webGL context. [#6663](https://github.com/scalableminds/webknossos/pull/6663) diff --git a/docs/dashboard.md b/docs/dashboard.md index 68559673e89..8f27ca4b660 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -9,10 +9,12 @@ You can *view* a dataset (read-only) or start new annotations from this screen. Search for your dataset by using the search bar or sorting any of the table columns. Learn more about managing datasets in the [Datasets guide](./datasets.md). -The presentation differs corresponding to your user role. +The presentation differs depending on your user role. Regular users can only start or continue annotations and work on tasks. [Admins and Team Managers](./users.md#access-rights-roles) also have access to additional administration actions, access-rights management, and advanced dataset properties for each dataset. +Read more about the organization of datasets [here](./datasets.md#dataset-organization). + ![Dashboard for Team Managers or Admins with access to dataset settings and additional administration actions.](./images/dashboard_datasets.jpeg) ![Dashboard for Regular Users](./images/dashboard_regular_user.jpeg) diff --git a/docs/datasets.md b/docs/datasets.md index ae7790518d0..30b938a1b2d 100644 --- a/docs/datasets.md +++ b/docs/datasets.md @@ -221,6 +221,8 @@ This is because the access permissions are handled cumulatively. In addition to the folder organization, datasets can also be tagged. Use the tags column to do so or select a dataset with a click and use the right sidebar. +To move multiple datasets to a folder at once, you can make use of multi-selection. As in typical file explorers, CTRL + left click adds individual datasets to the current selection. Shift + left click selects a range of datasets. + ## Dataset Sharing Read more in the [Sharing guide](./sharing.md#dataset-sharing) diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.tsx b/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.tsx index 8558d535ebc..61bef03c20a 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.tsx +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.tsx @@ -252,13 +252,30 @@ const onClearCache = async ( export function getDatasetActionContextMenu({ reloadDataset, - dataset, + datasets, hideContextMenu, }: { reloadDataset: (arg0: APIDatasetId) => Promise; - dataset: APIMaybeUnimportedDataset; + datasets: APIMaybeUnimportedDataset[]; hideContextMenu: () => void; }) { + if (datasets.length !== 1) { + return ( + + + No actions available. + + + ); + } + const dataset = datasets[0]; + return ( ) => Promise; updateDataset: (arg0: APIDataset) => Promise; addTagToSearch: (tag: string) => void; - onSelectDataset?: (dataset: APIMaybeUnimportedDataset | null) => void; - selectedDataset?: APIMaybeUnimportedDataset | null | undefined; + onSelectDataset: (dataset: APIMaybeUnimportedDataset | null, multiSelect?: boolean) => void; + selectedDatasets: APIMaybeUnimportedDataset[]; hideDetailsColumns?: boolean; context: DatasetCacheContextValue | DatasetCollectionContextValue; }; @@ -68,27 +68,27 @@ type State = { prevSearchQuery: string; sortedInfo: SorterResult; contextMenuPosition: [number, number] | null | undefined; - datasetForContextMenu: APIMaybeUnimportedDataset | null; + datasetsForContextMenu: APIMaybeUnimportedDataset[]; }; type ContextMenuProps = { contextMenuPosition: [number, number] | null | undefined; hideContextMenu: () => void; - dataset: APIMaybeUnimportedDataset | null; + datasets: APIMaybeUnimportedDataset[]; reloadDataset: Props["reloadDataset"]; }; function ContextMenuInner(propsWithInputRef: ContextMenuProps) { const inputRef = React.useContext(ContextMenuContext); - const { dataset, reloadDataset, contextMenuPosition, hideContextMenu } = propsWithInputRef; + const { datasets, reloadDataset, contextMenuPosition, hideContextMenu } = propsWithInputRef; let overlay =
; - if (contextMenuPosition != null && dataset != null) { + if (contextMenuPosition != null) { // getDatasetActionContextMenu should not be turned into // as this breaks antd's styling of the menu within the dropdown. overlay = getDatasetActionContextMenu({ hideContextMenu, - dataset, + datasets, reloadDataset, }); } @@ -239,8 +239,12 @@ class DatasetTable extends React.PureComponent { }, prevSearchQuery: "", contextMenuPosition: null, - datasetForContextMenu: null, + datasetsForContextMenu: [], }; + // currentPageData is only used for range selection (and not during + // rendering). That's why it's not included in this.state (also it + // would lead to infinite loops, too). + currentPageData: APIMaybeUnimportedDataset[] = []; static getDerivedStateFromProps(nextProps: Props, prevState: State): Partial { const maybeSortedInfo: SorterResult | {} = // Clear the sorting exactly when the search box is initially filled @@ -263,7 +267,6 @@ class DatasetTable extends React.PureComponent { _pagination: TablePaginationConfig, _filters: Record, sorter: SorterResult | SorterResult[], - _extra: TableCurrentDataSource, ) => { this.setState({ // @ts-ignore @@ -407,7 +410,7 @@ class DatasetTable extends React.PureComponent { hideContextMenu={() => { this.setState({ contextMenuPosition: null }); }} - dataset={this.state.datasetForContextMenu} + datasets={this.state.datasetsForContextMenu} reloadDataset={this.props.reloadDataset} contextMenuPosition={this.state.contextMenuPosition} /> @@ -423,7 +426,19 @@ class DatasetTable extends React.PureComponent { locale={{ emptyText: this.renderEmptyText(), }} + summary={(currentPageData) => { + // Workaround to get to the currently rendered entries (since the ordering + // is managed by antd). + // Also see https://github.com/ant-design/ant-design/issues/24022. + this.currentPageData = currentPageData as APIMaybeUnimportedDataset[]; + return null; + }} onRow={(record: APIMaybeUnimportedDataset) => ({ + onDragStart: () => { + if (!this.props.selectedDatasets.includes(record)) { + this.props.onSelectDataset(record); + } + }, onClick: (event) => { // @ts-expect-error if (event.target?.tagName !== "TD") { @@ -432,11 +447,38 @@ class DatasetTable extends React.PureComponent { // (e.g., the link action and a (de)selection). return; } - if (this.props.onSelectDataset) { - if (this.props.selectedDataset === record) { - this.props.onSelectDataset(null); - } else { - this.props.onSelectDataset(record); + + if (!event.shiftKey || this.props.selectedDatasets.length === 0) { + this.props.onSelectDataset(record, event.ctrlKey || event.metaKey); + } else { + // Shift was pressed and there's already another selected dataset that was not + // clicked just now. + // We are using the current page data as there is no way to get the currently + // rendered datasets otherwise. Also see + // https://github.com/ant-design/ant-design/issues/24022. + const renderedDatasets = this.currentPageData; + + const clickedDatasetIdx = renderedDatasets.indexOf(record); + const selectedIndices = this.props.selectedDatasets.map((selectedDS) => + renderedDatasets.indexOf(selectedDS), + ); + const closestSelectedDatasetIdx = _.minBy(selectedIndices, (idx) => + Math.abs(idx - clickedDatasetIdx), + ); + + if (clickedDatasetIdx == null || closestSelectedDatasetIdx == null) { + return; + } + + const [start, end] = [closestSelectedDatasetIdx, clickedDatasetIdx].sort( + (a, b) => a - b, + ); + + for (let idx = start; idx <= end; idx++) { + // closestSelectedDatasetIdx is already selected (don't deselect it). + if (idx !== closestSelectedDatasetIdx) { + this.props.onSelectDataset(renderedDatasets[idx], true); + } } } }, @@ -466,15 +508,25 @@ class DatasetTable extends React.PureComponent { const y = event.clientY - bounds.top; this.showContextMenuAt(x, y); - this.setState({ datasetForContextMenu: record }); + if (this.props.selectedDatasets.includes(record)) { + this.setState({ + datasetsForContextMenu: this.props.selectedDatasets, + }); + } else { + // If dataset is clicked which is not selected, ignore the selected + // datasets. + this.setState({ + datasetsForContextMenu: [record], + }); + } }, onDoubleClick: () => { window.location.href = `/datasets/${record.owningOrganization}/${record.name}/view`; }, })} rowSelection={{ - selectedRowKeys: this.props.selectedDataset ? [this.props.selectedDataset.name] : [], - onSelectNone: () => this.props.onSelectDataset?.(null), + selectedRowKeys: this.props.selectedDatasets.map((ds) => ds.name), + onSelectNone: () => this.props.onSelectDataset(null), }} > +
{dataset.tags.map((tag) => ( { } function DatasetViewWithLegacyContext({ user }: { user: APIUser }) { const datasetCacheContext = useContext(DatasetCacheContext); - return ; + return ( + {}} + /> + ); } const mapStateToProps = (state: OxalisState): StateProps => ({ diff --git a/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx b/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx index ae133cd267a..4ed7fc8d3bf 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx +++ b/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx @@ -38,6 +38,8 @@ export type DatasetCollectionContextValue = { setActiveFolderId: (id: string | null) => void; mostRecentlyUsedActiveFolderId: string | null; supportsFolders: true; + selectedDatasets: APIMaybeUnimportedDataset[]; + setSelectedDatasets: React.Dispatch>; globalSearchQuery: string | null; setGlobalSearchQuery: (val: string | null) => void; searchRecursively: boolean; @@ -83,6 +85,7 @@ export default function DatasetCollectionContextProvider({ const isMutating = useIsMutating() > 0; const { data: folder } = useFolderQuery(activeFolderId); + const [selectedDatasets, setSelectedDatasets] = useState([]); const [globalSearchQuery, setGlobalSearchQueryInner] = useState(null); const setGlobalSearchQuery = useCallback( (value: string | null) => { @@ -203,6 +206,8 @@ export default function DatasetCollectionContextProvider({ datasetsInFolderQuery.refetch(); }, + selectedDatasets, + setSelectedDatasets, globalSearchQuery, setGlobalSearchQuery, searchRecursively, @@ -238,6 +243,8 @@ export default function DatasetCollectionContextProvider({ updateFolderMutation, moveFolderMutation, updateDatasetMutation, + selectedDatasets, + setSelectedDatasets, globalSearchQuery, ], ); diff --git a/frontend/javascripts/dashboard/dataset_folder_view.tsx b/frontend/javascripts/dashboard/dataset_folder_view.tsx index e58c96b51ca..5255be4e775 100644 --- a/frontend/javascripts/dashboard/dataset_folder_view.tsx +++ b/frontend/javascripts/dashboard/dataset_folder_view.tsx @@ -1,23 +1,12 @@ -import { - FileOutlined, - FolderOpenOutlined, - SearchOutlined, - SettingOutlined, -} from "@ant-design/icons"; -import { Result, Spin, Tag, Tooltip } from "antd"; -import { stringToColor } from "libs/format_utils"; -import { pluralize } from "libs/utils"; -import _ from "lodash"; -import { DatasetExtentRow } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; +import { filterNullValues } from "libs/utils"; import React, { useEffect, useState } from "react"; -import { APIMaybeUnimportedDataset, APIUser, Folder } from "types/api_flow_types"; -import { DatasetLayerTags, DatasetTags, TeamTags } from "./advanced_dataset/dataset_table"; +import { APIMaybeUnimportedDataset, APIUser } from "types/api_flow_types"; import DatasetCollectionContextProvider, { useDatasetCollectionContext, } from "./dataset/dataset_collection_context"; -import { SEARCH_RESULTS_LIMIT, useFolderQuery } from "./dataset/queries"; import DatasetView from "./dataset_view"; +import { DetailsSidebar } from "./folders/details_sidebar"; import { EditFolderModal } from "./folders/edit_folder_modal"; import { FolderTreeSidebar } from "./folders/folder_tree"; @@ -34,17 +23,51 @@ export function DatasetFolderView(props: Props) { } function DatasetFolderViewInner(props: Props) { - const [selectedDataset, setSelectedDataset] = useState(null); const context = useDatasetCollectionContext(); + const { selectedDatasets, setSelectedDatasets } = context; const [folderIdForEditModal, setFolderIdForEditModal] = useState(null); + const setSelectedDataset = (ds: APIMaybeUnimportedDataset | null, multiSelect?: boolean) => { + if (!ds) { + setSelectedDatasets([]); + return; + } + + setSelectedDatasets((oldSelectedDatasets) => { + const set = new Set(oldSelectedDatasets); + + if (multiSelect) { + if (set.has(ds)) { + set.delete(ds); + } else { + set.add(ds); + } + } else { + if (set.has(ds) && set.size === 1) { + set.clear(); + } else { + set.clear(); + set.add(ds); + } + } + return Array.from(set); + }); + }; + useEffect(() => { - if (!selectedDataset || !context.datasets) { + if (selectedDatasets.length === 0 || !context.datasets) { return; } // If the cache changed (e.g., because a dataset was updated), we need to update // the selectedDataset instance, too, to avoid that it refers to stale data. - setSelectedDataset(context.datasets.find((ds) => ds.name === selectedDataset.name) ?? null); + setSelectedDatasets( + filterNullValues( + selectedDatasets.map( + (selectedDataset) => + context.datasets.find((ds) => ds.name === selectedDataset.name) ?? null, + ), + ), + ); }, [context.datasets]); return ( @@ -76,7 +99,7 @@ function DatasetFolderViewInner(props: Props) { @@ -90,7 +113,7 @@ function DatasetFolderViewInner(props: Props) { }} > ); } - -function DetailsSidebar({ - selectedDataset, - setSelectedDataset, - datasetCount, - searchQuery, - activeFolderId, - setFolderIdForEditModal, -}: { - selectedDataset: APIMaybeUnimportedDataset | null; - setSelectedDataset: (ds: APIMaybeUnimportedDataset | null) => void; - datasetCount: number; - searchQuery: string | null; - activeFolderId: string | null; - setFolderIdForEditModal: (value: string | null) => void; -}) { - const context = useDatasetCollectionContext(); - const { data: folder, error } = useFolderQuery(activeFolderId); - - useEffect(() => { - if (selectedDataset == null || !("folderId" in selectedDataset)) { - return; - } - if ( - selectedDataset.folderId !== context.activeFolderId && - context.activeFolderId != null && - context.globalSearchQuery == null - ) { - // Ensure that the selected dataset is in the active folder. If not, - // clear the sidebar. Don't do this when search results are shown (since - // these can cover multiple folders). - // Typically, this is triggered when navigating to another folder. - setSelectedDataset(null); - } - }, [selectedDataset, context.activeFolderId]); - - const maybeSelectMsg = datasetCount > 0 ? "Select one to see details." : ""; - - return ( -
- {selectedDataset != null ? ( - <> -

- {" "} - {selectedDataset.displayName || selectedDataset.name} -

- {selectedDataset.isActive && ( -
- Voxel Size & Extent -
- - - - -
-
-
- )} - {selectedDataset.description && ( -
- Description -
{selectedDataset.description}
-
- )} -
- Access Permissions -
- -
-
- Layers -
-
- {selectedDataset.isActive ? ( -
- Tags - -
- ) : null} - - ) : ( -
- {searchQuery ? ( - } - subTitle={ - datasetCount !== SEARCH_RESULTS_LIMIT ? ( - <> - {datasetCount} {pluralize("dataset", datasetCount)} were found. {maybeSelectMsg} - - ) : ( - <> - At least {SEARCH_RESULTS_LIMIT} datasets match your search criteria.{" "} - {maybeSelectMsg} - - ) - } - /> - ) : ( - <> - {folder ? ( -
-

- - setFolderIdForEditModal(folder.id)} /> - - - {folder.name} -

-

- This folder contains{" "} - - {datasetCount} {pluralize("dataset", datasetCount)}* - - . {maybeSelectMsg} -

- Access Permissions -
- -
- ) : error ? ( - "Could not load folder." - ) : activeFolderId != null ? ( - - ) : null} - - )} -
- )} -
- ); -} - -function FolderTeamTags({ folder }: { folder: Folder }) { - if (folder.allowedTeamsCumulative.length === 0) { - return Administrators & Dataset Managers; - } - const allowedTeamsById = _.keyBy(folder.allowedTeams, "id"); - - return ( - <> - {folder.allowedTeamsCumulative.map((team) => { - const isCumulative = !allowedTeamsById[team.id]; - return ( - - - {team.name} - {isCumulative ? "*" : ""} - - - ); - })} - - ); -} diff --git a/frontend/javascripts/dashboard/dataset_view.tsx b/frontend/javascripts/dashboard/dataset_view.tsx index 7158bd7a046..18d422e987c 100644 --- a/frontend/javascripts/dashboard/dataset_view.tsx +++ b/frontend/javascripts/dashboard/dataset_view.tsx @@ -50,8 +50,8 @@ const { Group: InputGroup } = Input; type Props = { user: APIUser; context: DatasetCacheContextValue | DatasetCollectionContextValue; - onSelectDataset?: (dataset: APIMaybeUnimportedDataset | null) => void; - selectedDataset?: APIMaybeUnimportedDataset | null | undefined; + onSelectDataset: (dataset: APIMaybeUnimportedDataset | null) => void; + selectedDatasets: APIMaybeUnimportedDataset[]; hideDetailsColumns: boolean; }; export type DatasetFilteringMode = "showAllDatasets" | "onlyShowReported" | "onlyShowUnreported"; @@ -165,7 +165,7 @@ function DatasetView(props: Props) { context={props.context} datasets={filteredDatasets} onSelectDataset={props.onSelectDataset} - selectedDataset={props.selectedDataset} + selectedDatasets={props.selectedDatasets} searchQuery={searchQuery || ""} searchTags={searchTags} isUserAdmin={Utils.isUserAdmin(user)} diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx new file mode 100644 index 00000000000..c7c6b5875eb --- /dev/null +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -0,0 +1,248 @@ +import { + FileOutlined, + FolderOpenOutlined, + SearchOutlined, + SettingOutlined, +} from "@ant-design/icons"; +import { Result, Spin, Tag, Tooltip } from "antd"; +import { stringToColor } from "libs/format_utils"; +import { pluralize } from "libs/utils"; +import _ from "lodash"; +import { DatasetExtentRow } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; +import React, { useEffect } from "react"; +import { APIMaybeUnimportedDataset, Folder } from "types/api_flow_types"; +import { DatasetLayerTags, DatasetTags, TeamTags } from "../advanced_dataset/dataset_table"; +import { useDatasetCollectionContext } from "../dataset/dataset_collection_context"; +import { SEARCH_RESULTS_LIMIT, useFolderQuery } from "../dataset/queries"; + +export function DetailsSidebar({ + selectedDatasets, + setSelectedDataset, + datasetCount, + searchQuery, + activeFolderId, + setFolderIdForEditModal, +}: { + selectedDatasets: APIMaybeUnimportedDataset[]; + setSelectedDataset: (ds: APIMaybeUnimportedDataset | null) => void; + datasetCount: number; + searchQuery: string | null; + activeFolderId: string | null; + setFolderIdForEditModal: (value: string | null) => void; +}) { + const context = useDatasetCollectionContext(); + const { data: folder, error } = useFolderQuery(activeFolderId); + + useEffect(() => { + if ( + selectedDatasets.some((ds) => ds.folderId !== context.activeFolderId) && + context.activeFolderId != null && + context.globalSearchQuery == null + ) { + // Ensure that the selected dataset(s) are in the active folder. If not, + // clear the selection. Don't do this when search results are shown (since + // these can cover multiple folders). + // Typically, this is triggered when navigating to another folder. + setSelectedDataset(null); + } + }, [selectedDatasets, context.activeFolderId]); + + return ( +
+ {selectedDatasets.length === 1 ? ( + + ) : selectedDatasets.length > 1 ? ( + + ) : searchQuery ? ( + + ) : ( + + )} +
+ ); +} + +function getMaybeSelectMessage(datasetCount: number) { + return datasetCount > 0 ? "Select one to see details." : ""; +} + +function DatasetDetails({ selectedDataset }: { selectedDataset: APIMaybeUnimportedDataset }) { + const context = useDatasetCollectionContext(); + return ( + <> +

+ {" "} + {selectedDataset.displayName || selectedDataset.name} +

+ {selectedDataset.isActive && ( +
+ Voxel Size & Extent +
+ + + + +
+
+
+ )} + {selectedDataset.description && ( +
+ Description +
{selectedDataset.description}
+
+ )} +
+ Access Permissions +
+ +
+
+ Layers +
+
+ {selectedDataset.isActive ? ( +
+ Tags + +
+ ) : null} + + ); +} + +function DatasetsDetails({ + selectedDatasets, + datasetCount, +}: { + selectedDatasets: APIMaybeUnimportedDataset[]; + datasetCount: number; +}) { + return ( +
+ Selected {selectedDatasets.length} of {datasetCount} datasets. Move them to another folder + with drag and drop. +
+ ); +} + +function SearchDetails({ datasetCount }: { datasetCount: number }) { + const maybeSelectMsg = getMaybeSelectMessage(datasetCount); + return ( + } + subTitle={ + datasetCount !== SEARCH_RESULTS_LIMIT ? ( + <> + {datasetCount} {pluralize("dataset", datasetCount)} were found. {maybeSelectMsg} + + ) : ( + <> + At least {SEARCH_RESULTS_LIMIT} datasets match your search criteria. {maybeSelectMsg} + + ) + } + /> + ); +} + +function FolderDetails({ + activeFolderId, + folder, + datasetCount, + setFolderIdForEditModal, + error, +}: { + activeFolderId: string | null; + folder: Folder | undefined; + datasetCount: number; + setFolderIdForEditModal: (id: string | null) => void; + error: unknown; +}) { + const maybeSelectMsg = getMaybeSelectMessage(datasetCount); + return ( + <> + {folder ? ( +
+

+ + setFolderIdForEditModal(folder.id)} /> + + + {folder.name} +

+

+ This folder contains{" "} + + {datasetCount} {pluralize("dataset", datasetCount)}* + + . {maybeSelectMsg} +

+ Access Permissions +
+ +
+ ) : error ? ( + "Could not load folder." + ) : activeFolderId != null ? ( + + ) : null} + + ); +} + +function FolderTeamTags({ folder }: { folder: Folder }) { + if (folder.allowedTeamsCumulative.length === 0) { + return Administrators & Dataset Managers; + } + const allowedTeamsById = _.keyBy(folder.allowedTeams, "id"); + + return ( + <> + {folder.allowedTeamsCumulative.map((team) => { + const isCumulative = !allowedTeamsById[team.id]; + return ( + + + {team.name} + {isCumulative ? "*" : ""} + + + ); + })} + + ); +} diff --git a/frontend/javascripts/dashboard/folders/folder_tree.tsx b/frontend/javascripts/dashboard/folders/folder_tree.tsx index 8133b02bf6b..d9b08fecefe 100644 --- a/frontend/javascripts/dashboard/folders/folder_tree.tsx +++ b/frontend/javascripts/dashboard/folders/folder_tree.tsx @@ -7,12 +7,11 @@ import { } from "../dataset/dataset_collection_context"; import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"; -import { Dropdown, Menu } from "antd"; +import { Dropdown, Menu, Modal } from "antd"; import Toast from "libs/toast"; import { DragObjectWithType } from "react-dnd"; import Tree, { DataNode, DirectoryTreeProps } from "antd/lib/tree"; import { Key } from "antd/lib/table/interface"; -import { MenuInfo } from "rc-menu/lib/interface"; import memoizeOne from "memoize-one"; import classNames from "classnames"; import { FolderItem } from "types/api_flow_types"; @@ -148,7 +147,7 @@ export function FolderTreeSidebar({ ref={drop} className={isDraggingDataset ? "highlight-folder-sidebar" : ""} style={{ - height: 400, + minHeight: 400, marginRight: 4, borderRadius: 2, paddingLeft: 6, @@ -260,17 +259,63 @@ function FolderItemAsDropTarget(props: { isEditable: boolean; }) { const context = useDatasetCollectionContext(); + const { selectedDatasets, setSelectedDatasets } = context; const { folderId, className, isEditable, ...restProps } = props; const [collectedProps, drop] = useDrop({ accept: DraggableDatasetType, drop: (item: DragObjectWithType & { datasetName: string }) => { - const dataset = context.datasets.find((ds) => ds.name === item.datasetName); + if (selectedDatasets.length > 1) { + if (selectedDatasets.every((ds) => ds.folderId === folderId)) { + Toast.warning( + "The selected datasets are already in the specified folder. No dataset was moved.", + ); + return; + } + + // Show a modal so that the user cannot do anything else while the datasets are being moved. + const modal = Modal.info({ + title: "Moving Datasets", + content: `Preparing to move ${selectedDatasets.length} datasets...`, + onCancel: (_close) => {}, + onOk: (_close) => {}, + okText: null, + }); - if (dataset) { - context.queries.updateDatasetMutation.mutateAsync([dataset, folderId]); + let successCounter = 0; + Promise.all( + selectedDatasets.map((ds) => + context.queries.updateDatasetMutation.mutateAsync([ds, folderId]).then(() => { + successCounter++; + modal.update({ + content: `Already moved ${successCounter} of ${selectedDatasets.length} datasets.`, + }); + }), + ), + ) + .then( + () => Toast.success(`Successfully moved ${selectedDatasets.length} datasets.`), + (err) => { + Toast.error( + `Couldn't move all ${selectedDatasets.length} datasets. See console for details`, + ); + console.error(err); + }, + ) + .finally(() => { + // The datasets are not in the active folder anymore. Clear the selection to avoid + // that stale instances are mutated during the next bulk action. + setSelectedDatasets([]); + modal.destroy(); + }); } else { - Toast.error("Could not move dataset. Please try again."); + const dataset = context.datasets.find((ds) => ds.name === item.datasetName); + + if (dataset) { + context.queries.updateDatasetMutation.mutateAsync([dataset, folderId]); + } else { + Toast.error("Could not move dataset. Please try again."); + } } }, canDrop: () => isEditable, diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 9a69d189494..10648dc5a25 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -625,6 +625,11 @@ export function toNullable(_maybe: Maybe): T | null | undefined { return _maybe.isJust ? _maybe.get() : null; } +export function filterNullValues(arr: Array): T[] { + // @ts-ignore + return arr.filter((el) => el != null); +} + // TODO: Remove this function as it's currently unused // Filters an array given a search string. Supports searching for several words as OR query. // Supports nested properties diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index d5a12a0550c..f4e0968abd2 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -186,3 +186,8 @@ pre.dataset-import-folder-structure-hint { .min-height-0(); } } + +.tags-container { + max-width: 280px; + display: inline-block; +}