fix(client): persist visible Book Dock destination on done

Save the library and folder shown in the metadata sheet before closing it. Avoid duplicate target updates after explicit selection and keep the sheet open when persistence fails.

Fixes #1573
This commit is contained in:
neon
2026-09-29 00:14:15 +00:00
parent 0085ffd11e
commit a572badecb
2 changed files with 143 additions and 10 deletions
@@ -49,6 +49,9 @@ const sheetWidthClass = computed(() => (metaView.value === 'editor' ? 'sm:w-md l
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 ?? [])
@@ -101,6 +104,8 @@ watch(
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
persistedTargetLibraryId.value = props.file.targetLibraryId
persistedTargetFolderId.value = props.file.targetFolderId
},
{ immediate: true },
)
@@ -120,6 +125,7 @@ watch(
)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let pendingTargetSave: Promise<BookDockFile | null> | null = null
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
@@ -182,15 +188,43 @@ async function onLibraryChange(event: Event) {
targetLibraryId.value = id
const lib = libraries.value.find((l) => l.id === targetLibraryId.value)
targetFolderId.value = lib?.folders?.[0]?.id ?? null
const updated = await setTarget(props.file.id, targetLibraryId.value, targetFolderId.value)
if (updated) emit('updated', updated)
await persistTarget()
}
async function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
targetFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
const updated = await setTarget(props.file.id, targetLibraryId.value, targetFolderId.value)
if (updated) emit('updated', updated)
await persistTarget()
}
async function persistTarget(): Promise<BookDockFile | null> {
const libraryId = targetLibraryId.value
const folderId = targetFolderId.value
const previousSave = pendingTargetSave
const request = (previousSave ?? Promise.resolve(null)).then(() => setTarget(props.file.id, libraryId, folderId))
pendingTargetSave = request
const updated = await request
if (updated) {
persistedTargetLibraryId.value = libraryId
persistedTargetFolderId.value = folderId
emit('updated', updated)
}
if (pendingTargetSave === request) pendingTargetSave = null
return updated
}
async function handleDone() {
if (finishing.value) return
finishing.value = true
try {
if (pendingTargetSave) await pendingTargetSave
const targetChanged = targetLibraryId.value !== persistedTargetLibraryId.value || targetFolderId.value !== persistedTargetFolderId.value
if (targetChanged && !(await persistTarget())) return
emit('close')
} finally {
finishing.value = false
}
}
function formatDate(iso: string): string {
@@ -639,7 +673,8 @@ onMounted(() => {
</button>
<button
class="relative h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95"
@click="$emit('close')"
:disabled="finishing"
@click="handleDone"
>
{{ t('bookDock.done') }}
</button>
@@ -1,15 +1,21 @@
import { shallowMount } from '@vue/test-utils'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { defineComponent, reactive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookDockFile, MetadataCandidate } from '@bookorbit/types'
import type { BookDockFile, Library, MetadataCandidate } from '@bookorbit/types'
import BookDockFileSheet from '../BookDockFileSheet.vue'
type LibraryOption = Pick<Library, 'id' | 'name'> & {
folders: Array<Pick<Library['folders'][number], 'id' | 'path'>>
}
const mocks = vi.hoisted(() => ({
fetchLibraries: vi.fn<() => Promise<void>>(),
loadProviders: vi.fn<() => Promise<void>>(),
search: vi.fn<(...args: unknown[]) => Promise<void>>(),
saveMetadata: vi.fn<(...args: unknown[]) => Promise<null>>(),
setTarget: vi.fn<(...args: unknown[]) => Promise<BookDockFile | null>>(),
filteredResults: [] as MetadataCandidate[],
libraries: [] as LibraryOption[],
}))
vi.mock('../../composables/useBookDockDetail', () => ({
@@ -17,14 +23,14 @@ vi.mock('../../composables/useBookDockDetail', () => ({
saved: ref(false),
saveError: ref(null),
saveMetadata: mocks.saveMetadata,
setTarget: vi.fn<(...args: unknown[]) => Promise<null>>().mockResolvedValue(null),
setTarget: mocks.setTarget,
coverUrl: (id: number) => `/api/v1/book-dock/files/${id}/cover`,
}),
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: ref([]),
libraries: ref(mocks.libraries),
fetchLibraries: mocks.fetchLibraries,
}),
}))
@@ -108,13 +114,15 @@ async function openSearchAndReadDefaults(file: BookDockFile): Promise<Record<str
return wrapper.getComponent(MetadataSearchPanelStub).props('searchDefaults') as Record<string, string | undefined>
}
describe('BookDockFileSheet metadata search defaults', () => {
describe('BookDockFileSheet', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.fetchLibraries.mockResolvedValue(undefined)
mocks.loadProviders.mockResolvedValue(undefined)
mocks.saveMetadata.mockResolvedValue(null)
mocks.setTarget.mockResolvedValue(null)
mocks.filteredResults.length = 0
mocks.libraries.length = 0
})
it.each([
@@ -283,4 +291,94 @@ describe('BookDockFileSheet metadata search defaults', () => {
expect(wrapper.emitted('discard')).toEqual([[dockFile]])
expect(wrapper.emitted('close')).toBeUndefined()
})
it('persists the visible default destination when finishing metadata edits', async () => {
mocks.libraries.push({
id: 7,
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')
const doneButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Done')
await doneButton!.trigger('click')
await flushPromises()
expect(mocks.setTarget).toHaveBeenCalledWith(1, 7, 70)
expect(wrapper.emitted('updated')).toEqual([[updatedFile]])
expect(wrapper.emitted('close')).toEqual([[]])
})
it('does not save an unchanged destination again when finishing', async () => {
mocks.libraries.push({
id: 7,
name: 'Novels',
folders: [{ id: 70, path: '/library/novels' }],
})
const wrapper = mountSheet(makeFile({ targetLibraryId: 7, targetFolderId: 70 }))
const doneButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Done')
await doneButton!.trigger('click')
await flushPromises()
expect(mocks.setTarget).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[]])
})
it('does not repeat a destination save that completed after changing the library', async () => {
mocks.libraries.push(
{
id: 7,
name: 'Novels',
folders: [{ id: 70, path: '/library/novels' }],
},
{
id: 8,
name: 'Comics',
folders: [{ id: 80, path: '/library/comics' }],
},
)
const updatedFile = makeFile({ targetLibraryId: 8, targetFolderId: 80 })
mocks.setTarget.mockResolvedValue(updatedFile)
const wrapper = mountSheet(makeFile({ targetLibraryId: 7, targetFolderId: 70 }))
const [librarySelect] = wrapper.findAll<HTMLSelectElement>('select')
await librarySelect!.setValue('8')
await flushPromises()
expect(mocks.setTarget).toHaveBeenCalledTimes(1)
expect(mocks.setTarget).toHaveBeenLastCalledWith(1, 8, 80)
mocks.setTarget.mockClear()
const doneButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Done')
await doneButton!.trigger('click')
await flushPromises()
expect(mocks.setTarget).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[]])
})
it('keeps the sheet open when the visible destination cannot be saved', async () => {
mocks.libraries.push({
id: 7,
name: 'Novels',
folders: [{ id: 70, path: '/library/novels' }],
})
mocks.setTarget.mockResolvedValue(null)
const wrapper = mountSheet(makeFile())
const doneButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Done')
await doneButton!.trigger('click')
await flushPromises()
expect(mocks.setTarget).toHaveBeenCalledWith(1, 7, 70)
expect(wrapper.emitted('updated')).toBeUndefined()
expect(wrapper.emitted('close')).toBeUndefined()
})
})