Skip to content

Commit

Permalink
feat(renterd): directory mode files multiselect and batch delete
Browse files Browse the repository at this point in the history
  • Loading branch information
alexfreska committed Oct 28, 2024
1 parent bde6f3a commit daba970
Show file tree
Hide file tree
Showing 31 changed files with 606 additions and 102 deletions.
5 changes: 5 additions & 0 deletions .changeset/bright-camels-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

Navigating into a directory in the file explorer is now by clicking on the directory name rather than anywhere on the row.
5 changes: 5 additions & 0 deletions .changeset/early-toys-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The directory-based file explorer now supports multiselect across any files and directories.
5 changes: 5 additions & 0 deletions .changeset/lazy-pandas-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The "all files" file explorer now supports multiselect across any files.
5 changes: 5 additions & 0 deletions .changeset/olive-cougars-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The "all files" file explorer multiselect menu now supports batch deletion of selected files.
5 changes: 5 additions & 0 deletions .changeset/two-seas-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The directory-based file explorer multiselect menu now supports batch deletion of selected files and directories.
97 changes: 94 additions & 3 deletions apps/renterd-e2e/src/fixtures/files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Page, expect } from '@playwright/test'
import { readFileSync } from 'fs'
import { fillTextInputByName } from '@siafoundation/e2e'
import { navigateToBuckets } from './navigate'
import { openBucket } from './buckets'
import { join } from 'path'

