feat(frontend): add paged namespace picker

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-27 16:27:28 +08:00
parent 18c9370318
commit 8f6941e31e
4 changed files with 250 additions and 0 deletions

View file

@ -403,6 +403,20 @@
},
"publishSkill": "Publish Skill"
},
"namespacePicker": {
"placeholder": "Select namespace",
"title": "Select namespace",
"description": "Search or browse namespaces without loading the entire registry.",
"search": "Search namespaces",
"searchPlaceholder": "Search by slug or display name",
"loading": "Loading namespaces...",
"empty": "No namespaces found",
"error": "Failed to load namespaces",
"retry": "Retry",
"previous": "Previous",
"next": "Next",
"page": "Page {{current}} of {{total}}"
},
"myNamespaces": {
"title": "My Namespaces",
"subtitle": "Manage your namespaces and teams",

View file

@ -403,6 +403,20 @@
},
"publishSkill": "发布技能"
},
"namespacePicker": {
"placeholder": "选择命名空间",
"title": "选择命名空间",
"description": "通过搜索或分页浏览命名空间,无需加载全部数据。",
"search": "搜索命名空间",
"searchPlaceholder": "按标识或显示名称搜索",
"loading": "正在加载命名空间...",
"empty": "未找到命名空间",
"error": "命名空间加载失败",
"retry": "重试",
"previous": "上一页",
"next": "下一页",
"page": "第 {{current}} / {{total}} 页"
},
"myNamespaces": {
"title": "我的命名空间",
"subtitle": "管理你的命名空间和团队",

View file

@ -0,0 +1,96 @@
/** @vitest-environment jsdom */
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const useMyNamespacesPageMock = vi.hoisted(() => vi.fn())
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespacesPage: useMyNamespacesPageMock,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
import { NamespacePicker } from './namespace-picker'
const firstPage = {
items: [
{
id: 1,
slug: 'active-team',
displayName: 'Active Team',
status: 'ACTIVE',
type: 'TEAM',
immutable: false,
canFreeze: false,
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
},
],
total: 21,
page: 0,
size: 20,
}
describe('NamespacePicker', () => {
beforeEach(() => {
vi.useFakeTimers()
useMyNamespacesPageMock.mockImplementation((params: { page?: number }) => ({
data: params.page === 1
? { ...firstPage, items: [{ ...firstPage.items[0], id: 21, slug: 'next-team', displayName: 'Next Team' }], page: 1 }
: firstPage,
isLoading: false,
error: null,
refetch: vi.fn(),
}))
})
afterEach(() => {
cleanup()
vi.useRealTimers()
useMyNamespacesPageMock.mockReset()
})
it('debounces an active-only namespace search without loading all pages', () => {
render(<NamespacePicker value="" onValueChange={vi.fn()} status="ACTIVE" />)
expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({
page: 0,
size: 20,
status: 'ACTIVE',
}, false)
fireEvent.click(screen.getByRole('button', { name: 'namespacePicker.placeholder' }))
fireEvent.change(screen.getByRole('searchbox', { name: 'namespacePicker.search' }), {
target: { value: 'team ai' },
})
expect(useMyNamespacesPageMock).not.toHaveBeenCalledWith(expect.objectContaining({ q: 'team ai' }), true)
act(() => vi.advanceTimersByTime(300))
expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({
page: 0,
size: 20,
status: 'ACTIVE',
q: 'team ai',
}, true)
})
it('paginates bounded results and emits the selected slug', () => {
const onValueChange = vi.fn()
render(<NamespacePicker value="selected-outside-page" onValueChange={onValueChange} />)
expect(screen.getByRole('button', { name: '@selected-outside-page' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '@selected-outside-page' }))
fireEvent.click(screen.getByRole('button', { name: 'namespacePicker.next' }))
expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({ page: 1, size: 20 }, true)
fireEvent.click(screen.getByRole('button', { name: 'Next Team (@next-team)' }))
expect(onValueChange).toHaveBeenCalledWith('next-team')
expect(screen.queryByRole('dialog')).toBeNull()
})
})

View file

@ -0,0 +1,126 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { useDebounce } from '@/shared/hooks/use-debounce'
import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries'
const PAGE_SIZE = 20
interface NamespacePickerProps {
value: string
onValueChange: (slug: string) => void
status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED'
disabled?: boolean
}
/**
* Server-paged namespace selector that keeps request and render size bounded.
*/
export function NamespacePicker({ value, onValueChange, status, disabled = false }: NamespacePickerProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [page, setPage] = useState(0)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search.trim(), 300)
const query = useMyNamespacesPage({
page,
size: PAGE_SIZE,
...(status ? { status } : {}),
...(debouncedSearch ? { q: debouncedSearch } : {}),
}, open)
const totalPages = query.data ? Math.max(Math.ceil(query.data.total / query.data.size), 1) : 1
useEffect(() => {
setPage(0)
}, [debouncedSearch, status])
const selectNamespace = (slug: string) => {
onValueChange(slug)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" disabled={disabled} className="w-full justify-start">
{value ? `@${value}` : t('namespacePicker.placeholder')}
</Button>
</DialogTrigger>
<DialogContent aria-label={t('namespacePicker.title')}>
<DialogHeader>
<DialogTitle>{t('namespacePicker.title')}</DialogTitle>
<DialogDescription>{t('namespacePicker.description')}</DialogDescription>
</DialogHeader>
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
aria-label={t('namespacePicker.search')}
placeholder={t('namespacePicker.searchPlaceholder')}
/>
<div className="min-h-44 space-y-2">
{query.isLoading ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('namespacePicker.loading')}</p>
) : query.error ? (
<div className="space-y-3 py-8 text-center">
<p className="text-sm text-destructive">{t('namespacePicker.error')}</p>
<Button type="button" size="sm" variant="outline" onClick={() => query.refetch()}>
{t('namespacePicker.retry')}
</Button>
</div>
) : query.data?.items.length ? (
query.data.items.map((namespace) => (
<button
key={namespace.id}
type="button"
aria-label={`${namespace.displayName} (@${namespace.slug})`}
onClick={() => selectNamespace(namespace.slug)}
className="flex w-full items-center justify-between rounded-lg border border-border px-4 py-3 text-left text-sm hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="font-medium">{namespace.displayName}</span>
<span className="text-muted-foreground">@{namespace.slug}</span>
</button>
))
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t('namespacePicker.empty')}</p>
)}
</div>
<div className="flex items-center justify-between gap-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={page === 0}
onClick={() => setPage((current) => Math.max(current - 1, 0))}
>
{t('namespacePicker.previous')}
</Button>
<span className="text-xs text-muted-foreground">
{t('namespacePicker.page', { current: page + 1, total: totalPages })}
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={page + 1 >= totalPages}
onClick={() => setPage((current) => current + 1)}
>
{t('namespacePicker.next')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}