mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
Merge pull request #791 from iflytek/codex/validate/issues620-621-20260901
feat(review): add progress history and light-dark themes
This commit is contained in:
commit
08723fd01a
71 changed files with 2650 additions and 95 deletions
|
|
@ -13,6 +13,10 @@
|
|||
|
||||
<artifactId>skillhub-app</artifactId>
|
||||
|
||||
<properties>
|
||||
<testcontainers.version>1.21.4</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
|
@ -121,6 +125,16 @@
|
|||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ 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.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.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
|
@ -136,6 +138,38 @@ public class ReviewController extends BaseApiController {
|
|||
return ok("response.success.read", governanceWorkflowAppService.listMyReviewSubmissions(page, size, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/my-progress")
|
||||
public ApiResponse<ReviewProgressPageResponse> 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}/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,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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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,176 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
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;
|
||||
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 RANKED_CTE = """
|
||||
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
|
||||
)
|
||||
""";
|
||||
|
||||
private static final String MY_PROGRESS_SQL = RANKED_CTE + """
|
||||
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
|
||||
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
|
||||
""";
|
||||
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public JpaReviewProgressQueryRepository(EntityManager entityManager) {
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public ReviewProgressPageResponse findMyProgress(
|
||||
String userId,
|
||||
ReviewTaskStatus status,
|
||||
String query,
|
||||
int page,
|
||||
int size) {
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
String statusName = status != null ? status.name() : "";
|
||||
String queryPattern = "%" + normalizedQuery + "%";
|
||||
Query nativeQuery = bindFilters(
|
||||
entityManager.createNativeQuery(MY_PROGRESS_SQL),
|
||||
userId,
|
||||
statusName,
|
||||
normalizedQuery,
|
||||
queryPattern)
|
||||
.setParameter("offset", (long) 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();
|
||||
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) {
|
||||
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,18 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
|
||||
/**
|
||||
* Query seam for author-facing review progress grouped by skill version.
|
||||
*/
|
||||
public interface ReviewProgressQueryRepository {
|
||||
|
||||
ReviewProgressPageResponse 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.ReviewProgressPageResponse;
|
||||
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,26 @@ public class GovernanceWorkflowAppService {
|
|||
return reviewPortalAppService.listMySubmissions(page, size, userId);
|
||||
}
|
||||
|
||||
public ReviewProgressPageResponse 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 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,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.ReviewProgressPageResponse;
|
||||
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,63 @@ public class ReviewPortalAppService {
|
|||
));
|
||||
}
|
||||
|
||||
public ReviewProgressPageResponse 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 List<ReviewTaskResponse> listReviewAttempts(
|
||||
Long reviewTaskId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
|
||||
Namespace namespace = namespaceRepository.findById(anchor.getNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException(
|
||||
"namespace.not_found", anchor.getNamespaceId()));
|
||||
if (!reviewService.canReviewNamespace(
|
||||
anchor,
|
||||
userId,
|
||||
namespace.getType(),
|
||||
normalizeRoles(userNsRoles),
|
||||
platformRoles(userId))) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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,15 @@ 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.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;
|
||||
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 +42,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 +84,9 @@ class ReviewPortalControllerTest {
|
|||
@MockBean
|
||||
private GovernanceQueryRepository governanceQueryRepository;
|
||||
|
||||
@MockBean
|
||||
private ReviewProgressQueryRepository reviewProgressQueryRepository;
|
||||
|
||||
@MockBean
|
||||
private RbacService rbacService;
|
||||
|
||||
|
|
@ -276,6 +284,188 @@ 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 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.statusCounts.pending").value(0))
|
||||
.andExpect(jsonPath("$.data.statusCounts.rejected").value(1));
|
||||
|
||||
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");
|
||||
ReviewTask otherAuthor = createReviewTask(7L, 20L, "author-2", ReviewTaskStatus.REJECTED);
|
||||
setField(otherAuthor, "skillId", 30L);
|
||||
setField(otherAuthor, "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(reviewTaskRepository.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0"))
|
||||
.willReturn(List.of(latest, previous, otherAuthor));
|
||||
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))
|
||||
.andExpect(jsonPath("$.data[0].submittedBy").value("author-1"))
|
||||
.andExpect(jsonPath("$.data[1].submittedBy").value("author-1"));
|
||||
|
||||
verify(reviewTaskRepository, never())
|
||||
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0");
|
||||
}
|
||||
|
||||
@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-2", 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.canReviewNamespace(
|
||||
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))
|
||||
.andExpect(jsonPath("$.data[0].submittedBy").value("author-1"))
|
||||
.andExpect(jsonPath("$.data[1].submittedBy").value("author-2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReviewAttempts_allowsNamespaceAdminToReadCrossAuthorHistory() 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-2", ReviewTaskStatus.REJECTED);
|
||||
setField(previous, "skillId", 30L);
|
||||
setField(previous, "skillVersion", "1.0.0");
|
||||
Namespace namespace = createNamespace(20L, "team-a");
|
||||
stubNamespaceRoles("namespace-admin", List.of(new NamespaceMember(
|
||||
20L, "namespace-admin", NamespaceRole.ADMIN)));
|
||||
given(rbacService.getUserRoleCodes("namespace-admin")).willReturn(Set.of());
|
||||
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest));
|
||||
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
|
||||
given(reviewService.canReviewNamespace(
|
||||
latest,
|
||||
"namespace-admin",
|
||||
namespace.getType(),
|
||||
Map.of(20L, NamespaceRole.ADMIN),
|
||||
Set.of())).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("namespace-admin")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andExpect(jsonPath("$.data[0].submittedBy").value("author-1"))
|
||||
.andExpect(jsonPath("$.data[1].submittedBy").value("author-2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReviewAttempts_forbidsSubmitterWithoutReviewerRole() throws Exception {
|
||||
ReviewTask ownAttempt = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED);
|
||||
setField(ownAttempt, "skillId", 30L);
|
||||
setField(ownAttempt, "skillVersion", "1.0.0");
|
||||
Namespace namespace = createNamespace(20L, "team-a");
|
||||
stubNamespaceRoles("author-1", List.of(new NamespaceMember(
|
||||
20L, "author-1", NamespaceRole.MEMBER)));
|
||||
given(rbacService.getUserRoleCodes("author-1")).willReturn(Set.of());
|
||||
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(ownAttempt));
|
||||
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
|
||||
given(reviewService.canReviewNamespace(
|
||||
ownAttempt,
|
||||
"author-1",
|
||||
namespace.getType(),
|
||||
Map.of(20L, NamespaceRole.MEMBER),
|
||||
Set.of())).willReturn(false);
|
||||
|
||||
mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("author-1")))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
|
||||
verify(reviewTaskRepository, never())
|
||||
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0");
|
||||
}
|
||||
|
||||
@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.canReviewNamespace(
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
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.jdbc.AutoConfigureTestDatabase;
|
||||
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.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(JpaReviewProgressQueryRepository.class)
|
||||
@Testcontainers
|
||||
class JpaReviewProgressQueryRepositoryTest {
|
||||
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES =
|
||||
new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configurePostgres(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
|
||||
}
|
||||
|
||||
@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));
|
||||
Skill gamma = entityManager.persistFlushFind(
|
||||
new Skill(namespace.getId(), "gamma-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(
|
||||
gamma,
|
||||
namespace,
|
||||
"author-1",
|
||||
"3.0.0",
|
||||
ReviewTaskStatus.REJECTED,
|
||||
Instant.parse("2026-08-28T10: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.items()).hasSize(1);
|
||||
assertThat(firstPage.total()).isEqualTo(3);
|
||||
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()).isEqualTo(1);
|
||||
|
||||
var emptyPage = repository.findMyProgress("author-1", null, "", 8, 1);
|
||||
assertThat(emptyPage.items()).isEmpty();
|
||||
assertThat(emptyPage.total()).isEqualTo(3);
|
||||
|
||||
var maximumPage = repository.findMyProgress(
|
||||
"author-1", null, "", Integer.MAX_VALUE, 100);
|
||||
assertThat(maximumPage.items()).isEmpty();
|
||||
assertThat(maximumPage.total()).isEqualTo(3);
|
||||
|
||||
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);
|
||||
|
||||
var searchMiss = repository.findMyProgress("author-1", null, "missing", 0, 20);
|
||||
assertThat(searchMiss.items()).isEmpty();
|
||||
assertThat(searchMiss.total()).isZero();
|
||||
assertThat(searchMiss.statusCounts().pending()).isZero();
|
||||
assertThat(searchMiss.statusCounts().approved()).isZero();
|
||||
assertThat(searchMiss.statusCounts().rejected()).isZero();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -611,6 +611,18 @@ class ScanTaskConsumerTest {
|
|||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
String submittedBy, Long skillId, String skillVersion) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
Long skillId, String skillVersion) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByNamespaceId(Long namespaceId) {
|
||||
return false;
|
||||
|
|
@ -621,6 +633,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,13 @@ 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);
|
||||
List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
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,18 @@ 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);
|
||||
|
||||
List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||
Long skillId, String skillVersion);
|
||||
|
||||
boolean existsByNamespaceId(Long namespaceId);
|
||||
|
||||
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
|
||||
|
||||
void deleteBySkillId(Long skillId);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
UPDATE ReviewTask t
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { loginWithCredentials, registerSession } from './helpers/session'
|
||||
import { createFreshSession, loginWithCredentials } from './helpers/session'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
|
||||
function getOptionalEnv(name: string): string | undefined {
|
||||
|
|
@ -15,20 +15,39 @@ function adminCredentials() {
|
|||
}
|
||||
}
|
||||
|
||||
function withoutKnownMetaCspWarning(messages: string[]): string[] {
|
||||
return messages.filter((message) => (
|
||||
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
|
||||
))
|
||||
}
|
||||
|
||||
test.describe('Rejected version replacement (Real API)', () => {
|
||||
test.describe.configure({ timeout: 150_000 })
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await registerSession(page, testInfo)
|
||||
await createFreshSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => {
|
||||
const consoleErrors: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
const adminConsoleErrors: string[] = []
|
||||
const adminPageErrors: 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()
|
||||
|
||||
const adminContext = await browser.newContext()
|
||||
const adminPage = await adminContext.newPage()
|
||||
adminPage.on('console', (message) => {
|
||||
if (message.type() === 'error') adminConsoleErrors.push(message.text())
|
||||
})
|
||||
adminPage.on('pageerror', (error) => adminPageErrors.push(error.message))
|
||||
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
|
||||
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
|
||||
await adminBuilder.init()
|
||||
|
|
@ -52,6 +71,23 @@ test.describe('Rejected version replacement (Real API)', () => {
|
|||
'PENDING_REVIEW',
|
||||
)
|
||||
await adminBuilder.rejectReview(rejectedReviewId)
|
||||
await publisherBuilder.waitForVersionStatus(
|
||||
namespace.slug,
|
||||
firstPublish.slug,
|
||||
firstPublish.version,
|
||||
'REJECTED',
|
||||
)
|
||||
|
||||
await page.goto('/dashboard/review-progress')
|
||||
await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible()
|
||||
const rejectedCard = page.locator('article').filter({ hasText: firstPublish.slug })
|
||||
await expect(rejectedCard).toContainText('Rejected')
|
||||
await expect(rejectedCard).toContainText('1 submission')
|
||||
await rejectedCard.getByRole('button', { name: 'Submission history' }).click()
|
||||
await expect(rejectedCard).toContainText('Rejected by Playwright E2E')
|
||||
await rejectedCard.getByRole('link', { name: 'Edit and resubmit' }).click()
|
||||
await expect(page).toHaveURL(/\/dashboard\/publish/)
|
||||
await expect(page.getByText(new RegExp(`Resubmit .* v${firstPublish.version}`))).toBeVisible()
|
||||
|
||||
const replacement = await publisherBuilder.publishSkill(namespace.slug, {
|
||||
name: skillName,
|
||||
|
|
@ -74,8 +110,122 @@ test.describe('Rejected version replacement (Real API)', () => {
|
|||
expect(replacement.version).toBe(firstPublish.version)
|
||||
expect(replacementReviewId).not.toBe(rejectedReviewId)
|
||||
|
||||
const progressResponse = await page.request.get(
|
||||
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=0&size=20`,
|
||||
)
|
||||
expect(progressResponse.status()).toBe(200)
|
||||
const progressBody = await progressResponse.json() as {
|
||||
data: {
|
||||
items: Array<{ latestStatus: string; attemptCount: number }>
|
||||
total: number
|
||||
statusCounts: { pending: number; approved: number; rejected: number }
|
||||
}
|
||||
}
|
||||
expect(progressBody.data.total).toBe(1)
|
||||
expect(progressBody.data.items).toHaveLength(1)
|
||||
expect(progressBody.data.items[0]).toMatchObject({ latestStatus: 'PENDING', attemptCount: 2 })
|
||||
expect(progressBody.data.statusCounts).toEqual({ pending: 1, approved: 0, rejected: 0 })
|
||||
|
||||
const rejectedFilterResponse = await page.request.get(
|
||||
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&status=REJECTED&page=0&size=20`,
|
||||
)
|
||||
expect(rejectedFilterResponse.status()).toBe(200)
|
||||
const rejectedFilterBody = await rejectedFilterResponse.json() as {
|
||||
data: { items: unknown[]; total: number; statusCounts: { pending: number } }
|
||||
}
|
||||
expect(rejectedFilterBody.data.items).toEqual([])
|
||||
expect(rejectedFilterBody.data.total).toBe(0)
|
||||
expect(rejectedFilterBody.data.statusCounts.pending).toBe(1)
|
||||
|
||||
const missingSearchResponse = await page.request.get(
|
||||
'/api/web/reviews/my-progress?q=definitely-missing-review-progress&page=0&size=20',
|
||||
)
|
||||
expect(missingSearchResponse.status()).toBe(200)
|
||||
const missingSearchBody = await missingSearchResponse.json() as {
|
||||
data: { items: unknown[]; total: number; statusCounts: { pending: number; approved: number; rejected: number } }
|
||||
}
|
||||
expect(missingSearchBody.data.items).toEqual([])
|
||||
expect(missingSearchBody.data.total).toBe(0)
|
||||
expect(missingSearchBody.data.statusCounts).toEqual({ pending: 0, approved: 0, rejected: 0 })
|
||||
|
||||
const outOfRangeResponse = await page.request.get(
|
||||
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=99&size=1`,
|
||||
)
|
||||
expect(outOfRangeResponse.status()).toBe(200)
|
||||
const outOfRangeBody = await outOfRangeResponse.json() as {
|
||||
data: { items: unknown[]; total: number; statusCounts: { pending: number } }
|
||||
}
|
||||
expect(outOfRangeBody.data.items).toEqual([])
|
||||
expect(outOfRangeBody.data.total).toBe(1)
|
||||
expect(outOfRangeBody.data.statusCounts.pending).toBe(1)
|
||||
|
||||
const maximumPageResponse = await page.request.get(
|
||||
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=2147483647&size=100`,
|
||||
)
|
||||
expect(maximumPageResponse.status()).toBe(200)
|
||||
const maximumPageBody = await maximumPageResponse.json() as {
|
||||
data: { items: unknown[]; total: number; statusCounts: { pending: number } }
|
||||
}
|
||||
expect(maximumPageBody.data.items).toEqual([])
|
||||
expect(maximumPageBody.data.total).toBe(1)
|
||||
expect(maximumPageBody.data.statusCounts.pending).toBe(1)
|
||||
|
||||
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 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(404)
|
||||
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')
|
||||
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 expect(progressCard).toContainText('Reviewed by')
|
||||
await expect(progressCard.getByRole('link', { name: 'Edit and resubmit' })).toHaveCount(0)
|
||||
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()
|
||||
expect(withoutKnownMetaCspWarning(consoleErrors)).toEqual([])
|
||||
expect(pageErrors).toEqual([])
|
||||
expect(withoutKnownMetaCspWarning(adminConsoleErrors)).toEqual([])
|
||||
expect(adminPageErrors).toEqual([])
|
||||
} finally {
|
||||
await adminBuilder.cleanup()
|
||||
await adminContext.close()
|
||||
|
|
|
|||
213
web/e2e/theme-toggle.spec.ts
Normal file
213
web/e2e/theme-toggle.spec.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
|
||||
test.describe('Light and dark theme', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await page.context().setExtraHTTPHeaders({ 'X-Mock-User-Id': 'local-user' })
|
||||
await page.route('**/api/v1/auth/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
userId: 'theme-layout-user',
|
||||
displayName: 'Theme Layout User',
|
||||
avatarUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
|
||||
platformRoles: [],
|
||||
oauthProvider: 'local',
|
||||
canChangePassword: true,
|
||||
},
|
||||
timestamp: '2026-09-01T00:00:00Z',
|
||||
requestId: 'theme-auth-fixture',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/web/me/namespaces', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: [],
|
||||
timestamp: '2026-09-01T00:00:00Z',
|
||||
requestId: 'theme-namespace-fixture',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/web/notifications/unread-count', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: { count: 1 },
|
||||
timestamp: '2026-09-01T00:00:00Z',
|
||||
requestId: 'theme-unread-fixture',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/web/notifications/sse', async (route) => {
|
||||
await route.fulfill({ status: 204 })
|
||||
})
|
||||
await page.route('**/api/web/me/stars?*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: { items: [], total: 0, page: 0, size: 100 },
|
||||
timestamp: '2026-09-01T00:00:00Z',
|
||||
requestId: 'theme-stars-fixture',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
const observedWindow = window as Window & { __themeAtFirstReactContent?: boolean }
|
||||
const observer = new MutationObserver(() => {
|
||||
const root = document.querySelector('#root')
|
||||
if (root?.childElementCount) {
|
||||
observedWindow.__themeAtFirstReactContent = document.documentElement.classList.contains('dark')
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
observer.observe(document, { childList: true, subtree: true })
|
||||
if (!window.sessionStorage.getItem('theme-test-initialized')) {
|
||||
window.localStorage.removeItem('skillhub-theme')
|
||||
window.sessionStorage.setItem('theme-test-initialized', 'true')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('switches themes and restores only the browser-local selection', async ({ page }, 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.stack ?? error.message))
|
||||
|
||||
await page.route('**/api/web/notifications?*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
items: [{
|
||||
id: 9001,
|
||||
category: 'REVIEW',
|
||||
eventType: 'REVIEW_SUBMITTED',
|
||||
title: 'Theme notification fixture',
|
||||
bodyJson: JSON.stringify({ skillName: 'Theme preview', version: '1.0.0' }),
|
||||
targetRoute: '/search',
|
||||
status: 'UNREAD',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
}],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 5,
|
||||
},
|
||||
timestamp: '2026-09-01T00:00:00Z',
|
||||
requestId: 'theme-notification-fixture',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.locator('html')).not.toHaveClass(/dark/)
|
||||
const header = page.locator('header')
|
||||
const lightHeaderBackground = await header.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
|
||||
const themeSwitch = page.getByRole('switch', { name: 'Dark theme' })
|
||||
await expect(themeSwitch).toHaveAttribute('aria-checked', 'false')
|
||||
await themeSwitch.click()
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect(themeSwitch).toHaveAttribute('aria-checked', 'true')
|
||||
await expect.poll(() => header.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(lightHeaderBackground)
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('skillhub-theme'))).toBe('dark')
|
||||
const destructiveContrast = await page.evaluate(() => {
|
||||
const probe = document.createElement('button')
|
||||
probe.className = 'bg-destructive text-destructive-foreground'
|
||||
probe.textContent = 'Destructive contrast probe'
|
||||
document.body.append(probe)
|
||||
const styles = getComputedStyle(probe)
|
||||
|
||||
const luminance = (color: string) => {
|
||||
const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number)
|
||||
if (!channels || channels.length !== 3) {
|
||||
throw new Error(`Unable to parse computed color: ${color}`)
|
||||
}
|
||||
const linear = channels.map((channel) => {
|
||||
const normalized = channel / 255
|
||||
return normalized <= 0.04045
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
}
|
||||
|
||||
const background = luminance(styles.backgroundColor)
|
||||
const foreground = luminance(styles.color)
|
||||
probe.remove()
|
||||
return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05)
|
||||
})
|
||||
expect(destructiveContrast).toBeGreaterThanOrEqual(4.5)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect(page.getByRole('switch', { name: 'Dark theme' })).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible()
|
||||
await expect.poll(() => page.evaluate(() => (
|
||||
window as Window & { __themeAtFirstReactContent?: boolean }
|
||||
).__themeAtFirstReactContent)).toBe(true)
|
||||
|
||||
await page.getByRole('link', { name: 'Search', exact: true }).first().click()
|
||||
await expect(page).toHaveURL(/\/search(?:\?|$)/)
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect(page.getByPlaceholder('Search skills...')).toBeVisible()
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-desktop.png'), fullPage: true })
|
||||
|
||||
const notificationButton = page.getByRole('button', { name: 'Notifications' })
|
||||
await notificationButton.click()
|
||||
await expect(page.getByText('Notifications', { exact: true })).toBeVisible()
|
||||
const firstNotification = page.getByRole('link').filter({ hasText: 'Review submitted' })
|
||||
await expect(firstNotification).toBeVisible()
|
||||
const backgroundBeforeHover = await firstNotification.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await firstNotification.hover()
|
||||
await expect.poll(() => firstNotification.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(backgroundBeforeHover)
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-notifications.png'), fullPage: true })
|
||||
await notificationButton.click()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(page.getByRole('switch', { name: 'Dark theme' })).toBeVisible()
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-mobile.png'), fullPage: true })
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 568 })
|
||||
const headerControls = page.locator('header > div')
|
||||
const [headerBox, controlsBox] = await Promise.all([header.boundingBox(), headerControls.boundingBox()])
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(controlsBox).not.toBeNull()
|
||||
expect((controlsBox?.x ?? 0) + (controlsBox?.width ?? 0)).toBeLessThanOrEqual(
|
||||
(headerBox?.x ?? 0) + (headerBox?.width ?? 0),
|
||||
)
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-mobile-320.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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
MergeInitiateResponse,
|
||||
MergeVerifyRequest,
|
||||
ReviewSkillDetail,
|
||||
ReviewProgressPage,
|
||||
ReviewTask,
|
||||
PromotionSortBy,
|
||||
PromotionSortDirection,
|
||||
|
|
@ -882,6 +883,25 @@ 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<ReviewProgressPage>(
|
||||
`${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 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`)
|
||||
},
|
||||
|
|
|
|||
287
web/src/api/generated/schema.d.ts
vendored
287
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;
|
||||
|
|
@ -2500,6 +2532,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;
|
||||
|
|
@ -4629,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;
|
||||
|
|
@ -4647,6 +4752,50 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseReviewProgressPageResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["ReviewProgressPageResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ReviewProgressPageResponse: {
|
||||
items?: components["schemas"]["ReviewProgressResponse"][];
|
||||
/** Format: int64 */
|
||||
total?: number;
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
statusCounts?: components["schemas"]["ReviewProgressStatusCounts"];
|
||||
};
|
||||
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;
|
||||
};
|
||||
ReviewProgressStatusCounts: {
|
||||
/** Format: int64 */
|
||||
pending?: number;
|
||||
/** Format: int64 */
|
||||
approved?: number;
|
||||
/** Format: int64 */
|
||||
rejected?: number;
|
||||
};
|
||||
ApiResponsePageResponsePromotionResponseDto: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -10029,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;
|
||||
|
|
@ -10167,6 +10360,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"]["ApiResponseReviewProgressPageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
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"]["ApiResponseReviewProgressPageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
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,33 @@ 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 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[]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { APP_HEADER_ELEVATED_CLASS_NAME, getAppHeaderClassName } from './layout-
|
|||
|
||||
describe('getAppHeaderClassName', () => {
|
||||
it('keeps the header flat before the page starts scrolling', () => {
|
||||
expect(getAppHeaderClassName(false)).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME)
|
||||
const className = getAppHeaderClassName(false)
|
||||
|
||||
expect(className).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME)
|
||||
expect(className).toContain('bg-background/90')
|
||||
expect(className).not.toContain('bg-white')
|
||||
})
|
||||
|
||||
it('adds a subtle drop shadow after the header becomes sticky', () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
export const APP_HEADER_BASE_CLASS_NAME =
|
||||
'sticky top-0 z-50 flex items-center justify-between border-b bg-white px-6 py-4 transition-shadow duration-200 md:px-12'
|
||||
'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12'
|
||||
|
||||
export const APP_HEADER_ELEVATED_CLASS_NAME = 'shadow-[0_10px_24px_-20px_rgba(15,23,42,0.32)]'
|
||||
export const APP_HEADER_ELEVATED_CLASS_NAME =
|
||||
'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]'
|
||||
|
||||
export function getAppHeaderClassName(isElevated: boolean): string {
|
||||
return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Outlet, Link, useRouterState } from '@tanstack/react-router'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { LanguageSwitcher } from '@/shared/components/language-switcher'
|
||||
import { ThemeToggle } from '@/shared/components/theme-toggle'
|
||||
import { UserMenu } from '@/shared/components/user-menu'
|
||||
import { NotificationBell } from '@/features/notification/notification-bell'
|
||||
import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
|
||||
|
|
@ -82,7 +83,7 @@ export function Layout() {
|
|||
<div
|
||||
className="absolute top-0 right-0 w-[600px] h-[500px] rounded-full opacity-90"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at 70% 20%, rgba(184,94,255,0.25) 0%, rgba(106,109,255,0.15) 40%, transparent 70%)',
|
||||
background: 'radial-gradient(ellipse at 70% 20%, hsl(var(--glow-accent) / 0.12) 0%, hsl(var(--glow-primary) / 0.07) 40%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -115,7 +116,8 @@ export function Layout() {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-6 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
<div className="flex items-center gap-2 text-[15px] font-normal sm:gap-3" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
{user && <NotificationBell />}
|
||||
{isLoading ? null : user ? (
|
||||
|
|
@ -149,7 +151,7 @@ export function Layout() {
|
|||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t rounded-t-2xl mt-auto" style={{ background: '#F1F5F9', borderColor: 'hsl(var(--border))' }}>
|
||||
<footer className="relative z-10 mt-auto rounded-t-2xl border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 md:px-12 py-10">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-10 md:gap-12">
|
||||
<div className="flex-shrink-0">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -283,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,
|
||||
})
|
||||
|
|
@ -328,6 +343,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 +511,7 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardNamespaceReviewDetailRoute,
|
||||
dashboardGovernanceRoute,
|
||||
dashboardReviewsRoute,
|
||||
dashboardReviewProgressRoute,
|
||||
dashboardReportsRoute,
|
||||
dashboardReviewDetailRoute,
|
||||
dashboardPromotionsRoute,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
* that config or a safe fallback object before importing the main entry.
|
||||
*/
|
||||
import './legacy-polyfills'
|
||||
import { initializeTheme } from './shared/lib/theme'
|
||||
|
||||
initializeTheme()
|
||||
|
||||
async function loadRuntimeConfig() {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function NotificationBell() {
|
|||
type="button"
|
||||
aria-label={t('notification.title')}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 transition-colors"
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full transition-colors hover:bg-muted"
|
||||
>
|
||||
{/* Bell SVG */}
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function NotificationDropdown({ onClose }: Props) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-white shadow-lg"
|
||||
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-popover text-popover-foreground shadow-lg"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -75,7 +75,7 @@ export function NotificationDropdown({ onClose }: Props) {
|
|||
<Link
|
||||
to={resolveNotificationTarget(item)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted"
|
||||
>
|
||||
{/* Unread dot */}
|
||||
<span className={`mt-1.5 flex-shrink-0 w-2 h-2 rounded-full ${item.status === 'UNREAD' ? 'bg-red-500' : 'bg-transparent'}`} />
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ export function createNotificationSseConnection(
|
|||
url: string,
|
||||
eventSourceFactory: EventSourceFactory = (targetUrl) =>
|
||||
new EventSource(targetUrl, { withCredentials: true }),
|
||||
timerApi: TimerApi = { setTimeout, clearTimeout },
|
||||
timerApi: TimerApi = {
|
||||
setTimeout: (...args) => globalThis.setTimeout(...args),
|
||||
clearTimeout: (timer) => globalThis.clearTimeout(timer),
|
||||
},
|
||||
): NotificationSseConnection {
|
||||
return new ManagedNotificationSseConnection(url, eventSourceFactory, timerApi)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
|
||||
return (
|
||||
<Card
|
||||
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
|
||||
className="group relative h-full cursor-pointer overflow-hidden border bg-card p-5 text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isS
|
|||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium',
|
||||
label.type === 'PRIVILEGED'
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900'
|
||||
: 'border-slate-300 bg-slate-100 text-slate-800',
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300'
|
||||
: 'border-border bg-secondary text-secondary-foreground',
|
||||
)}
|
||||
>
|
||||
{label.displayName}
|
||||
|
|
|
|||
|
|
@ -487,6 +487,38 @@
|
|||
"pageTitle": "Token Management",
|
||||
"pageSubtitle": "Manage access credentials for CLI and API"
|
||||
},
|
||||
"theme": {
|
||||
"darkMode": "Dark theme",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
|
@ -1328,6 +1360,7 @@
|
|||
"governance": "Governance Center",
|
||||
"stars": "Starred",
|
||||
"subscriptions": "My Subscriptions",
|
||||
"reviewProgress": "My Review Progress",
|
||||
"reviews": "Review Management",
|
||||
"promotions": "Promotion Management",
|
||||
"reports": "Report Management",
|
||||
|
|
@ -1358,6 +1391,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",
|
||||
|
|
@ -1420,6 +1457,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...",
|
||||
|
|
|
|||
|
|
@ -487,6 +487,38 @@
|
|||
"pageTitle": "Управление токенами",
|
||||
"pageSubtitle": "Учётные данные доступа для CLI и API"
|
||||
},
|
||||
"theme": {
|
||||
"darkMode": "Тёмная тема",
|
||||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему"
|
||||
},
|
||||
"reviewProgress": {
|
||||
"title": "Мои проверки",
|
||||
"subtitle": "Текущий статус, комментарии проверяющего и история повторных отправок",
|
||||
"searchLabel": "Поиск навыков или пространств имён",
|
||||
"searchPlaceholder": "Навык или пространство имён",
|
||||
"searchAction": "Найти",
|
||||
"statusFilter": "Фильтр по статусу проверки",
|
||||
"statusAll": "Все статусы",
|
||||
"statusPending": "На проверке",
|
||||
"statusApproved": "Одобрено",
|
||||
"statusRejected": "Отклонено",
|
||||
"statusSummary": "Сводка текущих статусов проверки",
|
||||
"latestSubmitted": "Последняя отправка: {{time}}",
|
||||
"latestReviewed": "Последнее решение: {{time}}",
|
||||
"attemptCount": "Отправок: {{count}}",
|
||||
"attemptNumber": "Попытка {{number}}",
|
||||
"submittedAt": "Отправлено {{time}}",
|
||||
"reviewedAt": "Проверено {{time}}",
|
||||
"reviewedBy": "Проверил: {{reviewer}}",
|
||||
"history": "История отправок",
|
||||
"resubmit": "Изменить и отправить снова",
|
||||
"loading": "Загрузка статуса проверки",
|
||||
"error": "Не удалось загрузить статус проверки. Повторите попытку позже.",
|
||||
"historyError": "Не удалось загрузить историю. Повторите попытку позже.",
|
||||
"emptyTitle": "Истории проверок пока нет",
|
||||
"emptyDescription": "После отправки публичного или командного навыка его статус появится здесь."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Центр рецензирования",
|
||||
"subtitle": "Задачи рецензирования на платформе",
|
||||
|
|
@ -1437,6 +1469,10 @@
|
|||
"title": "Уведомление о ревью",
|
||||
"description": "Отправленные пакеты скиллов проходят ревью администратора перед публикацией."
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "Повторная отправка {{skill}} v{{version}}",
|
||||
"description": "Загрузите исправленный пакет с тем же именем и версией навыка. Он снова пройдёт обычную проверку."
|
||||
},
|
||||
"namespace": "Пространство имён",
|
||||
"selectNamespace": "Выберите пространство имён",
|
||||
"visibility": "Видимость",
|
||||
|
|
@ -1490,6 +1526,8 @@
|
|||
"reviewer": "Рецензент",
|
||||
"reviewTime": "Рассмотрено",
|
||||
"reviewComment": "Комментарий ревью",
|
||||
"attemptHistory": "История отправок",
|
||||
"attemptHistoryError": "Не удалось загрузить историю отправок. Повторите попытку позже.",
|
||||
"actions": "Действия ревью",
|
||||
"commentLabel": "Комментарий (необязательно)",
|
||||
"commentPlaceholder": "Введите комментарий ревью...",
|
||||
|
|
@ -1629,6 +1667,7 @@
|
|||
"governance": "Центр управления",
|
||||
"stars": "Избранное",
|
||||
"subscriptions": "Мои подписки",
|
||||
"reviewProgress": "Мои проверки",
|
||||
"reviews": "Управление ревью",
|
||||
"promotions": "Управление продвижением",
|
||||
"reports": "Управление жалобами",
|
||||
|
|
|
|||
|
|
@ -487,6 +487,38 @@
|
|||
"pageTitle": "Token 管理",
|
||||
"pageSubtitle": "管理 CLI 和 API 使用的访问凭证"
|
||||
},
|
||||
"theme": {
|
||||
"darkMode": "深色主题",
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题"
|
||||
},
|
||||
"reviewProgress": {
|
||||
"title": "我的审核进度",
|
||||
"subtitle": "查看技能提交的当前状态、审核意见和每次重提记录",
|
||||
"searchLabel": "搜索技能或命名空间",
|
||||
"searchPlaceholder": "搜索 Skill 或命名空间",
|
||||
"searchAction": "搜索",
|
||||
"statusFilter": "按审核状态筛选",
|
||||
"statusAll": "全部状态",
|
||||
"statusPending": "审核中",
|
||||
"statusApproved": "已通过",
|
||||
"statusRejected": "已拒绝",
|
||||
"statusSummary": "当前审核状态汇总",
|
||||
"latestSubmitted": "最近提交:{{time}}",
|
||||
"latestReviewed": "最近审核:{{time}}",
|
||||
"attemptCount": "共 {{count}} 次提交",
|
||||
"attemptNumber": "第 {{number}} 次",
|
||||
"submittedAt": "提交于 {{time}}",
|
||||
"reviewedAt": "审核于 {{time}}",
|
||||
"reviewedBy": "审核人:{{reviewer}}",
|
||||
"history": "提交历史",
|
||||
"resubmit": "修改并重提",
|
||||
"loading": "正在加载审核进度",
|
||||
"error": "审核进度加载失败,请稍后重试。",
|
||||
"historyError": "提交历史加载失败,请稍后重试。",
|
||||
"emptyTitle": "还没有审核记录",
|
||||
"emptyDescription": "提交公开或团队 Skill 后,审核进度会显示在这里。"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "审核中心",
|
||||
"subtitle": "管理平台审核事务",
|
||||
|
|
@ -1328,6 +1360,7 @@
|
|||
"governance": "治理中心",
|
||||
"stars": "我的收藏",
|
||||
"subscriptions": "我的订阅",
|
||||
"reviewProgress": "我的审核进度",
|
||||
"reviews": "审核管理",
|
||||
"promotions": "推广管理",
|
||||
"reports": "举报管理",
|
||||
|
|
@ -1358,6 +1391,10 @@
|
|||
"title": "发布审核说明",
|
||||
"description": "技能包提交后需要经过管理员审核才能正式发布。"
|
||||
},
|
||||
"resubmitNotice": {
|
||||
"title": "重新提交 {{skill}} v{{version}}",
|
||||
"description": "上传修正后的技能包,并保持相同的技能名称和版本;提交后会重新进入正常审核流程。"
|
||||
},
|
||||
"namespace": "命名空间",
|
||||
"selectNamespace": "选择命名空间",
|
||||
"visibility": "可见性",
|
||||
|
|
@ -1420,6 +1457,8 @@
|
|||
"reviewer": "审核者",
|
||||
"reviewTime": "审核时间",
|
||||
"reviewComment": "审核意见",
|
||||
"attemptHistory": "提交历史",
|
||||
"attemptHistoryError": "提交历史加载失败,请稍后重试。",
|
||||
"actions": "审核操作",
|
||||
"commentLabel": "审核意见(可选)",
|
||||
"commentPlaceholder": "填写审核意见...",
|
||||
|
|
|
|||
|
|
@ -51,29 +51,33 @@
|
|||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme (preserved, not default) */
|
||||
--background: 222 47% 6%;
|
||||
--foreground: 210 40% 96%;
|
||||
--card: 222 40% 9%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
--popover: 222 40% 9%;
|
||||
--popover-foreground: 210 40% 96%;
|
||||
--primary: 239 100% 75%;
|
||||
--primary-foreground: 222 47% 6%;
|
||||
--secondary: 222 30% 13%;
|
||||
--secondary-foreground: 210 30% 85%;
|
||||
--muted: 222 25% 15%;
|
||||
--muted-foreground: 215 20% 55%;
|
||||
--accent: 271 100% 72%;
|
||||
/* Cool ink surfaces: distinct depth without near-black dead zones. */
|
||||
--background: 222 30% 10%;
|
||||
--foreground: 216 28% 93%;
|
||||
--card: 222 25% 14%;
|
||||
--card-foreground: 216 28% 93%;
|
||||
--popover: 222 24% 15%;
|
||||
--popover-foreground: 216 28% 93%;
|
||||
--primary: 241 92% 74%;
|
||||
--primary-foreground: 222 30% 10%;
|
||||
--secondary: 222 20% 18%;
|
||||
--secondary-foreground: 216 24% 88%;
|
||||
--muted: 222 18% 19%;
|
||||
--muted-foreground: 216 14% 66%;
|
||||
--accent: 272 86% 72%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 55%;
|
||||
--destructive: 0 72% 52%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 222 20% 18%;
|
||||
--input: 222 20% 18%;
|
||||
--ring: 239 100% 75%;
|
||||
--surface-glass: 222 35% 11%;
|
||||
--glow-primary: 239 100% 75%;
|
||||
--glow-accent: 271 100% 72%;
|
||||
--border: 222 16% 25%;
|
||||
--input: 222 16% 25%;
|
||||
--ring: 241 92% 74%;
|
||||
--surface-glass: 222 24% 14%;
|
||||
--glow-primary: 241 92% 70%;
|
||||
--glow-accent: 272 80% 68%;
|
||||
--text-secondary: 216 17% 76%;
|
||||
--text-muted: 216 14% 66%;
|
||||
--text-placeholder: 216 12% 54%;
|
||||
--border-card: 222 16% 24%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,9 +327,9 @@
|
|||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #0369a1;
|
||||
background: #e0f2fe;
|
||||
border: 1px solid #7dd3fc;
|
||||
color: hsl(199 76% 34%);
|
||||
background: hsl(199 89% 94%);
|
||||
border: 1px solid hsl(199 93% 74%);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
|
|
@ -342,7 +346,7 @@
|
|||
}
|
||||
|
||||
.status-pill--published { background: #16a34a; }
|
||||
.status-pill--review { background: #ea580c; }
|
||||
.status-pill--review { background: #c2410c; }
|
||||
.status-pill--archived { background: #6b7280; }
|
||||
.status-pill--rejected { background: #dc2626; }
|
||||
|
||||
|
|
@ -378,10 +382,26 @@
|
|||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.dark .handle-tag {
|
||||
color: hsl(196 80% 72%);
|
||||
background: hsl(199 70% 15%);
|
||||
border-color: hsl(199 60% 30%);
|
||||
}
|
||||
|
||||
.dark .badge-soft-green {
|
||||
background: hsl(142 55% 16%);
|
||||
color: hsl(142 70% 72%);
|
||||
}
|
||||
|
||||
.dark .badge-soft-blue {
|
||||
background: hsl(221 55% 18%);
|
||||
color: hsl(213 90% 75%);
|
||||
}
|
||||
|
||||
/* ─── Tab active ─── */
|
||||
.tab-active {
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
color: #1d4ed8;
|
||||
border-bottom: 2px solid hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* ─── Code block ─── */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
66
web/src/pages/dashboard/review-progress.test.tsx
Normal file
66
web/src/pages/dashboard/review-progress.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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,
|
||||
statusCounts: {
|
||||
pending: 0,
|
||||
approved: 0,
|
||||
rejected: 1,
|
||||
},
|
||||
},
|
||||
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).toContain('reviewProgress.statusSummary')
|
||||
expect(html).toContain('reviewProgress.latestReviewed')
|
||||
expect(html).toContain('/dashboard/publish')
|
||||
expect(html).not.toContain('reviews.typeSkill')
|
||||
})
|
||||
})
|
||||
267
web/src/pages/dashboard/review-progress.tsx
Normal file
267
web/src/pages/dashboard/review-progress.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
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 } 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'
|
||||
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.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) => (
|
||||
<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>
|
||||
{item.latestReviewedAt ? (
|
||||
<span>{t('reviewProgress.latestReviewed', { time: formatLocalDateTime(item.latestReviewedAt, locale) })}</span>
|
||||
) : null}
|
||||
<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="/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" />
|
||||
{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>
|
||||
) : (
|
||||
<ReviewAttemptTimeline attempts={attemptsQuery.data ?? []} locale={locale} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -110,7 +110,7 @@ export function LandingPage() {
|
|||
{/* Search box */}
|
||||
<div className="w-full max-w-2xl mb-8">
|
||||
<div
|
||||
className="flex items-center bg-white rounded-xl border shadow-sm px-5 py-3.5"
|
||||
className="flex items-center rounded-xl border bg-card px-5 py-3.5 text-card-foreground shadow-sm"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 flex-shrink-0 mr-3" style={{ color: 'hsl(var(--text-placeholder))' }} strokeWidth={1.5} />
|
||||
|
|
@ -181,7 +181,7 @@ export function LandingPage() {
|
|||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="bg-white rounded-xl p-8 border shadow-sm transition-shadow hover:shadow-md"
|
||||
className="rounded-xl border bg-card p-8 text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
>
|
||||
<div className="feature-icon w-12 h-12 rounded-2xl flex items-center justify-center mb-6 mx-auto bg-brand-gradient">
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function RegisterPage() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
|
||||
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
|
||||
<Card className="w-full border-border bg-card/95 shadow-xl">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<CardTitle>{t('register.title')}</CardTitle>
|
||||
<CardDescription>{t('register.subtitle')}</CardDescription>
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function ResetPasswordPage() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
|
||||
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
|
||||
<Card className="w-full border-border bg-card/95 shadow-xl">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<CardTitle>{t('resetPassword.title')}</CardTitle>
|
||||
<CardDescription>{t('resetPassword.subtitle')}</CardDescription>
|
||||
|
|
|
|||
|
|
@ -827,12 +827,12 @@ export function SkillDetailPage() {
|
|||
</span>
|
||||
)}
|
||||
{isReviewFlowPending && (
|
||||
<span className="badge-soft" style={{ background: '#fef3c7', color: '#92400e' }}>
|
||||
<span className="badge-soft bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300">
|
||||
{t('skillDetail.versionStatusPendingReview')}
|
||||
</span>
|
||||
)}
|
||||
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && (
|
||||
<span className="badge-soft" style={{ background: '#fee2e2', color: '#991b1b' }}>
|
||||
<span className="badge-soft bg-red-100 text-red-900 dark:bg-red-950/60 dark:text-red-300">
|
||||
{t('skillDetail.rejectedBadge')}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -861,8 +861,8 @@ export function SkillDetailPage() {
|
|||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2',
|
||||
label.type === 'PRIVILEGED'
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80'
|
||||
: 'border-slate-300 bg-slate-100 text-slate-800 hover:bg-slate-200/80',
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80 dark:bg-amber-950/60 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
||||
: 'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
)}
|
||||
>
|
||||
{label.displayName}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function CompactCopyButton({ text }: { text: string }) {
|
|||
onClick={handleCopy}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="absolute right-2.5 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-xl border bg-white transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
|
||||
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-xl border bg-card text-card-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--foreground))' }}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
|
|
@ -111,12 +111,11 @@ export function LandingQuickStartSection() {
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="mx-auto max-w-2xl rounded-[28px] border bg-white p-3 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.25)]"
|
||||
className="mx-auto max-w-2xl rounded-[28px] border bg-card p-3 text-card-foreground shadow-[0_24px_60px_-28px_hsl(var(--foreground)/0.18)]"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 gap-2 rounded-2xl p-1.5 md:grid-cols-3"
|
||||
style={{ background: 'linear-gradient(180deg, rgba(248,250,252,0.98) 0%, rgba(241,245,249,0.92) 100%)' }}
|
||||
className="grid grid-cols-1 gap-2 rounded-2xl bg-muted/70 p-1.5 md:grid-cols-3"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentTab.id
|
||||
|
|
@ -130,9 +129,9 @@ export function LandingQuickStartSection() {
|
|||
aria-pressed={isActive}
|
||||
className="flex min-h-11 items-center justify-center gap-2 rounded-[14px] px-4 py-3 text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? 'rgba(255,255,255,0.96)' : 'transparent',
|
||||
background: isActive ? 'hsl(var(--card))' : 'transparent',
|
||||
color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
|
||||
boxShadow: isActive ? '0 6px 18px rgba(15, 23, 42, 0.08)' : 'none',
|
||||
boxShadow: isActive ? '0 6px 18px hsl(var(--foreground) / 0.08)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.75} />
|
||||
|
|
@ -151,13 +150,12 @@ export function LandingQuickStartSection() {
|
|||
</p>
|
||||
|
||||
<div
|
||||
className="relative rounded-2xl border bg-slate-50/90 px-4 py-3 pr-14 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]"
|
||||
className="relative rounded-2xl border bg-muted/65 px-4 py-3 pr-16"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
<div className="overflow-x-auto whitespace-nowrap">
|
||||
<code
|
||||
className="font-mono text-sm md:text-base"
|
||||
style={{ color: currentTab.id === 'agent' ? '#16A34A' : '#0F172A' }}
|
||||
className={`font-mono text-sm md:text-base ${currentTab.id === 'agent' ? 'text-emerald-700 dark:text-emerald-400' : 'text-foreground'}`}
|
||||
>
|
||||
{currentTab.command}
|
||||
</code>
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
|||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="text-sm text-inherit">{currentLanguage.name}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
<span className="hidden text-sm text-inherit sm:inline">{currentLanguage.name}</span>
|
||||
<ChevronDown className="hidden h-3.5 w-3.5 opacity-70 sm:block" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-50 pt-2">
|
||||
|
|
|
|||
34
web/src/shared/components/theme-toggle.test.tsx
Normal file
34
web/src/shared/components/theme-toggle.test.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { THEME_STORAGE_KEY } from '@/shared/lib/theme'
|
||||
import { ThemeToggle } from './theme-toggle'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.dataset.theme = 'light'
|
||||
document.documentElement.style.colorScheme = 'light'
|
||||
})
|
||||
|
||||
it('switches theme and keeps the selection in browser-local storage', () => {
|
||||
render(<ThemeToggle />)
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'theme.darkMode' })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'theme.darkMode' }).getAttribute('aria-checked')).toBe('true')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
})
|
||||
51
web/src/shared/components/theme-toggle.tsx
Normal file
51
web/src/shared/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTheme } from '@/shared/hooks/use-theme'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||
const { t } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
const label = isDark ? t('theme.switchToLight') : t('theme.switchToDark')
|
||||
const accessibleName = t('theme.darkMode')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={accessibleName}
|
||||
aria-checked={isDark}
|
||||
title={label}
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
'group relative inline-flex h-11 w-16 shrink-0 items-center rounded-full border border-border bg-muted/70 px-1 text-muted-foreground shadow-sm transition-[background-color,border-color] duration-200 hover:border-primary/40 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute left-1 top-1.5 h-8 w-7 rounded-full border border-border/80 bg-card shadow-[0_3px_10px_-4px_hsl(var(--foreground)/0.45)] transition-transform duration-200 ease-out motion-reduce:transition-none',
|
||||
isDark && 'translate-x-7',
|
||||
)}
|
||||
/>
|
||||
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
|
||||
<Sun
|
||||
aria-hidden="true"
|
||||
className={cn('h-4 w-4 transition-colors duration-200', !isDark && 'text-foreground')}
|
||||
/>
|
||||
</span>
|
||||
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
|
||||
<Moon
|
||||
aria-hidden="true"
|
||||
className={cn('h-4 w-4 transition-colors duration-200', isDark && 'text-foreground')}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
className="w-8 h-8 rounded-full border border-border/60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-inherit">
|
||||
<span className="hidden text-sm font-medium text-inherit lg:inline">
|
||||
{user.displayName}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
24
web/src/shared/hooks/use-theme.ts
Normal file
24
web/src/shared/hooks/use-theme.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
applyTheme,
|
||||
DEFAULT_THEME,
|
||||
readStoredTheme,
|
||||
saveTheme,
|
||||
type Theme,
|
||||
} from '@/shared/lib/theme'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme() ?? DEFAULT_THEME)
|
||||
|
||||
const setTheme = useCallback((nextTheme: Theme) => {
|
||||
applyTheme(nextTheme)
|
||||
saveTheme(nextTheme)
|
||||
setThemeState(nextTheme)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
}, [setTheme, theme])
|
||||
|
||||
return { theme, setTheme, toggleTheme }
|
||||
}
|
||||
62
web/src/shared/lib/theme.test.ts
Normal file
62
web/src/shared/lib/theme.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyTheme,
|
||||
DEFAULT_THEME,
|
||||
initializeTheme,
|
||||
readStoredTheme,
|
||||
saveTheme,
|
||||
THEME_STORAGE_KEY,
|
||||
} from './theme'
|
||||
|
||||
describe('theme preference', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.documentElement.style.colorScheme = ''
|
||||
})
|
||||
|
||||
it('uses light when the browser has no saved preference', () => {
|
||||
expect(initializeTheme()).toBe(DEFAULT_THEME)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
})
|
||||
|
||||
it('restores a valid browser-local preference before render', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark')
|
||||
|
||||
expect(initializeTheme()).toBe('dark')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('ignores invalid and inaccessible storage values', () => {
|
||||
expect(readStoredTheme({ getItem: () => 'system', setItem: () => undefined })).toBeNull()
|
||||
expect(readStoredTheme({
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
setItem: () => undefined,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('applies and saves only the selected frontend theme', () => {
|
||||
applyTheme('dark')
|
||||
saveTheme('dark')
|
||||
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
|
||||
it('ignores storage write failures', () => {
|
||||
expect(() => saveTheme('dark', {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
})).not.toThrow()
|
||||
})
|
||||
})
|
||||
56
web/src/shared/lib/theme.ts
Normal file
56
web/src/shared/lib/theme.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export type Theme = 'light' | 'dark'
|
||||
|
||||
export const THEME_STORAGE_KEY = 'skillhub-theme'
|
||||
export const DEFAULT_THEME: Theme = 'light'
|
||||
|
||||
interface ThemeStorage {
|
||||
getItem(key: string): string | null
|
||||
setItem(key: string, value: string): void
|
||||
}
|
||||
|
||||
export function isTheme(value: unknown): value is Theme {
|
||||
return value === 'light' || value === 'dark'
|
||||
}
|
||||
|
||||
export function readStoredTheme(storage: ThemeStorage | undefined = getBrowserStorage()): Theme | null {
|
||||
if (!storage) return null
|
||||
|
||||
try {
|
||||
const value = storage.getItem(THEME_STORAGE_KEY)
|
||||
return isTheme(value) ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme, root: HTMLElement = document.documentElement) {
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
root.dataset.theme = theme
|
||||
root.style.colorScheme = theme
|
||||
}
|
||||
|
||||
export function initializeTheme(root: HTMLElement = document.documentElement): Theme {
|
||||
const theme = readStoredTheme() ?? DEFAULT_THEME
|
||||
applyTheme(theme, root)
|
||||
return theme
|
||||
}
|
||||
|
||||
export function saveTheme(theme: Theme, storage: ThemeStorage | undefined = getBrowserStorage()) {
|
||||
if (!storage) return
|
||||
|
||||
try {
|
||||
storage.setItem(THEME_STORAGE_KEY, theme)
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing or hardened browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserStorage(): Storage | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
|
|||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const INPUT_BASE_CLASS_NAME =
|
||||
'flex h-11 w-full rounded-lg border bg-white px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
'flex h-11 w-full rounded-lg border bg-background px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, style, ...props }, ref) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue