Skip to content

Commit 24271cc

Browse files
authored
Implement multi selection of datasets in folder tab (#6683)
* allow to select multiple datasets in the folders tab * complete multi select logic for all combinations of click, ctrl+click and drag * move details sidebar into own module * refactor sidebar sub components and display 'n datasets are selected' in multi selection case * show progress indicator when moving multiple datasets * fix not tall enough sidebar highlighting during drag * show 'selected x of z datasets' during multi select * implement range select with shift modifier * update changelog and docs * fix incorrect range selection when default ordering is used * improve selection click by shrinking invisible tag container * show empty context menu when right clicking multiple datasets * show warning when moving datasets to the folder where there are already located
1 parent 290a82c commit 24271cc

13 files changed

+467
-234
lines changed

CHANGELOG.unreleased.md

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
1414
- Added sign in via OIDC. [#6534](https://github.com/scalableminds/webknossos/pull/6534)
1515
- 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)
1616
- Added the option to search a specific folder in the new datasets tab. [#6677](https://github.com/scalableminds/webknossos/pull/6677)
17+
- 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)
1718

1819
### Changed
1920
- webKnossos is now able to recover from a lost webGL context. [#6663](https://github.com/scalableminds/webknossos/pull/6663)

docs/dashboard.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ You can *view* a dataset (read-only) or start new annotations from this screen.
99
Search for your dataset by using the search bar or sorting any of the table columns.
1010
Learn more about managing datasets in the [Datasets guide](./datasets.md).
1111

12-
The presentation differs corresponding to your user role.
12+
The presentation differs depending on your user role.
1313
Regular users can only start or continue annotations and work on tasks.
1414
[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.
1515

16+
Read more about the organization of datasets [here](./datasets.md#dataset-organization).
17+
1618
![Dashboard for Team Managers or Admins with access to dataset settings and additional administration actions.](./images/dashboard_datasets.jpeg)
1719
![Dashboard for Regular Users](./images/dashboard_regular_user.jpeg)
1820

docs/datasets.md

+2
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ This is because the access permissions are handled cumulatively.
221221
In addition to the folder organization, datasets can also be tagged.
222222
Use the tags column to do so or select a dataset with a click and use the right sidebar.
223223

224+
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.
225+
224226
## Dataset Sharing
225227
Read more in the [Sharing guide](./sharing.md#dataset-sharing)
226228

frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.tsx

+19-2
Original file line numberDiff line numberDiff line change
@@ -252,13 +252,30 @@ const onClearCache = async (
252252

253253
export function getDatasetActionContextMenu({
254254
reloadDataset,
255-
dataset,
255+
datasets,
256256
hideContextMenu,
257257
}: {
258258
reloadDataset: (arg0: APIDatasetId) => Promise<void>;
259-
dataset: APIMaybeUnimportedDataset;
259+
datasets: APIMaybeUnimportedDataset[];
260260
hideContextMenu: () => void;
261261
}) {
262+
if (datasets.length !== 1) {
263+
return (
264+
<Menu
265+
onClick={hideContextMenu}
266+
style={{
267+
borderRadius: 6,
268+
}}
269+
mode="vertical"
270+
>
271+
<Menu.Item key="view" disabled>
272+
No actions available.
273+
</Menu.Item>
274+
</Menu>
275+
);
276+
}
277+
const dataset = datasets[0];
278+
262279
return (
263280
<Menu
264281
onClick={hideContextMenu}

frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx

+71-19
Original file line numberDiff line numberDiff line change
@@ -59,36 +59,36 @@ type Props = {
5959
reloadDataset: (arg0: APIDatasetId, arg1?: Array<APIMaybeUnimportedDataset>) => Promise<void>;
6060
updateDataset: (arg0: APIDataset) => Promise<void>;
6161
addTagToSearch: (tag: string) => void;
62-
onSelectDataset?: (dataset: APIMaybeUnimportedDataset | null) => void;
63-
selectedDataset?: APIMaybeUnimportedDataset | null | undefined;
62+
onSelectDataset: (dataset: APIMaybeUnimportedDataset | null, multiSelect?: boolean) => void;
63+
selectedDatasets: APIMaybeUnimportedDataset[];
6464
hideDetailsColumns?: boolean;
6565
context: DatasetCacheContextValue | DatasetCollectionContextValue;
6666
};
6767
type State = {
6868
prevSearchQuery: string;
6969
sortedInfo: SorterResult<string>;
7070
contextMenuPosition: [number, number] | null | undefined;
71-
datasetForContextMenu: APIMaybeUnimportedDataset | null;
71+
datasetsForContextMenu: APIMaybeUnimportedDataset[];
7272
};
7373

7474
type ContextMenuProps = {
7575
contextMenuPosition: [number, number] | null | undefined;
7676
hideContextMenu: () => void;
77-
dataset: APIMaybeUnimportedDataset | null;
77+
datasets: APIMaybeUnimportedDataset[];
7878
reloadDataset: Props["reloadDataset"];
7979
};
8080

8181
function ContextMenuInner(propsWithInputRef: ContextMenuProps) {
8282
const inputRef = React.useContext(ContextMenuContext);
83-
const { dataset, reloadDataset, contextMenuPosition, hideContextMenu } = propsWithInputRef;
83+
const { datasets, reloadDataset, contextMenuPosition, hideContextMenu } = propsWithInputRef;
8484
let overlay = <div />;
8585

86-
if (contextMenuPosition != null && dataset != null) {
86+
if (contextMenuPosition != null) {
8787
// getDatasetActionContextMenu should not be turned into <DatasetActionMenu />
8888
// as this breaks antd's styling of the menu within the dropdown.
8989
overlay = getDatasetActionContextMenu({
9090
hideContextMenu,
91-
dataset,
91+
datasets,
9292
reloadDataset,
9393
});
9494
}
@@ -239,8 +239,12 @@ class DatasetTable extends React.PureComponent<Props, State> {
239239
},
240240
prevSearchQuery: "",
241241
contextMenuPosition: null,
242-
datasetForContextMenu: null,
242+
datasetsForContextMenu: [],
243243
};
244+
// currentPageData is only used for range selection (and not during
245+
// rendering). That's why it's not included in this.state (also it
246+
// would lead to infinite loops, too).
247+
currentPageData: APIMaybeUnimportedDataset[] = [];
244248

245249
static getDerivedStateFromProps(nextProps: Props, prevState: State): Partial<State> {
246250
const maybeSortedInfo: SorterResult<string> | {} = // Clear the sorting exactly when the search box is initially filled
@@ -263,7 +267,6 @@ class DatasetTable extends React.PureComponent<Props, State> {
263267
_pagination: TablePaginationConfig,
264268
_filters: Record<string, FilterValue | null>,
265269
sorter: SorterResult<RecordType> | SorterResult<RecordType>[],
266-
_extra: TableCurrentDataSource<RecordType>,
267270
) => {
268271
this.setState({
269272
// @ts-ignore
@@ -407,7 +410,7 @@ class DatasetTable extends React.PureComponent<Props, State> {
407410
hideContextMenu={() => {
408411
this.setState({ contextMenuPosition: null });
409412
}}
410-
dataset={this.state.datasetForContextMenu}
413+
datasets={this.state.datasetsForContextMenu}
411414
reloadDataset={this.props.reloadDataset}
412415
contextMenuPosition={this.state.contextMenuPosition}
413416
/>
@@ -423,7 +426,19 @@ class DatasetTable extends React.PureComponent<Props, State> {
423426
locale={{
424427
emptyText: this.renderEmptyText(),
425428
}}
429+
summary={(currentPageData) => {
430+
// Workaround to get to the currently rendered entries (since the ordering
431+
// is managed by antd).
432+
// Also see https://github.com/ant-design/ant-design/issues/24022.
433+
this.currentPageData = currentPageData as APIMaybeUnimportedDataset[];
434+
return null;
435+
}}
426436
onRow={(record: APIMaybeUnimportedDataset) => ({
437+
onDragStart: () => {
438+
if (!this.props.selectedDatasets.includes(record)) {
439+
this.props.onSelectDataset(record);
440+
}
441+
},
427442
onClick: (event) => {
428443
// @ts-expect-error
429444
if (event.target?.tagName !== "TD") {
@@ -432,11 +447,38 @@ class DatasetTable extends React.PureComponent<Props, State> {
432447
// (e.g., the link action and a (de)selection).
433448
return;
434449
}
435-
if (this.props.onSelectDataset) {
436-
if (this.props.selectedDataset === record) {
437-
this.props.onSelectDataset(null);
438-
} else {
439-
this.props.onSelectDataset(record);
450+
451+
if (!event.shiftKey || this.props.selectedDatasets.length === 0) {
452+
this.props.onSelectDataset(record, event.ctrlKey || event.metaKey);
453+
} else {
454+
// Shift was pressed and there's already another selected dataset that was not
455+
// clicked just now.
456+
// We are using the current page data as there is no way to get the currently
457+
// rendered datasets otherwise. Also see
458+
// https://github.com/ant-design/ant-design/issues/24022.
459+
const renderedDatasets = this.currentPageData;
460+
461+
const clickedDatasetIdx = renderedDatasets.indexOf(record);
462+
const selectedIndices = this.props.selectedDatasets.map((selectedDS) =>
463+
renderedDatasets.indexOf(selectedDS),
464+
);
465+
const closestSelectedDatasetIdx = _.minBy(selectedIndices, (idx) =>
466+
Math.abs(idx - clickedDatasetIdx),
467+
);
468+
469+
if (clickedDatasetIdx == null || closestSelectedDatasetIdx == null) {
470+
return;
471+
}
472+
473+
const [start, end] = [closestSelectedDatasetIdx, clickedDatasetIdx].sort(
474+
(a, b) => a - b,
475+
);
476+
477+
for (let idx = start; idx <= end; idx++) {
478+
// closestSelectedDatasetIdx is already selected (don't deselect it).
479+
if (idx !== closestSelectedDatasetIdx) {
480+
this.props.onSelectDataset(renderedDatasets[idx], true);
481+
}
440482
}
441483
}
442484
},
@@ -466,15 +508,25 @@ class DatasetTable extends React.PureComponent<Props, State> {
466508
const y = event.clientY - bounds.top;
467509

468510
this.showContextMenuAt(x, y);
469-
this.setState({ datasetForContextMenu: record });
511+
if (this.props.selectedDatasets.includes(record)) {
512+
this.setState({
513+
datasetsForContextMenu: this.props.selectedDatasets,
514+
});
515+
} else {
516+
// If dataset is clicked which is not selected, ignore the selected
517+
// datasets.
518+
this.setState({
519+
datasetsForContextMenu: [record],
520+
});
521+
}
470522
},
471523
onDoubleClick: () => {
472524
window.location.href = `/datasets/${record.owningOrganization}/${record.name}/view`;
473525
},
474526
})}
475527
rowSelection={{
476-
selectedRowKeys: this.props.selectedDataset ? [this.props.selectedDataset.name] : [],
477-
onSelectNone: () => this.props.onSelectDataset?.(null),
528+
selectedRowKeys: this.props.selectedDatasets.map((ds) => ds.name),
529+
onSelectNone: () => this.props.onSelectDataset(null),
478530
}}
479531
>
480532
<Column
@@ -644,7 +696,7 @@ export function DatasetTags({
644696
};
645697

646698
return (
647-
<div style={{ maxWidth: 280 }}>
699+
<div className="tags-container">
648700
{dataset.tags.map((tag) => (
649701
<CategorizationLabel
650702
tag={tag}

frontend/javascripts/dashboard/dashboard_view.tsx

+9-1
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,15 @@ class DashboardView extends PureComponent<PropsWithRouter, State> {
284284
}
285285
function DatasetViewWithLegacyContext({ user }: { user: APIUser }) {
286286
const datasetCacheContext = useContext(DatasetCacheContext);
287-
return <DatasetView user={user} hideDetailsColumns={false} context={datasetCacheContext} />;
287+
return (
288+
<DatasetView
289+
user={user}
290+
hideDetailsColumns={false}
291+
context={datasetCacheContext}
292+
selectedDatasets={[]}
293+
onSelectDataset={() => {}}
294+
/>
295+
);
288296
}
289297

290298
const mapStateToProps = (state: OxalisState): StateProps => ({

frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export type DatasetCollectionContextValue = {
3838
setActiveFolderId: (id: string | null) => void;
3939
mostRecentlyUsedActiveFolderId: string | null;
4040
supportsFolders: true;
41+
selectedDatasets: APIMaybeUnimportedDataset[];
42+
setSelectedDatasets: React.Dispatch<React.SetStateAction<APIMaybeUnimportedDataset[]>>;
4143
globalSearchQuery: string | null;
4244
setGlobalSearchQuery: (val: string | null) => void;
4345
searchRecursively: boolean;
@@ -83,6 +85,7 @@ export default function DatasetCollectionContextProvider({
8385
const isMutating = useIsMutating() > 0;
8486
const { data: folder } = useFolderQuery(activeFolderId);
8587

88+
const [selectedDatasets, setSelectedDatasets] = useState<APIMaybeUnimportedDataset[]>([]);
8689
const [globalSearchQuery, setGlobalSearchQueryInner] = useState<string | null>(null);
8790
const setGlobalSearchQuery = useCallback(
8891
(value: string | null) => {
@@ -203,6 +206,8 @@ export default function DatasetCollectionContextProvider({
203206

204207
datasetsInFolderQuery.refetch();
205208
},
209+
selectedDatasets,
210+
setSelectedDatasets,
206211
globalSearchQuery,
207212
setGlobalSearchQuery,
208213
searchRecursively,
@@ -238,6 +243,8 @@ export default function DatasetCollectionContextProvider({
238243
updateFolderMutation,
239244
moveFolderMutation,
240245
updateDatasetMutation,
246+
selectedDatasets,
247+
setSelectedDatasets,
241248
globalSearchQuery,
242249
],
243250
);

0 commit comments

Comments
 (0)