diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 8b352054..2e64eaf7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -81,6 +82,7 @@ public class NamespaceController extends BaseApiController { public ApiResponse> listMyNamespacesPage( Pageable pageable, @RequestParam(required = false) NamespaceStatus status, + @RequestParam(required = false) NamespaceType type, @RequestParam(required = false) String q, @RequestParam(required = false) String slug, @RequestParam(required = false) Set roles, @@ -93,6 +95,7 @@ public class NamespaceController extends BaseApiController { userNsRoles, normalizePlatformRoles(platformRoles), status, + type, q, slug, roles)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index c7841d60..01bdae60 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -168,6 +168,7 @@ public class NamespacePortalQueryAppService { Map userNamespaceRoles, Set platformRoles, NamespaceStatus status, + NamespaceType type, String query, String slug, Set roles) { @@ -180,6 +181,7 @@ public class NamespacePortalQueryAppService { if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) { Page visibleNamespaces = namespaceRepository.search( status, + type, normalizedQuery, normalizedSlug, boundedPageable @@ -200,6 +202,7 @@ public class NamespacePortalQueryAppService { Page visibleNamespaces = namespaceRepository.searchByIdIn( scopedNamespaceIds, status, + type, normalizedQuery, normalizedSlug, boundedPageable diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index a8dc2b4b..456ebd31 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -129,7 +129,7 @@ class NamespacePortalControllerTest { Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); - given(namespaceRepository.search(eq(null), eq(null), eq(null), any())) + given(namespaceRepository.search(eq(null), eq(null), eq(null), eq(null), any())) .willReturn(new org.springframework.data.domain.PageImpl<>( List.of(active, archived), org.springframework.data.domain.PageRequest.of(0, 2), @@ -159,6 +159,7 @@ class NamespacePortalControllerTest { given(namespaceRepository.searchByIdIn( eq(List.of(1L)), eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), eq("team"), eq("team-ai"), any() @@ -172,6 +173,7 @@ class NamespacePortalControllerTest { .param("page", "0") .param("size", "20") .param("status", "ACTIVE") + .param("type", "TEAM") .param("q", "team") .param("slug", "team-ai") .param("roles", "OWNER", "ADMIN") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java index 050bdbcc..2232a894 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import jakarta.persistence.EntityManager; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -34,6 +35,9 @@ class NamespaceJpaRepositoryTest { Namespace archived = new Namespace("archived-percent", "50% Archived", "owner-1"); archived.setStatus(NamespaceStatus.ARCHIVED); persist(archived); + Namespace global = new Namespace("global", "50% Global", "owner-1"); + global.setType(NamespaceType.GLOBAL); + persist(global); entityManager.flush(); } @@ -41,6 +45,7 @@ class NamespaceJpaRepositoryTest { void search_treatsEscapedWildcardsLiterallyAndAppliesStatus() { var percentPage = repository.search( NamespaceStatus.ACTIVE, + NamespaceType.TEAM, "!%", null, PageRequest.of(0, 10) @@ -48,6 +53,7 @@ class NamespaceJpaRepositoryTest { var underscorePage = repository.searchByIdIn( List.of(percentNamespace.getId(), underscoreNamespace.getId()), NamespaceStatus.ACTIVE, + NamespaceType.TEAM, "!_", null, PageRequest.of(0, 10) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 2253a77f..1a48cc0c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -155,6 +155,7 @@ class NamespacePortalQueryAppServiceTest { when(namespaceRepository.searchByIdIn( eq(List.of(1L)), eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), eq("team"), eq("team-ai"), any(Pageable.class) @@ -165,6 +166,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(1L, NamespaceRole.OWNER, 2L, NamespaceRole.MEMBER), Set.of("SUPER_ADMIN"), NamespaceStatus.ACTIVE, + NamespaceType.TEAM, " team ", " team-ai ", Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) @@ -175,11 +177,12 @@ class NamespacePortalQueryAppServiceTest { verify(namespaceRepository).searchByIdIn( eq(List.of(1L)), eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), eq("team"), eq("team-ai"), any(Pageable.class) ); - verify(namespaceRepository, never()).search(any(), any(), any(), any()); + verify(namespaceRepository, never()).search(any(), any(), any(), any(), any()); } @Test @@ -189,6 +192,7 @@ class NamespacePortalQueryAppServiceTest { Pageable expectedPageable = PageRequest.of(1, 10); when(namespaceRepository.search( eq(NamespaceStatus.ARCHIVED), + eq(null), eq("ops"), eq("ops-team"), any(Pageable.class) @@ -199,6 +203,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(), Set.of("SUPER_ADMIN"), NamespaceStatus.ARCHIVED, + null, " ops ", " ops-team ", Set.of() @@ -208,17 +213,19 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.total()).isEqualTo(11); verify(namespaceRepository).search( eq(NamespaceStatus.ARCHIVED), + eq(null), eq("ops"), eq("ops-team"), any(Pageable.class) ); - verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any()); + verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any(), any()); } @Test void listMyNamespaces_escapesLikeWildcardsForLiteralSubstringSearch() { Pageable expectedPageable = PageRequest.of(0, 20); when(namespaceRepository.search( + eq(null), eq(null), eq("50!%!_!!off"), eq(null), @@ -230,12 +237,14 @@ class NamespacePortalQueryAppServiceTest { Map.of(), Set.of("SUPER_ADMIN"), null, + null, " 50%_!off ", null, Set.of() ); verify(namespaceRepository).search( + eq(null), eq(null), eq("50!%!_!!off"), eq(null), @@ -252,6 +261,7 @@ class NamespacePortalQueryAppServiceTest { eq(null), eq(null), eq(null), + eq(null), any(Pageable.class) )).thenReturn(new PageImpl<>(List.of(administered, member), PageRequest.of(0, 20), 2)); @@ -260,6 +270,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(2L, NamespaceRole.ADMIN, 1L, NamespaceRole.MEMBER), Set.of(), null, + null, " ", "\t", Set.of() @@ -273,9 +284,10 @@ class NamespacePortalQueryAppServiceTest { eq(null), eq(null), eq(null), + eq(null), any(Pageable.class) ); - verify(namespaceRepository, never()).search(any(), any(), any(), any()); + verify(namespaceRepository, never()).search(any(), any(), any(), any(), any()); } @Test @@ -285,6 +297,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(1L, NamespaceRole.MEMBER), Set.of("SUPER_ADMIN"), NamespaceStatus.ACTIVE, + NamespaceType.TEAM, " team ", null, Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index 38dcc575..2f53f3da 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -15,10 +15,17 @@ public interface NamespaceRepository { Optional findBySlug(String slug); Page findAll(Pageable pageable); Page findByStatus(NamespaceStatus status, Pageable pageable); - Page search(NamespaceStatus status, String query, String slug, Pageable pageable); + Page search( + NamespaceStatus status, + NamespaceType type, + String query, + String slug, + Pageable pageable + ); Page searchByIdIn( List ids, NamespaceStatus status, + NamespaceType type, String query, String slug, Pageable pageable diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 99e243ba..4476f961 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -28,6 +29,7 @@ public interface NamespaceJpaRepository SELECT n FROM Namespace n WHERE (:status IS NULL OR n.status = :status) + AND (:type IS NULL OR n.type = :type) AND ( :query IS NULL OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' @@ -36,6 +38,7 @@ public interface NamespaceJpaRepository AND (:slug IS NULL OR n.slug = :slug) """) Page search(@Param("status") NamespaceStatus status, + @Param("type") NamespaceType type, @Param("query") String query, @Param("slug") String slug, Pageable pageable); @@ -46,6 +49,7 @@ public interface NamespaceJpaRepository FROM Namespace n WHERE n.id IN :ids AND (:status IS NULL OR n.status = :status) + AND (:type IS NULL OR n.type = :type) AND ( :query IS NULL OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' @@ -55,6 +59,7 @@ public interface NamespaceJpaRepository """) Page searchByIdIn(@Param("ids") List ids, @Param("status") NamespaceStatus status, + @Param("type") NamespaceType type, @Param("query") String query, @Param("slug") String slug, Pageable pageable); diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 07166c0c..ac319d3f 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -257,6 +257,7 @@ describe('namespaceApi.listMinePage', () => { page: 1, size: 20, status: 'ACTIVE', + type: 'TEAM', q: 'team ai', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], @@ -264,7 +265,7 @@ describe('namespaceApi.listMinePage', () => { 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', + 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&status=ACTIVE&type=TEAM&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', expect.objectContaining({ headers: expect.any(Headers) }), ) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6bdb6b91..1e35ba82 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -52,6 +52,7 @@ export interface MyNamespacePageParams { page?: number size?: number status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' + type?: 'GLOBAL' | 'TEAM' q?: string slug?: string roles?: Array<'OWNER' | 'ADMIN' | 'MEMBER'> @@ -659,6 +660,9 @@ export const namespaceApi = { if (params.status) { query.set('status', params.status) } + if (params.type) { + query.set('type', params.type) + } if (params.q?.trim()) { query.set('q', params.q.trim()) } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 019f13b1..efc08e1d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -10054,6 +10054,7 @@ export interface operations { query: { pageable: components["schemas"]["Pageable"]; status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + type?: "GLOBAL" | "TEAM"; q?: string; slug?: string; roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; @@ -10080,6 +10081,7 @@ export interface operations { query: { pageable: components["schemas"]["Pageable"]; status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + type?: "GLOBAL" | "TEAM"; q?: string; slug?: string; roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; diff --git a/web/src/features/review/use-namespace-review-entry.test.ts b/web/src/features/review/use-namespace-review-entry.test.ts index 97aaf380..228abac5 100644 --- a/web/src/features/review/use-namespace-review-entry.test.ts +++ b/web/src/features/review/use-namespace-review-entry.test.ts @@ -34,9 +34,14 @@ describe('useNamespaceReviewEntry', () => { it('uses one bounded ACTIVE query when an active review namespace exists', () => { const active = namespace('zeta-active', 'ACTIVE') - useMyNamespacesPageMock.mockImplementation((params: { status?: string }) => ({ + const global = { ...namespace('global', 'ACTIVE'), type: 'GLOBAL' as const } + useMyNamespacesPageMock.mockImplementation((params: { status?: string; type?: string }) => ({ data: { - items: params.status === 'ACTIVE' ? [active] : [namespace('alpha-archived', 'ARCHIVED')], + items: params.type !== 'TEAM' + ? [global] + : params.status === 'ACTIVE' + ? [active] + : [namespace('alpha-archived', 'ARCHIVED')], total: 1, page: 0, size: 1, @@ -51,11 +56,13 @@ describe('useNamespaceReviewEntry', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, true) expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { page: 0, size: 1, + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) expect(result.namespaceReviewEntry?.slug).toBe('zeta-active') @@ -79,6 +86,7 @@ describe('useNamespaceReviewEntry', () => { expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { page: 0, size: 1, + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, true) expect(result.namespaceReviewEntry?.slug).toBe('alpha-archived') diff --git a/web/src/features/review/use-namespace-review-entry.ts b/web/src/features/review/use-namespace-review-entry.ts index a525cfcc..f797f293 100644 --- a/web/src/features/review/use-namespace-review-entry.ts +++ b/web/src/features/review/use-namespace-review-entry.ts @@ -12,6 +12,7 @@ export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: [...REVIEW_ROLES], }, !hasGlobalReviewAccess) const activeEntry = getPreferredNamespaceReviewEntry(activeQuery.data?.items) @@ -23,6 +24,7 @@ export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { const fallbackQuery = useMyNamespacesPage({ page: 0, size: 1, + type: 'TEAM', roles: [...REVIEW_ROLES], }, fallbackEnabled) const fallbackEntry = fallbackEnabled @@ -34,5 +36,8 @@ export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { isLoadingNamespaces: !hasGlobalReviewAccess && (activeQuery.isLoading || (fallbackEnabled && fallbackQuery.isLoading)), hasNamespaceQueryError: Boolean(activeQuery.error || (fallbackEnabled && fallbackQuery.error)), + retryNamespaceQueries: () => fallbackEnabled + ? fallbackQuery.refetch() + : activeQuery.refetch(), } } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 49d23b47..84f260cf 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -492,6 +492,8 @@ "pageSubtitle": "Manage access credentials for CLI and API" }, "reviews": { + "namespaceLoadError": "Failed to load namespace review access.", + "retryNamespaceLoad": "Retry", "title": "Review Center", "subtitle": "Manage platform review tasks", "typeSkill": "Skill Reviews", @@ -1365,6 +1367,8 @@ "namespace": "Namespace", "selectNamespace": "Select namespace", "namespaceUnavailable": "The selected namespace is not active or is no longer available.", + "namespaceValidationError": "Failed to validate the selected namespace.", + "retryNamespaceValidation": "Retry validation", "visibility": "Visibility", "visibilityOptions": { "public": "Public", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e95dfc26..8fde761a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -492,6 +492,8 @@ "pageSubtitle": "管理 CLI 和 API 使用的访问凭证" }, "reviews": { + "namespaceLoadError": "无法加载命名空间审核权限。", + "retryNamespaceLoad": "重试", "title": "审核中心", "subtitle": "管理平台审核事务", "typeSkill": "技能审核", @@ -1366,6 +1368,8 @@ "namespace": "命名空间", "selectNamespace": "选择命名空间", "namespaceUnavailable": "所选命名空间未启用或已不可用。", + "namespaceValidationError": "无法校验所选命名空间。", + "retryNamespaceValidation": "重新校验", "visibility": "可见性", "visibilityOptions": { "public": "公开", diff --git a/web/src/pages/dashboard/publish.test.ts b/web/src/pages/dashboard/publish.test.ts index 550d8cf8..3f23a08f 100644 --- a/web/src/pages/dashboard/publish.test.ts +++ b/web/src/pages/dashboard/publish.test.ts @@ -135,6 +135,21 @@ describe('PublishPage', () => { expect(html).toContain('publish.namespaceUnavailable') }) + it('distinguishes a namespace validation request failure from an unavailable namespace', () => { + useMyNamespacesPageMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('network down'), + refetch: vi.fn(), + }) + + const html = renderToStaticMarkup(createElement(PublishPage)) + + expect(html).toContain('publish.namespaceValidationError') + expect(html).toContain('publish.retryNamespaceValidation') + expect(html).not.toContain('publish.namespaceUnavailable') + }) + it('exports a named component function', () => { expect(typeof PublishPage).toBe('function') }) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 4f0d8e63..c921b5e1 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -39,7 +39,12 @@ export function PublishPage() { const [warningDialogOpen, setWarningDialogOpen] = useState(false) const [precheckWarnings, setPrecheckWarnings] = useState([]) - const { data: selectedNamespacePage, isLoading: isLoadingSelectedNamespace } = useMyNamespacesPage({ + const { + data: selectedNamespacePage, + isLoading: isLoadingSelectedNamespace, + error: selectedNamespaceError, + refetch: refetchSelectedNamespace, + } = useMyNamespacesPage({ page: 0, size: 1, status: 'ACTIVE', @@ -165,8 +170,17 @@ export function PublishPage() { status="ACTIVE" disabled={publishMutation.isPending} /> - {namespaceSlug && !isLoadingSelectedNamespace && !selectedNamespace ? ( -

{t('publish.namespaceUnavailable')}

+ {namespaceSlug && !isLoadingSelectedNamespace ? ( + selectedNamespaceError ? ( +
+

{t('publish.namespaceValidationError')}

+ +
+ ) : !selectedNamespace ? ( +

{t('publish.namespaceUnavailable')}

+ ) : null ) : null} diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index aba65e8e..1da5c5cc 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -205,7 +205,25 @@ describe('ReviewsPage', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) }) + + it('renders a recoverable error when namespace review access cannot be loaded', () => { + userMock.platformRoles = ['USER'] + hasRoleMock.mockReturnValue(false) + useMyNamespacesPageMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('network down'), + refetch: vi.fn(), + }) + + const html = renderToStaticMarkup(createElement(ReviewsPage)) + + expect(html).toContain('reviews.namespaceLoadError') + expect(html).toContain('reviews.retryNamespaceLoad') + expect(html).not.toContain('Loading...') + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 8f6de4d7..cd7ad74f 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { @@ -54,6 +55,7 @@ export function ReviewsPage() { namespaceReviewEntry, isLoadingNamespaces, hasNamespaceQueryError, + retryNamespaceQueries, } = useNamespaceReviewEntry(hasGlobalReviewAccess) const showTypeTabs = isSkillAdmin && isUserAdmin @@ -252,7 +254,16 @@ export function ReviewsPage() {
- Loading... + {hasNamespaceQueryError ? ( +
+

{t('reviews.namespaceLoadError')}

+ +
+ ) : ( + 'Loading...' + )}
) diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx index 5deefa89..b8faf624 100644 --- a/web/src/shared/components/user-menu.test.tsx +++ b/web/src/shared/components/user-menu.test.tsx @@ -149,11 +149,13 @@ describe('UserMenu security settings visibility', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, true) expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) expect(html).toContain('user.menu.reviews') @@ -173,6 +175,7 @@ describe('UserMenu security settings visibility', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) }) diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index bd5f7d93..2f4b39f7 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -54,6 +54,7 @@ describe('use-namespace-queries exports', () => { page: 3, size: 15, status: 'ACTIVE', + type: 'TEAM', q: 'team', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], @@ -64,6 +65,7 @@ describe('use-namespace-queries exports', () => { page: 3, size: 15, status: 'ACTIVE', + type: 'TEAM', q: 'team', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], @@ -79,6 +81,7 @@ describe('use-namespace-queries exports', () => { page: 3, size: 15, status: 'ACTIVE', + type: 'TEAM', q: 'team', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index 4bdfa135..a9c7c91a 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -13,6 +13,7 @@ function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyN page: params.page ?? 0, size: params.size ?? MY_NAMESPACES_PAGE_SIZE, ...(params.status ? { status: params.status } : {}), + ...(params.type ? { type: params.type } : {}), ...(q ? { q } : {}), ...(slug ? { slug } : {}), ...(params.roles?.length ? { roles: [...params.roles] } : {}),