diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index c8286402..904be64a 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -23,6 +23,7 @@ test.describe('My Namespaces super admin actions (Real API)', () => { try { adminBuilder = new E2eTestDataBuilder(page, testInfo) await adminBuilder.init() + const activeNamespace = await adminBuilder.createNamespace('e2e-super-admin-publish-active') const namespace = await adminBuilder.createNamespace('e2e-super-admin-read') namespaceSlug = namespace.slug @@ -72,6 +73,22 @@ test.describe('My Namespaces super admin actions (Real API)', () => { expect(archiveResponse.ok()).toBe(true) namespaceArchived = true + await page.goto('/dashboard/publish') + await page.getByRole('button', { name: 'Select namespace', exact: true }).click() + await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(activeNamespace.slug) + const activeOption = page.getByRole('button', { + name: `${activeNamespace.displayName} (@${activeNamespace.slug})`, + }) + await expect(activeOption).toBeVisible() + await activeOption.click() + + await page.getByRole('button', { name: `@${activeNamespace.slug}`, exact: true }).click() + await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(namespace.slug) + await expect(page.getByText('No namespaces found')).toBeVisible() + await expect(page.getByRole('button', { + name: `${namespace.displayName} (@${namespace.slug})`, + })).toHaveCount(0) + await page.goto('/dashboard/namespaces') const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) diff --git a/web/src/features/namespace/use-my-namespaces.test.ts b/web/src/features/namespace/use-my-namespaces.test.ts deleted file mode 100644 index ee36c691..00000000 --- a/web/src/features/namespace/use-my-namespaces.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './use-my-namespaces' - -/** - * use-my-namespaces.ts is a feature-local re-export of the - * useMyNamespaces hook from the shared query layer. There is no custom - * logic, transformation, or query-key function to test. - * - * We verify the re-export contract so import paths used by namespace - * dashboard screens break fast if the module shape changes. - */ -describe('use-my-namespaces re-export', () => { - it('re-exports useMyNamespaces as a function', () => { - expect(mod.useMyNamespaces).toBeDefined() - expect(typeof mod.useMyNamespaces).toBe('function') - }) -}) diff --git a/web/src/features/namespace/use-my-namespaces.ts b/web/src/features/namespace/use-my-namespaces.ts deleted file mode 100644 index 6332cb17..00000000 --- a/web/src/features/namespace/use-my-namespaces.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Preserves a feature-local import path for dashboard namespace screens while - * the underlying query implementation still lives in the shared hook module. - */ -export { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index e57790a9..8277cd1b 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -82,7 +82,6 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useArchiveNamespace: () => ({ mutateAsync: archiveMutateAsync }), useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }), useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }), - useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }), useMyNamespacesPage: () => ({ data: mockNamespacePage.total > 0 || mockNamespacePage.items.length > 0 ? mockNamespacePage diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index c82faf93..92fa70dc 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const navigateMock = vi.fn() const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = [] const useMySkillsMock = vi.fn() +const useMyNamespacesPageMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -72,7 +73,7 @@ vi.mock('@/shared/hooks/use-user-queries', () => ({ })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useMyNamespaces: () => ({ data: [] }), + useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args), })) vi.mock('@/shared/hooks/use-debounce', () => ({ @@ -114,6 +115,13 @@ describe('MySkillsPage', () => { beforeEach(() => { navigateMock.mockReset() buttonRecords.length = 0 + useMyNamespacesPageMock.mockReset() + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [], total: 0, page: 0, size: 20 }, + isLoading: false, + error: null, + refetch: vi.fn(), + }) useMySkillsMock.mockReturnValue({ data: { items: [ @@ -137,6 +145,12 @@ describe('MySkillsPage', () => { }) }) + it('loads only the first namespace picker page while the picker is closed', () => { + renderToStaticMarkup(createElement(MySkillsPage)) + + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 20 }, false) + }) + it('navigates to publish page with namespace and visibility when update is clicked', () => { renderToStaticMarkup(createElement(MySkillsPage)) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 4dbe4765..0c70eac7 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -5,13 +5,12 @@ import { useAuth } from '@/features/auth/use-auth' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { NamespacePicker } from '@/shared/components/namespace-picker' import { Pagination } from '@/shared/components/pagination' import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' -import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries' import { useDebounce } from '@/shared/hooks/use-debounce' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' @@ -22,8 +21,6 @@ import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' const PAGE_SIZE = 10 -const ALL_NAMESPACES_VALUE = '__all_namespaces__' - /** * Dashboard page for skills owned by the current user. * @@ -91,8 +88,6 @@ export function MySkillsPage() { q: keyword || undefined, namespace: namespaceFilter || undefined, }) - const { data: namespaceOptions } = useMyNamespaces() - const skills = skillPage?.items ?? [] const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1 const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN')) @@ -302,24 +297,15 @@ export function MySkillsPage() { aria-label={t('mySkills.searchPlaceholder')} className="sm:max-w-md" /> - +
+ { + updateSearch({ namespace: value || undefined, page: 0 }) + }} + emptyValueLabel={t('mySkills.namespaceFilterAll')} + /> +
{hasActiveSearch ? ( + ) : null} {query.isLoading ? (

{t('namespacePicker.loading')}

) : query.error ? ( diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx index e037dbc2..1474f0bf 100644 --- a/web/src/shared/components/user-menu.test.tsx +++ b/web/src/shared/components/user-menu.test.tsx @@ -5,7 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as mod from './user-menu' import { UserMenu } from './user-menu' -const useMyNamespacesMock = vi.hoisted(() => vi.fn(() => ({ data: [] as ManagedNamespace[] }))) +const useMyNamespacesPageMock = vi.hoisted(() => vi.fn(() => ({ + data: { items: [] as ManagedNamespace[], total: 0, page: 0, size: 1 }, +}))) vi.mock('react', async () => { const actual = await vi.importActual('react') @@ -66,7 +68,7 @@ vi.mock('@/api/client', () => ({ })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useMyNamespaces: useMyNamespacesMock, + useMyNamespacesPage: useMyNamespacesPageMock, })) /** @@ -81,7 +83,7 @@ describe('user-menu module exports', () => { describe('UserMenu security settings visibility', () => { beforeEach(() => { - useMyNamespacesMock.mockClear() + useMyNamespacesPageMock.mockClear() }) it('shows security settings when password changes are allowed, independent of OAuth provider', () => { @@ -114,8 +116,8 @@ describe('UserMenu security settings visibility', () => { }) it('shows reviews for namespace admins without platform review roles', () => { - useMyNamespacesMock.mockReturnValue({ - data: [ + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [ { id: 10, slug: 'team-admin', @@ -131,7 +133,7 @@ describe('UserMenu security settings visibility', () => { currentUserRole: 'ADMIN', createdAt: '', }, - ], + ], total: 1, page: 0, size: 1 }, }) const html = renderToStaticMarkup( @@ -143,7 +145,11 @@ describe('UserMenu security settings visibility', () => { />, ) - expect(useMyNamespacesMock).toHaveBeenCalledWith(true) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, true) expect(html).toContain('user.menu.reviews') }) @@ -157,6 +163,10 @@ describe('UserMenu security settings visibility', () => { />, ) - expect(useMyNamespacesMock).toHaveBeenCalledWith(false) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, false) }) }) diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 69d2800d..a4b84ceb 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' -import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' import { buildGlobalReviewsPath, canAccessGlobalReviewCenter, canAccessReviewCenter } from '@/features/review/review-paths' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' @@ -37,8 +37,12 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user.platformRoles) - const { data: myNamespaces } = useMyNamespaces(!hasGlobalReviewAccess) - const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces) + const { data: myNamespacesPage } = useMyNamespacesPage({ + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, !hasGlobalReviewAccess) + const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespacesPage?.items) const canChangePassword = user.canChangePassword === true const open = isHovered || isClickOpen diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index 2e842f24..bd5f7d93 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -33,7 +33,6 @@ describe('use-namespace-queries exports', () => { it('exports all expected hook functions', async () => { const mod = await import('./use-namespace-queries') - expect(typeof mod.useMyNamespaces).toBe('function') expect(typeof mod.useMyNamespacesPage).toBe('function') expect(typeof mod.useCreateNamespace).toBe('function') expect(typeof mod.useNamespaceDetail).toBe('function') @@ -48,17 +47,6 @@ describe('use-namespace-queries exports', () => { expect(typeof mod.useRestoreNamespace).toBe('function') }) - it('passes the enabled flag to the my namespaces query', async () => { - const mod = await import('./use-namespace-queries') - - mod.useMyNamespaces(false) - - expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining({ - queryKey: ['namespaces', 'my'], - enabled: false, - })) - }) - it('passes bounded filters to a single paged my namespaces query', async () => { const mod = await import('./use-namespace-queries') @@ -97,20 +85,4 @@ describe('use-namespace-queries exports', () => { }) }) - it('fetches every page for compatibility consumers instead of truncating after the first page', async () => { - const firstPageItems = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, slug: `team-${index + 1}` })) - listMinePageMock - .mockResolvedValueOnce({ items: firstPageItems, total: 101, page: 0, size: 100 }) - .mockResolvedValueOnce({ items: [{ id: 101, slug: 'team-101' }], total: 101, page: 1, size: 100 }) - const mod = await import('./use-namespace-queries') - - mod.useMyNamespaces() - const queryOptions = useQueryMock.mock.calls[useQueryMock.mock.calls.length - 1]?.[0] - const result = await queryOptions.queryFn() - - expect(listMinePageMock).toHaveBeenNthCalledWith(1, { page: 0, size: 100 }) - expect(listMinePageMock).toHaveBeenNthCalledWith(2, { page: 1, size: 100 }) - expect(result).toHaveLength(101) - expect(result[result.length - 1]).toEqual({ id: 101, slug: 'team-101' }) - }) }) diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index 193f513c..4bdfa135 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -5,25 +5,6 @@ import { replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache' import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers' const MY_NAMESPACES_PAGE_SIZE = 20 -const MY_NAMESPACES_COMPAT_SIZE = 100 - -async function getMyNamespaces(): Promise { - const namespaces: ManagedNamespace[] = [] - let page = 0 - let total = Number.POSITIVE_INFINITY - - while (namespaces.length < total) { - const response = await namespaceApi.listMinePage({ page, size: MY_NAMESPACES_COMPAT_SIZE }) - namespaces.push(...response.items) - total = response.total - page += 1 - if (response.items.length === 0) { - break - } - } - - return namespaces -} function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyNamespacePageParams { const q = params.q?.trim() @@ -85,14 +66,6 @@ function invalidateNamespaceQueries(queryClient: ReturnType