fix(files): show batch operation failure details

This commit is contained in:
saltbo
2026-06-06 13:26:34 -04:00
parent 24e88e145a
commit 976590a0b4
10 changed files with 369 additions and 17 deletions
@@ -18,6 +18,7 @@ interface DeleteConfirmDialogProps {
isPending: boolean
operation?: OperationProgressState | null
onCancelOperation?: () => void
onDismissOperation?: () => void
}
export function DeleteConfirmDialog({
@@ -28,6 +29,7 @@ export function DeleteConfirmDialog({
isPending,
operation,
onCancelOperation,
onDismissOperation,
}: DeleteConfirmDialogProps) {
const { t } = useTranslation()
const running = !!operation
@@ -49,7 +51,11 @@ export function DeleteConfirmDialog({
{!running && <DialogDescription>{t('files.trashConfirmDescription', { count })}</DialogDescription>}
</DialogHeader>
{operation ? (
<OperationProgress operation={operation} onCancel={onCancelOperation ?? (() => {})} />
<OperationProgress
operation={operation}
onCancel={onCancelOperation ?? (() => {})}
onClose={onDismissOperation}
/>
) : (
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
+7 -1
View File
@@ -16,6 +16,7 @@ interface MoveDialogProps {
excludeIds: string[]
operation?: OperationProgressState | null
onCancelOperation?: () => void
onDismissOperation?: () => void
}
function buildPath(parent: string, name: string): string {
@@ -30,6 +31,7 @@ export function MoveDialog({
excludeIds,
operation,
onCancelOperation,
onDismissOperation,
}: MoveDialogProps) {
const { t } = useTranslation()
const [browsingPath, setBrowsingPath] = useState('')
@@ -84,7 +86,11 @@ export function MoveDialog({
</DialogHeader>
{operation ? (
<OperationProgress operation={operation} onCancel={onCancelOperation ?? (() => {})} />
<OperationProgress
operation={operation}
onCancel={onCancelOperation ?? (() => {})}
onClose={onDismissOperation}
/>
) : (
<>
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
@@ -0,0 +1,124 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DeleteConfirmDialog } from './delete-confirm-dialog'
import { MoveDialog } from './move-dialog'
import { OperationProgress, type OperationProgressState } from './operation-progress'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/ui/progress', () => ({
Progress: ({ value }: { value?: number }) => <div data-testid="progress">{value}</div>,
}))
vi.mock('../hooks/use-files-query', () => ({
useFilesQuery: () => ({ data: { items: [] }, isLoading: false }),
}))
afterEach(cleanup)
function failedOperation(): OperationProgressState {
return {
title: 'Move',
total: 3,
completed: 3,
currentName: '',
cancelRequested: false,
finished: true,
failures: [
{ name: 'Budget.xlsx', message: 'Name already exists' },
{ name: 'Archive', message: 'Permission denied' },
],
}
}
describe('OperationProgress', () => {
it('renders each failed item with its error reason', () => {
render(<OperationProgress operation={failedOperation()} onCancel={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByText('Budget.xlsx')).toBeTruthy()
expect(screen.getByText('Name already exists')).toBeTruthy()
expect(screen.getByText('Archive')).toBeTruthy()
expect(screen.getByText('Permission denied')).toBeTruthy()
expect(screen.getByText('common.close')).toBeTruthy()
})
it('calls onClose from the finished failure state', () => {
const onClose = vi.fn()
render(<OperationProgress operation={failedOperation()} onCancel={vi.fn()} onClose={onClose} />)
fireEvent.click(screen.getByText('common.close'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('keeps cancel available while the operation is still running', () => {
const onCancel = vi.fn()
render(
<OperationProgress
operation={{ ...failedOperation(), finished: false, completed: 1, failures: [] }}
onCancel={onCancel}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('common.cancel'))
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
describe('DeleteConfirmDialog', () => {
it('shows failure details inside the delete dialog execution state', () => {
render(
<DeleteConfirmDialog
open
count={3}
isPending
operation={failedOperation()}
onCancelOperation={vi.fn()}
onDismissOperation={vi.fn()}
onConfirm={vi.fn()}
onOpenChange={vi.fn()}
/>,
)
expect(screen.getByText('files.trashConfirmTitle')).toBeTruthy()
expect(screen.getByText('Budget.xlsx')).toBeTruthy()
expect(screen.getByText('Permission denied')).toBeTruthy()
expect(screen.queryByText('files.trashConfirmDescription')).toBeNull()
})
})
describe('MoveDialog', () => {
it('shows failure details inside the move dialog execution state', () => {
render(
<MoveDialog
open
isPending
operation={failedOperation()}
onCancelOperation={vi.fn()}
onDismissOperation={vi.fn()}
excludeIds={[]}
onConfirm={vi.fn()}
onOpenChange={vi.fn()}
/>,
)
expect(screen.getByText('files.moveTo')).toBeTruthy()
expect(screen.getByText('Budget.xlsx')).toBeTruthy()
expect(screen.getByText('Name already exists')).toBeTruthy()
expect(screen.queryByText('files.noFolders')).toBeNull()
})
})
@@ -2,22 +2,31 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
export interface OperationFailure {
name: string
message: string
}
export interface OperationProgressState {
title: string
total: number
completed: number
currentName: string
cancelRequested: boolean
finished: boolean
failures: OperationFailure[]
}
interface OperationProgressProps {
operation: OperationProgressState
onCancel: () => void
onClose?: () => void
}
export function OperationProgress({ operation, onCancel }: OperationProgressProps) {
export function OperationProgress({ operation, onCancel, onClose }: OperationProgressProps) {
const { t } = useTranslation()
const value = operation.total > 0 ? Math.round((operation.completed / operation.total) * 100) : 0
const hasFailures = operation.failures.length > 0
return (
<div className="space-y-4">
@@ -32,10 +41,35 @@ export function OperationProgress({ operation, onCancel }: OperationProgressProp
</div>
</div>
<Progress value={value} />
{hasFailures && (
<div className="rounded-md border border-destructive/30 bg-destructive/5">
<div className="border-b border-destructive/20 px-3 py-2 text-sm font-medium text-destructive">
{t('files.operationFailuresTitle', { count: operation.failures.length })}
</div>
<div className="max-h-44 overflow-y-auto">
{operation.failures.map((failure) => (
<div key={`${failure.name}-${failure.message}`} className="border-b px-3 py-2 last:border-0">
<div className="truncate text-sm font-medium" title={failure.name}>
{failure.name}
</div>
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground" title={failure.message}>
{failure.message}
</div>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end">
<Button variant="outline" onClick={onCancel} disabled={operation.cancelRequested}>
{operation.cancelRequested ? t('files.operationCanceling') : t('common.cancel')}
</Button>
{operation.finished ? (
<Button variant="outline" onClick={onClose}>
{t('common.close')}
</Button>
) : (
<Button variant="outline" onClick={onCancel} disabled={operation.cancelRequested}>
{operation.cancelRequested ? t('files.operationCanceling') : t('common.cancel')}
</Button>
)}
</div>
</div>
)
@@ -0,0 +1,106 @@
import { cleanup, render, screen } from '@testing-library/react'
import type React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OperationProgressState } from './dialogs/operation-progress'
import { FileManagerDialogs } from './file-manager-dialogs'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/ui/progress', () => ({
Progress: ({ value }: { value?: number }) => <div data-testid="progress">{value}</div>,
}))
vi.mock('./dialogs/rename-dialog', () => ({
RenameDialog: () => null,
}))
vi.mock('./dialogs/new-folder-dialog', () => ({
NewFolderDialog: () => null,
}))
vi.mock('./dialogs/name-conflict-dialog', () => ({
NameConflictDialog: () => null,
}))
vi.mock('./dialogs/share-dialog', () => ({
ShareDialog: () => null,
}))
vi.mock('./dialogs/delete-confirm-dialog', () => ({
DeleteConfirmDialog: () => null,
}))
vi.mock('./dialogs/move-dialog', () => ({
MoveDialog: () => null,
}))
afterEach(cleanup)
function failedOperation(): OperationProgressState {
return {
title: 'Restore',
total: 2,
completed: 2,
currentName: '',
cancelRequested: false,
finished: true,
failures: [{ name: 'Archive', message: 'Permission denied' }],
}
}
describe('FileManagerDialogs', () => {
it('shows failure details in the generic batch operation dialog', () => {
render(
<FileManagerDialogs
renameTarget={null}
onRenameClose={vi.fn()}
onRenameConfirm={vi.fn()}
renamePending={false}
showNewFolder={false}
onNewFolderClose={vi.fn()}
onNewFolderConfirm={vi.fn()}
newFolderPending={false}
deleteTargetIds={[]}
operation={failedOperation()}
onOperationCancel={vi.fn()}
onOperationDismiss={vi.fn()}
onDeleteClose={vi.fn()}
onDeleteConfirm={vi.fn()}
deletePending={false}
moveTargetIds={[]}
onMoveClose={vi.fn()}
onMoveConfirm={vi.fn()}
movePending={false}
shareTarget={null}
onShareClose={vi.fn()}
conflictDialogState={{
request: null,
applyToAll: false,
onApplyToAllChange: vi.fn(),
onChoose: vi.fn(),
onCancel: vi.fn(),
}}
/>,
)
expect(screen.getAllByText('Restore').length).toBeGreaterThan(0)
expect(screen.getByText('Archive')).toBeTruthy()
expect(screen.getByText('Permission denied')).toBeTruthy()
expect(screen.getByText('common.close')).toBeTruthy()
})
})
+10 -1
View File
@@ -22,6 +22,7 @@ interface FileManagerDialogsProps {
deleteTargetIds: string[]
operation: OperationProgressState | null
onOperationCancel: () => void
onOperationDismiss: () => void
onDeleteClose: () => void
onDeleteConfirm: () => void
deletePending: boolean
@@ -62,6 +63,7 @@ export function FileManagerDialogs(props: FileManagerDialogsProps) {
count={props.deleteTargetIds.length}
operation={props.deleteTargetIds.length > 0 ? props.operation : null}
onCancelOperation={props.onOperationCancel}
onDismissOperation={props.onOperationDismiss}
onOpenChange={(open) => {
if (!open) props.onDeleteClose()
}}
@@ -79,6 +81,7 @@ export function FileManagerDialogs(props: FileManagerDialogsProps) {
excludeIds={props.moveTargetIds}
operation={props.moveTargetIds.length > 0 ? props.operation : null}
onCancelOperation={props.onOperationCancel}
onDismissOperation={props.onOperationDismiss}
/>
<Dialog open={!!props.operation && props.deleteTargetIds.length === 0 && props.moveTargetIds.length === 0}>
@@ -86,7 +89,13 @@ export function FileManagerDialogs(props: FileManagerDialogsProps) {
<DialogHeader>
<DialogTitle>{props.operation?.title}</DialogTitle>
</DialogHeader>
{props.operation && <OperationProgress operation={props.operation} onCancel={props.onOperationCancel} />}
{props.operation && (
<OperationProgress
operation={props.operation}
onCancel={props.onOperationCancel}
onClose={props.onOperationDismiss}
/>
)}
</DialogContent>
</Dialog>
+32 -4
View File
@@ -406,7 +406,15 @@ export function FileManager({
) => {
operationCancelRef.current = false
const namesById = new Map(items.map((item) => [item.id, item.name]))
setOperationState({ title, total: ids.length, completed: 0, currentName: '', cancelRequested: false })
setOperationState({
title,
total: ids.length,
completed: 0,
currentName: '',
cancelRequested: false,
finished: false,
failures: [],
})
const result = await runSequentialOperation({
items: ids,
@@ -417,18 +425,30 @@ export function FileManager({
onItemComplete: (_id, index) => {
setOperationState((state) => (state ? { ...state, completed: index + 1 } : state))
},
onItemFailure: (_id, _error, index) => {
setOperationState((state) => (state ? { ...state, completed: index + 1 } : state))
onItemFailure: (id, error, index) => {
setOperationState((state) =>
state
? {
...state,
completed: index + 1,
failures: [
...state.failures,
{ name: namesById.get(id) ?? id, message: error.message || t('common.error') },
],
}
: state,
)
},
runItem: action,
})
invalidation()
setOperationState(null)
if (result.failed.length > 0) {
setOperationState((state) => (state ? { ...state, finished: true, currentName: '' } : state))
toast.error(t('files.operationFailedSummary', { failed: result.failed.length, total: ids.length }))
return result
}
setOperationState(null)
if (result.cancelled) {
toast.info(t('files.operationCancelled', { completed: result.completed, total: ids.length }))
return result
@@ -444,6 +464,13 @@ export function FileManager({
setOperationState((state) => (state ? { ...state, cancelRequested: true } : state))
}
function dismissOperation() {
setOperationState(null)
if (deleteTargetIds.length > 0) setDeleteTargetIds([])
if (moveTargetIds.length > 0) setMoveTargetIds([])
setRowSelection({})
}
function handleDndDrop(fileIds: string[], targetFolderId: string) {
conflict.reset()
runFileOperation(
@@ -642,6 +669,7 @@ export function FileManager({
deleteTargetIds={deleteTargetIds}
operation={operationState}
onOperationCancel={requestOperationCancel}
onOperationDismiss={dismissOperation}
onDeleteClose={() => setDeleteTargetIds([])}
onDeleteConfirm={() => {
const ids = [...deleteTargetIds]
+1
View File
@@ -90,6 +90,7 @@
"files.operationCanceling": "Canceling...",
"files.operationCancelled": "Canceled after {{completed}} / {{total}} item(s)",
"files.operationFailedSummary": "{{failed}} / {{total}} item(s) failed",
"files.operationFailuresTitle": "{{count}} failed item(s)",
"files.uploadBatchCancelled": "{{count}} remaining upload(s) cancelled",
"tasks.title": "Tasks",
"tasks.count": "{{count}} tasks",
+1
View File
@@ -90,6 +90,7 @@
"files.operationCanceling": "正在取消...",
"files.operationCancelled": "已取消,完成 {{completed}} / {{total}} 项",
"files.operationFailedSummary": "{{failed}} / {{total}} 项失败",
"files.operationFailuresTitle": "{{count}} 个失败项目",
"files.uploadBatchCancelled": "已取消剩余 {{count}} 个上传",
"tasks.title": "任务",
"tasks.count": "{{count}} 个任务",
+43 -6
View File
@@ -52,7 +52,15 @@ function TrashPage() {
): Promise<void> {
operationCancelRef.current = false
const namesById = new Map(items.map((item) => [item.id, item.name]))
setOperationState({ title, total: ids.length, completed: 0, currentName: '', cancelRequested: false })
setOperationState({
title,
total: ids.length,
completed: 0,
currentName: '',
cancelRequested: false,
finished: false,
failures: [],
})
const result = await runSequentialOperation({
items: ids,
@@ -63,16 +71,28 @@ function TrashPage() {
onItemComplete: (_id, index) => {
setOperationState((state) => (state ? { ...state, completed: index + 1 } : state))
},
onItemFailure: (_id, _error, index) => {
setOperationState((state) => (state ? { ...state, completed: index + 1 } : state))
onItemFailure: (id, error, index) => {
setOperationState((state) =>
state
? {
...state,
completed: index + 1,
failures: [
...state.failures,
{ name: namesById.get(id) ?? id, message: error.message || t('common.error') },
],
}
: state,
)
},
runItem,
})
setOperationState(null)
if (result.failed.length > 0) {
setOperationState((state) => (state ? { ...state, finished: true, currentName: '' } : state))
throw new Error(t('files.operationFailedSummary', { failed: result.failed.length, total: ids.length }))
}
setOperationState(null)
if (result.cancelled) {
toast.info(t('files.operationCancelled', { completed: result.completed, total: ids.length }))
}
@@ -83,6 +103,13 @@ function TrashPage() {
setOperationState((state) => (state ? { ...state, cancelRequested: true } : state))
}
function dismissOperation() {
setOperationState(null)
setConfirmDialog(null)
setPendingDeleteIds([])
setSelectedIds(new Set())
}
async function runRestore(ids: string[]) {
conflict.reset()
const showApplyToAll = ids.length > 1
@@ -254,7 +281,11 @@ function TrashPage() {
)}
</DialogHeader>
{operationState ? (
<OperationProgress operation={operationState} onCancel={requestOperationCancel} />
<OperationProgress
operation={operationState}
onCancel={requestOperationCancel}
onClose={dismissOperation}
/>
) : (
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDialog(null)}>
@@ -277,7 +308,13 @@ function TrashPage() {
<DialogHeader>
<DialogTitle>{operationState?.title}</DialogTitle>
</DialogHeader>
{operationState && <OperationProgress operation={operationState} onCancel={requestOperationCancel} />}
{operationState && (
<OperationProgress
operation={operationState}
onCancel={requestOperationCancel}
onClose={dismissOperation}
/>
)}
</DialogContent>
</Dialog>