feat(library): auto-select sole upload destinations

Remove redundant destination choices when only one book library or folder is available.
Preserve existing Dock targets and cover loading, selection, and failure states with unit tests.

Closes #1362
This commit is contained in:
neon
2026-10-07 04:56:44 +00:00
parent e6ea5bd77e
commit e767358443
14 changed files with 1065 additions and 243 deletions
@@ -20,7 +20,8 @@ import BookDockStatusBadge from './BookDockStatusBadge.vue'
import MetadataMatchWorkspace from '@/features/book/components/metadata-match/MetadataMatchWorkspace.vue'
import type { MetadataQuery } from '@/features/book/components/metadata-match/MetadataMatchQuery.vue'
import { useBookDockDetail } from '../composables/useBookDockDetail'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useBookDestination } from '@/features/library/composables/useBookDestination'
import BookDestinationFields from '@/features/library/components/BookDestinationFields.vue'
import { useMetadataSearch } from '@/features/book/composables/useMetadataSearch'
import type { MetadataDiffApply } from '@/features/book/composables/useMetadataDiff'
import { formatBytes } from '@/lib/formatting'
@@ -37,7 +38,8 @@ const emit = defineEmits<{
}>()
const { saved, saveError, saveMetadata, setTarget, coverUrl } = useBookDockDetail()
const { libraries, fetchLibraries: fetchLibs } = useLibraries()
const destination = useBookDestination({ defaultToFirstLibrary: true })
const { libraryId: targetLibraryId, folderId: targetFolderId, hasDestination, fetchLibraries: fetchLibs } = destination
const meta = computed(() => props.file.selectedMetadata ?? props.file.embeddedMetadata ?? ({} as BookDockMetadata))
@@ -47,15 +49,10 @@ const fetchedCandidate = ref<MetadataCandidate | null>(null)
const sheetWidthClass = computed(() => (metaView.value === 'editor' ? 'sm:w-md lg:w-lg' : 'sm:w-[min(100vw-3rem,80rem)] sm:max-w-none'))
const targetLibraryId = ref<number | null>(null)
const targetFolderId = ref<number | null>(null)
const persistedTargetLibraryId = ref<number | null>(null)
const persistedTargetFolderId = ref<number | null>(null)
const finishing = ref(false)
const selectedLibrary = computed(() => libraries.value.find((l) => l.id === targetLibraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const form = reactive({
title: '',
subtitle: '',
@@ -101,29 +98,13 @@ watch(
selectedCoverUrl.value = m.coverUrl ?? ''
metaView.value = 'editor'
targetLibraryId.value = props.file.targetLibraryId ?? libraries.value[0]?.id ?? null
const lib = libraries.value.find((l) => l.id === targetLibraryId.value)
targetFolderId.value = props.file.targetFolderId ?? lib?.folders?.[0]?.id ?? null
destination.reset(props.file.targetLibraryId, props.file.targetFolderId)
persistedTargetLibraryId.value = props.file.targetLibraryId
persistedTargetFolderId.value = props.file.targetFolderId
},
{ immediate: true },
)
watch(
() => libraries.value,
(libs) => {
if (!libs.length) return
if (targetLibraryId.value === null) {
targetLibraryId.value = props.file.targetLibraryId ?? libs[0]?.id ?? null
}
if (targetFolderId.value === null) {
const lib = libs.find((l) => l.id === targetLibraryId.value)
targetFolderId.value = props.file.targetFolderId ?? lib?.folders?.[0]?.id ?? null
}
},
)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let pendingTargetSave: Promise<BookDockFile | null> | null = null
@@ -182,22 +163,8 @@ function onPublishedYearChange() {
onFieldChange()
}
async function onLibraryChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
targetLibraryId.value = id
const lib = libraries.value.find((l) => l.id === targetLibraryId.value)
targetFolderId.value = lib?.folders?.[0]?.id ?? null
await persistTarget()
}
async function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
targetFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
await persistTarget()
}
async function persistTarget(): Promise<BookDockFile | null> {
if (!hasDestination.value) return null
const libraryId = targetLibraryId.value
const folderId = targetFolderId.value
const previousSave = pendingTargetSave
@@ -627,28 +594,12 @@ onMounted(() => {
</label>
</div>
<div class="space-y-3 pt-1">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.destinationLibrary') }}</span>
<select
class="mt-1 w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetLibraryId ?? ''"
@change="onLibraryChange"
>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.destinationFolder') }}</span>
<select
class="mt-1 w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetFolderId ?? ''"
@change="onFolderChange"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</label>
</div>
<BookDestinationFields
:destination="destination"
:library-label="t('bookDock.destinationLibrary')"
:folder-label="t('bookDock.destinationFolder')"
@change="persistTarget"
/>
<p class="text-xs text-muted-foreground">{{ t('bookDock.sheet.added', { date: formatDate(file.createdAt) }) }}</p>
</div>
@@ -6,7 +6,8 @@ import { X, Check, AlertCircle, Copy, Loader2, ExternalLink, ChevronDown, FileTe
import type { BookDockDiscardDuplicatesResult, BookDockFinalizePreviewResult } from '@bookorbit/types'
import { api } from '@/lib/api'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useBookDestination } from '@/features/library/composables/useBookDestination'
import BookDestinationFields from '@/features/library/components/BookDestinationFields.vue'
import { useBookDockFinalize } from '../composables/useBookDockFinalize'
const { t } = useI18n()
@@ -42,11 +43,10 @@ const emit = defineEmits<{
}>()
const router = useRouter()
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
const destination = useBookDestination({ defaultToFirstLibrary: true })
const { libraryId: defaultLibraryId, folderId: defaultFolderId, hasDestination, fetchLibraries, refreshLibraries } = destination
const { result, loading, error, finalize, reset } = useBookDockFinalize()
const defaultLibraryId = ref<number | null>(null)
const defaultFolderId = ref<number | null>(null)
const expandedErrors = ref<Set<number>>(new Set())
const reimportingIds = reactive(new Set<number>())
const renameInputs = ref<Map<number, string>>(new Map())
@@ -109,7 +109,7 @@ function effectiveSelectionPayload(): {
function finalizePayload(): FinalizePayload {
return {
...effectiveSelectionPayload(),
...(requiresDefaultDestination.value && defaultLibraryId.value !== null && defaultFolderId.value !== null
...(requiresDefaultDestination.value && hasDestination.value && defaultLibraryId.value !== null && defaultFolderId.value !== null
? {
defaultLibraryId: defaultLibraryId.value,
defaultFolderId: defaultFolderId.value,
@@ -157,53 +157,27 @@ const namePreview = ref<{ fileId: number; fileName: string; newName: string }[]>
const previewLoading = ref(false)
let previewReqSeq = 0
const selectedLibrary = computed(() => libraries.value.find((l) => l.id === defaultLibraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const requiresDefaultDestination = computed(() => (selectionSummary.value?.withoutDestination ?? props.selectionCount) > 0)
const canStart = computed(() => {
if (effectiveSelectionCount.value <= 0) return false
if (!selectionSummary.value) return false
if (!requiresDefaultDestination.value) return true
return defaultLibraryId.value !== null && defaultFolderId.value !== null
return hasDestination.value
})
onMounted(async () => {
await Promise.all([fetchLibraries(), fetchSelectionSummary()])
const first = libraries.value[0]
if (requiresDefaultDestination.value && first) {
defaultLibraryId.value = first.id
const firstFolder = first.folders?.[0]
if (firstFolder) defaultFolderId.value = firstFolder.id
}
void fetchFinalizePreview()
})
function onLibraryChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
if (id === null) {
defaultLibraryId.value = null
defaultFolderId.value = null
return
}
defaultLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
defaultFolderId.value = lib?.folders?.[0]?.id ?? null
}
function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
defaultFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
}
function syncNamePreviewFromFinalizePreview(preview: BookDockFinalizePreviewResult | null) {
namePreview.value =
preview?.items.filter((item) => !!item.newName).map((item) => ({ fileId: item.fileId, fileName: item.fileName, newName: item.newName! })) ?? []
}
async function fetchFinalizePreview() {
if (requiresDefaultDestination.value && (defaultLibraryId.value === null || defaultFolderId.value === null)) {
if (!selectionSummary.value || (requiresDefaultDestination.value && !hasDestination.value)) {
++previewReqSeq
finalizePreview.value = null
namePreview.value = []
previewLoading.value = false
@@ -264,7 +238,7 @@ async function fetchSelectionSummary() {
selectionSummary.value = { total: props.selectionCount, withDestination: 0, withoutDestination: props.selectionCount }
}
watch([defaultLibraryId, defaultFolderId, requiresDefaultDestination], () => {
watch([defaultLibraryId, defaultFolderId, requiresDefaultDestination, hasDestination], () => {
void fetchFinalizePreview()
})
@@ -433,29 +407,12 @@ async function handleDiscardResultDuplicates() {
<p v-if="finalizePreviewError" class="text-xs text-red-500 bg-red-500/10 rounded-lg p-2">{{ finalizePreviewError }}</p>
<div v-if="requiresDefaultDestination" class="space-y-3">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.finalizeDialog.defaultDestinationLibrary') }}</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="defaultLibraryId ?? ''"
@change="onLibraryChange"
>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.finalizeDialog.defaultDestinationFolder') }}</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="defaultFolderId ?? ''"
@change="onFolderChange"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</label>
</div>
<BookDestinationFields
v-if="requiresDefaultDestination"
:destination="destination"
:library-label="t('bookDock.finalizeDialog.defaultDestinationLibrary')"
:folder-label="t('bookDock.finalizeDialog.defaultDestinationFolder')"
/>
<div v-if="namePreview.length || previewLoading" class="space-y-1.5">
<div class="flex items-center gap-1.5">
@@ -3,7 +3,8 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, Loader2, FolderPlus } from '@lucide/vue'
import { api } from '@/lib/api'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useBookDestination } from '@/features/library/composables/useBookDestination'
import BookDestinationFields from '@/features/library/components/BookDestinationFields.vue'
const { t } = useI18n()
@@ -25,44 +26,18 @@ const emit = defineEmits<{
updated: []
}>()
const { libraries, fetchLibraries } = useLibraries()
const destination = useBookDestination({ defaultToFirstLibrary: true })
const { libraryId: targetLibraryId, folderId: targetFolderId, hasDestination, fetchLibraries } = destination
const loading = ref(false)
const error = ref<string | null>(null)
const result = ref<{ total: number; updated: number; failed: number } | null>(null)
const targetLibraryId = ref<number | null>(null)
const targetFolderId = ref<number | null>(null)
const canApply = computed(() => hasDestination.value && !loading.value)
const selectedLibrary = computed(() => libraries.value.find((l) => l.id === targetLibraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const canApply = computed(() => targetLibraryId.value !== null && targetFolderId.value !== null && !loading.value)
onMounted(async () => {
await fetchLibraries()
const first = libraries.value[0]
if (!first) return
targetLibraryId.value = first.id
targetFolderId.value = first.folders?.[0]?.id ?? null
onMounted(() => {
void fetchLibraries()
})
function onLibraryChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
if (id === null) {
targetLibraryId.value = null
targetFolderId.value = null
return
}
targetLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
targetFolderId.value = lib?.folders?.[0]?.id ?? null
}
function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
targetFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
}
async function applyDestination() {
if (!canApply.value) return
loading.value = true
@@ -114,27 +89,11 @@ function handleClose() {
</div>
<div v-if="!result" class="px-5 py-4 space-y-4">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.destinationLibrary') }}</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetLibraryId ?? ''"
@change="onLibraryChange"
>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">{{ t('bookDock.destinationFolder') }}</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetFolderId ?? ''"
@change="onFolderChange"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</label>
<BookDestinationFields
:destination="destination"
:library-label="t('bookDock.destinationLibrary')"
:folder-label="t('bookDock.destinationFolder')"
/>
<p v-if="error" class="text-xs text-red-500 bg-red-500/10 rounded-lg p-2">{{ error }}</p>
@@ -1,10 +1,11 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { defineComponent, reactive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookDockFile, Library, MetadataCandidate } from '@bookorbit/types'
import { i18n } from '@/i18n'
import BookDockFileSheet from '../BookDockFileSheet.vue'
type LibraryOption = Pick<Library, 'id' | 'name'> & {
type LibraryOption = Pick<Library, 'id' | 'name' | 'type'> & {
folders: Array<Pick<Library['folders'][number], 'id' | 'path'>>
}
@@ -18,6 +19,8 @@ const mocks = vi.hoisted(() => ({
libraries: [] as LibraryOption[],
}))
const libraryOptions = ref<LibraryOption[]>([])
vi.mock('../../composables/useBookDockDetail', () => ({
useBookDockDetail: () => ({
saved: ref(false),
@@ -30,8 +33,11 @@ vi.mock('../../composables/useBookDockDetail', () => ({
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: ref(mocks.libraries),
libraries: libraryOptions,
fetchLibraries: mocks.fetchLibraries,
loaded: ref(true),
loading: ref(false),
error: ref(null),
}),
}))
@@ -92,16 +98,22 @@ function makeFile(overrides: Partial<BookDockFile> = {}): BookDockFile {
}
}
const wrappers: ReturnType<typeof shallowMount>[] = []
afterEach(() => wrappers.splice(0).forEach((wrapper) => wrapper.unmount()))
function mountSheet(file: BookDockFile) {
return shallowMount(BookDockFileSheet, {
const wrapper = shallowMount(BookDockFileSheet, {
props: { file },
global: {
stubs: {
MetadataMatchWorkspace: WorkspaceStub,
BookDockStatusBadge: true,
BookDestinationFields: false,
},
},
})
wrappers.push(wrapper)
return wrapper
}
async function openSearchAndReadDefaults(file: BookDockFile): Promise<Record<string, string | undefined>> {
@@ -114,6 +126,7 @@ async function openSearchAndReadDefaults(file: BookDockFile): Promise<Record<str
describe('BookDockFileSheet', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
vi.clearAllMocks()
mocks.fetchLibraries.mockResolvedValue(undefined)
mocks.loadProviders.mockResolvedValue(undefined)
@@ -121,6 +134,7 @@ describe('BookDockFileSheet', () => {
mocks.setTarget.mockResolvedValue(null)
mocks.filteredResults.length = 0
mocks.libraries.length = 0
libraryOptions.value = mocks.libraries
})
it.each([
@@ -303,16 +317,17 @@ describe('BookDockFileSheet', () => {
it('persists the visible default destination when finishing metadata edits', async () => {
mocks.libraries.push({
id: 7,
type: 'books',
name: 'Novels',
folders: [{ id: 70, path: '/library/novels' }],
})
const updatedFile = makeFile({ targetLibraryId: 7, targetFolderId: 70 })
mocks.setTarget.mockResolvedValue(updatedFile)
const wrapper = mountSheet(makeFile())
const [librarySelect, folderSelect] = wrapper.findAll<HTMLSelectElement>('select')
expect(librarySelect!.element.value).toBe('7')
expect(folderSelect!.element.value).toBe('70')
await flushPromises()
expect(wrapper.findAll('select')).toHaveLength(0)
expect(wrapper.text()).toContain('Novels')
expect(wrapper.text()).toContain('/library/novels')
const doneButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Done')
await doneButton!.trigger('click')
@@ -326,6 +341,7 @@ describe('BookDockFileSheet', () => {
it('does not save an unchanged destination again when finishing', async () => {
mocks.libraries.push({
id: 7,
type: 'books',
name: 'Novels',
folders: [{ id: 70, path: '/library/novels' }],
})
@@ -343,11 +359,13 @@ describe('BookDockFileSheet', () => {
mocks.libraries.push(
{
id: 7,
type: 'books',
name: 'Novels',
folders: [{ id: 70, path: '/library/novels' }],
},
{
id: 8,
type: 'books',
name: 'Comics',
folders: [{ id: 80, path: '/library/comics' }],
},
@@ -375,6 +393,7 @@ describe('BookDockFileSheet', () => {
it('keeps the sheet open when the visible destination cannot be saved', async () => {
mocks.libraries.push({
id: 7,
type: 'books',
name: 'Novels',
folders: [{ id: 70, path: '/library/novels' }],
})
@@ -389,4 +408,73 @@ describe('BookDockFileSheet', () => {
expect(wrapper.emitted('updated')).toBeUndefined()
expect(wrapper.emitted('close')).toBeUndefined()
})
it('selects a delayed sole library and persists it only when Done is clicked', async () => {
mocks.fetchLibraries.mockImplementation(async () => {
libraryOptions.value = [{ id: 7, type: 'books', name: 'Novels', folders: [{ id: 70, path: '/books/novels' }] }]
})
mocks.setTarget.mockResolvedValue(makeFile({ targetLibraryId: 7, targetFolderId: 70 }))
const wrapper = mountSheet(makeFile())
await flushPromises()
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.text()).toContain('Novels')
expect(mocks.setTarget).not.toHaveBeenCalled()
await wrapper
.findAll('button')
.find((button) => button.text().trim() === 'Done')!
.trigger('click')
await flushPromises()
expect(mocks.setTarget).toHaveBeenCalledExactlyOnceWith(1, 7, 70)
})
it('preserves a saved non-first folder and offers the other folders', async () => {
mocks.libraries.push({
id: 7,
type: 'books',
name: 'Novels',
folders: [
{ id: 70, path: '/books/first' },
{ id: 71, path: '/books/second' },
],
})
const wrapper = mountSheet(makeFile({ targetLibraryId: 7, targetFolderId: 71 }))
await flushPromises()
expect(wrapper.findAll('select')).toHaveLength(1)
expect(wrapper.get<HTMLSelectElement>('select').element.value).toBe('71')
await wrapper.get('select').setValue('70')
await flushPromises()
expect(mocks.setTarget).toHaveBeenCalledWith(1, 7, 70)
})
it('uses the next file destination when switching files in an open sheet', async () => {
mocks.libraries.push(
{ id: 7, type: 'books', name: 'Novels', folders: [{ id: 70, path: '/books/novels' }] },
{ id: 8, type: 'books', name: 'Comics', folders: [{ id: 80, path: '/books/comics' }] },
)
const wrapper = mountSheet(makeFile({ targetLibraryId: 7, targetFolderId: 70 }))
await wrapper.setProps({ file: makeFile({ id: 2, targetLibraryId: 8, targetFolderId: 80 }) })
await flushPromises()
expect(wrapper.get<HTMLSelectElement>('select').element.value).toBe('8')
expect(wrapper.text()).toContain('/books/comics')
await wrapper
.findAll('button')
.find((button) => button.text().trim() === 'Done')!
.trigger('click')
await flushPromises()
expect(mocks.setTarget).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[]])
})
it('does not overwrite an unavailable saved destination with the sole library', async () => {
mocks.libraries.push({ id: 7, type: 'books', name: 'Novels', folders: [{ id: 70, path: '/books/novels' }] })
const wrapper = mountSheet(makeFile({ targetLibraryId: 99, targetFolderId: 990 }))
await flushPromises()
expect(wrapper.get<HTMLSelectElement>('select').element.value).toBe('')
await wrapper
.findAll('button')
.find((button) => button.text().trim() === 'Done')!
.trigger('click')
await flushPromises()
expect(mocks.setTarget).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[]])
})
})
@@ -1,9 +1,12 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { BookDockFinalizeResult } from '@bookorbit/types'
import BookDockFinalizeDialog from '../BookDockFinalizeDialog.vue'
import { i18n } from '@/i18n'
import { makeLibrary } from '@/features/library/test/fixtures'
const selectionSummary = ref({ total: 2, withDestination: 0, withoutDestination: 2 })
const apiMock = vi.fn<(...args: unknown[]) => Promise<Response>>()
const finalizeResult = ref<BookDockFinalizeResult | null>(null)
const finalizeLoading = ref(false)
@@ -11,6 +14,12 @@ const finalizeError = ref<string | null>(null)
const finalizeMock = vi.fn<(...args: unknown[]) => Promise<void>>()
const resetMock = vi.fn<() => void>()
const fetchLibrariesMock = vi.fn<() => Promise<void>>()
const libraryOptions = ref([
makeLibrary({ id: 2, name: 'Main', folders: [{ id: 3, path: '/books', role: 'downloads', createdAt: '2026-01-01T00:00:00.000Z' }] }),
])
const librariesLoaded = ref(true)
const librariesLoading = ref(false)
const librariesError = ref<string | null>(null)
const refreshLibrariesMock = vi.fn<() => void>()
vi.mock('vue-router', () => ({
@@ -25,7 +34,10 @@ vi.mock('@/lib/api', () => ({
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: ref([{ id: 2, name: 'Main', folders: [{ id: 3, path: '/books' }] }]),
libraries: libraryOptions,
loaded: librariesLoaded,
loading: librariesLoading,
error: librariesError,
fetchLibraries: fetchLibrariesMock,
refreshLibraries: refreshLibrariesMock,
}),
@@ -49,8 +61,11 @@ function jsonResponse(body: unknown, status = 200): Response {
} as unknown as Response
}
const wrappers: ReturnType<typeof mount>[] = []
afterEach(() => wrappers.splice(0).forEach((wrapper) => wrapper.unmount()))
function mountDialog() {
return mount(BookDockFinalizeDialog, {
const wrapper = mount(BookDockFinalizeDialog, {
props: {
selectionPayload: { fileIds: [10, 11] },
selectionCount: 2,
@@ -61,10 +76,20 @@ function mountDialog() {
},
},
})
wrappers.push(wrapper)
return wrapper
}
describe('BookDockFinalizeDialog', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
libraryOptions.value = [
makeLibrary({ id: 2, name: 'Main', folders: [{ id: 3, path: '/books', role: 'downloads', createdAt: '2026-01-01T00:00:00.000Z' }] }),
]
selectionSummary.value = { total: 2, withDestination: 0, withoutDestination: 2 }
librariesLoaded.value = true
librariesLoading.value = false
librariesError.value = null
apiMock.mockReset()
finalizeResult.value = null
finalizeLoading.value = false
@@ -77,7 +102,7 @@ describe('BookDockFinalizeDialog', () => {
apiMock.mockImplementation(async (url) => {
if (url === '/api/v1/book-dock/files/selection-summary') {
return jsonResponse({ total: 2, withDestination: 0, withoutDestination: 2 })
return jsonResponse(selectionSummary.value)
}
if (url === '/api/v1/book-dock/finalize/preview') {
@@ -177,4 +202,103 @@ describe('BookDockFinalizeDialog', () => {
expect(wrapper.text()).not.toContain('Import anyway')
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
})
function startButton(wrapper: ReturnType<typeof mountDialog>) {
return wrapper.findAll('button').find((button) => button.text().trim() === 'Start')!
}
it('shows a sole destination as text and files only after explicit confirmation', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.text()).toContain('Main')
expect(wrapper.text()).toContain('/books')
expect(finalizeMock).not.toHaveBeenCalled()
await startButton(wrapper).trigger('click')
expect(finalizeMock).toHaveBeenCalledExactlyOnceWith({ fileIds: [10, 11], defaultLibraryId: 2, defaultFolderId: 3 })
})
it('keeps multiple folders selectable and uses the chosen folder in preview and finalization', async () => {
libraryOptions.value[0]!.folders.push({ ...libraryOptions.value[0]!.folders[0]!, id: 4, path: '/books/second' })
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.findAll('select')).toHaveLength(1)
await wrapper.get('select').setValue('4')
await flushPromises()
const previewCalls = apiMock.mock.calls.filter(([url]) => url === '/api/v1/book-dock/finalize/preview')
const body = JSON.parse((previewCalls.at(-1)![1] as RequestInit).body as string)
expect(body.defaultFolderId).toBe(4)
await startButton(wrapper).trigger('click')
expect(finalizeMock).toHaveBeenCalledWith({ fileIds: [10, 11], defaultLibraryId: 2, defaultFolderId: 4 })
})
it('preserves the first-library default with multiple choices and excludes podcasts', async () => {
libraryOptions.value.unshift(makeLibrary({ id: 1, type: 'podcasts', name: 'Podcasts' }))
libraryOptions.value.push(makeLibrary({ id: 5, name: 'Comics' }))
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.get<HTMLSelectElement>('select').element.value).toBe('2')
expect(wrapper.text()).not.toContain('Podcasts')
await wrapper.get('select').setValue('5')
await flushPromises()
await startButton(wrapper).trigger('click')
expect(finalizeMock).toHaveBeenCalledWith({ fileIds: [10, 11], defaultLibraryId: 5, defaultFolderId: 30 })
})
it.each(['empty', 'folderless', 'failed', 'loading'])('blocks filing and preview for a %s fallback destination', async (scenario) => {
if (scenario === 'empty') libraryOptions.value = []
if (scenario === 'folderless') libraryOptions.value[0]!.folders = []
if (scenario === 'failed') librariesError.value = 'HTTP 503'
if (scenario === 'loading') librariesLoading.value = true
const wrapper = mountDialog()
await flushPromises()
expect(startButton(wrapper).attributes('disabled')).toBeDefined()
await startButton(wrapper).trigger('click')
expect(finalizeMock).not.toHaveBeenCalled()
expect(apiMock.mock.calls.some(([url]) => url === '/api/v1/book-dock/finalize/preview')).toBe(false)
})
it('does not send a fallback when all files already carry destinations', async () => {
selectionSummary.value = { total: 2, withDestination: 2, withoutDestination: 0 }
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.text()).not.toContain('/books')
await startButton(wrapper).trigger('click')
expect(finalizeMock).toHaveBeenCalledExactlyOnceWith({ fileIds: [10, 11] })
const previewCall = apiMock.mock.calls.find(([url]) => url === '/api/v1/book-dock/finalize/preview')!
expect(JSON.parse((previewCall[1] as RequestInit).body as string)).toEqual({ fileIds: [10, 11] })
})
it('ignores a late preview after the selected destination becomes unavailable', async () => {
const completePreviews: Array<(response: Response) => void> = []
apiMock.mockImplementation(async (url) => {
if (url === '/api/v1/book-dock/files/selection-summary') return jsonResponse(selectionSummary.value)
return new Promise<Response>((resolve) => {
completePreviews.push(resolve)
})
})
const wrapper = mountDialog()
await flushPromises()
expect(completePreviews.length).toBeGreaterThan(0)
libraryOptions.value[0]!.folders = []
await flushPromises()
for (const complete of completePreviews) {
complete(
jsonResponse({
total: 2,
ready: 2,
duplicates: 0,
destinationConflicts: 0,
missingDestination: 0,
blocked: 0,
truncated: false,
itemLimit: 200,
items: [{ fileId: 10, fileName: 'stale.epub', newName: 'stale-name.epub', status: 'ready' }],
}),
)
}
await flushPromises()
expect(wrapper.text()).not.toContain('stale-name.epub')
expect(startButton(wrapper).attributes('disabled')).toBeDefined()
expect(finalizeMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,103 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Library } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { makeLibrary } from '@/features/library/test/fixtures'
import BookDockSetDestinationDialog from '../BookDockSetDestinationDialog.vue'
const libraries = ref<Library[]>([])
const loaded = ref(true)
const loading = ref(false)
const error = ref<string | null>(null)
const fetchLibraries = vi.fn<() => Promise<void>>()
const api = vi.fn<(...args: unknown[]) => Promise<Response>>()
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries, loaded, loading, error, fetchLibraries, refreshLibraries: vi.fn<() => Promise<void>>() }),
}))
vi.mock('@/lib/api', () => ({ api: (...args: unknown[]) => api(...args) }))
let wrapper: VueWrapper
function mountDialog() {
wrapper = mount(BookDockSetDestinationDialog, {
props: { selectionPayload: { selectAll: true, excludedIds: [12], search: 'Dune' }, selectionCount: 2 },
global: { stubs: { Teleport: true } },
})
}
function applyButton() {
return wrapper.findAll('button').find((button) => button.text().trim() === 'Apply')!
}
beforeEach(() => {
i18n.global.locale.value = 'en'
libraries.value = [makeLibrary()]
loaded.value = true
loading.value = false
error.value = null
fetchLibraries.mockReset().mockResolvedValue(undefined)
api.mockReset().mockResolvedValue({ ok: true, json: async () => ({ total: 2, updated: 2, failed: 0 }) } as Response)
})
afterEach(() => wrapper?.unmount())
describe('BookDockSetDestinationDialog', () => {
it('shows a sole destination as text and applies it to the unchanged selection payload', async () => {
mountDialog()
await flushPromises()
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.text()).toContain('Main Library')
expect(wrapper.text()).toContain('/books')
expect(api).not.toHaveBeenCalled()
await applyButton().trigger('click')
expect(api).toHaveBeenCalledExactlyOnceWith('/api/v1/book-dock/files/set-target', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectAll: true, excludedIds: [12], search: 'Dune', targetLibraryId: 3, targetFolderId: 30 }),
})
})
it('keeps multiple folders selectable without requiring a library choice', async () => {
libraries.value[0]!.folders.push({ ...libraries.value[0]!.folders[0]!, id: 31, path: '/books/second' })
mountDialog()
await flushPromises()
expect(wrapper.findAll('select')).toHaveLength(1)
await wrapper.get('select').setValue('31')
await applyButton().trigger('click')
expect(JSON.parse((api.mock.calls[0]![1] as RequestInit).body as string).targetFolderId).toBe(31)
})
it('keeps the existing first-library default and updates the folder when choosing another', async () => {
libraries.value.unshift(makeLibrary({ id: 1, type: 'podcasts', name: 'Podcasts' }))
libraries.value.push(makeLibrary({ id: 4, name: 'Comics', folders: [{ id: 40, path: '/comics', role: 'downloads', createdAt: '2026-01-01' }] }))
mountDialog()
await flushPromises()
expect(wrapper.get<HTMLSelectElement>('select').element.value).toBe('3')
expect(wrapper.text()).not.toContain('Podcasts')
await wrapper.get('select').setValue('4')
await applyButton().trigger('click')
expect(JSON.parse((api.mock.calls[0]![1] as RequestInit).body as string)).toMatchObject({ targetLibraryId: 4, targetFolderId: 40 })
})
it.each(['empty', 'folderless', 'failed', 'loading'])('does not submit for a %s destination', async (scenario) => {
if (scenario === 'empty') libraries.value = []
if (scenario === 'folderless') libraries.value[0]!.folders = []
if (scenario === 'failed') error.value = 'HTTP 503'
if (scenario === 'loading') loading.value = true
mountDialog()
await flushPromises()
expect(applyButton().attributes('disabled')).toBeDefined()
await applyButton().trigger('click')
expect(api).not.toHaveBeenCalled()
})
it('enables Apply when a delayed library list arrives', async () => {
libraries.value = []
loaded.value = false
mountDialog()
await flushPromises()
expect(applyButton().attributes('disabled')).toBeDefined()
libraries.value = [makeLibrary()]
loaded.value = true
await flushPromises()
expect(applyButton().attributes('disabled')).toBeUndefined()
expect(wrapper.find('select').exists()).toBe(false)
})
})
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { BookDestination } from '../composables/useBookDestination'
const props = defineProps<{
destination: BookDestination
libraryLabel: string
folderLabel: string
scoped?: boolean
}>()
const emit = defineEmits<{ change: [] }>()
const { t } = useI18n()
function parseId(event: Event): number | null {
const id = Number((event.target as HTMLSelectElement).value)
return Number.isSafeInteger(id) && id > 0 ? id : null
}
function onLibraryChange(event: Event): void {
props.destination.selectLibrary(parseId(event))
emit('change')
}
function onFolderChange(event: Event): void {
props.destination.selectFolder(parseId(event))
emit('change')
}
</script>
<template>
<div class="space-y-3">
<p v-if="destination.error.value" class="text-sm text-destructive" role="alert">{{ t('library.destination.loadFailed') }}</p>
<p v-else-if="!destination.available.value" class="text-sm text-muted-foreground" role="status">{{ t('common.loading') }}</p>
<p v-else-if="!destination.libraries.value.length" class="text-sm text-muted-foreground" role="status">
{{ t('library.destination.noLibraries') }}
</p>
<template v-else>
<label v-if="!scoped && (destination.libraries.value.length > 1 || !destination.selectedLibrary.value)" class="block">
<span class="text-xs font-medium text-muted-foreground">{{ libraryLabel }}</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
:value="destination.selectedLibrary.value ? destination.libraryId.value : ''"
@change="onLibraryChange"
>
<option value="" disabled>{{ t('library.upload.selectLibrary') }}</option>
<option v-for="library in destination.libraries.value" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
</label>
<dl v-else-if="destination.selectedLibrary.value" class="space-y-1">
<dt class="text-xs font-medium text-muted-foreground">{{ libraryLabel }}</dt>
<dd class="text-sm text-foreground break-words">{{ destination.selectedLibrary.value.name }}</dd>
</dl>
<p v-else class="text-sm text-destructive" role="alert">{{ t('library.destination.unavailable') }}</p>
<template v-if="destination.selectedLibrary.value">
<p v-if="!destination.folders.value.length" class="text-sm text-muted-foreground" role="status">
{{ t('library.destination.noFolders') }}
</p>
<label v-else-if="destination.folders.value.length > 1 || !destination.selectedFolder.value" class="block">
<span class="text-xs font-medium text-muted-foreground">{{ folderLabel }}</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
:value="destination.selectedFolder.value ? destination.folderId.value : ''"
@change="onFolderChange"
>
<option value="" disabled>{{ t('library.destination.selectFolder') }}</option>
<option v-for="folder in destination.folders.value" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</label>
<dl v-else class="space-y-1">
<dt class="text-xs font-medium text-muted-foreground">{{ folderLabel }}</dt>
<dd class="text-sm text-foreground break-all">{{ destination.selectedFolder.value.path }}</dd>
</dl>
</template>
</template>
</div>
</template>
@@ -1,14 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CheckCircle2, FileUp, Loader2, Plus, RotateCcw, Upload, X, XCircle } from '@lucide/vue'
import type { Library } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT, useBookUpload, type FileUploadStatus } from '../composables/useBookUpload'
import { emitLibraryUploadCompleted } from '../composables/useLibraryUploadEvents'
import { useLibraries } from '../composables/useLibraries'
import { api } from '@/lib/api'
import { useBookDestination } from '../composables/useBookDestination'
import BookDestinationFields from './BookDestinationFields.vue'
import { formatBytes } from '@/lib/formatting'
const props = defineProps<{
libraryId?: number
@@ -21,20 +20,17 @@ const emit = defineEmits<{
const router = useRouter()
const { t } = useI18n()
const { libraries, fetchLibraries } = useLibraries()
const destination = useBookDestination({ libraryId: props.libraryId })
const { libraryId: selectedLibraryId, folderId: selectedFolderId, selectedLibrary, hasDestination, fetchLibraries } = destination
const { maxUploadSizeMb } = useAppInfo()
const selectedLibraryId = ref<number | undefined>(props.libraryId)
const folders = ref<Library['folders']>([])
const selectedFolderId = ref<number | undefined>(undefined)
const libraryName = ref<string | null>(null)
const isDragging = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const { files, pendingCount, isUploading, doneCount, errorCount, uploadedBookIds, addFiles, removeFile, retryFile, reset, startUpload } =
useBookUpload()
const canUpload = computed(() => pendingCount.value > 0 && !isUploading.value && selectedLibraryId.value !== undefined)
const canUpload = computed(() => pendingCount.value > 0 && !isUploading.value && hasDestination.value)
const hasFiles = computed(() => files.value.length > 0)
const allDone = computed(() => hasFiles.value && files.value.every((f) => f.status === 'done' || f.status === 'error'))
const allSuccess = computed(() => allDone.value && errorCount.value === 0)
@@ -42,7 +38,7 @@ const allSuccess = computed(() => allDone.value && errorCount.value === 0)
const headerTitle = computed(() => {
if (isUploading.value) return t('library.upload.header.uploading')
if (allDone.value) return t('library.upload.header.complete')
if (libraryName.value) return t('library.upload.header.uploadTo', { library: libraryName.value })
if (selectedLibrary.value) return t('library.upload.header.uploadTo', { library: selectedLibrary.value.name })
return t('library.upload.header.uploadBooks')
})
@@ -58,34 +54,12 @@ const fileSummary = computed(() => {
return { total: files.value.length, totalBytes, formatParts }
})
async function loadFolders(libraryId: number) {
const res = await api(`/api/v1/libraries/${libraryId}`)
if (res.ok) {
const data: Library = await res.json()
folders.value = data.folders ?? []
selectedFolderId.value = folders.value[0]?.id
libraryName.value = data.name
}
}
watch(selectedLibraryId, (id) => {
folders.value = []
selectedFolderId.value = undefined
libraryName.value = null
if (id !== undefined) loadFolders(id)
})
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
onMounted(async () => {
if (!props.libraryId) {
if (!libraries.value.length) await fetchLibraries()
if (selectedLibraryId.value !== undefined) loadFolders(selectedLibraryId.value)
} else {
loadFolders(props.libraryId)
}
onMounted(() => {
void fetchLibraries()
document.addEventListener('keydown', onKeydown)
})
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
@@ -131,8 +105,8 @@ function onFileInputChange(e: Event) {
async function handleUpload() {
const libraryId = selectedLibraryId.value
if (libraryId === undefined) return
const folderId = selectedFolderId.value
if (!hasDestination.value || libraryId === null) return
const folderId = selectedFolderId.value ?? undefined
const pendingAtStart = files.value.filter((f) => f.status === 'pending').length
if (pendingAtStart === 0) return
@@ -211,28 +185,12 @@ function formatPillClass(filename: string): string {
</div>
<div class="flex-1 overflow-y-auto p-5 flex flex-col gap-4">
<!-- Library selector (only when not pre-scoped to a library) -->
<div v-if="!libraryId" class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t('library.upload.library') }}</label>
<select
v-model="selectedLibraryId"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option :value="undefined" disabled>{{ t('library.upload.selectLibrary') }}</option>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</div>
<!-- Folder selector (only shown when library has multiple folders) -->
<div v-if="folders.length > 1" class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t('library.upload.targetFolder') }}</label>
<select
v-model="selectedFolderId"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</div>
<BookDestinationFields
:destination="destination"
:library-label="t('library.upload.library')"
:folder-label="t('library.upload.targetFolder')"
:scoped="libraryId !== undefined"
/>
<!-- Shared file input -->
<input ref="fileInputRef" type="file" multiple :accept="SUPPORTED_FORMATS_ACCEPT" class="sr-only" @change="onFileInputChange" />
@@ -0,0 +1,113 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Library } from '@bookorbit/types'
import { i18n } from '@/i18n'
import BookDestinationFields from '../BookDestinationFields.vue'
import { useBookDestination } from '../../composables/useBookDestination'
import { makeLibrary } from '../../test/fixtures'
const libraries = ref<Library[]>([])
const loaded = ref(true)
const loading = ref(false)
const error = ref<string | null>(null)
vi.mock('../../composables/useLibraries', () => ({
useLibraries: () => ({
libraries,
loaded,
loading,
error,
fetchLibraries: vi.fn<() => Promise<void>>(),
refreshLibraries: vi.fn<() => Promise<void>>(),
}),
}))
let wrapper: VueWrapper
function mountFields(options: Parameters<typeof useBookDestination>[0] = {}, scoped = false) {
wrapper = mount(
defineComponent({
setup() {
const destination = useBookDestination(options)
return () => h(BookDestinationFields, { destination, libraryLabel: 'Library', folderLabel: 'Folder', scoped })
},
}),
)
return wrapper.getComponent(BookDestinationFields)
}
beforeEach(() => {
i18n.global.locale.value = 'en'
libraries.value = [makeLibrary()]
loaded.value = true
loading.value = false
error.value = null
})
afterEach(() => wrapper?.unmount())
describe('BookDestinationFields', () => {
it('renders the sole library and folder as readable text without selects', () => {
mountFields()
expect(wrapper.findAll('select')).toHaveLength(0)
expect(wrapper.findAll('dt').map((term) => term.text())).toEqual(['Library', 'Folder'])
expect(wrapper.findAll('dd').map((value) => value.text())).toEqual(['Main Library', '/books'])
})
it('offers a programmatically labelled folder selector when only folders require a choice', async () => {
libraries.value[0]!.folders.push({ ...libraries.value[0]!.folders[0]!, id: 31, path: '/books/second' })
const fields = mountFields()
const label = wrapper.get('label')
expect(label.text()).toContain('Folder')
expect(wrapper.findAll('select')).toHaveLength(1)
await label.get('select').setValue('31')
expect(fields.props('destination').folderId.value).toBe(31)
expect(fields.emitted('change')).toEqual([[]])
})
it('offers a library selector and keeps a scoped library read-only', async () => {
libraries.value.push(makeLibrary({ id: 4, name: 'Comics' }))
const fields = mountFields()
expect(wrapper.get('label').text()).toContain('Library')
await wrapper.get('select').setValue('4')
expect(fields.props('destination').libraryId.value).toBe(4)
expect(wrapper.get('dd').text()).toBe('/books')
wrapper.unmount()
mountFields({ libraryId: 4 }, true)
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.text()).toContain('Comics')
})
it('exposes loading, failure, and empty states accessibly', async () => {
loaded.value = false
mountFields()
expect(wrapper.get('[role="status"]').text()).toBe('Loading...')
error.value = 'HTTP 503'
await nextTick()
expect(wrapper.get('[role="alert"]').text()).toContain('Could not load')
expect(wrapper.text()).not.toContain('HTTP 503')
error.value = null
loaded.value = true
libraries.value = []
await nextTick()
expect(wrapper.get('[role="status"]').text()).toContain('No book libraries')
})
it('explains a missing folder without showing an empty dropdown', () => {
libraries.value = [makeLibrary({ folders: [] })]
mountFields()
expect(wrapper.get('[role="status"]').text()).toContain('no destination folders')
expect(wrapper.find('select').exists()).toBe(false)
})
it('allows an unavailable carried destination to be corrected even with one choice', async () => {
const fields = mountFields({ libraryId: 99, folderId: 99 })
expect(wrapper.get<HTMLSelectElement>('select').element.value).toBe('')
await wrapper.get('select').setValue('3')
expect(fields.props('destination').hasDestination.value).toBe(true)
expect(wrapper.find('select').exists()).toBe(false)
})
it('shows an unavailable scoped library without silently replacing it', () => {
mountFields({ libraryId: 99 }, true)
expect(wrapper.get('[role="alert"]').text()).toContain('no longer available')
expect(wrapper.find('select').exists()).toBe(false)
})
})
@@ -0,0 +1,158 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Library } from '@bookorbit/types'
import { i18n } from '@/i18n'
import BookUploadModal from '../BookUploadModal.vue'
import { makeLibrary } from '../../test/fixtures'
import type { FileUploadItem } from '../../composables/useBookUpload'
const libraries = ref<Library[]>([])
const loaded = ref(true)
const loading = ref(false)
const error = ref<string | null>(null)
const fetchLibraries = vi.fn<() => Promise<void>>()
const startUpload = vi.fn<(...args: unknown[]) => Promise<void>>()
const files = ref<FileUploadItem[]>([])
vi.mock('../../composables/useLibraries', () => ({
useLibraries: () => ({ libraries, loaded, loading, error, fetchLibraries, refreshLibraries: vi.fn<() => Promise<void>>() }),
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn<(to: unknown) => void>() }) }))
vi.mock('@/features/settings/composables/useAppInfo', () => ({ useAppInfo: () => ({ maxUploadSizeMb: ref(500) }) }))
vi.mock('../../composables/useLibraryUploadEvents', () => ({ emitLibraryUploadCompleted: vi.fn<(event: unknown) => void>() }))
vi.mock('../../composables/useBookUpload', () => ({
SUPPORTED_FORMATS: ['epub'],
SUPPORTED_FORMATS_ACCEPT: '.epub',
useBookUpload: () => ({
files,
pendingCount: computed(() => files.value.filter((file) => file.status === 'pending').length),
isUploading: ref(false),
doneCount: ref(0),
errorCount: ref(0),
uploadedBookIds: ref([]),
addFiles: vi.fn<(files: File[]) => void>(),
removeFile: vi.fn<(id: string) => void>(),
retryFile: vi.fn<(id: string) => void>(),
reset: vi.fn<() => void>(),
startUpload,
}),
}))
let wrapper: VueWrapper
function mountUpload(libraryId?: number) {
wrapper = mount(BookUploadModal, { props: { libraryId }, global: { stubs: { Teleport: true } } })
return wrapper
}
function uploadButton() {
return wrapper.findAll('button').find((button) => button.text().startsWith('Upload ('))!
}
beforeEach(() => {
i18n.global.locale.value = 'en'
libraries.value = [makeLibrary()]
loaded.value = true
loading.value = false
error.value = null
fetchLibraries.mockReset().mockResolvedValue(undefined)
startUpload.mockReset().mockResolvedValue(undefined)
files.value = [{ id: 'book', file: new File(['book'], 'book.epub'), status: 'pending', progress: 0 }]
})
afterEach(() => wrapper?.unmount())
describe('BookUploadModal destination', () => {
it('enables upload to the sole library without any selection and sends its folder', async () => {
mountUpload()
await flushPromises()
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.text()).toContain('Upload to Main Library')
expect(uploadButton().attributes('disabled')).toBeUndefined()
expect(startUpload).not.toHaveBeenCalled()
await uploadButton().trigger('click')
expect(startUpload).toHaveBeenCalledExactlyOnceWith(3, 30)
})
it('waits for a delayed successful library load before enabling upload', async () => {
libraries.value = []
loaded.value = false
loading.value = true
let finish!: () => void
fetchLibraries.mockImplementation(
() =>
new Promise<void>((resolve) => {
finish = resolve
}),
)
mountUpload()
expect(uploadButton().attributes('disabled')).toBeDefined()
libraries.value = [makeLibrary()]
loaded.value = true
loading.value = false
finish()
await flushPromises()
expect(uploadButton().attributes('disabled')).toBeUndefined()
expect(wrapper.find('select').exists()).toBe(false)
})
it('requires a choice with multiple libraries and resets the folder when changing library', async () => {
libraries.value.push(makeLibrary({ id: 4, name: 'Comics', folders: [{ id: 40, path: '/comics', role: 'downloads', createdAt: '2026-01-01' }] }))
mountUpload()
await flushPromises()
expect(uploadButton().attributes('disabled')).toBeDefined()
await wrapper.get('select').setValue('4')
expect(wrapper.text()).toContain('Upload to Comics')
await uploadButton().trigger('click')
expect(startUpload).toHaveBeenLastCalledWith(4, 40)
await wrapper.get('select').setValue('3')
await uploadButton().trigger('click')
expect(startUpload).toHaveBeenLastCalledWith(3, 30)
})
it('keeps multiple folders selectable in a sole library', async () => {
libraries.value[0]!.folders.push({ ...libraries.value[0]!.folders[0]!, id: 31, path: '/books/second' })
mountUpload()
await flushPromises()
expect(wrapper.findAll('select')).toHaveLength(1)
await wrapper.get('select').setValue('31')
await uploadButton().trigger('click')
expect(startUpload).toHaveBeenCalledExactlyOnceWith(3, 31)
})
it('preserves a caller-scoped library even when it is not first', async () => {
libraries.value.push(makeLibrary({ id: 4, name: 'Comics' }))
mountUpload(4)
await flushPromises()
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.text()).toContain('Comics')
await uploadButton().trigger('click')
expect(startUpload).toHaveBeenCalledExactlyOnceWith(4, 30)
})
it('excludes podcast libraries from the sole-library calculation', async () => {
libraries.value.unshift(makeLibrary({ id: 1, type: 'podcasts', name: 'Podcasts' }))
mountUpload()
await flushPromises()
expect(wrapper.text()).not.toContain('Podcasts')
await uploadButton().trigger('click')
expect(startUpload).toHaveBeenCalledExactlyOnceWith(3, 30)
})
it.each(['empty', 'folderless', 'failed', 'unavailable'])('keeps upload disabled for a %s destination', async (scenario) => {
if (scenario === 'empty') libraries.value = []
if (scenario === 'folderless') libraries.value = [makeLibrary({ folders: [] })]
if (scenario === 'failed') error.value = 'HTTP 503'
mountUpload(scenario === 'unavailable' ? 99 : undefined)
await flushPromises()
expect(uploadButton().attributes('disabled')).toBeDefined()
await uploadButton().trigger('click')
expect(startUpload).not.toHaveBeenCalled()
expect(wrapper.find('[role="status"], [role="alert"]').exists()).toBe(true)
})
it('disables upload if the selected folder is removed during a refresh', async () => {
mountUpload()
await flushPromises()
libraries.value = [makeLibrary({ folders: [] })]
await flushPromises()
expect(uploadButton().attributes('disabled')).toBeDefined()
expect(startUpload).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,217 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useBookDestination } from '../useBookDestination'
import { makeLibrary } from '../../test/fixtures'
import type { Library } from '@bookorbit/types'
const libraries = ref<Library[]>([])
const loaded = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
const fetchLibraries = vi.fn<() => Promise<void>>()
const refreshLibraries = vi.fn<() => Promise<void>>()
vi.mock('../useLibraries', () => ({
useLibraries: () => ({
libraries,
loaded,
loading,
error,
fetchLibraries,
refreshLibraries,
}),
}))
let scope = effectScope()
function destination(options: Parameters<typeof useBookDestination>[0] = {}) {
return scope.run(() => useBookDestination(options))!
}
beforeEach(() => {
scope = effectScope()
libraries.value = []
loaded.value = false
loading.value = false
error.value = null
fetchLibraries.mockReset().mockResolvedValue(undefined)
refreshLibraries.mockReset().mockResolvedValue(undefined)
})
afterEach(() => scope.stop())
function load(items: Library[]) {
libraries.value = items
loaded.value = true
}
describe('useBookDestination', () => {
it('selects the sole book library and its folder after the list finishes loading', async () => {
const state = destination()
libraries.value = [makeLibrary()]
loading.value = true
await nextTick()
expect(state.libraryId.value).toBeNull()
expect(state.hasDestination.value).toBe(false)
loaded.value = true
loading.value = false
await nextTick()
expect(state.libraryId.value).toBe(3)
expect(state.folderId.value).toBe(30)
expect(state.hasDestination.value).toBe(true)
})
it('uses a cached list immediately and excludes podcast libraries', () => {
load([makeLibrary({ id: 1, type: 'podcasts' }), makeLibrary()])
const state = destination()
expect(state.libraries.value.map((library) => library.id)).toEqual([3])
expect(state.libraryId.value).toBe(3)
expect(state.hasDestination.value).toBe(true)
})
it('requires a choice when there are multiple libraries', () => {
load([makeLibrary({ id: 1 }), makeLibrary()])
const state = destination()
expect(state.libraryId.value).toBeNull()
expect(state.folderId.value).toBeNull()
expect(state.hasDestination.value).toBe(false)
})
it('preserves the existing Dock first-library default when requested', () => {
load([makeLibrary({ id: 1 }), makeLibrary()])
const state = destination({ defaultToFirstLibrary: true })
expect(state.libraryId.value).toBe(1)
expect(state.folderId.value).toBe(30)
})
it.each([[], [makeLibrary({ type: 'podcasts' })], [makeLibrary({ folders: [] })]].map((items) => ({ items })))(
'does not report a destination for an empty library or folder list',
({ items }) => {
load(items)
expect(destination().hasDestination.value).toBe(false)
},
)
it('preserves a carried library and non-first folder across loading and refreshes', async () => {
const state = destination({ libraryId: 3, folderId: 31 })
const library = makeLibrary()
library.folders.push({ ...library.folders[0]!, id: 31, path: '/books/second' })
load([makeLibrary({ id: 1 }), library])
await nextTick()
expect(state.libraryId.value).toBe(3)
expect(state.folderId.value).toBe(31)
libraries.value = [library, makeLibrary({ id: 1 })]
await nextTick()
expect(state.folderId.value).toBe(31)
expect(state.hasDestination.value).toBe(true)
})
it('fills a missing folder in the carried library', () => {
load([makeLibrary({ id: 1 }), makeLibrary()])
const state = destination({ libraryId: 3 })
expect(state.libraryId.value).toBe(3)
expect(state.folderId.value).toBe(30)
})
it('changes folders with the selected library and keeps manual selections after refresh', async () => {
const second = makeLibrary({ id: 4, folders: [{ id: 40, path: '/comics', role: 'downloads', createdAt: '2026-01-01' }] })
second.folders.push({ ...second.folders[0]!, id: 41 })
load([makeLibrary(), second])
const state = destination()
state.selectLibrary(4)
state.selectFolder(41)
await nextTick()
expect(state.hasDestination.value).toBe(true)
libraries.value = [makeLibrary(), second]
await nextTick()
expect(state.libraryId.value).toBe(4)
expect(state.folderId.value).toBe(41)
state.selectLibrary(3)
expect(state.folderId.value).toBe(30)
})
it('clears the previous folder when a library has no folders', async () => {
load([makeLibrary(), makeLibrary({ id: 4, folders: [] })])
const state = destination({ libraryId: 3 })
state.selectLibrary(4)
await nextTick()
expect(state.folderId.value).toBeNull()
expect(state.hasDestination.value).toBe(false)
})
it.each([
{ libraryId: 99, folderId: 30 },
{ libraryId: 3, folderId: 99 },
])('preserves unavailable carried IDs without treating them as valid', (carried) => {
load([makeLibrary()])
const state = destination(carried)
expect(state.libraryId.value).toBe(carried.libraryId)
expect(state.folderId.value).toBe(carried.folderId)
expect(state.hasDestination.value).toBe(false)
})
it('invalidates a destination when its library or folder disappears', async () => {
load([makeLibrary()])
const state = destination()
libraries.value = [makeLibrary({ folders: [] })]
await nextTick()
expect(state.hasDestination.value).toBe(false)
expect(state.folderId.value).toBe(30)
libraries.value = []
await nextTick()
expect(state.hasDestination.value).toBe(false)
expect(state.libraryId.value).toBe(3)
})
it('blocks cached destinations while refreshing or after a failed request', async () => {
load([makeLibrary()])
const state = destination()
loading.value = true
await nextTick()
expect(state.hasDestination.value).toBe(false)
loading.value = false
error.value = 'HTTP 503'
await nextTick()
expect(state.hasDestination.value).toBe(false)
error.value = null
await nextTick()
expect(state.hasDestination.value).toBe(true)
})
it('does not infer a default from a failed load', () => {
load([makeLibrary()])
error.value = 'HTTP 503'
const state = destination()
expect(state.libraryId.value).toBeNull()
expect(state.folderId.value).toBeNull()
})
it('resets both carried IDs when switching Dock files', async () => {
load([makeLibrary(), makeLibrary({ id: 4, folders: [{ id: 40, path: '/comics', role: 'downloads', createdAt: '2026-01-01' }] })])
const state = destination({ defaultToFirstLibrary: true })
state.reset(4, 40)
await nextTick()
expect(state.libraryId.value).toBe(4)
expect(state.folderId.value).toBe(40)
state.reset(null, null)
await nextTick()
expect(state.libraryId.value).toBe(3)
expect(state.folderId.value).toBe(30)
})
it('uses the normal cached fetch when no previous request failed', async () => {
const state = destination()
await state.fetchLibraries()
expect(fetchLibraries).toHaveBeenCalledExactlyOnceWith()
expect(refreshLibraries).not.toHaveBeenCalled()
})
it('retries a failed refresh even when a library list was already cached', async () => {
load([makeLibrary()])
error.value = 'HTTP 503'
const state = destination()
refreshLibraries.mockImplementation(async () => {
error.value = null
})
await state.fetchLibraries()
await nextTick()
expect(refreshLibraries).toHaveBeenCalledExactlyOnceWith()
expect(fetchLibraries).not.toHaveBeenCalled()
expect(state.hasDestination.value).toBe(true)
})
})
@@ -0,0 +1,68 @@
import { computed, ref, watchEffect } from 'vue'
import { useLibraries } from './useLibraries'
interface BookDestinationOptions {
libraryId?: number | null
folderId?: number | null
defaultToFirstLibrary?: boolean
}
export function useBookDestination(options: BookDestinationOptions = {}) {
const source = useLibraries()
const libraries = computed(() => source.libraries.value.filter((library) => library.type === 'books'))
const libraryId = ref<number | null>(options.libraryId ?? null)
const folderId = ref<number | null>(options.folderId ?? null)
const selectedLibrary = computed(() => libraries.value.find((library) => library.id === libraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const selectedFolder = computed(() => folders.value.find((folder) => folder.id === folderId.value))
const available = computed(() => source.loaded.value && !source.loading.value && !source.error.value)
const hasDestination = computed(() => available.value && !!selectedLibrary.value && !!selectedFolder.value)
watchEffect(() => {
if (!available.value) return
if (libraryId.value === null && (libraries.value.length === 1 || options.defaultToFirstLibrary)) {
libraryId.value = libraries.value[0]?.id ?? null
}
if (folderId.value === null) folderId.value = folders.value[0]?.id ?? null
})
async function fetchLibraries(): Promise<void> {
if (source.error.value) await source.refreshLibraries()
else await source.fetchLibraries()
}
function selectLibrary(id: number | null): void {
libraryId.value = id
folderId.value = libraries.value.find((library) => library.id === id)?.folders[0]?.id ?? null
}
function selectFolder(id: number | null): void {
folderId.value = id
}
function reset(library: number | null, folder: number | null): void {
libraryId.value = library
folderId.value = folder
}
return {
libraries,
libraryId,
folderId,
selectedLibrary,
folders,
selectedFolder,
available,
hasDestination,
loading: source.loading,
loaded: source.loaded,
error: source.error,
fetchLibraries,
refreshLibraries: source.refreshLibraries,
selectLibrary,
selectFolder,
reset,
}
}
export type BookDestination = ReturnType<typeof useBookDestination>
@@ -0,0 +1,42 @@
import type { Library } from '@bookorbit/types'
export function makeLibrary(overrides: Partial<Library> = {}): Library {
return {
id: 3,
type: 'books',
name: 'Main Library',
icon: null,
displayOrder: 0,
coverAspectRatio: '2/3',
watch: false,
autoScanCronExpression: null,
metadataPrecedence: [],
formatPriority: [],
allowedFormats: [],
organizationMode: 'book_per_file',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 10,
markAsFinishedPercentComplete: 95,
fileNamingPattern: null,
fileWriteEnabled: false,
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 50,
fileWriteFb2Enabled: false,
fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 50,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 50,
fileWriteKindleEnabled: false,
fileWriteKindleMaxFileSizeMb: 100,
fileWriteAudioEnabled: false,
fileWriteAudioMaxFileSizeMb: 50,
fileRenameEnabled: false,
folders: [{ id: 30, path: '/books', role: 'downloads', createdAt: '2026-01-01T00:00:00.000Z' }],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
+7
View File
@@ -9259,6 +9259,13 @@
"createFolder": "Could not create folder"
}
},
"destination": {
"loadFailed": "Could not load destination libraries. Close and reopen to try again.",
"noLibraries": "No book libraries are available. Create a library or ask an administrator for access.",
"noFolders": "This library has no destination folders. Add a folder in Library settings.",
"unavailable": "The selected library is no longer available.",
"selectFolder": "Select a folder..."
},
"upload": {
"library": "Library",
"selectLibrary": "Select a library...",