mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
fix(frontend): bound namespace selection
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
8f6941e31e
commit
2bd358049b
18 changed files with 167 additions and 164 deletions
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue