mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(promotion): improve promotion review dashboard
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
dc185861d4
commit
e501be9cf8
24 changed files with 1517 additions and 154 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=待审核提升请求不支持按处理时间排序
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
365
web/e2e/promotions-review.spec.ts
Normal file
365
web/e2e/promotions-review.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
)
|
||||
|
|
|
|||
18
web/src/api/generated/schema.d.ts
vendored
18
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "用户管理",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
225
web/src/pages/dashboard/promotions.test.tsx
Normal file
225
web/src/pages/dashboard/promotions.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue