fix(frontend): bound namespace selection

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-27 16:37:10 +08:00
parent 8f6941e31e
commit 2bd358049b
18 changed files with 167 additions and 164 deletions

View file

@ -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}`)

View file

@ -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')
})
})

View file

@ -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'

View file

@ -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

View file

@ -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))

View file

@ -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"
/>
<Select
value={namespaceFilter || ALL_NAMESPACES_VALUE}
onValueChange={(value) => {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
<SelectItem key={ns.id} value={ns.slug}>
@{ns.slug}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="sm:w-[14rem]">
<NamespacePicker
value={namespaceFilter}
onValueChange={(value) => {
updateSearch({ namespace: value || undefined, page: 0 })
}}
emptyValueLabel={t('mySkills.namespaceFilterAll')}
/>
</div>
{hasActiveSearch ? (
<Button
type="button"

View file

@ -1,5 +1,9 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
const useMyNamespacesPageMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ slug: 'test-ns' }),
}))
@ -52,7 +56,7 @@ vi.mock('@/shared/ui/select', () => ({
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => ({ data: [] }),
useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args),
useNamespaceDetail: () => ({ data: null, isLoading: false }),
useNamespaceMembers: () => ({ data: [], isLoading: false, error: null }),
useRemoveNamespaceMember: () => ({ mutateAsync: vi.fn() }),
@ -69,4 +73,14 @@ describe('NamespaceMembersPage', () => {
it('exports a named component function', () => {
expect(typeof NamespaceMembersPage).toBe('function')
})
it('loads only the current namespace membership entry', () => {
useMyNamespacesPageMock.mockReturnValue({
data: { items: [], total: 0, page: 0, size: 1 },
})
renderToStaticMarkup(createElement(NamespaceMembersPage))
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, slug: 'test-ns' }, true)
})
})

View file

@ -19,7 +19,7 @@ import {
SelectValue,
} from '@/shared/ui/select'
import {
useMyNamespaces,
useMyNamespacesPage,
useNamespaceDetail,
useNamespaceMembers,
useRemoveNamespaceMember,
@ -50,7 +50,7 @@ export function NamespaceMembersPage() {
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
const { data: membersPage, isLoading: isLoadingMembers, error: membersError } = useNamespaceMembers(slug, page, MEMBER_PAGE_SIZE)
const { data: myNamespaces } = useMyNamespaces()
const { data: myNamespacesPage } = useMyNamespacesPage({ page: 0, size: 1, slug }, !!slug)
const updateRoleMutation = useUpdateNamespaceMemberRole()
const removeMemberMutation = useRemoveNamespaceMember()
@ -58,7 +58,7 @@ export function NamespaceMembersPage() {
const totalMembers = membersPage?.total ?? 0
const totalPages = Math.max(1, Math.ceil(totalMembers / MEMBER_PAGE_SIZE))
const currentNamespace = myNamespaces?.find((item) => item.slug === slug)
const currentNamespace = myNamespacesPage?.items.find((item) => item.slug === slug)
const currentUserRole = currentNamespace?.currentUserRole
const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE'
// Membership changes are only allowed in active team namespaces and only for

View file

@ -1,9 +1,10 @@
import { createElement } from 'react'
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const useSearchMock = vi.fn()
const selectRecords: Array<{ value?: string }> = []
const useMyNamespacesPageMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
@ -25,7 +26,7 @@ vi.mock('@/features/publish/upload-zone', () => ({
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
Button: ({ children, ...props }: { children: ReactNode }) => createElement('button', props, children),
}))
vi.mock('@/shared/ui/select', () => ({
@ -53,7 +54,7 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => ({ data: [], isLoading: false }),
useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -75,6 +76,13 @@ import { PublishPage } from './publish'
describe('PublishPage', () => {
beforeEach(() => {
selectRecords.length = 0
useMyNamespacesPageMock.mockReset()
useMyNamespacesPageMock.mockReturnValue({
data: { items: [], total: 0, page: 0, size: 20 },
isLoading: false,
error: null,
refetch: vi.fn(),
})
useSearchMock.mockReturnValue({
namespace: ' team-ai ',
visibility: 'private',
@ -84,8 +92,9 @@ describe('PublishPage', () => {
it('prefills namespace and visibility from route search params', () => {
renderToStaticMarkup(createElement(PublishPage))
expect(selectRecords[0]?.value).toBe('team-ai')
expect(selectRecords[1]?.value).toBe('PRIVATE')
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, slug: 'team-ai' }, true)
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 20, status: 'ACTIVE' }, false)
expect(selectRecords[0]?.value).toBe('PRIVATE')
})
it('falls back to public visibility when search params are missing', () => {
@ -93,8 +102,8 @@ describe('PublishPage', () => {
renderToStaticMarkup(createElement(PublishPage))
expect(selectRecords[0]?.value).toBe('__select_namespace__')
expect(selectRecords[1]?.value).toBe('PUBLIC')
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1 }, false)
expect(selectRecords[0]?.value).toBe('PUBLIC')
})
it('exports a named component function', () => {

View file

@ -17,19 +17,17 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
normalizeSelectValue,
} from '@/shared/ui/select'
import { Label } from '@/shared/ui/label'
import { Card } from '@/shared/ui/card'
import { usePublishSkill } from '@/shared/hooks/use-skill-queries'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { NamespacePicker } from '@/shared/components/namespace-picker'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
const EMPTY_NAMESPACE_VALUE = '__select_namespace__'
export function PublishPage() {
const { t } = useTranslation()
const navigate = useNavigate()
@ -41,9 +39,13 @@ export function PublishPage() {
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
const { data: selectedNamespacePage } = useMyNamespacesPage({
page: 0,
size: 1,
...(namespaceSlug ? { slug: namespaceSlug } : {}),
}, !!namespaceSlug)
const publishMutation = usePublishSkill()
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
const selectedNamespace = selectedNamespacePage?.items.find((ns) => ns.slug === namespaceSlug)
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
? t('publish.visibilityOptions.loggedInUsersOnly')
: t('publish.visibilityOptions.namespaceOnly')
@ -155,29 +157,13 @@ export function PublishPage() {
<Card className="p-8 space-y-8">
<div className="space-y-3">
<Label htmlFor="namespace" className="text-sm font-semibold font-heading">{t('publish.namespace')}</Label>
{isLoadingNamespaces ? (
<div className="h-11 animate-shimmer rounded-lg" />
) : (
<Select
value={normalizeSelectValue(namespaceSlug) ?? EMPTY_NAMESPACE_VALUE}
onValueChange={(value) => {
setNamespaceSlug(value === EMPTY_NAMESPACE_VALUE ? '' : value)
}}
>
<SelectTrigger id="namespace">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_NAMESPACE_VALUE}>{t('publish.selectNamespace')}</SelectItem>
{namespaces?.map((ns) => (
<SelectItem key={ns.id} value={ns.slug}>
{ns.displayName} (@{ns.slug})
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Label className="text-sm font-semibold font-heading">{t('publish.namespace')}</Label>
<NamespacePicker
value={namespaceSlug}
onValueChange={setNamespaceSlug}
status="ACTIVE"
disabled={publishMutation.isPending}
/>
</div>
<div className="space-y-3">

View file

@ -74,9 +74,9 @@ vi.mock('@/features/auth/use-auth', () => ({
useAuth: () => ({ hasRole: hasRoleMock, user: userMock }),
}))
const useMyNamespacesMock = vi.fn()
const useMyNamespacesPageMock = vi.fn()
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: (enabled?: boolean) => useMyNamespacesMock(enabled),
useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -114,11 +114,11 @@ describe('ReviewsPage', () => {
paginationProps.length = 0
hasRoleMock.mockReset()
useReviewListMock.mockReset()
useMyNamespacesMock.mockReset()
useMyNamespacesPageMock.mockReset()
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN')
userMock.platformRoles = ['SKILL_ADMIN']
useMyNamespacesMock.mockReturnValue({
data: [],
useMyNamespacesPageMock.mockReturnValue({
data: { items: [], total: 0, page: 0, size: 1 },
isLoading: false,
})
useSearchMock.mockReturnValue({})
@ -201,6 +201,10 @@ describe('ReviewsPage', () => {
renderToStaticMarkup(createElement(ReviewsPage))
expect(useMyNamespacesMock).toHaveBeenCalledWith(false)
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({
page: 0,
size: 1,
roles: ['OWNER', 'ADMIN'],
}, false)
})
})

View file

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { FileCheck2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
@ -51,8 +51,12 @@ export function ReviewsPage() {
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles)
const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces(!hasGlobalReviewAccess)
const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces)
const { data: myNamespacesPage, isLoading: isLoadingNamespaces } = useMyNamespacesPage({
page: 0,
size: 1,
roles: ['OWNER', 'ADMIN'],
}, !hasGlobalReviewAccess)
const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespacesPage?.items)
const showTypeTabs = isSkillAdmin && isUserAdmin
// Determine default top-level tab

View file

@ -93,4 +93,21 @@ describe('NamespacePicker', () => {
expect(onValueChange).toHaveBeenCalledWith('next-team')
expect(screen.queryByRole('dialog')).toBeNull()
})
it('supports clearing an optional namespace filter', () => {
const onValueChange = vi.fn()
render(
<NamespacePicker
value="active-team"
onValueChange={onValueChange}
emptyValueLabel="All namespaces"
/>,
)
fireEvent.click(screen.getByRole('button', { name: '@active-team' }))
fireEvent.click(screen.getByRole('button', { name: 'All namespaces' }))
expect(onValueChange).toHaveBeenCalledWith('')
expect(screen.queryByRole('dialog')).toBeNull()
})
})

View file

@ -20,12 +20,19 @@ interface NamespacePickerProps {
onValueChange: (slug: string) => void
status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED'
disabled?: boolean
emptyValueLabel?: string
}
/**
* Server-paged namespace selector that keeps request and render size bounded.
*/
export function NamespacePicker({ value, onValueChange, status, disabled = false }: NamespacePickerProps) {
export function NamespacePicker({
value,
onValueChange,
status,
disabled = false,
emptyValueLabel,
}: NamespacePickerProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [page, setPage] = useState(0)
@ -70,6 +77,15 @@ export function NamespacePicker({ value, onValueChange, status, disabled = false
/>
<div className="min-h-44 space-y-2">
{emptyValueLabel ? (
<button
type="button"
onClick={() => selectNamespace('')}
className="flex w-full items-center rounded-lg border border-border px-4 py-3 text-left text-sm font-medium hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{emptyValueLabel}
</button>
) : null}
{query.isLoading ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('namespacePicker.loading')}</p>
) : query.error ? (

View file

@ -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<typeof import('react')>('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)
})
})

View file

@ -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

View file

@ -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' })
})
})

View file

@ -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<ManagedNamespace[]> {
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<typeof useQueryClien
queryClient.invalidateQueries({ queryKey: ['reviews'] })
}
export function useMyNamespaces(enabled = true) {
return useQuery({
queryKey: ['namespaces', 'my'],
queryFn: getMyNamespaces,
enabled,
})
}
export function useMyNamespacesPage(params: MyNamespacePageParams = {}, enabled = true) {
const normalizedParams = normalizeMyNamespacePageParams(params)
return useQuery({