mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
fix(review): complete progress history workflow
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
b8b0fba3d4
commit
3e77365a5d
31 changed files with 793 additions and 74 deletions
|
|
@ -8,14 +8,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
|
|||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewActionRequest;
|
||||
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewTaskRequest;
|
||||
import com.iflytek.skillhub.dto.ReviewTaskResponse;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -139,7 +139,7 @@ public class ReviewController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping("/my-progress")
|
||||
public ApiResponse<PageResponse<ReviewProgressResponse>> listMyProgress(
|
||||
public ApiResponse<ReviewProgressPageResponse> listMyProgress(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "") String q,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
|
|
@ -158,6 +158,18 @@ public class ReviewController extends BaseApiController {
|
|||
return ok("response.success.read", governanceWorkflowAppService.listMyReviewAttempts(id, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/attempts")
|
||||
public ApiResponse<List<ReviewTaskResponse>> listReviewAttempts(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false)
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
governanceWorkflowAppService.listReviewAttempts(id, userId, userNsRoles)
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Author-facing review progress page with search-scoped current-status totals.
|
||||
*/
|
||||
public record ReviewProgressPageResponse(
|
||||
List<ReviewProgressResponse> items,
|
||||
long total,
|
||||
int page,
|
||||
int size,
|
||||
ReviewProgressStatusCounts statusCounts
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
/**
|
||||
* Current review-status totals for the author's grouped skill-version progress.
|
||||
*/
|
||||
public record ReviewProgressStatusCounts(
|
||||
long pending,
|
||||
long approved,
|
||||
long rejected
|
||||
) {}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressStatusCounts;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import java.sql.Timestamp;
|
||||
|
|
@ -22,7 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
@Repository
|
||||
public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository {
|
||||
|
||||
private static final String MY_PROGRESS_SQL = """
|
||||
private static final String RANKED_CTE = """
|
||||
WITH ranked AS (
|
||||
SELECT task.id,
|
||||
task.skill_id,
|
||||
|
|
@ -45,8 +46,10 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
|||
SELECT *
|
||||
FROM ranked
|
||||
WHERE attempt_rank = 1
|
||||
AND (:status = '' OR status = :status)
|
||||
)
|
||||
""";
|
||||
|
||||
private static final String MY_PROGRESS_SQL = RANKED_CTE + """
|
||||
SELECT latest.id,
|
||||
latest.skill_id,
|
||||
namespace.slug,
|
||||
|
|
@ -56,16 +59,31 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
|||
latest.review_comment,
|
||||
latest.submitted_at,
|
||||
latest.reviewed_at,
|
||||
latest.attempt_count,
|
||||
COUNT(*) OVER () AS total_groups
|
||||
latest.attempt_count
|
||||
FROM latest
|
||||
JOIN skill ON skill.id = latest.skill_id
|
||||
JOIN namespace ON namespace.id = latest.namespace_id
|
||||
WHERE (
|
||||
:query = ''
|
||||
OR LOWER(skill.slug) LIKE :queryPattern
|
||||
OR LOWER(namespace.slug) LIKE :queryPattern
|
||||
)
|
||||
AND (:status = '' OR latest.status = :status)
|
||||
ORDER BY latest.submitted_at DESC, latest.id DESC
|
||||
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
||||
""";
|
||||
|
||||
private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """
|
||||
SELECT COUNT(*) FILTER (WHERE :status = '' OR latest.status = :status) AS filtered_total,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'PENDING') AS pending_count,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count
|
||||
FROM latest
|
||||
JOIN skill ON skill.id = latest.skill_id
|
||||
JOIN namespace ON namespace.id = latest.namespace_id
|
||||
WHERE :query = ''
|
||||
OR LOWER(skill.slug) LIKE :queryPattern
|
||||
OR LOWER(namespace.slug) LIKE :queryPattern
|
||||
ORDER BY latest.submitted_at DESC, latest.id DESC
|
||||
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
||||
""";
|
||||
|
||||
private final EntityManager entityManager;
|
||||
|
|
@ -76,26 +94,54 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
|||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ReviewProgressResponse> findMyProgress(
|
||||
public ReviewProgressPageResponse findMyProgress(
|
||||
String userId,
|
||||
ReviewTaskStatus status,
|
||||
String query,
|
||||
int page,
|
||||
int size) {
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
Query nativeQuery = entityManager.createNativeQuery(MY_PROGRESS_SQL)
|
||||
.setParameter("userId", userId)
|
||||
.setParameter("status", status != null ? status.name() : "")
|
||||
.setParameter("query", normalizedQuery)
|
||||
.setParameter("queryPattern", "%" + normalizedQuery + "%")
|
||||
String statusName = status != null ? status.name() : "";
|
||||
String queryPattern = "%" + normalizedQuery + "%";
|
||||
Query nativeQuery = bindFilters(
|
||||
entityManager.createNativeQuery(MY_PROGRESS_SQL),
|
||||
userId,
|
||||
statusName,
|
||||
normalizedQuery,
|
||||
queryPattern)
|
||||
.setParameter("offset", page * size)
|
||||
.setParameter("size", size);
|
||||
Query summaryQuery = bindFilters(
|
||||
entityManager.createNativeQuery(MY_PROGRESS_SUMMARY_SQL),
|
||||
userId,
|
||||
statusName,
|
||||
normalizedQuery,
|
||||
queryPattern);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = nativeQuery.getResultList();
|
||||
List<ReviewProgressResponse> items = rows.stream().map(this::mapRow).toList();
|
||||
long total = rows.isEmpty() ? 0 : number(rows.get(0)[10]).longValue();
|
||||
return new PageResponse<>(items, total, page, size);
|
||||
Object[] summary = (Object[]) summaryQuery.getSingleResult();
|
||||
long total = number(summary[0]).longValue();
|
||||
ReviewProgressStatusCounts statusCounts = new ReviewProgressStatusCounts(
|
||||
number(summary[1]).longValue(),
|
||||
number(summary[2]).longValue(),
|
||||
number(summary[3]).longValue()
|
||||
);
|
||||
return new ReviewProgressPageResponse(items, total, page, size, statusCounts);
|
||||
}
|
||||
|
||||
private Query bindFilters(
|
||||
Query query,
|
||||
String userId,
|
||||
String status,
|
||||
String normalizedQuery,
|
||||
String queryPattern) {
|
||||
return query
|
||||
.setParameter("userId", userId)
|
||||
.setParameter("status", status)
|
||||
.setParameter("query", normalizedQuery)
|
||||
.setParameter("queryPattern", queryPattern);
|
||||
}
|
||||
|
||||
private ReviewProgressResponse mapRow(Object[] row) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
|
||||
/**
|
||||
* Query seam for author-facing review progress grouped by skill version.
|
||||
*/
|
||||
public interface ReviewProgressQueryRepository {
|
||||
|
||||
PageResponse<ReviewProgressResponse> findMyProgress(
|
||||
ReviewProgressPageResponse findMyProgress(
|
||||
String userId,
|
||||
ReviewTaskStatus status,
|
||||
String query,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import com.iflytek.skillhub.dto.NamespaceResponse;
|
|||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.PromotionResponseDto;
|
||||
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewTaskResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
|
||||
|
|
@ -95,7 +95,7 @@ public class GovernanceWorkflowAppService {
|
|||
return reviewPortalAppService.listMySubmissions(page, size, userId);
|
||||
}
|
||||
|
||||
public PageResponse<ReviewProgressResponse> listMyReviewProgress(
|
||||
public ReviewProgressPageResponse listMyReviewProgress(
|
||||
String status,
|
||||
String query,
|
||||
int page,
|
||||
|
|
@ -108,6 +108,13 @@ public class GovernanceWorkflowAppService {
|
|||
return reviewPortalAppService.listMyAttempts(reviewTaskId, userId);
|
||||
}
|
||||
|
||||
public List<ReviewTaskResponse> listReviewAttempts(
|
||||
Long reviewTaskId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
return reviewPortalAppService.listReviewAttempts(reviewTaskId, userId, userNsRoles);
|
||||
}
|
||||
|
||||
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
|||
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.ReviewProgressResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewTaskResponse;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
|
||||
|
|
@ -217,7 +217,7 @@ public class ReviewPortalAppService {
|
|||
));
|
||||
}
|
||||
|
||||
public PageResponse<ReviewProgressResponse> listMyProgress(
|
||||
public ReviewProgressPageResponse listMyProgress(
|
||||
String status,
|
||||
String query,
|
||||
int page,
|
||||
|
|
@ -250,6 +250,22 @@ public class ReviewPortalAppService {
|
|||
return governanceQueryRepository.getReviewTaskResponses(attempts);
|
||||
}
|
||||
|
||||
public List<ReviewTaskResponse> listReviewAttempts(
|
||||
Long reviewTaskId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
|
||||
if (!canViewReview(anchor, userId, normalizeRoles(userNsRoles))) {
|
||||
throw new DomainForbiddenException("review.no_permission");
|
||||
}
|
||||
|
||||
List<ReviewTask> attempts = reviewTaskRepository
|
||||
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
anchor.getSkillId(), anchor.getSkillVersion());
|
||||
return governanceQueryRepository.getReviewTaskResponses(attempts);
|
||||
}
|
||||
|
||||
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
|
|||
import com.iflytek.skillhub.dto.ReviewTaskResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressStatusCounts;
|
||||
import com.iflytek.skillhub.dto.SkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.SkillFileResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
|
|
@ -297,13 +299,21 @@ class ReviewPortalControllerTest {
|
|||
2L
|
||||
);
|
||||
given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20))
|
||||
.willReturn(new com.iflytek.skillhub.dto.PageResponse<>(List.of(item), 1, 0, 20));
|
||||
.willReturn(new ReviewProgressPageResponse(
|
||||
List.of(item),
|
||||
1,
|
||||
0,
|
||||
20,
|
||||
new ReviewProgressStatusCounts(0, 0, 1)
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/v1/reviews/my-progress").with(auth("author-1")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].skillSlug").value("skill-a"))
|
||||
.andExpect(jsonPath("$.data.items[0].attemptCount").value(2))
|
||||
.andExpect(jsonPath("$.data.items[0].latestStatus").value("REJECTED"));
|
||||
.andExpect(jsonPath("$.data.items[0].latestStatus").value("REJECTED"))
|
||||
.andExpect(jsonPath("$.data.statusCounts.pending").value(0))
|
||||
.andExpect(jsonPath("$.data.statusCounts.rejected").value(1));
|
||||
|
||||
verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20);
|
||||
}
|
||||
|
|
@ -330,6 +340,62 @@ class ReviewPortalControllerTest {
|
|||
.andExpect(jsonPath("$.data[1].id").value(8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReviewAttempts_allowsAuthorizedReviewerToReadVersionHistory() throws Exception {
|
||||
ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.PENDING);
|
||||
setField(latest, "skillId", 30L);
|
||||
setField(latest, "skillVersion", "1.0.0");
|
||||
ReviewTask previous = createReviewTask(8L, 20L, "author-1", ReviewTaskStatus.REJECTED);
|
||||
setField(previous, "skillId", 30L);
|
||||
setField(previous, "skillVersion", "1.0.0");
|
||||
Namespace namespace = createNamespace(20L, "team-a");
|
||||
stubNamespaceRoles("reviewer-1", List.of());
|
||||
given(rbacService.getUserRoleCodes("reviewer-1")).willReturn(Set.of("SKILL_ADMIN"));
|
||||
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest));
|
||||
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
|
||||
given(reviewService.canViewReview(
|
||||
latest,
|
||||
"reviewer-1",
|
||||
namespace.getType(),
|
||||
Map.of(),
|
||||
Set.of("SKILL_ADMIN"))).willReturn(true);
|
||||
given(reviewTaskRepository.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0"))
|
||||
.willReturn(List.of(latest, previous));
|
||||
given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous)))
|
||||
.willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("reviewer-1")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andExpect(jsonPath("$.data[0].id").value(12))
|
||||
.andExpect(jsonPath("$.data[1].id").value(8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReviewAttempts_forbidsUnrelatedUser() throws Exception {
|
||||
ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.PENDING);
|
||||
setField(latest, "skillId", 30L);
|
||||
setField(latest, "skillVersion", "1.0.0");
|
||||
Namespace namespace = createNamespace(20L, "team-a");
|
||||
stubNamespaceRoles("other-user", List.of());
|
||||
given(rbacService.getUserRoleCodes("other-user")).willReturn(Set.of());
|
||||
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest));
|
||||
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
|
||||
given(reviewService.canViewReview(
|
||||
latest,
|
||||
"other-user",
|
||||
namespace.getType(),
|
||||
Map.of(),
|
||||
Set.of())).willReturn(false);
|
||||
|
||||
mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("other-user")))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
|
||||
verify(reviewTaskRepository, never())
|
||||
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception {
|
||||
stubNamespaceRoles("admin", List.of());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@DataJpaTest
|
||||
@ActiveProfiles("test")
|
||||
@Import(JpaReviewProgressQueryRepository.class)
|
||||
class JpaReviewProgressQueryRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private TestEntityManager entityManager;
|
||||
|
||||
@Autowired
|
||||
private JpaReviewProgressQueryRepository repository;
|
||||
|
||||
@Test
|
||||
void groupsAttemptsFiltersLatestStatusAndKeepsTotalsOnEmptyPage() {
|
||||
Namespace namespace = entityManager.persistFlushFind(
|
||||
new Namespace("team-review", "Review Team", "owner"));
|
||||
Skill alpha = entityManager.persistFlushFind(
|
||||
new Skill(namespace.getId(), "alpha-skill", "author-1", SkillVisibility.PUBLIC));
|
||||
Skill beta = entityManager.persistFlushFind(
|
||||
new Skill(namespace.getId(), "beta-skill", "author-1", SkillVisibility.PUBLIC));
|
||||
|
||||
persistAttempt(
|
||||
alpha,
|
||||
namespace,
|
||||
"author-1",
|
||||
"1.0.0",
|
||||
ReviewTaskStatus.REJECTED,
|
||||
Instant.parse("2026-08-30T10:00:00Z"));
|
||||
persistAttempt(
|
||||
alpha,
|
||||
namespace,
|
||||
"author-1",
|
||||
"1.0.0",
|
||||
ReviewTaskStatus.PENDING,
|
||||
Instant.parse("2026-08-31T10:00:00Z"));
|
||||
persistAttempt(
|
||||
beta,
|
||||
namespace,
|
||||
"author-1",
|
||||
"2.0.0",
|
||||
ReviewTaskStatus.APPROVED,
|
||||
Instant.parse("2026-08-29T10:00:00Z"));
|
||||
persistAttempt(
|
||||
beta,
|
||||
namespace,
|
||||
"other-author",
|
||||
"3.0.0",
|
||||
ReviewTaskStatus.REJECTED,
|
||||
Instant.parse("2026-08-31T11:00:00Z"));
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
var firstPage = repository.findMyProgress("author-1", null, "", 0, 1);
|
||||
|
||||
assertThat(firstPage.total()).isEqualTo(2);
|
||||
assertThat(firstPage.items()).singleElement().satisfies(item -> {
|
||||
assertThat(item.skillSlug()).isEqualTo("alpha-skill");
|
||||
assertThat(item.latestStatus()).isEqualTo("PENDING");
|
||||
assertThat(item.attemptCount()).isEqualTo(2);
|
||||
});
|
||||
assertThat(firstPage.statusCounts().pending()).isEqualTo(1);
|
||||
assertThat(firstPage.statusCounts().approved()).isEqualTo(1);
|
||||
assertThat(firstPage.statusCounts().rejected()).isZero();
|
||||
|
||||
var emptyPage = repository.findMyProgress("author-1", null, "", 8, 1);
|
||||
assertThat(emptyPage.items()).isEmpty();
|
||||
assertThat(emptyPage.total()).isEqualTo(2);
|
||||
|
||||
var searchedAndFiltered = repository.findMyProgress(
|
||||
"author-1", ReviewTaskStatus.APPROVED, "BETA", 0, 20);
|
||||
assertThat(searchedAndFiltered.items()).singleElement()
|
||||
.satisfies(item -> assertThat(item.skillSlug()).isEqualTo("beta-skill"));
|
||||
assertThat(searchedAndFiltered.total()).isEqualTo(1);
|
||||
assertThat(searchedAndFiltered.statusCounts().approved()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private void persistAttempt(
|
||||
Skill skill,
|
||||
Namespace namespace,
|
||||
String author,
|
||||
String version,
|
||||
ReviewTaskStatus status,
|
||||
Instant submittedAt) {
|
||||
ReviewTask task = new ReviewTask(
|
||||
null, skill.getId(), namespace.getId(), version, author);
|
||||
task.setStatus(status);
|
||||
setField(task, "submittedAt", submittedAt);
|
||||
entityManager.persist(task);
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (ReflectiveOperationException error) {
|
||||
throw new AssertionError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -617,6 +617,12 @@ class ScanTaskConsumerTest {
|
|||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
Long skillId, String skillVersion) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByNamespaceId(Long namespaceId) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ public interface ReviewTaskRepository {
|
|||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
String submittedBy, Long skillId, String skillVersion);
|
||||
List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
Long skillId, String skillVersion);
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
|
||||
void deleteBySkillId(Long skillId);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
|
|||
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
String submittedBy, Long skillId, String skillVersion);
|
||||
|
||||
List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
Long skillId, String skillVersion);
|
||||
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
|
||||
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
|
||||
|
|
|
|||
|
|
@ -95,11 +95,24 @@ test.describe('Rejected version replacement (Real API)', () => {
|
|||
expect(attemptsBody.data.map((attempt) => attempt.status)).toEqual(['PENDING', 'REJECTED'])
|
||||
expect(attemptsBody.data[1]?.skillVersionId).toBeNull()
|
||||
|
||||
const reviewerAttemptsResponse = await adminPage.request.get(
|
||||
`/api/web/reviews/${replacementReviewId}/attempts`,
|
||||
)
|
||||
expect(reviewerAttemptsResponse.status()).toBe(200)
|
||||
const reviewerAttemptsBody = await reviewerAttemptsResponse.json() as {
|
||||
data: Array<{ id: number; status: string }>
|
||||
}
|
||||
expect(reviewerAttemptsBody.data.map((attempt) => attempt.id)).toEqual([
|
||||
replacementReviewId,
|
||||
rejectedReviewId,
|
||||
])
|
||||
|
||||
const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`)
|
||||
expect(replacedReviewResponse.status()).toBe(200)
|
||||
|
||||
await page.goto('/dashboard/review-progress')
|
||||
await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /In review/ })).toContainText('1')
|
||||
const progressCard = page.locator('article').filter({ hasText: replacement.slug })
|
||||
await expect(progressCard).toContainText('In review')
|
||||
await expect(progressCard).toContainText('2 submissions')
|
||||
|
|
@ -107,12 +120,22 @@ test.describe('Rejected version replacement (Real API)', () => {
|
|||
await expect(progressCard).toContainText('Attempt 2')
|
||||
await expect(progressCard).toContainText('Attempt 1')
|
||||
await expect(progressCard).toContainText('Rejected by Playwright E2E')
|
||||
await expect(progressCard).toContainText('Reviewed by')
|
||||
await page.screenshot({ path: testInfo.outputPath('author-review-progress-desktop.png'), fullPage: true })
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(progressCard).toBeVisible()
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: testInfo.outputPath('author-review-progress-mobile.png'), fullPage: true })
|
||||
|
||||
await adminPage.goto(`/dashboard/reviews/${replacementReviewId}`)
|
||||
await expect(adminPage.getByRole('heading', { name: 'Submission History' })).toBeVisible()
|
||||
await expect(adminPage.getByText('Attempt 2')).toBeVisible()
|
||||
await expect(adminPage.getByText('Attempt 1')).toBeVisible()
|
||||
|
||||
await progressCard.getByRole('link', { name: 'Edit and resubmit' }).click()
|
||||
await expect(page).toHaveURL(/\/dashboard\/publish/)
|
||||
await expect(page.getByText(new RegExp(`Resubmit .* v${replacement.version}`))).toBeVisible()
|
||||
const unexpectedConsoleErrors = consoleErrors.filter((message) => (
|
||||
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
|
||||
))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type {
|
|||
MergeInitiateResponse,
|
||||
MergeVerifyRequest,
|
||||
ReviewSkillDetail,
|
||||
ReviewProgress,
|
||||
ReviewProgressPage,
|
||||
ReviewTask,
|
||||
PromotionSortBy,
|
||||
PromotionSortDirection,
|
||||
|
|
@ -889,7 +889,7 @@ export const reviewApi = {
|
|||
if (params.q) searchParams.set('q', params.q)
|
||||
searchParams.set('page', String(params.page ?? 0))
|
||||
searchParams.set('size', String(params.size ?? 20))
|
||||
return fetchJson<{ items: ReviewProgress[]; total: number; page: number; size: number }>(
|
||||
return fetchJson<ReviewProgressPage>(
|
||||
`${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`,
|
||||
)
|
||||
},
|
||||
|
|
@ -898,6 +898,10 @@ export const reviewApi = {
|
|||
return fetchJson<ReviewTask[]>(`${WEB_API_PREFIX}/reviews/my-progress/${reviewTaskId}/attempts`)
|
||||
},
|
||||
|
||||
async listAttempts(reviewTaskId: number): Promise<ReviewTask[]> {
|
||||
return fetchJson<ReviewTask[]>(`${WEB_API_PREFIX}/reviews/${reviewTaskId}/attempts`)
|
||||
},
|
||||
|
||||
async getSkillDetail(id: number): Promise<ReviewSkillDetail> {
|
||||
return fetchJson<ReviewSkillDetail>(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`)
|
||||
},
|
||||
|
|
|
|||
113
web/src/api/generated/schema.d.ts
vendored
113
web/src/api/generated/schema.d.ts
vendored
|
|
@ -2404,6 +2404,38 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/reviews/{id}/attempts": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listReviewAttempts"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/reviews/{id}/attempts": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listReviewAttempts_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/reviews/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -4693,6 +4725,15 @@ export interface components {
|
|||
downloadUrl?: string;
|
||||
activeVersion?: string;
|
||||
};
|
||||
ApiResponseListReviewTaskResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["ReviewTaskResponse"][];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ApiResponsePageResponseReviewTaskResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -4711,25 +4752,16 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseListReviewTaskResponse: {
|
||||
ApiResponseReviewProgressPageResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["ReviewTaskResponse"][];
|
||||
data?: components["schemas"]["ReviewProgressPageResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ApiResponsePageResponseReviewProgressResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["PageResponseReviewProgressResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
PageResponseReviewProgressResponse: {
|
||||
ReviewProgressPageResponse: {
|
||||
items?: components["schemas"]["ReviewProgressResponse"][];
|
||||
/** Format: int64 */
|
||||
total?: number;
|
||||
|
|
@ -4737,6 +4769,7 @@ export interface components {
|
|||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
statusCounts?: components["schemas"]["ReviewProgressStatusCounts"];
|
||||
};
|
||||
ReviewProgressResponse: {
|
||||
/** Format: int64 */
|
||||
|
|
@ -4755,6 +4788,14 @@ export interface components {
|
|||
/** Format: int64 */
|
||||
attemptCount?: number;
|
||||
};
|
||||
ReviewProgressStatusCounts: {
|
||||
/** Format: int64 */
|
||||
pending?: number;
|
||||
/** Format: int64 */
|
||||
approved?: number;
|
||||
/** Format: int64 */
|
||||
rejected?: number;
|
||||
};
|
||||
ApiResponsePageResponsePromotionResponseDto: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -10137,6 +10178,50 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
listReviewAttempts: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseListReviewTaskResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listReviewAttempts_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseListReviewTaskResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getReviewDetail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -10339,7 +10424,7 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"];
|
||||
"*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -10364,7 +10449,7 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"];
|
||||
"*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -454,6 +454,20 @@ export interface ReviewProgress {
|
|||
attemptCount: number
|
||||
}
|
||||
|
||||
export interface ReviewProgressStatusCounts {
|
||||
pending: number
|
||||
approved: number
|
||||
rejected: number
|
||||
}
|
||||
|
||||
export interface ReviewProgressPage {
|
||||
items: ReviewProgress[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
statusCounts: ReviewProgressStatusCounts
|
||||
}
|
||||
|
||||
export interface ReviewSkillDetail {
|
||||
skill: SkillDetail
|
||||
versions: SkillVersion[]
|
||||
|
|
|
|||
|
|
@ -287,9 +287,20 @@ const dashboardPublishRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/publish',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: (search: Record<string, unknown>): { namespace?: string; visibility?: string } => ({
|
||||
validateSearch: (search: Record<string, unknown>): {
|
||||
namespace?: string
|
||||
visibility?: string
|
||||
resubmitSkill?: string
|
||||
resubmitVersion?: string
|
||||
} => ({
|
||||
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined,
|
||||
visibility: typeof search.visibility === 'string' && search.visibility ? search.visibility : undefined,
|
||||
resubmitSkill: typeof search.resubmitSkill === 'string' && search.resubmitSkill
|
||||
? search.resubmitSkill
|
||||
: undefined,
|
||||
resubmitVersion: typeof search.resubmitVersion === 'string' && search.resubmitVersion
|
||||
? search.resubmitVersion
|
||||
: undefined,
|
||||
}),
|
||||
component: PublishPage,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ describe('normalizePublishPrefill', () => {
|
|||
expect(normalizePublishPrefill({
|
||||
namespace: 'team-ai',
|
||||
visibility: 'private',
|
||||
resubmitSkill: 'agent-helper',
|
||||
resubmitVersion: '1.2.0',
|
||||
})).toEqual({
|
||||
namespace: 'team-ai',
|
||||
visibility: 'PRIVATE',
|
||||
resubmitSkill: 'agent-helper',
|
||||
resubmitVersion: '1.2.0',
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -19,6 +23,8 @@ describe('normalizePublishPrefill', () => {
|
|||
})).toEqual({
|
||||
namespace: 'team-ai',
|
||||
visibility: 'PUBLIC',
|
||||
resubmitSkill: '',
|
||||
resubmitVersion: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -28,6 +34,8 @@ describe('normalizePublishPrefill', () => {
|
|||
})).toEqual({
|
||||
namespace: 'team-ml',
|
||||
visibility: 'PUBLIC',
|
||||
resubmitSkill: '',
|
||||
resubmitVersion: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ const VALID_VISIBILITIES = new Set(['PUBLIC', 'NAMESPACE_ONLY', 'PRIVATE'])
|
|||
interface PublishPrefillSearch {
|
||||
namespace?: string
|
||||
visibility?: string
|
||||
resubmitSkill?: string
|
||||
resubmitVersion?: string
|
||||
}
|
||||
|
||||
export interface PublishPrefillState {
|
||||
namespace: string
|
||||
visibility: string
|
||||
resubmitSkill: string
|
||||
resubmitVersion: string
|
||||
}
|
||||
|
||||
export function normalizePublishPrefill(search: PublishPrefillSearch): PublishPrefillState {
|
||||
|
|
@ -16,8 +20,17 @@ export function normalizePublishPrefill(search: PublishPrefillSearch): PublishPr
|
|||
? search.visibility.trim().toUpperCase()
|
||||
: ''
|
||||
|
||||
const resubmitSkill = typeof search.resubmitSkill === 'string'
|
||||
? search.resubmitSkill.trim()
|
||||
: ''
|
||||
const resubmitVersion = typeof search.resubmitVersion === 'string'
|
||||
? search.resubmitVersion.trim()
|
||||
: ''
|
||||
|
||||
return {
|
||||
namespace,
|
||||
visibility: VALID_VISIBILITIES.has(normalizedVisibility) ? normalizedVisibility : 'PUBLIC',
|
||||
resubmitSkill,
|
||||
resubmitVersion,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
web/src/features/review/review-attempt-timeline.test.tsx
Normal file
54
web/src/features/review/review-attempt-timeline.test.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ReviewTask } from '@/api/types'
|
||||
import { ReviewAttemptTimeline } from './review-attempt-timeline'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string | number>) => (
|
||||
values ? `${key}:${Object.values(values).join(':')}` : key
|
||||
),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ReviewAttemptTimeline', () => {
|
||||
it('shows newest-first numbering plus submission and review metadata', () => {
|
||||
const attempts: ReviewTask[] = [
|
||||
{
|
||||
id: 2,
|
||||
skillVersionId: 20,
|
||||
namespace: 'team-a',
|
||||
skillSlug: 'demo',
|
||||
version: '1.0.0',
|
||||
status: 'PENDING',
|
||||
submittedBy: 'author',
|
||||
submittedAt: '2026-09-01T02:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
skillVersionId: null,
|
||||
namespace: 'team-a',
|
||||
skillSlug: 'demo',
|
||||
version: '1.0.0',
|
||||
status: 'REJECTED',
|
||||
submittedBy: 'author',
|
||||
reviewedBy: 'reviewer',
|
||||
reviewedByName: 'Reviewer One',
|
||||
reviewComment: 'Add tests',
|
||||
submittedAt: '2026-09-01T00:00:00Z',
|
||||
reviewedAt: '2026-09-01T01:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<ReviewAttemptTimeline attempts={attempts} locale="en" />,
|
||||
)
|
||||
|
||||
expect(html).toContain('reviewProgress.attemptNumber:2')
|
||||
expect(html).toContain('reviewProgress.attemptNumber:1')
|
||||
expect(html).toContain('reviewProgress.submittedAt:')
|
||||
expect(html).toContain('reviewProgress.reviewedAt:')
|
||||
expect(html).toContain('reviewProgress.reviewedBy:Reviewer One')
|
||||
expect(html).toContain('Add tests')
|
||||
})
|
||||
})
|
||||
73
web/src/features/review/review-attempt-timeline.tsx
Normal file
73
web/src/features/review/review-attempt-timeline.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReviewTask } from '@/api/types'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
const statusClassNames: Record<ReviewTask['status'], string> = {
|
||||
PENDING: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300',
|
||||
APPROVED: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300',
|
||||
REJECTED: 'border-red-500/25 bg-red-500/10 text-red-800 dark:text-red-300',
|
||||
}
|
||||
|
||||
function statusKey(status: ReviewTask['status']) {
|
||||
if (status === 'PENDING') return 'reviewProgress.statusPending'
|
||||
if (status === 'APPROVED') return 'reviewProgress.statusApproved'
|
||||
return 'reviewProgress.statusRejected'
|
||||
}
|
||||
|
||||
export function ReviewAttemptTimeline({
|
||||
attempts,
|
||||
locale,
|
||||
}: {
|
||||
attempts: ReviewTask[]
|
||||
locale: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ol className="space-y-3">
|
||||
{attempts.map((attempt, index) => (
|
||||
<li
|
||||
key={attempt.id}
|
||||
className="grid gap-3 rounded-lg border border-border/60 bg-background/70 p-3 text-sm md:grid-cols-[auto_1fr_auto] md:items-start"
|
||||
>
|
||||
<span className="font-medium text-foreground">
|
||||
{t('reviewProgress.attemptNumber', { number: attempts.length - index })}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className={cn(
|
||||
'inline-flex rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||
statusClassNames[attempt.status],
|
||||
)}>
|
||||
{t(statusKey(attempt.status))}
|
||||
</span>
|
||||
{attempt.reviewComment ? (
|
||||
<p className="mt-2 whitespace-pre-wrap text-foreground/85">{attempt.reviewComment}</p>
|
||||
) : null}
|
||||
{attempt.reviewedBy ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('reviewProgress.reviewedBy', {
|
||||
reviewer: attempt.reviewedByName || attempt.reviewedBy,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground md:text-right">
|
||||
<time dateTime={attempt.submittedAt}>
|
||||
{t('reviewProgress.submittedAt', {
|
||||
time: formatLocalDateTime(attempt.submittedAt, locale),
|
||||
})}
|
||||
</time>
|
||||
{attempt.reviewedAt ? (
|
||||
<time className="block" dateTime={attempt.reviewedAt}>
|
||||
{t('reviewProgress.reviewedAt', {
|
||||
time: formatLocalDateTime(attempt.reviewedAt, locale),
|
||||
})}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,6 +21,12 @@ describe('use-review-detail exports', () => {
|
|||
expect(typeof mod.useReviewSkillDetail).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useReviewAttempts', async () => {
|
||||
const mod = await import('./use-review-detail')
|
||||
expect(mod.useReviewAttempts).toBeDefined()
|
||||
expect(typeof mod.useReviewAttempts).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useApproveReview', async () => {
|
||||
const mod = await import('./use-review-detail')
|
||||
expect(mod.useApproveReview).toBeDefined()
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ export function useReviewSkillDetail(taskId: number) {
|
|||
})
|
||||
}
|
||||
|
||||
export function useReviewAttempts(taskId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['reviews', taskId, 'attempts'],
|
||||
queryFn: () => reviewApi.listAttempts(taskId),
|
||||
enabled: !!taskId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves a review and refreshes both the review queue and the governance
|
||||
* dashboard, which reads aggregate review state from separate endpoints.
|
||||
|
|
|
|||
|
|
@ -502,9 +502,14 @@
|
|||
"statusPending": "In review",
|
||||
"statusApproved": "Approved",
|
||||
"statusRejected": "Rejected",
|
||||
"statusSummary": "Current review status summary",
|
||||
"latestSubmitted": "Latest submission: {{time}}",
|
||||
"latestReviewed": "Latest decision: {{time}}",
|
||||
"attemptCount": "{{count}} submissions",
|
||||
"attemptNumber": "Attempt {{number}}",
|
||||
"submittedAt": "Submitted {{time}}",
|
||||
"reviewedAt": "Reviewed {{time}}",
|
||||
"reviewedBy": "Reviewed by {{reviewer}}",
|
||||
"history": "Submission history",
|
||||
"resubmit": "Edit and resubmit",
|
||||
"loading": "Loading review progress",
|
||||
|
|
@ -1385,6 +1390,10 @@
|
|||
"title": "Review Notice",
|
||||
"description": "Submitted skill packages require admin review before publication."
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "Resubmit {{skill}} v{{version}}",
|
||||
"description": "Upload a corrected package that keeps the same skill name and version. It will enter the normal review flow again."
|
||||
},
|
||||
"namespace": "Namespace",
|
||||
"selectNamespace": "Select namespace",
|
||||
"visibility": "Visibility",
|
||||
|
|
@ -1447,6 +1456,8 @@
|
|||
"reviewer": "Reviewed By",
|
||||
"reviewTime": "Reviewed At",
|
||||
"reviewComment": "Review Comment",
|
||||
"attemptHistory": "Submission History",
|
||||
"attemptHistoryError": "Submission history could not be loaded. Try again later.",
|
||||
"actions": "Review Actions",
|
||||
"commentLabel": "Comment (optional)",
|
||||
"commentPlaceholder": "Enter review comment...",
|
||||
|
|
|
|||
|
|
@ -502,9 +502,14 @@
|
|||
"statusPending": "На проверке",
|
||||
"statusApproved": "Одобрено",
|
||||
"statusRejected": "Отклонено",
|
||||
"statusSummary": "Сводка текущих статусов проверки",
|
||||
"latestSubmitted": "Последняя отправка: {{time}}",
|
||||
"latestReviewed": "Последнее решение: {{time}}",
|
||||
"attemptCount": "Отправок: {{count}}",
|
||||
"attemptNumber": "Попытка {{number}}",
|
||||
"submittedAt": "Отправлено {{time}}",
|
||||
"reviewedAt": "Проверено {{time}}",
|
||||
"reviewedBy": "Проверил: {{reviewer}}",
|
||||
"history": "История отправок",
|
||||
"resubmit": "Изменить и отправить снова",
|
||||
"loading": "Загрузка статуса проверки",
|
||||
|
|
@ -1463,6 +1468,10 @@
|
|||
"title": "Уведомление о ревью",
|
||||
"description": "Отправленные пакеты скиллов проходят ревью администратора перед публикацией."
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "Повторная отправка {{skill}} v{{version}}",
|
||||
"description": "Загрузите исправленный пакет с тем же именем и версией навыка. Он снова пройдёт обычную проверку."
|
||||
},
|
||||
"namespace": "Пространство имён",
|
||||
"selectNamespace": "Выберите пространство имён",
|
||||
"visibility": "Видимость",
|
||||
|
|
@ -1516,6 +1525,8 @@
|
|||
"reviewer": "Рецензент",
|
||||
"reviewTime": "Рассмотрено",
|
||||
"reviewComment": "Комментарий ревью",
|
||||
"attemptHistory": "История отправок",
|
||||
"attemptHistoryError": "Не удалось загрузить историю отправок. Повторите попытку позже.",
|
||||
"actions": "Действия ревью",
|
||||
"commentLabel": "Комментарий (необязательно)",
|
||||
"commentPlaceholder": "Введите комментарий ревью...",
|
||||
|
|
|
|||
|
|
@ -502,9 +502,14 @@
|
|||
"statusPending": "审核中",
|
||||
"statusApproved": "已通过",
|
||||
"statusRejected": "已拒绝",
|
||||
"statusSummary": "当前审核状态汇总",
|
||||
"latestSubmitted": "最近提交:{{time}}",
|
||||
"latestReviewed": "最近审核:{{time}}",
|
||||
"attemptCount": "共 {{count}} 次提交",
|
||||
"attemptNumber": "第 {{number}} 次",
|
||||
"submittedAt": "提交于 {{time}}",
|
||||
"reviewedAt": "审核于 {{time}}",
|
||||
"reviewedBy": "审核人:{{reviewer}}",
|
||||
"history": "提交历史",
|
||||
"resubmit": "修改并重提",
|
||||
"loading": "正在加载审核进度",
|
||||
|
|
@ -1385,6 +1390,10 @@
|
|||
"title": "发布审核说明",
|
||||
"description": "技能包提交后需要经过管理员审核才能正式发布。"
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "重新提交 {{skill}} v{{version}}",
|
||||
"description": "上传修正后的技能包,并保持相同的技能名称和版本;提交后会重新进入正常审核流程。"
|
||||
},
|
||||
"namespace": "命名空间",
|
||||
"selectNamespace": "选择命名空间",
|
||||
"visibility": "可见性",
|
||||
|
|
@ -1447,6 +1456,8 @@
|
|||
"reviewer": "审核者",
|
||||
"reviewTime": "审核时间",
|
||||
"reviewComment": "审核意见",
|
||||
"attemptHistory": "提交历史",
|
||||
"attemptHistoryError": "提交历史加载失败,请稍后重试。",
|
||||
"actions": "审核操作",
|
||||
"commentLabel": "审核意见(可选)",
|
||||
"commentPlaceholder": "填写审核意见...",
|
||||
|
|
|
|||
|
|
@ -158,6 +158,20 @@ export function PublishPage() {
|
|||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('publish.title')} subtitle={t('publish.subtitle')} />
|
||||
|
||||
{prefill.resubmitSkill && prefill.resubmitVersion ? (
|
||||
<Card className="border-amber-500/25 bg-amber-500/5 p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{t('publish.resubmitNotice.title', {
|
||||
skill: `@${prefill.namespace}/${prefill.resubmitSkill}`,
|
||||
version: prefill.resubmitVersion,
|
||||
})}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('publish.resubmitNotice.description')}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="p-4 bg-blue-500/5 border-blue-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
|
|||
|
|
@ -103,9 +103,25 @@ const useReviewSkillDetailMock = vi.fn<() => unknown>(() => ({
|
|||
error: null,
|
||||
}))
|
||||
|
||||
const useReviewAttemptsMock = vi.fn<() => unknown>(() => ({
|
||||
data: [{
|
||||
id: 13,
|
||||
skillVersionId: 10,
|
||||
namespace: 'global',
|
||||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
submittedBy: 'local-admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/use-review-detail', () => ({
|
||||
useReviewDetail: () => useReviewDetailMock(),
|
||||
useReviewSkillDetail: () => useReviewSkillDetailMock(),
|
||||
useReviewAttempts: () => useReviewAttemptsMock(),
|
||||
useApproveReview: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
|
|
@ -139,6 +155,21 @@ describe('ReviewDetailPage', () => {
|
|||
userMock.platformRoles = ['SKILL_ADMIN']
|
||||
useReviewDetailMock.mockReset()
|
||||
useReviewSkillDetailMock.mockReset()
|
||||
useReviewAttemptsMock.mockReset()
|
||||
useReviewAttemptsMock.mockReturnValue({
|
||||
data: [{
|
||||
id: 13,
|
||||
skillVersionId: 10,
|
||||
namespace: 'global',
|
||||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
submittedBy: 'local-admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
useReviewDetailMock.mockReturnValue({
|
||||
data: {
|
||||
id: 13,
|
||||
|
|
@ -203,6 +234,8 @@ describe('ReviewDetailPage', () => {
|
|||
|
||||
expect(html).toContain('max-w-6xl mx-auto flex')
|
||||
expect(html).toContain('aria-expanded="false"')
|
||||
expect(html).toContain('review.attemptHistory')
|
||||
expect(html).toContain('reviewProgress.attemptNumber')
|
||||
})
|
||||
|
||||
it('renders not-found state when the review record is missing', () => {
|
||||
|
|
|
|||
|
|
@ -19,13 +19,20 @@ import { toast } from '@/shared/lib/toast'
|
|||
import { cn } from '@/shared/lib/utils'
|
||||
import { resolveReviewActionErrorDescription } from '@/features/review/review-error'
|
||||
import { ReviewSkillDetailSection } from '@/features/review/review-skill-detail-section'
|
||||
import { ReviewAttemptTimeline } from '@/features/review/review-attempt-timeline'
|
||||
import { SecurityAuditSection } from '@/features/security-audit/security-audit-section'
|
||||
import { FileTree } from '@/features/skill/file-tree'
|
||||
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
|
||||
import type { FileTreeNode } from '@/features/skill/file-tree-builder'
|
||||
import { useReviewFile } from '@/features/review/use-review-file'
|
||||
import { buildApiUrl, WEB_API_PREFIX } from '@/api/client'
|
||||
import { useReviewDetail, useReviewSkillDetail, useApproveReview, useRejectReview } from '@/features/review/use-review-detail'
|
||||
import {
|
||||
useReviewAttempts,
|
||||
useReviewDetail,
|
||||
useReviewSkillDetail,
|
||||
useApproveReview,
|
||||
useRejectReview,
|
||||
} from '@/features/review/use-review-detail'
|
||||
|
||||
/**
|
||||
* Review task detail page for moderators. The route owns the approve/reject
|
||||
|
|
@ -46,6 +53,11 @@ function ReviewDetailScreen({
|
|||
const { user } = useAuth()
|
||||
|
||||
const { data: review, isLoading } = useReviewDetail(taskId)
|
||||
const {
|
||||
data: reviewAttempts,
|
||||
isLoading: isLoadingReviewAttempts,
|
||||
isError: isReviewAttemptsError,
|
||||
} = useReviewAttempts(taskId)
|
||||
const {
|
||||
data: reviewSkillDetail,
|
||||
isLoading: isLoadingReviewSkillDetail,
|
||||
|
|
@ -253,6 +265,17 @@ function ReviewDetailScreen({
|
|||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-4 p-6 md:p-8">
|
||||
<h2 className="text-xl font-bold font-heading">{t('review.attemptHistory')}</h2>
|
||||
{isLoadingReviewAttempts ? (
|
||||
<div className="h-20 animate-shimmer rounded-lg" />
|
||||
) : isReviewAttemptsError ? (
|
||||
<p className="text-sm text-destructive">{t('review.attemptHistoryError')}</p>
|
||||
) : (
|
||||
<ReviewAttemptTimeline attempts={reviewAttempts ?? []} locale={i18n.language} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{review.status === 'PENDING' && (
|
||||
<Card className="p-8 space-y-6">
|
||||
<h2 className="text-xl font-bold font-heading">{t('review.actions')}</h2>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ vi.mock('@/features/review/use-my-review-progress', () => ({
|
|||
total: 1,
|
||||
page: 0,
|
||||
size: 20,
|
||||
statusCounts: {
|
||||
pending: 0,
|
||||
approved: 0,
|
||||
rejected: 1,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
|
|
@ -53,6 +58,9 @@ describe('ReviewProgressPage', () => {
|
|||
expect(html).toContain('reviewProgress.statusRejected')
|
||||
expect(html).toContain('reviewProgress.resubmit')
|
||||
expect(html).toContain('reviewProgress.history')
|
||||
expect(html).toContain('reviewProgress.statusSummary')
|
||||
expect(html).toContain('reviewProgress.latestReviewed')
|
||||
expect(html).toContain('/dashboard/publish')
|
||||
expect(html).not.toContain('reviews.typeSkill')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { useState, type FormEvent } from 'react'
|
|||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { ChevronDown, ChevronUp, Clock3, RotateCcw, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReviewProgress, ReviewTask } from '@/api/types'
|
||||
import type { ReviewProgress } from '@/api/types'
|
||||
import { ReviewAttemptTimeline } from '@/features/review/review-attempt-timeline'
|
||||
import { useMyReviewAttempts, useMyReviewProgress } from '@/features/review/use-my-review-progress'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
|
|
@ -101,6 +102,35 @@ export function ReviewProgressPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{progressQuery.data ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3" aria-label={t('reviewProgress.statusSummary')}>
|
||||
{([
|
||||
['PENDING', progressQuery.data.statusCounts.pending, 'statusPending'],
|
||||
['APPROVED', progressQuery.data.statusCounts.approved, 'statusApproved'],
|
||||
['REJECTED', progressQuery.data.statusCounts.rejected, 'statusRejected'],
|
||||
] as const).map(([status, count, labelKey]) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
aria-pressed={search.status === status}
|
||||
onClick={() => updateSearch({
|
||||
status: search.status === status ? null : status,
|
||||
page: 0,
|
||||
})}
|
||||
className={cn(
|
||||
'rounded-xl border border-border/70 bg-background px-4 py-3 text-left transition-colors hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
search.status === status && 'border-primary/40 bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<span className="block text-2xl font-semibold text-foreground">{count}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`reviewProgress.${labelKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{progressQuery.isLoading ? (
|
||||
<div className="space-y-3" aria-label={t('reviewProgress.loading')}>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
|
|
@ -182,6 +212,9 @@ function ProgressItem({
|
|||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
<span>{t('reviewProgress.latestSubmitted', { time: formatLocalDateTime(item.latestSubmittedAt, locale) })}</span>
|
||||
{item.latestReviewedAt ? (
|
||||
<span>{t('reviewProgress.latestReviewed', { time: formatLocalDateTime(item.latestReviewedAt, locale) })}</span>
|
||||
) : null}
|
||||
<span>{t('reviewProgress.attemptCount', { count: item.attemptCount })}</span>
|
||||
</div>
|
||||
{item.latestReviewComment ? (
|
||||
|
|
@ -192,8 +225,12 @@ function ProgressItem({
|
|||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{status === 'REJECTED' ? (
|
||||
<Link
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: item.namespace, slug: item.skillSlug }}
|
||||
to="/dashboard/publish"
|
||||
search={{
|
||||
namespace: item.namespace,
|
||||
resubmitSkill: item.skillSlug,
|
||||
resubmitVersion: item.skillVersion,
|
||||
}}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'gap-2')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
|
|
@ -221,35 +258,10 @@ function ProgressItem({
|
|||
) : attemptsQuery.isError ? (
|
||||
<p className="text-sm text-destructive">{t('reviewProgress.historyError')}</p>
|
||||
) : (
|
||||
<AttemptTimeline attempts={attemptsQuery.data ?? []} locale={locale} />
|
||||
<ReviewAttemptTimeline attempts={attemptsQuery.data ?? []} locale={locale} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function AttemptTimeline({ attempts, locale }: { attempts: ReviewTask[]; locale: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ol className="space-y-3">
|
||||
{attempts.map((attempt, index) => (
|
||||
<li key={attempt.id} className="grid gap-2 rounded-lg border border-border/60 bg-background/70 p-3 text-sm md:grid-cols-[auto_1fr_auto] md:items-start">
|
||||
<span className="font-medium text-foreground">
|
||||
{t('reviewProgress.attemptNumber', { number: attempts.length - index })}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className={cn('inline-flex rounded-full border px-2 py-0.5 text-xs font-medium', statusClassNames[attempt.status])}>
|
||||
{t(`reviewProgress.status${attempt.status === 'PENDING' ? 'Pending' : attempt.status === 'APPROVED' ? 'Approved' : 'Rejected'}`)}
|
||||
</span>
|
||||
{attempt.reviewComment ? <p className="mt-2 whitespace-pre-wrap text-foreground/85">{attempt.reviewComment}</p> : null}
|
||||
</div>
|
||||
<time className="text-xs text-muted-foreground" dateTime={attempt.submittedAt}>
|
||||
{formatLocalDateTime(attempt.submittedAt, locale)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue