From 5f1cb4bd35f2ef629ba9d2aafbf5e33963c8d45a 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/few-sheep-shout.md | 5 + apps/renterd-e2e/src/specs/filesDrag.spec.ts | 75 ++++++++++ .../FilesDirectory/FilesExplorer.tsx | 4 +- .../renterd/contexts/filesDirectory/index.tsx | 37 ++--- apps/renterd/contexts/filesDirectory/move.tsx | 77 ++++++---- apps/renterd/lib/rename.spec.ts | 131 ++++++++++++------ apps/renterd/lib/rename.ts | 60 ++++++-- .../src/components/Table/index.tsx | 48 ++++--- libs/e2e/src/fixtures/mouse.ts | 36 +++++ libs/e2e/src/index.ts | 1 + 10 files changed, 347 insertions(+), 127 deletions(-) create mode 100644 .changeset/few-sheep-shout.md create mode 100644 apps/renterd-e2e/src/specs/filesDrag.spec.ts create mode 100644 libs/e2e/src/fixtures/mouse.ts diff --git a/.changeset/few-sheep-shout.md b/.changeset/few-sheep-shout.md new file mode 100644 index 000000000..0c42d4f50 --- /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. This works even when selecting files (and entire directories) from across multiple different origin directories. diff --git a/apps/renterd-e2e/src/specs/filesDrag.spec.ts b/apps/renterd-e2e/src/specs/filesDrag.spec.ts new file mode 100644 index 000000000..1b883abe3 --- /dev/null +++ b/apps/renterd-e2e/src/specs/filesDrag.spec.ts @@ -0,0 +1,75 @@ +import { test } from '@playwright/test' +import { navigateToBuckets } from '../fixtures/navigate' +import { createBucket, openBucket } from '../fixtures/buckets' +import { + getFileRowById, + openDirectory, + createFilesMap, + expectFilesMap, +} 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, + '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() + + 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', + 'file4.txt': 'visible', + dir1: { + 'file2.txt': 'visible', + }, + dir2: { + 'file5.txt': 'visible', + 'file3.txt': 'hidden', + 'file4.txt': 'hidden', + }, + }) +}) diff --git a/apps/renterd/components/FilesDirectory/FilesExplorer.tsx b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx index f08d79a56..196bb7a63 100644 --- a/apps/renterd/components/FilesDirectory/FilesExplorer.tsx +++ b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx @@ -24,7 +24,7 @@ export function FilesExplorer() { onDragStart, onDragCancel, onDragMove, - draggingObject, + draggingObjects, } = useFilesDirectory() const canUpload = useCanUpload() return ( @@ -53,7 +53,7 @@ export function FilesExplorer() { onDragEnd={onDragEnd} onDragCancel={onDragCancel} onDragMove={onDragMove} - draggingDatum={draggingObject} + draggingDatums={draggingObjects} /> diff --git a/apps/renterd/contexts/filesDirectory/index.tsx b/apps/renterd/contexts/filesDirectory/index.tsx index 4918c4e1e..bce9762a2 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,21 @@ function useFilesDirectoryMain() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeBucket]) + const { + onDragEnd, + onDragOver, + onDragCancel, + onDragMove, + onDragStart, + draggingObjects, + } = useMove({ + dataset, + activeDirectory, + setActiveDirectory, + refresh, + multiSelect, + }) + const datasetPageWithOnClick = useMemo(() => { if (!_datasetPage) { return undefined @@ -106,8 +107,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 +121,7 @@ function useFilesDirectoryMain() { isDraggable: d.type !== 'bucket' && !d.isUploading, } }) - }, [datasetPageWithOnClick, draggingObject]) + }, [datasetPageWithOnClick, draggingObjects]) const dataState = useDatasetEmptyState( dataset, @@ -162,7 +163,7 @@ function useFilesDirectoryMain() { onDragMove, onDragCancel, onDragOver, - draggingObject, + draggingObjects, } } diff --git a/apps/renterd/contexts/filesDirectory/move.tsx b/apps/renterd/contexts/filesDirectory/move.tsx index b76adc6c5..cd3e7433e 100644 --- a/apps/renterd/contexts/filesDirectory/move.tsx +++ b/apps/renterd/contexts/filesDirectory/move.tsx @@ -9,10 +9,11 @@ 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 { getMoveFileOperations } from '../../lib/rename' type Props = { + multiSelect: MultiSelect activeDirectory: FullPathSegments setActiveDirectory: ( func: (directory: FullPathSegments) => FullPathSegments @@ -24,41 +25,47 @@ 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) { + if (!draggingObjects) { return } - const response = await rename.post({ - payload: { - force: false, - bucket, - from, - to, - mode, - }, - }) - refresh() - if (response.error) { - triggerErrorToast({ title: 'Error moving files', body: response.error }) + const paths = draggingObjects.map((o) => o.path) + const moveOperations = getMoveFileOperations(paths, e, activeDirectory) + + 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() }, - [refresh, rename, activeDirectory] + [refresh, rename, activeDirectory, draggingObjects] ) const delayedNavigation = useCallback( @@ -103,9 +110,19 @@ export function useMove({ const onDragStart = useCallback( (e: DragStartEvent) => { - setDraggingObject(dataset?.find((d) => d.id === e.active.id)) + // If an object included in active multiselection 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 +142,18 @@ export function useMove({ const onDragEnd = useCallback( async (e: DragEndEvent) => { delayedNavigation(undefined) - setDraggingObject(undefined) + setDraggingObjects(undefined) moveFiles(e) }, - [setDraggingObject, delayedNavigation, moveFiles] + [setDraggingObjects, delayedNavigation, moveFiles] ) const onDragCancel = useCallback( async (e: DragCancelEvent) => { delayedNavigation(undefined) - setDraggingObject(undefined) + setDraggingObjects(undefined) }, - [setDraggingObject, delayedNavigation] + [setDraggingObjects, delayedNavigation] ) return { @@ -145,6 +162,6 @@ export function useMove({ onDragCancel, onDragMove, onDragStart, - draggingObject, + draggingObjects, } } diff --git a/apps/renterd/lib/rename.spec.ts b/apps/renterd/lib/rename.spec.ts index b452bf87b..1af7ad224 100644 --- a/apps/renterd/lib/rename.spec.ts +++ b/apps/renterd/lib/rename.spec.ts @@ -1,31 +1,76 @@ -import { getMoveFileRenameParams, getRenameFileRenameParams } from './rename' +import { 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', + ], + { + collisions: [ + { + id: 'default/path/correct/', + }, + ], + }, + ['default', 'path', 'xxx'] + ) + ).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( + getMoveFileOperations( + ['default/path/a/'], { - active: { - id: 'default/path/a/', - }, 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( + getMoveFileOperations( + ['default/path/a/'], { - active: { - id: 'default/path/a/', - }, collisions: [ { id: 'default/path/nested/', @@ -34,38 +79,38 @@ describe('getMoveFileRenameParams', () => { }, ['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( + getMoveFileOperations( + ['default/path/a'], { - active: { - id: 'default/path/a', - }, collisions: [], }, ['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( + getMoveFileOperations( + ['default/path/a'], { - active: { - id: 'default/path/a', - }, collisions: [ { id: 'default/path/nested/', @@ -74,12 +119,14 @@ describe('getMoveFileRenameParams', () => { }, ['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..1ac90f8b6 100644 --- a/apps/renterd/lib/rename.ts +++ b/apps/renterd/lib/rename.ts @@ -13,12 +13,10 @@ 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 }, +export function getMoveFileDestinationDirectory( + e: { collisions: { id: Id }[] | null }, activeDirectory: FullPathSegments ) { - const fromPath = String(e.active.id) let toPath = pathSegmentsToPath(activeDirectory) if (e.collisions?.length) { if (e.collisions[0].id === '..') { @@ -27,16 +25,50 @@ export function getMoveFileRenameParams( 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[], + e: { collisions: { id: Id }[] | null }, + activeDirectory: FullPathSegments +) { + const toPath = getMoveFileDestinationDirectory(e, activeDirectory) + 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(toPath, 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..60e89e70b 100644 --- a/libs/design-system/src/components/Table/index.tsx +++ b/libs/design-system/src/components/Table/index.tsx @@ -77,7 +77,7 @@ type Props< onDragMove?: (e: DragMoveEvent) => void onDragEnd?: (e: DragEndEvent) => void onDragCancel?: (e: DragCancelEvent) => void - draggingDatum?: D + draggingDatums?: D[] testId?: string } @@ -105,7 +105,7 @@ export function Table< onDragMove, onDragEnd, onDragCancel, - draggingDatum, + draggingDatums, testId, }: Props) { let show = 'emptyState' @@ -175,24 +175,30 @@ export function Table< onDragCancel={onDragCancel} > - {draggingDatum && ( - - - -
-
- )} + {draggingDatums ? ( + draggingDatums.length === 1 ? ( + + + +
+
+ ) : ( + + Move selection ({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/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'