export async function deleteFile(page: Page, path: string) {
await openFileContextMenu(page, path)
Expand Down Expand Up @@ -62,12 +65,26 @@ export async function openFileContextMenu(page: Page, path: string) {
}

export async function openDirectory(page: Page, path: string) {
await page.getByTestId('filesTable').getByTestId(path).click()
const parts = path.split('/')
const name = parts[parts.length - 2] + '/'
await page.getByTestId('filesTable').getByTestId(path).getByText(name).click()
for (const dir of path.split('/').slice(0, -1)) {
await expect(page.getByTestId('navbar').getByText(dir)).toBeVisible()
}
}

export async function openDirectoryFromAnywhere(page: Page, path: string) {
const bucket = path.split('/')[0]
const dirParts = path.split('/').slice(1)
await navigateToBuckets({ page })
await openBucket(page, path.split('/')[0])
let currentPath = bucket + '/'
for (const dir of dirParts) {
currentPath += dir + '/'
await openDirectory(page, currentPath)
}
}

export async function navigateToParentDirectory(page: Page) {
const isEmpty = await page
.getByText('The current directory does not contain any files yet')
Expand Down Expand Up @@ -107,11 +124,11 @@ export async function fileNotInList(page: Page, path: string) {
await expect(page.getByTestId('filesTable').getByTestId(path)).toBeHidden()
}

export async function getFileRowById(page: Page, id: string) {
export function getFileRowById(page: Page, id: string) {
return page.getByTestId('filesTable').getByTestId(id)
}

export async function dragAndDropFile(
async function simulateDragAndDropFile(
page: Page,
selector: string,
filePath: string,
Expand Down Expand Up @@ -139,3 +156,77 @@ export async function dragAndDropFile(

await page.dispatchEvent(selector, 'drop', { dataTransfer })
}

export async function dragAndDropFileFromSystem(
page: Page,
systemFilePath: string,
localFileName?: string
) {
await simulateDragAndDropFile(
page,
`[data-testid=filesDropzone]`,
join(__dirname, 'sample-files', systemFilePath),
'/' + (localFileName || systemFilePath)
)
}

export interface FileMap {
[key: string]: string | FileMap
}

// Iterate through the file map and create files/directories.
export async function createFilesMap(
page: Page,
bucketName: string,
map: FileMap
) {
const create = async (map: FileMap, stack: string[]) => {
for (const name in map) {
await openDirectoryFromAnywhere(page, stack.join('/'))
const currentDirPath = stack.join('/')
const path = `${currentDirPath}/${name}`
if (!!map[name] && typeof map[name] === 'object') {
await createDirectory(page, name)
await fileInList(page, path + '/')
await create(map[name] as FileMap, stack.concat(name))
} else {
await dragAndDropFileFromSystem(page, 'sample.txt', name)
await fileInList(page, path)
}
}
}
await create(map, [bucketName])
await navigateToBuckets({ page })
await openBucket(page, bucketName)
}

interface FileExpectMap {
[key: string]: 'visible' | 'hidden' | FileExpectMap
}

// Check each file and directory in the map exists.
export async function expectFilesMap(
page: Page,
bucketName: string,
map: FileExpectMap
) {
const check = async (map: FileMap, stack: string[]) => {
for (const name in map) {
await openDirectoryFromAnywhere(page, stack.join('/'))
const currentDirPath = stack.join('/')
const path = `${currentDirPath}/${name}`
if (typeof map[name] === 'string') {
const state = map[name] as 'visible' | 'hidden'
if (state === 'visible') {
await fileInList(page, path)
} else {
await fileNotInList(page, path)
}
} else {
await fileInList(page, path + '/')
await check(map[name] as FileMap, stack.concat(name))
}
}
}
await check(map, [bucketName])
}
File renamed without changes.
118 changes: 97 additions & 21 deletions apps/renterd-e2e/src/specs/files.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { test, expect } from '@playwright/test'
import { navigateToBuckets } from '../fixtures/navigate'
import { createBucket, deleteBucket, openBucket } from '../fixtures/buckets'
import path from 'path'
import {
deleteDirectory,
deleteFile,
dragAndDropFile,
fileInList,
fileNotInList,
getFileRowById,
navigateToParentDirectory,
openDirectory,
openFileContextMenu,
createDirectory,
dragAndDropFileFromSystem,
createFilesMap,
expectFilesMap,
} from '../fixtures/files'
import { afterTest, beforeTest } from '../fixtures/beforeTest'
import { clearToasts, fillTextInputByName } from '@siafoundation/e2e'
Expand Down Expand Up @@ -81,12 +82,7 @@ test('can create directory, upload file, rename file, navigate, delete a file, d
await clearToasts({ page })

// Upload.
await dragAndDropFile(
page,
`[data-testid=filesDropzone]`,
path.join(__dirname, originalFileName),
originalFileName
)
await dragAndDropFileFromSystem(page, originalFileName)
await expect(page.getByText('100%')).toBeVisible()
await fileInList(page, originalFilePath)

Expand All @@ -104,12 +100,7 @@ test('can create directory, upload file, rename file, navigate, delete a file, d
await clearToasts({ page })

// Upload the file again.
await dragAndDropFile(
page,
`[data-testid=filesDropzone]`,
path.join(__dirname, originalFileName),
originalFileName
)
await dragAndDropFileFromSystem(page, originalFileName)
await expect(page.getByText('100%')).toBeVisible()
await fileInList(page, originalFilePath)

Expand All @@ -131,7 +122,7 @@ test('shows a new intermediate directory when uploading nested files', async ({
const bucketName = 'files-test'
const containerDir = 'test-dir'
const containerDirPath = `${bucketName}/${containerDir}/`
const systemDir = 'nested-sample'
const systemDir = 'sample-files'
const systemFile = 'sample.txt'
const systemFilePath = `${systemDir}/${systemFile}`
const dirPath = `${bucketName}/${containerDir}/${systemDir}/`
Expand All @@ -154,12 +145,7 @@ test('shows a new intermediate directory when uploading nested files', async ({
await clearToasts({ page })

// Upload a nested file.
await dragAndDropFile(
page,
`[data-testid=filesDropzone]`,
path.join(__dirname, systemFilePath),
'/' + systemFilePath
)
await dragAndDropFileFromSystem(page, systemFile, systemFilePath)
await fileInList(page, dirPath)
const dirRow = await getFileRowById(page, dirPath)
// The intermediate directory should show up before the file is finished uploading.
Expand Down Expand Up @@ -188,3 +174,93 @@ test('shows a new intermediate directory when uploading nested files', async ({
await navigateToBuckets({ page })
await deleteBucket(page, bucketName)
})

test('batch delete across nested directories', async ({ page }) => {
test.setTimeout(120_000)
const bucketName = 'bucket1'
await navigateToBuckets({ page })
await createBucket(page, bucketName)
await createFilesMap(page, bucketName, {
dir1: {
'file1.txt': null,
'file2.txt': null,
},
dir2: {
'file3.txt': null,
'file4.txt': null,
'file5.txt': null,
},
})
await navigateToBuckets({ page })
await openBucket(page, bucketName)

// Select entire dir1.
await getFileRowById(page, 'bucket1/dir1/').click()
await openDirectory(page, 'bucket1/dir2/')

// Select file3 and file4.
await getFileRowById(page, 'bucket1/dir2/file3.txt').click()
await getFileRowById(page, 'bucket1/dir2/file4.txt').click()
const menu = page.getByLabel('file multiselect menu')

// Delete selected files.
await menu.getByLabel('delete selected files').click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Delete' }).click()

await expectFilesMap(page, bucketName, {
'dir1/': 'hidden',
dir2: {
'file3.txt': 'hidden',
'file4.txt': 'hidden',
'file5.txt': 'visible',
},
})
})

test('batch delete using the all files explorer mode', async ({ page }) => {
test.setTimeout(120_000)
const bucketName = 'bucket1'
await navigateToBuckets({ page })
await createBucket(page, bucketName)
await createFilesMap(page, bucketName, {
dir1: {
'file1.txt': null,
'file2.txt': null,
},
dir2: {
'file3.txt': null,
'file4.txt': null,
'file5.txt': null,
},
})
await navigateToBuckets({ page })
await openBucket(page, bucketName)
await page.getByLabel('change explorer mode').click()
await page.getByRole('menuitem', { name: 'All files' }).click()

// Select entire dir1.
await getFileRowById(page, 'bucket1/dir1/').click()
// Select file3 and file4.
await getFileRowById(page, 'bucket1/dir2/file3.txt').click()
await getFileRowById(page, 'bucket1/dir2/file4.txt').click()
const menu = page.getByLabel('file multiselect menu')

// Delete selected files.
await menu.getByLabel('delete selected files').click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Delete' }).click()

// Change back to directory mode to validate.
await page.getByLabel('change explorer mode').click()
await page.getByRole('menuitem', { name: 'Directory' }).click()

await expectFilesMap(page, bucketName, {
'dir1/': 'hidden',
dir2: {
'file3.txt': 'hidden',
'file4.txt': 'hidden',
'file5.txt': 'visible',
},
})
})
2 changes: 1 addition & 1 deletion apps/renterd/components/Files/BucketContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function BucketContextMenu({ name }: Props) {
return (
<DropdownMenu
trigger={
<Button variant="ghost" icon="hover">
<Button size="none" variant="ghost" icon="hover">
<BucketIcon size={16} />
</Button>
}
Expand Down
1 change: 1 addition & 0 deletions apps/renterd/components/Files/DirectoryContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function DirectoryContextMenu({ path, size }: Props) {
trigger={
<Button
aria-label="Directory context menu"
size="none"
variant="ghost"
icon="hover"
>
Expand Down
15 changes: 13 additions & 2 deletions apps/renterd/components/Files/FileContextMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,23 @@ export function FileContextMenu({ trigger, path, contentProps }: Props) {
<DropdownMenu
trigger={
trigger || (
<Button aria-label="File context menu" variant="ghost" icon="hover">
<Button
size="none"
aria-label="File context menu"
variant="ghost"
icon="hover"
>
<Document16 />
</Button>
)
}
contentProps={{ align: 'start', ...contentProps }}
contentProps={{
align: 'start',
...contentProps,
onClick: (e) => {
e.stopPropagation()
},
}}
>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function FilesExplorerModeContextMenu() {
<DropdownMenu
trigger={
<Button
aria-label="change explorer mode"
tipSide="bottom"
tip={
isViewingUploads
Expand Down
Loading

0 comments on commit daba970

Please sign in to comment.