fix(reviews): add dashboard review pagination and tests (#241)

This commit is contained in:
dongmucat 2026-04-07 14:10:48 +08:00 committed by GitHub
parent 8cb783406d
commit c2981836bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 286 additions and 60 deletions

View file

@ -155,7 +155,12 @@ export class E2eTestDataBuilder {
}
async createNamespace(base = 'e2e-team'): Promise<SeededNamespace> {
const slug = `${base}-${this.suffix}`.slice(0, 64)
const rawSlug = `${base}-${this.suffix}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
const slug = rawSlug.slice(0, 64)
const displayName = `E2E ${slug}`
const created = await parseEnvelope<SeededNamespace>(

View file

@ -0,0 +1,83 @@
import { expect, test, type Page } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
interface ApiEnvelope<T> {
code: number
msg: string
data: T
}
interface ReviewPageData {
total: number
size: number
}
async function fetchReviewPageMeta(page: Page, status: ReviewStatus): Promise<ReviewPageData> {
const response = await page.request.get(`/api/web/reviews?status=${status}&page=0&size=20&sortDirection=DESC`)
const body = await response.json() as ApiEnvelope<ReviewPageData>
if (!response.ok() || body.code !== 0) {
throw new Error(`Failed to query reviews for ${status}: status=${response.status()} code=${body.code} msg=${body.msg}`)
}
return body.data
}
test.describe('Review Management Pagination (Real API)', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({
'X-Mock-User-Id': 'local-admin',
})
})
test('matches pagination rendering with real review totals', async ({ page }) => {
const statuses: ReviewStatus[] = ['PENDING', 'APPROVED', 'REJECTED']
const metaByStatus = new Map<ReviewStatus, ReviewPageData>()
for (const status of statuses) {
metaByStatus.set(status, await fetchReviewPageMeta(page, status))
}
await page.goto('/dashboard/reviews')
await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible()
const tabMeta: Record<ReviewStatus, { tabLabel: string; summaryPrefix: string }> = {
PENDING: { tabLabel: 'Pending', summaryPrefix: 'Total' },
APPROVED: { tabLabel: 'Approved', summaryPrefix: 'Total' },
REJECTED: { tabLabel: 'Rejected', summaryPrefix: 'Total' },
}
for (const status of statuses) {
await page.getByRole('button', { name: tabMeta[status].tabLabel }).click()
const meta = metaByStatus.get(status)
if (!meta) {
throw new Error(`Missing metadata for ${status}`)
}
const totalPages = meta.size > 0 ? Math.ceil(meta.total / meta.size) : 0
if (meta.total === 0) {
await expect(page.getByText('No review tasks')).toBeVisible()
await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0)
continue
}
const previousButton = page.getByRole('button', { name: 'Previous' }).first()
const nextButton = page.getByRole('button', { name: 'Next' }).first()
await expect(previousButton).toBeVisible()
await expect(nextButton).toBeVisible()
await expect(previousButton).toBeDisabled()
if (totalPages > 1) {
await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 1`))).toBeVisible()
await expect(nextButton).toBeEnabled()
await nextButton.click()
await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 2`))).toBeVisible()
} else {
await expect(nextButton).toBeDisabled()
}
}
})
})

View file

@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { createElement } from 'react'
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ slug: 'test-ns' }),
@ -19,10 +21,6 @@ vi.mock('@/shared/lib/date-time', () => ({
formatLocalDateTime: (v: string) => v,
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
}))
@ -42,12 +40,22 @@ vi.mock('@/shared/ui/tabs', () => ({
TabsTrigger: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useNamespaceDetail: () => ({ data: null, isLoading: false }),
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
vi.mock('@/shared/components/pagination', () => ({
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
paginationProps.push(props)
return null
},
}))
const useNamespaceDetailMock = vi.fn()
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useNamespaceDetail: (...args: unknown[]) => useNamespaceDetailMock(...args),
}))
const useReviewListMock = vi.fn()
vi.mock('@/features/review/use-review-list', () => ({
useReviewList: () => ({ data: null, isLoading: false }),
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -61,7 +69,89 @@ vi.mock('@/features/namespace/namespace-header', () => ({
import { NamespaceReviewsPage } from './namespace-reviews'
describe('NamespaceReviewsPage', () => {
function createReviewItem(id: number) {
return {
id,
namespace: 'demo-ns',
skillSlug: `skill-${id}`,
version: '1.0.0',
submittedBy: 'user-1',
submittedByName: 'User 1',
submittedAt: '2026-04-01T12:00:00Z',
reviewedBy: null,
reviewedByName: null,
reviewedAt: null,
reviewComment: null,
}
}
beforeEach(() => {
paginationProps.length = 0
useNamespaceDetailMock.mockReset()
useReviewListMock.mockReset()
useNamespaceDetailMock.mockReturnValue({
data: {
id: 100,
slug: 'test-ns',
displayName: 'Test Namespace',
type: 'CUSTOM',
status: 'ACTIVE',
},
isLoading: false,
})
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled || status !== 'PENDING') {
return { data: null, isLoading: false }
}
return {
data: {
items: [createReviewItem(1)],
totalElements: 11,
totalPages: 2,
page,
size: 10,
total: 11,
},
isLoading: false,
}
})
})
it('exports a named component function', () => {
expect(typeof NamespaceReviewsPage).toBe('function')
})
it('renders pagination for namespace review list when totalPages > 1', () => {
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
expect(html).toContain('nsReviews.pageSummary')
expect(paginationProps).toHaveLength(1)
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(2)
})
it('does not render pagination when there is only one page', () => {
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled || status !== 'PENDING') {
return { data: null, isLoading: false }
}
return {
data: {
items: [createReviewItem(2)],
totalElements: 1,
totalPages: 1,
page,
size: 10,
total: 1,
},
isLoading: false,
}
})
renderToStaticMarkup(createElement(NamespaceReviewsPage))
expect(paginationProps).toHaveLength(0)
})
})

View file

@ -2,13 +2,13 @@ import { useState } from 'react'
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
import { useReviewList } from '@/features/review/use-review-list'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
@ -51,26 +51,7 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
return (
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<p>{t('nsReviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage === 0}
onClick={() => changePage(status, currentPage - 1)}
>
{t('nsReviews.prevPage')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages - 1}
onClick={() => changePage(status, currentPage + 1)}
>
{t('nsReviews.nextPage')}
</Button>
</div>
<Pagination page={currentPage} totalPages={totalPages} onPageChange={(nextPage) => changePage(status, nextPage)} />
</div>
)
}

View file

@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { createElement } from 'react'
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
@ -19,10 +21,6 @@ vi.mock('react-i18next', async () => {
}
})
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
@ -55,12 +53,22 @@ vi.mock('@/shared/ui/table', () => ({
TableRow: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/features/review/use-review-list', () => ({
useReviewList: () => ({ data: null, isLoading: false }),
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
vi.mock('@/shared/components/pagination', () => ({
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
paginationProps.push(props)
return null
},
}))
const useReviewListMock = vi.fn()
vi.mock('@/features/review/use-review-list', () => ({
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
}))
const hasRoleMock = vi.fn()
vi.mock('@/features/auth/use-auth', () => ({
useAuth: () => ({ hasRole: () => false }),
useAuth: () => ({ hasRole: hasRoleMock }),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -78,7 +86,86 @@ vi.mock('./profile-review-table', () => ({
import { ReviewsPage } from './reviews'
describe('ReviewsPage', () => {
function createReviewItem(id: number) {
return {
id,
namespace: 'demo',
skillSlug: `skill-${id}`,
version: '1.0.0',
submittedBy: 'user-1',
submittedByName: 'User 1',
submittedAt: '2026-04-01T12:00:00Z',
reviewedBy: null,
reviewedByName: null,
reviewedAt: null,
reviewComment: null,
}
}
beforeEach(() => {
paginationProps.length = 0
hasRoleMock.mockReset()
useReviewListMock.mockReset()
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN')
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled) {
return { data: null, isLoading: false }
}
if (status === 'PENDING') {
return {
data: {
items: [createReviewItem(1)],
totalElements: 21,
totalPages: 2,
page,
size: 20,
total: 21,
},
isLoading: false,
}
}
return { data: null, isLoading: false }
})
})
it('exports a named component function', () => {
expect(typeof ReviewsPage).toBe('function')
})
it('renders pagination for pending reviews when totalPages > 1', () => {
const html = renderToStaticMarkup(createElement(ReviewsPage))
expect(html).toContain('reviews.pageSummary')
expect(paginationProps).toHaveLength(1)
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(2)
})
it('renders disabled-style pagination when there is only one page', () => {
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled || status !== 'PENDING') {
return { data: null, isLoading: false }
}
return {
data: {
items: [createReviewItem(2)],
totalElements: 1,
totalPages: 1,
page,
size: 20,
total: 1,
},
isLoading: false,
}
})
renderToStaticMarkup(createElement(ReviewsPage))
expect(paginationProps).toHaveLength(1)
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(1)
})
})

View file

@ -2,7 +2,6 @@ import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { FileCheck2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
@ -17,6 +16,7 @@ import {
import { useReviewList } from '@/features/review/use-review-list'
import { useAuth } from '@/features/auth/use-auth'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { ProfileReviewTable } from './profile-review-table'
@ -72,31 +72,11 @@ export function ReviewsPage() {
}
function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) {
if (totalPages <= 1) return null
const currentPage = pages[status]
return (
<div className="flex flex-col gap-3 border-t border-border/60 px-6 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<p>{t('reviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage === 0}
onClick={() => changePage(status, currentPage - 1)}
>
{t('reviews.prevPage')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages - 1}
onClick={() => changePage(status, currentPage + 1)}
>
{t('reviews.nextPage')}
</Button>
</div>
<Pagination page={currentPage} totalPages={Math.max(totalPages, 1)} onPageChange={(nextPage) => changePage(status, nextPage)} />
</div>
)
}