mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge branch 'main' into fix/namespace-management-gaps
Resolved conflicts by keeping both sides:
- web/src/api/client.ts: preserve paginated listMembers(slug, {page,size})
and add delete(slug) from main.
- web/src/shared/hooks/use-namespace-queries.ts: keep useUpdateNamespace
and useTransferNamespaceOwnership from this branch, plus useDeleteNamespace
from main.
This commit is contained in:
commit
ab06c75737
43 changed files with 848 additions and 110 deletions
|
|
@ -96,6 +96,14 @@ public class NamespaceController extends BaseApiController {
|
|||
namespacePortalCommandAppService.updateNamespace(slug, request, userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/namespaces/{slug}")
|
||||
public ApiResponse<MessageResponse> deleteNamespace(
|
||||
@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
return ok("response.success.deleted",
|
||||
namespacePortalCommandAppService.deleteNamespace(slug, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/freeze")
|
||||
public ApiResponse<NamespaceResponse> freezeNamespace(@PathVariable String slug,
|
||||
@RequestBody(required = false) NamespaceLifecycleRequest request,
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ public record MyNamespaceResponse(
|
|||
boolean canFreeze,
|
||||
boolean canUnfreeze,
|
||||
boolean canArchive,
|
||||
boolean canRestore
|
||||
boolean canRestore,
|
||||
boolean canDelete
|
||||
) {
|
||||
public static MyNamespaceResponse from(Namespace namespace,
|
||||
NamespaceRole currentUserRole,
|
||||
NamespaceAccessPolicy accessPolicy) {
|
||||
NamespaceAccessPolicy accessPolicy,
|
||||
boolean canDelete) {
|
||||
return new MyNamespaceResponse(
|
||||
namespace.getId(),
|
||||
namespace.getSlug(),
|
||||
|
|
@ -45,7 +47,8 @@ public record MyNamespaceResponse(
|
|||
accessPolicy.canFreeze(namespace, currentUserRole),
|
||||
accessPolicy.canUnfreeze(namespace, currentUserRole),
|
||||
accessPolicy.canArchive(namespace, currentUserRole),
|
||||
accessPolicy.canRestore(namespace, currentUserRole)
|
||||
accessPolicy.canRestore(namespace, currentUserRole),
|
||||
canDelete
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ public class NamespacePortalCommandAppService {
|
|||
return NamespaceResponse.from(updated);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageResponse deleteNamespace(String slug, String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceService.deleteNamespace(namespace.getId(), userId);
|
||||
return new MessageResponse("Namespace deleted successfully");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public NamespaceResponse freezeNamespace(String slug,
|
||||
NamespaceLifecycleRequest request,
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ public class NamespacePortalQueryAppService {
|
|||
.map(namespace -> MyNamespaceResponse.from(
|
||||
namespace,
|
||||
namespaceRoles.get(namespace.getId()),
|
||||
namespaceAccessPolicy))
|
||||
namespaceAccessPolicy,
|
||||
namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId()))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX idx_promotion_request_target_namespace ON promotion_request(target_namespace_id);
|
||||
|
|
@ -69,7 +69,9 @@ error.namespace.id.notFound=Namespace not found: {0}
|
|||
error.namespace.slug.notFound=Namespace not found: {0}
|
||||
error.namespace.membership.required=Namespace membership required
|
||||
error.namespace.admin.required=Namespace owner or admin role required
|
||||
error.namespace.owner.required=Namespace owner role required
|
||||
error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces
|
||||
error.namespace.delete.hasDependencies=Namespace cannot be deleted while it still contains skills or governance records
|
||||
error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly
|
||||
error.namespace.member.alreadyExists=User is already a namespace member
|
||||
error.namespace.member.notFound=Member not found
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ error.namespace.id.notFound=未找到命名空间:{0}
|
|||
error.namespace.slug.notFound=未找到命名空间:{0}
|
||||
error.namespace.membership.required=需要先加入该命名空间
|
||||
error.namespace.admin.required=需要命名空间管理员或所有者权限
|
||||
error.namespace.owner.required=需要命名空间所有者权限
|
||||
error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间
|
||||
error.namespace.delete.hasDependencies=命名空间下仍有技能或治理记录,暂时不能删除
|
||||
error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色
|
||||
error.namespace.member.alreadyExists=用户已经是该命名空间成员
|
||||
error.namespace.member.notFound=未找到命名空间成员
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import static org.mockito.BDDMockito.given;
|
|||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
|
|
@ -90,7 +91,8 @@ class NamespacePortalControllerTest {
|
|||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data[0].slug").value("team-a"))
|
||||
.andExpect(jsonPath("$.data[0].status").value("ARCHIVED"))
|
||||
.andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER"));
|
||||
.andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER"))
|
||||
.andExpect(jsonPath("$.data[0].canDelete").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -144,6 +146,20 @@ class NamespacePortalControllerTest {
|
|||
.andExpect(jsonPath("$.data.description").value("Updated description"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNamespace_returnsSuccessMessage() throws Exception {
|
||||
Namespace existing = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(existing);
|
||||
|
||||
mockMvc.perform(delete("/api/v1/namespaces/team-a")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.message").value("Namespace deleted successfully"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMembers_forNonMember_returns403() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,17 @@ class NamespacePortalCommandAppServiceTest {
|
|||
verify(namespaceGovernanceService).freezeNamespace("team-alpha", "owner-1", "cleanup", null, "127.0.0.1", "JUnit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNamespace_delegatesToDomainService() {
|
||||
Namespace namespace = namespace(7L, "team-alpha");
|
||||
when(namespaceService.getNamespaceBySlug("team-alpha")).thenReturn(namespace);
|
||||
|
||||
MessageResponse response = service.deleteNamespace("team-alpha", "owner-1");
|
||||
|
||||
assertThat(response.message()).isEqualTo("Namespace deleted successfully");
|
||||
verify(namespaceService).deleteNamespace(7L, "owner-1");
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug) {
|
||||
Namespace namespace = new Namespace(slug, "Team Alpha", "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", id);
|
||||
|
|
|
|||
|
|
@ -55,11 +55,13 @@ class NamespacePortalQueryAppServiceTest {
|
|||
when(namespaceAccessPolicy.canUnfreeze(alpha, NamespaceRole.OWNER)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canArchive(alpha, NamespaceRole.OWNER)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.canRestore(alpha, NamespaceRole.OWNER)).thenReturn(false);
|
||||
when(namespaceService.canDelete(alpha, NamespaceRole.OWNER)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.isImmutable(zeta)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canFreeze(zeta, NamespaceRole.ADMIN)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.canUnfreeze(zeta, NamespaceRole.ADMIN)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canArchive(zeta, NamespaceRole.ADMIN)).thenReturn(true);
|
||||
when(namespaceAccessPolicy.canRestore(zeta, NamespaceRole.ADMIN)).thenReturn(false);
|
||||
when(namespaceService.canDelete(zeta, NamespaceRole.ADMIN)).thenReturn(false);
|
||||
|
||||
var response = service.listMyNamespaces(Map.of(
|
||||
2L, NamespaceRole.ADMIN,
|
||||
|
|
@ -69,8 +71,10 @@ class NamespacePortalQueryAppServiceTest {
|
|||
assertThat(response).hasSize(2);
|
||||
assertThat(response.get(0).slug()).isEqualTo("alpha");
|
||||
assertThat(response.get(0).currentUserRole()).isEqualTo(NamespaceRole.OWNER);
|
||||
assertThat(response.get(0).canDelete()).isTrue();
|
||||
assertThat(response.get(1).slug()).isEqualTo("zeta");
|
||||
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
|
||||
assertThat(response.get(1).canDelete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -481,6 +481,11 @@ class ScanTaskConsumerTest {
|
|||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByNamespaceId(Long namespaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds) {
|
||||
throw unsupported();
|
||||
|
|
|
|||
|
|
@ -49,4 +49,9 @@ public class NamespaceAccessPolicy {
|
|||
&& namespace.getStatus() == NamespaceStatus.ARCHIVED
|
||||
&& role == NamespaceRole.OWNER;
|
||||
}
|
||||
|
||||
public boolean canDelete(Namespace namespace, NamespaceRole role) {
|
||||
return namespace.getType() == NamespaceType.TEAM
|
||||
&& role == NamespaceRole.OWNER;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ public interface NamespaceMemberRepository {
|
|||
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
|
||||
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
|
||||
NamespaceMember save(NamespaceMember member);
|
||||
void deleteByNamespaceId(Long namespaceId);
|
||||
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ public interface NamespaceRepository {
|
|||
Optional<Namespace> findBySlug(String slug);
|
||||
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
|
||||
Namespace save(Namespace namespace);
|
||||
void delete(Namespace namespace);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -16,13 +19,22 @@ public class NamespaceService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
private final SkillRepository skillRepository;
|
||||
private final ReviewTaskRepository reviewTaskRepository;
|
||||
private final PromotionRequestRepository promotionRequestRepository;
|
||||
|
||||
public NamespaceService(NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
NamespaceAccessPolicy namespaceAccessPolicy) {
|
||||
NamespaceAccessPolicy namespaceAccessPolicy,
|
||||
SkillRepository skillRepository,
|
||||
ReviewTaskRepository reviewTaskRepository,
|
||||
PromotionRequestRepository promotionRequestRepository) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||
this.skillRepository = skillRepository;
|
||||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -102,13 +114,34 @@ public class NamespaceService {
|
|||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently deletes an empty team namespace after ownership checks.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteNamespace(Long namespaceId, String operatorUserId) {
|
||||
Namespace namespace = getNamespace(namespaceId);
|
||||
assertNotImmutable(namespace);
|
||||
|
||||
NamespaceRole role = requireRole(namespaceId, operatorUserId);
|
||||
if (!namespaceAccessPolicy.canDelete(namespace, role)) {
|
||||
throw new DomainForbiddenException("error.namespace.owner.required");
|
||||
}
|
||||
assertNoDependentData(namespaceId);
|
||||
|
||||
namespaceMemberRepository.deleteByNamespaceId(namespaceId);
|
||||
namespaceRepository.delete(namespace);
|
||||
}
|
||||
|
||||
public boolean canDelete(Namespace namespace, NamespaceRole role) {
|
||||
return namespaceAccessPolicy.canDelete(namespace, role)
|
||||
&& !hasDependentData(namespace.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the caller holds an owner or admin membership in the namespace.
|
||||
*/
|
||||
public void assertAdminOrOwner(Long namespaceId, String userId) {
|
||||
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
|
||||
.map(NamespaceMember::getRole)
|
||||
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
|
||||
NamespaceRole role = requireRole(namespaceId, userId);
|
||||
if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) {
|
||||
throw new DomainForbiddenException("error.namespace.admin.required");
|
||||
}
|
||||
|
|
@ -133,6 +166,24 @@ public class NamespaceService {
|
|||
}
|
||||
}
|
||||
|
||||
private NamespaceRole requireRole(Long namespaceId, String userId) {
|
||||
return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
|
||||
.map(NamespaceMember::getRole)
|
||||
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
|
||||
}
|
||||
|
||||
private void assertNoDependentData(Long namespaceId) {
|
||||
if (hasDependentData(namespaceId)) {
|
||||
throw new DomainBadRequestException("error.namespace.delete.hasDependencies");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasDependentData(Long namespaceId) {
|
||||
return skillRepository.existsByNamespaceId(namespaceId)
|
||||
|| reviewTaskRepository.existsByNamespaceId(namespaceId)
|
||||
|| promotionRequestRepository.existsByTargetNamespaceId(namespaceId);
|
||||
}
|
||||
|
||||
private void assertWritable(Namespace namespace) {
|
||||
if (!namespaceAccessPolicy.canMutateSettings(namespace)) {
|
||||
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public interface PromotionRequestRepository {
|
|||
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
|
||||
Optional<PromotionRequest> findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status);
|
||||
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
|
||||
boolean existsByTargetNamespaceId(Long namespaceId);
|
||||
void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId);
|
||||
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
|
||||
String reviewComment, Long targetSkillId, Integer expectedVersion);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public interface ReviewTaskRepository {
|
|||
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
|
||||
void delete(ReviewTask reviewTask);
|
||||
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public interface SkillRepository {
|
|||
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
|
||||
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
Skill save(Skill skill);
|
||||
void flush();
|
||||
void delete(Skill skill);
|
||||
|
|
|
|||
|
|
@ -54,4 +54,16 @@ class NamespaceAccessPolicyTest {
|
|||
assertThat(policy.canRestore(namespace, NamespaceRole.OWNER)).isTrue();
|
||||
assertThat(policy.canRestore(namespace, NamespaceRole.ADMIN)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteIsOwnerOnlyForTeamNamespaces() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner");
|
||||
namespace.setType(NamespaceType.TEAM);
|
||||
|
||||
assertThat(policy.canDelete(namespace, NamespaceRole.OWNER)).isTrue();
|
||||
assertThat(policy.canDelete(namespace, NamespaceRole.ADMIN)).isFalse();
|
||||
|
||||
namespace.setType(NamespaceType.GLOBAL);
|
||||
assertThat(policy.canDelete(namespace, NamespaceRole.OWNER)).isFalse();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package com.iflytek.skillhub.domain.namespace;
|
|||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
|
@ -26,6 +30,15 @@ class NamespaceServiceTest {
|
|||
@Mock
|
||||
private NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
|
||||
@Mock
|
||||
private SkillRepository skillRepository;
|
||||
|
||||
@Mock
|
||||
private ReviewTaskRepository reviewTaskRepository;
|
||||
|
||||
@Mock
|
||||
private PromotionRequestRepository promotionRequestRepository;
|
||||
|
||||
@InjectMocks
|
||||
private NamespaceService namespaceService;
|
||||
|
||||
|
|
@ -195,4 +208,93 @@ class NamespaceServiceTest {
|
|||
assertThrows(DomainForbiddenException.class, () ->
|
||||
namespaceService.assertMember(namespaceId, userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNamespace_shouldDeleteMembersAndNamespaceForOwner() {
|
||||
Long namespaceId = 1L;
|
||||
String operatorUserId = "owner-1";
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
|
||||
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER)));
|
||||
when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
|
||||
when(reviewTaskRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
|
||||
when(promotionRequestRepository.existsByTargetNamespaceId(namespaceId)).thenReturn(false);
|
||||
|
||||
namespaceService.deleteNamespace(namespaceId, operatorUserId);
|
||||
|
||||
verify(namespaceMemberRepository).deleteByNamespaceId(namespaceId);
|
||||
verify(namespaceRepository).delete(namespace);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNamespace_shouldRejectAdminUser() {
|
||||
Long namespaceId = 1L;
|
||||
String operatorUserId = "admin-1";
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.ADMIN)).thenReturn(false);
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
|
||||
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.ADMIN)));
|
||||
|
||||
DomainForbiddenException exception = assertThrows(DomainForbiddenException.class, () ->
|
||||
namespaceService.deleteNamespace(namespaceId, operatorUserId));
|
||||
|
||||
assertEquals("error.namespace.owner.required", exception.messageCode());
|
||||
verify(namespaceRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNamespace_shouldRejectNamespaceWithDependentData() {
|
||||
Long namespaceId = 1L;
|
||||
String operatorUserId = "owner-1";
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
|
||||
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER)));
|
||||
when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(true);
|
||||
|
||||
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () ->
|
||||
namespaceService.deleteNamespace(namespaceId, operatorUserId));
|
||||
|
||||
assertEquals("error.namespace.delete.hasDependencies", exception.messageCode());
|
||||
verify(namespaceMemberRepository, never()).deleteByNamespaceId(namespaceId);
|
||||
verify(namespaceRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canDelete_shouldReturnFalseWhenRoleCannotDelete() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
|
||||
assertFalse(namespaceService.canDelete(namespace, NamespaceRole.ADMIN));
|
||||
verify(skillRepository, never()).existsByNamespaceId(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canDelete_shouldReflectDependencyChecksForOwner() {
|
||||
Long namespaceId = 1L;
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
|
||||
setField(namespace, "id", namespaceId);
|
||||
when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
|
||||
when(reviewTaskRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
|
||||
when(promotionRequestRepository.existsByTargetNamespaceId(namespaceId)).thenReturn(true);
|
||||
|
||||
assertFalse(namespaceService.canDelete(namespace, NamespaceRole.OWNER));
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
|
|||
return delegate.findByNamespaceIdAndStatus(namespaceId, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByNamespaceId(Long namespaceId) {
|
||||
return delegate.existsByNamespaceId(namespaceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Skill save(Skill skill) {
|
||||
return jpaDelegate.save(skill);
|
||||
|
|
|
|||
|
|
@ -22,5 +22,6 @@ public interface NamespaceMemberJpaRepository
|
|||
List<NamespaceMember> findByUserId(String userId);
|
||||
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
|
||||
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
|
||||
void deleteByNamespaceId(Long namespaceId);
|
||||
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ public interface PromotionRequestJpaRepository extends JpaRepository<PromotionRe
|
|||
|
||||
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
|
||||
|
||||
boolean existsByTargetNamespaceId(Long targetNamespaceId);
|
||||
|
||||
void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId);
|
||||
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
|
|||
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
|
||||
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
|
||||
|
||||
@Modifying
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
|
|||
List<Skill> findByIdIn(List<Long> ids);
|
||||
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
|
||||
@Override
|
||||
default List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {
|
||||
|
|
|
|||
4
web/.browserslistrc
Normal file
4
web/.browserslistrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Chrome >= 83
|
||||
Edge >= 83
|
||||
Firefox >= 78
|
||||
Safari >= 14
|
||||
|
|
@ -15,6 +15,7 @@ export interface SeededNamespace {
|
|||
currentUserRole?: string
|
||||
canUnfreeze?: boolean
|
||||
canRestore?: boolean
|
||||
canDelete?: boolean
|
||||
}
|
||||
|
||||
export interface SeededSkill {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { registerSession } from './helpers/session'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
|
||||
test.describe('My Namespaces Data (Real API)', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await registerSession(page, testInfo)
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
'X-Mock-User-Id': 'local-admin',
|
||||
})
|
||||
})
|
||||
|
||||
test('shows namespace created by request helper', async ({ page }, testInfo) => {
|
||||
|
|
@ -23,4 +24,55 @@ test.describe('My Namespaces Data (Real API)', () => {
|
|||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('deletes a writable namespace from the dashboard', async ({ page }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
try {
|
||||
const namespace = await builder.createNamespace('e2e-delete')
|
||||
|
||||
await page.goto('/dashboard/namespaces')
|
||||
|
||||
const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`)
|
||||
await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible()
|
||||
|
||||
await page.getByTestId(`delete-namespace-${namespace.slug}`).click()
|
||||
await expect(page.getByTestId('namespace-action-dialog-delete')).toBeVisible()
|
||||
|
||||
const deleteResponsePromise = page.waitForResponse((response) =>
|
||||
response.request().method() === 'DELETE'
|
||||
&& response.url().includes(`/api/web/namespaces/${namespace.slug}`),
|
||||
)
|
||||
await page.getByTestId('namespace-action-confirm-delete').click()
|
||||
const deleteResponse = await deleteResponsePromise
|
||||
|
||||
expect(deleteResponse.ok()).toBeTruthy()
|
||||
await expect(page.getByText(`@${namespace.slug}`)).toHaveCount(0)
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('hides the delete action when the namespace has dependent skills', async ({ page }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
try {
|
||||
const namespace = await builder.createNamespace('e2e-delete-guard')
|
||||
|
||||
await builder.publishSkill(namespace.slug, {
|
||||
name: `Delete Guard ${Date.now()}`,
|
||||
description: 'Prevents namespace deletion during Playwright validation',
|
||||
})
|
||||
|
||||
await page.goto('/dashboard/namespaces')
|
||||
|
||||
const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`)
|
||||
await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible()
|
||||
await expect(page.getByTestId(`delete-namespace-${namespace.slug}`)).toHaveCount(0)
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalWindow = globalThis.window
|
||||
const originalDocument = globalThis.document
|
||||
|
||||
function setMockWindow(runtimeConfig?: Window['__SKILLHUB_RUNTIME_CONFIG__']) {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
fetchText,
|
||||
getDirectAuthRuntimeConfig,
|
||||
getSessionBootstrapRuntimeConfig,
|
||||
namespaceApi,
|
||||
} from './client'
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -48,6 +50,16 @@ beforeEach(() => {
|
|||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
if (originalDocument) {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalDocument,
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
}
|
||||
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
|
|
@ -116,6 +128,41 @@ describe('fetchText', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('namespaceApi.delete', () => {
|
||||
it('sends a DELETE request to the normalized namespace endpoint', async () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' }
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
cookie: 'XSRF-TOKEN=test-token',
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: null,
|
||||
timestamp: '2026-05-07T00:00:00Z',
|
||||
requestId: 'req-test',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await namespaceApi.delete('@team-delete')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.example.com/api/web/namespaces/team-delete',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDirectAuthRuntimeConfig', () => {
|
||||
it('returns disabled when no runtime config is present', () => {
|
||||
const config = getDirectAuthRuntimeConfig()
|
||||
|
|
|
|||
|
|
@ -675,6 +675,13 @@ export const namespaceApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async delete(slug: string): Promise<void> {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`, {
|
||||
method: 'DELETE',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async listMembers(slug: string, params?: { page?: number; size?: number }): Promise<PagedResponse<NamespaceMember>> {
|
||||
const queryPage = params?.page ?? 0
|
||||
const querySize = params?.size ?? 20
|
||||
|
|
|
|||
49
web/src/api/generated/schema.d.ts
vendored
49
web/src/api/generated/schema.d.ts
vendored
|
|
@ -302,7 +302,7 @@ export interface paths {
|
|||
get: operations["getNamespace"];
|
||||
put: operations["updateNamespace"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["deleteNamespace"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
|
|
@ -318,7 +318,7 @@ export interface paths {
|
|||
get: operations["getNamespace_1"];
|
||||
put: operations["updateNamespace_1"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["deleteNamespace_1"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
|
|
@ -4341,6 +4341,7 @@ export interface components {
|
|||
canUnfreeze?: boolean;
|
||||
canArchive?: boolean;
|
||||
canRestore?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
ApiResponseGovernanceSummaryResponse: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -5641,6 +5642,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
deleteNamespace: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseMessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNamespace_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -5689,6 +5712,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
deleteNamespace_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseMessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateExpiration: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export interface ManagedNamespace extends Namespace {
|
|||
canUnfreeze: boolean
|
||||
canArchive: boolean
|
||||
canRestore: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface NamespaceMember {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
* Deployments inject `/runtime-config.js` at startup, and this file guarantees the app sees either
|
||||
* that config or a safe fallback object before importing the main entry.
|
||||
*/
|
||||
import './legacy-polyfills'
|
||||
async function loadRuntimeConfig() {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
|
|
@ -16,14 +17,16 @@ async function loadRuntimeConfig() {
|
|||
}
|
||||
|
||||
function ensureRuntimeConfigFallback() {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ ??= {
|
||||
apiBaseUrl: '',
|
||||
appBaseUrl: '',
|
||||
authDirectEnabled: 'false',
|
||||
authDirectProvider: '',
|
||||
authSessionBootstrapEnabled: 'false',
|
||||
authSessionBootstrapProvider: '',
|
||||
authSessionBootstrapAuto: 'false',
|
||||
if (!window.__SKILLHUB_RUNTIME_CONFIG__) {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
apiBaseUrl: '',
|
||||
appBaseUrl: '',
|
||||
authDirectEnabled: 'false',
|
||||
authDirectProvider: '',
|
||||
authSessionBootstrapEnabled: 'false',
|
||||
authSessionBootstrapProvider: '',
|
||||
authSessionBootstrapAuto: 'false',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'ADMIN',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
@ -59,6 +60,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'OWNER',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
@ -78,6 +80,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'MEMBER',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
@ -98,6 +101,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'ADMIN',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@
|
|||
"unfreeze": "Unfreeze",
|
||||
"archive": "Archive",
|
||||
"restore": "Restore",
|
||||
"delete": "Delete",
|
||||
"activeHint": "This namespace is fully active and can continue managing members, reviews, and skill publishing.",
|
||||
"frozenHint": "This namespace is frozen. Members can still view it, but it is read-only and cannot publish or change membership.",
|
||||
"archivedHint": "This namespace is archived. Public entry points are hidden, but you can still view and restore it from the dashboard.",
|
||||
|
|
@ -440,6 +441,8 @@
|
|||
"archiveConfirmDescription": "\"{{name}}\" will be hidden from public entry points and only remain recoverable from the dashboard.",
|
||||
"restoreConfirmTitle": "Restore namespace",
|
||||
"restoreConfirmDescription": "\"{{name}}\" will return to an active operating state.",
|
||||
"deleteConfirmTitle": "Delete namespace",
|
||||
"deleteConfirmDescription": "\"{{name}}\" will be permanently deleted. This cannot be undone.",
|
||||
"freezeSuccessTitle": "Namespace frozen",
|
||||
"freezeSuccessDescription": "\"{{name}}\" is now read-only.",
|
||||
"freezeErrorTitle": "Failed to freeze namespace",
|
||||
|
|
@ -452,6 +455,9 @@
|
|||
"restoreSuccessTitle": "Namespace restored",
|
||||
"restoreSuccessDescription": "\"{{name}}\" can operate normally again.",
|
||||
"restoreErrorTitle": "Failed to restore namespace",
|
||||
"deleteSuccessTitle": "Namespace deleted",
|
||||
"deleteSuccessDescription": "\"{{name}}\" has been permanently deleted.",
|
||||
"deleteErrorTitle": "Failed to delete namespace",
|
||||
"emptyTitle": "No namespaces yet",
|
||||
"emptyDescription": "Create a namespace to organize your skills"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@
|
|||
"unfreeze": "解冻",
|
||||
"archive": "归档",
|
||||
"restore": "恢复",
|
||||
"delete": "删除",
|
||||
"activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。",
|
||||
"frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。",
|
||||
"archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。",
|
||||
|
|
@ -440,6 +441,8 @@
|
|||
"archiveConfirmDescription": "归档后“{{name}}”会从公开入口隐藏,仅保留管理台恢复入口。",
|
||||
"restoreConfirmTitle": "确认恢复命名空间",
|
||||
"restoreConfirmDescription": "恢复后“{{name}}”会重新进入可运营状态。",
|
||||
"deleteConfirmTitle": "确认删除命名空间",
|
||||
"deleteConfirmDescription": "删除后会永久移除“{{name}}”,且无法恢复。",
|
||||
"freezeSuccessTitle": "命名空间已冻结",
|
||||
"freezeSuccessDescription": "“{{name}}”已切换为只读状态。",
|
||||
"freezeErrorTitle": "冻结命名空间失败",
|
||||
|
|
@ -452,6 +455,9 @@
|
|||
"restoreSuccessTitle": "命名空间已恢复",
|
||||
"restoreSuccessDescription": "“{{name}}”已重新开放治理能力。",
|
||||
"restoreErrorTitle": "恢复命名空间失败",
|
||||
"deleteSuccessTitle": "命名空间已删除",
|
||||
"deleteSuccessDescription": "“{{name}}”已被永久删除。",
|
||||
"deleteErrorTitle": "删除命名空间失败",
|
||||
"emptyTitle": "还没有命名空间",
|
||||
"emptyDescription": "创建一个命名空间来组织你的技能"
|
||||
},
|
||||
|
|
|
|||
78
web/src/legacy-polyfills.ts
Normal file
78
web/src/legacy-polyfills.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Polyfills for older browsers (e.g. Chromium 83 on Debian 10) that lack a few
|
||||
* ES2021+ runtime methods used by bundled dependencies. esbuild only transpiles
|
||||
* syntax, not runtime APIs, so we patch the prototypes here before any other
|
||||
* module runs.
|
||||
*
|
||||
* TypeScript's lib target is ES2020, so the methods we patch are referenced via
|
||||
* string keys / loose casts to avoid compile-time type errors.
|
||||
*/
|
||||
|
||||
type AnyStringReplacer = (substring: string, ...args: unknown[]) => string
|
||||
|
||||
const StringProto = String.prototype as unknown as Record<string, unknown>
|
||||
const ArrayProto = Array.prototype as unknown as Record<string, unknown>
|
||||
const ObjectCtor = Object as unknown as Record<string, unknown>
|
||||
|
||||
if (typeof StringProto.replaceAll !== 'function') {
|
||||
Object.defineProperty(String.prototype, 'replaceAll', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function replaceAll(
|
||||
this: string,
|
||||
search: string | RegExp,
|
||||
replacement: string | AnyStringReplacer,
|
||||
): string {
|
||||
if (search instanceof RegExp) {
|
||||
if (!search.flags.includes('g')) {
|
||||
throw new TypeError('String.prototype.replaceAll called with a non-global RegExp argument')
|
||||
}
|
||||
return this.replace(search, replacement as string)
|
||||
}
|
||||
const needle = String(search)
|
||||
if (needle === '') {
|
||||
return this.replace(new RegExp('', 'g'), replacement as string)
|
||||
}
|
||||
const escaped = needle.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')
|
||||
return this.replace(new RegExp(escaped, 'g'), replacement as string)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof ArrayProto.at !== 'function') {
|
||||
Object.defineProperty(Array.prototype, 'at', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function at(this: unknown[], index: number) {
|
||||
const len = this.length
|
||||
const i = Math.trunc(index) || 0
|
||||
const resolved = i < 0 ? len + i : i
|
||||
if (resolved < 0 || resolved >= len) return undefined
|
||||
return this[resolved]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof StringProto.at !== 'function') {
|
||||
Object.defineProperty(String.prototype, 'at', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function at(this: string, index: number) {
|
||||
const len = this.length
|
||||
const i = Math.trunc(index) || 0
|
||||
const resolved = i < 0 ? len + i : i
|
||||
if (resolved < 0 || resolved >= len) return undefined
|
||||
return this.charAt(resolved)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof ObjectCtor.hasOwn !== 'function') {
|
||||
Object.defineProperty(Object, 'hasOwn', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function hasOwn(target: object, property: PropertyKey) {
|
||||
return Object.prototype.hasOwnProperty.call(target, property)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,7 +1,20 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ManagedNamespace } from '@/api/types'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const freezeMutateAsync = vi.fn()
|
||||
const unfreezeMutateAsync = vi.fn()
|
||||
const archiveMutateAsync = vi.fn()
|
||||
const restoreMutateAsync = vi.fn()
|
||||
const deleteMutateAsync = vi.fn()
|
||||
|
||||
let mockNamespaces: ManagedNamespace[] = []
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useNavigate: () => navigateMock,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
|
|
@ -19,19 +32,25 @@ vi.mock('@/features/auth/use-auth', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
Button: ({ children, ...props }: { children: ReactNode }) => createElement('button', props, children),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
Card: ({ children, ...props }: { children: ReactNode }) => createElement('div', props, children),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/namespace-badge', () => ({
|
||||
NamespaceBadge: () => null,
|
||||
NamespaceBadge: ({ name }: { name: string }) => createElement('span', null, name),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/empty-state', () => ({
|
||||
EmptyState: () => null,
|
||||
EmptyState: ({ title, description, action }: { title: string; description: string; action?: ReactNode }) => createElement(
|
||||
'section',
|
||||
null,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
|
|
@ -39,19 +58,26 @@ vi.mock('@/shared/components/confirm-dialog', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
DashboardPageHeader: ({ title, subtitle, actions }: { title: string; subtitle: string; actions?: ReactNode }) => createElement(
|
||||
'header',
|
||||
null,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/create-namespace-dialog', () => ({
|
||||
CreateNamespaceDialog: () => null,
|
||||
CreateNamespaceDialog: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useArchiveNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
useFreezeNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
useMyNamespaces: () => ({ data: [], isLoading: false }),
|
||||
useRestoreNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
useUnfreezeNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
useArchiveNamespace: () => ({ mutateAsync: archiveMutateAsync }),
|
||||
useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }),
|
||||
useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }),
|
||||
useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }),
|
||||
useRestoreNamespace: () => ({ mutateAsync: restoreMutateAsync }),
|
||||
useUnfreezeNamespace: () => ({ mutateAsync: unfreezeMutateAsync }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
|
|
@ -59,9 +85,104 @@ vi.mock('@/shared/lib/toast', () => ({
|
|||
}))
|
||||
|
||||
import { MyNamespacesPage } from './my-namespaces'
|
||||
import { executeNamespaceAction, resolveNamespaceActionCopy } from './my-namespaces'
|
||||
|
||||
function buildNamespace(overrides: Partial<ManagedNamespace> = {}): ManagedNamespace {
|
||||
return {
|
||||
id: 1,
|
||||
slug: 'team-ml',
|
||||
displayName: 'Team ML',
|
||||
description: 'namespace',
|
||||
type: 'TEAM',
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-05-07T00:00:00Z',
|
||||
immutable: false,
|
||||
canFreeze: false,
|
||||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MyNamespacesPage', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset()
|
||||
freezeMutateAsync.mockReset()
|
||||
unfreezeMutateAsync.mockReset()
|
||||
archiveMutateAsync.mockReset()
|
||||
restoreMutateAsync.mockReset()
|
||||
deleteMutateAsync.mockReset()
|
||||
mockNamespaces = []
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof MyNamespacesPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the delete action when the namespace is deletable', () => {
|
||||
mockNamespaces = [buildNamespace({ canDelete: true })]
|
||||
|
||||
const html = renderToStaticMarkup(createElement(MyNamespacesPage))
|
||||
|
||||
expect(html).toContain('myNamespaces.delete')
|
||||
})
|
||||
|
||||
it('hides the delete action when the namespace is not deletable', () => {
|
||||
mockNamespaces = [buildNamespace({ canDelete: false })]
|
||||
|
||||
const html = renderToStaticMarkup(createElement(MyNamespacesPage))
|
||||
|
||||
expect(html).not.toContain('myNamespaces.delete')
|
||||
})
|
||||
|
||||
it('routes delete actions to the delete mutation and emits success feedback', async () => {
|
||||
const t = (key: string) => key
|
||||
const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML')
|
||||
deleteMutateAsync.mockResolvedValueOnce(undefined)
|
||||
const success = vi.fn()
|
||||
const error = vi.fn()
|
||||
|
||||
await executeNamespaceAction(
|
||||
{ action: 'delete', slug: 'team-ml', name: 'Team ML' },
|
||||
{
|
||||
freeze: { mutateAsync: freezeMutateAsync },
|
||||
unfreeze: { mutateAsync: unfreezeMutateAsync },
|
||||
archive: { mutateAsync: archiveMutateAsync },
|
||||
restore: { mutateAsync: restoreMutateAsync },
|
||||
delete: { mutateAsync: deleteMutateAsync },
|
||||
},
|
||||
copy,
|
||||
{ success, error },
|
||||
)
|
||||
|
||||
expect(deleteMutateAsync).toHaveBeenCalledWith({ slug: 'team-ml' })
|
||||
expect(success).toHaveBeenCalledWith('myNamespaces.deleteSuccessTitle', 'myNamespaces.deleteSuccessDescription')
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces delete failures through error feedback and rethrows', async () => {
|
||||
const t = (key: string) => key
|
||||
const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML')
|
||||
deleteMutateAsync.mockRejectedValueOnce(new Error('blocked'))
|
||||
const success = vi.fn()
|
||||
const error = vi.fn()
|
||||
|
||||
await expect(executeNamespaceAction(
|
||||
{ action: 'delete', slug: 'team-ml', name: 'Team ML' },
|
||||
{
|
||||
freeze: { mutateAsync: freezeMutateAsync },
|
||||
unfreeze: { mutateAsync: unfreezeMutateAsync },
|
||||
archive: { mutateAsync: archiveMutateAsync },
|
||||
restore: { mutateAsync: restoreMutateAsync },
|
||||
delete: { mutateAsync: deleteMutateAsync },
|
||||
},
|
||||
copy,
|
||||
{ success, error },
|
||||
)).rejects.toThrow('blocked')
|
||||
|
||||
expect(error).toHaveBeenCalledWith('myNamespaces.deleteErrorTitle', 'blocked')
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ import { EmptyState } from '@/shared/components/empty-state'
|
|||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
|
||||
import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries'
|
||||
import {
|
||||
useArchiveNamespace,
|
||||
useDeleteNamespace,
|
||||
useFreezeNamespace,
|
||||
useMyNamespaces,
|
||||
useRestoreNamespace,
|
||||
useUnfreezeNamespace,
|
||||
} from '@/shared/hooks/use-namespace-queries'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
||||
type PendingNamespaceAction =
|
||||
|
|
@ -17,6 +24,120 @@ type PendingNamespaceAction =
|
|||
| { action: 'unfreeze'; slug: string; name: string }
|
||||
| { action: 'archive'; slug: string; name: string }
|
||||
| { action: 'restore'; slug: string; name: string }
|
||||
| { action: 'delete'; slug: string; name: string }
|
||||
|
||||
type NamespaceActionCopy = {
|
||||
title: string
|
||||
description: string
|
||||
confirmText: string
|
||||
successTitle: string
|
||||
successDescription: string
|
||||
errorTitle: string
|
||||
variant: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
type NamespaceActionMutation = {
|
||||
mutateAsync: (input: { slug: string }) => Promise<unknown>
|
||||
}
|
||||
|
||||
type NamespaceActionMutations = {
|
||||
freeze: NamespaceActionMutation
|
||||
unfreeze: NamespaceActionMutation
|
||||
archive: NamespaceActionMutation
|
||||
restore: NamespaceActionMutation
|
||||
delete: NamespaceActionMutation
|
||||
}
|
||||
|
||||
type NamespaceActionToast = {
|
||||
success: (title: string, description?: string) => void
|
||||
error: (title: string, description?: string) => void
|
||||
}
|
||||
|
||||
export function resolveNamespaceActionCopy(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
action: PendingNamespaceAction['action'],
|
||||
name: string,
|
||||
): NamespaceActionCopy {
|
||||
if (action === 'freeze') {
|
||||
return {
|
||||
title: t('myNamespaces.freezeConfirmTitle'),
|
||||
description: t('myNamespaces.freezeConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.freeze'),
|
||||
successTitle: t('myNamespaces.freezeSuccessTitle'),
|
||||
successDescription: t('myNamespaces.freezeSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.freezeErrorTitle'),
|
||||
variant: 'default',
|
||||
}
|
||||
}
|
||||
if (action === 'unfreeze') {
|
||||
return {
|
||||
title: t('myNamespaces.unfreezeConfirmTitle'),
|
||||
description: t('myNamespaces.unfreezeConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.unfreeze'),
|
||||
successTitle: t('myNamespaces.unfreezeSuccessTitle'),
|
||||
successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.unfreezeErrorTitle'),
|
||||
variant: 'default',
|
||||
}
|
||||
}
|
||||
if (action === 'archive') {
|
||||
return {
|
||||
title: t('myNamespaces.archiveConfirmTitle'),
|
||||
description: t('myNamespaces.archiveConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.archive'),
|
||||
successTitle: t('myNamespaces.archiveSuccessTitle'),
|
||||
successDescription: t('myNamespaces.archiveSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.archiveErrorTitle'),
|
||||
variant: 'destructive',
|
||||
}
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return {
|
||||
title: t('myNamespaces.deleteConfirmTitle'),
|
||||
description: t('myNamespaces.deleteConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.delete'),
|
||||
successTitle: t('myNamespaces.deleteSuccessTitle'),
|
||||
successDescription: t('myNamespaces.deleteSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.deleteErrorTitle'),
|
||||
variant: 'destructive',
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: t('myNamespaces.restoreConfirmTitle'),
|
||||
description: t('myNamespaces.restoreConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.restore'),
|
||||
successTitle: t('myNamespaces.restoreSuccessTitle'),
|
||||
successDescription: t('myNamespaces.restoreSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.restoreErrorTitle'),
|
||||
variant: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeNamespaceAction(
|
||||
pendingAction: PendingNamespaceAction,
|
||||
mutations: NamespaceActionMutations,
|
||||
copy: NamespaceActionCopy,
|
||||
notifications: NamespaceActionToast,
|
||||
) {
|
||||
try {
|
||||
if (pendingAction.action === 'freeze') {
|
||||
await mutations.freeze.mutateAsync({ slug: pendingAction.slug })
|
||||
} else if (pendingAction.action === 'unfreeze') {
|
||||
await mutations.unfreeze.mutateAsync({ slug: pendingAction.slug })
|
||||
} else if (pendingAction.action === 'archive') {
|
||||
await mutations.archive.mutateAsync({ slug: pendingAction.slug })
|
||||
} else if (pendingAction.action === 'delete') {
|
||||
await mutations.delete.mutateAsync({ slug: pendingAction.slug })
|
||||
} else {
|
||||
await mutations.restore.mutateAsync({ slug: pendingAction.slug })
|
||||
}
|
||||
|
||||
notifications.success(copy.successTitle, copy.successDescription)
|
||||
} catch (error) {
|
||||
notifications.error(copy.errorTitle, error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard page for namespaces the current user can manage or review. It owns
|
||||
|
|
@ -34,6 +155,7 @@ export function MyNamespacesPage() {
|
|||
const unfreezeMutation = useUnfreezeNamespace()
|
||||
const archiveMutation = useArchiveNamespace()
|
||||
const restoreMutation = useRestoreNamespace()
|
||||
const deleteMutation = useDeleteNamespace()
|
||||
|
||||
const handleNamespaceClick = (slug: string) => {
|
||||
navigate({ to: `/space/${encodeURIComponent(slug)}` })
|
||||
|
|
@ -82,79 +204,25 @@ export function MyNamespacesPage() {
|
|||
return t('myNamespaces.activeHint')
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizes dialog copy so lifecycle operations can reuse one confirmation
|
||||
* component without scattering user-facing strings across event handlers.
|
||||
*/
|
||||
const resolveActionCopy = (action: PendingNamespaceAction['action'], name: string) => {
|
||||
if (action === 'freeze') {
|
||||
return {
|
||||
title: t('myNamespaces.freezeConfirmTitle'),
|
||||
description: t('myNamespaces.freezeConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.freeze'),
|
||||
successTitle: t('myNamespaces.freezeSuccessTitle'),
|
||||
successDescription: t('myNamespaces.freezeSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.freezeErrorTitle'),
|
||||
variant: 'default' as const,
|
||||
}
|
||||
}
|
||||
if (action === 'unfreeze') {
|
||||
return {
|
||||
title: t('myNamespaces.unfreezeConfirmTitle'),
|
||||
description: t('myNamespaces.unfreezeConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.unfreeze'),
|
||||
successTitle: t('myNamespaces.unfreezeSuccessTitle'),
|
||||
successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.unfreezeErrorTitle'),
|
||||
variant: 'default' as const,
|
||||
}
|
||||
}
|
||||
if (action === 'archive') {
|
||||
return {
|
||||
title: t('myNamespaces.archiveConfirmTitle'),
|
||||
description: t('myNamespaces.archiveConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.archive'),
|
||||
successTitle: t('myNamespaces.archiveSuccessTitle'),
|
||||
successDescription: t('myNamespaces.archiveSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.archiveErrorTitle'),
|
||||
variant: 'destructive' as const,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: t('myNamespaces.restoreConfirmTitle'),
|
||||
description: t('myNamespaces.restoreConfirmDescription', { name }),
|
||||
confirmText: t('myNamespaces.restore'),
|
||||
successTitle: t('myNamespaces.restoreSuccessTitle'),
|
||||
successDescription: t('myNamespaces.restoreSuccessDescription', { name }),
|
||||
errorTitle: t('myNamespaces.restoreErrorTitle'),
|
||||
variant: 'default' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const handleNamespaceAction = async () => {
|
||||
if (!pendingAction) {
|
||||
return
|
||||
}
|
||||
|
||||
const copy = resolveActionCopy(pendingAction.action, pendingAction.name)
|
||||
|
||||
try {
|
||||
if (pendingAction.action === 'freeze') {
|
||||
await freezeMutation.mutateAsync({ slug: pendingAction.slug })
|
||||
} else if (pendingAction.action === 'unfreeze') {
|
||||
await unfreezeMutation.mutateAsync({ slug: pendingAction.slug })
|
||||
} else if (pendingAction.action === 'archive') {
|
||||
await archiveMutation.mutateAsync({ slug: pendingAction.slug })
|
||||
} else {
|
||||
await restoreMutation.mutateAsync({ slug: pendingAction.slug })
|
||||
}
|
||||
|
||||
toast.success(copy.successTitle, copy.successDescription)
|
||||
setPendingAction(null)
|
||||
} catch (error) {
|
||||
toast.error(copy.errorTitle, error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
const copy = resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name)
|
||||
await executeNamespaceAction(
|
||||
pendingAction,
|
||||
{
|
||||
freeze: freezeMutation,
|
||||
unfreeze: unfreezeMutation,
|
||||
archive: archiveMutation,
|
||||
restore: restoreMutation,
|
||||
delete: deleteMutation,
|
||||
},
|
||||
copy,
|
||||
toast,
|
||||
)
|
||||
setPendingAction(null)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -184,6 +252,7 @@ export function MyNamespacesPage() {
|
|||
{namespaces.map((namespace, idx) => (
|
||||
<Card
|
||||
key={namespace.id}
|
||||
data-testid={`namespace-card-${namespace.slug}`}
|
||||
className={`p-6 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
|
||||
onClick={() => handleNamespaceClick(namespace.slug)}
|
||||
>
|
||||
|
|
@ -281,6 +350,19 @@ export function MyNamespacesPage() {
|
|||
{t('myNamespaces.restore')}
|
||||
</Button>
|
||||
)}
|
||||
{namespace.canDelete && (
|
||||
<Button
|
||||
data-testid={`delete-namespace-${namespace.slug}`}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPendingAction({ action: 'delete', slug: namespace.slug, name: namespace.displayName })
|
||||
}}
|
||||
>
|
||||
{t('myNamespaces.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -305,10 +387,12 @@ export function MyNamespacesPage() {
|
|||
setPendingAction(null)
|
||||
}
|
||||
}}
|
||||
title={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).title : ''}
|
||||
description={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).description : ''}
|
||||
confirmText={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).confirmText : undefined}
|
||||
variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'}
|
||||
title={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).title : ''}
|
||||
description={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).description : ''}
|
||||
confirmText={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).confirmText : undefined}
|
||||
variant={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).variant : 'default'}
|
||||
contentTestId={pendingAction ? `namespace-action-dialog-${pendingAction.action}` : undefined}
|
||||
confirmButtonTestId={pendingAction ? `namespace-action-confirm-${pendingAction.action}` : undefined}
|
||||
onConfirm={handleNamespaceAction}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ interface ConfirmDialogProps {
|
|||
cancelText?: string
|
||||
variant?: 'default' | 'destructive'
|
||||
onConfirm: () => void | Promise<void>
|
||||
contentTestId?: string
|
||||
confirmButtonTestId?: string
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
|
|
@ -30,6 +32,8 @@ export function ConfirmDialog({
|
|||
cancelText,
|
||||
variant = 'default',
|
||||
onConfirm,
|
||||
contentTestId,
|
||||
confirmButtonTestId,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedConfirmText = confirmText ?? t('dialog.confirm')
|
||||
|
|
@ -41,7 +45,7 @@ export function ConfirmDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent data-testid={contentTestId}>
|
||||
<DialogHeader className="min-w-0 text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{title}</DialogTitle>
|
||||
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
|
||||
|
|
@ -50,7 +54,7 @@ export function ConfirmDialog({
|
|||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{resolvedCancelText}
|
||||
</Button>
|
||||
<Button variant={variant} onClick={handleConfirm}>
|
||||
<Button data-testid={confirmButtonTestId} variant={variant} onClick={handleConfirm}>
|
||||
{resolvedConfirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ async function removeNamespaceMember(params: { slug: string; userId: string }):
|
|||
return namespaceApi.removeMember(params.slug, params.userId)
|
||||
}
|
||||
|
||||
async function deleteNamespace(params: { slug: string }): Promise<void> {
|
||||
return namespaceApi.delete(params.slug)
|
||||
}
|
||||
|
||||
async function batchAddNamespaceMembers(params: { slug: string; members: Array<{ userId: string; role: string }> }): Promise<BatchMemberResponse> {
|
||||
return namespaceApi.batchAddMembers(params.slug, params.members)
|
||||
}
|
||||
|
|
@ -211,3 +215,15 @@ export function useTransferNamespaceOwnership() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteNamespace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteNamespace,
|
||||
onSuccess: (_data, variables) => {
|
||||
invalidateNamespaceQueries(queryClient, variables.slug)
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
|
|||
<DialogOverlay />
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
|
||||
className
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { defineConfig } from 'vite'
|
|||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
const LEGACY_BROWSER_TARGETS = ['chrome83', 'edge83', 'firefox78', 'safari14']
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
|
|
@ -9,6 +11,15 @@ export default defineConfig({
|
|||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: LEGACY_BROWSER_TARGETS,
|
||||
cssTarget: LEGACY_BROWSER_TARGETS,
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
target: LEGACY_BROWSER_TARGETS,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ['**/node_modules/**', '**/e2e/**'],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue