mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(reviews): add dashboard review pagination and tests (#241)
This commit is contained in:
parent
8cb783406d
commit
c2981836bb
6 changed files with 286 additions and 60 deletions
|
|
@ -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>(
|
||||
|
|
|
|||
83
web/e2e/reviews-pagination.spec.ts
Normal file
83
web/e2e/reviews-pagination.spec.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue