From 817426c50f7710ce2fd5d97db77be6c49dd0da48 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:29:58 +0800 Subject: [PATCH 1/2] fix(promotion): paginate review queues Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .../promotion/use-promotion-list.test.ts | 8 +-- .../features/promotion/use-promotion-list.ts | 9 +-- web/src/pages/dashboard/promotions.test.tsx | 68 +++++++++++++++++-- web/src/pages/dashboard/promotions.tsx | 63 +++++++++++++---- 4 files changed, 122 insertions(+), 26 deletions(-) diff --git a/web/src/features/promotion/use-promotion-list.test.ts b/web/src/features/promotion/use-promotion-list.test.ts index 580713f1..72c4d8d2 100644 --- a/web/src/features/promotion/use-promotion-list.test.ts +++ b/web/src/features/promotion/use-promotion-list.test.ts @@ -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 } + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise> } 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 } + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise> } expect(options.queryKey).toEqual(['promotions', { status: 'APPROVED', diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts index db0b1a14..40f01f11 100644 --- a/web/src/features/promotion/use-promotion-list.ts +++ b/web/src/features/promotion/use-promotion-list.ts @@ -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, }) } diff --git a/web/src/pages/dashboard/promotions.test.tsx b/web/src/pages/dashboard/promotions.test.tsx index a04bc219..a1666c47 100644 --- a/web/src/pages/dashboard/promotions.test.tsx +++ b/web/src/pages/dashboard/promotions.test.tsx @@ -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 ( + + ) + }, +})) + import { PromotionsPage } from './promotions' function createPromotion(overrides: Partial = {}): PromotionTask { @@ -99,9 +111,12 @@ function createPromotion(overrides: Partial = {}): 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,34 @@ describe('PromotionsPage', () => { expect(screen.getByText('5 stars')).toBeTruthy() }) + it('paginates pending and history queues independently', () => { + installPromotionListMock({ pendingTotal: 21, approvedTotal: 21 }) + render() + + 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('renders approved history as a sortable table', () => { render() diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx index 7ebcf901..ba1ad811 100644 --- a/web/src/pages/dashboard/promotions.tsx +++ b/web/src/pages/dashboard/promotions.tsx @@ -2,6 +2,7 @@ import { 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 +type PromotionPage = PagedResponse + +const PAGE_SIZE = 20 function formatFileSize(bytes: number): string { if (bytes < 1024) { @@ -67,6 +71,14 @@ 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 +} + function PendingPromotionCard({ item, comment, @@ -123,9 +135,9 @@ 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>({}) @@ -134,14 +146,14 @@ function PendingPromotionList() { return
} - if (!items || items.length === 0) { + if (!data || data.items.length === 0) { return
{t('promotions.empty')}
} const isMutating = approveMutation.isPending || rejectMutation.isPending return (
- {items.map((item) => ( + {data.items.map((item) => ( rejectMutation.mutate({ id: item.id, comment: commentById[item.id] })} /> ))} +
) } @@ -159,14 +172,24 @@ 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') @@ -180,13 +203,14 @@ function PromotionHistoryTable({ ) } - if (!items || items.length === 0) { + if (!data || data.items.length === 0) { return
{t('promotions.empty')}
} return ( -
- +
+
+
{t('promotions.colSkill')} @@ -210,7 +234,7 @@ function PromotionHistoryTable({ - {items.map((item) => { + {data.items.map((item) => { const reviewCommentId = `promotion-review-comment-${item.id}` return ( @@ -240,7 +264,9 @@ function PromotionHistoryTable({ ) })} -
+ +
+
) } @@ -250,6 +276,11 @@ function PromotionHistoryTable({ */ export function PromotionsPage() { const { t } = useTranslation() + const [pages, setPages] = useState>({ + PENDING: 0, + APPROVED: 0, + REJECTED: 0, + }) const [historySortDirection, setHistorySortDirection] = useState>({ APPROVED: 'DESC', REJECTED: 'DESC', @@ -262,6 +293,10 @@ export function PromotionsPage() { })) } + function changePage(status: PromotionStatus, page: number) { + setPages((current) => ({ ...current, [status]: page })) + } + return (
@@ -272,12 +307,14 @@ export function PromotionsPage() { {t('promotions.tabRejected')} - + changePage('PENDING', page)} /> changePage('APPROVED', page)} onToggleSort={() => toggleHistorySort('APPROVED')} /> @@ -285,6 +322,8 @@ export function PromotionsPage() { changePage('REJECTED', page)} onToggleSort={() => toggleHistorySort('REJECTED')} /> From fe8a0cb21ff673ad17db7cf644232a5198d28ac7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:58:11 +0800 Subject: [PATCH 2/2] fix(promotion): clamp emptied queue pages Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- web/src/pages/dashboard/promotions.test.tsx | 30 ++++++++++++++++++++- web/src/pages/dashboard/promotions.tsx | 16 ++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/web/src/pages/dashboard/promotions.test.tsx b/web/src/pages/dashboard/promotions.test.tsx index a1666c47..7f924af9 100644 --- a/web/src/pages/dashboard/promotions.test.tsx +++ b/web/src/pages/dashboard/promotions.test.tsx @@ -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' @@ -246,6 +246,34 @@ describe('PromotionsPage', () => { 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() + + 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() diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx index ba1ad811..5ac266eb 100644 --- a/web/src/pages/dashboard/promotions.tsx +++ b/web/src/pages/dashboard/promotions.tsx @@ -1,4 +1,4 @@ -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' @@ -79,6 +79,18 @@ function PromotionPagination({ data, onPageChange }: { data: PromotionPage; onPa return } +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, @@ -141,6 +153,7 @@ function PendingPromotionList({ page, onPageChange }: { page: number; onPageChan const approveMutation = useApprovePromotion() const rejectMutation = useRejectPromotion() const [commentById, setCommentById] = useState>({}) + useClampPromotionPage(data, page, onPageChange) if (isLoading) { return
@@ -192,6 +205,7 @@ function PromotionHistoryTable({ }) const nextDirection = sortDirection === 'DESC' ? 'ASC' : 'DESC' const sortLabel = nextDirection === 'ASC' ? t('promotions.sortReviewedTimeAsc') : t('promotions.sortReviewedTimeDesc') + useClampPromotionPage(data, page, onPageChange) if (isLoading) { return (