diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 7236f292..07166c0c 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -238,6 +238,36 @@ describe('namespaceApi.listMinePage', () => { size: 25, }) }) + + it('encodes namespace filters without issuing an unbounded request', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: { items: [], total: 0, page: 1, size: 20 }, + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-filtered', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + await namespaceApi.listMinePage({ + page: 1, + size: 20, + status: 'ACTIVE', + q: 'team ai', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&status=ACTIVE&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', + expect.objectContaining({ headers: expect.any(Headers) }), + ) + }) }) describe('getDirectAuthRuntimeConfig', () => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 463eb36b..6bdb6b91 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -48,6 +48,15 @@ import type { import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' +export interface MyNamespacePageParams { + page?: number + size?: number + status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' + q?: string + slug?: string + roles?: Array<'OWNER' | 'ADMIN' | 'MEMBER'> +} + /** * Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers. * @@ -643,10 +652,21 @@ export const namespaceApi = { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, - async listMinePage(params: { page?: number; size?: number } = {}): Promise> { + async listMinePage(params: MyNamespacePageParams = {}): Promise> { const page = params.page ?? 0 const size = params.size ?? 20 - return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?page=${page}&size=${size}`) + const query = new URLSearchParams({ page: String(page), size: String(size) }) + if (params.status) { + query.set('status', params.status) + } + if (params.q?.trim()) { + query.set('q', params.q.trim()) + } + if (params.slug?.trim()) { + query.set('slug', normalizeNamespaceSlug(params.slug)) + } + params.roles?.forEach((role) => query.append('roles', role)) + return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`) }, async getDetail(slug: string): Promise { diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 4b59e2b0..18a30fa8 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -160,7 +160,7 @@ export function MyNamespacesPage() { const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const [page, setPage] = useState(0) const [pendingAction, setPendingAction] = useState(null) - const { data: namespacePage, isLoading } = useMyNamespacesPage(page, PAGE_SIZE) + const { data: namespacePage, isLoading } = useMyNamespacesPage({ page, size: PAGE_SIZE }) const freezeMutation = useFreezeNamespace() const unfreezeMutation = useUnfreezeNamespace() const archiveMutation = useArchiveNamespace() diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index 42808b0b..2e842f24 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -59,14 +59,42 @@ describe('use-namespace-queries exports', () => { })) }) - it('passes page and size to the paged my namespaces query', async () => { + it('passes bounded filters to a single paged my namespaces query', async () => { const mod = await import('./use-namespace-queries') - mod.useMyNamespacesPage(3, 15) + mod.useMyNamespacesPage({ + page: 3, + size: 15, + status: 'ACTIVE', + q: 'team', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }) expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining({ - queryKey: ['namespaces', 'my', { page: 3, size: 15 }], + queryKey: ['namespaces', 'my', { + page: 3, + size: 15, + status: 'ACTIVE', + q: 'team', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }], })) + const queryOptions = useQueryMock.mock.calls[useQueryMock.mock.calls.length - 1]?.[0] + listMinePageMock.mockResolvedValue({ items: [], total: 101, page: 3, size: 15 }) + + await queryOptions.queryFn() + + expect(listMinePageMock).toHaveBeenCalledTimes(1) + expect(listMinePageMock).toHaveBeenCalledWith({ + page: 3, + size: 15, + status: 'ACTIVE', + q: 'team', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }) }) it('fetches every page for compatibility consumers instead of truncating after the first page', async () => { diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index 26ca5e8f..193f513c 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole, BatchMemberResponse, PagedResponse } from '@/api/types' -import { namespaceApi } from '@/api/client' +import { namespaceApi, type MyNamespacePageParams } from '@/api/client' import { replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache' import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers' @@ -25,8 +25,21 @@ async function getMyNamespaces(): Promise { return namespaces } -async function getMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE): Promise> { - return namespaceApi.listMinePage({ page, size }) +function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyNamespacePageParams { + const q = params.q?.trim() + const slug = params.slug?.trim() + return { + page: params.page ?? 0, + size: params.size ?? MY_NAMESPACES_PAGE_SIZE, + ...(params.status ? { status: params.status } : {}), + ...(q ? { q } : {}), + ...(slug ? { slug } : {}), + ...(params.roles?.length ? { roles: [...params.roles] } : {}), + } +} + +async function getMyNamespacesPage(params: MyNamespacePageParams): Promise> { + return namespaceApi.listMinePage(params) } async function createNamespace(request: CreateNamespaceRequest): Promise { @@ -80,10 +93,11 @@ export function useMyNamespaces(enabled = true) { }) } -export function useMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE, enabled = true) { +export function useMyNamespacesPage(params: MyNamespacePageParams = {}, enabled = true) { + const normalizedParams = normalizeMyNamespacePageParams(params) return useQuery({ - queryKey: ['namespaces', 'my', { page, size }], - queryFn: () => getMyNamespacesPage(page, size), + queryKey: ['namespaces', 'my', normalizedParams], + queryFn: () => getMyNamespacesPage(normalizedParams), enabled, }) }