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 18, 2024
1 parent 7ee383a commit 1342b09
Show file tree
Hide file tree
Showing 17 changed files with 371 additions and 56 deletions.
99 changes: 96 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,28 @@ 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])
// Add a part of the path and open it, then repeat until full path is reached.
// Create a variable that stores the full string of current path.
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 +126,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 +158,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 createFilesFromMap(
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 (typeof map[name] === 'string') {
await dragAndDropFileFromSystem(page, 'sample.txt', map[name])
await fileInList(page, path)
} else {
await createDirectory(page, name)
await fileInList(page, path + '/')
await create(map[name] as FileMap, stack.concat(name))
}
}
}
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 expectFilesFromMap(
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.
63 changes: 42 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,
createFilesFromMap,
expectFilesFromMap,
} 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,38 @@ 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 createFilesFromMap(page, bucketName, {
dir1: {
'file1.txt': 'file1.txt',
'file2.txt': 'file2.txt',
},
dir2: {
'file3.txt': 'file3.txt',
'file4.txt': 'file4.txt',
},
})
await navigateToBuckets({ page })
await openBucket(page, bucketName)
// Select dir1.
await getFileRowById(page, 'bucket1/dir1/').click()
await openDirectory(page, 'bucket1/dir2/')
await getFileRowById(page, 'bucket1/dir2/file3.txt').click()
await getFileRowById(page, 'bucket1/dir2/file4.txt').click()
const menu = page.getByLabel('file multiselect menu')
await menu.getByLabel('delete selected files').click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Delete' }).click()
await expectFilesFromMap(page, bucketName, {
'dir1/': 'hidden',
dir2: {
'file3.txt': 'hidden',
'file4.txt': 'hidden',
},
})
})
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
7 changes: 6 additions & 1 deletion apps/renterd/components/Files/FileContextMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ 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>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Button,
Paragraph,
triggerSuccessToast,
triggerErrorToast,
} from '@siafoundation/design-system'
import { Delete16 } from '@siafoundation/react-icons'
import { useCallback, useMemo } from 'react'
import { useDialog } from '../../../contexts/dialog'
import { useFilesDirectory } from '../../../contexts/filesDirectory'
import { useObjectsRemove } from '@siafoundation/renterd-react'

export function FilesDirectoryBatchDelete() {
const { multiSelect } = useFilesDirectory()

const filesToDelete = useMemo(
() =>
Object.entries(multiSelect.selectionMap).map(([_, item]) => ({
bucket: item.bucket.name,
prefix: item.key,
})),
[multiSelect.selectionMap]
)
const { openConfirmDialog } = useDialog()
const objectsRemove = useObjectsRemove()
const deleteFiles = useCallback(async () => {
const totalCount = filesToDelete.length
let errorCount = 0
for (const { bucket, prefix } of filesToDelete) {
const response = await objectsRemove.post({
payload: {
bucket,
prefix,
},
})
if (response.error) {
errorCount++
}
}
if (errorCount > 0) {
triggerErrorToast({
title: `${totalCount - errorCount} files deleted`,
body: `Error deleting ${errorCount}/${totalCount} total files.`,
})
} else {
triggerSuccessToast({ title: `${totalCount} files deleted` })
}
multiSelect.deselectAll()
}, [multiSelect, filesToDelete, objectsRemove])

return (
<Button
aria-label="delete selected files"
tip="Delete selected files"
onClick={() => {
openConfirmDialog({
title: `Delete files`,
action: 'Delete',
variant: 'red',
body: (
<div className="flex flex-col gap-1">
<Paragraph size="14">
Are you sure you would like to delete the{' '}
{multiSelect.selectionCount.toLocaleString()} selected files?
</Paragraph>
</div>
),
onConfirm: async () => {
deleteFiles()
},
})
}}
>
<Delete16 />
</Button>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MultiSelectionMenu } from '@siafoundation/design-system'
import { FilesDirectoryBatchDelete } from './FilesDirectoryBatchDelete'
import { useFilesDirectory } from '../../../contexts/filesDirectory'

export function FilesDirectoryBatchMenu() {
const { multiSelect } = useFilesDirectory()

return (
<MultiSelectionMenu multiSelect={multiSelect} entityWord="file">
<FilesDirectoryBatchDelete />
</MultiSelectionMenu>
)
}
2 changes: 2 additions & 0 deletions apps/renterd/components/FilesDirectory/FilesExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function FilesExplorer() {
datasetPage,
pageCount,
dataState,
cellContext,
onDragEnd,
onDragOver,
onDragStart,
Expand All @@ -40,6 +41,7 @@ export function FilesExplorer() {
emptyState={<EmptyState />}
pageSize={10}
data={datasetPage}
context={cellContext}
columns={columns}
sortableColumns={sortableColumns}
sortField={sortField}
Expand Down
2 changes: 2 additions & 0 deletions apps/renterd/components/FilesDirectory/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RenterdAuthedLayout } from '../RenterdAuthedLayout'
import { FilesActionsMenu } from './FilesActionsMenu'
import { FilesStatsMenu } from './FilesStatsMenu'
import { FilesExplorer } from './FilesExplorer'
import { FilesDirectoryBatchMenu } from './FilesDirectoryBatchMenu'

export function FilesDirectory() {
const { openDialog } = useDialog()
Expand All @@ -21,6 +22,7 @@ export function FilesDirectory() {
actions={<FilesActionsMenu />}
openSettings={() => openDialog('settings')}
>
<FilesDirectoryBatchMenu />
<div className="p-6 min-w-fit">
<FilesExplorer />
</div>
Expand Down
Loading

0 comments on commit 1342b09

Please sign in to comment.