mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
feat(frontend): add paged namespace picker
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
18c9370318
commit
8f6941e31e
4 changed files with 250 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -403,6 +403,20 @@
|
|||
},
|
||||
"publishSkill": "发布技能"
|
||||
},
|
||||
"namespacePicker": {
|
||||
"placeholder": "选择命名空间",
|
||||
"title": "选择命名空间",
|
||||
"description": "通过搜索或分页浏览命名空间,无需加载全部数据。",
|
||||
"search": "搜索命名空间",
|
||||
"searchPlaceholder": "按标识或显示名称搜索",
|
||||
"loading": "正在加载命名空间...",
|
||||
"empty": "未找到命名空间",
|
||||
"error": "命名空间加载失败",
|
||||
"retry": "重试",
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"page": "第 {{current}} / {{total}} 页"
|
||||
},
|
||||
"myNamespaces": {
|
||||
"title": "我的命名空间",
|
||||
"subtitle": "管理你的命名空间和团队",
|
||||
|
|
|
|||
96
web/src/shared/components/namespace-picker.test.tsx
Normal file
96
web/src/shared/components/namespace-picker.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
126
web/src/shared/components/namespace-picker.tsx
Normal file
126
web/src/shared/components/namespace-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue