mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
feat(suite): show viewer-safe membership hints on skill cards
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
0771f2c14b
commit
baaba6a1aa
22 changed files with 594 additions and 16 deletions
|
|
@ -120,8 +120,15 @@ SMOKE_ADMIN_PASSWORD='<configured-password>' \
|
|||
- 原有 Skill 搜索接口继续只返回 Skill。
|
||||
|
||||
类型化发现结果通过 `resourceType=SKILL|SUITE` 区分同名资源。Suite 详情返回固定版本、按顺序排列
|
||||
的成员快照、Entry Skill、实时可安装状态和阻塞原因。普通 Skill 详情会列出当前用户可见、以该 Skill
|
||||
作为 Entry 的最新已发布 SuiteVersion,并链接到完整 Suite;Skill 的独立安装能力保持不变。
|
||||
的成员快照、Entry Skill、实时可安装状态和阻塞原因。普通 Skill 详情会列出当前用户可见、包含该
|
||||
Skill 的最新已发布 SuiteVersion,区分 Entry 和普通成员,并链接到完整 Suite。
|
||||
|
||||
市场、首页及收藏等复用技能卡片的列表通过 `hasVisibleSuites` 展示轻量的“关联套件”标识。
|
||||
该字段只依据当前用户可见、ACTIVE 且未隐藏的 Suite 当前已发布版本计算,以批量查询返回,
|
||||
不会为每张卡片单独请求套件详情。卡片仍进入原 Skill 详情,在详情中查看一个或多个关联套件及
|
||||
可见的配套成员;不可访问的套件不会产生标识。Suite 隐藏、归档或新发布版本移除该 Skill 后,
|
||||
重新读取列表时按最新关系计算。标识仅表示被套件收录,不代表强制依赖、唯一父套件或必须整套
|
||||
安装;Skill 的独立安装能力保持不变。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ npx clawhub install my-team--pdf-parser
|
|||
|
||||
点击技能包卡片,查看详细信息、版本历史、文件列表。
|
||||
|
||||
如果技能被当前用户可见的套件收录,卡片会显示“关联套件”。点击卡片进入技能详情后,可以
|
||||
查看关联套件、入口或普通成员身份,以及可见的配套技能,并跳转到套件详情了解整体用法和
|
||||
安装指引。一个技能可以关联多个套件;这个标识不表示必须整套安装,也不改变单独安装技能的
|
||||
方式。不可访问的套件不会显示关联提示。
|
||||
|
||||
6. **安装使用**
|
||||
|
||||
找到合适的技能包后,使用 CLI 命令安装或点击「下载」按钮。
|
||||
|
|
|
|||
|
|
@ -35,9 +35,31 @@ public record SkillSummaryResponse(
|
|||
List<SkillLabelDto> labels,
|
||||
String avatarUrl,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
ManagementState managementState
|
||||
ManagementState managementState,
|
||||
boolean hasVisibleSuites
|
||||
) {
|
||||
|
||||
public SkillSummaryResponse(
|
||||
Long id, String slug, String displayName, String summary, String visibility, String status,
|
||||
Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount,
|
||||
String namespace, Instant updatedAt, String ownerId, String ownerDisplayName,
|
||||
boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion,
|
||||
SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List<SkillLabelDto> labels,
|
||||
String avatarUrl, ManagementState managementState) {
|
||||
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
|
||||
ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion,
|
||||
headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot,
|
||||
labels, avatarUrl, managementState, false);
|
||||
}
|
||||
|
||||
public SkillSummaryResponse withVisibleSuites(boolean hasVisibleSuites) {
|
||||
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
||||
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
|
||||
canSubmitPromotion, headlineVersion, publishedVersion, ownerPreviewVersion,
|
||||
resolutionMode, complianceSnapshot, labels, avatarUrl, managementState, hasVisibleSuites);
|
||||
}
|
||||
|
||||
/** Owner workspace projection, never populated by public discovery or social lists. */
|
||||
public record ManagementState(String requestedVisibility, Long reviewTaskId,
|
||||
String promotionRole, String relatedNamespace, String relatedSlug,
|
||||
|
|
@ -66,7 +88,7 @@ public record SkillSummaryResponse(
|
|||
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
||||
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
|
||||
canSubmitPromotion && (state == null || !state.ownerReadOnly()), headlineVersion, publishedVersion, ownerPreviewVersion,
|
||||
resolutionMode, complianceSnapshot, labels, avatarUrl, state);
|
||||
resolutionMode, complianceSnapshot, labels, avatarUrl, state, hasVisibleSuites);
|
||||
}
|
||||
|
||||
public SkillSummaryResponse(
|
||||
|
|
@ -140,6 +162,6 @@ public record SkillSummaryResponse(
|
|||
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
||||
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
|
||||
canSubmitPromotion, headlineVersion,
|
||||
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl, managementState);
|
||||
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl, managementState, hasVisibleSuites);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,31 @@ public class SkillSuiteReferenceQueryRepository {
|
|||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
/** Page-bounded existence projection; shares the detail projection's visibility policy. */
|
||||
@Transactional(readOnly = true)
|
||||
public Set<Long> findSkillIdsWithVisibleMemberships(
|
||||
List<Long> skillIds,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
if (skillIds.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
MapSqlParameterSource parameters = viewerParameters(null, userId, namespaceRoles, platformRoles)
|
||||
.addValue("skillIds", skillIds);
|
||||
return Set.copyOf(jdbcTemplate.queryForList("""
|
||||
SELECT DISTINCT current_member.skill_id
|
||||
FROM skill_suite suite
|
||||
JOIN namespace suite_namespace ON suite_namespace.id = suite.namespace_id
|
||||
JOIN skill_suite_version version ON version.id = suite.latest_version_id
|
||||
JOIN skill_suite_version_member current_member
|
||||
ON current_member.suite_version_id = version.id
|
||||
WHERE current_member.skill_id IN (:skillIds)
|
||||
AND %s
|
||||
""".formatted(visibleSuitePredicate()), parameters, Long.class));
|
||||
}
|
||||
|
||||
/** Legacy entry-only projection retained for older clients. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<SkillSuiteReferenceResponse> findVisibleEntryReferences(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public class MySkillAppService {
|
|||
private final HiddenSkillQueryRepository hiddenSkillQueryRepository;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillSuiteMembershipProjectionService suiteMembershipProjection;
|
||||
|
||||
public MySkillAppService(
|
||||
SkillOwnerAccessPolicy ownerAccess,
|
||||
|
|
@ -57,6 +58,24 @@ public class MySkillAppService {
|
|||
HiddenSkillQueryRepository hiddenSkillQueryRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
NamespaceRepository namespaceRepository) {
|
||||
this(ownerAccess, skillRepository, skillVersionRepository, starredSkillQueryRepository,
|
||||
skillSubscriptionRepository, mySkillQueryRepository, hiddenSkillQueryRepository,
|
||||
skillLifecycleProjectionService, namespaceRepository, null);
|
||||
}
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public MySkillAppService(
|
||||
SkillOwnerAccessPolicy ownerAccess,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
StarredSkillQueryRepository starredSkillQueryRepository,
|
||||
SkillSubscriptionRepository skillSubscriptionRepository,
|
||||
MySkillQueryRepository mySkillQueryRepository,
|
||||
HiddenSkillQueryRepository hiddenSkillQueryRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillSuiteMembershipProjectionService suiteMembershipProjection) {
|
||||
this.suiteMembershipProjection = suiteMembershipProjection;
|
||||
this.ownerAccess = ownerAccess;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -121,7 +140,11 @@ public class MySkillAppService {
|
|||
state == null ? null : state.relatedSlug(), readOnly));
|
||||
}).toList();
|
||||
}
|
||||
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
|
||||
return new PageResponse<>(withSuiteMemberships(items, userId), skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
|
||||
}
|
||||
|
||||
private List<SkillSummaryResponse> withSuiteMemberships(List<SkillSummaryResponse> items, String userId) {
|
||||
return suiteMembershipProjection == null ? items : suiteMembershipProjection.enrich(items, userId);
|
||||
}
|
||||
|
||||
private Page<Skill> listHiddenSkills(int page,
|
||||
|
|
@ -143,7 +166,7 @@ public class MySkillAppService {
|
|||
Page<Skill> skills = starredSkillQueryRepository.findAccessible(
|
||||
userId, namespaceRoles, platformRoles, PageRequest.of(page, size));
|
||||
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(skills.getContent(), userId);
|
||||
return new PageResponse<>(items, skills.getTotalElements(), skills.getNumber(), skills.getSize());
|
||||
return new PageResponse<>(withSuiteMemberships(items, userId), skills.getTotalElements(), skills.getNumber(), skills.getSize());
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMySubscriptions(String userId, int page, int size) {
|
||||
|
|
@ -167,7 +190,7 @@ public class MySkillAppService {
|
|||
.toList();
|
||||
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId);
|
||||
|
||||
return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize());
|
||||
return new PageResponse<>(withSuiteMemberships(items, userId), subPage.getTotalElements(), subPage.getNumber(), subPage.getSize());
|
||||
}
|
||||
|
||||
private Page<Skill> filterSkills(String userId,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ public class SkillSearchAppService {
|
|||
private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService;
|
||||
private final RbacService rbacService;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final SkillSuiteMembershipProjectionService suiteMembershipProjection;
|
||||
|
||||
public SkillSearchAppService(
|
||||
SearchQueryService searchQueryService,
|
||||
|
|
@ -62,7 +63,6 @@ public class SkillSearchAppService {
|
|||
);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public SkillSearchAppService(
|
||||
SearchQueryService searchQueryService,
|
||||
SkillRepository skillRepository,
|
||||
|
|
@ -72,6 +72,23 @@ public class SkillSearchAppService {
|
|||
ComplianceSnapshotProjectionService complianceSnapshotProjectionService,
|
||||
RbacService rbacService,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this(searchQueryService, skillRepository, namespaceRepository, namespaceService,
|
||||
skillLifecycleProjectionService, complianceSnapshotProjectionService, rbacService,
|
||||
userAccountRepository, null);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public SkillSearchAppService(
|
||||
SearchQueryService searchQueryService,
|
||||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceService namespaceService,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
ComplianceSnapshotProjectionService complianceSnapshotProjectionService,
|
||||
RbacService rbacService,
|
||||
UserAccountRepository userAccountRepository,
|
||||
SkillSuiteMembershipProjectionService suiteMembershipProjection) {
|
||||
this.suiteMembershipProjection = suiteMembershipProjection;
|
||||
this.searchQueryService = searchQueryService;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
|
|
@ -188,6 +205,9 @@ public class SkillSearchAppService {
|
|||
requireInstallableLatest
|
||||
));
|
||||
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds(), scope.userId());
|
||||
if (suiteMembershipProjection != null) {
|
||||
pageItems = suiteMembershipProjection.enrich(pageItems, scope.userId());
|
||||
}
|
||||
return new SearchResponse(pageItems, result.total(), page, size);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.RbacService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Adds viewer-specific Suite membership to one page, without per-card queries or search-index state. */
|
||||
@Service
|
||||
public class SkillSuiteMembershipProjectionService {
|
||||
private final SkillSuiteReferenceQueryRepository references;
|
||||
private final NamespaceMemberRepository namespaceMembers;
|
||||
private final RbacService rbacService;
|
||||
|
||||
public SkillSuiteMembershipProjectionService(SkillSuiteReferenceQueryRepository references,
|
||||
NamespaceMemberRepository namespaceMembers,
|
||||
RbacService rbacService) {
|
||||
this.references = references;
|
||||
this.namespaceMembers = namespaceMembers;
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
public List<SkillSummaryResponse> enrich(List<SkillSummaryResponse> summaries, String userId) {
|
||||
if (summaries.isEmpty()) {
|
||||
return summaries;
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = userId == null ? Map.of()
|
||||
: namespaceMembers.findByUserId(userId).stream().collect(Collectors.toMap(
|
||||
NamespaceMember::getNamespaceId, NamespaceMember::getRole));
|
||||
Set<String> platformRoles = userId == null ? Set.of() : rbacService.getUserRoleCodes(userId);
|
||||
Set<Long> related = references.findSkillIdsWithVisibleMemberships(
|
||||
summaries.stream().map(SkillSummaryResponse::id).distinct().toList(),
|
||||
userId, roles, platformRoles);
|
||||
return summaries.stream().map(summary -> summary.withVisibleSuites(related.contains(summary.id()))).toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +127,7 @@ class SkillControllerTest {
|
|||
.andExpect(jsonPath("$.components.schemas.CliResolveResponse.required.length()").value(6))
|
||||
.andExpect(jsonPath("$.components.schemas.SkillVersionResponse.properties.changelog.nullable").value(true))
|
||||
.andReturn().getResponse();
|
||||
Files.writeString(Path.of("target", "registry-openapi.json"), response.getContentAsString());
|
||||
Files.writeString(Path.of("target", "registry-openapi.json"), response.getContentAsString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -100,6 +100,107 @@ class SuiteDiscoveryIntegrationTest {
|
|||
entityManager.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchesVisibleMembershipFlagsForOrdinaryMembersWithoutDuplicates() {
|
||||
Namespace namespace = entityManager.persistFlushFind(
|
||||
new Namespace("badge-team", "Badge Team", "owner"));
|
||||
PublishedSkill entry = publishedSkill(namespace, "entry", "owner", SkillVisibility.PUBLIC, "Entry");
|
||||
PublishedSkill member = publishedSkill(namespace, "member", "owner", SkillVisibility.PUBLIC, "Member");
|
||||
PublishedSkill standalone = publishedSkill(namespace, "standalone", "owner", SkillVisibility.PUBLIC, "Standalone");
|
||||
for (int index = 0; index < 2; index++) {
|
||||
SkillSuite suite = entityManager.persistFlushFind(new SkillSuite(
|
||||
namespace.getId(), "pack-" + index, "Pack", "owner"));
|
||||
SkillSuiteVersion version = entityManager.persistFlushFind(new SkillSuiteVersion(
|
||||
suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner"));
|
||||
version.setStatus(SkillSuiteVersionStatus.PUBLISHED);
|
||||
persistMember(version, entry, 0, true);
|
||||
persistMember(version, member, 1, false);
|
||||
suite.setLatestVersionId(version.getId());
|
||||
}
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
List.of(entry.skill().getId(), member.skill().getId(), standalone.skill().getId()),
|
||||
null, Map.of(), Set.of()))
|
||||
.containsExactlyInAnyOrder(entry.skill().getId(), member.skill().getId());
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
List.of(), null, Map.of(), Set.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void membershipFlagsApplyTheSamePrivateAndNamespaceVisibilityAsDetail() {
|
||||
Namespace namespace = entityManager.persistFlushFind(
|
||||
new Namespace("badge-visibility", "Badge Visibility", "owner"));
|
||||
PublishedSkill privateMember = publishedSkill(
|
||||
namespace, "private-pack-member", "owner", SkillVisibility.PUBLIC, "Private Pack Member");
|
||||
PublishedSkill teamMember = publishedSkill(
|
||||
namespace, "team-pack-member", "owner", SkillVisibility.PUBLIC, "Team Pack Member");
|
||||
publishedSuite(namespace, "private-pack", "author", SkillVisibility.PRIVATE, privateMember);
|
||||
publishedSuite(namespace, "team-pack", "author", SkillVisibility.NAMESPACE_ONLY, teamMember);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
List<Long> ids = List.of(privateMember.skill().getId(), teamMember.skill().getId());
|
||||
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
ids, null, Map.of(), Set.of())).isEmpty();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
ids, "other-author", Map.of(), Set.of())).isEmpty();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
ids, "other-author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of()))
|
||||
.containsExactly(teamMember.skill().getId());
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
ids, "author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of()))
|
||||
.containsExactlyInAnyOrderElementsOf(ids);
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
ids, "author", Map.of(), Set.of())).isEmpty();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
ids, "owner", Map.of(namespace.getId(), NamespaceRole.ADMIN), Set.of()))
|
||||
.containsExactlyInAnyOrderElementsOf(ids);
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(
|
||||
ids, "owner", Map.of(), Set.of("SUPER_ADMIN")))
|
||||
.containsExactlyInAnyOrderElementsOf(ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
void membershipFlagsUseOnlyActiveVisibleSuitesAndCurrentPublishedSnapshot() {
|
||||
Namespace namespace = entityManager.persistFlushFind(
|
||||
new Namespace("badge-state", "Badge State", "owner"));
|
||||
PublishedSkill member = publishedSkill(namespace, "member", "owner", SkillVisibility.PUBLIC, "Member");
|
||||
PublishedSkill replacement = publishedSkill(namespace, "replacement", "owner", SkillVisibility.PUBLIC, "Replacement");
|
||||
SkillSuite suite = publishedSuite(namespace, "pack", "owner", SkillVisibility.PUBLIC, member);
|
||||
entityManager.flush();
|
||||
List<Long> ids = List.of(member.skill().getId(), replacement.skill().getId());
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of()))
|
||||
.containsExactly(member.skill().getId());
|
||||
|
||||
suite.setHidden(true);
|
||||
entityManager.flush();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of()))
|
||||
.isEmpty();
|
||||
suite.setHidden(false);
|
||||
suite.setStatus(SkillSuiteStatus.ARCHIVED);
|
||||
entityManager.flush();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of()))
|
||||
.isEmpty();
|
||||
suite.setStatus(SkillSuiteStatus.ACTIVE);
|
||||
SkillSuiteVersion latest = entityManager.persistFlushFind(new SkillSuiteVersion(
|
||||
suite.getId(), "2.0.0", SkillVisibility.PUBLIC, "owner"));
|
||||
persistMember(latest, replacement, 0, true);
|
||||
suite.setLatestVersionId(latest.getId());
|
||||
entityManager.flush();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of()))
|
||||
.isEmpty();
|
||||
latest.setStatus(SkillSuiteVersionStatus.PUBLISHED);
|
||||
entityManager.flush();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of()))
|
||||
.containsExactly(replacement.skill().getId());
|
||||
namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.FROZEN);
|
||||
entityManager.flush();
|
||||
assertThat(suiteReferenceRepository.findSkillIdsWithVisibleMemberships(ids, null, Map.of(), Set.of()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsSkillAndSuiteWithTheSameCoordinateAsDistinctResourceTypes() {
|
||||
Namespace namespace = entityManager.persistFlushFind(
|
||||
|
|
@ -541,6 +642,19 @@ class SuiteDiscoveryIntegrationTest {
|
|||
});
|
||||
}
|
||||
|
||||
private SkillSuite publishedSuite(Namespace namespace, String slug, String ownerId,
|
||||
SkillVisibility visibility, PublishedSkill member) {
|
||||
SkillSuite suite = entityManager.persistFlushFind(
|
||||
new SkillSuite(namespace.getId(), slug, slug, ownerId));
|
||||
SkillSuiteVersion version = entityManager.persistFlushFind(new SkillSuiteVersion(
|
||||
suite.getId(), "1.0.0", visibility, ownerId));
|
||||
version.setStatus(SkillSuiteVersionStatus.PUBLISHED);
|
||||
persistMember(version, member, 0, true);
|
||||
suite.setLatestVersionId(version.getId());
|
||||
entityManager.flush();
|
||||
return suite;
|
||||
}
|
||||
|
||||
private PublishedSkill publishedSkill(
|
||||
Namespace namespace,
|
||||
String slug,
|
||||
|
|
|
|||
|
|
@ -108,7 +108,14 @@ class MySkillAppServiceTest {
|
|||
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of());
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1")));
|
||||
|
||||
var suiteProjection = org.mockito.Mockito.mock(SkillSuiteMembershipProjectionService.class);
|
||||
ReflectionTestUtils.setField(service, "suiteMembershipProjection", suiteProjection);
|
||||
given(suiteProjection.enrich(org.mockito.ArgumentMatchers.anyList(), eq("user-1")))
|
||||
.willAnswer(invocation -> invocation.<List<com.iflytek.skillhub.dto.SkillSummaryResponse>>getArgument(0)
|
||||
.stream().map(item -> item.withVisibleSuites(true)).toList());
|
||||
var stars = service.listMyStars("user-1", 1, 1, Map.of(), Set.of());
|
||||
assertThat(stars.items().getFirst().hasVisibleSuites()).isTrue();
|
||||
org.mockito.Mockito.verify(suiteProjection).enrich(org.mockito.ArgumentMatchers.anyList(), eq("user-1"));
|
||||
|
||||
assertThat(stars.total()).isEqualTo(2);
|
||||
assertThat(stars.page()).isEqualTo(1);
|
||||
|
|
|
|||
|
|
@ -110,7 +110,14 @@ class SkillSearchAppServiceTest {
|
|||
when(userAccountRepository.findByIdIn(List.of("owner-1")))
|
||||
.thenReturn(List.of(new UserAccount("owner-1", "Alice", "alice@example.com", null)));
|
||||
|
||||
var suiteProjection = org.mockito.Mockito.mock(SkillSuiteMembershipProjectionService.class);
|
||||
setField(service, "suiteMembershipProjection", suiteProjection);
|
||||
when(suiteProjection.enrich(anyList(), org.mockito.ArgumentMatchers.isNull()))
|
||||
.thenAnswer(invocation -> invocation.<List<com.iflytek.skillhub.dto.SkillSummaryResponse>>getArgument(0)
|
||||
.stream().map(item -> item.withVisibleSuites(true)).toList());
|
||||
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null);
|
||||
assertEquals(true, response.items().getFirst().hasVisibleSuites());
|
||||
verify(suiteProjection, times(1)).enrich(anyList(), org.mockito.ArgumentMatchers.isNull());
|
||||
|
||||
assertEquals(1, response.items().size());
|
||||
assertEquals("visible-skill", response.items().getFirst().slug());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.RbacService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillSuiteMembershipProjectionServiceTest {
|
||||
@Mock private SkillSuiteReferenceQueryRepository references;
|
||||
@Mock private NamespaceMemberRepository namespaceMembers;
|
||||
@Mock private RbacService rbacService;
|
||||
@InjectMocks private SkillSuiteMembershipProjectionService projection;
|
||||
|
||||
@Test
|
||||
void batchesDistinctSkillIdsAndKeepsPageOrderUsingTheCurrentViewer() {
|
||||
when(namespaceMembers.findByUserId("viewer"))
|
||||
.thenReturn(List.of(new NamespaceMember(7L, "viewer", NamespaceRole.MEMBER)));
|
||||
when(rbacService.getUserRoleCodes("viewer")).thenReturn(Set.of("USER"));
|
||||
when(references.findSkillIdsWithVisibleMemberships(
|
||||
List.of(2L, 1L), "viewer", Map.of(7L, NamespaceRole.MEMBER), Set.of("USER")))
|
||||
.thenReturn(Set.of(1L));
|
||||
|
||||
var result = projection.enrich(List.of(summary(2L), summary(1L), summary(2L)), "viewer");
|
||||
|
||||
assertThat(result).extracting(SkillSummaryResponse::id).containsExactly(2L, 1L, 2L);
|
||||
assertThat(result).extracting(SkillSummaryResponse::hasVisibleSuites).containsExactly(false, true, false);
|
||||
verify(references).findSkillIdsWithVisibleMemberships(
|
||||
List.of(2L, 1L), "viewer", Map.of(7L, NamespaceRole.MEMBER), Set.of("USER"));
|
||||
verify(namespaceMembers).findByUserId("viewer");
|
||||
verify(rbacService).getUserRoleCodes("viewer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousProjectionDoesNotLoadAnyIdentityOrMemberships() {
|
||||
when(references.findSkillIdsWithVisibleMemberships(List.of(1L), null, Map.of(), Set.of()))
|
||||
.thenReturn(Set.of(1L));
|
||||
assertThat(projection.enrich(List.of(summary(1L)), null)).singleElement()
|
||||
.extracting(SkillSummaryResponse::hasVisibleSuites).isEqualTo(true);
|
||||
verifyNoInteractions(namespaceMembers, rbacService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyPageDoesNotQueryAnyDependency() {
|
||||
assertThat(projection.enrich(List.of(), "viewer")).isEmpty();
|
||||
verifyNoInteractions(references, namespaceMembers, rbacService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void visibilityQueryFailureIsNotConvertedToACachedFalseFlag() {
|
||||
when(references.findSkillIdsWithVisibleMemberships(any(), eq(null), any(), any()))
|
||||
.thenThrow(new IllegalStateException("database unavailable"));
|
||||
assertThatThrownBy(() -> projection.enrich(List.of(summary(1L)), null))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelAndManagementCopiesPreserveTheViewerSpecificFlag() {
|
||||
SkillSummaryResponse source = summary(1L).withVisibleSuites(true);
|
||||
SkillSummaryResponse copied = source.withLabels(List.of()).withManagementState(
|
||||
new SkillSummaryResponse.ManagementState(null, null, null, null, null, true));
|
||||
assertThat(copied.hasVisibleSuites()).isTrue();
|
||||
assertThat(copied.managementState().ownerReadOnly()).isTrue();
|
||||
assertThat(source.withVisibleSuites(false).hasVisibleSuites()).isFalse();
|
||||
}
|
||||
|
||||
private SkillSummaryResponse summary(Long id) {
|
||||
return new SkillSummaryResponse(id, "skill-" + id, "Skill", "Summary", "PUBLIC", "ACTIVE",
|
||||
0L, 0, BigDecimal.ZERO, 0, "global", Instant.EPOCH, false,
|
||||
null, null, null, "PUBLISHED", null);
|
||||
}
|
||||
}
|
||||
1
web/src/api/generated/schema.d.ts
vendored
1
web/src/api/generated/schema.d.ts
vendored
|
|
@ -9186,6 +9186,7 @@ export interface components {
|
|||
labels?: components["schemas"]["SkillLabelDto"][];
|
||||
avatarUrl?: string;
|
||||
managementState?: components["schemas"]["ManagementState"];
|
||||
hasVisibleSuites?: boolean;
|
||||
};
|
||||
ApiResponseBoolean: {
|
||||
/** Format: int32 */
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ export interface BatchMemberResponse {
|
|||
|
||||
// Skill types
|
||||
export interface SkillSummary {
|
||||
hasVisibleSuites?: boolean
|
||||
managementState?: components['schemas']['ManagementState'] | null
|
||||
id: number
|
||||
slug: string
|
||||
|
|
|
|||
83
web/src/features/auth/skill-suite-session-cache.test.tsx
Normal file
83
web/src/features/auth/skill-suite-session-cache.test.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { useMySubscriptionsPage } from '@/shared/hooks/use-user-queries'
|
||||
import { usePasswordLogin } from './use-password-login'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
viewer: 'guest',
|
||||
nextViewer: 'member',
|
||||
direct: false,
|
||||
search: vi.fn(),
|
||||
subscriptions: vi.fn(),
|
||||
localLogin: vi.fn(),
|
||||
directLogin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
WEB_API_PREFIX: '/api/web',
|
||||
fetchJson: state.search,
|
||||
meApi: { getSubscriptionsPage: state.subscriptions },
|
||||
authApi: { localLogin: state.localLogin, directLogin: state.directLogin },
|
||||
getDirectAuthRuntimeConfig: () => ({ enabled: state.direct, provider: state.direct ? 'local' : '' }),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.direct = false
|
||||
state.search.mockImplementation(async () => ({ items: [{ hasVisibleSuites: state.viewer === 'member' }] }))
|
||||
state.subscriptions.mockImplementation(async () => ({ items: [{ hasVisibleSuites: state.viewer === 'member' }] }))
|
||||
const login = async () => {
|
||||
state.viewer = state.nextViewer
|
||||
return { userId: state.viewer }
|
||||
}
|
||||
state.localLogin.mockImplementation(login)
|
||||
state.directLogin.mockImplementation(login)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Suite membership summaries across successful authentication', () => {
|
||||
it.each([
|
||||
{ from: 'guest', to: 'member', direct: false, expected: true },
|
||||
{ from: 'member', to: 'other-user', direct: true, expected: false },
|
||||
])('reloads the same mounted lists after $from logs in as $to', async ({ from, to, direct, expected }) => {
|
||||
state.viewer = from
|
||||
state.nextViewer = to
|
||||
state.direct = direct
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } })
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client }, children)
|
||||
const hook = renderHook(() => ({
|
||||
search: useSearchSkills({ q: 'workflow' }),
|
||||
subscriptions: useMySubscriptionsPage({ page: 0 }),
|
||||
login: usePasswordLogin(),
|
||||
}), { wrapper })
|
||||
try {
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.search.data?.items[0].hasVisibleSuites).toBe(from === 'member')
|
||||
expect(hook.result.current.subscriptions.data?.items[0].hasVisibleSuites).toBe(from === 'member')
|
||||
})
|
||||
expect(state.search).toHaveBeenCalledTimes(1)
|
||||
expect(state.subscriptions).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.login.mutateAsync({ username: to, password: 'test-password' })
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.search.data?.items[0].hasVisibleSuites).toBe(expected)
|
||||
expect(hook.result.current.subscriptions.data?.items[0].hasVisibleSuites).toBe(expected)
|
||||
})
|
||||
expect(state.search).toHaveBeenCalledTimes(2)
|
||||
expect(state.subscriptions).toHaveBeenCalledTimes(2)
|
||||
expect(direct ? state.directLogin : state.localLogin).toHaveBeenCalledTimes(1)
|
||||
expect(client.getQueryData(['auth', 'me'])).toEqual({ userId: to })
|
||||
} finally {
|
||||
hook.unmount()
|
||||
client.clear()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { clearSessionScopedQueries, getNotificationQueryKeyScope } from './notification-session'
|
||||
|
||||
describe('getNotificationQueryKeyScope', () => {
|
||||
|
|
@ -15,18 +15,113 @@ describe('getNotificationQueryKeyScope', () => {
|
|||
})
|
||||
|
||||
describe('clearSessionScopedQueries', () => {
|
||||
it('removes user-scoped notification and dashboard caches without touching public search caches', () => {
|
||||
it('clears viewer-specific skill summaries while keeping unrelated public data', () => {
|
||||
const queryClient = new QueryClient()
|
||||
queryClient.setQueryData(['notifications', 'user-a', 'unread-count'], { count: 3 })
|
||||
queryClient.setQueryData(['labels', 'visible'], [{ slug: 'official' }])
|
||||
queryClient.setQueryData(['skills', 'my', { page: 0, size: 12 }], { items: [] })
|
||||
queryClient.setQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }], { items: [] })
|
||||
queryClient.setQueryData(['skills', 'subscriptions'], [{ hasVisibleSuites: true }])
|
||||
queryClient.setQueryData(['skills', 'subscriptions', 'page', { page: 0 }], { items: [{ hasVisibleSuites: true }] })
|
||||
queryClient.setQueryData(['catalog', 'metadata'], { title: 'SkillHub' })
|
||||
|
||||
clearSessionScopedQueries(queryClient)
|
||||
|
||||
expect(queryClient.getQueryData(['notifications', 'user-a', 'unread-count'])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['labels', 'visible'])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['skills', 'my', { page: 0, size: 12 }])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }])).toEqual({ items: [] })
|
||||
expect(queryClient.getQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['skills', 'subscriptions'])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['skills', 'subscriptions', 'page', { page: 0 }])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['catalog', 'metadata'])).toEqual({ title: 'SkillHub' })
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ from: 'guest', to: 'member', expected: true },
|
||||
{ from: 'member', to: 'other-user', expected: false },
|
||||
{ from: 'member', to: 'guest', expected: false },
|
||||
])('refreshes mounted search and subscription observers when $from becomes $to', async ({ from, to, expected }) => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } })
|
||||
let viewer = from
|
||||
const searchLoader = vi.fn(async () => ({ items: [{ hasVisibleSuites: viewer === 'member' }] }))
|
||||
const subscriptionsLoader = vi.fn(async () => ({ items: [{ hasVisibleSuites: viewer === 'member' }] }))
|
||||
const search = new QueryObserver(client, {
|
||||
queryKey: ['skills', 'search', { q: 'workflow' }],
|
||||
queryFn: searchLoader,
|
||||
})
|
||||
const subscriptions = new QueryObserver(client, {
|
||||
queryKey: ['skills', 'subscriptions', 'page', { page: 0 }],
|
||||
queryFn: subscriptionsLoader,
|
||||
})
|
||||
const stopSearch = search.subscribe(() => {})
|
||||
const stopSubscriptions = subscriptions.subscribe(() => {})
|
||||
try {
|
||||
await Promise.all([search.refetch(), subscriptions.refetch()])
|
||||
expect(search.getCurrentResult().data?.items[0].hasVisibleSuites).toBe(from === 'member')
|
||||
expect(searchLoader).toHaveBeenCalledTimes(1)
|
||||
expect(subscriptionsLoader).toHaveBeenCalledTimes(1)
|
||||
|
||||
viewer = to
|
||||
clearSessionScopedQueries(client)
|
||||
expect(search.getCurrentResult().data).toBeUndefined()
|
||||
expect(subscriptions.getCurrentResult().data).toBeUndefined()
|
||||
await vi.waitFor(() => {
|
||||
expect(search.getCurrentResult().data?.items[0].hasVisibleSuites).toBe(expected)
|
||||
expect(subscriptions.getCurrentResult().data?.items[0].hasVisibleSuites).toBe(expected)
|
||||
})
|
||||
expect(searchLoader).toHaveBeenCalledTimes(2)
|
||||
expect(subscriptionsLoader).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
stopSearch()
|
||||
stopSubscriptions()
|
||||
client.clear()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores a late response started under the previous viewer', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } })
|
||||
let finishPreviousRequest: (value: { hasVisibleSuites: boolean }) => void = () => {}
|
||||
const previousRequest = new Promise<{ hasVisibleSuites: boolean }>((resolve) => {
|
||||
finishPreviousRequest = resolve
|
||||
})
|
||||
const loader = vi.fn()
|
||||
.mockReturnValueOnce(previousRequest)
|
||||
.mockResolvedValue({ hasVisibleSuites: false })
|
||||
const observer = new QueryObserver(client, { queryKey: ['skills', 'search', { q: 'workflow' }], queryFn: loader })
|
||||
const unsubscribe = observer.subscribe(() => {})
|
||||
try {
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
clearSessionScopedQueries(client)
|
||||
await vi.waitFor(() => expect(observer.getCurrentResult().data).toEqual({ hasVisibleSuites: false }))
|
||||
finishPreviousRequest({ hasVisibleSuites: true })
|
||||
await previousRequest
|
||||
await Promise.resolve()
|
||||
expect(observer.getCurrentResult().data).toEqual({ hasVisibleSuites: false })
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
client.clear()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not restore the previous membership hint when the new viewer request fails', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } })
|
||||
const loader = vi.fn()
|
||||
.mockResolvedValueOnce({ hasVisibleSuites: true })
|
||||
.mockRejectedValue(new Error('Request rejected for the new viewer'))
|
||||
const observer = new QueryObserver(client, { queryKey: ['skills', 'search', { q: 'workflow' }], queryFn: loader })
|
||||
const unsubscribe = observer.subscribe(() => {})
|
||||
try {
|
||||
await observer.refetch()
|
||||
expect(observer.getCurrentResult().data).toEqual({ hasVisibleSuites: true })
|
||||
clearSessionScopedQueries(client)
|
||||
await vi.waitFor(() => expect(observer.getCurrentResult().isError).toBe(true))
|
||||
expect(observer.getCurrentResult().data).toBeUndefined()
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
client.clear()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ export function clearSessionScopedQueries(queryClient: QueryClient) {
|
|||
queryClient.removeQueries({ queryKey: ['labels'] })
|
||||
queryClient.removeQueries({ queryKey: ['skills', 'my'] })
|
||||
queryClient.removeQueries({ queryKey: ['skills', 'stars'] })
|
||||
// Public Skill summaries also contain viewer-specific Suite visibility. Reset
|
||||
// mounted observers so they drop the previous identity's data and refetch.
|
||||
void queryClient.resetQueries({ queryKey: ['skills', 'search'] })
|
||||
void queryClient.resetQueries({ queryKey: ['skills', 'subscriptions'] })
|
||||
queryClient.removeQueries({ queryKey: ['namespaces', 'my'] })
|
||||
queryClient.removeQueries({ queryKey: ['governance'] })
|
||||
queryClient.removeQueries({ queryKey: ['reviews'] })
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { SkillAvatar } from './skill-avatar'
|
|||
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { formatRelativeTime } from '@/shared/lib/format-relative-time'
|
||||
import { Bookmark, ShieldCheck, User, Clock } from 'lucide-react'
|
||||
import { Bookmark, Boxes, ShieldCheck, User, Clock } from 'lucide-react'
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillSummary
|
||||
|
|
@ -61,13 +61,22 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<div className="mt-2 grid justify-items-start">
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<NamespaceBadge
|
||||
className="block max-w-full truncate"
|
||||
type="TEAM"
|
||||
name={`@${skill.namespace}`}
|
||||
title={`@${skill.namespace}`}
|
||||
/>
|
||||
{skill.hasVisibleSuites === true ? (
|
||||
<span
|
||||
className="inline-flex max-w-full shrink-0 items-center gap-1 rounded-full border border-border bg-secondary/60 px-2 py-0.5 text-xs font-medium text-secondary-foreground"
|
||||
title={t('skillCard.relatedSuitesHint')}
|
||||
>
|
||||
<Boxes aria-hidden="true" className="h-3 w-3 shrink-0" />
|
||||
{t('skillCard.relatedSuites')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2114,6 +2114,8 @@
|
|||
"subscribe": "Subscribe"
|
||||
},
|
||||
"skillCard": {
|
||||
"relatedSuites": "Related suites",
|
||||
"relatedSuitesHint": "This skill is included in suites you can access. Open its details to explore related skills.",
|
||||
"downloads": "{{value}} downloads",
|
||||
"stars": "{{count}} stars",
|
||||
"rating": "Rating {{rating}}",
|
||||
|
|
|
|||
|
|
@ -2484,6 +2484,8 @@
|
|||
}
|
||||
},
|
||||
"skillCard": {
|
||||
"relatedSuites": "Связанные наборы",
|
||||
"relatedSuitesHint": "Этот навык входит в доступные вам наборы. Откройте его страницу, чтобы узнать о связанных навыках.",
|
||||
"downloads": "Загрузки: {{value}}",
|
||||
"stars": "Звёзды: {{count}}",
|
||||
"rating": "Рейтинг {{rating}}",
|
||||
|
|
|
|||
|
|
@ -2113,6 +2113,8 @@
|
|||
"subscribe": "订阅"
|
||||
},
|
||||
"skillCard": {
|
||||
"relatedSuites": "关联套件",
|
||||
"relatedSuitesHint": "此技能被你可见的套件收录,查看详情了解配套关系。",
|
||||
"downloads": "下载 {{value}}",
|
||||
"stars": "收藏 {{count}}",
|
||||
"rating": "评分 {{rating}}",
|
||||
|
|
|
|||
14
web/src/i18n/skill-card-suite-locale.test.ts
Normal file
14
web/src/i18n/skill-card-suite-locale.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import ru from './locales/ru.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
describe('Skill card Suite membership locales', () => {
|
||||
it('defines the passive membership label and explanation in every supported locale', () => {
|
||||
expect(zh.skillCard.relatedSuites).toBe('关联套件')
|
||||
for (const locale of [zh, en, ru]) {
|
||||
expect(locale.skillCard.relatedSuites).toBeTruthy()
|
||||
expect(locale.skillCard.relatedSuitesHint).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue