mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
refactor(books): update book window to use slots
Modify book navigation composables to work with sparse subsets by pairing loaded book IDs with their absolute position in the full listing. This ensures books reached via the jump rail maintain their true position even when windowed. Closes #319
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { useBookNavigation } from '../useBookNavigation'
|
||||
import type { BookPlaceholder, BookSlot } from '../useBookWindow'
|
||||
|
||||
function makeBook(id: number): BookCard {
|
||||
return {
|
||||
id,
|
||||
status: 'active',
|
||||
title: `Book ${id}`,
|
||||
authors: [],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
addedAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: null,
|
||||
metadataScore: null,
|
||||
hasCover: false,
|
||||
hasMetadataLocks: false,
|
||||
lockedFields: [],
|
||||
subtitle: null,
|
||||
publisher: null,
|
||||
pageCount: null,
|
||||
isbn13: null,
|
||||
narrators: [],
|
||||
}
|
||||
}
|
||||
|
||||
function placeholder(id: number): BookPlaceholder {
|
||||
return { id, placeholder: true }
|
||||
}
|
||||
|
||||
describe('useBookNavigation', () => {
|
||||
afterEach(() => {
|
||||
const nav = useBookNavigation()
|
||||
nav.setBookContext([], 0)
|
||||
nav.registerLoadMore(null)
|
||||
})
|
||||
|
||||
describe('setBookContext (fully-loaded list)', () => {
|
||||
it('uses the array position as the absolute index', () => {
|
||||
const nav = useBookNavigation()
|
||||
nav.setBookContext([10, 20, 30], 3)
|
||||
|
||||
expect(nav.currentIndex(10)).toBe(0)
|
||||
expect(nav.currentIndex(30)).toBe(2)
|
||||
expect(nav.total.value).toBe(3)
|
||||
expect(nav.hasContext.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns -1 for an id outside the context', () => {
|
||||
const nav = useBookNavigation()
|
||||
nav.setBookContext([10, 20], 2)
|
||||
|
||||
expect(nav.currentIndex(999)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setBookSlotContext (windowed listing reached via the jump rail)', () => {
|
||||
it('reports the true slot index for a book in a far block, not its loaded-subset position', () => {
|
||||
const nav = useBookNavigation()
|
||||
// Mirrors the bug: the first block (rows 0-2) loads on open, then the
|
||||
// jump rail loads a far block (rows 5000-5001). Everything in between is
|
||||
// still a placeholder, so the loaded subset is sparse.
|
||||
const slots: BookSlot[] = [
|
||||
makeBook(1),
|
||||
makeBook(2),
|
||||
makeBook(3),
|
||||
...Array.from({ length: 4997 }, (_, i) => placeholder(-(i + 1))),
|
||||
makeBook(5001),
|
||||
makeBook(5002),
|
||||
]
|
||||
|
||||
nav.setBookSlotContext(slots, 5083)
|
||||
|
||||
// The old compacted-list logic reported index 3 (the 4th loaded card);
|
||||
// the fix reports the real slot index.
|
||||
expect(nav.currentIndex(5001)).toBe(5000)
|
||||
expect(nav.currentIndex(5002)).toBe(5001)
|
||||
// Head books keep their low positions.
|
||||
expect(nav.currentIndex(1)).toBe(0)
|
||||
expect(nav.total.value).toBe(5083)
|
||||
})
|
||||
|
||||
it('returns -1 for a book that is not loaded into any slot', () => {
|
||||
const nav = useBookNavigation()
|
||||
nav.setBookSlotContext([makeBook(1), placeholder(-1)], 2)
|
||||
|
||||
expect(nav.currentIndex(999)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prev/next adjacency', () => {
|
||||
it('navigates between absolute-adjacent loaded books', async () => {
|
||||
const nav = useBookNavigation()
|
||||
nav.setBookSlotContext([makeBook(1), makeBook(2), makeBook(3)], 3)
|
||||
|
||||
expect(nav.getPrevId(2)).toBe(1)
|
||||
expect(await nav.getNextId(2)).toBe(3)
|
||||
})
|
||||
|
||||
it('does not jump across a placeholder gap left by the jump rail', async () => {
|
||||
const nav = useBookNavigation()
|
||||
// Loaded: rows 0,1 and row 3; row 2 is a placeholder.
|
||||
const slots: BookSlot[] = [makeBook(1), makeBook(2), placeholder(-1), makeBook(50)]
|
||||
nav.setBookSlotContext(slots, 4)
|
||||
|
||||
// Book 2 (index 1) has no loaded neighbour at index 2, so next is null
|
||||
// rather than the far book 50 (index 3).
|
||||
expect(await nav.getNextId(2)).toBe(null)
|
||||
// Book 50 (index 3) has a placeholder at index 2, so prev is null.
|
||||
expect(nav.getPrevId(50)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextId load-more at the end of a contiguous prefix', () => {
|
||||
it('loads the next chunk and returns the newly adjacent book', async () => {
|
||||
const nav = useBookNavigation()
|
||||
nav.setBookContext([1, 2], 4)
|
||||
nav.registerLoadMore(async () => {
|
||||
nav.setBookContext([1, 2, 3, 4], 4)
|
||||
})
|
||||
|
||||
expect(await nav.getNextId(2)).toBe(3)
|
||||
})
|
||||
|
||||
it('returns null when load-more adds nothing', async () => {
|
||||
const nav = useBookNavigation()
|
||||
nav.setBookContext([1, 2], 4)
|
||||
nav.registerLoadMore(async () => {})
|
||||
|
||||
expect(await nav.getNextId(2)).toBe(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { useBookNavigation } from '../useBookNavigation'
|
||||
import { useBookViewContext } from '../useBookViewContext'
|
||||
import type { BookPlaceholder, BookSlot } from '../useBookWindow'
|
||||
|
||||
function placeholder(id: number): BookPlaceholder {
|
||||
return { id, placeholder: true }
|
||||
}
|
||||
|
||||
function makeBook(id: number): BookCard {
|
||||
return {
|
||||
@@ -66,4 +71,36 @@ describe('useBookViewContext', () => {
|
||||
expect(loadMore).toHaveBeenCalledTimes(1)
|
||||
expect(nextId).toBe(3)
|
||||
})
|
||||
|
||||
it('reports the absolute slot index after a jump rail loads a far block', async () => {
|
||||
const nav = useBookNavigation()
|
||||
// First block (rows 0-1) plus a far block (rows 5000-5001) loaded; the
|
||||
// rows in between are still placeholders, as after a jump to "Z".
|
||||
const slots = ref<BookSlot[]>([
|
||||
makeBook(1),
|
||||
makeBook(2),
|
||||
...Array.from({ length: 4998 }, (_, i) => placeholder(-(i + 1))),
|
||||
makeBook(5001),
|
||||
makeBook(5002),
|
||||
])
|
||||
const total = ref(5002)
|
||||
const loadMore = vi.fn(async () => {})
|
||||
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
useBookViewContext(slots, total, loadMore)
|
||||
return {}
|
||||
},
|
||||
template: '<div />',
|
||||
})
|
||||
|
||||
const wrapper = mount(Harness)
|
||||
|
||||
expect(nav.currentIndex(5001)).toBe(5000)
|
||||
expect(nav.currentIndex(5002)).toBe(5001)
|
||||
expect(nav.currentIndex(1)).toBe(0)
|
||||
expect(nav.total.value).toBe(5002)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { isBookPlaceholder, type BookSlot } from './useBookWindow'
|
||||
|
||||
const bookIds = ref<number[]>([])
|
||||
// Each entry pairs a loaded book id with its absolute position in the full
|
||||
// listing. For fully-loaded lists the index equals the array position; for a
|
||||
// windowed listing it equals the book's slot index, so a book reached via the
|
||||
// jump rail keeps its true position even though only a sparse subset is loaded.
|
||||
type NavEntry = { id: number; index: number }
|
||||
|
||||
const entries = ref<NavEntry[]>([])
|
||||
const total = ref(0)
|
||||
let loadMoreCallback: (() => Promise<void>) | null = null
|
||||
|
||||
const positionById = computed(() => {
|
||||
const map = new Map<number, number>()
|
||||
entries.value.forEach((entry, pos) => map.set(entry.id, pos))
|
||||
return map
|
||||
})
|
||||
|
||||
export function useBookNavigation() {
|
||||
const setBookContext = (ids: number[], totalCount: number) => {
|
||||
bookIds.value = ids
|
||||
entries.value = ids.map((id, index) => ({ id, index }))
|
||||
total.value = totalCount
|
||||
}
|
||||
|
||||
const setBookSlotContext = (slots: BookSlot[], totalCount: number) => {
|
||||
const next: NavEntry[] = []
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i]!
|
||||
if (!isBookPlaceholder(slot)) next.push({ id: slot.id, index: i })
|
||||
}
|
||||
entries.value = next
|
||||
total.value = totalCount
|
||||
}
|
||||
|
||||
@@ -15,43 +38,46 @@ export function useBookNavigation() {
|
||||
}
|
||||
|
||||
const getNextId = async (currentId: number) => {
|
||||
const idx = bookIds.value.indexOf(currentId)
|
||||
if (idx !== -1) {
|
||||
if (idx < bookIds.value.length - 1) {
|
||||
return bookIds.value[idx + 1]
|
||||
}
|
||||
if (bookIds.value.length < total.value && loadMoreCallback) {
|
||||
const beforeLength = bookIds.value.length
|
||||
await loadMoreCallback()
|
||||
if (bookIds.value.length === beforeLength) {
|
||||
// No more books were loaded, avoid infinite loop
|
||||
return null
|
||||
}
|
||||
// After loading more, check again
|
||||
const newIdx = bookIds.value.indexOf(currentId)
|
||||
if (newIdx !== -1 && newIdx < bookIds.value.length - 1) {
|
||||
return bookIds.value[newIdx + 1]
|
||||
}
|
||||
const pos = positionById.value.get(currentId)
|
||||
if (pos === undefined) return null
|
||||
const current = entries.value[pos]!
|
||||
const next = entries.value[pos + 1]
|
||||
if (next && next.index === current.index + 1) return next.id
|
||||
if (current.index + 1 < total.value && loadMoreCallback) {
|
||||
const beforeLength = entries.value.length
|
||||
await loadMoreCallback()
|
||||
if (entries.value.length === beforeLength) {
|
||||
// No more books were loaded, avoid infinite loop
|
||||
return null
|
||||
}
|
||||
const newPos = positionById.value.get(currentId)
|
||||
if (newPos === undefined) return null
|
||||
const reloaded = entries.value[newPos]!
|
||||
const after = entries.value[newPos + 1]
|
||||
if (after && after.index === reloaded.index + 1) return after.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getPrevId = (currentId: number) => {
|
||||
const idx = bookIds.value.indexOf(currentId)
|
||||
if (idx !== -1 && idx > 0) {
|
||||
return bookIds.value[idx - 1]
|
||||
}
|
||||
return null
|
||||
const pos = positionById.value.get(currentId)
|
||||
if (pos === undefined || pos === 0) return null
|
||||
const current = entries.value[pos]!
|
||||
const prev = entries.value[pos - 1]!
|
||||
return prev.index === current.index - 1 ? prev.id : null
|
||||
}
|
||||
|
||||
const hasContext = computed(() => bookIds.value.length > 0)
|
||||
const currentIndex = (currentId: number) => bookIds.value.indexOf(currentId)
|
||||
const hasContext = computed(() => entries.value.length > 0)
|
||||
const currentIndex = (currentId: number) => {
|
||||
const pos = positionById.value.get(currentId)
|
||||
return pos === undefined ? -1 : entries.value[pos]!.index
|
||||
}
|
||||
|
||||
return {
|
||||
bookIds: computed(() => bookIds.value),
|
||||
bookIds: computed(() => entries.value.map((entry) => entry.id)),
|
||||
total: computed(() => total.value),
|
||||
setBookContext,
|
||||
setBookSlotContext,
|
||||
registerLoadMore,
|
||||
getNextId,
|
||||
getPrevId,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { onMounted, watch, type Ref } from 'vue'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { useBookNavigation } from './useBookNavigation'
|
||||
import type { BookSlot } from './useBookWindow'
|
||||
|
||||
export function useBookViewContext(books: Ref<BookCard[]>, total: Ref<number>, loadMore: () => Promise<unknown> | unknown) {
|
||||
const { setBookContext, registerLoadMore } = useBookNavigation()
|
||||
export function useBookViewContext(slots: Ref<BookSlot[]>, total: Ref<number>, loadMore: () => Promise<unknown> | unknown) {
|
||||
const { setBookSlotContext, registerLoadMore } = useBookNavigation()
|
||||
const syncBookContext = () => {
|
||||
setBookContext(
|
||||
books.value.map((book) => book.id),
|
||||
total.value,
|
||||
)
|
||||
setBookSlotContext(slots.value, total.value)
|
||||
}
|
||||
|
||||
watch([books, total], () => syncBookContext(), { immediate: true })
|
||||
watch([slots, total], () => syncBookContext(), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
registerLoadMore(async () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ const {
|
||||
const { sortModel: tableSortModel } = useViewSort(tableSort, 'collection', collectionId)
|
||||
const collectionLoadError = computed(() => collectionsError.value ?? booksError.value)
|
||||
const { setBookContext } = useBookNavigation()
|
||||
useBookViewContext(books, total, loadMorePrefix)
|
||||
useBookViewContext(slots, total, loadMorePrefix)
|
||||
|
||||
function handleSaveCurrentView(name: string) {
|
||||
if (!tableRef.value) return
|
||||
|
||||
@@ -136,7 +136,7 @@ const {
|
||||
})
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
const { setBookContext } = useBookNavigation()
|
||||
useBookViewContext(books, total, loadMorePrefix)
|
||||
useBookViewContext(slots, total, loadMorePrefix)
|
||||
|
||||
const FILTER_STORAGE_PREFIX = 'bookorbit:filter:library:'
|
||||
function getFilterKey(id: number) {
|
||||
|
||||
@@ -96,7 +96,7 @@ const {
|
||||
q: debouncedQuery,
|
||||
})
|
||||
const { setBookContext } = useBookNavigation()
|
||||
useBookViewContext(books, total, loadMorePrefix)
|
||||
useBookViewContext(slots, total, loadMorePrefix)
|
||||
const { smartScopes, loaded: smartScopesLoaded, error: smartScopesError, fetchSmartScopes, deleteSmartScope } = useSmartScopes()
|
||||
const smartScopeNotFound = ref(false)
|
||||
const smartScopeLoadError = computed(() => smartScopesError.value ?? booksError.value)
|
||||
|
||||
Reference in New Issue
Block a user