fix(ISSUE-64): close compliance review gaps

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-30 11:19:06 +08:00
parent eb2db152fd
commit e17e3e2d09
20 changed files with 199 additions and 52 deletions

View file

@ -177,7 +177,7 @@ public class SkillController extends BaseApiController {
detail.manifestJson(),
detail.complianceMappings().stream()
.map(mapping -> new SkillComplianceMappingResponse(
mapping.standard().value(),
mapping.standard(),
mapping.standardVersion(),
mapping.controlId(),
mapping.controlTitle(),

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.ratelimit.RateLimit;
@ -40,6 +41,10 @@ public class SkillSearchController extends BaseApiController {
@RequestParam(required = false) String q,
@RequestParam(required = false) String namespace,
@RequestParam(name = "label", required = false) java.util.List<String> labels,
@Parameter(
name = "complianceStandard",
schema = @Schema(implementation = ComplianceStandard.class)
)
@RequestParam(required = false) String complianceStandard,
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
@RequestParam(required = false) String sort,

View file

@ -1,7 +1,9 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
public record SkillComplianceMappingResponse(
String standard,
ComplianceStandard standard,
String standardVersion,
String controlId,
String controlTitle,

View file

@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.search.SearchQuery;
@ -169,7 +170,7 @@ public class SkillSearchAppService {
int page,
int size,
List<String> labelSlugs,
String complianceStandard,
ComplianceStandard complianceStandard,
SearchVisibilityScope scope,
boolean requireInstallableLatest) {
SearchResult result = searchQueryService.search(new SearchQuery(
@ -198,11 +199,11 @@ public class SkillSearchAppService {
.toList();
}
private String normalizeComplianceStandard(String complianceStandard) {
private ComplianceStandard normalizeComplianceStandard(String complianceStandard) {
if (complianceStandard == null || complianceStandard.isBlank()) {
return null;
}
return complianceStandard.trim().toLowerCase(java.util.Locale.ROOT);
return ComplianceStandard.findByValue(complianceStandard).orElse(null);
}
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds) {

View file

@ -28,7 +28,9 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -127,6 +129,16 @@ class SkillControllerTest {
}
}
@Test
void openApiShouldExposeClosedComplianceStandardEnums() throws Exception {
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("\"SkillComplianceMappingResponse\"")))
.andExpect(content().string(containsString("\"standard\"")))
.andExpect(content().string(containsString("\"enum\":[\"mitre_attack\",\"nist_csf\",\"gdpr\",\"hipaa\",\"soc2\"]")))
.andExpect(content().string(containsString("\"name\":\"complianceStandard\"")));
}
@Test
void resolveVersionShouldReturnUnifiedEnvelope() throws Exception {
when(skillQueryService.resolveVersion(

View file

@ -12,6 +12,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.search.SearchQuery;
import com.iflytek.skillhub.search.SearchQueryService;
@ -313,7 +314,7 @@ class SkillSearchAppServiceTest {
ArgumentCaptor<SearchQuery> captor = ArgumentCaptor.forClass(SearchQuery.class);
verify(searchQueryService).search(captor.capture());
assertEquals("gdpr", captor.getValue().complianceStandard());
assertEquals(ComplianceStandard.GDPR, captor.getValue().complianceStandard());
}
@Test

View file

@ -37,15 +37,21 @@ public class SkillComplianceAuditDetailFactory {
return build("latest_published_removed", version, extras);
}
public String build(String snapshotKind, SkillVersion version, Map<String, Object> extras) {
public String latestPublishedRemoved(SkillVersion version,
SkillVersion replacementLatestPublished,
Map<String, Object> extras) {
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("snapshotKind", snapshotKind);
payload.put("versionId", version.getId());
payload.put("version", version.getVersion());
payload.put(
"compliance",
complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson())
);
if (extras != null && !extras.isEmpty()) {
payload.putAll(extras);
}
if (replacementLatestPublished != null) {
payload.put("replacementLatestPublished", snapshotPayload("latest_published_entered", replacementLatestPublished));
}
return build("latest_published_removed", version, payload);
}
public String build(String snapshotKind, SkillVersion version, Map<String, Object> extras) {
LinkedHashMap<String, Object> payload = snapshotPayload(snapshotKind, version);
if (extras != null && !extras.isEmpty()) {
payload.putAll(extras);
}
@ -55,4 +61,16 @@ public class SkillComplianceAuditDetailFactory {
throw new IllegalStateException("Failed to serialize compliance audit detail", ex);
}
}
private LinkedHashMap<String, Object> snapshotPayload(String snapshotKind, SkillVersion version) {
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("snapshotKind", snapshotKind);
payload.put("versionId", version.getId());
payload.put("version", version.getVersion());
payload.put(
"compliance",
complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson())
);
return payload;
}
}

View file

@ -270,13 +270,14 @@ public class SkillGovernanceService {
version.setYankReason(reason);
version.setDownloadReady(false);
SkillVersion saved = skillVersionRepository.save(version);
skillRepository.findById(version.getSkillId()).ifPresent(skill -> {
if (versionId.equals(skill.getLatestVersionId())) {
skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId()));
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
}
});
SkillVersion replacementLatestPublished = null;
Skill skill = skillRepository.findById(version.getSkillId()).orElse(null);
if (skill != null && versionId.equals(skill.getLatestVersionId())) {
replacementLatestPublished = findLatestPublishedVersion(skill.getId());
skill.setLatestVersionId(replacementLatestPublished != null ? replacementLatestPublished.getId() : null);
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
}
LinkedHashMap<String, Object> auditExtras = new LinkedHashMap<>();
if (reason != null && !reason.isBlank()) {
auditExtras.put("reason", reason);
@ -289,20 +290,36 @@ public class SkillGovernanceService {
null,
clientIp,
userAgent,
complianceAuditDetailFactory.latestPublishedRemoved(version, auditExtras)
complianceAuditDetailFactory.latestPublishedRemoved(version, replacementLatestPublished, auditExtras)
);
if (replacementLatestPublished != null) {
auditLogService.record(
actorUserId,
"YANK_SKILL_VERSION",
"SKILL_VERSION",
replacementLatestPublished.getId(),
null,
clientIp,
userAgent,
complianceAuditDetailFactory.latestPublishedEntered(replacementLatestPublished, auditExtras)
);
}
eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent(
version.getSkillId(), versionId, actorUserId));
return saved;
}
private Long findLatestPublishedVersionId(Long skillId) {
SkillVersion latestPublishedVersion = findLatestPublishedVersion(skillId);
return latestPublishedVersion != null ? latestPublishedVersion.getId() : null;
}
private SkillVersion findLatestPublishedVersion(Long skillId) {
return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream()
.max(java.util.Comparator
.comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())))
.map(SkillVersion::getId)
.orElse(null);
}

View file

@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.doThrow;
@ -38,6 +39,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ -212,11 +214,37 @@ class SkillGovernanceServiceTest {
setField(yanked, "id", 22L);
yanked.setStatus(SkillVersionStatus.PUBLISHED);
yanked.setPublishedAt(Instant.parse("2026-03-18T10:00:00Z"));
yanked.setParsedMetadataJson("""
{
"frontmatter": {
"x-astron-compliance": [
{
"standard": "gdpr",
"standardVersion": "2024",
"controlId": "Article-17"
}
]
}
}
""");
SkillVersion fallback = new SkillVersion(2L, "1.0.0", "owner");
setField(fallback, "id", 11L);
fallback.setStatus(SkillVersionStatus.PUBLISHED);
fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z"));
fallback.setParsedMetadataJson("""
{
"frontmatter": {
"x-astron-compliance": [
{
"standard": "soc2",
"standardVersion": "2017",
"controlId": "CC6.1"
}
]
}
}
""");
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 2L);
@ -232,6 +260,33 @@ class SkillGovernanceServiceTest {
assertThat(skill.getLatestVersionId()).isEqualTo(11L);
verify(skillRepository).save(skill);
ArgumentCaptor<Long> entityIdCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<String> detailCaptor = ArgumentCaptor.forClass(String.class);
verify(auditLogService, times(2)).record(
org.mockito.ArgumentMatchers.eq("admin"),
org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"),
org.mockito.ArgumentMatchers.eq("SKILL_VERSION"),
entityIdCaptor.capture(),
org.mockito.ArgumentMatchers.eq(null),
org.mockito.ArgumentMatchers.eq("127.0.0.1"),
org.mockito.ArgumentMatchers.eq("JUnit"),
detailCaptor.capture()
);
assertThat(entityIdCaptor.getAllValues()).containsExactlyInAnyOrder(22L, 11L);
assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> {
assertThat(detail).contains("\"snapshotKind\":\"latest_published_removed\"");
assertThat(detail).contains("\"versionId\":22");
assertThat(detail).contains("\"replacementLatestPublished\"");
assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\"");
assertThat(detail).contains("\"versionId\":11");
assertThat(detail).contains("\"standard\":\"soc2\"");
});
assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> {
assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\"");
assertThat(detail).contains("\"versionId\":11");
assertThat(detail).contains("\"standard\":\"soc2\"");
});
}
@Test

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.search;
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
import java.util.List;
/**
@ -13,7 +14,7 @@ public record SearchQuery(
int page,
int size,
List<String> labelSlugs,
String complianceStandard,
ComplianceStandard complianceStandard,
boolean requireInstallableLatest
) {
public SearchQuery(

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.postgres;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
import com.iflytek.skillhub.search.SearchEmbeddingService;
import com.iflytek.skillhub.search.SearchQuery;
import com.iflytek.skillhub.search.SearchQueryService;
@ -275,11 +276,11 @@ public class PostgresFullTextQueryService implements SearchQueryService {
return new SearchResult(skillIds, total, query.page(), query.size());
}
private String normalizeComplianceStandard(String complianceStandard) {
if (complianceStandard == null || complianceStandard.isBlank()) {
private String normalizeComplianceStandard(ComplianceStandard complianceStandard) {
if (complianceStandard == null) {
return null;
}
return complianceStandard.trim().toLowerCase(Locale.ROOT);
return complianceStandard.value();
}
private List<Long> rerankBySemanticSimilarity(List<Long> candidateSkillIds,

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.search.postgres;
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
import com.iflytek.skillhub.search.HashingSearchEmbeddingService;
@ -318,7 +319,7 @@ class PostgresFullTextQueryServiceTest {
0,
20,
List.of("official"),
"gdpr",
ComplianceStandard.GDPR,
false
));

View file

@ -4164,7 +4164,7 @@ export interface components {
complianceMappings?: components["schemas"]["SkillComplianceMappingResponse"][];
};
SkillComplianceMappingResponse: {
standard?: string;
standard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2";
standardVersion?: string;
controlId?: string;
controlTitle?: string;
@ -8516,7 +8516,7 @@ export interface operations {
q?: string;
namespace?: string;
label?: string[];
complianceStandard?: string;
complianceStandard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2";
sort?: string;
page?: number;
size?: number;

View file

@ -256,7 +256,7 @@ export interface SkillVersion {
}
export interface SkillComplianceMapping {
standard: string
standard: ComplianceStandard
standardVersion: string
controlId: string
controlTitle?: string
@ -332,12 +332,20 @@ export interface SkillTag {
createdAt: string
}
export const COMPLIANCE_STANDARD_VALUES = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const
export type ComplianceStandard = (typeof COMPLIANCE_STANDARD_VALUES)[number]
export function isComplianceStandard(value: string): value is ComplianceStandard {
return COMPLIANCE_STANDARD_VALUES.some((candidate) => candidate === value)
}
// Search and pagination
export interface SearchParams {
q?: string
namespace?: string
label?: string
complianceStandard?: string
complianceStandard?: ComplianceStandard
sort?: string
page?: number
size?: number

View file

@ -1,10 +1,12 @@
import { lazy, Suspense, type ComponentType } from 'react'
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
import type { ComplianceStandard } from '@/api/types'
import { Layout } from './layout'
import { getCurrentUser } from '@/api/client'
import { RoleGuard } from '@/shared/components/role-guard'
import { createRequireAuth } from '@/shared/lib/auth-route'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
import { isComplianceStandard } from '@/api/types'
/**
* Central route registry for the SkillHub web app.
@ -199,12 +201,13 @@ const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'search',
component: SearchPage,
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; complianceStandard?: string; sort: string; page: number; starredOnly: boolean } => {
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; complianceStandard?: ComplianceStandard; sort: string; page: number; starredOnly: boolean } => {
const rawComplianceStandard = typeof search.complianceStandard === 'string' ? search.complianceStandard : ''
return {
q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''),
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined,
label: typeof search.label === 'string' && search.label ? search.label : undefined,
complianceStandard: typeof search.complianceStandard === 'string' && search.complianceStandard ? search.complianceStandard : undefined,
complianceStandard: isComplianceStandard(rawComplianceStandard) ? rawComplianceStandard : undefined,
sort: (search.sort as string) || 'newest',
page: Number(search.page) || 0,
starredOnly: search.starredOnly === true || search.starredOnly === 'true',

View file

@ -68,6 +68,12 @@ describe('install-command', () => {
)
})
it('adds an explicit version to the ClawHub install command when a version is selected', () => {
expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe(
'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version 0.9.0',
)
})
it('builds a one-line SkillHub npx command for the global namespace', () => {
expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe(
'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn',
@ -80,6 +86,12 @@ describe('install-command', () => {
)
})
it('adds an explicit version to the SkillHub install command when a version is selected', () => {
expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe(
'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version 0.9.0',
)
})
it('uses the runtime app base url when available', () => {
setMockWindow('https://app.example.com')

View file

@ -29,14 +29,21 @@ export function getBaseUrl(): string {
return `${window.location.protocol}//${window.location.host}`
}
export function buildInstallCommand(namespace: string, slug: string, baseUrl: string): string {
const installTarget = buildInstallTarget(namespace, slug)
return `npx clawhub install ${installTarget} --registry ${baseUrl}`
function buildVersionedCommand(command: string, version?: string): string {
if (!version) {
return command
}
return `${command} --version ${version}`
}
export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string {
export function buildInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string {
const installTarget = buildInstallTarget(namespace, slug)
return buildVersionedCommand(`npx clawhub install ${installTarget} --registry ${baseUrl}`, version)
}
export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string {
const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}`
return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`
return buildVersionedCommand(`npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`, version)
}
interface CommandBlockProps {
@ -80,11 +87,11 @@ function CommandBlock({ command }: CommandBlockProps) {
)
}
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
export function InstallCommand({ namespace, slug, version }: InstallCommandProps) {
const { t } = useTranslation()
const baseUrl = useMemo(() => getBaseUrl(), [])
const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version])
const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version])
return (
<Tabs defaultValue="clawhub" className="space-y-3">

View file

@ -2,7 +2,7 @@ import { startTransition, useEffect, useRef, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import type { SkillSummary } from '@/api/types'
import { COMPLIANCE_STANDARD_VALUES, type ComplianceStandard, type SkillSummary } from '@/api/types'
import { useAuth } from '@/features/auth/use-auth'
import { SearchBar } from '@/features/search/search-bar'
import { SkillCard } from '@/features/skill/skill-card'
@ -17,7 +17,6 @@ import { Button } from '@/shared/ui/button'
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
const PAGE_SIZE = 12
const COMPLIANCE_STANDARD_OPTIONS = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const
function blurActiveElement() {
if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') {
@ -94,7 +93,7 @@ export function SearchPage() {
const q = normalizeSearchQuery(searchParams.q || '')
const namespace = (searchParams.namespace || '').replace(/^@/, '')
const selectedLabel = searchParams.label || ''
const complianceStandard = searchParams.complianceStandard || ''
const complianceStandard = searchParams.complianceStandard
const sort = searchParams.sort || 'newest'
const page = searchParams.page ?? 0
const starredOnly = searchParams.starredOnly ?? false
@ -105,7 +104,7 @@ export function SearchPage() {
q: string
namespace: string
label: string
complianceStandard: string
complianceStandard?: ComplianceStandard
sort: string
page: number
starredOnly: boolean
@ -142,7 +141,7 @@ export function SearchPage() {
q,
namespace: namespace || undefined,
label: selectedLabel || undefined,
complianceStandard: complianceStandard || undefined,
complianceStandard,
sort,
page,
size: PAGE_SIZE,
@ -228,8 +227,8 @@ export function SearchPage() {
navigate({ to: '/search', search: buildSearchState({ label: nextLabel, page: 0 }) })
}
const handleComplianceToggle = (standard: string) => {
const nextStandard = complianceStandard === standard ? '' : standard
const handleComplianceToggle = (standard: ComplianceStandard) => {
const nextStandard = complianceStandard === standard ? undefined : standard
navigate({ to: '/search', search: buildSearchState({ complianceStandard: nextStandard, page: 0 }) })
}
@ -359,7 +358,7 @@ export function SearchPage() {
{!starredOnly && (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-muted-foreground">{t('search.compliance.label')}</span>
{COMPLIANCE_STANDARD_OPTIONS.map((standard) => (
{COMPLIANCE_STANDARD_VALUES.map((standard) => (
<Button
key={standard}
variant={complianceStandard === standard ? 'default' : 'outline'}

View file

@ -139,7 +139,7 @@ vi.mock('@/features/skill/file-tree', () => ({
}))
vi.mock('@/features/skill/install-command', () => ({
InstallCommand: () => <div>install</div>,
InstallCommand: ({ version }: { version?: string }) => <div>install:{version ?? 'latest'}</div>,
}))
vi.mock('@/features/social/rating-input', () => ({
@ -389,6 +389,7 @@ describe('SkillDetailPage', () => {
expect(html).toContain('skillDetail.complianceSectionTitle')
expect(html).toContain('Right to erasure')
expect(html).toContain('skillDetail.complianceControlId')
expect(html).toContain('install:0.9.0')
})
it('shows the label management panel for a user who can manage the skill lifecycle', () => {

View file

@ -207,6 +207,9 @@ export function SkillDetailPage() {
const canManageLabels = Boolean(skill && user && (skill.canManageLifecycle || hasRole('SUPER_ADMIN')))
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
const selectedVersionComplianceMappings = selectedVersionDetail?.complianceMappings ?? []
const installCommandVersion = selectedVersionEntry?.status === 'PUBLISHED'
? selectedVersionEntry.version
: publishedVersion?.version
useEffect(() => {
// Recompute collapse rules whenever rendered documentation height changes so the page can keep
@ -1178,7 +1181,7 @@ export function SkillDetailPage() {
<InstallCommand
namespace={namespace}
slug={slug}
version={publishedVersion.version}
version={installCommandVersion}
/>
</Card>
)}