Merge pull request #774 from FenjuFu/fix/promotion-pagination

fix(promotion): paginate review queues
This commit is contained in:
XiaoSeS 2026-08-31 15:22:21 +08:00 committed by GitHub
commit 8361ea3fcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 166 additions and 28 deletions

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PromotionTask } from '@/api/types'
import type { PagedResponse, PromotionTask } from '@/api/types'
const mocks = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
@ -61,7 +61,7 @@ describe('usePromotionList', () => {
it('defaults to the pending queue without history sort params', async () => {
usePromotionList()
const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise<PromotionTask[]> }
const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise<PagedResponse<PromotionTask>> }
expect(options.queryKey).toEqual(['promotions', {
status: 'PENDING',
@ -70,7 +70,7 @@ describe('usePromotionList', () => {
sortBy: undefined,
sortDirection: undefined,
}])
await expect(options.queryFn()).resolves.toEqual([promotion])
await expect(options.queryFn()).resolves.toEqual({ items: [promotion], total: 1, page: 0, size: 20 })
expect(mocks.promotionList).toHaveBeenCalledWith({
status: 'PENDING',
page: 0,
@ -82,7 +82,7 @@ describe('usePromotionList', () => {
it('passes reviewed-time sort params for history queues', async () => {
usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'ASC' })
const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise<PromotionTask[]> }
const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise<PagedResponse<PromotionTask>> }
expect(options.queryKey).toEqual(['promotions', {
status: 'APPROVED',

View file

@ -11,8 +11,8 @@ export interface PromotionListParams {
}
/**
* Returns the promotion queue for a given status. The hook unwraps the backend
* page object because promotion screens currently consume the item list only.
* Returns a promotion page for a given status, preserving pagination metadata
* so queue screens can navigate beyond the first backend page.
*/
export function usePromotionList(params: PromotionListParams = { status: 'PENDING' }) {
const normalizedParams = {
@ -25,10 +25,7 @@ export function usePromotionList(params: PromotionListParams = { status: 'PENDIN
return useQuery({
queryKey: ['promotions', normalizedParams],
queryFn: async () => {
const page = await promotionApi.list(normalizedParams)
return page.items
},
queryFn: () => promotionApi.list(normalizedParams),
staleTime: 30_000,
})
}

View file

@ -1,5 +1,5 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PromotionStatus, PromotionTask } from '@/api/types'
@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
approveMutate: vi.fn(),
rejectMutate: vi.fn(),
usePromotionList: vi.fn(),
paginationProps: [] as Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }>,
translations: {
'promotions.approve': 'Approve',
'promotions.colReviewComment': 'Review Comment',
@ -68,6 +69,17 @@ vi.mock('@/shared/components/dashboard-page-header', () => ({
),
}))
vi.mock('@/shared/components/pagination', () => ({
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
mocks.paginationProps.push(props)
return (
<button type="button" onClick={() => props.onPageChange(props.page + 1)}>
pagination:{props.page}/{props.totalPages}
</button>
)
},
}))
import { PromotionsPage } from './promotions'
function createPromotion(overrides: Partial<PromotionTask> = {}): PromotionTask {
@ -99,9 +111,12 @@ function createPromotion(overrides: Partial<PromotionTask> = {}): PromotionTask
function installPromotionListMock(overrides: {
pending?: PromotionTask[]
pendingTotal?: number
approvedDesc?: PromotionTask[]
approvedTotal?: number
approvedAsc?: PromotionTask[]
rejectedDesc?: PromotionTask[]
rejectedTotal?: number
rejectedAsc?: PromotionTask[]
} = {}) {
const pending = overrides.pending ?? [createPromotion()]
@ -152,20 +167,37 @@ function installPromotionListMock(overrides: {
const approvedAsc = overrides.approvedAsc ?? [...approvedDesc].reverse()
const rejectedAsc = overrides.rejectedAsc ?? [...rejectedDesc].reverse()
mocks.usePromotionList.mockImplementation((params: { status?: PromotionStatus; sortDirection?: 'ASC' | 'DESC' } = {}) => {
mocks.usePromotionList.mockImplementation((params: {
status?: PromotionStatus
page?: number
size?: number
sortDirection?: 'ASC' | 'DESC'
} = {}) => {
const page = params.page ?? 0
const size = params.size ?? 20
if (params.status === 'APPROVED') {
return { data: params.sortDirection === 'ASC' ? approvedAsc : approvedDesc, isLoading: false }
return {
data: { items: params.sortDirection === 'ASC' ? approvedAsc : approvedDesc, total: overrides.approvedTotal ?? approvedDesc.length, page, size },
isLoading: false,
}
}
if (params.status === 'REJECTED') {
return { data: params.sortDirection === 'ASC' ? rejectedAsc : rejectedDesc, isLoading: false }
return {
data: { items: params.sortDirection === 'ASC' ? rejectedAsc : rejectedDesc, total: overrides.rejectedTotal ?? rejectedDesc.length, page, size },
isLoading: false,
}
}
return {
data: { items: pending, total: overrides.pendingTotal ?? pending.length, page, size },
isLoading: false,
}
return { data: pending, isLoading: false }
})
}
describe('PromotionsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.paginationProps.length = 0
installPromotionListMock()
})
@ -186,6 +218,62 @@ describe('PromotionsPage', () => {
expect(screen.getByText('5 stars')).toBeTruthy()
})
it('paginates pending and history queues independently', () => {
installPromotionListMock({ pendingTotal: 21, approvedTotal: 21 })
render(<PromotionsPage />)
expect(mocks.usePromotionList).toHaveBeenCalledWith({
status: 'PENDING',
page: 0,
size: 20,
})
fireEvent.click(screen.getByRole('button', { name: 'pagination:0/2' }))
expect(mocks.usePromotionList).toHaveBeenCalledWith({
status: 'PENDING',
page: 1,
size: 20,
})
expect(screen.getByRole('button', { name: 'pagination:1/2' })).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: 'Approved' }))
expect(mocks.usePromotionList).toHaveBeenCalledWith({
status: 'APPROVED',
page: 0,
size: 20,
sortBy: 'reviewedAt',
sortDirection: 'DESC',
})
expect(screen.getByRole('button', { name: 'pagination:0/2' })).toBeTruthy()
})
it('returns to the last valid page when a mutation empties the current page', async () => {
const pending = createPromotion()
mocks.usePromotionList.mockImplementation((params: { status?: PromotionStatus; page?: number } = {}) => {
const page = params.page ?? 0
return {
data: {
items: params.status === 'PENDING' && page === 0 ? [pending] : [],
total: params.status === 'PENDING' ? 20 : 0,
page,
size: 20,
},
isLoading: false,
}
})
render(<PromotionsPage />)
mocks.paginationProps[0]?.onPageChange(1)
await waitFor(() => {
expect(mocks.usePromotionList).toHaveBeenLastCalledWith({
status: 'PENDING',
page: 0,
size: 20,
})
})
expect(screen.getByText('Knowledge Helper')).toBeTruthy()
})
it('renders approved history as a sortable table', () => {
render(<PromotionsPage />)

View file

@ -1,7 +1,8 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { formatCompactCount } from '@/shared/lib/number-format'
import { cn } from '@/shared/lib/utils'
@ -17,10 +18,13 @@ import {
TableRow,
} from '@/shared/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import type { PromotionTask } from '@/api/types'
import type { PagedResponse, PromotionTask } from '@/api/types'
import type { PromotionSortDirection, PromotionStatus } from '@/features/promotion/use-promotion-list'
type HistoryPromotionStatus = Extract<PromotionStatus, 'APPROVED' | 'REJECTED'>
type PromotionPage = PagedResponse<PromotionTask>
const PAGE_SIZE = 20
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
@ -67,6 +71,26 @@ function SorterGlyph({ direction }: { direction: PromotionSortDirection }) {
)
}
function PromotionPagination({ data, onPageChange }: { data: PromotionPage; onPageChange: (page: number) => void }) {
const totalPages = data.size > 0 ? Math.ceil(data.total / data.size) : 0
if (totalPages <= 1) {
return null
}
return <Pagination page={data.page} totalPages={totalPages} onPageChange={onPageChange} />
}
function useClampPromotionPage(data: PromotionPage | undefined, page: number, onPageChange: (page: number) => void) {
useEffect(() => {
if (!data) {
return
}
const totalPages = data.size > 0 ? Math.ceil(data.total / data.size) : 0
if (page > 0 && page >= totalPages) {
onPageChange(Math.max(0, totalPages - 1))
}
}, [data, onPageChange, page])
}
function PendingPromotionCard({
item,
comment,
@ -123,25 +147,26 @@ function PendingPromotionCard({
)
}
function PendingPromotionList() {
function PendingPromotionList({ page, onPageChange }: { page: number; onPageChange: (page: number) => void }) {
const { t } = useTranslation()
const { data: items, isLoading } = usePromotionList({ status: 'PENDING' })
const { data, isLoading } = usePromotionList({ status: 'PENDING', page, size: PAGE_SIZE })
const approveMutation = useApprovePromotion()
const rejectMutation = useRejectPromotion()
const [commentById, setCommentById] = useState<Record<number, string>>({})
useClampPromotionPage(data, page, onPageChange)
if (isLoading) {
return <div className="h-32 animate-shimmer rounded-xl" />
}
if (!items || items.length === 0) {
if (!data || data.items.length === 0) {
return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('promotions.empty')}</div>
}
const isMutating = approveMutation.isPending || rejectMutation.isPending
return (
<div className="space-y-4">
{items.map((item) => (
{data.items.map((item) => (
<PendingPromotionCard
key={item.id}
item={item}
@ -152,6 +177,7 @@ function PendingPromotionList() {
onReject={() => rejectMutation.mutate({ id: item.id, comment: commentById[item.id] })}
/>
))}
<PromotionPagination data={data} onPageChange={onPageChange} />
</div>
)
}
@ -159,16 +185,27 @@ function PendingPromotionList() {
function PromotionHistoryTable({
status,
sortDirection,
page,
onPageChange,
onToggleSort,
}: {
status: HistoryPromotionStatus
sortDirection: PromotionSortDirection
page: number
onPageChange: (page: number) => void
onToggleSort: () => void
}) {
const { t, i18n } = useTranslation()
const { data: items, isLoading } = usePromotionList({ status, sortBy: 'reviewedAt', sortDirection })
const { data, isLoading } = usePromotionList({
status,
page,
size: PAGE_SIZE,
sortBy: 'reviewedAt',
sortDirection,
})
const nextDirection = sortDirection === 'DESC' ? 'ASC' : 'DESC'
const sortLabel = nextDirection === 'ASC' ? t('promotions.sortReviewedTimeAsc') : t('promotions.sortReviewedTimeDesc')
useClampPromotionPage(data, page, onPageChange)
if (isLoading) {
return (
@ -180,13 +217,14 @@ function PromotionHistoryTable({
)
}
if (!items || items.length === 0) {
if (!data || data.items.length === 0) {
return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('promotions.empty')}</div>
}
return (
<div className="overflow-hidden rounded-xl border border-border/60">
<Table aria-label={t('promotions.historyTableLabel')}>
<div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border/60">
<Table aria-label={t('promotions.historyTableLabel')}>
<TableHeader>
<TableRow className="bg-muted/35">
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('promotions.colSkill')}</TableHead>
@ -210,7 +248,7 @@ function PromotionHistoryTable({
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
{data.items.map((item) => {
const reviewCommentId = `promotion-review-comment-${item.id}`
return (
<TableRow key={item.id}>
@ -240,7 +278,9 @@ function PromotionHistoryTable({
)
})}
</TableBody>
</Table>
</Table>
</div>
<PromotionPagination data={data} onPageChange={onPageChange} />
</div>
)
}
@ -250,6 +290,11 @@ function PromotionHistoryTable({
*/
export function PromotionsPage() {
const { t } = useTranslation()
const [pages, setPages] = useState<Record<PromotionStatus, number>>({
PENDING: 0,
APPROVED: 0,
REJECTED: 0,
})
const [historySortDirection, setHistorySortDirection] = useState<Record<HistoryPromotionStatus, PromotionSortDirection>>({
APPROVED: 'DESC',
REJECTED: 'DESC',
@ -262,6 +307,10 @@ export function PromotionsPage() {
}))
}
function changePage(status: PromotionStatus, page: number) {
setPages((current) => ({ ...current, [status]: page }))
}
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('promotions.title')} subtitle={t('promotions.subtitle')} />
@ -272,12 +321,14 @@ export function PromotionsPage() {
<TabsTrigger value="REJECTED">{t('promotions.tabRejected')}</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-6">
<PendingPromotionList />
<PendingPromotionList page={pages.PENDING} onPageChange={(page) => changePage('PENDING', page)} />
</TabsContent>
<TabsContent value="APPROVED" className="mt-6">
<PromotionHistoryTable
status="APPROVED"
sortDirection={historySortDirection.APPROVED}
page={pages.APPROVED}
onPageChange={(page) => changePage('APPROVED', page)}
onToggleSort={() => toggleHistorySort('APPROVED')}
/>
</TabsContent>
@ -285,6 +336,8 @@ export function PromotionsPage() {
<PromotionHistoryTable
status="REJECTED"
sortDirection={historySortDirection.REJECTED}
page={pages.REJECTED}
onPageChange={(page) => changePage('REJECTED', page)}
onToggleSort={() => toggleHistorySort('REJECTED')}
/>
</TabsContent>