From 8f6941e31e6de33c9918abc1ea3fc5d47c09695b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 16:27:28 +0800 Subject: [PATCH] feat(frontend): add paged namespace picker Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/i18n/locales/en.json | 14 ++ web/src/i18n/locales/zh.json | 14 ++ .../components/namespace-picker.test.tsx | 96 +++++++++++++ .../shared/components/namespace-picker.tsx | 126 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 web/src/shared/components/namespace-picker.test.tsx create mode 100644 web/src/shared/components/namespace-picker.tsx diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 23ff842f..c5123d06 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 8a743d7d..31b36c2b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -403,6 +403,20 @@ }, "publishSkill": "发布技能" }, + "namespacePicker": { + "placeholder": "选择命名空间", + "title": "选择命名空间", + "description": "通过搜索或分页浏览命名空间,无需加载全部数据。", + "search": "搜索命名空间", + "searchPlaceholder": "按标识或显示名称搜索", + "loading": "正在加载命名空间...", + "empty": "未找到命名空间", + "error": "命名空间加载失败", + "retry": "重试", + "previous": "上一页", + "next": "下一页", + "page": "第 {{current}} / {{total}} 页" + }, "myNamespaces": { "title": "我的命名空间", "subtitle": "管理你的命名空间和团队", diff --git a/web/src/shared/components/namespace-picker.test.tsx b/web/src/shared/components/namespace-picker.test.tsx new file mode 100644 index 00000000..f6ee3af7 --- /dev/null +++ b/web/src/shared/components/namespace-picker.test.tsx @@ -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() + + 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() + + 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() + }) +}) diff --git a/web/src/shared/components/namespace-picker.tsx b/web/src/shared/components/namespace-picker.tsx new file mode 100644 index 00000000..46a4a60a --- /dev/null +++ b/web/src/shared/components/namespace-picker.tsx @@ -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 ( + + + + + + + {t('namespacePicker.title')} + {t('namespacePicker.description')} + + + setSearch(event.target.value)} + aria-label={t('namespacePicker.search')} + placeholder={t('namespacePicker.searchPlaceholder')} + /> + +
+ {query.isLoading ? ( +

{t('namespacePicker.loading')}

+ ) : query.error ? ( +
+

{t('namespacePicker.error')}

+ +
+ ) : query.data?.items.length ? ( + query.data.items.map((namespace) => ( + + )) + ) : ( +

{t('namespacePicker.empty')}

+ )} +
+ +
+ + + {t('namespacePicker.page', { current: page + 1, total: totalPages })} + + +
+
+
+ ) +}