From e501be9cf865f6750eab33382ddf7a044c290e41 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 23 Jun 2026 17:55:24 +0800 Subject: [PATCH] feat(promotion): improve promotion review dashboard Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/PromotionController.java | 14 +- .../skillhub/dto/PromotionResponseDto.java | 6 + .../JpaGovernanceQueryRepository.java | 6 + .../service/GovernanceWorkflowAppService.java | 9 +- .../service/PromotionPortalAppService.java | 87 ++++- .../src/main/resources/messages.properties | 4 + .../src/main/resources/messages_zh.properties | 4 + .../PromotionPortalControllerTest.java | 212 ++++++++++ .../PromotionApprovalFlowIntegrationTest.java | 85 ++++ .../PromotionPortalAppServiceTest.java | 6 + .../review/PromotionRequestRepository.java | 2 + .../jpa/PromotionRequestJpaRepository.java | 30 +- web/e2e/promotions-review.spec.ts | 365 ++++++++++++++++++ web/playwright.config.ts | 14 +- web/src/api/client.ts | 11 +- web/src/api/generated/schema.d.ts | 18 +- web/src/api/types.ts | 24 +- .../promotion/use-promotion-list.test.ts | 140 +++++-- .../features/promotion/use-promotion-list.ts | 27 +- web/src/i18n/locales/en.json | 18 +- web/src/i18n/locales/zh.json | 18 +- web/src/pages/dashboard/promotions.test.ts | 53 --- web/src/pages/dashboard/promotions.test.tsx | 225 +++++++++++ web/src/pages/dashboard/promotions.tsx | 295 +++++++++++--- 24 files changed, 1518 insertions(+), 155 deletions(-) create mode 100644 web/e2e/promotions-review.spec.ts delete mode 100644 web/src/pages/dashboard/promotions.test.ts create mode 100644 web/src/pages/dashboard/promotions.test.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 1fb9ba5ab..7b4b1a44b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -10,6 +10,8 @@ import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; @@ -79,11 +81,19 @@ public ApiResponse rejectPromotion(@PathVariable Long id, } @GetMapping - public ApiResponse> listPromotions(@RequestParam(defaultValue = "PENDING") String status, + public ApiResponse> listPromotions(@Parameter(schema = @Schema(allowableValues = {"PENDING", "APPROVED", "REJECTED"}, defaultValue = "PENDING")) + @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, + @Parameter(schema = @Schema(allowableValues = {"reviewedAt"})) + @RequestParam(required = false) String sortBy, + @Parameter(schema = @Schema(allowableValues = {"ASC", "DESC"}, defaultValue = "DESC")) + @RequestParam(required = false) String sortDirection, @RequestAttribute("userId") String userId) { - return ok("response.success.read", governanceWorkflowAppService.listPromotions(status, page, size, userId)); + return ok( + "response.success.read", + governanceWorkflowAppService.listPromotions(status, page, size, sortBy, sortDirection, userId) + ); } @GetMapping("/pending") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java index 888f447f7..62c0d5358 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java @@ -5,9 +5,15 @@ public record PromotionResponseDto( Long id, Long sourceSkillId, + String sourceSkillDisplayName, + String sourceSkillSummary, String sourceNamespace, String sourceSkillSlug, String sourceVersion, + Integer sourceVersionFileCount, + Long sourceVersionTotalSize, + Long sourceSkillDownloadCount, + Integer sourceSkillStarCount, String targetNamespace, Long targetSkillId, String status, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index 7fb2aec17..646f032e7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -200,9 +200,15 @@ private PromotionResponseDto toPromotionResponse(PromotionRequest request, Promo return new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(), + skill.getSummary(), sourceNamespace.getSlug(), skill.getSlug(), version.getVersion(), + version.getFileCount(), + version.getTotalSize(), + skill.getDownloadCount(), + skill.getStarCount(), targetNamespace.getSlug(), request.getTargetSkillId(), request.getStatus().name(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 6cca39525..9aac59d98 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -165,8 +165,13 @@ public PromotionResponseDto rejectPromotion(Long promotionId, return promotionPortalAppService.rejectPromotion(promotionId, comment, userId, auditContext); } - public PageResponse listPromotions(String status, int page, int size, String userId) { - return promotionPortalAppService.listPromotions(status, page, size, userId); + public PageResponse listPromotions(String status, + int page, + int size, + String sortBy, + String sortDirection, + String userId) { + return promotionPortalAppService.listPromotions(status, page, size, sortBy, sortDirection, userId); } public PageResponse listPendingPromotions(int page, int size, String userId) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index ef4b18886..aab28382c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -7,17 +7,21 @@ import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.slf4j.MDC; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @Service @@ -98,10 +102,12 @@ public PromotionResponseDto rejectPromotion(Long promotionId, public PageResponse listPromotions(String status, int page, int size, + String sortBy, + String sortDirection, String userId) { requirePromotionAdmin(userId); - ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); - Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + ReviewTaskStatus reviewStatus = parsePromotionStatus(status); + Page requests = findPromotionRequests(reviewStatus, page, size, sortBy, sortDirection); return PageResponse.from(new PageImpl<>( governanceQueryRepository.getPromotionResponses(requests.getContent()), requests.getPageable(), @@ -112,7 +118,16 @@ public PageResponse listPromotions(String status, public PageResponse listPendingPromotions(int page, int size, String userId) { requirePromotionAdmin(userId); Page requests = promotionRequestRepository.findByStatus( - ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + ReviewTaskStatus.PENDING, + PageRequest.of( + page, + size, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ) + ); return PageResponse.from(new PageImpl<>( governanceQueryRepository.getPromotionResponses(requests.getContent()), requests.getPageable(), @@ -129,6 +144,72 @@ public PromotionResponseDto getPromotionDetail(Long promotionId, String userId) return governanceQueryRepository.getPromotionResponse(promotion); } + private ReviewTaskStatus parsePromotionStatus(String status) { + if (status == null) { + return ReviewTaskStatus.PENDING; + } + if (status.isBlank()) { + throw new DomainBadRequestException("promotion.status.invalid", status); + } + try { + ReviewTaskStatus parsed = ReviewTaskStatus.valueOf(status.toUpperCase(Locale.ROOT)); + return switch (parsed) { + case PENDING, APPROVED, REJECTED -> parsed; + default -> throw new DomainBadRequestException("promotion.status.invalid", status); + }; + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("promotion.status.invalid", status); + } + } + + private Page findPromotionRequests(ReviewTaskStatus status, + int page, + int size, + String sortBy, + String sortDirection) { + if (status == ReviewTaskStatus.PENDING) { + if (sortBy != null || sortDirection != null) { + throw new DomainBadRequestException("promotion.sort.pending_unsupported"); + } + return promotionRequestRepository.findByStatus( + status, + PageRequest.of( + page, + size, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ) + ); + } + + if (sortBy != null && (sortBy.isBlank() || !"reviewedAt".equals(sortBy))) { + throw new DomainBadRequestException("promotion.sort.field.invalid", sortBy); + } + + Sort.Direction direction = parsePromotionSortDirection(sortDirection); + Pageable pageable = PageRequest.of(page, size); + if (direction == Sort.Direction.ASC) { + return promotionRequestRepository.findHistoryByStatusOrderByReviewedAtAsc(status, pageable); + } + return promotionRequestRepository.findHistoryByStatusOrderByReviewedAtDesc(status, pageable); + } + + private Sort.Direction parsePromotionSortDirection(String sortDirection) { + if (sortDirection == null) { + return Sort.Direction.DESC; + } + if (sortDirection.isBlank()) { + throw new DomainBadRequestException("promotion.sort.direction.invalid", sortDirection); + } + try { + return Sort.Direction.valueOf(sortDirection.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("promotion.sort.direction.invalid", sortDirection); + } + } + private void requirePromotionAdmin(String userId) { Set platformRoles = platformRoles(userId); if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8f7cf1dfc..25a631276 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -174,3 +174,7 @@ validation.auth.password.reset.code.notBlank=Verification code cannot be blank validation.auth.password.reset.code.invalid=Verification code must be 6 digits validation.auth.password.reset.newPassword.notBlank=New password cannot be blank promotion.target_skill_conflict=The target global skill "{0}" already exists +promotion.status.invalid=Unsupported promotion status: {0} +promotion.sort.field.invalid=Unsupported promotion sort field: {0} +promotion.sort.direction.invalid=Unsupported promotion sort direction: {0} +promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index e608d2475..99183ae5b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -174,3 +174,7 @@ validation.auth.password.reset.code.notBlank=验证码不能为空 validation.auth.password.reset.code.invalid=验证码必须为 6 位数字 validation.auth.password.reset.newPassword.notBlank=新密码不能为空 promotion.target_skill_conflict=目标全局技能“{0}”已存在 +promotion.status.invalid=不支持的提升审核状态:{0} +promotion.sort.field.invalid=不支持的提升审核排序字段:{0} +promotion.sort.direction.invalid=不支持的提升审核排序方向:{0} +promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java index 9369b5623..05aeecf04 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java @@ -20,6 +20,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -108,6 +111,179 @@ void listPendingPromotions_forbidsRegularUser() throws Exception { verify(promotionRequestRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } + @Test + void listPromotions_defaultsToPendingWithStableSubmittedSort() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + PageRequest pageable = PageRequest.of( + 0, + 20, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ); + given(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/v1/promotions").with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].id").value(1L)) + .andExpect(jsonPath("$.data.total").value(1)); + + verify(promotionRequestRepository).findByStatus(ReviewTaskStatus.PENDING, pageable); + } + + @Test + void listPromotions_sortsApprovedHistoryByReviewedAtDescendingByDefault() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + PageRequest pageable = PageRequest.of(1, 5); + given(promotionRequestRepository.findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus.APPROVED, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("page", "1") + .param("size", "5") + .param("sortBy", "reviewedAt") + .with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(promotionRequestRepository).findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus.APPROVED, pageable); + } + + @Test + void listPromotions_sortsRejectedHistoryByReviewedAtAscending() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SUPER_ADMIN")); + PageRequest pageable = PageRequest.of(0, 10); + given(promotionRequestRepository.findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus.REJECTED, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "REJECTED") + .param("page", "0") + .param("size", "10") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(promotionRequestRepository).findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus.REJECTED, pageable); + } + + @Test + void listPromotions_rejectsInvalidStatus() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "DONE") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("DONE"))); + } + + @Test + void listPromotions_rejectsBlankStatus() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsPendingSortFieldEvenWhenBlank() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "PENDING") + .param("sortBy", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsPendingSortDirectionEvenWhenBlank() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "PENDING") + .param("sortDirection", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsInvalidHistorySortField() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "submittedAt") + .param("sortDirection", "DESC") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("submittedAt"))); + } + + @Test + void listPromotions_rejectsInvalidHistorySortDirection() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "reviewedAt") + .param("sortDirection", "SIDEWAYS") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("SIDEWAYS"))); + } + + @Test + void listPromotions_rejectsBlankHistorySortDirection() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "reviewedAt") + .param("sortDirection", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + @Test void getPromotionDetail_allowsSubmitter() throws Exception { PromotionRequest request = createPromotionRequest(1L, "user-1"); @@ -140,9 +316,15 @@ private void stubPromotionResponse(PromotionRequest request) { given(governanceQueryRepository.getPromotionResponse(request)).willReturn(new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + "Skill A", + "Skill A summary", "team-a", "skill-a", "1.0.0", + 3, + 2048L, + 7L, + 2, "global", request.getTargetSkillId(), request.getStatus().name(), @@ -156,6 +338,36 @@ private void stubPromotionResponse(PromotionRequest request) { )); } + private void stubPromotionListResponse(List requests) { + given(governanceQueryRepository.getPromotionResponses(requests)).willReturn( + requests.stream() + .map(request -> new PromotionResponseDto( + request.getId(), + request.getSourceSkillId(), + "Skill A", + "Skill A summary", + "team-a", + "skill-a", + "1.0.0", + 3, + 2048L, + 7L, + 2, + "global", + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), + "Submitter", + request.getReviewedBy(), + null, + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() + )) + .toList() + ); + } + private void stubNamespaceRoles(String userId, List members) { given(namespaceMemberRepository.findByUserId(userId)).willReturn(members); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index a2268a67b..aaa74655d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -36,6 +36,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -44,6 +45,7 @@ import static org.mockito.Mockito.when; 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.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -106,6 +108,12 @@ void approvePromotion_persistsTargetSkillAndReturnsSuccessForBootstrapAdmin() th .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value(graph.request().getId())) + .andExpect(jsonPath("$.data.sourceSkillDisplayName").value(org.hamcrest.Matchers.startsWith("Promote Skill"))) + .andExpect(jsonPath("$.data.sourceSkillSummary").value("Used to verify promotion approval flow.")) + .andExpect(jsonPath("$.data.sourceVersionFileCount").value(0)) + .andExpect(jsonPath("$.data.sourceVersionTotalSize").value(0)) + .andExpect(jsonPath("$.data.sourceSkillDownloadCount").value(0)) + .andExpect(jsonPath("$.data.sourceSkillStarCount").value(0)) .andExpect(jsonPath("$.data.status").value("APPROVED")) .andExpect(jsonPath("$.data.reviewedBy").value(REVIEWER_ID)) .andExpect(jsonPath("$.data.reviewComment").value("ship it")); @@ -187,6 +195,75 @@ void approvePromotion_rejectsSkillAdminSelfApprovalThroughWebRoute() throws Exce assertThat(savedRequest.getTargetSkillId()).isNull(); } + @Test + @Transactional + void listPromotions_sortsApprovedAndRejectedHistoryByReviewedAtWithNullsLastAndTieBreaker() throws Exception { + when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SUPER_ADMIN")); + + assertHistorySortForStatus(ReviewTaskStatus.APPROVED, "APPROVED"); + assertHistorySortForStatus(ReviewTaskStatus.REJECTED, "REJECTED"); + } + + private void assertHistorySortForStatus(ReviewTaskStatus reviewStatus, String statusParam) throws Exception { + promotionRequestRepository.deleteAll(); + promotionRequestRepository.flush(); + + PromotionGraph latest = createPromotionGraph(); + PromotionGraph sameTimeOlderId = createPromotionGraph(); + PromotionGraph sameTimeNewerId = createPromotionGraph(); + PromotionGraph legacyNullReviewedAt = createPromotionGraph(); + + Instant sameReviewedAt = Instant.parse("2026-06-18T08:00:00Z"); + markPromotionHistory(latest.request(), reviewStatus, Instant.parse("2026-06-18T09:00:00Z")); + markPromotionHistory(sameTimeOlderId.request(), reviewStatus, sameReviewedAt); + markPromotionHistory(sameTimeNewerId.request(), reviewStatus, sameReviewedAt); + markPromotionHistory(legacyNullReviewedAt.request(), reviewStatus, null); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "0") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "DESC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "1") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "DESC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "0") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "1") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId())); + } + private PromotionGraph createPromotionGraph() { return createPromotionGraph(SUBMITTER_ID); } @@ -236,6 +313,14 @@ private void saveUserIfAbsent(String userId, String displayName, String email) { } } + private void markPromotionHistory(PromotionRequest request, ReviewTaskStatus status, Instant reviewedAt) { + request.setStatus(status); + request.setReviewedBy(REVIEWER_ID); + request.setReviewComment(status == ReviewTaskStatus.APPROVED ? "approved" : "rejected"); + request.setReviewedAt(reviewedAt); + promotionRequestRepository.saveAndFlush(request); + } + private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) { PlatformPrincipal principal = new PlatformPrincipal( userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java index abede8fc6..bc824b89e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -136,9 +136,15 @@ private PromotionResponseDto response(PromotionRequest request) { return new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + "Skill A", + "Skill A summary", "team-a", "skill-a", "1.0.0", + 3, + 2048L, + 7L, + 2, "global", request.getTargetSkillId(), request.getStatus().name(), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 05d07f04f..8bfdf245b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -14,6 +14,8 @@ public interface PromotionRequestRepository { Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); + Page findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus status, Pageable pageable); + Page findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus status, Pageable pageable); boolean existsByTargetNamespaceId(Long namespaceId); void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index c26f611e4..93e61a9fc 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -3,6 +3,7 @@ import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,7 +11,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.Optional; /** * JPA-backed repository for promotion requests, including optimistic status updates. @@ -25,6 +25,34 @@ public interface PromotionRequestJpaRepository extends JpaRepository findByStatus(ReviewTaskStatus status, Pageable pageable); + @Query( + value = """ + SELECT p + FROM PromotionRequest p + WHERE p.status = :status + ORDER BY CASE WHEN p.reviewedAt IS NULL THEN 1 ELSE 0 END ASC, + p.reviewedAt ASC, + p.id ASC + """, + countQuery = "SELECT COUNT(p) FROM PromotionRequest p WHERE p.status = :status" + ) + Page findHistoryByStatusOrderByReviewedAtAsc(@Param("status") ReviewTaskStatus status, + Pageable pageable); + + @Query( + value = """ + SELECT p + FROM PromotionRequest p + WHERE p.status = :status + ORDER BY CASE WHEN p.reviewedAt IS NULL THEN 1 ELSE 0 END ASC, + p.reviewedAt DESC, + p.id DESC + """, + countQuery = "SELECT COUNT(p) FROM PromotionRequest p WHERE p.status = :status" + ) + Page findHistoryByStatusOrderByReviewedAtDesc(@Param("status") ReviewTaskStatus status, + Pageable pageable); + boolean existsByTargetNamespaceId(Long targetNamespaceId); void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts new file mode 100644 index 000000000..af7d98a29 --- /dev/null +++ b/web/e2e/promotions-review.spec.ts @@ -0,0 +1,365 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +type PromotionStatus = 'PENDING' | 'APPROVED' | 'REJECTED' + +function promotion(id: number, status: PromotionStatus, name: string, reviewedAt: string | null = null) { + return { + id, + sourceSkillId: id + 100, + sourceSkillDisplayName: name, + sourceSkillSummary: `Summary for ${name}`, + sourceNamespace: 'team-ai', + sourceSkillSlug: name.toLowerCase().replaceAll(' ', '-'), + sourceVersion: '1.3.0', + sourceVersionFileCount: 23, + sourceVersionTotalSize: 1_843_200, + sourceSkillDownloadCount: 18, + sourceSkillStarCount: 5, + targetNamespace: 'global', + targetSkillId: status === 'PENDING' ? undefined : id + 200, + status, + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: status === 'PENDING' ? undefined : 'admin-1', + reviewedByName: status === 'PENDING' ? undefined : 'Admin One', + reviewComment: status === 'REJECTED' ? 'Needs clearer documentation before promotion.' : 'Looks good.', + submittedAt: '2026-06-18T12:00:00Z', + reviewedAt, + } +} + +test.describe('Promotion review dashboard', () => { + let unexpectedPromotionRequests: string[] + let expectedPromotionRequests: string[] + + test.beforeEach(async ({ page }) => { + unexpectedPromotionRequests = [] + expectedPromotionRequests = [] + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { + userId: 'local-admin', + displayName: 'Local Admin', + email: 'local-admin@example.com', + avatarUrl: '', + oauthProvider: 'mock', + platformRoles: ['SUPER_ADMIN'], + }, + timestamp: new Date().toISOString(), + requestId: 'e2e-auth', + }), + }) + }) + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { count: 0 }, + timestamp: new Date().toISOString(), + requestId: 'e2e-notifications', + }), + }) + }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: [], + timestamp: new Date().toISOString(), + requestId: 'e2e-namespaces', + }), + }) + }) + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }) + }) + }) + + async function installPromotionRouteMock(page: Page, expectedSignatures: string[]) { + expectedPromotionRequests = [...expectedSignatures] + + await page.route('**/api/web/promotions**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + const allowedParams = new Set(['status', 'page', 'size', 'sortBy', 'sortDirection']) + const extraParams = Array.from(url.searchParams.keys()).filter((key) => !allowedParams.has(key)) + if (request.method() !== 'GET' || url.pathname !== '/api/web/promotions' || extraParams.length > 0) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'unexpected promotion request shape', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const statusParam = url.searchParams.get('status') + if (statusParam === null) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'promotion request must include explicit status', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const statusValues: PromotionStatus[] = ['PENDING', 'APPROVED', 'REJECTED'] + if (!statusValues.includes(statusParam as PromotionStatus)) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: `unexpected status ${statusParam}`, + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const status = statusParam as PromotionStatus + const sortBy = url.searchParams.get('sortBy') + const sortDirectionParam = url.searchParams.get('sortDirection') + const requestSignature = `${status}|${sortBy ?? 'none'}|${sortDirectionParam ?? 'none'}` + const expectedSignature = expectedPromotionRequests.shift() + if (requestSignature !== expectedSignature) { + unexpectedPromotionRequests.push(`${url.toString()} expected ${expectedSignature ?? 'no more requests'}`) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'unexpected promotion request order', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + if (status === 'PENDING' && (sortBy !== null || sortDirectionParam !== null)) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'pending request must not include history sort params', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + if (status !== 'PENDING' && (sortBy !== 'reviewedAt' || !['ASC', 'DESC'].includes(sortDirectionParam ?? ''))) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'history request must include reviewedAt sort params', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const dataByStatus: Record[]> = { + PENDING: [promotion(1, 'PENDING', 'Knowledge Helper')], + APPROVED: [ + promotion(2, 'APPROVED', 'Newest Approved', '2026-06-18T09:00:00Z'), + promotion(3, 'APPROVED', 'Oldest Approved', '2026-06-17T09:00:00Z'), + ], + REJECTED: [ + promotion(4, 'REJECTED', 'Newest Rejected', '2026-06-18T08:00:00Z'), + promotion(5, 'REJECTED', 'Oldest Rejected', '2026-06-16T08:00:00Z'), + ], + } + + const items = [...dataByStatus[status]] + if (status !== 'PENDING' && sortDirectionParam === 'ASC') { + items.reverse() + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { items, total: items.length, page: 0, size: 20 }, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions', + }), + }) + }) + } + + function expectPromotionRequestsSatisfied() { + expect(unexpectedPromotionRequests).toEqual([]) + expect(expectedPromotionRequests).toEqual([]) + } + + test('shows enhanced pending cards and sorts approved/rejected history by reviewed time', async ({ page }) => { + await installPromotionRouteMock(page, [ + 'PENDING|none|none', + 'APPROVED|reviewedAt|DESC', + 'APPROVED|reviewedAt|ASC', + 'REJECTED|reviewedAt|DESC', + 'REJECTED|reviewedAt|ASC', + ]) + + const pendingRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'PENDING' + && !url.searchParams.has('sortBy') + && !url.searchParams.has('sortDirection') + }) + + await page.goto('/dashboard/promotions') + await pendingRequest + + await expect(page.getByRole('heading', { name: 'Promotion Review' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Knowledge Helper' })).toBeVisible() + await expect(page.getByText('@team-ai/knowledge-helper -> @global')).toBeVisible() + await expect(page.getByText('Summary for Knowledge Helper')).toBeVisible() + await expect(page.getByText(/Jun 18, 2026/)).toBeVisible() + await expect(page.getByText('v1.3.0')).toBeVisible() + await expect(page.getByText('Submitter Owner One')).toBeVisible() + await expect(page.getByText('23 files')).toBeVisible() + await expect(page.getByText('1.8 MB')).toBeVisible() + await expect(page.getByText('18 downloads')).toBeVisible() + await expect(page.getByText('5 stars')).toBeVisible() + + const approvedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Approved' }).click() + await approvedDescRequest + const approvedTable = page.getByRole('table', { name: 'Promotion history' }) + await expect(approvedTable).toBeVisible() + await expect(approvedTable.getByRole('row').nth(1)).toContainText('Newest Approved') + await expect(approvedTable.getByRole('row').nth(2)).toContainText('Oldest Approved') + + const approvedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).click() + await approvedAscRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + await expect(approvedTable.getByRole('row').nth(1)).toContainText('Oldest Approved') + await expect(approvedTable.getByRole('row').nth(2)).toContainText('Newest Approved') + + const rejectedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'REJECTED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Rejected' }).click() + await rejectedDescRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time ascending' })).toBeVisible() + + const rejectedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'REJECTED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).click() + await rejectedAscRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + + await page.getByRole('tab', { name: 'Approved' }).click() + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + expectPromotionRequestsSatisfied() + }) + + test('sorter can be toggled from the keyboard', async ({ page }) => { + await installPromotionRouteMock(page, [ + 'PENDING|none|none', + 'APPROVED|reviewedAt|DESC', + 'APPROVED|reviewedAt|ASC', + ]) + + await page.goto('/dashboard/promotions') + + const approvedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Approved' }).click() + await approvedDescRequest + + const approvedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).focus() + await page.keyboard.press('Enter') + await approvedAscRequest + + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + expectPromotionRequestsSatisfied() + }) +}) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 00b74867c..36a60ebfa 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,5 +1,15 @@ import { defineConfig, devices } from '@playwright/test' +const localNoProxyHosts = ['localhost', '127.0.0.1', '::1'] +const mergedNoProxy = Array.from(new Set([ + ...(process.env.NO_PROXY?.split(',').filter(Boolean) ?? []), + ...(process.env.no_proxy?.split(',').filter(Boolean) ?? []), + ...localNoProxyHosts, +])).join(',') + +process.env.NO_PROXY = mergedNoProxy +process.env.no_proxy = mergedNoProxy + export default defineConfig({ testDir: './e2e', fullyParallel: false, @@ -9,7 +19,7 @@ export default defineConfig({ workers: Number(process.env.PLAYWRIGHT_WORKERS ?? 1), reporter: 'html', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://127.0.0.1:3000', trace: 'on-first-retry', screenshot: 'on', }, @@ -21,7 +31,7 @@ export default defineConfig({ ], webServer: { command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort', - url: 'http://localhost:3000', + url: 'http://127.0.0.1:3000', reuseExistingServer: true, timeout: 120000, }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 16d2e7fee..d701fd117 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,6 +15,9 @@ import type { MergeVerifyRequest, ReviewSkillDetail, ReviewTask, + PromotionSortBy, + PromotionSortDirection, + PromotionStatus, PromotionTask, AuditLogItem, SkillSummary, @@ -899,11 +902,17 @@ export const promotionApi = { }) }, - async list(params: { status?: string; page?: number; size?: number }) { + async list(params: { status?: PromotionStatus; page?: number; size?: number; sortBy?: PromotionSortBy; sortDirection?: PromotionSortDirection }) { const searchParams = new URLSearchParams() searchParams.set('status', params.status ?? 'PENDING') searchParams.set('page', String(params.page ?? 0)) searchParams.set('size', String(params.size ?? 20)) + if (params.sortBy) { + searchParams.set('sortBy', params.sortBy) + } + if (params.sortDirection) { + searchParams.set('sortDirection', params.sortDirection) + } return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>( `${WEB_API_PREFIX}/promotions?${searchParams.toString()}`, ) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a141fb207..fe5dc5a17 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3692,9 +3692,19 @@ export interface components { id?: number; /** Format: int64 */ sourceSkillId?: number; + sourceSkillDisplayName?: string; + sourceSkillSummary?: string; sourceNamespace?: string; sourceSkillSlug?: string; sourceVersion?: string; + /** Format: int32 */ + sourceVersionFileCount?: number; + /** Format: int64 */ + sourceVersionTotalSize?: number; + /** Format: int64 */ + sourceSkillDownloadCount?: number; + /** Format: int32 */ + sourceSkillStarCount?: number; targetNamespace?: string; /** Format: int64 */ targetSkillId?: number; @@ -6933,9 +6943,11 @@ export interface operations { listPromotions: { parameters: { query?: { - status?: string; + status?: "PENDING" | "APPROVED" | "REJECTED"; page?: number; size?: number; + sortBy?: "reviewedAt"; + sortDirection?: "ASC" | "DESC"; }; header?: never; path?: never; @@ -6981,9 +6993,11 @@ export interface operations { listPromotions_1: { parameters: { query?: { - status?: string; + status?: "PENDING" | "APPROVED" | "REJECTED"; page?: number; size?: number; + sortBy?: "reviewedAt"; + sortDirection?: "ASC" | "DESC"; }; header?: never; path?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41d..60bef288e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -385,22 +385,32 @@ export interface ReviewSkillDetail { activeVersion: string } +export type PromotionStatus = 'PENDING' | 'APPROVED' | 'REJECTED' +export type PromotionSortDirection = 'ASC' | 'DESC' +export type PromotionSortBy = 'reviewedAt' + export interface PromotionTask { id: number sourceSkillId: number + sourceSkillDisplayName: string + sourceSkillSummary?: string | null sourceNamespace: string sourceSkillSlug: string sourceVersion: string + sourceVersionFileCount: number + sourceVersionTotalSize: number + sourceSkillDownloadCount: number + sourceSkillStarCount: number targetNamespace: string - targetSkillId?: number - status: 'PENDING' | 'APPROVED' | 'REJECTED' + targetSkillId?: number | null + status: PromotionStatus submittedBy: string - submittedByName?: string - reviewedBy?: string - reviewedByName?: string - reviewComment?: string + submittedByName?: string | null + reviewedBy?: string | null + reviewedByName?: string | null + reviewComment?: string | null submittedAt: string - reviewedAt?: string + reviewedAt?: string | null } export interface SkillReport { diff --git a/web/src/features/promotion/use-promotion-list.test.ts b/web/src/features/promotion/use-promotion-list.test.ts index 638658f14..580713f16 100644 --- a/web/src/features/promotion/use-promotion-list.test.ts +++ b/web/src/features/promotion/use-promotion-list.test.ts @@ -1,35 +1,121 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './use-promotion-list' - -/** - * use-promotion-list.ts exports four hooks (usePromotionList, - * usePromotionDetail, useApprovePromotion, useRejectPromotion) and - * re-exports the PromotionTask type. All hooks are thin wrappers around - * useQuery/useMutation with no exported pure helpers, query-key functions, - * or data transformations beyond unwrapping the backend page object - * (which cannot be tested without an API client mock). - * - * We verify the export contract so downstream consumers break fast if - * the module shape changes. - */ -describe('use-promotion-list module exports', () => { - it('exports usePromotionList as a function', () => { - expect(mod.usePromotionList).toBeDefined() - expect(typeof mod.usePromotionList).toBe('function') +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PromotionTask } from '@/api/types' + +const mocks = vi.hoisted(() => ({ + invalidateQueries: vi.fn(), + useMutation: vi.fn(), + useQuery: vi.fn(), + promotionList: vi.fn(), + promotionGet: vi.fn(), + promotionApprove: vi.fn(), + promotionReject: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: mocks.useMutation, + useQuery: (options: unknown) => mocks.useQuery(options), + useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), +})) + +vi.mock('@/api/client', () => ({ + promotionApi: { + list: (...args: unknown[]) => mocks.promotionList(...args), + get: (...args: unknown[]) => mocks.promotionGet(...args), + approve: (...args: unknown[]) => mocks.promotionApprove(...args), + reject: (...args: unknown[]) => mocks.promotionReject(...args), + }, +})) + +import { usePromotionList } from './use-promotion-list' + +const promotion = { + id: 1, + sourceSkillId: 10, + sourceSkillDisplayName: 'Code Review Bot', + sourceSkillSummary: 'Reviews code changes.', + sourceNamespace: 'team-ai', + sourceSkillSlug: 'code-review-bot', + sourceVersion: '1.0.0', + sourceVersionFileCount: 3, + sourceVersionTotalSize: 2048, + sourceSkillDownloadCount: 7, + sourceSkillStarCount: 2, + targetNamespace: 'global', + targetSkillId: null, + status: 'PENDING', + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: null, + reviewedByName: null, + reviewComment: null, + submittedAt: '2026-06-18T01:00:00Z', + reviewedAt: null, +} satisfies PromotionTask + +describe('usePromotionList', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useQuery.mockImplementation((options: unknown) => options) + mocks.promotionList.mockResolvedValue({ items: [promotion], total: 1, page: 0, size: 20 }) }) - it('exports usePromotionDetail as a function', () => { - expect(mod.usePromotionDetail).toBeDefined() - expect(typeof mod.usePromotionDetail).toBe('function') + it('defaults to the pending queue without history sort params', async () => { + usePromotionList() + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise } + + expect(options.queryKey).toEqual(['promotions', { + status: 'PENDING', + page: 0, + size: 20, + sortBy: undefined, + sortDirection: undefined, + }]) + await expect(options.queryFn()).resolves.toEqual([promotion]) + expect(mocks.promotionList).toHaveBeenCalledWith({ + status: 'PENDING', + page: 0, + size: 20, + sortBy: undefined, + sortDirection: undefined, + }) }) - it('exports useApprovePromotion as a function', () => { - expect(mod.useApprovePromotion).toBeDefined() - expect(typeof mod.useApprovePromotion).toBe('function') + it('passes reviewed-time sort params for history queues', async () => { + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'ASC' }) + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise } + + expect(options.queryKey).toEqual(['promotions', { + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'ASC', + }]) + await options.queryFn() + expect(mocks.promotionList).toHaveBeenCalledWith({ + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'ASC', + }) }) - it('exports useRejectPromotion as a function', () => { - expect(mod.useRejectPromotion).toBeDefined() - expect(typeof mod.useRejectPromotion).toBe('function') + it('uses different query keys for opposite history sort directions', () => { + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'ASC' }) + const ascKey = mocks.useQuery.mock.calls[0]?.[0].queryKey + + mocks.useQuery.mockClear() + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'DESC' }) + const descKey = mocks.useQuery.mock.calls[0]?.[0].queryKey + + expect(ascKey).not.toEqual(descKey) + expect(descKey).toEqual(['promotions', { + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'DESC', + }]) }) }) diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts index 74d885132..db0b1a145 100644 --- a/web/src/features/promotion/use-promotion-list.ts +++ b/web/src/features/promotion/use-promotion-list.ts @@ -1,18 +1,35 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { promotionApi } from '@/api/client' -import type { PromotionTask } from '@/api/types' +import type { PromotionSortBy, PromotionSortDirection, PromotionStatus, PromotionTask } from '@/api/types' + +export interface PromotionListParams { + status?: PromotionStatus + page?: number + size?: number + sortBy?: PromotionSortBy + sortDirection?: PromotionSortDirection +} /** * Returns the promotion queue for a given status. The hook unwraps the backend * page object because promotion screens currently consume the item list only. */ -export function usePromotionList(status = 'PENDING') { +export function usePromotionList(params: PromotionListParams = { status: 'PENDING' }) { + const normalizedParams = { + status: params.status ?? 'PENDING', + page: params.page ?? 0, + size: params.size ?? 20, + sortBy: params.sortBy, + sortDirection: params.sortDirection, + } + return useQuery({ - queryKey: ['promotions', status], + queryKey: ['promotions', normalizedParams], queryFn: async () => { - const page = await promotionApi.list({ status }) + const page = await promotionApi.list(normalizedParams) return page.items }, + staleTime: 30_000, }) } @@ -56,4 +73,4 @@ export function useRejectPromotion() { }) } -export type { PromotionTask } +export type { PromotionSortDirection, PromotionStatus, PromotionTask } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 56039107a..b060534a8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -554,7 +554,23 @@ "commentPlaceholder": "Review comment (optional)", "approve": "Approve", "reject": "Reject", - "empty": "No promotion requests" + "empty": "No promotion requests", + "historyTableLabel": "Promotion history", + "colSkill": "Skill", + "colVersion": "Version", + "colSubmitter": "Submitter", + "colReviewer": "Reviewer", + "colReviewedAt": "Reviewed At", + "colReviewComment": "Review Comment", + "sortReviewedTimeAsc": "Sort by reviewed time ascending", + "sortReviewedTimeDesc": "Sort by reviewed time descending", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "Submitter {{user}}", + "fileCountTag": "{{count}} files", + "packageSizeTag": "{{size}}", + "downloadCountTag": "{{value}} downloads", + "starCountTag": "{{value}} stars" }, "adminUsers": { "title": "User Management", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 1920b1584..d0d7c23a3 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -554,7 +554,23 @@ "commentPlaceholder": "审核意见(可选)", "approve": "通过", "reject": "拒绝", - "empty": "暂无提升申请" + "empty": "暂无提升申请", + "historyTableLabel": "提升审核历史", + "colSkill": "技能", + "colVersion": "版本", + "colSubmitter": "提交人", + "colReviewer": "审核人", + "colReviewedAt": "处理时间", + "colReviewComment": "审核意见", + "sortReviewedTimeAsc": "按处理时间正序排序", + "sortReviewedTimeDesc": "按处理时间倒序排序", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "提交人 {{user}}", + "fileCountTag": "{{count}} 个文件", + "packageSizeTag": "{{size}}", + "downloadCountTag": "{{value}} 次下载", + "starCountTag": "{{value}} 个星标" }, "adminUsers": { "title": "用户管理", diff --git a/web/src/pages/dashboard/promotions.test.ts b/web/src/pages/dashboard/promotions.test.ts deleted file mode 100644 index 827cd6863..000000000 --- a/web/src/pages/dashboard/promotions.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('react-i18next', async () => { - const actual = await vi.importActual('react-i18next') - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => key, - i18n: { language: 'en' }, - }), - } -}) - -vi.mock('@/features/promotion/use-promotion-list', () => ({ - useApprovePromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), - usePromotionList: () => ({ data: [], isLoading: false }), - useRejectPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), -})) - -vi.mock('@/shared/lib/date-time', () => ({ - formatLocalDateTime: (v: string) => v, -})) - -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - -vi.mock('@/shared/ui/tabs', () => ({ - Tabs: ({ children }: { children: unknown }) => children, - TabsContent: ({ children }: { children: unknown }) => children, - TabsList: ({ children }: { children: unknown }) => children, - TabsTrigger: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/components/dashboard-page-header', () => ({ - DashboardPageHeader: () => null, -})) - -import { PromotionsPage } from './promotions' - -describe('PromotionsPage', () => { - it('exports a named component function', () => { - expect(typeof PromotionsPage).toBe('function') - }) -}) diff --git a/web/src/pages/dashboard/promotions.test.tsx b/web/src/pages/dashboard/promotions.test.tsx new file mode 100644 index 000000000..a04bc2191 --- /dev/null +++ b/web/src/pages/dashboard/promotions.test.tsx @@ -0,0 +1,225 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PromotionStatus, PromotionTask } from '@/api/types' + +const mocks = vi.hoisted(() => ({ + approveMutate: vi.fn(), + rejectMutate: vi.fn(), + usePromotionList: vi.fn(), + translations: { + 'promotions.approve': 'Approve', + 'promotions.colReviewComment': 'Review Comment', + 'promotions.colReviewedAt': 'Reviewed At', + 'promotions.colReviewer': 'Reviewer', + 'promotions.colSkill': 'Skill', + 'promotions.colSubmitter': 'Submitter', + 'promotions.colVersion': 'Version', + 'promotions.commentPlaceholder': 'Review comment (optional)', + 'promotions.downloadCountTag': '{{value}} downloads', + 'promotions.empty': 'No promotion requests', + 'promotions.emptyValue': '-', + 'promotions.fileCountTag': '{{count}} files', + 'promotions.historyTableLabel': 'Promotion history', + 'promotions.packageSizeTag': '{{size}}', + 'promotions.reject': 'Reject', + 'promotions.sortReviewedTimeAsc': 'Sort by reviewed time ascending', + 'promotions.sortReviewedTimeDesc': 'Sort by reviewed time descending', + 'promotions.starCountTag': '{{value}} stars', + 'promotions.submitterTag': 'Submitter {{user}}', + 'promotions.subtitle': 'Review promotion requests', + 'promotions.tabApproved': 'Approved', + 'promotions.tabPending': 'Pending', + 'promotions.tabRejected': 'Rejected', + 'promotions.title': 'Promotion Review', + 'promotions.versionTag': 'v{{version}}', + } as Record, +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + i18n: { language: 'en' }, + t: (key: string, values?: Record) => { + const template = mocks.translations[key] ?? key + return Object.entries(values ?? {}).reduce( + (result, [name, value]) => result.split(`{{${name}}}`).join(String(value)), + template, + ) + }, + }), + } +}) + +vi.mock('@/features/promotion/use-promotion-list', () => ({ + useApprovePromotion: () => ({ mutate: mocks.approveMutate, isPending: false }), + usePromotionList: (params: unknown) => mocks.usePromotionList(params), + useRejectPromotion: () => ({ mutate: mocks.rejectMutate, isPending: false }), +})) + +vi.mock('@/shared/components/dashboard-page-header', () => ({ + DashboardPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) => ( +
+

{title}

+

{subtitle}

+
+ ), +})) + +import { PromotionsPage } from './promotions' + +function createPromotion(overrides: Partial = {}): PromotionTask { + return { + id: 1, + sourceSkillId: 101, + sourceSkillDisplayName: 'Knowledge Helper', + sourceSkillSummary: 'Summary for Knowledge Helper', + sourceNamespace: 'team-ai', + sourceSkillSlug: 'knowledge-helper', + sourceVersion: '1.3.0', + sourceVersionFileCount: 23, + sourceVersionTotalSize: 1_843_200, + sourceSkillDownloadCount: 18, + sourceSkillStarCount: 5, + targetNamespace: 'global', + targetSkillId: null, + status: 'PENDING', + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: null, + reviewedByName: null, + reviewComment: null, + submittedAt: '2026-06-18T12:00:00Z', + reviewedAt: null, + ...overrides, + } +} + +function installPromotionListMock(overrides: { + pending?: PromotionTask[] + approvedDesc?: PromotionTask[] + approvedAsc?: PromotionTask[] + rejectedDesc?: PromotionTask[] + rejectedAsc?: PromotionTask[] +} = {}) { + const pending = overrides.pending ?? [createPromotion()] + const approvedDesc = overrides.approvedDesc ?? [ + createPromotion({ + id: 2, + status: 'APPROVED', + sourceSkillDisplayName: 'Newest Approved', + sourceSkillSlug: 'newest-approved', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Looks good.', + reviewedAt: '2026-06-18T09:00:00Z', + }), + createPromotion({ + id: 3, + status: 'APPROVED', + sourceSkillDisplayName: 'Oldest Approved', + sourceSkillSlug: 'oldest-approved', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Approved after review.', + reviewedAt: '2026-06-17T09:00:00Z', + }), + ] + const rejectedDesc = overrides.rejectedDesc ?? [ + createPromotion({ + id: 4, + status: 'REJECTED', + sourceSkillDisplayName: 'Newest Rejected', + sourceSkillSlug: 'newest-rejected', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Needs clearer docs before promotion.', + reviewedAt: '2026-06-18T08:00:00Z', + }), + createPromotion({ + id: 5, + status: 'REJECTED', + sourceSkillDisplayName: 'Oldest Rejected', + sourceSkillSlug: 'oldest-rejected', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: null, + reviewedAt: '2026-06-16T08:00:00Z', + }), + ] + const approvedAsc = overrides.approvedAsc ?? [...approvedDesc].reverse() + const rejectedAsc = overrides.rejectedAsc ?? [...rejectedDesc].reverse() + + mocks.usePromotionList.mockImplementation((params: { status?: PromotionStatus; sortDirection?: 'ASC' | 'DESC' } = {}) => { + if (params.status === 'APPROVED') { + return { data: params.sortDirection === 'ASC' ? approvedAsc : approvedDesc, isLoading: false } + } + if (params.status === 'REJECTED') { + return { data: params.sortDirection === 'ASC' ? rejectedAsc : rejectedDesc, isLoading: false } + } + return { data: pending, isLoading: false } + }) +} + +describe('PromotionsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + installPromotionListMock() + }) + + afterEach(() => cleanup()) + + it('renders enhanced pending card review context', () => { + render() + + expect(screen.getByRole('heading', { name: 'Promotion Review' })).toBeTruthy() + expect(screen.getByText('Knowledge Helper')).toBeTruthy() + expect(screen.getByText('@team-ai/knowledge-helper -> @global')).toBeTruthy() + expect(screen.getByText('Summary for Knowledge Helper')).toBeTruthy() + expect(screen.getByText('v1.3.0')).toBeTruthy() + expect(screen.getByText('Submitter Owner One')).toBeTruthy() + expect(screen.getByText('23 files')).toBeTruthy() + expect(screen.getByText('1.8 MB')).toBeTruthy() + expect(screen.getByText('18 downloads')).toBeTruthy() + expect(screen.getByText('5 stars')).toBeTruthy() + }) + + it('renders approved history as a sortable table', () => { + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + const table = screen.getByRole('table', { name: 'Promotion history' }) + let rows = within(table).getAllByRole('row') + expect(rows[1]?.textContent).toContain('Newest Approved') + expect(rows[2]?.textContent).toContain('Oldest Approved') + + const ascendingButton = screen.getByRole('button', { name: 'Sort by reviewed time ascending' }) + expect(ascendingButton.closest('th')?.getAttribute('aria-sort')).toBe('descending') + expect(ascendingButton.querySelector('[aria-hidden="true"]')).toBeTruthy() + + fireEvent.click(ascendingButton) + rows = within(screen.getByRole('table', { name: 'Promotion history' })).getAllByRole('row') + expect(rows[1]?.textContent).toContain('Oldest Approved') + expect(rows[2]?.textContent).toContain('Newest Approved') + const descendingButton = screen.getByRole('button', { name: 'Sort by reviewed time descending' }) + expect(descendingButton.closest('th')?.getAttribute('aria-sort')).toBe('ascending') + }) + + it('keeps approved and rejected sort state independent', () => { + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + fireEvent.click(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: 'Rejected' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + }) +}) diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx index a342443e4..7ebcf9012 100644 --- a/web/src/pages/dashboard/promotions.tsx +++ b/web/src/pages/dashboard/promotions.tsx @@ -1,20 +1,131 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { formatLocalDateTime } from '@/shared/lib/date-time' +import { formatCompactCount } from '@/shared/lib/number-format' +import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/shared/ui/table' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' -import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import type { PromotionTask } from '@/api/types' +import type { PromotionSortDirection, PromotionStatus } from '@/features/promotion/use-promotion-list' -/** - * Renders one promotion queue lane. Pending items expose moderation actions, - * while historical lanes stay read-only and surface the review comment only. - */ -function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) { +type HistoryPromotionStatus = Extract + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B` + } + const units = ['KB', 'MB', 'GB'] + let value = bytes / 1024 + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}` +} + +function formatUserName(displayName: string | null | undefined, userId: string | null | undefined, fallback: string) { + return displayName || userId || fallback +} + +function sourceCoordinate(item: PromotionTask) { + return `@${item.sourceNamespace}/${item.sourceSkillSlug}` +} + +function promotionCoordinate(item: PromotionTask) { + return `${sourceCoordinate(item)} -> @${item.targetNamespace}` +} + +function SorterGlyph({ direction }: { direction: PromotionSortDirection }) { + return ( +