Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(renterd): files explorer multiselect and batch delete #785

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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])
}
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
Loading