feat(promotion): improve promotion review dashboard

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-23 17:55:24 +08:00
parent dc185861d4
commit e501be9cf8
24 changed files with 1517 additions and 154 deletions

View file

@ -10,6 +10,8 @@ import com.iflytek.skillhub.dto.PromotionRequestDto;
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 class PromotionController extends BaseApiController {
}
@GetMapping
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(@RequestParam(defaultValue = "PENDING") String status,
public ApiResponse<PageResponse<PromotionResponseDto>> 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")

View file

@ -5,9 +5,15 @@ import java.time.Instant;
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,

View file

@ -200,9 +200,15 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
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(),

View file

@ -165,8 +165,13 @@ public class GovernanceWorkflowAppService {
return promotionPortalAppService.rejectPromotion(promotionId, comment, userId, auditContext);
}
public PageResponse<PromotionResponseDto> listPromotions(String status, int page, int size, String userId) {
return promotionPortalAppService.listPromotions(status, page, size, userId);
public PageResponse<PromotionResponseDto> listPromotions(String status,
int page,
int size,
String sortBy,
String sortDirection,
String userId) {
return promotionPortalAppService.listPromotions(status, page, size, sortBy, sortDirection, userId);
}
public PageResponse<PromotionResponseDto> listPendingPromotions(int page, int size, String userId) {

View file

@ -7,17 +7,21 @@ import com.iflytek.skillhub.domain.review.PromotionRequest;
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 class PromotionPortalAppService {
public PageResponse<PromotionResponseDto> listPromotions(String status,
int page,
int size,
String sortBy,
String sortDirection,
String userId) {
requirePromotionAdmin(userId);
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
Page<PromotionRequest> requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size));
ReviewTaskStatus reviewStatus = parsePromotionStatus(status);
Page<PromotionRequest> requests = findPromotionRequests(reviewStatus, page, size, sortBy, sortDirection);
return PageResponse.from(new PageImpl<>(
governanceQueryRepository.getPromotionResponses(requests.getContent()),
requests.getPageable(),
@ -112,7 +118,16 @@ public class PromotionPortalAppService {
public PageResponse<PromotionResponseDto> listPendingPromotions(int page, int size, String userId) {
requirePromotionAdmin(userId);
Page<PromotionRequest> 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 class PromotionPortalAppService {
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<PromotionRequest> 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<String> platformRoles = platformRoles(userId);
if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {

View file

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

View file

@ -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=待审核提升请求不支持按处理时间排序

View file

@ -20,6 +20,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
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 @@ class PromotionPortalControllerTest {
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 @@ class PromotionPortalControllerTest {
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 @@ class PromotionPortalControllerTest {
));
}
private void stubPromotionListResponse(List<PromotionRequest> 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<NamespaceMember> members) {
given(namespaceMemberRepository.findByUserId(userId)).willReturn(members);
}

View file

@ -36,6 +36,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
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.verify;
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 @@ class PromotionApprovalFlowIntegrationTest {
.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 @@ class PromotionApprovalFlowIntegrationTest {
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 @@ class PromotionApprovalFlowIntegrationTest {
}
}
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,

View file

@ -136,9 +136,15 @@ class PromotionPortalAppServiceTest {
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(),

View file

@ -14,6 +14,8 @@ public interface PromotionRequestRepository {
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
Optional<PromotionRequest> findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status);
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<PromotionRequest> findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus status, Pageable pageable);
Page<PromotionRequest> findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus status, Pageable pageable);
boolean existsByTargetNamespaceId(Long namespaceId);
void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.infra.jpa;
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.Modifying;
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<PromotionRe
Page<PromotionRequest> 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<PromotionRequest> 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<PromotionRequest> findHistoryByStatusOrderByReviewedAtDesc(@Param("status") ReviewTaskStatus status,
Pageable pageable);
boolean existsByTargetNamespaceId(Long targetNamespaceId);
void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId);

View file

@ -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<PromotionStatus, ReturnType<typeof promotion>[]> = {
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()
})
})

View file

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

View file

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

View file

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

View file

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

View file

@ -1,35 +1,121 @@
import { describe, expect, it } from 'vitest'
import * as mod from './use-promotion-list'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PromotionTask } from '@/api/types'
/**
* 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')
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<PromotionTask[]> }
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<PromotionTask[]> }
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',
}])
})
})

View file

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

View file

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

View file

@ -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": "用户管理",

View file

@ -1,53 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('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')
})
})

View file

@ -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<string, string>,
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
i18n: { language: 'en' },
t: (key: string, values?: Record<string, unknown>) => {
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 }) => (
<header>
<h1>{title}</h1>
<p>{subtitle}</p>
</header>
),
}))
import { PromotionsPage } from './promotions'
function createPromotion(overrides: Partial<PromotionTask> = {}): 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(<PromotionsPage />)
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(<PromotionsPage />)
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(<PromotionsPage />)
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()
})
})

View file

@ -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<PromotionStatus, 'APPROVED' | 'REJECTED'>
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 (
<span aria-hidden="true" className="flex h-4 w-3 flex-col items-center justify-center gap-0.5">
<span
className={cn(
'h-0 w-0 border-x-[4px] border-b-[5px] border-x-transparent',
direction === 'ASC' ? 'border-b-foreground' : 'border-b-muted-foreground/45'
)}
/>
<span
className={cn(
'h-0 w-0 border-x-[4px] border-t-[5px] border-x-transparent',
direction === 'DESC' ? 'border-t-foreground' : 'border-t-muted-foreground/45'
)}
/>
</span>
)
}
function PendingPromotionCard({
item,
comment,
isMutating,
onCommentChange,
onApprove,
onReject,
}: {
item: PromotionTask
comment: string
isMutating: boolean
onCommentChange: (value: string) => void
onApprove: () => void
onReject: () => void
}) {
const { t, i18n } = useTranslation()
const { data: items, isLoading } = usePromotionList(status)
const submitter = formatUserName(item.submittedByName, item.submittedBy, t('promotions.emptyValue'))
return (
<Card className="space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-1">
<h3 className="break-words font-heading text-base font-semibold text-foreground">{item.sourceSkillDisplayName}</h3>
<p className="break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">{promotionCoordinate(item)}</p>
</div>
<div className="shrink-0 text-sm text-muted-foreground">{formatLocalDateTime(item.submittedAt, i18n.language)}</div>
</div>
{item.sourceSkillSummary ? (
<p className="text-sm leading-6 text-muted-foreground break-words [overflow-wrap:anywhere] line-clamp-2">
{item.sourceSkillSummary}
</p>
) : null}
<div className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<span>{t('promotions.versionTag', { version: item.sourceVersion })}</span>
<span>{t('promotions.submitterTag', { user: submitter })}</span>
<span>{t('promotions.fileCountTag', { count: item.sourceVersionFileCount })}</span>
<span>{t('promotions.packageSizeTag', { size: formatFileSize(item.sourceVersionTotalSize) })}</span>
<span>{t('promotions.downloadCountTag', { value: formatCompactCount(item.sourceSkillDownloadCount) })}</span>
<span>{t('promotions.starCountTag', { value: formatCompactCount(item.sourceSkillStarCount) })}</span>
</div>
<Input
placeholder={t('promotions.commentPlaceholder')}
value={comment}
onChange={(event) => onCommentChange(event.target.value)}
/>
<div className="flex flex-wrap gap-3">
<Button onClick={onApprove} disabled={isMutating}>
{t('promotions.approve')}
</Button>
<Button variant="destructive" onClick={onReject} disabled={isMutating}>
{t('promotions.reject')}
</Button>
</div>
</Card>
)
}
function PendingPromotionList() {
const { t } = useTranslation()
const { data: items, isLoading } = usePromotionList({ status: 'PENDING' })
const approveMutation = useApprovePromotion()
const rejectMutation = useRejectPromotion()
const [commentById, setCommentById] = useState<Record<number, string>>({})
@ -24,59 +135,133 @@ function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECT
}
if (!items || items.length === 0) {
return <Card className="p-10 text-center text-muted-foreground">{t('promotions.empty')}</Card>
return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('promotions.empty')}</div>
}
const isMutating = approveMutation.isPending || rejectMutation.isPending
return (
<div className="space-y-4">
{items.map((item) => (
<Card key={item.id} className="p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold font-heading">{item.sourceNamespace}/{item.sourceSkillSlug}</div>
<div className="text-sm text-muted-foreground">
{item.sourceVersion} {'->'} @{item.targetNamespace}
</div>
</div>
<div className="text-sm text-muted-foreground">{formatLocalDateTime(item.submittedAt, i18n.language)}</div>
</div>
{status === 'PENDING' ? (
<>
<Input
placeholder={t('promotions.commentPlaceholder')}
value={commentById[item.id] ?? ''}
onChange={(event) => setCommentById((prev) => ({ ...prev, [item.id]: event.target.value }))}
/>
<div className="flex gap-3">
<Button
onClick={() => approveMutation.mutate({ id: item.id, comment: commentById[item.id] })}
disabled={approveMutation.isPending || rejectMutation.isPending}
>
{t('promotions.approve')}
</Button>
<Button
variant="destructive"
onClick={() => rejectMutation.mutate({ id: item.id, comment: commentById[item.id] })}
disabled={approveMutation.isPending || rejectMutation.isPending}
>
{t('promotions.reject')}
</Button>
</div>
</>
) : item.reviewComment ? (
<p className="text-sm text-muted-foreground">{item.reviewComment}</p>
) : null}
</Card>
<PendingPromotionCard
key={item.id}
item={item}
comment={commentById[item.id] ?? ''}
isMutating={isMutating}
onCommentChange={(value) => setCommentById((prev) => ({ ...prev, [item.id]: value }))}
onApprove={() => approveMutation.mutate({ id: item.id, comment: commentById[item.id] })}
onReject={() => rejectMutation.mutate({ id: item.id, comment: commentById[item.id] })}
/>
))}
</div>
)
}
function PromotionHistoryTable({
status,
sortDirection,
onToggleSort,
}: {
status: HistoryPromotionStatus
sortDirection: PromotionSortDirection
onToggleSort: () => void
}) {
const { t, i18n } = useTranslation()
const { data: items, isLoading } = usePromotionList({ status, sortBy: 'reviewedAt', sortDirection })
const nextDirection = sortDirection === 'DESC' ? 'ASC' : 'DESC'
const sortLabel = nextDirection === 'ASC' ? t('promotions.sortReviewedTimeAsc') : t('promotions.sortReviewedTimeDesc')
if (isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-16 animate-shimmer rounded-xl" />
))}
</div>
)
}
if (!items || items.length === 0) {
return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('promotions.empty')}</div>
}
return (
<div className="overflow-hidden rounded-xl border border-border/60">
<Table aria-label={t('promotions.historyTableLabel')}>
<TableHeader>
<TableRow className="bg-muted/35">
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('promotions.colSkill')}</TableHead>
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('promotions.colVersion')}</TableHead>
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('promotions.colSubmitter')}</TableHead>
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('promotions.colReviewer')}</TableHead>
<TableHead aria-sort={sortDirection === 'DESC' ? 'descending' : 'ascending'} className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-2 px-0 text-xs uppercase tracking-[0.18em] text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label={sortLabel}
onClick={onToggleSort}
>
{t('promotions.colReviewedAt')}
<SorterGlyph direction={sortDirection} />
</Button>
</TableHead>
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('promotions.colReviewComment')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const reviewCommentId = `promotion-review-comment-${item.id}`
return (
<TableRow key={item.id}>
<TableCell>
<div className="min-w-0">
<div className="break-words font-medium text-foreground">{item.sourceSkillDisplayName}</div>
<div className="break-words text-xs text-muted-foreground [overflow-wrap:anywhere]">{sourceCoordinate(item)}</div>
</div>
</TableCell>
<TableCell>{t('promotions.versionTag', { version: item.sourceVersion })}</TableCell>
<TableCell>{formatUserName(item.submittedByName, item.submittedBy, t('promotions.emptyValue'))}</TableCell>
<TableCell>{formatUserName(item.reviewedByName, item.reviewedBy, t('promotions.emptyValue'))}</TableCell>
<TableCell>{item.reviewedAt ? formatLocalDateTime(item.reviewedAt, i18n.language) : t('promotions.emptyValue')}</TableCell>
<TableCell className="max-w-[18rem]">
{item.reviewComment ? (
<>
<p id={`${reviewCommentId}-visible`} aria-describedby={reviewCommentId} className="line-clamp-2 break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">
{item.reviewComment}
</p>
<span id={reviewCommentId} className="sr-only">{item.reviewComment}</span>
</>
) : (
t('promotions.emptyValue')
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
/**
* Dashboard page for namespace promotion requests.
*/
export function PromotionsPage() {
const { t } = useTranslation()
const [historySortDirection, setHistorySortDirection] = useState<Record<HistoryPromotionStatus, PromotionSortDirection>>({
APPROVED: 'DESC',
REJECTED: 'DESC',
})
function toggleHistorySort(status: HistoryPromotionStatus) {
setHistorySortDirection((current) => ({
...current,
[status]: current[status] === 'DESC' ? 'ASC' : 'DESC',
}))
}
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('promotions.title')} subtitle={t('promotions.subtitle')} />
@ -86,9 +271,23 @@ export function PromotionsPage() {
<TabsTrigger value="APPROVED">{t('promotions.tabApproved')}</TabsTrigger>
<TabsTrigger value="REJECTED">{t('promotions.tabRejected')}</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-6"><PromotionSection status="PENDING" /></TabsContent>
<TabsContent value="APPROVED" className="mt-6"><PromotionSection status="APPROVED" /></TabsContent>
<TabsContent value="REJECTED" className="mt-6"><PromotionSection status="REJECTED" /></TabsContent>
<TabsContent value="PENDING" className="mt-6">
<PendingPromotionList />
</TabsContent>
<TabsContent value="APPROVED" className="mt-6">
<PromotionHistoryTable
status="APPROVED"
sortDirection={historySortDirection.APPROVED}
onToggleSort={() => toggleHistorySort('APPROVED')}
/>
</TabsContent>
<TabsContent value="REJECTED" className="mt-6">
<PromotionHistoryTable
status="REJECTED"
sortDirection={historySortDirection.REJECTED}
onToggleSort={() => toggleHistorySort('REJECTED')}
/>
</TabsContent>
</Tabs>
</div>
)