Merge pull request #354 from iflytek/fix/namespace-skill-pagination

fix(namespace): add pagination to namespace skill list
This commit is contained in:
dongmucat 2026-04-27 16:06:19 +08:00 • committed by GitHub
commit c71e4c197a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 134 additions and 11 deletions

View file

@ -0,0 +1,104 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
test.describe('Namespace Skill List Pagination (Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
})
test('shows namespace page with skills and no pagination when under 20 skills', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.ensureWritableNamespace()
await builder.publishSkill(namespace.slug)
await page.goto(`/space/${namespace.slug}`)
await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible()
await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible()
// Verify pagination controls do not appear when there are fewer than 20 skills
await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0)
} finally {
await builder.cleanup()
}
})
test('shows pagination controls when there are more than 20 skills', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.ensureWritableNamespace()
// Build a fake skill list to simulate >20 skills without hitting rate limits.
const fakeSkill = (i: number) => ({
id: 90000 + i,
namespace: namespace.slug,
slug: `fake-skill-${i}`,
displayName: `Fake Skill ${i}`,
summary: `Pagination test skill ${i}`,
downloadCount: 0,
starCount: 0,
ratingAvg: 0,
ratingCount: 0,
versions: [{ id: 90000 + i, version: '1.0.0', status: 'PUBLISHED' }],
})
// Intercept the search API with a regex (glob '?' is ambiguous for literal '?').
// Return fully mocked responses to avoid needing real published skills.
await page.route(/\/api\/web\/skills\?/, async (route) => {
const url = new URL(route.request().url())
const reqPage = Number(url.searchParams.get('page') ?? '0')
const items = reqPage === 0
? Array.from({ length: 20 }, (_, i) => fakeSkill(i))
: [fakeSkill(20)]
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: { items, total: 21, page: reqPage, size: 20 },
}),
})
})
await page.goto(`/space/${namespace.slug}`)
await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible()
await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible()
// Verify pagination controls appear
const previousButton = page.getByRole('button', { name: 'Previous' }).first()
const nextButton = page.getByRole('button', { name: 'Next' }).first()
await expect(previousButton).toBeVisible()
await expect(nextButton).toBeVisible()
// First page: Previous should be disabled, Next should be enabled
await expect(previousButton).toBeDisabled()
await expect(nextButton).toBeEnabled()
// Navigate to second page
await nextButton.click()
// Second page: Previous should be enabled
await expect(previousButton).toBeEnabled()
// Navigate back to first page
await previousButton.click()
await expect(previousButton).toBeDisabled()
await expect(nextButton).toBeEnabled()
} finally {
await builder.cleanup()
}
})
})

View file

@ -1,12 +1,16 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { SkillCard } from '@/features/skill/skill-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
import { EmptyState } from '@/shared/components/empty-state'
import { Pagination } from '@/shared/components/pagination'
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
const PAGE_SIZE = 20
/**
* Public namespace page showing namespace metadata and the skills currently discoverable inside it.
*/
@ -14,13 +18,22 @@ export function NamespacePage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { namespace } = useParams({ from: '/space/$namespace' })
const [page, setPage] = useState(0)
// Reset page when namespace changes
useEffect(() => {
setPage(0)
}, [namespace])
const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace)
const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({
namespace,
size: 20,
page,
size: PAGE_SIZE,
})
const totalPages = skillsData ? Math.max(Math.ceil(skillsData.total / skillsData.size), 1) : 1
const handleSkillClick = (slug: string) => {
navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` })
}
@ -47,16 +60,22 @@ export function NamespacePage() {
{isLoadingSkills ? (
<SkeletonList count={6} />
) : skillsData && skillsData.items.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{skillsData.items.map((skill, idx) => (
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
<SkillCard
skill={skill}
onClick={() => handleSkillClick(skill.slug)}
/>
</div>
))}
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{skillsData.items.map((skill, idx) => (
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
<SkillCard
skill={skill}
onClick={() => handleSkillClick(skill.slug)}
/>
</div>
))}
</div>
{skillsData.total > PAGE_SIZE ? (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
) : null}
</>
) : (
<EmptyState
title={t('namespace.emptyTitle')}