From 989c16313f16eacd1ff6390b3a96e81d5f38dc60 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 24 May 2026 16:13:25 +0000 Subject: [PATCH] fix(series): open add-to-collection sheet from book actions Handle add-to-collection actions in the series detail view so the collection sheet opens with the selected book id. Add regression tests for grid and table actions plus sheet close state reset. Closes #102 --- .../series/views/SeriesDetailView.spec.ts | 308 ++++++++++++++++++ .../series/views/SeriesDetailView.vue | 27 +- 2 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 client/src/features/series/views/SeriesDetailView.spec.ts diff --git a/client/src/features/series/views/SeriesDetailView.spec.ts b/client/src/features/series/views/SeriesDetailView.spec.ts new file mode 100644 index 000000000..b838c0224 --- /dev/null +++ b/client/src/features/series/views/SeriesDetailView.spec.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick, reactive, ref, type PropType } from 'vue' +import { mount } from '@vue/test-utils' +import type { BookCard, SeriesDetail } from '@bookorbit/types' +import SeriesDetailView from './SeriesDetailView.vue' + +type ViewMode = 'grid' | 'list' | 'table' + +class MockIntersectionObserver { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + takeRecords = vi.fn(() => []) +} + +const mocks = vi.hoisted(() => ({ + route: null as unknown as { params: { seriesName: string }; query: Record }, + routerPush: vi.fn<(to: unknown) => Promise>(), + fetchLibraries: vi.fn<() => Promise>(), + setBookContext: vi.fn<(ids: number[], total: number) => void>(), + loadBooks: vi.fn<(input?: unknown) => Promise>(), + seriesInfo: null as unknown as { value: SeriesDetail | null }, + items: null as unknown as { value: BookCard[] }, + total: null as unknown as { value: number }, + loading: null as unknown as { value: boolean }, + error: null as unknown as { value: string | null }, + notFound: null as unknown as { value: boolean }, + hasMore: null as unknown as { value: boolean }, + sort: null as unknown as { value: 'seriesIndex' | 'title' | 'addedAt' }, + order: null as unknown as { value: 'asc' | 'desc' }, + libraryId: null as unknown as { value: number | null }, + effectiveViewMode: null as unknown as { value: ViewMode }, + fetchSeriesBooks: vi.fn(), + api: vi.fn(), +})) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRoute: () => mocks.route, + useRouter: () => ({ push: mocks.routerPush }), + } +}) + +vi.mock('@/features/auth/composables/usePermissions', () => ({ + usePermissions: () => ({ hasPermission: () => true }), +})) + +vi.mock('@/features/book/composables/useBookNavigation', () => ({ + useBookNavigation: () => ({ setBookContext: mocks.setBookContext }), +})) + +vi.mock('@/features/book/composables/useCoverVersions', () => ({ + useCoverVersions: () => ({ coverUrl: (bookId: number) => `/covers/${bookId}` }), +})) + +vi.mock('@/composables/useDisplaySettings', () => ({ + useDisplaySettings: () => ({ + portraitCoverSize: ref(160), + gridGap: ref(16), + }), +})) + +vi.mock('@/composables/useEffectiveViewMode', () => ({ + useEffectiveViewMode: () => ({ + effectiveViewMode: mocks.effectiveViewMode, + }), +})) + +vi.mock('@/features/library/composables/useLibraries', () => ({ + useLibraries: () => ({ + libraries: ref([]), + fetchLibraries: mocks.fetchLibraries, + }), +})) + +vi.mock('@/composables/usePageTitle', () => ({ + usePageTitle: () => undefined, +})) + +vi.mock('@/features/book/composables/useSafeHtml', () => ({ + useSafeHtml: () => ref(''), +})) + +vi.mock('../api/series', () => ({ + fetchSeriesBooks: (...args: unknown[]) => mocks.fetchSeriesBooks(...args), +})) + +vi.mock('@/lib/api', () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})) + +vi.mock('../composables/useSeriesDetail', () => ({ + useSeriesDetail: () => ({ + seriesInfo: mocks.seriesInfo, + items: mocks.items, + total: mocks.total, + loading: mocks.loading, + error: mocks.error, + notFound: mocks.notFound, + hasMore: mocks.hasMore, + sort: mocks.sort, + order: mocks.order, + libraryId: mocks.libraryId, + load: mocks.loadBooks, + }), +})) + +function makeBook(overrides: Partial = {}): BookCard { + return { + id: 1, + status: 'present', + title: 'Series Book', + authors: ['Author'], + seriesName: 'The Series', + seriesIndex: 1, + files: [], + publishedYear: null, + language: null, + genres: [], + tags: [], + rating: null, + readingProgress: null, + readStatus: null, + addedAt: '2026-01-01T00:00:00.000Z', + updatedAt: null, + metadataScore: null, + hasCover: false, + hasMetadataLocks: false, + lockedFields: [], + subtitle: null, + publisher: null, + pageCount: null, + isbn13: null, + narrators: [], + ...overrides, + } +} + +function makeSeriesInfo(): SeriesDetail { + return { + name: 'The Series', + bookCount: 1, + readCount: 0, + authors: ['Author'], + possibleGaps: [], + } +} + +const VirtualBookGridStub = defineComponent({ + name: 'VirtualBookGrid', + emits: ['action', 'update:book'], + template: ` +
+ + +
+ `, +}) + +const BookListRowStub = defineComponent({ + name: 'BookListRow', + props: { + book: { + type: Object as PropType, + required: true, + }, + }, + emits: ['action'], + template: ``, +}) + +const VirtualBookTableStub = defineComponent({ + name: 'VirtualBookTable', + emits: ['action', 'update:sort', 'update:book'], + template: ``, +}) + +const AddToCollectionSheetStub = defineComponent({ + name: 'AddToCollectionSheet', + props: { + open: { + type: Boolean, + required: true, + }, + bookIds: { + type: Array as PropType, + required: true, + }, + }, + emits: ['update:open'], + template: ` +
+ +
+ `, +}) + +function mountView(mode: ViewMode) { + mocks.effectiveViewMode.value = mode + + return mount(SeriesDetailView, { + global: { + stubs: { + VirtualBookGrid: VirtualBookGridStub, + BookListRow: BookListRowStub, + VirtualBookTable: VirtualBookTableStub, + AddToCollectionSheet: AddToCollectionSheetStub, + EntityNotFound: true, + SeriesCompletionBar: true, + SeriesGapBanner: true, + }, + }, + }) +} + +describe('SeriesDetailView', () => { + beforeEach(() => { + mocks.route = reactive({ + params: { seriesName: 'The%20Series' }, + query: {}, + }) + + mocks.seriesInfo = ref(makeSeriesInfo()) + mocks.items = ref([makeBook({ id: 7 })]) + mocks.total = ref(1) + mocks.loading = ref(false) + mocks.error = ref(null) + mocks.notFound = ref(false) + mocks.hasMore = ref(false) + mocks.sort = ref('seriesIndex') + mocks.order = ref('asc') + mocks.libraryId = ref(null) + mocks.effectiveViewMode = ref('grid') + + mocks.routerPush.mockReset() + mocks.routerPush.mockResolvedValue(undefined) + mocks.fetchLibraries.mockReset() + mocks.fetchLibraries.mockResolvedValue(undefined) + mocks.setBookContext.mockReset() + mocks.loadBooks.mockReset() + mocks.loadBooks.mockResolvedValue(undefined) + mocks.fetchSeriesBooks.mockReset() + mocks.fetchSeriesBooks.mockResolvedValue({ + items: [makeBook({ id: 7, seriesIndex: 1 })], + total: 1, + page: 0, + size: 8, + seriesInfo: makeSeriesInfo(), + }) + mocks.api.mockReset() + mocks.api.mockResolvedValue({ ok: false }) + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('opens AddToCollectionSheet with the clicked book id from grid actions', async () => { + const wrapper = mountView('grid') + await nextTick() + + await wrapper.get('[data-testid="grid-add-action"]').trigger('click') + + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('true') + expect(sheet.attributes('data-book-ids')).toBe('42') + }) + + it('opens AddToCollectionSheet with the clicked book id from table actions', async () => { + const wrapper = mountView('table') + await nextTick() + + await wrapper.get('[data-testid="table-add-action"]').trigger('click') + + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('true') + expect(sheet.attributes('data-book-ids')).toBe('91') + }) + + it('navigates on quick-view and closes the collection sheet state', async () => { + const wrapper = mountView('grid') + await nextTick() + + await wrapper.get('[data-testid="grid-add-action"]').trigger('click') + await wrapper.get('[data-testid="grid-quick-action"]').trigger('click') + + expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 42 } }) + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('false') + expect(sheet.attributes('data-book-ids')).toBe('') + }) + + it('clears selected ids when the collection sheet closes', async () => { + const wrapper = mountView('grid') + await nextTick() + + await wrapper.get('[data-testid="grid-add-action"]').trigger('click') + await wrapper.get('[data-testid="collection-sheet-close"]').trigger('click') + + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('false') + expect(sheet.attributes('data-book-ids')).toBe('') + }) +}) diff --git a/client/src/features/series/views/SeriesDetailView.vue b/client/src/features/series/views/SeriesDetailView.vue index faa825ab5..7ac96a82e 100644 --- a/client/src/features/series/views/SeriesDetailView.vue +++ b/client/src/features/series/views/SeriesDetailView.vue @@ -19,6 +19,7 @@ import { usePageTitle } from '@/composables/usePageTitle' import { useSafeHtml } from '@/features/book/composables/useSafeHtml' import { api } from '@/lib/api' import EntityNotFound from '@/components/EntityNotFound.vue' +import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue' import SeriesCompletionBar from '../components/SeriesCompletionBar.vue' import SeriesGapBanner from '../components/SeriesGapBanner.vue' import { fetchSeriesBooks } from '../api/series' @@ -87,6 +88,8 @@ const canEditMetadata = computed(() => hasPermission('library_edit_metadata')) const safeLeadDescription = useSafeHtml(() => leadBook.value?.description) const visibleSeriesAuthors = computed(() => (seriesInfo.value?.authors ?? []).slice(0, 5)) const hiddenSeriesAuthorsCount = computed(() => Math.max(0, (seriesInfo.value?.authors.length ?? 0) - visibleSeriesAuthors.value.length)) +const addToCollectionOpen = ref(false) +const addToCollectionBookId = ref(null) const leadFallbackStyle = computed(() => { const name = seriesInfo.value?.name ?? seriesName.value return bookCoverStyle(name || 'Series') @@ -113,10 +116,26 @@ const leadMetaItems = computed(() => { return items }) -function handleBookAction(book: BookCard, action: string) { +type BookActionType = 'quick-view' | 'add-to-collection' | 'delete' + +function handleBookAction(book: BookCard, action: BookActionType) { if (action === 'quick-view') { + addToCollectionOpen.value = false + addToCollectionBookId.value = null void router.push({ name: 'book-detail', params: { bookId: book.id } }) + return } + + if (action === 'add-to-collection') { + addToCollectionBookId.value = book.id + addToCollectionOpen.value = true + return + } +} + +function handleAddToCollectionOpenChange(open: boolean) { + addToCollectionOpen.value = open + if (!open) addToCollectionBookId.value = null } function handleTableBookUpdate(updated: BookCard) { @@ -585,4 +604,10 @@ watch( + +