From 23995b803b9cd40d38d5eb3334f9a521bdb5fbf4 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Tue, 29 Oct 2024 16:25:09 -0700 Subject: [PATCH] feat(renterd): bulk move files via multiselect drag interaction --- .changeset/cyan-hairs-flash.md | 5 + .changeset/early-toys-know.md | 2 +- .changeset/few-sheep-shout.md | 5 + .changeset/great-points-hide.md | 2 +- .changeset/happy-comics-retire.md | 2 +- .changeset/lazy-pandas-report.md | 2 +- .changeset/olive-cougars-divide.md | 2 +- .changeset/real-lemons-jog.md | 5 + .changeset/two-seas-shake.md | 2 +- apps/renterd-e2e/src/specs/files.spec.ts | 4 +- apps/renterd-e2e/src/specs/filesMove.spec.ts | 196 ++++++++++++++++++ apps/renterd-e2e/src/specs/keys.spec.ts | 2 +- .../Files/batchActions/FilesBatchDelete.tsx | 10 +- .../FilesBatchMove.tsx | 39 ++++ .../FilesDirectoryBatchMenu.tsx | 4 +- .../FilesDirectory/FilesExplorer.tsx | 6 +- .../renterd/contexts/filesDirectory/index.tsx | 41 ++-- apps/renterd/contexts/filesDirectory/move.tsx | 116 ++++++++--- apps/renterd/lib/rename.spec.ts | 155 +++++++++----- apps/renterd/lib/rename.ts | 62 ++++-- .../src/components/Table/index.tsx | 52 +++-- .../src/multi/MultiSelectionMenu.tsx | 2 +- libs/e2e/src/fixtures/mouse.ts | 36 ++++ libs/e2e/src/index.ts | 1 + 24 files changed, 591 insertions(+), 162 deletions(-) create mode 100644 .changeset/cyan-hairs-flash.md create mode 100644 .changeset/few-sheep-shout.md create mode 100644 .changeset/real-lemons-jog.md create mode 100644 apps/renterd-e2e/src/specs/filesMove.spec.ts create mode 100644 apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesBatchMove.tsx create mode 100644 libs/e2e/src/fixtures/mouse.ts diff --git a/.changeset/cyan-hairs-flash.md b/.changeset/cyan-hairs-flash.md new file mode 100644 index 000000000..794377655 --- /dev/null +++ b/.changeset/cyan-hairs-flash.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/e2e': minor +--- + +Added methods for mouse move and hover behaviours. diff --git a/.changeset/early-toys-know.md b/.changeset/early-toys-know.md index a898926cc..8f6688b38 100644 --- a/.changeset/early-toys-know.md +++ b/.changeset/early-toys-know.md @@ -2,4 +2,4 @@ 'renterd': minor --- -The directory-based file explorer now supports multiselect across any files and directories. +The directory-based file explorer now supports multi-select across any files and directories. diff --git a/.changeset/few-sheep-shout.md b/.changeset/few-sheep-shout.md new file mode 100644 index 000000000..fa6021678 --- /dev/null +++ b/.changeset/few-sheep-shout.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Files and directories can now be selected and moved in bulk to a destination folder via drag and drop or the multi-select actions menu. This works even when selecting files (and entire directories) from across multiple different origin directories. diff --git a/.changeset/great-points-hide.md b/.changeset/great-points-hide.md index 1b7391538..8d6815855 100644 --- a/.changeset/great-points-hide.md +++ b/.changeset/great-points-hide.md @@ -2,4 +2,4 @@ '@siafoundation/design-system': minor --- -Added useMultiSelect hook that tracks multiselect state. It supports selection, shift-selecting for ranges, deselection, and works across pagination. +Added useMultiSelect hook that tracks multi-select state. It supports selection, shift-selecting for ranges, deselection, and works across pagination. diff --git a/.changeset/happy-comics-retire.md b/.changeset/happy-comics-retire.md index ca28f9c4b..56fd5176c 100644 --- a/.changeset/happy-comics-retire.md +++ b/.changeset/happy-comics-retire.md @@ -2,4 +2,4 @@ 'renterd': minor --- -The key management table now supports multiselect and batch deletion. +The key management table now supports multi-select and batch deletion. diff --git a/.changeset/lazy-pandas-report.md b/.changeset/lazy-pandas-report.md index 1a8063131..d84f38c7c 100644 --- a/.changeset/lazy-pandas-report.md +++ b/.changeset/lazy-pandas-report.md @@ -2,4 +2,4 @@ 'renterd': minor --- -The "all files" file explorer now supports multiselect across any files. +The "all files" file explorer now supports multi-select across any files. diff --git a/.changeset/olive-cougars-divide.md b/.changeset/olive-cougars-divide.md index 4631b044b..83130002f 100644 --- a/.changeset/olive-cougars-divide.md +++ b/.changeset/olive-cougars-divide.md @@ -2,4 +2,4 @@ 'renterd': minor --- -The "all files" file explorer multiselect menu now supports batch deletion of selected files. +The "all files" file explorer multi-select menu now supports batch deletion of selected files. diff --git a/.changeset/real-lemons-jog.md b/.changeset/real-lemons-jog.md new file mode 100644 index 000000000..040fb7fbe --- /dev/null +++ b/.changeset/real-lemons-jog.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +The table now supports multiple dragging datums. diff --git a/.changeset/two-seas-shake.md b/.changeset/two-seas-shake.md index 235e8a8bc..206502403 100644 --- a/.changeset/two-seas-shake.md +++ b/.changeset/two-seas-shake.md @@ -2,4 +2,4 @@ 'renterd': minor --- -The directory-based file explorer multiselect menu now supports batch deletion of selected files and directories. +The directory-based file explorer multi-select menu now supports batch deletion of selected files and directories. diff --git a/apps/renterd-e2e/src/specs/files.spec.ts b/apps/renterd-e2e/src/specs/files.spec.ts index bd878523d..922b70c2a 100644 --- a/apps/renterd-e2e/src/specs/files.spec.ts +++ b/apps/renterd-e2e/src/specs/files.spec.ts @@ -204,7 +204,7 @@ test('batch delete across nested directories', async ({ page }) => { await file3.click() const file4 = await getFileRowById(page, 'bucket1/dir2/file4.txt') await file4.click() - const menu = page.getByLabel('file multiselect menu') + const menu = page.getByLabel('file multi-select menu') // Delete selected files. await menu.getByLabel('delete selected files').click() @@ -250,7 +250,7 @@ test('batch delete using the all files explorer mode', async ({ page }) => { await file3.click() const file4 = await getFileRowById(page, 'bucket1/dir2/file4.txt') await file4.click() - const menu = page.getByLabel('file multiselect menu') + const menu = page.getByLabel('file multi-select menu') // Delete selected files. await menu.getByLabel('delete selected files').click() diff --git a/apps/renterd-e2e/src/specs/filesMove.spec.ts b/apps/renterd-e2e/src/specs/filesMove.spec.ts new file mode 100644 index 000000000..e7c796b1b --- /dev/null +++ b/apps/renterd-e2e/src/specs/filesMove.spec.ts @@ -0,0 +1,196 @@ +import { test } from '@playwright/test' +import { navigateToBuckets } from '../fixtures/navigate' +import { createBucket, openBucket } from '../fixtures/buckets' +import { + getFileRowById, + openDirectory, + createFilesMap, + expectFilesMap, + navigateToParentDirectory, +} from '../fixtures/files' +import { afterTest, beforeTest } from '../fixtures/beforeTest' +import { hoverMouseOver, moveMouseOver } from '@siafoundation/e2e' + +test.beforeEach(async ({ page }) => { + await beforeTest(page, { + hostdCount: 3, + }) +}) + +test.afterEach(async () => { + await afterTest() +}) + +test('move two files by selecting and dragging from one directory out to another', async ({ + page, +}) => { + test.setTimeout(120_000) + const bucketName = 'bucket1' + await navigateToBuckets({ page }) + await createBucket(page, bucketName) + await createFilesMap(page, bucketName, { + 'file1.txt': null, + dir1: { + 'file2.txt': null, + }, + dir2: { + 'file3.txt': null, + 'file4.txt': null, + dir3: { + 'file5.txt': null, + 'file6.txt': null, + }, + }, + }) + await navigateToBuckets({ page }) + await openBucket(page, bucketName) + + await openDirectory(page, 'bucket1/dir2/') + + // Select file3 and entire dir3. + const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt', true) + await file3.click() + const dir3 = await getFileRowById(page, 'bucket1/dir2/dir3/', true) + await dir3.click() + + // Move all selected files by dragging one of them. + await moveMouseOver(page, file3) + await page.mouse.down() + + const parentDir = await getFileRowById(page, '..', true) + await hoverMouseOver(page, parentDir) + + const file1 = await getFileRowById(page, 'bucket1/file1.txt', true) + await moveMouseOver(page, file1) + await page.mouse.up() + + await expectFilesMap(page, bucketName, { + 'file1.txt': 'visible', + 'file3.txt': 'visible', + dir3: { + 'file5.txt': 'visible', + 'file6.txt': 'visible', + }, + dir1: { + 'file2.txt': 'visible', + }, + dir2: { + 'file3.txt': 'hidden', + 'file4.txt': 'visible', + dir3: 'hidden', + }, + }) +}) + +test('move a file via drag and drop while leaving a separate set of selected files in place', async ({ + page, +}) => { + test.setTimeout(120_000) + const bucketName = 'bucket1' + await navigateToBuckets({ page }) + await createBucket(page, bucketName) + await createFilesMap(page, bucketName, { + 'file1.txt': null, + dir1: { + 'file2.txt': null, + }, + dir2: { + 'file3.txt': null, + 'file4.txt': null, + 'file5.txt': null, + }, + }) + await navigateToBuckets({ page }) + await openBucket(page, bucketName) + + await openDirectory(page, 'bucket1/dir2/') + + // Select file3 and file4. + const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt', true) + await file3.click() + const file4 = await getFileRowById(page, 'bucket1/dir2/file4.txt', true) + await file4.click() + + // Move file5 which is not in the selection. + const file5 = await getFileRowById(page, 'bucket1/dir2/file5.txt', true) + await moveMouseOver(page, file5) + await page.mouse.down() + + const parentDir = await getFileRowById(page, '..', true) + await hoverMouseOver(page, parentDir) + + const file1 = await getFileRowById(page, 'bucket1/file1.txt', true) + await moveMouseOver(page, file1) + await page.mouse.up() + + await expectFilesMap(page, bucketName, { + 'file1.txt': 'visible', + 'file5.txt': 'visible', + dir1: { + 'file2.txt': 'visible', + }, + dir2: { + 'file3.txt': 'hidden', + 'file4.txt': 'hidden', + }, + }) +}) + +test('move files by selecting and using the docked menu batch action', async ({ + page, +}) => { + test.setTimeout(120_000) + const bucketName = 'bucket1' + await navigateToBuckets({ page }) + await createBucket(page, bucketName) + await createFilesMap(page, bucketName, { + 'file1.txt': null, + dir1: { + 'file2.txt': null, + }, + dir2: { + 'file3.txt': null, + 'file4.txt': null, + dir3: { + 'file5.txt': null, + 'file6.txt': null, + }, + }, + }) + await navigateToBuckets({ page }) + await openBucket(page, bucketName) + + await openDirectory(page, 'bucket1/dir2/') + + // Select file3 and entire dir3. + const file3 = await getFileRowById(page, 'bucket1/dir2/file3.txt', true) + await file3.click() + const dir3 = await getFileRowById(page, 'bucket1/dir2/dir3/', true) + await dir3.click() + + await navigateToParentDirectory(page) + + const menu = page.getByLabel('file multi-select menu') + + // Delete selected files. + await menu.getByLabel('move selected files to the current directory').click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'Move' }).click() + + await expectFilesMap(page, bucketName, { + 'file1.txt': 'visible', + 'file3.txt': 'visible', + dir3: { + 'file5.txt': 'visible', + 'file6.txt': 'visible', + }, + dir1: { + 'file2.txt': 'visible', + }, + dir2: { + 'file3.txt': 'hidden', + 'file4.txt': 'visible', + dir3: 'hidden', + }, + }) +}) diff --git a/apps/renterd-e2e/src/specs/keys.spec.ts b/apps/renterd-e2e/src/specs/keys.spec.ts index 67419540c..cc73c804a 100644 --- a/apps/renterd-e2e/src/specs/keys.spec.ts +++ b/apps/renterd-e2e/src/specs/keys.spec.ts @@ -43,7 +43,7 @@ test('batch delete multiple keys', async ({ page }) => { await rowIdx3.click({ modifiers: ['Shift'] }) // Delete all 4 keys. - const menu = page.getByLabel('key multiselect menu') + const menu = page.getByLabel('key multi-select menu') await menu.getByLabel('delete selected keys').click() const dialog = page.getByRole('dialog') await dialog.getByRole('button', { name: 'Delete' }).click() diff --git a/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx b/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx index a0a8def17..1514f652d 100644 --- a/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx +++ b/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx @@ -3,19 +3,15 @@ import { Paragraph, triggerSuccessToast, triggerErrorToast, - MultiSelect, } from '@siafoundation/design-system' import { Delete16 } from '@siafoundation/react-icons' import { useCallback, useMemo } from 'react' import { useDialog } from '../../../contexts/dialog' import { useObjectsRemove } from '@siafoundation/renterd-react' -import { ObjectData } from '../../../contexts/filesManager/types' +import { useFilesDirectory } from '../../../contexts/filesDirectory' -export function FilesBatchDelete({ - multiSelect, -}: { - multiSelect: MultiSelect -}) { +export function FilesBatchDelete() { + const { multiSelect } = useFilesDirectory() const filesToDelete = useMemo( () => Object.entries(multiSelect.selectionMap).map(([_, item]) => ({ diff --git a/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesBatchMove.tsx b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesBatchMove.tsx new file mode 100644 index 000000000..968a8748b --- /dev/null +++ b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesBatchMove.tsx @@ -0,0 +1,39 @@ +import { Button, Paragraph } from '@siafoundation/design-system' +import { FolderMoveTo16 } from '@siafoundation/react-icons' +import { useFilesDirectory } from '../../../contexts/filesDirectory' +import { useDialog } from '../../../contexts/dialog' + +export function FilesBatchMove() { + const { openConfirmDialog } = useDialog() + const { multiSelect, moveSelectedFiles, moveSelectedFilesOperationCount } = + useFilesDirectory() + + return ( + + ) +} diff --git a/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx index f8c8d0ffb..19791620d 100644 --- a/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx +++ b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx @@ -1,13 +1,15 @@ import { MultiSelectionMenu } from '@siafoundation/design-system' import { FilesBatchDelete } from '../../Files/batchActions/FilesBatchDelete' import { useFilesDirectory } from '../../../contexts/filesDirectory' +import { FilesBatchMove } from './FilesBatchMove' export function FilesDirectoryBatchMenu() { const { multiSelect } = useFilesDirectory() return ( - + + ) } diff --git a/apps/renterd/components/FilesDirectory/FilesExplorer.tsx b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx index f08d79a56..f896c78cb 100644 --- a/apps/renterd/components/FilesDirectory/FilesExplorer.tsx +++ b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx @@ -4,6 +4,7 @@ import { EmptyState } from './EmptyState' import { useCanUpload } from '../Files/useCanUpload' import { useFilesManager } from '../../contexts/filesManager' import { columns } from '../../contexts/filesDirectory/columns' +import { pluralize } from '@siafoundation/units' export function FilesExplorer() { const { @@ -24,7 +25,7 @@ export function FilesExplorer() { onDragStart, onDragCancel, onDragMove, - draggingObject, + draggingObjects, } = useFilesDirectory() const canUpload = useCanUpload() return ( @@ -53,7 +54,8 @@ export function FilesExplorer() { onDragEnd={onDragEnd} onDragCancel={onDragCancel} onDragMove={onDragMove} - draggingDatum={draggingObject} + draggingDatums={draggingObjects} + draggingMultipleLabel={(count) => `move ${pluralize(count, 'file')}`} /> diff --git a/apps/renterd/contexts/filesDirectory/index.tsx b/apps/renterd/contexts/filesDirectory/index.tsx index 4918c4e1e..ea6241ac0 100644 --- a/apps/renterd/contexts/filesDirectory/index.tsx +++ b/apps/renterd/contexts/filesDirectory/index.tsx @@ -27,20 +27,6 @@ function useFilesDirectoryMain() { const { limit, marker, isMore, response, refresh, dataset } = useDataset() - const { - onDragEnd, - onDragOver, - onDragCancel, - onDragMove, - onDragStart, - draggingObject, - } = useMove({ - dataset, - activeDirectory, - setActiveDirectory, - refresh, - }) - // Add parent directory to the dataset. const _datasetPage = useMemo(() => { if (!dataset) { @@ -79,6 +65,23 @@ function useFilesDirectoryMain() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeBucket]) + const { + onDragEnd, + onDragOver, + onDragCancel, + onDragMove, + onDragStart, + draggingObjects, + moveSelectedFiles, + moveSelectedFilesOperationCount, + } = useMove({ + dataset, + activeDirectory, + setActiveDirectory, + refresh, + multiSelect, + }) + const datasetPageWithOnClick = useMemo(() => { if (!_datasetPage) { return undefined @@ -106,8 +109,8 @@ function useFilesDirectoryMain() { } return datasetPageWithOnClick.map((d) => { if ( - draggingObject && - draggingObject.id !== d.id && + draggingObjects && + draggingObjects.find((dobj) => dobj.id !== d.id) && d.type === 'directory' ) { return { @@ -120,7 +123,7 @@ function useFilesDirectoryMain() { isDraggable: d.type !== 'bucket' && !d.isUploading, } }) - }, [datasetPageWithOnClick, draggingObject]) + }, [datasetPageWithOnClick, draggingObjects]) const dataState = useDatasetEmptyState( dataset, @@ -162,7 +165,9 @@ function useFilesDirectoryMain() { onDragMove, onDragCancel, onDragOver, - draggingObject, + draggingObjects, + moveSelectedFiles, + moveSelectedFilesOperationCount, } } diff --git a/apps/renterd/contexts/filesDirectory/move.tsx b/apps/renterd/contexts/filesDirectory/move.tsx index b76adc6c5..091d8dad1 100644 --- a/apps/renterd/contexts/filesDirectory/move.tsx +++ b/apps/renterd/contexts/filesDirectory/move.tsx @@ -1,5 +1,5 @@ import { ObjectData } from '../filesManager/types' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { DragStartEvent, DragEndEvent, @@ -9,10 +9,14 @@ import { } from '@dnd-kit/core' import { FullPathSegments, getDirectorySegmentsFromPath } from '../../lib/paths' import { useObjectsRename } from '@siafoundation/renterd-react' -import { triggerErrorToast } from '@siafoundation/design-system' -import { getMoveFileRenameParams } from '../../lib/rename' +import { MultiSelect, triggerErrorToast } from '@siafoundation/design-system' +import { + getMoveFileDestinationDirectory, + getMoveFileOperations, +} from '../../lib/rename' type Props = { + multiSelect: MultiSelect activeDirectory: FullPathSegments setActiveDirectory: ( func: (directory: FullPathSegments) => FullPathSegments @@ -24,41 +28,77 @@ type Props = { const navigationDelay = 500 export function useMove({ + multiSelect, dataset, activeDirectory, setActiveDirectory, refresh, }: Props) { - const [draggingObject, setDraggingObject] = useState( - undefined - ) + const [draggingObjects, setDraggingObjects] = useState< + ObjectData[] | undefined + >(undefined) const [, setNavTimeout] = useState() const rename = useObjectsRename() const moveFiles = useCallback( - async (e: DragEndEvent) => { - const { bucket, from, to, mode } = getMoveFileRenameParams( - e, - activeDirectory - ) - if (from === to) { + async (paths: string[], destinationPath: string) => { + if (!paths.length) { return } - const response = await rename.post({ - payload: { - force: false, - bucket, - from, - to, - mode, - }, - }) + const moveOperations = getMoveFileOperations(paths, destinationPath) + + for (const operation of moveOperations) { + const { bucket, from, to, mode } = operation + const response = await rename.post({ + payload: { + force: false, + bucket, + from, + to, + mode, + }, + }) + if (response.error) { + triggerErrorToast({ + title: 'Error moving files', + body: response.error, + }) + } + } refresh() - if (response.error) { - triggerErrorToast({ title: 'Error moving files', body: response.error }) + multiSelect.deselectAll() + }, + [refresh, rename, multiSelect] + ) + + const moveSelectedFilesOperationCount = useMemo(() => { + const destinationPath = getMoveFileDestinationDirectory(activeDirectory) + return getMoveFileOperations(multiSelect.selectedIds, destinationPath) + .length + }, [multiSelect.selectedIds, activeDirectory]) + + const moveSelectedFiles = useCallback(async () => { + if (!multiSelect.selectedIds.length) { + return + } + const paths = multiSelect.selectedIds + const destinationPath = getMoveFileDestinationDirectory(activeDirectory) + moveFiles(paths, destinationPath) + }, [multiSelect.selectedIds, activeDirectory, moveFiles]) + + const moveDraggedFiles = useCallback( + async (e: DragEndEvent) => { + if (!draggingObjects) { + return } + const paths = draggingObjects.map((o) => o.path) + const destinationPath = getMoveFileDestinationDirectory( + activeDirectory, + e + ) + moveFiles(paths, destinationPath) }, - [refresh, rename, activeDirectory] + [draggingObjects, activeDirectory, moveFiles] ) const delayedNavigation = useCallback( @@ -103,9 +143,19 @@ export function useMove({ const onDragStart = useCallback( (e: DragStartEvent) => { - setDraggingObject(dataset?.find((d) => d.id === e.active.id)) + // If an object included in active multi-selection is dragged, + // drag the selection. + const id = String(e.active.id) + if (multiSelect.selectedIds.includes(id)) { + setDraggingObjects( + Object.entries(multiSelect.selectionMap).map(([, obj]) => obj) + ) + } else { + const ob = dataset?.find((d) => d.id === e.active.id) + setDraggingObjects(ob ? [ob] : undefined) + } }, - [dataset, setDraggingObject] + [dataset, setDraggingObjects, multiSelect] ) const onDragOver = useCallback( @@ -125,18 +175,18 @@ export function useMove({ const onDragEnd = useCallback( async (e: DragEndEvent) => { delayedNavigation(undefined) - setDraggingObject(undefined) - moveFiles(e) + setDraggingObjects(undefined) + moveDraggedFiles(e) }, - [setDraggingObject, delayedNavigation, moveFiles] + [setDraggingObjects, delayedNavigation, moveDraggedFiles] ) const onDragCancel = useCallback( async (e: DragCancelEvent) => { delayedNavigation(undefined) - setDraggingObject(undefined) + setDraggingObjects(undefined) }, - [setDraggingObject, delayedNavigation] + [setDraggingObjects, delayedNavigation] ) return { @@ -145,6 +195,8 @@ export function useMove({ onDragCancel, onDragMove, onDragStart, - draggingObject, + draggingObjects, + moveSelectedFiles, + moveSelectedFilesOperationCount, } } diff --git a/apps/renterd/lib/rename.spec.ts b/apps/renterd/lib/rename.spec.ts index b452bf87b..07f11f1ab 100644 --- a/apps/renterd/lib/rename.spec.ts +++ b/apps/renterd/lib/rename.spec.ts @@ -1,85 +1,130 @@ -import { getMoveFileRenameParams, getRenameFileRenameParams } from './rename' +import { get } from 'http' +import { + getMoveFileDestinationDirectory, + getMoveFileOperations, + getRenameFileRenameParams, +} from './rename' -describe('getMoveFileRenameParams', () => { +describe('getMoveFileOperations', () => { + it('correctly maps from and to paths and sorts more specific operations before broader ones', () => { + expect( + getMoveFileOperations( + [ + 'default/path/a/', + 'default/path/a/b/', + 'default/path/a/b/c.jpeg', + 'other/path/a.png', + 'default/path/correct/noop/', + 'default/path/correct/noop.png', + ], + getMoveFileDestinationDirectory(['default', 'path', 'xxx'], { + collisions: [ + { + id: 'default/path/correct/', + }, + ], + }) + ) + ).toEqual([ + { + bucket: 'default', + from: '/path/a/b/c.jpeg', + to: '/path/correct/c.jpeg', + mode: 'single', + }, + { + bucket: 'default', + from: '/path/a/b/', + to: '/path/correct/b/', + mode: 'multi', + }, + { + bucket: 'default', + from: '/path/a/', + to: '/path/correct/a/', + mode: 'multi', + }, + { + bucket: 'other', + from: '/path/a.png', + to: '/path/correct/a.png', + mode: 'single', + }, + ]) + }) it('directory current', () => { expect( - getMoveFileRenameParams( - { - active: { - id: 'default/path/a/', - }, + getMoveFileOperations( + ['default/path/a/'], + getMoveFileDestinationDirectory(['default', 'path', 'to'], { collisions: [], - }, - ['default', 'path', 'to'] + }) ) - ).toEqual({ - bucket: 'default', - from: '/path/a/', - to: '/path/to/a/', - mode: 'multi', - }) + ).toEqual([ + { + bucket: 'default', + from: '/path/a/', + to: '/path/to/a/', + mode: 'multi', + }, + ]) }) it('directory nested collision', () => { expect( - getMoveFileRenameParams( - { - active: { - id: 'default/path/a/', - }, + getMoveFileOperations( + ['default/path/a/'], + getMoveFileDestinationDirectory(['default', 'path', 'to'], { collisions: [ { id: 'default/path/nested/', }, ], - }, - ['default', 'path', 'to'] + }) ) - ).toEqual({ - bucket: 'default', - from: '/path/a/', - to: '/path/nested/a/', - mode: 'multi', - }) + ).toEqual([ + { + bucket: 'default', + from: '/path/a/', + to: '/path/nested/a/', + mode: 'multi', + }, + ]) }) it('file current', () => { expect( - getMoveFileRenameParams( - { - active: { - id: 'default/path/a', - }, - collisions: [], - }, - ['default', 'path', 'to'] + getMoveFileOperations( + ['default/path/a'], + getMoveFileDestinationDirectory(['default', 'path', 'to']) ) - ).toEqual({ - bucket: 'default', - from: '/path/a', - to: '/path/to/a', - mode: 'single', - }) + ).toEqual([ + { + bucket: 'default', + from: '/path/a', + to: '/path/to/a', + mode: 'single', + }, + ]) }) it('file nested collision', () => { expect( - getMoveFileRenameParams( - { - active: { - id: 'default/path/a', - }, + getMoveFileOperations( + ['default/path/a'], + getMoveFileDestinationDirectory(['default', 'path', 'to'], { collisions: [ { id: 'default/path/nested/', }, ], - }, - ['default', 'path', 'to'] + }) ) - ).toEqual({ - bucket: 'default', - from: '/path/a', - to: '/path/nested/a', - mode: 'single', - }) + ).toEqual([ + { + bucket: 'default', + from: '/path/a', + to: '/path/nested/a', + mode: 'single', + }, + ]) }) }) diff --git a/apps/renterd/lib/rename.ts b/apps/renterd/lib/rename.ts index e38e4953b..5f8b06bb5 100644 --- a/apps/renterd/lib/rename.ts +++ b/apps/renterd/lib/rename.ts @@ -13,30 +13,60 @@ import { type Id = string | number -// Parameters for moving a directory or file to drag destination -export function getMoveFileRenameParams( - e: { active: { id: Id }; collisions: { id: Id }[] | null }, - activeDirectory: FullPathSegments +export function getMoveFileDestinationDirectory( + activeDirectory: FullPathSegments, + e?: { collisions: { id: Id }[] | null } ) { - const fromPath = String(e.active.id) let toPath = pathSegmentsToPath(activeDirectory) - if (e.collisions?.length) { + if (e?.collisions?.length) { if (e.collisions[0].id === '..') { toPath = pathSegmentsToPath(activeDirectory.slice(0, -1)) } else { toPath = String(e.collisions[0].id) } } - const filename = getFilename(fromPath) - const bucket = getBucketFromPath(fromPath) - const from = getKeyFromPath(fromPath) - const to = getKeyFromPath(join(toPath, filename)) - return { - bucket, - from, - to, - mode: filename.endsWith('/') ? 'multi' : 'single', - } as const + return toPath +} + +export function getMoveFileOperations( + paths: FullPath[], + destinationPath: FullPath +) { + const list: { + bucket: string + from: string + to: string + mode: 'multi' | 'single' + }[] = [] + + // Generate initial list with paths mapped to their rename parameters + for (const fromPath of paths) { + const filename = getFilename(fromPath) + const bucket = getBucketFromPath(fromPath) + const from = getKeyFromPath(fromPath) + const to = getKeyFromPath(join(destinationPath, filename)) + if (from === to) { + continue + } + list.push({ + bucket, + from, + to, + mode: filename.endsWith('/') ? 'multi' : 'single', + }) + } + + // Sort list by most specific file or directory first. + // So that specific files are moved directly into the destination + // before their parent directory is moved with all its contents. + list.sort((a, b) => { + if (a.from === b.from) { + return 0 + } + return a.from.startsWith(b.from) ? -1 : 1 + }) + + return list } // Parameters for renaming the name of a file or directory diff --git a/libs/design-system/src/components/Table/index.tsx b/libs/design-system/src/components/Table/index.tsx index 216446edd..45c973327 100644 --- a/libs/design-system/src/components/Table/index.tsx +++ b/libs/design-system/src/components/Table/index.tsx @@ -77,7 +77,8 @@ type Props< onDragMove?: (e: DragMoveEvent) => void onDragEnd?: (e: DragEndEvent) => void onDragCancel?: (e: DragCancelEvent) => void - draggingDatum?: D + draggingDatums?: D[] + draggingMultipleLabel?: (n: number) => string testId?: string } @@ -105,7 +106,8 @@ export function Table< onDragMove, onDragEnd, onDragCancel, - draggingDatum, + draggingDatums, + draggingMultipleLabel = (n) => `Move selection (${n})`, testId, }: Props) { let show = 'emptyState' @@ -175,24 +177,32 @@ export function Table< onDragCancel={onDragCancel} > - {draggingDatum && ( - - - -
-
- )} + {draggingDatums ? ( + draggingDatums.length === 1 ? ( + + + + + +
+
+ ) : ( + + {draggingMultipleLabel(draggingDatums.length)} + + ) + ) : null}
{show === 'currentData' && data?.map((row) => { - if (draggingDatum?.id === row.id) { + if (draggingDatums?.find((d) => d.id === row.id)) { return null } diff --git a/libs/design-system/src/multi/MultiSelectionMenu.tsx b/libs/design-system/src/multi/MultiSelectionMenu.tsx index f85859a19..0966d7758 100644 --- a/libs/design-system/src/multi/MultiSelectionMenu.tsx +++ b/libs/design-system/src/multi/MultiSelectionMenu.tsx @@ -28,7 +28,7 @@ export function MultiSelectionMenu({ return ( {!!multiSelect.selectionCount && ( diff --git a/libs/e2e/src/fixtures/mouse.ts b/libs/e2e/src/fixtures/mouse.ts new file mode 100644 index 000000000..00996e1c2 --- /dev/null +++ b/libs/e2e/src/fixtures/mouse.ts @@ -0,0 +1,36 @@ +import { Locator, Page } from 'playwright' +import { step } from './step' + +export const moveMouseOver = step( + 'move mouse over', + async (page: Page, locator: Locator) => { + const box = await locator.boundingBox() + if (!box) { + throw new Error(`Element not found: ${locator}`) + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + } +) + +export const hoverMouseOver = step( + 'hover mouse over', + async (page: Page, locator: Locator, hoverDuration = 1000) => { + const box = await locator.boundingBox() + + if (!box) { + throw new Error(`Element not found: ${locator}`) + } + + const hoverX = box.x + box.width / 2 + const hoverY = box.y + box.height / 2 + + // Hover with micro-movements to keep the drag state active. + // Move every 100ms. + const hoverStep = 100 + for (let i = 0; i < hoverDuration / hoverStep; i++) { + // Slight wiggle to maintain drag. + await page.mouse.move(hoverX + (i % 2), hoverY + (i % 2)) + await page.waitForTimeout(hoverStep) + } + } +) diff --git a/libs/e2e/src/index.ts b/libs/e2e/src/index.ts index b6614d859..452ff3653 100644 --- a/libs/e2e/src/index.ts +++ b/libs/e2e/src/index.ts @@ -12,3 +12,4 @@ export * from './fixtures/table' export * from './fixtures/skip' export * from './fixtures/step' export * from './fixtures/expect' +export * from './fixtures/mouse'