mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): paginate Book Dock navigation
Add a reusable pagination control and cover Book Dock navigation with focused tests. Align the client PDF dependency and lockfile for clean CI verification.
This commit is contained in:
+17
-17
@@ -41,24 +41,24 @@
|
||||
"dependencies": {
|
||||
"@bookorbit/types": "workspace:*",
|
||||
"@embedpdf/core": "2.15.0",
|
||||
"@embedpdf/engines": "2.15.1",
|
||||
"@embedpdf/models": "2.15.1",
|
||||
"@embedpdf/pdfium": "2.15.1",
|
||||
"@embedpdf/plugin-annotation": "2.15.1",
|
||||
"@embedpdf/plugin-bookmark": "2.15.1",
|
||||
"@embedpdf/plugin-document-manager": "2.15.1",
|
||||
"@embedpdf/plugin-history": "2.15.1",
|
||||
"@embedpdf/plugin-interaction-manager": "2.15.1",
|
||||
"@embedpdf/plugin-pan": "2.15.1",
|
||||
"@embedpdf/plugin-render": "2.15.1",
|
||||
"@embedpdf/plugin-rotate": "2.15.1",
|
||||
"@embedpdf/engines": "2.15.0",
|
||||
"@embedpdf/models": "2.15.0",
|
||||
"@embedpdf/pdfium": "2.15.0",
|
||||
"@embedpdf/plugin-annotation": "2.15.0",
|
||||
"@embedpdf/plugin-bookmark": "2.15.0",
|
||||
"@embedpdf/plugin-document-manager": "2.15.0",
|
||||
"@embedpdf/plugin-history": "2.15.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.15.0",
|
||||
"@embedpdf/plugin-pan": "2.15.0",
|
||||
"@embedpdf/plugin-render": "2.15.0",
|
||||
"@embedpdf/plugin-rotate": "2.15.0",
|
||||
"@embedpdf/plugin-scroll": "2.15.0",
|
||||
"@embedpdf/plugin-search": "2.15.1",
|
||||
"@embedpdf/plugin-selection": "2.15.1",
|
||||
"@embedpdf/plugin-spread": "2.15.1",
|
||||
"@embedpdf/plugin-thumbnail": "2.15.1",
|
||||
"@embedpdf/plugin-tiling": "2.15.1",
|
||||
"@embedpdf/plugin-viewport": "2.15.1",
|
||||
"@embedpdf/plugin-search": "2.15.0",
|
||||
"@embedpdf/plugin-selection": "2.15.0",
|
||||
"@embedpdf/plugin-spread": "2.15.0",
|
||||
"@embedpdf/plugin-thumbnail": "2.15.0",
|
||||
"@embedpdf/plugin-tiling": "2.15.0",
|
||||
"@embedpdf/plugin-viewport": "2.15.0",
|
||||
"@embedpdf/plugin-zoom": "2.15.0",
|
||||
"@fontsource-variable/fraunces": "^5.3.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
|
||||
type PaginationMessageKeys = {
|
||||
label: string
|
||||
previousPage: string
|
||||
nextPage: string
|
||||
goToPage: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
page: number
|
||||
totalPages: number
|
||||
messageKeys?: PaginationMessageKeys
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [value: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const defaultMessageKeys: PaginationMessageKeys = {
|
||||
label: 'common.pagination.label',
|
||||
previousPage: 'common.pagination.previousPage',
|
||||
nextPage: 'common.pagination.nextPage',
|
||||
goToPage: 'common.pagination.goToPage',
|
||||
}
|
||||
const messageKeys = computed(() => props.messageKeys ?? defaultMessageKeys)
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const last = props.totalPages
|
||||
const pages = new Set([1, 2, props.page - 1, props.page, props.page + 1, last - 1, last])
|
||||
const sorted = [...pages].filter((page) => page >= 1 && page <= last).sort((a, b) => a - b)
|
||||
|
||||
return sorted.flatMap((page, index) => {
|
||||
const previous = sorted[index - 1]
|
||||
if (previous === undefined) return [page]
|
||||
if (page - previous === 2) return [previous + 1, page]
|
||||
if (page - previous > 2) return [-1, page]
|
||||
return [page]
|
||||
})
|
||||
})
|
||||
|
||||
function handlePreviousPage(): void {
|
||||
if (props.page > 1) emit('update:page', props.page - 1)
|
||||
}
|
||||
|
||||
function handleNextPage(): void {
|
||||
if (props.page < props.totalPages) emit('update:page', props.page + 1)
|
||||
}
|
||||
|
||||
function handleGoToPage(page: number): void {
|
||||
emit('update:page', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="totalPages > 1" class="flex min-w-0 items-center justify-center gap-1" :aria-label="t(messageKeys.label)">
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-card text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:disabled="page <= 1"
|
||||
:aria-label="t(messageKeys.previousPage)"
|
||||
@click="handlePreviousPage"
|
||||
>
|
||||
<ChevronLeft :size="15" class="rtl:rotate-180" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<span aria-live="polite" class="min-w-0 px-2 text-center text-xs text-muted-foreground md:sr-only md:p-0">
|
||||
{{ t('common.pagination.pageOf', { page: formatNumber(page), totalPages: formatNumber(totalPages) }) }}
|
||||
</span>
|
||||
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<template v-for="(entry, index) in pageNumbers" :key="`${entry}-${index}`">
|
||||
<span v-if="entry === -1" aria-hidden="true" class="px-1 text-xs text-muted-foreground">…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="grid h-8 min-w-8 place-items-center rounded-md border px-2 text-xs font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="entry === page ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-foreground hover:bg-accent'"
|
||||
:aria-label="t(messageKeys.goToPage, { page: formatNumber(entry) })"
|
||||
:aria-current="entry === page ? 'page' : undefined"
|
||||
@click="handleGoToPage(entry)"
|
||||
>
|
||||
{{ formatNumber(entry) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-card text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:disabled="page >= totalPages"
|
||||
:aria-label="t(messageKeys.nextPage)"
|
||||
@click="handleNextPage"
|
||||
>
|
||||
<ChevronRight :size="15" class="rtl:rotate-180" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PaginationNav from '../PaginationNav.vue'
|
||||
|
||||
function mountPager(page: number, totalPages: number) {
|
||||
return mount(PaginationNav, { props: { page, totalPages } })
|
||||
}
|
||||
|
||||
function numberedButtons(wrapper: ReturnType<typeof mountPager>) {
|
||||
return wrapper.findAll('button').filter((button) => /^\d+$/.test(button.text().trim()))
|
||||
}
|
||||
|
||||
describe('PaginationNav', () => {
|
||||
it('keeps a 350-page run bounded around the current page', () => {
|
||||
const wrapper = mountPager(176, 350)
|
||||
|
||||
expect(numberedButtons(wrapper).map((button) => button.text())).toEqual(['1', '2', '175', '176', '177', '349', '350'])
|
||||
expect(wrapper.findAll('[aria-hidden="true"]').filter((element) => element.text() === '…')).toHaveLength(2)
|
||||
expect(wrapper.get('[aria-current="page"]').text()).toBe('176')
|
||||
expect(wrapper.get('[aria-live="polite"]').text()).toBe('Page 176 of 350')
|
||||
})
|
||||
|
||||
it('shows the first and last pages at either boundary', () => {
|
||||
const first = mountPager(1, 350)
|
||||
const last = mountPager(350, 350)
|
||||
|
||||
expect(numberedButtons(first).map((button) => button.text())).toEqual(['1', '2', '349', '350'])
|
||||
expect(numberedButtons(last).map((button) => button.text())).toEqual(['1', '2', '349', '350'])
|
||||
expect(first.get('[aria-label="Previous page"]').attributes('disabled')).toBeDefined()
|
||||
expect(last.get('[aria-label="Next page"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('fills single-page gaps and lists short runs fully', () => {
|
||||
expect(numberedButtons(mountPager(4, 8)).map((button) => button.text())).toEqual(['1', '2', '3', '4', '5', '6', '7', '8'])
|
||||
expect(numberedButtons(mountPager(2, 4)).map((button) => button.text())).toEqual(['1', '2', '3', '4'])
|
||||
})
|
||||
|
||||
it('emits bounded previous, next, and direct page changes', async () => {
|
||||
const wrapper = mountPager(176, 350)
|
||||
|
||||
await wrapper.get('[aria-label="Previous page"]').trigger('click')
|
||||
await wrapper.get('[aria-label="Next page"]').trigger('click')
|
||||
await wrapper.get('[aria-label="Go to page 350"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:page')).toEqual([[175], [177], [350]])
|
||||
})
|
||||
|
||||
it('hides itself when there is one page', () => {
|
||||
expect(mountPager(1, 1).find('nav').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import PaginationNav from '@/components/PaginationNav.vue'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
|
||||
import { ENTITY_PAGE_SIZES } from '../types'
|
||||
@@ -20,32 +21,16 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const paginationMessageKeys = {
|
||||
label: 'tools.entityManager.browse.pagination',
|
||||
previousPage: 'tools.entityManager.browse.previousPage',
|
||||
nextPage: 'tools.entityManager.browse.nextPage',
|
||||
goToPage: 'tools.entityManager.browse.goToPage',
|
||||
}
|
||||
|
||||
const from = computed(() => (props.total === 0 ? 0 : (props.page - 1) * props.pageSize + 1))
|
||||
const to = computed(() => Math.min(props.page * props.pageSize, props.total))
|
||||
|
||||
/** Page numbers around the current page, with -1 marking a collapsed gap. */
|
||||
const pageNumbers = computed<number[]>(() => {
|
||||
const last = props.totalPages
|
||||
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1)
|
||||
|
||||
const around = [props.page - 1, props.page, props.page + 1].filter((p) => p > 1 && p < last)
|
||||
const pages = [1, ...around, last]
|
||||
|
||||
return pages.flatMap((page, index) => {
|
||||
const previous = pages[index - 1]
|
||||
return previous !== undefined && page - previous > 1 ? [-1, page] : [page]
|
||||
})
|
||||
})
|
||||
|
||||
function handlePrevPage(): void {
|
||||
emit('update:page', props.page - 1)
|
||||
}
|
||||
|
||||
function handleNextPage(): void {
|
||||
emit('update:page', props.page + 1)
|
||||
}
|
||||
|
||||
function handleGoToPage(page: number): void {
|
||||
emit('update:page', page)
|
||||
}
|
||||
@@ -82,45 +67,6 @@ function handlePageSize(size: number): void {
|
||||
{{ t('tools.entityManager.browse.range', { from: formatNumber(from), to: formatNumber(to), total: formatNumber(total) }) }}
|
||||
</p>
|
||||
|
||||
<nav v-if="totalPages > 1" class="ms-auto flex items-center gap-1" :aria-label="t('tools.entityManager.browse.pagination')">
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:disabled="page <= 1"
|
||||
:aria-label="t('tools.entityManager.browse.previousPage')"
|
||||
@click="handlePrevPage"
|
||||
>
|
||||
<ChevronLeft :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<template v-for="(entry, index) in pageNumbers" :key="`${entry}-${index}`">
|
||||
<span v-if="entry === -1" class="px-1 text-xs text-muted-foreground" :aria-label="t('tools.entityManager.browse.morePages')">…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="grid h-8 min-w-8 place-items-center rounded-md border px-2 text-xs font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="
|
||||
entry === page
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
"
|
||||
:aria-label="t('tools.entityManager.browse.goToPage', { page: formatNumber(entry) })"
|
||||
:aria-current="entry === page ? 'page' : undefined"
|
||||
@click="handleGoToPage(entry)"
|
||||
>
|
||||
{{ formatNumber(entry) }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:disabled="page >= totalPages"
|
||||
:aria-label="t('tools.entityManager.browse.nextPage')"
|
||||
@click="handleNextPage"
|
||||
>
|
||||
<ChevronRight :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
<PaginationNav class="ms-auto" :page="page" :total-pages="totalPages" :message-keys="paginationMessageKeys" @update:page="handleGoToPage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ describe('EntityBrowsePager', () => {
|
||||
it('collapses long page runs around the current page', () => {
|
||||
const wrapper = mountPager({ page: 9 })
|
||||
|
||||
expect(pageLinks(wrapper).map((button) => button.text())).toEqual(['1', '8', '9', '10', '18'])
|
||||
expect(pageLinks(wrapper).map((button) => button.text())).toEqual(['1', '2', '8', '9', '10', '17', '18'])
|
||||
expect(wrapper.text()).toContain('\u2026')
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
"no": "No",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"undo": "Undo"
|
||||
"undo": "Undo",
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"pageOf": "Page {page} of {totalPages}",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"goToPage": "Go to page {page}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"privacySharing": {
|
||||
|
||||
@@ -22,6 +22,7 @@ import BookDockFinalizeDialog from '@/features/book-dock/components/BookDockFina
|
||||
import BookDockBulkEditDialog from '@/features/book-dock/components/BookDockBulkEditDialog.vue'
|
||||
import BookDockSetDestinationDialog from '@/features/book-dock/components/BookDockSetDestinationDialog.vue'
|
||||
import BookDockDiscardDialog from '@/features/book-dock/components/BookDockDiscardDialog.vue'
|
||||
import PaginationNav from '@/components/PaginationNav.vue'
|
||||
|
||||
type ApplyFetchedResult = {
|
||||
total: number
|
||||
@@ -649,17 +650,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="pageCount > 1" class="flex items-center justify-center gap-1">
|
||||
<button
|
||||
v-for="p in pageCount"
|
||||
:key="p"
|
||||
class="size-8 rounded-lg text-xs font-medium transition-all active:scale-95"
|
||||
:class="filters.page === p ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'"
|
||||
@click="setPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
<PaginationNav :page="filters.page" :total-pages="pageCount" @update:page="setPage" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import BookDockView from '../BookDockView.vue'
|
||||
|
||||
const apiMock = vi.fn<(url: string) => Promise<Response>>(
|
||||
async (_url: string) =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ items: [], total: 7000, page: 1, size: 20 }),
|
||||
}) as Response,
|
||||
)
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: (url: string) => apiMock(url) }))
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({ isDemoRestrictedAccount: ref(false), hasPermission: () => false }),
|
||||
}))
|
||||
vi.mock('@/features/book-dock/composables/useBookDockSummary', () => ({
|
||||
useBookDockSummary: () => ({
|
||||
summary: ref({ total: 7000, paused: false }),
|
||||
fetchSummary: vi.fn<() => void>(),
|
||||
subscribe: vi.fn<() => void>(),
|
||||
onBookDockChange: () => vi.fn<() => void>(),
|
||||
socketConnected: ref(true),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/features/book-dock/composables/useBookDockStatistics', () => ({
|
||||
useBookDockStatistics: () => ({ statistics: ref(null), fetchStatistics: vi.fn<() => void>() }),
|
||||
}))
|
||||
vi.mock('@/features/book-dock/composables/useBookDockUpload', () => ({
|
||||
useBookDockUpload: () => ({ addFiles: vi.fn<() => void>(), isUploading: ref(false) }),
|
||||
SUPPORTED_FORMATS: ['epub'],
|
||||
SUPPORTED_FORMATS_ACCEPT: '.epub',
|
||||
}))
|
||||
vi.mock('@/features/book-dock/composables/useBookDockConflicts', () => ({
|
||||
useBookDockConflicts: () => ({ conflicts: ref([]), scheduleConflicts: vi.fn<() => void>(), cancelConflicts: vi.fn<() => void>() }),
|
||||
}))
|
||||
|
||||
describe('BookDockView pagination', () => {
|
||||
it('keeps a 7000-file dock navigable without rendering hundreds of buttons', async () => {
|
||||
const wrapper = shallowMount(BookDockView, { global: { stubs: { PaginationNav: false } } })
|
||||
await vi.waitFor(() => expect(wrapper.findAll('nav button')).toHaveLength(6))
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('nav button')
|
||||
.map((button) => button.text())
|
||||
.filter(Boolean),
|
||||
).toEqual(['1', '2', '349', '350'])
|
||||
expect(wrapper.get('[aria-current="page"]').text()).toBe('1')
|
||||
expect(String(apiMock.mock.calls[0]?.[0])).toContain('limit=20')
|
||||
|
||||
await wrapper.get('[aria-label="Go to page 350"]').trigger('click')
|
||||
await vi.waitFor(() => expect(String(apiMock.mock.calls.at(-1)?.[0])).toContain('page=350'))
|
||||
expect(wrapper.get('[aria-current="page"]').text()).toBe('350')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user