fix(namespace): add pagination to namespace skill list

The namespace detail page hardcoded a limit of 20 skills with no
pagination controls, so namespaces with more than 20 skills appeared
truncated. Add page state, wire it into the search query, and render
the shared Pagination component when total exceeds PAGE_SIZE. Also
reset page to 0 when the namespace param changes.

Closes #350
This commit is contained in:
dongmucat 2026-04-27 10:54:57 +08:00
parent 0cc3c46831
commit 0020989ccb
2 changed files with 108 additions and 11 deletions

View file

@ -0,0 +1,78 @@
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()
// Publish 21 skills to trigger pagination (PAGE_SIZE = 20)
for (let i = 0; i < 21; i += 1) {
await builder.publishSkill(namespace.slug, {
name: `e2e-skill-${i}`,
description: `Test skill ${i} for pagination`,
})
}
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: both buttons should be enabled (or Previous enabled, Next disabled if only 2 pages)
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')}