mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(authors): collapse series on author pages (#1271)
Reduce large author listings to one card per series while preserving book counts, scoped visibility, and row-based pagination. Compute both totals in the collapsed query so deep pages remain consistent without a second author-wide scan. Closes #301 Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
AuthorsPage,
|
||||
AuthorBooksPage,
|
||||
AuthorDetail,
|
||||
AuthorMetadataCandidate,
|
||||
AuthorMetadataProviderKey,
|
||||
BooksPage,
|
||||
JumpBucketsResponse,
|
||||
MergeAuthorsResult,
|
||||
} from '@bookorbit/types'
|
||||
@@ -34,6 +34,7 @@ type ListAuthorBooksParams = {
|
||||
sort: AuthorBookSort
|
||||
order: SortDirection
|
||||
libraryId?: number | null
|
||||
collapseSeries?: boolean
|
||||
}
|
||||
|
||||
export type UpdateAuthorPayload = {
|
||||
@@ -141,13 +142,14 @@ export async function fetchAuthor(id: number): Promise<AuthorDetail | null> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchAuthorBooks(authorId: number, params: ListAuthorBooksParams): Promise<BooksPage> {
|
||||
export async function fetchAuthorBooks(authorId: number, params: ListAuthorBooksParams): Promise<AuthorBooksPage> {
|
||||
const query = toQuery({
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
sort: params.sort,
|
||||
order: params.order,
|
||||
libraryId: params.libraryId ?? undefined,
|
||||
collapseSeries: params.collapseSeries ? 'true' : undefined,
|
||||
})
|
||||
|
||||
const res = await api(`/api/v1/authors/${authorId}/books${query}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { BookCard, BooksPage } from '@bookorbit/types'
|
||||
import type { AuthorBooksPage, BookCard } from '@bookorbit/types'
|
||||
|
||||
vi.mock('../api/author', () => ({
|
||||
fetchAuthorBooks: vi.fn<typeof import('../api/author').fetchAuthorBooks>(),
|
||||
@@ -15,8 +15,8 @@ function makeBook(id: number): BookCard {
|
||||
return { id, title: `Book ${id}` } as BookCard
|
||||
}
|
||||
|
||||
function makePage(items: BookCard[], total = items.length): BooksPage {
|
||||
return { items, total, page: 0, size: 50 }
|
||||
function makePage(items: BookCard[], total = items.length, bookTotal = total): AuthorBooksPage {
|
||||
return { items, total, bookTotal, page: 0, size: 50 }
|
||||
}
|
||||
|
||||
describe('useAuthorBooks', () => {
|
||||
@@ -56,19 +56,19 @@ describe('useAuthorBooks', () => {
|
||||
expect(mockFetchAuthorBooks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips fetch when already loading', async () => {
|
||||
it('does not fire a second request while one is in flight', async () => {
|
||||
const authorId = ref(1)
|
||||
let resolveFirst!: (v: BooksPage) => void
|
||||
let resolveFirst!: (v: AuthorBooksPage) => void
|
||||
mockFetchAuthorBooks.mockImplementation(
|
||||
() =>
|
||||
new Promise<BooksPage>((resolve) => {
|
||||
new Promise<AuthorBooksPage>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { load } = useAuthorBooks(authorId)
|
||||
const firstLoad = load(true)
|
||||
const secondLoad = load(true)
|
||||
const secondLoad = load(false)
|
||||
|
||||
resolveFirst(makePage([makeBook(1)]))
|
||||
await Promise.all([firstLoad, secondLoad])
|
||||
@@ -76,6 +76,64 @@ describe('useAuthorBooks', () => {
|
||||
expect(mockFetchAuthorBooks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reloads with the new flag when collapseSeries changes during an unresolved load', async () => {
|
||||
const authorId = ref(1)
|
||||
let resolveFirst!: (v: AuthorBooksPage) => void
|
||||
mockFetchAuthorBooks
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AuthorBooksPage>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeBook(50)], 1, 4))
|
||||
|
||||
const { load, items, total, bookTotal, collapseSeries } = useAuthorBooks(authorId)
|
||||
const flatLoad = load(true)
|
||||
|
||||
// The toggle flips while the flat request is still out, so the reset it asks for is the one
|
||||
// that has to win.
|
||||
collapseSeries.value = true
|
||||
void load(true)
|
||||
|
||||
resolveFirst(makePage([makeBook(1), makeBook(2), makeBook(3), makeBook(4)], 4, 4))
|
||||
await flatLoad
|
||||
await vi.waitFor(() => expect(mockFetchAuthorBooks).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ collapseSeries: false }))
|
||||
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ collapseSeries: true, page: 0 }))
|
||||
expect(items.value).toHaveLength(1)
|
||||
expect(items.value[0]!.id).toBe(50)
|
||||
expect(total.value).toBe(1)
|
||||
expect(bookTotal.value).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps the error of a stale request off the page when a reset is queued behind it', async () => {
|
||||
const authorId = ref(1)
|
||||
let rejectFirst!: (e: unknown) => void
|
||||
mockFetchAuthorBooks
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AuthorBooksPage>((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeBook(7)], 1))
|
||||
|
||||
const { load, error, items, collapseSeries } = useAuthorBooks(authorId)
|
||||
const flatLoad = load(true)
|
||||
|
||||
collapseSeries.value = true
|
||||
void load(true)
|
||||
|
||||
rejectFirst(new Error('stale request failed'))
|
||||
await flatLoad
|
||||
await vi.waitFor(() => expect(items.value).toHaveLength(1))
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
expect(items.value[0]!.id).toBe(7)
|
||||
})
|
||||
|
||||
it('skips non-reset load when no more items to load', async () => {
|
||||
const authorId = ref(1)
|
||||
mockFetchAuthorBooks.mockResolvedValue(makePage([makeBook(1), makeBook(2)], 2))
|
||||
@@ -103,6 +161,46 @@ describe('useAuthorBooks', () => {
|
||||
expect(hasMore.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sends collapseSeries only once it is turned on', async () => {
|
||||
const authorId = ref(1)
|
||||
mockFetchAuthorBooks.mockResolvedValue(makePage([makeBook(1)]))
|
||||
|
||||
const { load, collapseSeries } = useAuthorBooks(authorId)
|
||||
await load(true)
|
||||
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ collapseSeries: false }))
|
||||
|
||||
collapseSeries.value = true
|
||||
await load(true)
|
||||
|
||||
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ collapseSeries: true }))
|
||||
})
|
||||
|
||||
it('paginates on rows while keeping the book count separately', async () => {
|
||||
const authorId = ref(1)
|
||||
// 30 collapsed rows standing for 200 books: the row count is what says there is more to load,
|
||||
// and the book count must not be the thing driving it.
|
||||
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1), makeBook(2)], 30, 200))
|
||||
|
||||
const { load, items, total, bookTotal, hasMore } = useAuthorBooks(authorId)
|
||||
await load(true)
|
||||
|
||||
expect(items.value).toHaveLength(2)
|
||||
expect(total.value).toBe(30)
|
||||
expect(bookTotal.value).toBe(200)
|
||||
expect(hasMore.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stops loading once every row has arrived, however many books they stand for', async () => {
|
||||
const authorId = ref(1)
|
||||
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1), makeBook(2)], 2, 200))
|
||||
|
||||
const { load, hasMore, bookTotal } = useAuthorBooks(authorId)
|
||||
await load(true)
|
||||
|
||||
expect(hasMore.value).toBe(false)
|
||||
expect(bookTotal.value).toBe(200)
|
||||
})
|
||||
|
||||
it('resets items and page on reset=true', async () => {
|
||||
const authorId = ref(1)
|
||||
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1)], 3)).mockResolvedValueOnce(makePage([makeBook(99)], 1))
|
||||
@@ -199,14 +297,15 @@ describe('useAuthorBooks', () => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('passes sort, order, and libraryId to fetchAuthorBooks', async () => {
|
||||
it('passes sort, order, libraryId, and collapseSeries to fetchAuthorBooks', async () => {
|
||||
const authorId = ref(5)
|
||||
mockFetchAuthorBooks.mockResolvedValue(makePage([]))
|
||||
|
||||
const { load, sort, order, libraryId } = useAuthorBooks(authorId)
|
||||
const { load, sort, order, libraryId, collapseSeries } = useAuthorBooks(authorId)
|
||||
sort.value = 'title'
|
||||
order.value = 'asc'
|
||||
libraryId.value = 7
|
||||
collapseSeries.value = true
|
||||
|
||||
await load(true)
|
||||
|
||||
@@ -216,6 +315,7 @@ describe('useAuthorBooks', () => {
|
||||
sort: 'title',
|
||||
order: 'asc',
|
||||
libraryId: 7,
|
||||
collapseSeries: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,10 +370,10 @@ describe('useAuthorBooks', () => {
|
||||
await load(true)
|
||||
expect(items.value).toHaveLength(1)
|
||||
|
||||
let resolve!: (v: BooksPage) => void
|
||||
let resolve!: (v: AuthorBooksPage) => void
|
||||
mockFetchAuthorBooks.mockImplementation(
|
||||
() =>
|
||||
new Promise<BooksPage>((r) => {
|
||||
new Promise<AuthorBooksPage>((r) => {
|
||||
resolve = r
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -8,20 +8,33 @@ const PAGE_SIZE = 50
|
||||
|
||||
export function useAuthorBooks(authorId: Ref<number>) {
|
||||
const items = ref<BookCard[]>([])
|
||||
/** Rows, so it keeps matching `items` once a series arrives as a single collapsed card. */
|
||||
const total = ref(0)
|
||||
/** Books behind those rows. Equal to `total` while the list is flat. */
|
||||
const bookTotal = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const sort = ref<AuthorBookSort>('addedAt')
|
||||
const order = ref<SortDirection>('desc')
|
||||
const libraryId = ref<number | null>(null)
|
||||
const collapseSeries = ref(false)
|
||||
|
||||
const page = ref(0)
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
/**
|
||||
* Set when a reset arrives while a request is in flight: the query changed under it, so the
|
||||
* pending response is stale and the list has to be rebuilt once that request settles.
|
||||
*/
|
||||
let resetQueued = false
|
||||
|
||||
async function load(reset = false): Promise<void> {
|
||||
if (!authorId.value || Number.isNaN(authorId.value)) return
|
||||
if (loading.value) return
|
||||
if (loading.value) {
|
||||
if (reset) resetQueued = true
|
||||
return
|
||||
}
|
||||
if (!reset && !hasMore.value) return
|
||||
|
||||
loading.value = true
|
||||
@@ -39,27 +52,38 @@ export function useAuthorBooks(authorId: Ref<number>) {
|
||||
sort: sort.value,
|
||||
order: order.value,
|
||||
libraryId: libraryId.value,
|
||||
collapseSeries: collapseSeries.value,
|
||||
})
|
||||
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
total.value = data.total
|
||||
page.value += 1
|
||||
// Dropped on purpose when a reset is queued: these rows answer the previous query.
|
||||
if (!resetQueued) {
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
total.value = data.total
|
||||
bookTotal.value = data.bookTotal
|
||||
page.value += 1
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load books'
|
||||
if (!resetQueued) error.value = err instanceof Error ? err.message : 'Failed to load books'
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (resetQueued) {
|
||||
resetQueued = false
|
||||
void load(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
bookTotal,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
sort,
|
||||
order,
|
||||
libraryId,
|
||||
collapseSeries,
|
||||
load,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,16 @@ const mocks = vi.hoisted(() => ({
|
||||
authorNotFound: null as unknown as Ref<boolean>,
|
||||
books: null as unknown as Ref<BookCard[]>,
|
||||
total: null as unknown as Ref<number>,
|
||||
bookTotal: null as unknown as Ref<number>,
|
||||
loadingBooks: null as unknown as Ref<boolean>,
|
||||
booksError: null as unknown as Ref<string | null>,
|
||||
hasMore: null as unknown as Ref<boolean>,
|
||||
sort: null as unknown as Ref<'addedAt' | 'title' | 'publishedYear'>,
|
||||
order: null as unknown as Ref<'asc' | 'desc'>,
|
||||
libraryId: null as unknown as Ref<number | null>,
|
||||
collapseSeries: null as unknown as Ref<boolean>,
|
||||
collapsePreference: false,
|
||||
setCollapsePreference: vi.fn<(ctx: unknown, value: boolean | null) => Promise<void>>(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -102,16 +106,26 @@ vi.mock('../composables/useAuthorBooks', () => ({
|
||||
useAuthorBooks: () => ({
|
||||
items: mocks.books,
|
||||
total: mocks.total,
|
||||
bookTotal: mocks.bookTotal,
|
||||
loading: mocks.loadingBooks,
|
||||
error: mocks.booksError,
|
||||
hasMore: mocks.hasMore,
|
||||
sort: mocks.sort,
|
||||
order: mocks.order,
|
||||
libraryId: mocks.libraryId,
|
||||
collapseSeries: mocks.collapseSeries,
|
||||
load: mocks.loadBooks,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/book/composables/useSeriesCollapsePreference', () => ({
|
||||
useSeriesCollapsePreference: () => ({
|
||||
getEffectivePreference: () => mocks.collapsePreference,
|
||||
setPreference: mocks.setCollapsePreference,
|
||||
prefs: ref(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useAuthorMetadataPreview', () => ({
|
||||
useAuthorMetadataPreview: () => ({
|
||||
preview: ref(null),
|
||||
@@ -228,12 +242,16 @@ describe('AuthorDetailView', () => {
|
||||
mocks.authorNotFound = ref(false)
|
||||
mocks.books = ref([makeBook(101), makeBook(102)])
|
||||
mocks.total = ref(2)
|
||||
mocks.bookTotal = ref(2)
|
||||
mocks.loadingBooks = ref(false)
|
||||
mocks.booksError = ref(null)
|
||||
mocks.hasMore = ref(false)
|
||||
mocks.sort = ref('addedAt')
|
||||
mocks.order = ref('desc')
|
||||
mocks.libraryId = ref(null)
|
||||
mocks.collapseSeries = ref(false)
|
||||
mocks.collapsePreference = false
|
||||
mocks.setCollapsePreference.mockResolvedValue()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -275,6 +293,49 @@ describe('AuthorDetailView', () => {
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('series collapse toggle', () => {
|
||||
it('starts from the saved author pages preference', async () => {
|
||||
mocks.collapsePreference = true
|
||||
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(mocks.collapseSeries.value).toBe(true)
|
||||
expect(wrapper.get('[data-testid="author-collapse-series-toggle"]').attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('turns collapsing on and remembers it for every author page', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
await wrapper.get('[data-testid="author-collapse-series-toggle"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.collapseSeries.value).toBe(true)
|
||||
expect(mocks.setCollapsePreference).toHaveBeenCalledWith({ authorPages: true }, true)
|
||||
})
|
||||
|
||||
it('turns collapsing back off', async () => {
|
||||
mocks.collapsePreference = true
|
||||
|
||||
const wrapper = await mountView()
|
||||
await wrapper.get('[data-testid="author-collapse-series-toggle"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.collapseSeries.value).toBe(false)
|
||||
expect(mocks.setCollapsePreference).toHaveBeenCalledWith({ authorPages: true }, false)
|
||||
})
|
||||
|
||||
it('counts books rather than rows in the all-loaded label', async () => {
|
||||
// 2 collapsed rows standing for 200 books: the reader should be told about the books.
|
||||
mocks.total = ref(2)
|
||||
mocks.bookTotal = ref(200)
|
||||
mocks.hasMore = ref(false)
|
||||
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain(i18n.global.t('author.detail.books.allLoaded', { total: '200' }))
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps unsaved edit fields when an author image is uploaded', async () => {
|
||||
vi.mocked(uploadAuthorImage).mockResolvedValue(makeAuthor({ imageUrl: '/api/v1/authors/7/image' }))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowUpDown, ChevronDown, ChevronLeft, ImageMinus, LayoutGrid, List, Upload } from '@lucide/vue'
|
||||
import { ArrowUpDown, ChevronDown, ChevronLeft, ImageMinus, Layers, LayoutGrid, List, Upload } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { AuthorDetail, AuthorSummary, BookCard } from '@bookorbit/types'
|
||||
@@ -12,6 +12,7 @@ import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
||||
import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrollRestoreOnActivate'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
@@ -49,7 +50,30 @@ const authorBooksViewMode = ref<'grid' | 'list'>('grid')
|
||||
|
||||
const authorId = computed(() => Number(route.params.id))
|
||||
const { author, loading: loadingAuthor, error: authorError, notFound: authorNotFound, load: loadAuthor } = useAuthorDetail(authorId)
|
||||
const { items: books, total, loading: loadingBooks, error: booksError, hasMore, sort, order, libraryId, load: loadBooks } = useAuthorBooks(authorId)
|
||||
const {
|
||||
items: books,
|
||||
total,
|
||||
bookTotal,
|
||||
loading: loadingBooks,
|
||||
error: booksError,
|
||||
hasMore,
|
||||
sort,
|
||||
order,
|
||||
libraryId,
|
||||
collapseSeries,
|
||||
load: loadBooks,
|
||||
} = useAuthorBooks(authorId)
|
||||
|
||||
// One flag for every author page, so the state carries from one author to the next rather than
|
||||
// being re-chosen per author; the toggle below writes it back. Tracked rather than read once,
|
||||
// because on a cold load of an author URL the signed-in user - and so the preference - can
|
||||
// arrive after this component is set up.
|
||||
const { getEffectivePreference, setPreference } = useSeriesCollapsePreference()
|
||||
watch(
|
||||
() => getEffectivePreference({ authorPages: true }),
|
||||
(value) => (collapseSeries.value = value),
|
||||
{ immediate: true },
|
||||
)
|
||||
const authorName = computed(() => author.value?.name ?? '')
|
||||
const pageTitle = computed(() => {
|
||||
if (author.value?.name) return t('author.detail.pageTitleNamed', { name: author.value.name })
|
||||
@@ -129,7 +153,10 @@ const {
|
||||
books.value = books.value.filter((b) => b.id !== id)
|
||||
if (books.value.length === previousLength) return
|
||||
|
||||
// Only a plain book card carries a delete action - a collapsed series row opens the series
|
||||
// instead - so the card that just went is worth exactly one row and one book.
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
bookTotal.value = Math.max(0, bookTotal.value - 1)
|
||||
if (author.value) {
|
||||
author.value = {
|
||||
...author.value,
|
||||
@@ -209,6 +236,14 @@ function handleBookUpdate(updated: BookCard) {
|
||||
if (idx !== -1) books.value = books.value.map((b, i) => (i === idx ? updated : b))
|
||||
}
|
||||
|
||||
const collapseToggleLabel = computed(() => (collapseSeries.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
|
||||
|
||||
async function handleToggleCollapse() {
|
||||
const next = !collapseSeries.value
|
||||
collapseSeries.value = next
|
||||
await setPreference({ authorPages: true }, next)
|
||||
}
|
||||
|
||||
function loadIfSentinelVisible() {
|
||||
if (loadingBooks.value || !hasMore.value || !sentinel.value) return
|
||||
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
|
||||
@@ -448,7 +483,7 @@ watch(authorId, () => {
|
||||
void Promise.all([loadAuthor(), loadBooks(true)]).then(() => loadMetadataPreview())
|
||||
})
|
||||
|
||||
watch([sort, order, libraryId], () => {
|
||||
watch([sort, order, libraryId, collapseSeries], () => {
|
||||
void loadBooks(true)
|
||||
})
|
||||
|
||||
@@ -618,7 +653,7 @@ defineOptions({ name: 'AuthorDetailView' })
|
||||
<div class="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">{{ t('author.detail.books.heading') }}</h2>
|
||||
<div class="w-full space-y-2 sm:hidden">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-2">
|
||||
<div class="relative min-w-0">
|
||||
<select
|
||||
:value="sort"
|
||||
@@ -639,6 +674,22 @@ defineOptions({ name: 'AuthorDetailView' })
|
||||
{{ order === 'asc' ? t('author.detail.books.asc') : t('author.detail.books.desc') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="author-collapse-series-toggle-mobile"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
|
||||
:class="
|
||||
collapseSeries
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
:aria-label="collapseToggleLabel"
|
||||
:aria-pressed="collapseSeries"
|
||||
:title="collapseToggleLabel"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="14" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center rounded-md border border-input bg-background">
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors"
|
||||
@@ -703,6 +754,22 @@ defineOptions({ name: 'AuthorDetailView' })
|
||||
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
data-testid="author-collapse-series-toggle"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
|
||||
:class="
|
||||
collapseSeries
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
:aria-label="collapseToggleLabel"
|
||||
:aria-pressed="collapseSeries"
|
||||
:title="collapseToggleLabel"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="14" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center rounded-md border border-input bg-background">
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors"
|
||||
@@ -751,7 +818,7 @@ defineOptions({ name: 'AuthorDetailView' })
|
||||
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center">
|
||||
<span v-if="loadingBooks" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span>
|
||||
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">{{
|
||||
t('author.detail.books.allLoaded', { total: formatNumber(total) })
|
||||
t('author.detail.books.allLoaded', { total: formatNumber(bookTotal) })
|
||||
}}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -238,7 +238,38 @@ describe('useSeriesCollapsePreference', () => {
|
||||
|
||||
await setPreference('global', true)
|
||||
|
||||
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {}, smartScopes: {} })
|
||||
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {}, smartScopes: {}, authorPages: false })
|
||||
})
|
||||
|
||||
it('writes the author pages flag without touching the other scopes', async () => {
|
||||
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: true, libraries: { '2': true }, collections: {} } }))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference, prefs } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference({ authorPages: true }, true)
|
||||
|
||||
expect(prefs.value!.authorPages).toBe(true)
|
||||
expect(prefs.value!.global).toBe(true)
|
||||
expect(prefs.value!.libraries).toEqual({ '2': true })
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
'/api/v1/users/me/series-collapse-preferences',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ authorPages: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('turns the author pages flag back off', async () => {
|
||||
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, authorPages: true } }))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference, prefs } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference({ authorPages: true }, false)
|
||||
|
||||
expect(prefs.value!.authorPages).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves existing library overrides when setting global', async () => {
|
||||
|
||||
@@ -4,6 +4,12 @@ import { resolveCollapsePreference } from '@bookorbit/types'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/**
|
||||
* A place whose collapse state is remembered. `authorPages` is a single flag rather than an id,
|
||||
* so every author page shares it - see the type in `@bookorbit/types` for why.
|
||||
*/
|
||||
export type CollapseScope = { libraryId?: number; collectionId?: number; smartScopeId?: number; authorPages?: boolean }
|
||||
|
||||
export function useSeriesCollapsePreference() {
|
||||
const { user } = useAuth()
|
||||
|
||||
@@ -11,28 +17,24 @@ export function useSeriesCollapsePreference() {
|
||||
return user.value?.settings.seriesCollapsePreferences
|
||||
})
|
||||
|
||||
function getEffectivePreference(ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number }): boolean {
|
||||
function getEffectivePreference(ctx: CollapseScope): boolean {
|
||||
return resolveCollapsePreference(prefs.value, ctx)
|
||||
}
|
||||
|
||||
let pendingUpdate: Promise<void> = Promise.resolve()
|
||||
|
||||
async function setPreference(
|
||||
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
|
||||
value: boolean | null,
|
||||
): Promise<void> {
|
||||
async function setPreference(ctx: CollapseScope | 'global', value: boolean | null): Promise<void> {
|
||||
const op = () => doSetPreference(ctx, value)
|
||||
pendingUpdate = pendingUpdate.then(op, op)
|
||||
return pendingUpdate
|
||||
}
|
||||
|
||||
async function doSetPreference(
|
||||
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
|
||||
value: boolean | null,
|
||||
): Promise<void> {
|
||||
async function doSetPreference(ctx: CollapseScope | 'global', value: boolean | null): Promise<void> {
|
||||
let body: Record<string, unknown>
|
||||
if (ctx === 'global') {
|
||||
body = { global: value === null ? false : value }
|
||||
} else if (ctx.authorPages) {
|
||||
body = { authorPages: value === null ? false : value }
|
||||
} else if (ctx.smartScopeId !== undefined) {
|
||||
body = { smartScopes: { [String(ctx.smartScopeId)]: value } }
|
||||
} else if (ctx.collectionId !== undefined) {
|
||||
@@ -64,6 +66,7 @@ export function useSeriesCollapsePreference() {
|
||||
libraries: nextLibraries as Record<string, boolean>,
|
||||
collections: nextCollections as Record<string, boolean>,
|
||||
smartScopes: nextSmartScopes as Record<string, boolean>,
|
||||
authorPages: body.authorPages !== undefined ? (body.authorPages as boolean) : (current.authorPages ?? false),
|
||||
}
|
||||
|
||||
const previous = user.value?.settings ? { ...user.value.settings } : undefined
|
||||
|
||||
Reference in New Issue
Block a user