diff --git a/client/src/features/book-dock/components/BookDockFileSheet.vue b/client/src/features/book-dock/components/BookDockFileSheet.vue index 3e679363c..8e00ece91 100644 --- a/client/src/features/book-dock/components/BookDockFileSheet.vue +++ b/client/src/features/book-dock/components/BookDockFileSheet.vue @@ -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(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(null) -const targetFolderId = ref(null) const persistedTargetLibraryId = ref(null) const persistedTargetFolderId = ref(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 | null = null let pendingTargetSave: Promise | 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 { + if (!hasDestination.value) return null const libraryId = targetLibraryId.value const folderId = targetFolderId.value const previousSave = pendingTargetSave @@ -627,28 +594,12 @@ onMounted(() => { -
- - -
+

{{ t('bookDock.sheet.added', { date: formatDate(file.createdAt) }) }}

diff --git a/client/src/features/book-dock/components/BookDockFinalizeDialog.vue b/client/src/features/book-dock/components/BookDockFinalizeDialog.vue index 4b25e3800..1fcbec04a 100644 --- a/client/src/features/book-dock/components/BookDockFinalizeDialog.vue +++ b/client/src/features/book-dock/components/BookDockFinalizeDialog.vue @@ -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(null) -const defaultFolderId = ref(null) const expandedErrors = ref>(new Set()) const reimportingIds = reactive(new Set()) const renameInputs = ref>(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() {

{{ finalizePreviewError }}

-
- - - -
+
diff --git a/client/src/features/book-dock/components/BookDockSetDestinationDialog.vue b/client/src/features/book-dock/components/BookDockSetDestinationDialog.vue index 33f5a3674..f0235d626 100644 --- a/client/src/features/book-dock/components/BookDockSetDestinationDialog.vue +++ b/client/src/features/book-dock/components/BookDockSetDestinationDialog.vue @@ -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(null) const result = ref<{ total: number; updated: number; failed: number } | null>(null) -const targetLibraryId = ref(null) -const targetFolderId = ref(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() {
- - - +

{{ error }}

diff --git a/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts b/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts index 994431cf7..f34b5798b 100644 --- a/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts +++ b/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts @@ -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 & { +type LibraryOption = Pick & { folders: Array> } @@ -18,6 +19,8 @@ const mocks = vi.hoisted(() => ({ libraries: [] as LibraryOption[], })) +const libraryOptions = ref([]) + 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 { } } +const wrappers: ReturnType[] = [] +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> { @@ -114,6 +126,7 @@ async function openSearchAndReadDefaults(file: BookDockFile): Promise { 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('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('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('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('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([[]]) + }) }) diff --git a/client/src/features/book-dock/components/__tests__/BookDockFinalizeDialog.spec.ts b/client/src/features/book-dock/components/__tests__/BookDockFinalizeDialog.spec.ts index 1ce1ee293..a8f060377 100644 --- a/client/src/features/book-dock/components/__tests__/BookDockFinalizeDialog.spec.ts +++ b/client/src/features/book-dock/components/__tests__/BookDockFinalizeDialog.spec.ts @@ -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>() const finalizeResult = ref(null) const finalizeLoading = ref(false) @@ -11,6 +14,12 @@ const finalizeError = ref(null) const finalizeMock = vi.fn<(...args: unknown[]) => Promise>() const resetMock = vi.fn<() => void>() const fetchLibrariesMock = vi.fn<() => Promise>() +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(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[] = [] +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) { + 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('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((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() + }) }) diff --git a/client/src/features/book-dock/components/__tests__/BookDockSetDestinationDialog.spec.ts b/client/src/features/book-dock/components/__tests__/BookDockSetDestinationDialog.spec.ts new file mode 100644 index 000000000..b8500990a --- /dev/null +++ b/client/src/features/book-dock/components/__tests__/BookDockSetDestinationDialog.spec.ts @@ -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([]) +const loaded = ref(true) +const loading = ref(false) +const error = ref(null) +const fetchLibraries = vi.fn<() => Promise>() +const api = vi.fn<(...args: unknown[]) => Promise>() +vi.mock('@/features/library/composables/useLibraries', () => ({ + useLibraries: () => ({ libraries, loaded, loading, error, fetchLibraries, refreshLibraries: vi.fn<() => Promise>() }), +})) +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('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) + }) +}) diff --git a/client/src/features/library/components/BookDestinationFields.vue b/client/src/features/library/components/BookDestinationFields.vue new file mode 100644 index 000000000..4f0fe75c2 --- /dev/null +++ b/client/src/features/library/components/BookDestinationFields.vue @@ -0,0 +1,77 @@ + + + diff --git a/client/src/features/library/components/BookUploadModal.vue b/client/src/features/library/components/BookUploadModal.vue index ad23f2281..b1a4f23f6 100644 --- a/client/src/features/library/components/BookUploadModal.vue +++ b/client/src/features/library/components/BookUploadModal.vue @@ -1,14 +1,13 @@