From e767358443419253eeed782518d6fc4f675d69fd Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:56:44 +0000 Subject: [PATCH] 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 --- .../components/BookDockFileSheet.vue | 73 +----- .../components/BookDockFinalizeDialog.vue | 73 ++---- .../BookDockSetDestinationDialog.vue | 65 +----- .../__tests__/BookDockFileSheet.spec.ts | 104 ++++++++- .../__tests__/BookDockFinalizeDialog.spec.ts | 132 ++++++++++- .../BookDockSetDestinationDialog.spec.ts | 103 +++++++++ .../components/BookDestinationFields.vue | 77 +++++++ .../library/components/BookUploadModal.vue | 76 ++---- .../__tests__/BookDestinationFields.spec.ts | 113 +++++++++ .../__tests__/BookUploadModal.spec.ts | 158 +++++++++++++ .../__tests__/useBookDestination.spec.ts | 217 ++++++++++++++++++ .../library/composables/useBookDestination.ts | 68 ++++++ client/src/features/library/test/fixtures.ts | 42 ++++ client/src/locales/en.json | 7 + 14 files changed, 1065 insertions(+), 243 deletions(-) create mode 100644 client/src/features/book-dock/components/__tests__/BookDockSetDestinationDialog.spec.ts create mode 100644 client/src/features/library/components/BookDestinationFields.vue create mode 100644 client/src/features/library/components/__tests__/BookDestinationFields.spec.ts create mode 100644 client/src/features/library/components/__tests__/BookUploadModal.spec.ts create mode 100644 client/src/features/library/composables/__tests__/useBookDestination.spec.ts create mode 100644 client/src/features/library/composables/useBookDestination.ts create mode 100644 client/src/features/library/test/fixtures.ts 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 @@