mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
Merge pull request #774 from FenjuFu/fix/promotion-pagination
fix(promotion): paginate review queues
This commit is contained in:
commit
8361ea3fcd
4 changed files with 166 additions and 28 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue