feat(client): confirm destructive Book Dock discards

Require explicit confirmation before row, sheet, or bulk discard deletes files from disk. Provide a separate deselect action for bulk selections.

Closes #1007
This commit is contained in:
neon
2026-08-25 00:53:04 +00:00
parent 8c854dada8
commit e165812792
12 changed files with 407 additions and 31 deletions
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import { api } from '@/lib/api'
const props = defineProps<{
selectionPayload: {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
status?: string
needsReview?: boolean
search?: string
}
selectionCount: number
}>()
const emit = defineEmits<{
close: []
discarded: []
}>()
const { t } = useI18n()
const discarding = ref(false)
const error = ref(false)
function handleClose() {
if (!discarding.value) emit('close')
}
async function handleConfirm() {
if (discarding.value) return
discarding.value = true
error.value = false
try {
const response = await api('/api/v1/book-dock/files/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(props.selectionPayload),
})
if (!response.ok) {
error.value = true
return
}
emit('discarded')
} catch {
error.value = true
} finally {
discarding.value = false
}
}
</script>
<template>
<ConfirmDialog
open
destructive
:title="t('bookDock.discardDialog.title', { count: selectionCount })"
:description="t('bookDock.discardDialog.description', { count: selectionCount })"
:confirm-label="t('bookDock.discardDialog.confirm', { count: selectionCount })"
:busy="discarding"
@confirm="handleConfirm"
@cancel="handleClose"
>
<p v-if="error" class="mt-3 text-sm text-destructive" role="alert">
{{ t('bookDock.discardDialog.failed') }}
</p>
</ConfirmDialog>
</template>
@@ -30,11 +30,11 @@ const props = defineProps<{ file: BookDockFile }>()
const emit = defineEmits<{
close: []
discarded: []
discard: [BookDockFile]
updated: [BookDockFile]
}>()
const { saved, saveError, saveMetadata, setTarget, discardFile, coverUrl } = useBookDockDetail()
const { saved, saveError, saveMetadata, setTarget, coverUrl } = useBookDockDetail()
const { libraries, fetchLibraries: fetchLibs } = useLibraries()
const meta = computed(() => props.file.selectedMetadata ?? props.file.embeddedMetadata ?? ({} as BookDockMetadata))
@@ -195,9 +195,8 @@ function formatDate(iso: string): string {
return formatDateTime(new Date(iso))
}
async function handleDiscard() {
await discardFile(props.file.id)
emit('discarded')
function handleDiscard() {
emit('discard', props.file)
}
const {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowUpDown, Check, FolderPlus, Keyboard, Loader2, PenLine, RefreshCw, Trash2, Wand2 } from '@lucide/vue'
import { ArrowUpDown, Check, FolderPlus, Keyboard, Loader2, PenLine, RefreshCw, Trash2, Wand2, X } from '@lucide/vue'
import { Permission, type BookDockSummary } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
import { formatNumber } from '@/i18n/formatters'
@@ -29,6 +29,7 @@ const emit = defineEmits<{
finalize: []
setDestination: []
bulkEdit: []
deselect: []
bulkDiscard: []
applyFetched: []
retryFetch: []
@@ -72,6 +73,9 @@ function emitSetDestination() {
function emitBulkEdit() {
emit('bulkEdit')
}
function emitDeselect() {
emit('deselect')
}
function emitBulkDiscard() {
emit('bulkDiscard')
}
@@ -174,6 +178,15 @@ function emitRetryFetch() {
<PenLine class="size-3" aria-hidden="true" />
{{ t('bookDock.bulkEditAction') }}
</button>
<button
type="button"
data-testid="book-dock-deselect"
class="inline-flex h-7 items-center gap-1.5 rounded-lg bg-muted px-2.5 text-[11.5px] font-medium text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
@click="emitDeselect"
>
<X class="size-3" aria-hidden="true" />
{{ t('bookDock.deselect') }}
</button>
<button
type="button"
data-testid="book-dock-bulk-discard"
@@ -0,0 +1,117 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BookDockDiscardDialog from '../BookDockDiscardDialog.vue'
const apiMock = vi.fn<(...args: unknown[]) => Promise<Response>>()
vi.mock('@/lib/api', () => ({
api: (...args: unknown[]) => apiMock(...args),
}))
const ConfirmDialogStub = defineComponent({
name: 'ConfirmDialog',
props: {
title: { type: String, required: true },
description: { type: String, required: true },
confirmLabel: { type: String, required: true },
busy: Boolean,
},
emits: ['confirm', 'cancel'],
template: `
<div>
<h2>{{ title }}</h2>
<p>{{ description }}</p>
<slot />
<button type="button" :disabled="busy" @click="$emit('cancel')">Cancel</button>
<button type="button" :disabled="busy" @click="$emit('confirm')">{{ confirmLabel }}</button>
</div>
`,
})
type SelectionPayload = {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
status?: string
search?: string
}
function mountDialog(
selectionCount = 2,
selectionPayload: SelectionPayload = { selectAll: true, excludedIds: [9], status: 'ready', search: 'Dune' },
) {
return mount(BookDockDiscardDialog, {
props: {
selectionPayload,
selectionCount,
},
global: {
stubs: { ConfirmDialog: ConfirmDialogStub },
},
})
}
describe('BookDockDiscardDialog', () => {
beforeEach(() => {
apiMock.mockReset()
})
it('warns that discarding permanently deletes the selected files from disk', () => {
const wrapper = mountDialog()
expect(wrapper.text()).toContain('Discard the 2 selected files?')
expect(wrapper.text()).toContain('This permanently deletes the 2 selected files from disk. This action cannot be undone.')
expect(wrapper.text()).toContain('Delete 2 files')
expect(apiMock).not.toHaveBeenCalled()
})
it('uses singular warning copy for a row discard', () => {
const wrapper = mountDialog(1, { fileIds: [42] })
expect(wrapper.text()).toContain('Discard the selected file?')
expect(wrapper.text()).toContain('This permanently deletes the selected file from disk. This action cannot be undone.')
expect(wrapper.text()).toContain('Delete file')
})
it('closes without deleting when cancelled', async () => {
const wrapper = mountDialog()
const cancelButton = wrapper.findAll('button').find((button) => button.text() === 'Cancel')
expect(cancelButton).toBeDefined()
await cancelButton!.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
expect(apiMock).not.toHaveBeenCalled()
})
it('discards the captured selection only after confirmation', async () => {
apiMock.mockResolvedValue({ ok: true } as Response)
const wrapper = mountDialog()
const confirmButton = wrapper.findAll('button').find((button) => button.text() === 'Delete 2 files')
expect(confirmButton).toBeDefined()
await confirmButton!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/v1/book-dock/files/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectAll: true, excludedIds: [9], status: 'ready', search: 'Dune' }),
})
expect(wrapper.emitted('discarded')).toHaveLength(1)
})
it('keeps the dialog open and reports a failed deletion', async () => {
apiMock.mockResolvedValue({ ok: false } as Response)
const wrapper = mountDialog()
const confirmButton = wrapper.findAll('button').find((button) => button.text() === 'Delete 2 files')
await confirmButton!.trigger('click')
await flushPromises()
expect(wrapper.emitted('discarded')).toBeUndefined()
expect(wrapper.get('[role="alert"]').text()).toBe('The selected files could not be deleted. Try again.')
expect(wrapper.text()).toContain('Discard the 2 selected files?')
})
})
@@ -55,4 +55,12 @@ describe('BookDockFileRow metadata refetch action', () => {
expect(wrapper.get('[data-testid="book-dock-row-retry"]').attributes('disabled')).toBeDefined()
})
it('requests confirmation instead of deleting from the row', async () => {
const wrapper = mountRow('ready')
await wrapper.get('[data-testid="book-dock-row-discard"]').trigger('click')
expect(wrapper.emitted('discard')).toEqual([[expect.objectContaining({ id: 42 })]])
})
})
@@ -18,7 +18,6 @@ vi.mock('../../composables/useBookDockDetail', () => ({
saveError: ref(null),
saveMetadata: mocks.saveMetadata,
setTarget: vi.fn<(...args: unknown[]) => Promise<null>>().mockResolvedValue(null),
discardFile: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined),
coverUrl: (id: number) => `/api/v1/book-dock/files/${id}/cover`,
}),
}))
@@ -248,4 +247,16 @@ describe('BookDockFileSheet metadata search defaults', () => {
}),
)
})
it('requests confirmation before discarding the file', async () => {
const dockFile = makeFile()
const wrapper = mountSheet(dockFile)
const discardButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Discard')
expect(discardButton).toBeDefined()
await discardButton!.trigger('click')
expect(wrapper.emitted('discard')).toEqual([[dockFile]])
expect(wrapper.emitted('close')).toBeUndefined()
})
})
@@ -0,0 +1,65 @@
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { BookDockSummary } from '@bookorbit/types'
import BookDockListBar from '../BookDockListBar.vue'
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
hasPermission: () => true,
isDemoRestrictedAccount: { value: false },
}),
}))
const summary: BookDockSummary = {
pending: 0,
working: 0,
ready: 2,
error: 0,
needsReview: 0,
readyToFile: 2,
total: 2,
paused: false,
}
function mountListBar(selectionCount: number) {
return shallowMount(BookDockListBar, {
props: {
pageCount: 2,
total: 2,
totalSizeBytes: null,
allOnPageSelected: selectionCount === 2,
selectAllMatching: false,
selectionCount,
fetchedCount: 0,
errorCount: 0,
sort: 'attention',
summary,
keyboardActive: false,
},
})
}
describe('BookDockListBar selection actions', () => {
it('offers a distinct deselect action while files are selected', () => {
const wrapper = mountListBar(2)
expect(wrapper.get('[data-testid="book-dock-deselect"]').text()).toBe('Deselect')
expect(wrapper.get('[data-testid="book-dock-bulk-discard"]').text()).toBe('Discard')
})
it('emits deselect without invoking discard', async () => {
const wrapper = mountListBar(2)
await wrapper.get('[data-testid="book-dock-deselect"]').trigger('click')
expect(wrapper.emitted('deselect')).toHaveLength(1)
expect(wrapper.emitted('bulkDiscard')).toBeUndefined()
})
it('does not show selection actions when nothing is selected', () => {
const wrapper = mountListBar(0)
expect(wrapper.find('[data-testid="book-dock-deselect"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="book-dock-bulk-discard"]').exists()).toBe(false)
})
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { useBookDockFiles } from '../useBookDockFiles'
describe('useBookDockFiles deleted selections', () => {
it('removes a deleted file from an explicit selection', () => {
const files = useBookDockFiles()
files.total.value = 3
files.toggleSelect(1)
files.toggleSelect(2)
files.removeDeletedSelection(1)
expect(files.selectionCount.value).toBe(1)
expect(files.isSelected(1)).toBe(false)
expect(files.isSelected(2)).toBe(true)
})
it('keeps all remaining matches selected after deleting an excluded row', () => {
const files = useBookDockFiles()
files.total.value = 3
files.toggleSelectAll()
files.toggleSelect(1)
expect(files.selectionCount.value).toBe(2)
files.removeDeletedSelection(1)
files.total.value = 2
expect(files.selectionCount.value).toBe(2)
expect(files.isSelected(2)).toBe(true)
expect(files.isSelected(3)).toBe(true)
})
})
@@ -68,14 +68,9 @@ export function useBookDockDetail() {
return null
}
async function discardFile(id: number) {
await api(`/api/v1/book-dock/files/${id}`, { method: 'DELETE' })
file.value = null
}
function coverUrl(id: number): string {
return `/api/v1/book-dock/files/${id}/cover`
}
return { file, loading, saving, saved, saveError, loadFile, saveMetadata, debouncedSave, setTarget, discardFile, coverUrl }
return { file, loading, saving, saved, saveError, loadFile, saveMetadata, debouncedSave, setTarget, coverUrl }
}
@@ -155,6 +155,12 @@ export function useBookDockFiles() {
lastToggledId.value = null
}
function removeDeletedSelection(id: number) {
selectedIds.value.delete(id)
excludedIds.value.delete(id)
if (lastToggledId.value === id) lastToggledId.value = null
}
function isSelected(id: number): boolean {
if (selectAll.value) return !excludedIds.value.has(id)
return selectedIds.value.has(id)
@@ -204,6 +210,7 @@ export function useBookDockFiles() {
toggleSelect,
toggleSelectAll,
clearSelection,
removeDeletedSelection,
isSelected,
selectAll,
selectionCount,
+7
View File
@@ -5000,6 +5000,7 @@
"apply": "Apply",
"done": "Done",
"discard": "Discard",
"deselect": "Deselect",
"finalize": "Finalize",
"rescan": "Rescan",
"uploadAction": "Upload",
@@ -5009,6 +5010,12 @@
"bulkEditAction": "Bulk Edit",
"applyFetched": "Apply Fetched",
"retryErrors": "Retry Errors",
"discardDialog": {
"title": "{count, plural, =0 {Discard no files?} one {Discard the selected file?} other {Discard the # selected files?}}",
"description": "{count, plural, =0 {No files are selected.} one {This permanently deletes the selected file from disk. This action cannot be undone.} other {This permanently deletes the # selected files from disk. This action cannot be undone.}}",
"confirm": "{count, plural, one {Delete file} other {Delete # files}}",
"failed": "The selected files could not be deleted. Try again."
},
"commaSeparated": "comma-separated",
"destinationLibrary": "Destination Library",
"destinationFolder": "Destination Folder",
+67 -18
View File
@@ -21,6 +21,7 @@ import BookDockFileSheet from '@/features/book-dock/components/BookDockFileSheet
import BookDockFinalizeDialog from '@/features/book-dock/components/BookDockFinalizeDialog.vue'
import BookDockBulkEditDialog from '@/features/book-dock/components/BookDockBulkEditDialog.vue'
import BookDockSetDestinationDialog from '@/features/book-dock/components/BookDockSetDestinationDialog.vue'
import BookDockDiscardDialog from '@/features/book-dock/components/BookDockDiscardDialog.vue'
type ApplyFetchedResult = {
total: number
@@ -29,6 +30,22 @@ type ApplyFetchedResult = {
skippedEdited: number
}
type BookDockSelectionPayload = {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
status?: string
needsReview?: boolean
search?: string
}
type PendingDiscard = {
selectionPayload: BookDockSelectionPayload
selectionCount: number
source: 'bulk' | 'row' | 'sheet'
fileId?: number
}
const { t } = useI18n()
const {
@@ -46,6 +63,7 @@ const {
toggleSelect,
toggleSelectAll,
clearSelection,
removeDeletedSelection,
isSelected,
selectAll,
selectionCount,
@@ -64,6 +82,7 @@ const selectedFile = ref<BookDockFile | null>(null)
const showFinalizeDialog = ref(false)
const showBulkEditDialog = ref(false)
const showSetDestinationDialog = ref(false)
const pendingDiscard = ref<PendingDiscard | null>(null)
const dragOver = ref(false)
const newFilesDetected = ref(false)
const namePreviewByFileId = ref<Record<number, string>>({})
@@ -106,11 +125,6 @@ function closeSheet() {
selectedFile.value = null
}
function onDiscarded() {
selectedFile.value = null
refresh()
}
function refresh() {
fetchFiles()
fetchSummary(true)
@@ -183,9 +197,8 @@ function handleSort(field: SortField) {
setSort(field)
}
async function handleRowDiscard(file: BookDockFile) {
await api(`/api/v1/book-dock/files/${file.id}`, { method: 'DELETE' })
refresh()
function handleRowDiscard(file: BookDockFile) {
openSingleDiscard(file, 'row')
}
async function handleRowRetry(file: BookDockFile) {
@@ -204,14 +217,41 @@ function openFinalizeForFile(file: BookDockFile) {
showFinalizeDialog.value = true
}
async function handleBulkDiscard() {
const payload = getSelectionPayload()
await api('/api/v1/book-dock/files/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
clearSelection()
function openBulkDiscardDialog() {
if (!hasSelection.value) return
pendingDiscard.value = {
selectionPayload: getSelectionPayload(),
selectionCount: selectionCount.value,
source: 'bulk',
}
}
function openSingleDiscard(file: BookDockFile, source: 'row' | 'sheet') {
pendingDiscard.value = {
selectionPayload: { fileIds: [file.id] },
selectionCount: 1,
source,
fileId: file.id,
}
}
function handleSheetDiscard(file: BookDockFile) {
openSingleDiscard(file, 'sheet')
}
function closeDiscardDialog() {
pendingDiscard.value = null
}
function handleDiscarded() {
const discarded = pendingDiscard.value
if (!discarded) return
if (discarded.source === 'bulk') clearSelection()
else if (discarded.fileId !== undefined) removeDeletedSelection(discarded.fileId)
if (discarded.source === 'sheet') selectedFile.value = null
pendingDiscard.value = null
refresh()
}
@@ -582,7 +622,8 @@ onUnmounted(() => {
@finalize="openFinalize"
@set-destination="openSetDestination"
@bulk-edit="openBulkEdit"
@bulk-discard="handleBulkDiscard"
@deselect="clearSelection"
@bulk-discard="openBulkDiscardDialog"
@apply-fetched="handleApplyFetched"
@retry-fetch="handleRetryFetch"
/>
@@ -621,7 +662,7 @@ onUnmounted(() => {
</main>
<Teleport to="body">
<BookDockFileSheet v-if="selectedFile" :file="selectedFile" @close="closeSheet" @discarded="onDiscarded" @updated="onFileUpdated" />
<BookDockFileSheet v-if="selectedFile" :file="selectedFile" @close="closeSheet" @discard="handleSheetDiscard" @updated="onFileUpdated" />
</Teleport>
<BookDockFinalizeDialog
@@ -647,6 +688,14 @@ onUnmounted(() => {
@close="showSetDestinationDialog = false"
@updated="onDestinationSet"
/>
<BookDockDiscardDialog
v-if="pendingDiscard"
:selection-payload="pendingDiscard.selectionPayload"
:selection-count="pendingDiscard.selectionCount"
@close="closeDiscardDialog"
@discarded="handleDiscarded"
/>
</div>
</template>