mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
feat(review): add author review progress and attempt history
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
ebac94a043
commit
7995c00683
35 changed files with 1159 additions and 30 deletions
|
|
@ -8,12 +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.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 org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -136,6 +138,26 @@ public class ReviewController extends BaseApiController {
|
|||
return ok("response.success.read", governanceWorkflowAppService.listMyReviewSubmissions(page, size, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/my-progress")
|
||||
public ApiResponse<PageResponse<ReviewProgressResponse>> listMyProgress(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "") String q,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId)
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/my-progress/{id}/attempts")
|
||||
public ApiResponse<List<ReviewTaskResponse>> listMyAttempts(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
return ok("response.success.read", governanceWorkflowAppService.listMyReviewAttempts(id, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Author-facing summary for one skill version's review attempts.
|
||||
*/
|
||||
public record ReviewProgressResponse(
|
||||
Long latestReviewTaskId,
|
||||
Long skillId,
|
||||
String namespace,
|
||||
String skillSlug,
|
||||
String skillVersion,
|
||||
String latestStatus,
|
||||
String latestReviewComment,
|
||||
Instant latestSubmittedAt,
|
||||
Instant latestReviewedAt,
|
||||
long attemptCount
|
||||
) {}
|
||||
|
|
@ -104,15 +104,19 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
|
|||
? Map.of()
|
||||
: skillVersionRepository.findByIdIn(versionIds).stream()
|
||||
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
|
||||
List<Long> skillIds = distinct(versionsById.values().stream().map(SkillVersion::getSkillId).toList());
|
||||
Set<Long> skillIds = new LinkedHashSet<>(distinct(
|
||||
versionsById.values().stream().map(SkillVersion::getSkillId).toList()));
|
||||
skillIds.addAll(distinct(tasks.stream().map(ReviewTask::getSkillId).toList()));
|
||||
Map<Long, Skill> skillsById = skillIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillRepository.findByIdIn(skillIds).stream()
|
||||
: skillRepository.findByIdIn(List.copyOf(skillIds)).stream()
|
||||
.collect(Collectors.toMap(Skill::getId, Function.identity()));
|
||||
List<Long> namespaceIds = distinct(skillsById.values().stream().map(Skill::getNamespaceId).toList());
|
||||
Set<Long> namespaceIds = new LinkedHashSet<>(distinct(
|
||||
skillsById.values().stream().map(Skill::getNamespaceId).toList()));
|
||||
namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList()));
|
||||
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
: namespaceRepository.findByIdIn(List.copyOf(namespaceIds)).stream()
|
||||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
List<String> userIds = distinctStrings(tasks.stream()
|
||||
.flatMap(task -> java.util.stream.Stream.of(task.getSubmittedBy(), task.getReviewedBy()))
|
||||
|
|
@ -168,9 +172,14 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
|
|||
}
|
||||
|
||||
private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) {
|
||||
SkillVersion version = require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found");
|
||||
Skill skill = require(bundle.skillsById(), version.getSkillId(), "skill.not_found");
|
||||
Namespace namespace = require(bundle.namespacesById(), skill.getNamespaceId(), "namespace.not_found");
|
||||
Long skillId = task.getSkillId() != null
|
||||
? task.getSkillId()
|
||||
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId();
|
||||
Skill skill = require(bundle.skillsById(), skillId, "skill.not_found");
|
||||
Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found");
|
||||
String skillVersion = task.getSkillVersion() != null
|
||||
? task.getSkillVersion()
|
||||
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion();
|
||||
UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy());
|
||||
UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null;
|
||||
return new ReviewTaskResponse(
|
||||
|
|
@ -178,7 +187,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
|
|||
task.getSkillVersionId(),
|
||||
namespace.getSlug(),
|
||||
skill.getSlug(),
|
||||
version.getVersion(),
|
||||
skillVersion,
|
||||
task.getStatus().name(),
|
||||
task.getSubmittedBy(),
|
||||
submittedBy != null ? submittedBy.getDisplayName() : null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
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 jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PostgreSQL read-model query for review progress.
|
||||
*
|
||||
* <p>Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts,
|
||||
* not individual review tasks. Window functions keep grouping, latest-attempt selection, counts,
|
||||
* filtering, and pagination in the database instead of loading an author's full history.</p>
|
||||
*/
|
||||
@Repository
|
||||
public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository {
|
||||
|
||||
private static final String MY_PROGRESS_SQL = """
|
||||
WITH ranked AS (
|
||||
SELECT task.id,
|
||||
task.skill_id,
|
||||
task.namespace_id,
|
||||
task.skill_version,
|
||||
task.status,
|
||||
task.review_comment,
|
||||
task.submitted_at,
|
||||
task.reviewed_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY task.skill_id, task.skill_version
|
||||
ORDER BY task.submitted_at DESC, task.id DESC
|
||||
) AS attempt_rank,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY task.skill_id, task.skill_version
|
||||
) AS attempt_count
|
||||
FROM review_task task
|
||||
WHERE task.submitted_by = :userId
|
||||
), latest AS (
|
||||
SELECT *
|
||||
FROM ranked
|
||||
WHERE attempt_rank = 1
|
||||
AND (:status = '' OR status = :status)
|
||||
)
|
||||
SELECT latest.id,
|
||||
latest.skill_id,
|
||||
namespace.slug,
|
||||
skill.slug,
|
||||
latest.skill_version,
|
||||
latest.status,
|
||||
latest.review_comment,
|
||||
latest.submitted_at,
|
||||
latest.reviewed_at,
|
||||
latest.attempt_count,
|
||||
COUNT(*) OVER () AS total_groups
|
||||
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;
|
||||
|
||||
public JpaReviewProgressQueryRepository(EntityManager entityManager) {
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ReviewProgressResponse> 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 + "%")
|
||||
.setParameter("offset", page * size)
|
||||
.setParameter("size", size);
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
private ReviewProgressResponse mapRow(Object[] row) {
|
||||
return new ReviewProgressResponse(
|
||||
number(row[0]).longValue(),
|
||||
number(row[1]).longValue(),
|
||||
(String) row[2],
|
||||
(String) row[3],
|
||||
(String) row[4],
|
||||
String.valueOf(row[5]),
|
||||
(String) row[6],
|
||||
instant(row[7]),
|
||||
instant(row[8]),
|
||||
number(row[9]).longValue()
|
||||
);
|
||||
}
|
||||
|
||||
private Number number(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number;
|
||||
}
|
||||
throw new IllegalStateException("Expected numeric review progress value, got " + value);
|
||||
}
|
||||
|
||||
private Instant instant(Object value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Instant instant) return instant;
|
||||
if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant();
|
||||
if (value instanceof Timestamp timestamp) return timestamp.toInstant();
|
||||
throw new IllegalStateException("Expected review progress timestamp, got " + value.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressResponse;
|
||||
|
||||
/**
|
||||
* Query seam for author-facing review progress grouped by skill version.
|
||||
*/
|
||||
public interface ReviewProgressQueryRepository {
|
||||
|
||||
PageResponse<ReviewProgressResponse> findMyProgress(
|
||||
String userId,
|
||||
ReviewTaskStatus status,
|
||||
String query,
|
||||
int page,
|
||||
int size
|
||||
);
|
||||
}
|
||||
|
|
@ -8,10 +8,12 @@ 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.ReviewTaskResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
|
@ -93,6 +95,19 @@ public class GovernanceWorkflowAppService {
|
|||
return reviewPortalAppService.listMySubmissions(page, size, userId);
|
||||
}
|
||||
|
||||
public PageResponse<ReviewProgressResponse> listMyReviewProgress(
|
||||
String status,
|
||||
String query,
|
||||
int page,
|
||||
int size,
|
||||
String userId) {
|
||||
return reviewPortalAppService.listMyProgress(status, query, page, size, userId);
|
||||
}
|
||||
|
||||
public List<ReviewTaskResponse> listMyReviewAttempts(Long reviewTaskId, String userId) {
|
||||
return reviewPortalAppService.listMyAttempts(reviewTaskId, userId);
|
||||
}
|
||||
|
||||
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ 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.ReviewTaskResponse;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
|
||||
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
|
@ -34,6 +36,7 @@ public class ReviewPortalAppService {
|
|||
private final ReviewTaskRepository reviewTaskRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final GovernanceQueryRepository governanceQueryRepository;
|
||||
private final ReviewProgressQueryRepository reviewProgressQueryRepository;
|
||||
private final RbacService rbacService;
|
||||
private final AuditLogService auditLogService;
|
||||
private final RequestIdAccessor requestIdAccessor;
|
||||
|
|
@ -42,6 +45,7 @@ public class ReviewPortalAppService {
|
|||
ReviewTaskRepository reviewTaskRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
GovernanceQueryRepository governanceQueryRepository,
|
||||
ReviewProgressQueryRepository reviewProgressQueryRepository,
|
||||
RbacService rbacService,
|
||||
AuditLogService auditLogService,
|
||||
RequestIdAccessor requestIdAccessor) {
|
||||
|
|
@ -49,6 +53,7 @@ public class ReviewPortalAppService {
|
|||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.governanceQueryRepository = governanceQueryRepository;
|
||||
this.reviewProgressQueryRepository = reviewProgressQueryRepository;
|
||||
this.rbacService = rbacService;
|
||||
this.auditLogService = auditLogService;
|
||||
this.requestIdAccessor = requestIdAccessor;
|
||||
|
|
@ -212,6 +217,39 @@ public class ReviewPortalAppService {
|
|||
));
|
||||
}
|
||||
|
||||
public PageResponse<ReviewProgressResponse> listMyProgress(
|
||||
String status,
|
||||
String query,
|
||||
int page,
|
||||
int size,
|
||||
String userId) {
|
||||
ReviewTaskStatus reviewStatus = status == null || status.isBlank()
|
||||
? null
|
||||
: ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT));
|
||||
int safePage = Math.max(page, 0);
|
||||
int safeSize = Math.min(Math.max(size, 1), 100);
|
||||
return reviewProgressQueryRepository.findMyProgress(
|
||||
userId,
|
||||
reviewStatus,
|
||||
query != null ? query : "",
|
||||
safePage,
|
||||
safeSize
|
||||
);
|
||||
}
|
||||
|
||||
public List<ReviewTaskResponse> listMyAttempts(Long reviewTaskId, String userId) {
|
||||
ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
|
||||
if (!anchor.getSubmittedBy().equals(userId)) {
|
||||
throw new DomainForbiddenException("review.no_permission");
|
||||
}
|
||||
|
||||
List<ReviewTask> attempts = reviewTaskRepository
|
||||
.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
userId, anchor.getSkillId(), anchor.getSkillVersion());
|
||||
return governanceQueryRepository.getReviewTaskResponses(attempts);
|
||||
}
|
||||
|
||||
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
ALTER TABLE review_task
|
||||
ADD COLUMN skill_id BIGINT,
|
||||
ADD COLUMN skill_version VARCHAR(64);
|
||||
|
||||
UPDATE review_task task
|
||||
SET skill_id = version.skill_id,
|
||||
skill_version = version.version
|
||||
FROM skill_version version
|
||||
WHERE task.skill_version_id = version.id;
|
||||
|
||||
ALTER TABLE review_task
|
||||
ALTER COLUMN skill_id SET NOT NULL,
|
||||
ALTER COLUMN skill_version SET NOT NULL,
|
||||
ALTER COLUMN skill_version_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE review_task
|
||||
DROP CONSTRAINT review_task_skill_version_id_fkey,
|
||||
ADD CONSTRAINT fk_review_task_skill_version
|
||||
FOREIGN KEY (skill_version_id) REFERENCES skill_version(id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT fk_review_task_skill
|
||||
FOREIGN KEY (skill_id) REFERENCES skill(id);
|
||||
|
||||
CREATE INDEX idx_review_task_submitter_submitted
|
||||
ON review_task(submitted_by, submitted_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX idx_review_task_skill_version_attempts
|
||||
ON review_task(skill_id, skill_version, submitted_at DESC, id DESC);
|
||||
|
|
@ -16,11 +16,13 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
|||
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.SkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.SkillFileResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionResponse;
|
||||
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
|
||||
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
|
||||
import com.iflytek.skillhub.service.ReviewSkillDetailAppService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -38,6 +40,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
|
@ -79,6 +82,9 @@ class ReviewPortalControllerTest {
|
|||
@MockBean
|
||||
private GovernanceQueryRepository governanceQueryRepository;
|
||||
|
||||
@MockBean
|
||||
private ReviewProgressQueryRepository reviewProgressQueryRepository;
|
||||
|
||||
@MockBean
|
||||
private RbacService rbacService;
|
||||
|
||||
|
|
@ -276,6 +282,54 @@ class ReviewPortalControllerTest {
|
|||
verify(reviewTaskRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMyReviewProgress_isScopedToAuthenticatedAuthor() throws Exception {
|
||||
var item = new ReviewProgressResponse(
|
||||
12L,
|
||||
30L,
|
||||
"team-a",
|
||||
"skill-a",
|
||||
"1.0.0",
|
||||
"REJECTED",
|
||||
"Please add tests",
|
||||
Instant.parse("2026-08-31T10:00:00Z"),
|
||||
Instant.parse("2026-08-31T11:00:00Z"),
|
||||
2L
|
||||
);
|
||||
given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20))
|
||||
.willReturn(new com.iflytek.skillhub.dto.PageResponse<>(List.of(item), 1, 0, 20));
|
||||
|
||||
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"));
|
||||
|
||||
verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMyReviewAttempts_returnsOnlyTheAuthorsVersionHistory() throws Exception {
|
||||
ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED);
|
||||
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");
|
||||
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest));
|
||||
given(reviewTaskRepository.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
"author-1", 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/my-progress/12/attempts").with(auth("author-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 downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception {
|
||||
stubNamespaceRoles("admin", List.of());
|
||||
|
|
|
|||
|
|
@ -212,7 +212,8 @@ class SkillApprovalVisibilityFlowIntegrationTest {
|
|||
version = skillVersionRepository.save(version);
|
||||
skillVersionRepository.flush();
|
||||
|
||||
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
|
||||
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(
|
||||
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId));
|
||||
|
||||
return new PendingSkillGraph(namespace, skill, version, reviewTask);
|
||||
}
|
||||
|
|
@ -237,7 +238,8 @@ class SkillApprovalVisibilityFlowIntegrationTest {
|
|||
version = skillVersionRepository.save(version);
|
||||
skillVersionRepository.flush();
|
||||
|
||||
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
|
||||
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(
|
||||
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId));
|
||||
|
||||
return new PendingSkillGraph(namespace, skill, version, reviewTask);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,15 +91,18 @@ class SkillVersionDeleteFlowIntegrationTest {
|
|||
retainedVersion.setStatus(SkillVersionStatus.REJECTED);
|
||||
retainedVersion = skillVersionRepository.save(retainedVersion);
|
||||
|
||||
ReviewTask rejectedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
|
||||
ReviewTask rejectedTask = new ReviewTask(
|
||||
rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId);
|
||||
rejectedTask.setStatus(ReviewTaskStatus.REJECTED);
|
||||
rejectedTask = reviewTaskRepository.save(rejectedTask);
|
||||
|
||||
ReviewTask approvedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
|
||||
ReviewTask approvedTask = new ReviewTask(
|
||||
rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId);
|
||||
approvedTask.setStatus(ReviewTaskStatus.APPROVED);
|
||||
approvedTask = reviewTaskRepository.save(approvedTask);
|
||||
|
||||
ReviewTask retainedTask = new ReviewTask(retainedVersion.getId(), namespace.getId(), ownerId);
|
||||
ReviewTask retainedTask = new ReviewTask(
|
||||
retainedVersion.getId(), skill.getId(), namespace.getId(), retainedVersion.getVersion(), ownerId);
|
||||
retainedTask.setStatus(ReviewTaskStatus.REJECTED);
|
||||
retainedTask = reviewTaskRepository.save(retainedTask);
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,32 @@ class JpaGovernanceQueryRepositoryTest {
|
|||
assertThat(responses.get(0).reviewedByName()).isEqualTo("Reviewer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReviewTaskResponses_usesSnapshotAfterReviewedVersionIsReplaced() {
|
||||
ReviewTask task = new ReviewTask(null, 201L, 11L, "1.2.0", "submitter");
|
||||
setField(task, "id", 6L);
|
||||
setField(task, "status", ReviewTaskStatus.REJECTED);
|
||||
|
||||
Skill skill = new Skill(11L, "skill-a", "submitter", SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 201L);
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "submitter");
|
||||
setField(namespace, "id", 11L);
|
||||
UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null);
|
||||
|
||||
given(skillRepository.findByIdIn(List.of(201L))).willReturn(List.of(skill));
|
||||
given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace));
|
||||
given(userAccountRepository.findByIdIn(List.of("submitter"))).willReturn(List.of(submitter));
|
||||
|
||||
var responses = repository.getReviewTaskResponses(List.of(task));
|
||||
|
||||
assertThat(responses).singleElement().satisfies(response -> {
|
||||
assertThat(response.skillVersionId()).isNull();
|
||||
assertThat(response.namespace()).isEqualTo("team-a");
|
||||
assertThat(response.skillSlug()).isEqualTo("skill-a");
|
||||
assertThat(response.version()).isEqualTo("1.2.0");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPromotionResponses_assemblesPromotionReadModel() {
|
||||
PromotionRequest request = new PromotionRequest(201L, 101L, 12L, "submitter");
|
||||
|
|
|
|||
|
|
@ -611,6 +611,12 @@ class ScanTaskConsumerTest {
|
|||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
String submittedBy, Long skillId, String skillVersion) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByNamespaceId(Long namespaceId) {
|
||||
return false;
|
||||
|
|
@ -621,6 +627,11 @@ class ScanTaskConsumerTest {
|
|||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteBySkillId(Long skillId) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(ReviewTask reviewTask) {
|
||||
this.deletedTask = reviewTask;
|
||||
|
|
|
|||
|
|
@ -111,7 +111,8 @@ public class ReviewService {
|
|||
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
skillVersionRepository.save(skillVersion);
|
||||
|
||||
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
|
||||
ReviewTask task = new ReviewTask(
|
||||
skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId);
|
||||
try {
|
||||
ReviewTask saved = reviewTaskRepository.save(task);
|
||||
eventPublisher.publishEvent(new ReviewSubmittedEvent(
|
||||
|
|
@ -153,7 +154,8 @@ public class ReviewService {
|
|||
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
skillVersionRepository.save(skillVersion);
|
||||
|
||||
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
|
||||
ReviewTask task = new ReviewTask(
|
||||
skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId);
|
||||
try {
|
||||
ReviewTask saved = reviewTaskRepository.save(task);
|
||||
eventPublisher.publishEvent(new ReviewSubmittedEvent(
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ public class ReviewTask {
|
|||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "skill_version_id", nullable = false)
|
||||
@Column(name = "skill_version_id")
|
||||
private Long skillVersionId;
|
||||
|
||||
@Column(name = "skill_id", nullable = false)
|
||||
private Long skillId;
|
||||
|
||||
@Column(name = "skill_version", nullable = false, length = 64)
|
||||
private String skillVersion;
|
||||
|
||||
@Column(name = "namespace_id", nullable = false)
|
||||
private Long namespaceId;
|
||||
|
||||
|
|
@ -49,10 +55,23 @@ public class ReviewTask {
|
|||
this.submittedBy = submittedBy;
|
||||
}
|
||||
|
||||
public ReviewTask(Long skillVersionId, Long skillId, Long namespaceId,
|
||||
String skillVersion, String submittedBy) {
|
||||
this.skillVersionId = skillVersionId;
|
||||
this.skillId = skillId;
|
||||
this.namespaceId = namespaceId;
|
||||
this.skillVersion = skillVersion;
|
||||
this.submittedBy = submittedBy;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
|
||||
public Long getSkillVersionId() { return skillVersionId; }
|
||||
|
||||
public Long getSkillId() { return skillId; }
|
||||
|
||||
public String getSkillVersion() { return skillVersion; }
|
||||
|
||||
public Long getNamespaceId() { return namespaceId; }
|
||||
|
||||
public ReviewTaskStatus getStatus() { return status; }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review;
|
|||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
|
|
@ -15,8 +16,11 @@ public interface ReviewTaskRepository {
|
|||
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
String submittedBy, Long skillId, String skillVersion);
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
|
||||
void deleteBySkillId(Long skillId);
|
||||
void delete(ReviewTask reviewTask);
|
||||
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
|
||||
String reviewComment, Integer expectedVersion);
|
||||
|
|
|
|||
|
|
@ -107,9 +107,8 @@ public class SkillHardDeleteService {
|
|||
skillRepository.save(skill);
|
||||
skillRepository.flush();
|
||||
|
||||
if (!versionIds.isEmpty()) {
|
||||
reviewTaskRepository.deleteBySkillVersionIdIn(versionIds);
|
||||
}
|
||||
// Also removes detached historical attempts whose replaced skill version no longer exists.
|
||||
reviewTaskRepository.deleteBySkillId(skill.getId());
|
||||
promotionRequestRepository.deleteBySourceSkillIdOrTargetSkillId(skill.getId(), skill.getId());
|
||||
skillTagRepository.deleteBySkillId(skill.getId());
|
||||
skillStarRepository.deleteBySkillId(skill.getId());
|
||||
|
|
|
|||
|
|
@ -561,7 +561,8 @@ public class SkillPublishService {
|
|||
|
||||
// Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE)
|
||||
if (!autoPublish && visibility != SkillVisibility.PRIVATE) {
|
||||
ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId);
|
||||
ReviewTask reviewTask = new ReviewTask(
|
||||
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), publisherId);
|
||||
ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask);
|
||||
eventPublisher.publishEvent(new ReviewSubmittedEvent(
|
||||
savedReviewTask.getId(),
|
||||
|
|
@ -608,10 +609,11 @@ public class SkillPublishService {
|
|||
skillRepository.flush();
|
||||
}
|
||||
|
||||
// Every review task referencing this version has to go, not just a PENDING one:
|
||||
// a rejected version still owns a REJECTED task whose foreign key blocks the
|
||||
// skill_version delete below, which surfaces to the caller as an HTTP 500.
|
||||
reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId()));
|
||||
// A replaceable version may still have one obsolete pending task, but settled attempts are
|
||||
// durable governance history. The database detaches those settled attempts from the
|
||||
// replaced version while retaining their skill/version snapshot.
|
||||
reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING)
|
||||
.ifPresent(reviewTaskRepository::delete);
|
||||
|
||||
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
|
||||
List<String> storageKeys = new ArrayList<>();
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ public class SkillReviewSubmitService {
|
|||
skillVersionRepository.save(version);
|
||||
|
||||
// Create review task
|
||||
ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId);
|
||||
ReviewTask reviewTask = new ReviewTask(
|
||||
versionId, skill.getId(), skill.getNamespaceId(), version.getVersion(), actorUserId);
|
||||
reviewTaskRepository.save(reviewTask);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ class SkillHardDeleteServiceTest {
|
|||
inOrder.verify(skillRepository).save(skill);
|
||||
inOrder.verify(skillRepository).flush();
|
||||
inOrder.verify(skillVersionRepository).deleteBySkillId(7L);
|
||||
verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(21L, 22L));
|
||||
verify(reviewTaskRepository).deleteBySkillId(7L);
|
||||
verify(promotionRequestRepository).deleteBySourceSkillIdOrTargetSkillId(7L, 7L);
|
||||
verify(skillTagRepository).deleteBySkillId(7L);
|
||||
verify(skillStarRepository).deleteBySkillId(7L);
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ class SkillPublishServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception {
|
||||
void testPublishFromEntries_ShouldPreserveSettledReviewHistoryWhenReplacingRejectedVersion() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String publisherId = "user-100";
|
||||
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
|
||||
|
|
@ -473,7 +473,7 @@ class SkillPublishServiceTest {
|
|||
|
||||
assertEquals("1.0.0", result.version().getVersion());
|
||||
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
|
||||
verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L));
|
||||
verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(List.of(8L));
|
||||
verify(skillFileRepository).deleteByVersionId(8L);
|
||||
verify(skillVersionRepository).delete(rejectedVersion);
|
||||
verify(skillVersionRepository, times(2)).flush();
|
||||
|
|
@ -482,6 +482,8 @@ class SkillPublishServiceTest {
|
|||
ArgumentCaptor<ReviewTask> reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class);
|
||||
verify(reviewTaskRepository).save(reviewTaskCaptor.capture());
|
||||
assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId());
|
||||
assertEquals(skill.getId(), reviewTaskCaptor.getValue().getSkillId());
|
||||
assertEquals("1.0.0", reviewTaskCaptor.getValue().getSkillVersion());
|
||||
assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
|
|
@ -28,10 +29,15 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
|
|||
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
|
||||
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
String submittedBy, Long skillId, String skillVersion);
|
||||
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
|
||||
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
|
||||
|
||||
void deleteBySkillId(Long skillId);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
UPDATE ReviewTask t
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ test.describe('Rejected version replacement (Real API)', () => {
|
|||
})
|
||||
|
||||
test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => {
|
||||
const consoleErrors: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
|
||||
const publisherBuilder = new E2eTestDataBuilder(page, testInfo)
|
||||
await publisherBuilder.init()
|
||||
|
||||
|
|
@ -74,8 +81,43 @@ test.describe('Rejected version replacement (Real API)', () => {
|
|||
expect(replacement.version).toBe(firstPublish.version)
|
||||
expect(replacementReviewId).not.toBe(rejectedReviewId)
|
||||
|
||||
const attemptsResponse = await page.request.get(
|
||||
`/api/web/reviews/my-progress/${replacementReviewId}/attempts`,
|
||||
)
|
||||
expect(attemptsResponse.status()).toBe(200)
|
||||
const attemptsBody = await attemptsResponse.json() as {
|
||||
data: Array<{ id: number; status: string; skillVersionId: number | null }>
|
||||
}
|
||||
expect(attemptsBody.data.map((attempt) => attempt.id)).toEqual([
|
||||
replacementReviewId,
|
||||
rejectedReviewId,
|
||||
])
|
||||
expect(attemptsBody.data.map((attempt) => attempt.status)).toEqual(['PENDING', 'REJECTED'])
|
||||
expect(attemptsBody.data[1]?.skillVersionId).toBeNull()
|
||||
|
||||
const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`)
|
||||
expect(replacedReviewResponse.status()).toBe(404)
|
||||
expect(replacedReviewResponse.status()).toBe(200)
|
||||
|
||||
await page.goto('/dashboard/review-progress')
|
||||
await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible()
|
||||
const progressCard = page.locator('article').filter({ hasText: replacement.slug })
|
||||
await expect(progressCard).toContainText('In review')
|
||||
await expect(progressCard).toContainText('2 submissions')
|
||||
await progressCard.getByRole('button', { name: 'Submission history' }).click()
|
||||
await expect(progressCard).toContainText('Attempt 2')
|
||||
await expect(progressCard).toContainText('Attempt 1')
|
||||
await expect(progressCard).toContainText('Rejected by Playwright E2E')
|
||||
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 })
|
||||
const unexpectedConsoleErrors = consoleErrors.filter((message) => (
|
||||
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
|
||||
))
|
||||
expect(unexpectedConsoleErrors).toEqual([])
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await adminBuilder.cleanup()
|
||||
await adminContext.close()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
MergeInitiateResponse,
|
||||
MergeVerifyRequest,
|
||||
ReviewSkillDetail,
|
||||
ReviewProgress,
|
||||
ReviewTask,
|
||||
PromotionSortBy,
|
||||
PromotionSortDirection,
|
||||
|
|
@ -882,6 +883,21 @@ export const reviewApi = {
|
|||
return fetchJson<ReviewTask>(`${WEB_API_PREFIX}/reviews/${id}`)
|
||||
},
|
||||
|
||||
async listMyProgress(params: { status?: string; q?: string; page?: number; size?: number }) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.status) searchParams.set('status', params.status)
|
||||
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 }>(
|
||||
`${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
async listMyAttempts(reviewTaskId: number): Promise<ReviewTask[]> {
|
||||
return fetchJson<ReviewTask[]>(`${WEB_API_PREFIX}/reviews/my-progress/${reviewTaskId}/attempts`)
|
||||
},
|
||||
|
||||
async getSkillDetail(id: number): Promise<ReviewSkillDetail> {
|
||||
return fetchJson<ReviewSkillDetail>(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`)
|
||||
},
|
||||
|
|
|
|||
202
web/src/api/generated/schema.d.ts
vendored
202
web/src/api/generated/schema.d.ts
vendored
|
|
@ -2500,6 +2500,70 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/reviews/my-progress/{id}/attempts": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listMyAttempts"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/reviews/my-progress/{id}/attempts": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listMyAttempts_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/reviews/my-progress": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listMyProgress"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/reviews/my-progress": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listMyProgress_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/promotions/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -4647,6 +4711,50 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseListReviewTaskResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["ReviewTaskResponse"][];
|
||||
/** 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: {
|
||||
items?: components["schemas"]["ReviewProgressResponse"][];
|
||||
/** Format: int64 */
|
||||
total?: number;
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ReviewProgressResponse: {
|
||||
/** Format: int64 */
|
||||
latestReviewTaskId?: number;
|
||||
/** Format: int64 */
|
||||
skillId?: number;
|
||||
namespace?: string;
|
||||
skillSlug?: string;
|
||||
skillVersion?: string;
|
||||
latestStatus?: string;
|
||||
latestReviewComment?: string;
|
||||
/** Format: date-time */
|
||||
latestSubmittedAt?: string;
|
||||
/** Format: date-time */
|
||||
latestReviewedAt?: string;
|
||||
/** Format: int64 */
|
||||
attemptCount?: number;
|
||||
};
|
||||
ApiResponsePageResponsePromotionResponseDto: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -10167,6 +10275,100 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
listMyAttempts: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseListReviewTaskResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listMyAttempts_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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listMyProgress: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string;
|
||||
q?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listMyProgress_1: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string;
|
||||
q?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getPromotionDetail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ export interface SkillDeleteResult {
|
|||
|
||||
export interface ReviewTask {
|
||||
id: number
|
||||
skillVersionId: number
|
||||
skillVersionId: number | null
|
||||
namespace: string
|
||||
skillSlug: string
|
||||
version: string
|
||||
|
|
@ -441,6 +441,19 @@ export interface ReviewTask {
|
|||
reviewedAt?: string
|
||||
}
|
||||
|
||||
export interface ReviewProgress {
|
||||
latestReviewTaskId: number
|
||||
skillId: number
|
||||
namespace: string
|
||||
skillSlug: string
|
||||
skillVersion: string
|
||||
latestStatus: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
latestReviewComment?: string
|
||||
latestSubmittedAt: string
|
||||
latestReviewedAt?: string
|
||||
attemptCount: number
|
||||
}
|
||||
|
||||
export interface ReviewSkillDetail {
|
||||
skill: SkillDetail
|
||||
versions: SkillVersion[]
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ const NamespaceReviewDetailPage = createLazyRouteComponent(
|
|||
)
|
||||
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
|
||||
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
|
||||
const ReviewProgressPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/review-progress'),
|
||||
'ReviewProgressPage',
|
||||
)
|
||||
const ReportsPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/dashboard/reports'),
|
||||
'ReportsPage',
|
||||
|
|
@ -328,6 +332,24 @@ const dashboardReviewsRoute = createRoute({
|
|||
component: ReviewsPage,
|
||||
})
|
||||
|
||||
const dashboardReviewProgressRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/review-progress',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: (search: Record<string, unknown>): {
|
||||
status?: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
q?: string
|
||||
page?: number
|
||||
} => ({
|
||||
status: search.status === 'PENDING' || search.status === 'APPROVED' || search.status === 'REJECTED'
|
||||
? search.status
|
||||
: undefined,
|
||||
q: typeof search.q === 'string' && search.q.trim() ? search.q.trim() : undefined,
|
||||
page: typeof search.page === 'number' && search.page > 0 ? search.page : undefined,
|
||||
}),
|
||||
component: ReviewProgressPage,
|
||||
})
|
||||
|
||||
const dashboardReportsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/reports',
|
||||
|
|
@ -478,6 +500,7 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardNamespaceReviewDetailRoute,
|
||||
dashboardGovernanceRoute,
|
||||
dashboardReviewsRoute,
|
||||
dashboardReviewProgressRoute,
|
||||
dashboardReportsRoute,
|
||||
dashboardReviewDetailRoute,
|
||||
dashboardPromotionsRoute,
|
||||
|
|
|
|||
24
web/src/features/review/use-my-review-progress.ts
Normal file
24
web/src/features/review/use-my-review-progress.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { reviewApi } from '@/api/client'
|
||||
|
||||
interface MyReviewProgressParams {
|
||||
status?: string
|
||||
q?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export function useMyReviewProgress(params: MyReviewProgressParams) {
|
||||
return useQuery({
|
||||
queryKey: ['reviews', 'my-progress', params],
|
||||
queryFn: () => reviewApi.listMyProgress(params),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMyReviewAttempts(reviewTaskId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ['reviews', 'my-progress', reviewTaskId, 'attempts'],
|
||||
queryFn: () => reviewApi.listMyAttempts(reviewTaskId!),
|
||||
enabled: reviewTaskId !== null,
|
||||
})
|
||||
}
|
||||
|
|
@ -491,6 +491,28 @@
|
|||
"switchToLight": "Switch to light theme",
|
||||
"switchToDark": "Switch to dark theme"
|
||||
},
|
||||
"reviewProgress": {
|
||||
"title": "My Review Progress",
|
||||
"subtitle": "Track the current decision, reviewer feedback, and every resubmission of your skills",
|
||||
"searchLabel": "Search skills or namespaces",
|
||||
"searchPlaceholder": "Search skill or namespace",
|
||||
"searchAction": "Search",
|
||||
"statusFilter": "Filter by review status",
|
||||
"statusAll": "All statuses",
|
||||
"statusPending": "In review",
|
||||
"statusApproved": "Approved",
|
||||
"statusRejected": "Rejected",
|
||||
"latestSubmitted": "Latest submission: {{time}}",
|
||||
"attemptCount": "{{count}} submissions",
|
||||
"attemptNumber": "Attempt {{number}}",
|
||||
"history": "Submission history",
|
||||
"resubmit": "Edit and resubmit",
|
||||
"loading": "Loading review progress",
|
||||
"error": "Review progress could not be loaded. Try again later.",
|
||||
"historyError": "Submission history could not be loaded. Try again later.",
|
||||
"emptyTitle": "No review history yet",
|
||||
"emptyDescription": "Reviews for public or team skills will appear here after submission."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Review Center",
|
||||
"subtitle": "Manage platform review tasks",
|
||||
|
|
@ -1332,6 +1354,7 @@
|
|||
"governance": "Governance Center",
|
||||
"stars": "Starred",
|
||||
"subscriptions": "My Subscriptions",
|
||||
"reviewProgress": "My Review Progress",
|
||||
"reviews": "Review Management",
|
||||
"promotions": "Promotion Management",
|
||||
"reports": "Report Management",
|
||||
|
|
|
|||
|
|
@ -491,6 +491,28 @@
|
|||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему"
|
||||
},
|
||||
"reviewProgress": {
|
||||
"title": "Мои проверки",
|
||||
"subtitle": "Текущий статус, комментарии проверяющего и история повторных отправок",
|
||||
"searchLabel": "Поиск навыков или пространств имён",
|
||||
"searchPlaceholder": "Навык или пространство имён",
|
||||
"searchAction": "Найти",
|
||||
"statusFilter": "Фильтр по статусу проверки",
|
||||
"statusAll": "Все статусы",
|
||||
"statusPending": "На проверке",
|
||||
"statusApproved": "Одобрено",
|
||||
"statusRejected": "Отклонено",
|
||||
"latestSubmitted": "Последняя отправка: {{time}}",
|
||||
"attemptCount": "Отправок: {{count}}",
|
||||
"attemptNumber": "Попытка {{number}}",
|
||||
"history": "История отправок",
|
||||
"resubmit": "Изменить и отправить снова",
|
||||
"loading": "Загрузка статуса проверки",
|
||||
"error": "Не удалось загрузить статус проверки. Повторите попытку позже.",
|
||||
"historyError": "Не удалось загрузить историю. Повторите попытку позже.",
|
||||
"emptyTitle": "Истории проверок пока нет",
|
||||
"emptyDescription": "После отправки публичного или командного навыка его статус появится здесь."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Центр рецензирования",
|
||||
"subtitle": "Задачи рецензирования на платформе",
|
||||
|
|
@ -1633,6 +1655,7 @@
|
|||
"governance": "Центр управления",
|
||||
"stars": "Избранное",
|
||||
"subscriptions": "Мои подписки",
|
||||
"reviewProgress": "Мои проверки",
|
||||
"reviews": "Управление ревью",
|
||||
"promotions": "Управление продвижением",
|
||||
"reports": "Управление жалобами",
|
||||
|
|
|
|||
|
|
@ -491,6 +491,28 @@
|
|||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题"
|
||||
},
|
||||
"reviewProgress": {
|
||||
"title": "我的审核进度",
|
||||
"subtitle": "查看技能提交的当前状态、审核意见和每次重提记录",
|
||||
"searchLabel": "搜索技能或命名空间",
|
||||
"searchPlaceholder": "搜索 Skill 或命名空间",
|
||||
"searchAction": "搜索",
|
||||
"statusFilter": "按审核状态筛选",
|
||||
"statusAll": "全部状态",
|
||||
"statusPending": "审核中",
|
||||
"statusApproved": "已通过",
|
||||
"statusRejected": "已拒绝",
|
||||
"latestSubmitted": "最近提交:{{time}}",
|
||||
"attemptCount": "共 {{count}} 次提交",
|
||||
"attemptNumber": "第 {{number}} 次",
|
||||
"history": "提交历史",
|
||||
"resubmit": "修改并重提",
|
||||
"loading": "正在加载审核进度",
|
||||
"error": "审核进度加载失败,请稍后重试。",
|
||||
"historyError": "提交历史加载失败,请稍后重试。",
|
||||
"emptyTitle": "还没有审核记录",
|
||||
"emptyDescription": "提交公开或团队 Skill 后,审核进度会显示在这里。"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "审核中心",
|
||||
"subtitle": "管理平台审核事务",
|
||||
|
|
@ -1332,6 +1354,7 @@
|
|||
"governance": "治理中心",
|
||||
"stars": "我的收藏",
|
||||
"subscriptions": "我的订阅",
|
||||
"reviewProgress": "我的审核进度",
|
||||
"reviews": "审核管理",
|
||||
"promotions": "推广管理",
|
||||
"reports": "举报管理",
|
||||
|
|
|
|||
58
web/src/pages/dashboard/review-progress.test.tsx
Normal file
58
web/src/pages/dashboard/review-progress.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ReviewProgressPage } from './review-progress'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'zh' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => ({}),
|
||||
Link: ({ children, to, className }: { children: ReactNode; to: string; className?: string }) => (
|
||||
<a href={to} className={className}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/use-my-review-progress', () => ({
|
||||
useMyReviewProgress: () => ({
|
||||
data: {
|
||||
items: [{
|
||||
latestReviewTaskId: 42,
|
||||
skillId: 7,
|
||||
namespace: 'team-a',
|
||||
skillSlug: 'demo-skill',
|
||||
skillVersion: '1.2.0',
|
||||
latestStatus: 'REJECTED',
|
||||
latestReviewComment: 'Please clarify the permission requirements.',
|
||||
latestSubmittedAt: '2026-09-01T01:00:00Z',
|
||||
latestReviewedAt: '2026-09-01T02:00:00Z',
|
||||
attemptCount: 2,
|
||||
}],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useMyReviewAttempts: () => ({ data: [], isLoading: false, isError: false }),
|
||||
}))
|
||||
|
||||
describe('ReviewProgressPage', () => {
|
||||
it('renders the author-facing latest result and keeps reviewer management out of the page', () => {
|
||||
const html = renderToStaticMarkup(<ReviewProgressPage />)
|
||||
|
||||
expect(html).toContain('reviewProgress.title')
|
||||
expect(html).toContain('@team-a/demo-skill')
|
||||
expect(html).toContain('v1.2.0')
|
||||
expect(html).toContain('reviewProgress.statusRejected')
|
||||
expect(html).toContain('reviewProgress.resubmit')
|
||||
expect(html).toContain('reviewProgress.history')
|
||||
expect(html).not.toContain('reviews.typeSkill')
|
||||
})
|
||||
})
|
||||
255
web/src/pages/dashboard/review-progress.tsx
Normal file
255
web/src/pages/dashboard/review-progress.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
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 { useMyReviewAttempts, useMyReviewProgress } from '@/features/review/use-my-review-progress'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import { Button, buttonVariants } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
|
||||
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const statusClassNames: Record<ReviewStatus, 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',
|
||||
}
|
||||
|
||||
export function ReviewProgressPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const search = useSearch({ from: '/dashboard/review-progress' })
|
||||
const [queryInput, setQueryInput] = useState(search.q ?? '')
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const progressQuery = useMyReviewProgress({
|
||||
status: search.status,
|
||||
q: search.q,
|
||||
page: search.page ?? 0,
|
||||
size: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const page = search.page ?? 0
|
||||
const totalPages = progressQuery.data
|
||||
? Math.ceil(progressQuery.data.total / progressQuery.data.size)
|
||||
: 0
|
||||
|
||||
function updateSearch(next: { status?: ReviewStatus | null; q?: string; page?: number }) {
|
||||
void navigate({
|
||||
to: '/dashboard/review-progress',
|
||||
search: {
|
||||
status: next.status === null ? undefined : next.status ?? search.status,
|
||||
q: next.q ?? search.q,
|
||||
page: next.page ?? 0,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
function submitSearch(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
updateSearch({ q: queryInput.trim(), page: 0 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader
|
||||
title={t('reviewProgress.title')}
|
||||
subtitle={t('reviewProgress.subtitle')}
|
||||
/>
|
||||
|
||||
<Card className="border-border/70">
|
||||
<CardContent className="space-y-6 p-5 md:p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<form className="flex min-w-0 flex-1 gap-2" onSubmit={submitSearch}>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={queryInput}
|
||||
onChange={(event) => setQueryInput(event.target.value)}
|
||||
placeholder={t('reviewProgress.searchPlaceholder')}
|
||||
className="pl-9"
|
||||
aria-label={t('reviewProgress.searchLabel')}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline">{t('reviewProgress.searchAction')}</Button>
|
||||
</form>
|
||||
<div className="w-full md:w-48">
|
||||
<Select
|
||||
value={search.status ?? 'ALL'}
|
||||
onValueChange={(value) => updateSearch({
|
||||
status: value === 'ALL' ? null : value as ReviewStatus,
|
||||
page: 0,
|
||||
})}
|
||||
>
|
||||
<SelectTrigger aria-label={t('reviewProgress.statusFilter')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">{t('reviewProgress.statusAll')}</SelectItem>
|
||||
<SelectItem value="PENDING">{t('reviewProgress.statusPending')}</SelectItem>
|
||||
<SelectItem value="APPROVED">{t('reviewProgress.statusApproved')}</SelectItem>
|
||||
<SelectItem value="REJECTED">{t('reviewProgress.statusRejected')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progressQuery.isLoading ? (
|
||||
<div className="space-y-3" aria-label={t('reviewProgress.loading')}>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-28 animate-shimmer rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : progressQuery.isError ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">
|
||||
{t('reviewProgress.error')}
|
||||
</div>
|
||||
) : progressQuery.data?.items.length ? (
|
||||
<div className="space-y-3">
|
||||
{progressQuery.data.items.map((item) => (
|
||||
<ProgressItem
|
||||
key={`${item.skillId}:${item.skillVersion}`}
|
||||
item={item}
|
||||
expanded={expandedId === item.latestReviewTaskId}
|
||||
onToggle={() => setExpandedId((current) => (
|
||||
current === item.latestReviewTaskId ? null : item.latestReviewTaskId
|
||||
))}
|
||||
locale={i18n.language}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/70 px-6 py-14 text-center">
|
||||
<Clock3 className="mx-auto h-8 w-8 text-muted-foreground" />
|
||||
<p className="mt-4 font-medium text-foreground">{t('reviewProgress.emptyTitle')}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('reviewProgress.emptyDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressItem({
|
||||
item,
|
||||
expanded,
|
||||
onToggle,
|
||||
locale,
|
||||
}: {
|
||||
item: ReviewProgress
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
locale: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const attemptsQuery = useMyReviewAttempts(expanded ? item.latestReviewTaskId : null)
|
||||
const status = item.latestStatus
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-xl border border-border/70 bg-card text-card-foreground">
|
||||
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:p-5">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: item.namespace, slug: item.skillSlug }}
|
||||
className="truncate font-semibold text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
@{item.namespace}/{item.skillSlug}
|
||||
</Link>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs text-secondary-foreground">
|
||||
v{item.skillVersion}
|
||||
</span>
|
||||
<span className={cn('rounded-full border px-2.5 py-1 text-xs font-medium', statusClassNames[status])}>
|
||||
{t(`reviewProgress.status${status === 'PENDING' ? 'Pending' : status === 'APPROVED' ? 'Approved' : 'Rejected'}`)}
|
||||
</span>
|
||||
</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>
|
||||
<span>{t('reviewProgress.attemptCount', { count: item.attemptCount })}</span>
|
||||
</div>
|
||||
{item.latestReviewComment ? (
|
||||
<p className="line-clamp-2 text-sm text-foreground/85">{item.latestReviewComment}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{status === 'REJECTED' ? (
|
||||
<Link
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: item.namespace, slug: item.skillSlug }}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'gap-2')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t('reviewProgress.resubmit')}
|
||||
</Link>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="gap-2"
|
||||
>
|
||||
{t('reviewProgress.history')}
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="border-t border-border/70 bg-muted/30 px-4 py-4 md:px-5">
|
||||
{attemptsQuery.isLoading ? (
|
||||
<div className="h-16 animate-shimmer rounded-lg" />
|
||||
) : attemptsQuery.isError ? (
|
||||
<p className="text-sm text-destructive">{t('reviewProgress.historyError')}</p>
|
||||
) : (
|
||||
<AttemptTimeline 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -77,6 +77,20 @@ describe('user-menu module exports', () => {
|
|||
})
|
||||
|
||||
describe('UserMenu security settings visibility', () => {
|
||||
it('keeps author review progress separate from reviewer management', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
user={{
|
||||
displayName: 'Skill Author',
|
||||
platformRoles: ['USER'],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(html).toContain('user.menu.reviewProgress')
|
||||
expect(html).not.toContain('user.menu.reviews')
|
||||
})
|
||||
|
||||
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
|
|
|
|||
|
|
@ -164,6 +164,9 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
<Link to="/dashboard/subscriptions" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.subscriptions')}
|
||||
</Link>
|
||||
<Link to="/dashboard/review-progress" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reviewProgress')}
|
||||
</Link>
|
||||
{reviewCenterVisible ? (
|
||||
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reviews')}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue