mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(series): make select mode work when series are collapsed
Selecting inside a collapsed library was broken in every layout: a grid series card navigated into the series instead of selecting, and a list or table series row selected only the representative book rather than the series it stands for. Suppress collapse for as long as select mode is active, so the list reloads uncollapsed and every row is an individual, selectable book. The saved collapse preference is untouched and comes back on exit. The toolbar toggle stays focusable while locked and explains itself through its tooltip, since entering select mode visibly re-lays out the view. Closes #963
This commit is contained in:
@@ -188,6 +188,7 @@ function collapsedCoverSrc(bookId: number): string {
|
||||
|
||||
function handleRowClick(event: MouseEvent) {
|
||||
if (props.selectionMode) {
|
||||
if (isCollapsedSeries.value) return
|
||||
emit('select', event)
|
||||
return
|
||||
}
|
||||
@@ -212,19 +213,11 @@ function handleRowClick(event: MouseEvent) {
|
||||
data-testid="collapsed-series-list-row"
|
||||
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors"
|
||||
:class="[
|
||||
selectionMode ? 'cursor-pointer select-none' : canOpenSeries ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default',
|
||||
selectionMode ? 'cursor-default select-none' : canOpenSeries ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default',
|
||||
selected ? 'bg-primary/8 ring-1 ring-primary/30' : '',
|
||||
]"
|
||||
@click="handleRowClick"
|
||||
>
|
||||
<div
|
||||
v-if="selectionMode"
|
||||
class="h-5 w-5 rounded shrink-0 flex items-center justify-center transition-colors"
|
||||
:class="selected ? 'bg-primary' : 'border border-border bg-background'"
|
||||
>
|
||||
<Check v-if="selected" class="text-primary-foreground" :size="12" />
|
||||
</div>
|
||||
|
||||
<div :class="collapsedCoverContainerClass">
|
||||
<template v-if="collapsedCoverIds.length > 0">
|
||||
<BookCoverSurface
|
||||
@@ -306,6 +299,7 @@ function handleRowClick(event: MouseEvent) {
|
||||
<!-- Selection checkbox -->
|
||||
<div
|
||||
v-if="selectionMode"
|
||||
data-testid="book-selection-checkbox"
|
||||
class="h-5 w-5 rounded shrink-0 flex items-center justify-center transition-colors"
|
||||
:class="selected ? 'bg-primary' : 'border border-border bg-background'"
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ const STACK_COVER_STEP_PCT = 8
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
showLabel?: boolean
|
||||
selectionMode?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -115,11 +116,13 @@ function handleClick() {
|
||||
}
|
||||
|
||||
function openSeriesDetails() {
|
||||
if (props.selectionMode) return
|
||||
if (props.book.seriesId == null) return
|
||||
void router.push({ name: 'series-detail', params: { seriesId: props.book.seriesId }, query: { from: route.fullPath } })
|
||||
}
|
||||
|
||||
async function openAuthorDetails() {
|
||||
if (props.selectionMode) return
|
||||
const authorName = authorQuery.value?.trim()
|
||||
if (!authorName) return
|
||||
|
||||
@@ -138,6 +141,7 @@ async function openAuthorDetails() {
|
||||
}
|
||||
|
||||
function handleLabelClick(field: GridCardLabelField) {
|
||||
if (props.selectionMode) return
|
||||
if (field === 'author') {
|
||||
void openAuthorDetails()
|
||||
return
|
||||
@@ -193,7 +197,12 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col @container cursor-pointer" @click="handleClick">
|
||||
<div
|
||||
class="flex flex-col @container"
|
||||
:class="selectionMode ? 'cursor-default' : 'cursor-pointer'"
|
||||
:inert="selectionMode || undefined"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div class="group">
|
||||
<BookCoverSurface
|
||||
|
||||
@@ -38,8 +38,9 @@ vi.mock('./BookCoverCard.vue', () => ({
|
||||
vi.mock('./CollapsedSeriesCard.vue', () => ({
|
||||
default: {
|
||||
name: 'CollapsedSeriesCard',
|
||||
props: ['book', 'showLabel'],
|
||||
template: '<div data-testid="collapsed-series-card">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
|
||||
props: ['book', 'showLabel', 'selectionMode'],
|
||||
template:
|
||||
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -176,6 +177,28 @@ describe('VirtualBookGrid', () => {
|
||||
expect(updatedBook?.title).toBe('Updated')
|
||||
})
|
||||
|
||||
it.each([false, true])('forwards selection mode to collapsed series cards when virtualized=%s', (virtualized) => {
|
||||
const seriesBook = makeBook(4, {
|
||||
collapsedSeries: {
|
||||
bookCount: 3,
|
||||
readCount: 0,
|
||||
coverBookIds: [4],
|
||||
seriesLatestAddedAt: null,
|
||||
},
|
||||
})
|
||||
const wrapper = mount(VirtualBookGrid, {
|
||||
props: {
|
||||
books: [seriesBook],
|
||||
coverSize: 120,
|
||||
gridGap: 12,
|
||||
selectionMode: true,
|
||||
virtualized,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="collapsed-series-card"]').attributes('data-selection-mode')).toBe('true')
|
||||
})
|
||||
|
||||
it('scales square cover slots independently of media format in static mode', () => {
|
||||
const books = [
|
||||
makeBook(1, {
|
||||
|
||||
@@ -277,7 +277,7 @@ defineExpose({ scrollToIndex })
|
||||
:class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }"
|
||||
:style="staticItemStyle(book)"
|
||||
>
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" :selection-mode="selectionMode" />
|
||||
<BookCoverCard
|
||||
v-else
|
||||
:book="book"
|
||||
@@ -295,7 +295,7 @@ defineExpose({ scrollToIndex })
|
||||
|
||||
<div v-else-if="!virtualized" class="grid w-full max-w-full items-end" :style="staticGridStyle" data-testid="book-grid-static">
|
||||
<div v-for="book in staticBooks" :key="book.id" class="min-w-0" :class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }">
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" :selection-mode="selectionMode" />
|
||||
<BookCoverCard
|
||||
v-else
|
||||
:book="book"
|
||||
@@ -328,7 +328,12 @@ defineExpose({ scrollToIndex })
|
||||
<template #default="{ item }">
|
||||
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(item.id) }">
|
||||
<BookCoverSkeleton v-if="isBookPlaceholder(item)" />
|
||||
<CollapsedSeriesCard v-else-if="asBook(item).collapsedSeries" :book="asBook(item)" :show-label="showLabel" />
|
||||
<CollapsedSeriesCard
|
||||
v-else-if="asBook(item).collapsedSeries"
|
||||
:book="asBook(item)"
|
||||
:show-label="showLabel"
|
||||
:selection-mode="selectionMode"
|
||||
/>
|
||||
<BookCoverCard
|
||||
v-else
|
||||
:book="asBook(item)"
|
||||
|
||||
@@ -389,11 +389,12 @@ function handleSelectAllToggle(event: Event) {
|
||||
emit('select-all', checked)
|
||||
}
|
||||
|
||||
function handleCheckboxClick(bookId: number, event: MouseEvent) {
|
||||
function handleCheckboxClick(book: BookCard, event: MouseEvent) {
|
||||
if (book.collapsedSeries) return
|
||||
if (!props.selectionMode) {
|
||||
emit('enter-selection')
|
||||
}
|
||||
emit('select', bookId, event)
|
||||
emit('select', book.id, event)
|
||||
}
|
||||
|
||||
// Sort strip
|
||||
@@ -459,7 +460,7 @@ const { focusedRowIndex, focusedColIndex, isFocusedCell, handleTableKeydown } =
|
||||
virtualizer,
|
||||
isCellReadOnly,
|
||||
onActivate: handleActivate,
|
||||
onSelect: (id, event) => emit('select', id, event),
|
||||
onSelect: handleCheckboxClick,
|
||||
onCopyRow: () => {},
|
||||
columnMapGetter: () => combinedColumnMap.value,
|
||||
})
|
||||
@@ -580,6 +581,7 @@ function handleNavigate(book: BookCard, colId: string, direction: 'next' | 'prev
|
||||
|
||||
function handleRowClick(book: BookCard, e: MouseEvent) {
|
||||
if (props.selectionMode) {
|
||||
if (book.collapsedSeries) return
|
||||
emit('select', book.id, e)
|
||||
return
|
||||
}
|
||||
@@ -812,10 +814,11 @@ defineExpose({
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<input
|
||||
v-if="!rowBook(vItem.index).collapsedSeries"
|
||||
type="checkbox"
|
||||
class="accent-primary h-3.5 w-3.5 cursor-pointer"
|
||||
:checked="isSelected?.(rowBook(vItem.index).id) ?? false"
|
||||
@click.stop="handleCheckboxClick(rowBook(vItem.index).id, $event)"
|
||||
@click.stop="handleCheckboxClick(rowBook(vItem.index), $event)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -290,7 +290,7 @@ describe('BookListRow collapsed series', () => {
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('selects the collapsed row instead of navigating in selection mode', async () => {
|
||||
it('does not select a representative book or navigate in selection mode', async () => {
|
||||
const wrapper = mount(BookListRow, {
|
||||
props: { book: collapsedSeriesBook, selectionMode: true },
|
||||
global: globalStubs,
|
||||
@@ -298,8 +298,9 @@ describe('BookListRow collapsed series', () => {
|
||||
|
||||
await wrapper.get('[data-testid="collapsed-series-list-row"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
expect(routerPushMock).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="book-selection-checkbox"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a fallback cover when collapsed cover ids are unavailable', () => {
|
||||
|
||||
@@ -216,6 +216,24 @@ describe('CollapsedSeriesCard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not navigate while selection mode is replacing collapsed rows', async () => {
|
||||
gridCardPrimaryLabel.value = 'series-title'
|
||||
gridCardSecondaryLabel.value = 'author'
|
||||
cardInfoMode.value = 'below-cover'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook(), selectionMode: true, showLabel: true },
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await wrapper.find('[data-testid="series-hover-action"]').trigger('click')
|
||||
await wrapper.find('[data-testid="grid-card-label-primary"]').trigger('click')
|
||||
await wrapper.find('[data-testid="grid-card-label-secondary"]').trigger('click')
|
||||
|
||||
expect(wrapper.attributes('inert')).toBeDefined()
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
expect(mockFetchAuthors).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses series id even when series name has special characters', async () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ seriesId: 43, seriesName: 'The Wheel & Time' }) },
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import VirtualBookTable from '../VirtualBookTable.vue'
|
||||
|
||||
const ROW_SIZE = 44
|
||||
|
||||
vi.mock('@tanstack/vue-virtual', () => ({
|
||||
useVirtualizer: (options: Ref<{ count: number }>) =>
|
||||
computed(() => ({
|
||||
getVirtualItems: () => Array.from({ length: options.value.count }, (_, index) => ({ index, key: index, start: index * ROW_SIZE })),
|
||||
getTotalSize: () => options.value.count * ROW_SIZE,
|
||||
scrollToIndex: () => {},
|
||||
scrollOffset: 0,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('vue-router')>()),
|
||||
useRouter: () => ({ push: vi.fn<(to: unknown) => void>() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/book/composables/useActiveCustomFields', () => ({
|
||||
useActiveCustomFields: () => ({
|
||||
fields: ref([]),
|
||||
loading: ref(false),
|
||||
initialized: ref(true),
|
||||
refresh: vi.fn<() => Promise<void>>(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeBook(id: number, overrides: Partial<BookCard> = {}): BookCard {
|
||||
return {
|
||||
id,
|
||||
status: 'present',
|
||||
coverAspectRatio: '2/3',
|
||||
title: `Book ${id}`,
|
||||
authors: [],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [],
|
||||
publishedDate: null,
|
||||
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: [],
|
||||
customMetadata: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const collapsedSeriesBook = makeBook(1, {
|
||||
seriesId: 7,
|
||||
seriesName: 'Discworld',
|
||||
collapsedSeries: { bookCount: 3, readCount: 0, coverBookIds: [1], seriesLatestAddedAt: null },
|
||||
})
|
||||
const plainBook = makeBook(2)
|
||||
|
||||
function mountTable() {
|
||||
return mount(VirtualBookTable, {
|
||||
props: {
|
||||
books: [collapsedSeriesBook, plainBook],
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
viewType: 'library',
|
||||
selectionMode: true,
|
||||
isSelected: () => false,
|
||||
total: 2,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BookTableHeader: true,
|
||||
BookTableCellDispatcher: true,
|
||||
BookTableCollapsedSeriesCell: true,
|
||||
BookTableContextMenu: true,
|
||||
BookTableHeaderContextMenu: true,
|
||||
BookCoverDialog: true,
|
||||
KeyboardShortcutOverlay: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function bodyRows(wrapper: ReturnType<typeof mountTable>) {
|
||||
return wrapper.findAll('tbody tr')
|
||||
}
|
||||
|
||||
describe('VirtualBookTable collapsed series rows', () => {
|
||||
it('omits the row checkbox for a collapsed series while offering it for a book', () => {
|
||||
const wrapper = mountTable()
|
||||
const [seriesRow, bookRow] = bodyRows(wrapper)
|
||||
|
||||
expect(seriesRow?.find('input[type="checkbox"]').exists()).toBe(false)
|
||||
expect(bookRow?.find('input[type="checkbox"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a click on a collapsed series row', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
await bodyRows(wrapper)[0]?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('selects the book behind a normal row', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
await bodyRows(wrapper)[1]?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')?.[0]?.[0]).toBe(plainBook.id)
|
||||
})
|
||||
|
||||
it('skips a collapsed series row when selecting with the keyboard', async () => {
|
||||
const wrapper = mountTable()
|
||||
const grid = wrapper.get('[tabindex="0"]')
|
||||
|
||||
await grid.trigger('keydown', { key: 'ArrowDown' })
|
||||
await grid.trigger('keydown', { key: ' ' })
|
||||
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
|
||||
await grid.trigger('keydown', { key: 'ArrowDown' })
|
||||
await grid.trigger('keydown', { key: ' ' })
|
||||
|
||||
expect(wrapper.emitted('select')?.[0]?.[0]).toBe(plainBook.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useBookSelection } from '../useBookSelection'
|
||||
|
||||
describe('useBookSelection', () => {
|
||||
it('uses a provided selection-mode ref as its source of truth', () => {
|
||||
const selectionMode = ref(false)
|
||||
const selection = useBookSelection(selectionMode)
|
||||
|
||||
expect(selection.selectionMode).toBe(selectionMode)
|
||||
|
||||
selection.enterSelectionMode()
|
||||
expect(selectionMode.value).toBe(true)
|
||||
|
||||
selection.toggleBook(12)
|
||||
selection.exitSelectionMode()
|
||||
|
||||
expect(selectionMode.value).toBe(false)
|
||||
expect(selection.selectedIds.value).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('creates an independent selection-mode ref when none is provided', () => {
|
||||
const first = useBookSelection()
|
||||
const second = useBookSelection()
|
||||
|
||||
first.enterSelectionMode()
|
||||
|
||||
expect(first.selectionMode.value).toBe(true)
|
||||
expect(second.selectionMode.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
confirmDelete: vi.fn<() => void>(),
|
||||
routerPush: vi.fn<(to: unknown) => void>(),
|
||||
setBookContext: vi.fn<(ids: number[], total: number) => void>(),
|
||||
useBookViewSelectionCalls: [] as Array<[unknown, { value: boolean } | undefined]>,
|
||||
useBookBulkActions: vi.fn<(...args: unknown[]) => object>(() => ({
|
||||
handleBulkRefreshMetadata: vi.fn<() => void>(),
|
||||
handleBulkReExtractCover: vi.fn<() => void>(),
|
||||
@@ -41,15 +42,18 @@ vi.mock('../useTableViewControls', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../useBookViewSelection', () => ({
|
||||
useBookViewSelection: () => ({
|
||||
selectedIds: mockSelectedIds,
|
||||
selectionMode: mockSelectionMode,
|
||||
enterSelectionMode: mocks.enterSelectionMode,
|
||||
exitSelectionMode: mocks.exitSelectionMode,
|
||||
toggleBook: mocks.toggleBook,
|
||||
handleSelect: vi.fn<() => void>(),
|
||||
toggleSelectionMode: vi.fn<() => void>(),
|
||||
}),
|
||||
useBookViewSelection: (books: unknown, selectionMode?: { value: boolean }) => {
|
||||
mocks.useBookViewSelectionCalls.push([books, selectionMode])
|
||||
return {
|
||||
selectedIds: mockSelectedIds,
|
||||
selectionMode: selectionMode ?? mockSelectionMode,
|
||||
enterSelectionMode: mocks.enterSelectionMode,
|
||||
exitSelectionMode: mocks.exitSelectionMode,
|
||||
toggleBook: mocks.toggleBook,
|
||||
handleSelect: vi.fn<() => void>(),
|
||||
toggleSelectionMode: vi.fn<() => void>(),
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../useDeleteBook', () => ({
|
||||
@@ -116,6 +120,7 @@ describe('useBookTableShell', () => {
|
||||
mocks.cancelDelete.mockClear()
|
||||
mocks.confirmDelete.mockClear()
|
||||
mocks.useBookBulkActions.mockClear()
|
||||
mocks.useBookViewSelectionCalls.length = 0
|
||||
mocks.routerPush.mockClear()
|
||||
mocks.setBookContext.mockClear()
|
||||
})
|
||||
@@ -149,6 +154,16 @@ describe('useBookTableShell', () => {
|
||||
expect(thirdArg).toBe(books)
|
||||
})
|
||||
|
||||
it('passes an external selection-mode ref to the view selection state', () => {
|
||||
const books = ref([makeBook()])
|
||||
const selectionMode = ref(false)
|
||||
|
||||
const shell = useBookTableShell({ books, selectionMode })
|
||||
|
||||
expect(mocks.useBookViewSelectionCalls).toEqual([[books, selectionMode]])
|
||||
expect(shell.selectionMode).toBe(selectionMode)
|
||||
})
|
||||
|
||||
describe('handleBookAction', () => {
|
||||
it('sets quickViewBookId and opens quick view for quick-view action', () => {
|
||||
const books = ref([makeBook({ id: 7 })])
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock('../useBookEvents', () => ({
|
||||
|
||||
import { BOOK_WINDOW_BLOCK_SIZE } from '../useBookWindow'
|
||||
import { useBookViewWindow } from '../useBookViewWindow'
|
||||
import { useEffectiveSeriesCollapse } from '../useEffectiveSeriesCollapse'
|
||||
|
||||
function makeBook(id: number): BookCard {
|
||||
return {
|
||||
@@ -87,12 +88,24 @@ async function flush() {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function setup(options: { scope?: number | null; viewMode?: string; collapse?: boolean; q?: string; railEnabled?: boolean; enabled?: boolean } = {}) {
|
||||
function setup(
|
||||
options: {
|
||||
scope?: number | null
|
||||
viewMode?: string
|
||||
collapse?: boolean
|
||||
selectionMode?: boolean
|
||||
q?: string
|
||||
railEnabled?: boolean
|
||||
enabled?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const scopeId = ref<number | null>(options.scope === undefined ? 1 : options.scope)
|
||||
const viewMode = ref(options.viewMode ?? 'table')
|
||||
const railEnabled = ref(options.railEnabled ?? true)
|
||||
const enabled = ref(options.enabled ?? true)
|
||||
const collapseEnabled = ref(options.collapse ?? false)
|
||||
const collapsePreference = ref(options.collapse ?? false)
|
||||
const selectionMode = ref(options.selectionMode ?? false)
|
||||
const collapseEnabled = useEffectiveSeriesCollapse(collapsePreference, selectionMode)
|
||||
const q = ref(options.q ?? '')
|
||||
const win = useBookViewWindow({
|
||||
scopeId,
|
||||
@@ -104,7 +117,7 @@ function setup(options: { scope?: number | null; viewMode?: string; collapse?: b
|
||||
q,
|
||||
enabled,
|
||||
})
|
||||
return { win, scopeId, viewMode, railEnabled, enabled, collapseEnabled, q }
|
||||
return { win, scopeId, viewMode, railEnabled, enabled, collapsePreference, selectionMode, collapseEnabled, q }
|
||||
}
|
||||
|
||||
function listRequests(): number[] {
|
||||
@@ -113,6 +126,12 @@ function listRequests(): number[] {
|
||||
.map(([, init]) => (JSON.parse(String(init?.body)) as { pagination: { page: number } }).pagination.page)
|
||||
}
|
||||
|
||||
function listRequestBodies(): Array<Record<string, unknown>> {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([url]) => !url.includes('jump-buckets'))
|
||||
.map(([, init]) => JSON.parse(String(init?.body)) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function jumpRequestCount(): number {
|
||||
return fetchMock.mock.calls.filter(([url]) => url.includes('jump-buckets')).length
|
||||
}
|
||||
@@ -240,6 +259,44 @@ describe('useBookViewWindow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads uncollapsed books for selection mode and restores collapse afterward', async () => {
|
||||
mockApi(120)
|
||||
const { win, collapsePreference, selectionMode } = setup({ collapse: true })
|
||||
await flush()
|
||||
|
||||
expect(listRequestBodies()).toHaveLength(1)
|
||||
expect(listRequestBodies()[0]).toMatchObject({ collapseSeries: true, pagination: { page: 0 } })
|
||||
|
||||
fetchMock.mockClear()
|
||||
selectionMode.value = true
|
||||
await flush()
|
||||
|
||||
expect(win.query.value).not.toHaveProperty('collapseSeries')
|
||||
expect(listRequestBodies()).toHaveLength(1)
|
||||
expect(listRequestBodies()[0]).not.toHaveProperty('collapseSeries')
|
||||
expect(collapsePreference.value).toBe(true)
|
||||
|
||||
fetchMock.mockClear()
|
||||
selectionMode.value = false
|
||||
await flush()
|
||||
|
||||
expect(listRequestBodies()).toHaveLength(1)
|
||||
expect(listRequestBodies()[0]).toMatchObject({ collapseSeries: true, pagination: { page: 0 } })
|
||||
expect(collapsePreference.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does not reload when selection mode cannot change the effective collapse state', async () => {
|
||||
mockApi(120)
|
||||
const { selectionMode } = setup({ collapse: false })
|
||||
await flush()
|
||||
fetchMock.mockClear()
|
||||
|
||||
selectionMode.value = true
|
||||
await flush()
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reverses the letter template for a descending sort', async () => {
|
||||
mockApi(120)
|
||||
const { win } = setup()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useEffectiveSeriesCollapse } from '../useEffectiveSeriesCollapse'
|
||||
|
||||
describe('useEffectiveSeriesCollapse', () => {
|
||||
it('suppresses collapse during selection without changing the saved preference', () => {
|
||||
const preference = ref(true)
|
||||
const selectionMode = ref(false)
|
||||
const effective = useEffectiveSeriesCollapse(preference, selectionMode)
|
||||
|
||||
expect(effective.value).toBe(true)
|
||||
|
||||
selectionMode.value = true
|
||||
|
||||
expect(effective.value).toBe(false)
|
||||
expect(preference.value).toBe(true)
|
||||
|
||||
selectionMode.value = false
|
||||
|
||||
expect(effective.value).toBe(true)
|
||||
expect(preference.value).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps collapse disabled when the saved preference is disabled', () => {
|
||||
const preference = ref(false)
|
||||
const selectionMode = ref(false)
|
||||
const effective = useEffectiveSeriesCollapse(preference, selectionMode)
|
||||
|
||||
selectionMode.value = true
|
||||
selectionMode.value = false
|
||||
|
||||
expect(effective.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the latest saved preference after selection mode exits', () => {
|
||||
const preference = ref(true)
|
||||
const selectionMode = ref(true)
|
||||
const effective = useEffectiveSeriesCollapse(preference, selectionMode)
|
||||
|
||||
preference.value = false
|
||||
expect(effective.value).toBe(false)
|
||||
|
||||
selectionMode.value = false
|
||||
expect(effective.value).toBe(false)
|
||||
|
||||
preference.value = true
|
||||
expect(effective.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,7 @@ describe('useTableKeyboard', () => {
|
||||
let displayColumns: Ref<ColumnDef[]>
|
||||
let activeCellKey: Ref<string | null>
|
||||
let onActivate: (book: BookCard, colId: string) => void
|
||||
let onSelect: (id: number, event: MouseEvent) => void
|
||||
let onSelect: (book: BookCard, event: MouseEvent) => void
|
||||
let scrollToIndexSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
function createKeyboard() {
|
||||
@@ -58,7 +58,7 @@ describe('useTableKeyboard', () => {
|
||||
displayColumns = ref(makeColumns())
|
||||
activeCellKey = ref(null)
|
||||
onActivate = vi.fn<(book: BookCard, colId: string) => void>()
|
||||
onSelect = vi.fn<(id: number, event: MouseEvent) => void>()
|
||||
onSelect = vi.fn<(book: BookCard, event: MouseEvent) => void>()
|
||||
scrollToIndexSpy = vi.fn<() => void>()
|
||||
})
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('useTableKeyboard', () => {
|
||||
})
|
||||
keyboard.handleTableKeydown(keyEvent('ArrowDown'))
|
||||
keyboard.handleTableKeydown(keyEvent(' '))
|
||||
expect(onSelect).toHaveBeenCalledWith(1, expect.anything())
|
||||
expect(onSelect).toHaveBeenCalledWith(books[0], expect.anything())
|
||||
})
|
||||
|
||||
it('does nothing when activeCell is set', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
|
||||
export function useBookSelection() {
|
||||
const selectionMode = ref(false)
|
||||
export function useBookSelection(selectionMode: Ref<boolean> = ref(false)) {
|
||||
const selectedIds = ref<Set<number>>(new Set())
|
||||
const selectedCount = computed(() => selectedIds.value.size)
|
||||
const anchorId = ref<number | null>(null)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useBookBulkActions, type QuerySelectionState } from './useBookBulkActio
|
||||
interface BookTableShellOptions {
|
||||
viewType?: 'library' | 'collection' | 'smartScope'
|
||||
books: Ref<BookCard[]>
|
||||
selectionMode?: Ref<boolean>
|
||||
total?: Ref<number>
|
||||
loading?: Ref<boolean>
|
||||
exitSelectionMode?: () => void
|
||||
@@ -21,11 +22,11 @@ interface BookTableShellOptions {
|
||||
|
||||
type BookActionType = 'quick-view' | 'add-to-collection' | 'move-to-library' | 'delete'
|
||||
|
||||
export function useBookTableShell({ books, querySelection, onMoveToLibrary }: BookTableShellOptions) {
|
||||
export function useBookTableShell({ books, selectionMode, querySelection, onMoveToLibrary }: BookTableShellOptions) {
|
||||
const router = useRouter()
|
||||
const { setBookContext } = useBookNavigation()
|
||||
const tableControls = useTableViewControls()
|
||||
const selection = useBookViewSelection(books)
|
||||
const selection = useBookViewSelection(books, selectionMode)
|
||||
|
||||
const {
|
||||
pendingId: deleteBookId,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { computed, type Ref } from 'vue'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { useBookSelection } from './useBookSelection'
|
||||
|
||||
export function useBookViewSelection(books: Ref<BookCard[]>) {
|
||||
const selection = useBookSelection()
|
||||
export function useBookViewSelection(books: Ref<BookCard[]>, selectionMode?: Ref<boolean>) {
|
||||
const selection = useBookSelection(selectionMode)
|
||||
|
||||
const bookIds = computed(() => books.value.map((book) => book.id))
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
export function useEffectiveSeriesCollapse(preference: Ref<boolean>, selectionMode: Ref<boolean>) {
|
||||
return computed(() => preference.value && !selectionMode.value)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function useTableKeyboard(opts: {
|
||||
virtualizer: Ref<{ scrollToIndex: (index: number, opts?: Record<string, unknown>) => void }>
|
||||
isCellReadOnly: (book: BookCard, col: { id: string; isEditable: boolean }) => boolean
|
||||
onActivate: (book: BookCard, colId: string) => void
|
||||
onSelect: (id: number, event: MouseEvent) => void
|
||||
onSelect: (book: BookCard, event: MouseEvent) => void
|
||||
onCopyRow?: (book: BookCard) => void
|
||||
columnMapGetter?: () => Map<string, ColumnDef>
|
||||
}) {
|
||||
@@ -158,7 +158,7 @@ export function useTableKeyboard(opts: {
|
||||
event.preventDefault()
|
||||
if (focusedRowIndex.value >= books.length) return
|
||||
const book = books[focusedRowIndex.value]
|
||||
if (book) opts.onSelect(book.id, event as unknown as MouseEvent)
|
||||
if (book) opts.onSelect(book, event as unknown as MouseEvent)
|
||||
} else if (isCopyShortcut) {
|
||||
event.preventDefault()
|
||||
const target = resolveCopyTarget(event, books, columns)
|
||||
|
||||
@@ -6871,6 +6871,7 @@
|
||||
"clearAllFilters": "Clear all filters",
|
||||
"expandSeries": "Expand series",
|
||||
"collapseSeries": "Collapse series",
|
||||
"collapseLockedWhileSelecting": "Series stay expanded while selecting",
|
||||
"expanded": "Expanded",
|
||||
"exportMetadata": "Export metadata",
|
||||
"showControlsAria": "Show library controls",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { toast } from 'vue-sonner'
|
||||
import { useCollections } from '@/features/collection/composables/useCollections'
|
||||
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
import { useEffectiveSeriesCollapse } from '@/features/book/composables/useEffectiveSeriesCollapse'
|
||||
import { useViewSearch } from '@/features/book/composables/useViewSearch'
|
||||
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
|
||||
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
|
||||
@@ -67,6 +68,8 @@ usePageTitle(pageTitle)
|
||||
|
||||
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
|
||||
const collapseEnabledRef = ref(getEffectivePreference({ collectionId: collectionId.value }))
|
||||
const selectionMode = ref(false)
|
||||
const effectiveCollapseEnabled = useEffectiveSeriesCollapse(collapseEnabledRef, selectionMode)
|
||||
|
||||
watch(collectionId, (id) => {
|
||||
collapseEnabledRef.value = getEffectivePreference({ collectionId: id })
|
||||
@@ -113,7 +116,7 @@ const {
|
||||
viewMode: effectiveViewMode,
|
||||
railEnabled: showJumpRails,
|
||||
railViewport: mainRef,
|
||||
collapseEnabled: collapseEnabledRef,
|
||||
collapseEnabled: effectiveCollapseEnabled,
|
||||
q: debouncedQuery,
|
||||
})
|
||||
const { sortModel: tableSortModel } = useViewSort(tableSort, 'collection', collectionId)
|
||||
@@ -170,7 +173,7 @@ function handleTableDensityChange(value: 'compact' | 'comfortable' | 'roomy') {
|
||||
}
|
||||
|
||||
function handleSelectAllLoaded(checked: boolean) {
|
||||
const ids = books.value.map((book) => book.id)
|
||||
const ids = books.value.filter((book) => !book.collapsedSeries).map((book) => book.id)
|
||||
if (checked) selectAll(ids)
|
||||
else deselectAll(ids)
|
||||
}
|
||||
@@ -187,7 +190,6 @@ const {
|
||||
handleDuplicateTablePreset,
|
||||
handleTogglePresetFavorite,
|
||||
handleImportPresetBackup,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
enterSelectionMode,
|
||||
@@ -220,6 +222,7 @@ const {
|
||||
handleEditIndividually,
|
||||
} = useBookTableShell({
|
||||
books,
|
||||
selectionMode,
|
||||
onMoveToLibrary: (bookId) => openMoveForBook(bookId),
|
||||
})
|
||||
|
||||
@@ -323,7 +326,14 @@ async function handleBulkEditConfirm(fields: BulkEditFields) {
|
||||
}
|
||||
}
|
||||
|
||||
const collapseToggleLabel = computed(() => (effectiveCollapseEnabled.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
|
||||
const collapseToggleHint = computed(() => (selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : collapseToggleLabel.value))
|
||||
const collapseMenuLabel = computed(() =>
|
||||
selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : t('views.bookView.collapseSeries'),
|
||||
)
|
||||
|
||||
async function handleToggleCollapse() {
|
||||
if (selectionMode.value) return
|
||||
const next = !collapseEnabledRef.value
|
||||
collapseEnabledRef.value = next
|
||||
await setPreference({ collectionId: collectionId.value }, next)
|
||||
@@ -482,19 +492,20 @@ defineOptions({ name: 'CollectionView' })
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
||||
:class="
|
||||
collapseEnabledRef
|
||||
effectiveCollapseEnabled
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
:aria-label="collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')"
|
||||
:aria-label="collapseToggleLabel"
|
||||
:aria-disabled="selectionMode || undefined"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}</TooltipContent>
|
||||
<TooltipContent>{{ collapseToggleHint }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@@ -534,10 +545,10 @@ defineOptions({ name: 'CollectionView' })
|
||||
</button>
|
||||
</template>
|
||||
<template #mobile-menu>
|
||||
<DropdownMenuItem @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
|
||||
<DropdownMenuItem :disabled="selectionMode" @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="effectiveCollapseEnabled" :size="14" class="mr-2" />
|
||||
<Square v-else :size="14" class="mr-2" />
|
||||
{{ t('views.bookView.collapseSeries') }}
|
||||
{{ collapseMenuLabel }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<template v-if="effectiveViewMode === 'table'" #columns>
|
||||
|
||||
@@ -44,6 +44,7 @@ import type { BookCard } from '@bookorbit/types'
|
||||
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
|
||||
import { useViewSearch } from '@/features/book/composables/useViewSearch'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
import { useEffectiveSeriesCollapse } from '@/features/book/composables/useEffectiveSeriesCollapse'
|
||||
|
||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
|
||||
@@ -95,6 +96,8 @@ provide(COVER_ASPECT_RATIO_KEY, currentCoverAspectRatio)
|
||||
|
||||
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
|
||||
const collapseEnabledRef = ref(libraryId.value !== null ? getEffectivePreference({ libraryId: libraryId.value }) : false)
|
||||
const selectionMode = ref(false)
|
||||
const effectiveCollapseEnabled = useEffectiveSeriesCollapse(collapseEnabledRef, selectionMode)
|
||||
|
||||
watch(libraryId, (id) => {
|
||||
collapseEnabledRef.value = id !== null ? getEffectivePreference({ libraryId: id }) : false
|
||||
@@ -143,7 +146,7 @@ const {
|
||||
viewMode: effectiveViewMode,
|
||||
railEnabled: showJumpRails,
|
||||
railViewport: mainRef,
|
||||
collapseEnabled: collapseEnabledRef,
|
||||
collapseEnabled: effectiveCollapseEnabled,
|
||||
q: debouncedQuery,
|
||||
})
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
@@ -239,7 +242,7 @@ function handleTableDensityChange(value: 'compact' | 'comfortable' | 'roomy') {
|
||||
}
|
||||
|
||||
function handleSelectAllLoaded(checked: boolean) {
|
||||
const ids = books.value.map((book) => book.id)
|
||||
const ids = books.value.filter((book) => !book.collapsedSeries).map((book) => book.id)
|
||||
if (checked) {
|
||||
selectAll(ids)
|
||||
if (total.value > books.value.length) {
|
||||
@@ -388,7 +391,6 @@ const {
|
||||
handleDuplicateTablePreset,
|
||||
handleTogglePresetFavorite,
|
||||
handleImportPresetBackup,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
enterSelectionMode,
|
||||
@@ -422,6 +424,7 @@ const {
|
||||
handleEditIndividually,
|
||||
} = useBookTableShell({
|
||||
books,
|
||||
selectionMode,
|
||||
querySelection,
|
||||
onMoveToLibrary: (bookId) => openMoveForBook(bookId),
|
||||
})
|
||||
@@ -569,7 +572,14 @@ function hasAddOrRemoveFields(fields: BulkEditFields): boolean {
|
||||
return [fields.authors, fields.genres, fields.tags, fields.narrators].some((f) => f && f.mode !== 'replace')
|
||||
}
|
||||
|
||||
const collapseToggleLabel = computed(() => (effectiveCollapseEnabled.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
|
||||
const collapseToggleHint = computed(() => (selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : collapseToggleLabel.value))
|
||||
const collapseMenuLabel = computed(() =>
|
||||
selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : t('views.bookView.collapseSeries'),
|
||||
)
|
||||
|
||||
async function handleToggleCollapse() {
|
||||
if (selectionMode.value) return
|
||||
if (libraryId.value === null) return
|
||||
const next = !collapseEnabledRef.value
|
||||
collapseEnabledRef.value = next
|
||||
@@ -639,19 +649,20 @@ defineOptions({ name: 'HomeView' })
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
||||
:class="
|
||||
collapseEnabledRef
|
||||
effectiveCollapseEnabled
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
:aria-label="collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')"
|
||||
:aria-label="collapseToggleLabel"
|
||||
:aria-disabled="selectionMode || undefined"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}</TooltipContent>
|
||||
<TooltipContent>{{ collapseToggleHint }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -713,10 +724,10 @@ defineOptions({ name: 'HomeView' })
|
||||
</button>
|
||||
</template>
|
||||
<template #mobile-menu>
|
||||
<DropdownMenuItem @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
|
||||
<DropdownMenuItem :disabled="selectionMode" @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="effectiveCollapseEnabled" :size="14" class="mr-2" />
|
||||
<Square v-else :size="14" class="mr-2" />
|
||||
{{ t('views.bookView.collapseSeries') }}
|
||||
{{ collapseMenuLabel }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<template v-if="effectiveViewMode === 'table'" #columns>
|
||||
|
||||
@@ -42,6 +42,7 @@ import JumpRail from '@/features/book/components/JumpRail.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
import { useEffectiveSeriesCollapse } from '@/features/book/composables/useEffectiveSeriesCollapse'
|
||||
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
|
||||
import { useSmartScopeKoboSync } from '@/features/smart-scope/composables/useSmartScopeKoboSync'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
@@ -78,6 +79,8 @@ const { allSavedViews, saveView, renameView, deleteView, duplicateView, toggleFa
|
||||
|
||||
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
|
||||
const collapseEnabledRef = ref(getEffectivePreference({ smartScopeId: smartScopeId.value }))
|
||||
const selectionMode = ref(false)
|
||||
const effectiveCollapseEnabled = useEffectiveSeriesCollapse(collapseEnabledRef, selectionMode)
|
||||
|
||||
watch(smartScopeId, (id) => {
|
||||
collapseEnabledRef.value = getEffectivePreference({ smartScopeId: id })
|
||||
@@ -124,7 +127,7 @@ const {
|
||||
viewMode: effectiveViewMode,
|
||||
railEnabled: showJumpRails,
|
||||
railViewport: mainRef,
|
||||
collapseEnabled: collapseEnabledRef,
|
||||
collapseEnabled: effectiveCollapseEnabled,
|
||||
q: debouncedQuery,
|
||||
enabled: scopeQueryReady,
|
||||
})
|
||||
@@ -199,7 +202,7 @@ function handleTableDensityChange(value: 'compact' | 'comfortable' | 'roomy') {
|
||||
}
|
||||
|
||||
function handleSelectAllLoaded(checked: boolean) {
|
||||
const ids = books.value.map((book) => book.id)
|
||||
const ids = books.value.filter((book) => !book.collapsedSeries).map((book) => book.id)
|
||||
if (checked) selectAll(ids)
|
||||
else deselectAll(ids)
|
||||
}
|
||||
@@ -216,7 +219,6 @@ const {
|
||||
handleDuplicateTablePreset,
|
||||
handleTogglePresetFavorite,
|
||||
handleImportPresetBackup,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
enterSelectionMode,
|
||||
@@ -249,6 +251,7 @@ const {
|
||||
handleEditIndividually,
|
||||
} = useBookTableShell({
|
||||
books,
|
||||
selectionMode,
|
||||
onMoveToLibrary: (bookId) => openMoveForBook(bookId),
|
||||
})
|
||||
|
||||
@@ -390,7 +393,14 @@ function openMetadataExport() {
|
||||
collapseMobileControlsIfNeeded()
|
||||
}
|
||||
|
||||
const collapseToggleLabel = computed(() => (effectiveCollapseEnabled.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
|
||||
const collapseToggleHint = computed(() => (selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : collapseToggleLabel.value))
|
||||
const collapseMenuLabel = computed(() =>
|
||||
selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : t('views.bookView.collapseSeries'),
|
||||
)
|
||||
|
||||
async function handleToggleCollapse() {
|
||||
if (selectionMode.value) return
|
||||
const next = !collapseEnabledRef.value
|
||||
collapseEnabledRef.value = next
|
||||
await setPreference({ smartScopeId: smartScopeId.value }, next)
|
||||
@@ -566,20 +576,21 @@ defineOptions({ name: 'SmartScopeView' })
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
||||
:class="
|
||||
collapseEnabledRef
|
||||
effectiveCollapseEnabled
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
:aria-label="collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')"
|
||||
:aria-label="collapseToggleLabel"
|
||||
:aria-disabled="selectionMode || undefined"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}
|
||||
{{ collapseToggleHint }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -658,10 +669,10 @@ defineOptions({ name: 'SmartScopeView' })
|
||||
</button>
|
||||
</template>
|
||||
<template #mobile-menu>
|
||||
<DropdownMenuItem @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
|
||||
<DropdownMenuItem :disabled="selectionMode" @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="effectiveCollapseEnabled" :size="14" class="mr-2" />
|
||||
<Square v-else :size="14" class="mr-2" />
|
||||
{{ t('views.bookView.collapseSeries') }}
|
||||
{{ collapseMenuLabel }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<template v-if="effectiveViewMode === 'table'" #columns>
|
||||
|
||||
Reference in New Issue
Block a user