mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
feat: complete skill promotion submission flow
This commit is contained in:
parent
bead940e3e
commit
556e65efef
18 changed files with 533 additions and 19 deletions
195
scripts/promotion-smoke-test.sh
Executable file
195
scripts/promotion-smoke-test.sh
Executable file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-http://localhost:8080}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
USER_COOKIE="$(mktemp)"
|
||||
ADMIN_COOKIE="$(mktemp)"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
SLUG="psmoke$(date +%s)"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$USER_COOKIE" "$ADMIN_COOKIE"
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
pass() {
|
||||
echo "PASS: $1"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1"
|
||||
FAIL=$((FAIL + 1))
|
||||
}
|
||||
|
||||
csrf_token() {
|
||||
local cookie_file="$1"
|
||||
awk '$6 == "XSRF-TOKEN" { print $7 }' "$cookie_file" | tail -n 1
|
||||
}
|
||||
|
||||
bootstrap_csrf() {
|
||||
local cookie_file="$1"
|
||||
local user_id="$2"
|
||||
curl -s -c "$cookie_file" -H "X-Mock-User-Id: $user_id" "$BASE_URL/api/v1/auth/providers" >/dev/null
|
||||
}
|
||||
|
||||
json_field() {
|
||||
local json="$1"
|
||||
local expr="$2"
|
||||
JSON_INPUT="$json" python3 - "$expr" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
expr = sys.argv[1]
|
||||
value = json.loads(os.environ["JSON_INPUT"])
|
||||
for part in expr.split("."):
|
||||
if part.isdigit():
|
||||
value = value[int(part)]
|
||||
else:
|
||||
value = value[part]
|
||||
if isinstance(value, (dict, list)):
|
||||
print(json.dumps(value, ensure_ascii=False))
|
||||
else:
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
assert_code() {
|
||||
local description="$1"
|
||||
local json="$2"
|
||||
local expected="$3"
|
||||
local actual
|
||||
actual="$(json_field "$json" "code")"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
pass "$description"
|
||||
else
|
||||
fail "$description (expected code $expected, got $actual)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Promotion Workflow Smoke Test ==="
|
||||
echo "Target: $BASE_URL"
|
||||
echo "Slug: $SLUG"
|
||||
echo
|
||||
|
||||
bootstrap_csrf "$USER_COOKIE" "local-user"
|
||||
bootstrap_csrf "$ADMIN_COOKIE" "local-admin"
|
||||
|
||||
USER_CSRF="$(csrf_token "$USER_COOKIE")"
|
||||
ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")"
|
||||
|
||||
if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then
|
||||
echo "Could not bootstrap CSRF tokens"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
"$BASE_URL/api/web/namespaces/global")"
|
||||
assert_code "Global namespace detail is available" "$GLOBAL_RESPONSE" "0"
|
||||
GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_RESPONSE" "data.id")"
|
||||
|
||||
CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
-H "X-XSRF-TOKEN: $USER_CSRF" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$BASE_URL/api/web/namespaces" \
|
||||
-d "{\"slug\":\"$SLUG\",\"displayName\":\"Promotion Smoke $SLUG\",\"description\":\"promotion smoke test\"}")"
|
||||
assert_code "Owner can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0"
|
||||
NAMESPACE_ID="$(json_field "$CREATE_NAMESPACE_RESPONSE" "data.id")"
|
||||
|
||||
cat > "$WORK_DIR/SKILL.md" <<'EOF'
|
||||
---
|
||||
name: Promotion Smoke Skill
|
||||
description: Promotion smoke test
|
||||
version: 1.0.0
|
||||
---
|
||||
Body
|
||||
EOF
|
||||
(cd "$WORK_DIR" && zip -q skill.zip SKILL.md)
|
||||
|
||||
PUBLISH_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
-H "X-XSRF-TOKEN: $USER_CSRF" \
|
||||
-F "file=@$WORK_DIR/skill.zip;type=application/zip" \
|
||||
-F "visibility=PUBLIC" \
|
||||
"$BASE_URL/api/web/skills/$SLUG/publish")"
|
||||
assert_code "Owner can publish a team skill" "$PUBLISH_RESPONSE" "0"
|
||||
SKILL_ID="$(json_field "$PUBLISH_RESPONSE" "data.skillId")"
|
||||
SKILL_SLUG="$(json_field "$PUBLISH_RESPONSE" "data.slug")"
|
||||
|
||||
PENDING_REVIEWS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
|
||||
"$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")"
|
||||
assert_code "Admin can list pending namespace reviews" "$PENDING_REVIEWS_RESPONSE" "0"
|
||||
REVIEW_ID="$(json_field "$PENDING_REVIEWS_RESPONSE" "data.items.0.id")"
|
||||
|
||||
APPROVE_REVIEW_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
|
||||
-H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$BASE_URL/api/web/reviews/$REVIEW_ID/approve" \
|
||||
-d '{"comment":"ok"}')"
|
||||
assert_code "Admin can approve team skill review" "$APPROVE_REVIEW_RESPONSE" "0"
|
||||
|
||||
SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")"
|
||||
assert_code "Owner can load team skill detail" "$SKILL_DETAIL_RESPONSE" "0"
|
||||
VERSION_ID="$(json_field "$SKILL_DETAIL_RESPONSE" "data.latestVersionId")"
|
||||
CAN_SUBMIT_PROMOTION="$(json_field "$SKILL_DETAIL_RESPONSE" "data.canSubmitPromotion")"
|
||||
if [[ "$CAN_SUBMIT_PROMOTION" == "True" || "$CAN_SUBMIT_PROMOTION" == "true" ]]; then
|
||||
pass "Approved team skill is marked promotable"
|
||||
else
|
||||
fail "Approved team skill should expose canSubmitPromotion=true"
|
||||
fi
|
||||
|
||||
MY_SKILLS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
"$BASE_URL/api/web/me/skills")"
|
||||
assert_code "Owner can list my skills with promotion metadata" "$MY_SKILLS_RESPONSE" "0"
|
||||
if JSON_INPUT="$MY_SKILLS_RESPONSE" python3 - "$SKILL_ID" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
skill_id = int(sys.argv[1])
|
||||
items = json.loads(os.environ["JSON_INPUT"])["data"]
|
||||
match = next(item for item in items if item["id"] == skill_id)
|
||||
raise SystemExit(0 if match["canSubmitPromotion"] and match["latestVersionId"] else 1)
|
||||
PY
|
||||
then
|
||||
pass "My skills response exposes promotion submission fields"
|
||||
else
|
||||
fail "My skills response should expose latestVersionId and canSubmitPromotion"
|
||||
fi
|
||||
|
||||
SUBMIT_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
-H "X-XSRF-TOKEN: $USER_CSRF" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$BASE_URL/api/web/promotions" \
|
||||
-d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")"
|
||||
assert_code "Owner can submit promotion to global namespace" "$SUBMIT_PROMOTION_RESPONSE" "0"
|
||||
|
||||
PENDING_PROMOTIONS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
|
||||
"$BASE_URL/api/web/promotions?status=PENDING")"
|
||||
assert_code "Admin can list pending promotions" "$PENDING_PROMOTIONS_RESPONSE" "0"
|
||||
if JSON_INPUT="$PENDING_PROMOTIONS_RESPONSE" python3 - "$SKILL_ID" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
skill_id = int(sys.argv[1])
|
||||
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
|
||||
raise SystemExit(0 if any(item["sourceSkillId"] == skill_id for item in items) else 1)
|
||||
PY
|
||||
then
|
||||
pass "Pending promotions list contains the submitted team skill"
|
||||
else
|
||||
fail "Pending promotions list should include submitted team skill"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
if [[ "$FAIL" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -70,8 +70,10 @@ public class SkillController extends BaseApiController {
|
|||
detail.ratingCount(),
|
||||
detail.hidden(),
|
||||
detail.latestVersion(),
|
||||
detail.latestVersionId(),
|
||||
namespace,
|
||||
detail.canManageLifecycle(),
|
||||
detail.canSubmitPromotion(),
|
||||
detail.viewingVersionStatus(),
|
||||
detail.canInteract()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ public record SkillDetailResponse(
|
|||
Integer ratingCount,
|
||||
boolean hidden,
|
||||
String latestVersion,
|
||||
Long latestVersionId,
|
||||
String namespace,
|
||||
boolean canManageLifecycle,
|
||||
boolean canSubmitPromotion,
|
||||
String viewingVersionStatus,
|
||||
boolean canInteract
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ public record SkillSummaryResponse(
|
|||
BigDecimal ratingAvg,
|
||||
Integer ratingCount,
|
||||
String latestVersion,
|
||||
Long latestVersionId,
|
||||
String latestVersionStatus,
|
||||
String namespace,
|
||||
LocalDateTime updatedAt
|
||||
LocalDateTime updatedAt,
|
||||
boolean canSubmitPromotion
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
|
|
@ -50,15 +52,13 @@ public class MySkillAppService {
|
|||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
|
||||
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> namespacesById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
com.iflytek.skillhub.domain.namespace.Namespace::getId,
|
||||
com.iflytek.skillhub.domain.namespace.Namespace::getSlug));
|
||||
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity()));
|
||||
|
||||
return skills.stream()
|
||||
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById))
|
||||
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
|
@ -80,18 +80,16 @@ public class MySkillAppService {
|
|||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
|
||||
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> namespacesById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
com.iflytek.skillhub.domain.namespace.Namespace::getId,
|
||||
com.iflytek.skillhub.domain.namespace.Namespace::getSlug));
|
||||
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity()));
|
||||
|
||||
return stars.stream()
|
||||
.sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed())
|
||||
.map(star -> skillsById.get(star.getSkillId()))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById))
|
||||
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
|
@ -116,8 +114,9 @@ public class MySkillAppService {
|
|||
private SkillSummaryResponse toSummaryResponse(
|
||||
Skill skill,
|
||||
Map<Long, SkillVersion> versionsBySkillId,
|
||||
Map<Long, String> namespaceSlugsById) {
|
||||
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> namespacesById) {
|
||||
SkillVersion latestVersion = versionsBySkillId.get(skill.getId());
|
||||
com.iflytek.skillhub.domain.namespace.Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
|
||||
return new SkillSummaryResponse(
|
||||
skill.getId(),
|
||||
|
|
@ -130,12 +129,30 @@ public class MySkillAppService {
|
|||
skill.getRatingAvg(),
|
||||
skill.getRatingCount(),
|
||||
Optional.ofNullable(latestVersion).map(SkillVersion::getVersion).orElse(null),
|
||||
Optional.ofNullable(latestVersion).map(SkillVersion::getId).orElse(null),
|
||||
Optional.ofNullable(latestVersion).map(SkillVersion::getStatus).map(Enum::name).orElse(null),
|
||||
namespaceSlugsById.get(skill.getNamespaceId()),
|
||||
skill.getUpdatedAt()
|
||||
namespace != null ? namespace.getSlug() : null,
|
||||
skill.getUpdatedAt(),
|
||||
canSubmitPromotion(skill, latestVersion, namespace)
|
||||
);
|
||||
}
|
||||
|
||||
private boolean canSubmitPromotion(
|
||||
Skill skill,
|
||||
SkillVersion latestVersion,
|
||||
com.iflytek.skillhub.domain.namespace.Namespace namespace) {
|
||||
if (namespace == null) {
|
||||
return false;
|
||||
}
|
||||
if (namespace.getType() == NamespaceType.GLOBAL) {
|
||||
return false;
|
||||
}
|
||||
if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
return latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED;
|
||||
}
|
||||
|
||||
private Map<Long, SkillVersion> loadLatestRelevantVersions(java.util.Collection<Skill> skills) {
|
||||
if (skills.isEmpty()) {
|
||||
return Map.of();
|
||||
|
|
|
|||
|
|
@ -198,9 +198,11 @@ public class SkillSearchAppService {
|
|||
skill.getRatingAvg(),
|
||||
skill.getRatingCount(),
|
||||
latestVersion,
|
||||
skill.getLatestVersionId(),
|
||||
latestVersion == null ? null : "PUBLISHED",
|
||||
namespaceSlug,
|
||||
skill.getUpdatedAt()
|
||||
skill.getUpdatedAt(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,9 +62,11 @@ class ClawHubCompatControllerTest {
|
|||
BigDecimal.valueOf(4.5),
|
||||
2,
|
||||
"1.2.0",
|
||||
11L,
|
||||
"PUBLISHED",
|
||||
"global",
|
||||
LocalDateTime.of(2026, 3, 13, 9, 0))),
|
||||
LocalDateTime.of(2026, 3, 13, 9, 0),
|
||||
false)),
|
||||
1,
|
||||
0,
|
||||
20
|
||||
|
|
|
|||
|
|
@ -126,7 +126,9 @@ class SkillControllerTest {
|
|||
LocalDateTime.of(2026, 3, 15, 10, 0),
|
||||
LocalDateTime.of(2026, 3, 15, 10, 0),
|
||||
null,
|
||||
11L,
|
||||
true,
|
||||
false,
|
||||
"PENDING_REVIEW",
|
||||
false
|
||||
));
|
||||
|
|
@ -135,6 +137,8 @@ class SkillControllerTest {
|
|||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.latestVersion").value("1.1.0"))
|
||||
.andExpect(jsonPath("$.data.latestVersionId").value(11L))
|
||||
.andExpect(jsonPath("$.data.canSubmitPromotion").value(false))
|
||||
.andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW"))
|
||||
.andExpect(jsonPath("$.data.canInteract").value(false));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,36 @@ class MySkillAppServiceTest {
|
|||
|
||||
assertThat(skills).hasSize(1);
|
||||
assertThat(skills.get(0).latestVersion()).isEqualTo("1.0.0");
|
||||
assertThat(skills.get(0).latestVersionId()).isEqualTo(11L);
|
||||
assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PENDING_REVIEW");
|
||||
assertThat(skills.get(0).canSubmitPromotion()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_marksTeamPublishedSkillAsPromotable() {
|
||||
Skill skill = new Skill(101L, "team-skill", "user-1", SkillVisibility.PUBLIC);
|
||||
skill.setDisplayName("Team Skill");
|
||||
skill.setSummary("published");
|
||||
ReflectionTestUtils.setField(skill, "id", 2L);
|
||||
ReflectionTestUtils.setField(skill, "updatedAt", LocalDateTime.of(2026, 3, 15, 11, 0));
|
||||
|
||||
SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1");
|
||||
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
ReflectionTestUtils.setField(publishedVersion, "id", 22L);
|
||||
ReflectionTestUtils.setField(publishedVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 10, 30));
|
||||
|
||||
Namespace namespace = new Namespace("team-ai", "Team AI", "user-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", 101L);
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill));
|
||||
given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of(publishedVersion));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
|
||||
|
||||
var skills = service.listMySkills("user-1");
|
||||
|
||||
assertThat(skills).hasSize(1);
|
||||
assertThat(skills.get(0).latestVersionId()).isEqualTo(22L);
|
||||
assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PUBLISHED");
|
||||
assertThat(skills.get(0).canSubmitPromotion()).isTrue();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
|
|
@ -71,7 +72,9 @@ public class SkillQueryService {
|
|||
java.time.LocalDateTime createdAt,
|
||||
java.time.LocalDateTime updatedAt,
|
||||
SkillVersion latestVersionEntity,
|
||||
Long latestVersionId,
|
||||
boolean canManageLifecycle,
|
||||
boolean canSubmitPromotion,
|
||||
String viewingVersionStatus,
|
||||
boolean canInteract
|
||||
) {}
|
||||
|
|
@ -137,7 +140,9 @@ public class SkillQueryService {
|
|||
skill.getCreatedAt(),
|
||||
skill.getUpdatedAt(),
|
||||
latestVersionEntity,
|
||||
latestVersionEntity != null ? latestVersionEntity.getId() : null,
|
||||
canManageRestrictedSkill(skill, currentUserId, userNsRoles),
|
||||
canSubmitPromotion(namespace, skill, latestVersionEntity, currentUserId, userNsRoles),
|
||||
latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null,
|
||||
latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED
|
||||
);
|
||||
|
|
@ -481,6 +486,24 @@ public class SkillQueryService {
|
|||
|| role == NamespaceRole.OWNER;
|
||||
}
|
||||
|
||||
private boolean canSubmitPromotion(
|
||||
Namespace namespace,
|
||||
Skill skill,
|
||||
SkillVersion latestVersionEntity,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (namespace.getType() == NamespaceType.GLOBAL) {
|
||||
return false;
|
||||
}
|
||||
if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != SkillStatus.ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
if (latestVersionEntity == null || latestVersionEntity.getStatus() != SkillVersionStatus.PUBLISHED) {
|
||||
return false;
|
||||
}
|
||||
return canManageRestrictedSkill(skill, currentUserId, userNsRoles);
|
||||
}
|
||||
|
||||
private boolean isOwner(Skill skill, String currentUserId) {
|
||||
return currentUserId != null && skill.getOwnerId().equals(currentUserId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,6 +463,35 @@ class SkillQueryServiceTest {
|
|||
assertTrue(result.canManageLifecycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldAllowPromotionForTeamOwnerOnPublishedSkill() throws Exception {
|
||||
String namespaceSlug = "team-ns";
|
||||
String skillSlug = "team-skill";
|
||||
String userId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", userId);
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
assertEquals(11L, result.latestVersionId());
|
||||
assertTrue(result.canSubmitPromotion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
@ -483,6 +512,7 @@ class SkillQueryServiceTest {
|
|||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
assertFalse(result.canManageLifecycle());
|
||||
assertFalse(result.canSubmitPromotion());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -693,6 +693,16 @@ export const reviewApi = {
|
|||
}
|
||||
|
||||
export const promotionApi = {
|
||||
async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise<void> {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/promotions`, {
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
|
||||
async list(params: { status?: string; page?: number; size?: number }) {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('status', params.status ?? 'PENDING')
|
||||
|
|
|
|||
|
|
@ -139,9 +139,11 @@ export interface SkillSummary {
|
|||
ratingAvg?: number
|
||||
ratingCount: number
|
||||
latestVersion?: string
|
||||
latestVersionId?: number
|
||||
latestVersionStatus?: string
|
||||
namespace: string
|
||||
updatedAt: string
|
||||
canSubmitPromotion: boolean
|
||||
}
|
||||
|
||||
export interface SkillDetail {
|
||||
|
|
@ -157,12 +159,20 @@ export interface SkillDetail {
|
|||
ratingCount: number
|
||||
hidden: boolean
|
||||
latestVersion?: string
|
||||
latestVersionId?: number
|
||||
namespace: string
|
||||
canManageLifecycle: boolean
|
||||
canSubmitPromotion: boolean
|
||||
viewingVersionStatus?: string
|
||||
canInteract: boolean
|
||||
}
|
||||
|
||||
export interface SubmitPromotionRequest {
|
||||
sourceSkillId: number
|
||||
sourceVersionId: number
|
||||
targetNamespaceId: number
|
||||
}
|
||||
|
||||
export interface SkillVersion {
|
||||
id: number
|
||||
version: string
|
||||
|
|
|
|||
|
|
@ -256,6 +256,14 @@
|
|||
"withdrawSuccessTitle": "Upload withdrawn",
|
||||
"withdrawSuccessDescription": "The pending version for \"{{skill}}\" has been withdrawn.",
|
||||
"withdrawErrorTitle": "Failed to withdraw upload",
|
||||
"promoteToGlobal": "Promote to Global",
|
||||
"promotionConfirmTitle": "Submit promotion request",
|
||||
"promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?",
|
||||
"promotionSuccessTitle": "Promotion request submitted",
|
||||
"promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.",
|
||||
"promotionDuplicateTitle": "Promotion already pending",
|
||||
"promotionDuplicateDescription": "This version already has a pending promotion request.",
|
||||
"promotionErrorTitle": "Failed to submit promotion request",
|
||||
"emptyTitle": "No skills yet",
|
||||
"emptyDescription": "Start publishing your first skill",
|
||||
"publishSkill": "Publish Skill"
|
||||
|
|
@ -568,6 +576,16 @@
|
|||
"rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.",
|
||||
"rereleaseErrorTitle": "Failed to re-release version",
|
||||
"yankVersion": "Yank Current Version",
|
||||
"promoteToGlobal": "Promote to Global",
|
||||
"promotionSectionTitle": "Promote to Global",
|
||||
"promotionSectionDescription": "Submit the currently published version v{{version}} for review into the global namespace.",
|
||||
"promotionConfirmTitle": "Submit promotion request",
|
||||
"promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?",
|
||||
"promotionSuccessTitle": "Promotion request submitted",
|
||||
"promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.",
|
||||
"promotionDuplicateTitle": "Promotion already pending",
|
||||
"promotionDuplicateDescription": "This version already has a pending promotion request.",
|
||||
"promotionErrorTitle": "Failed to submit promotion request",
|
||||
"reportSkill": "Report Skill",
|
||||
"reportDialogTitle": "Report skill",
|
||||
"reportDialogDescription": "Provide a reason so administrators can review and act on it quickly.",
|
||||
|
|
|
|||
|
|
@ -256,6 +256,14 @@
|
|||
"withdrawSuccessTitle": "已撤销上传",
|
||||
"withdrawSuccessDescription": "“{{skill}}”的待审核版本已撤销。",
|
||||
"withdrawErrorTitle": "撤销上传失败",
|
||||
"promoteToGlobal": "申请提升到全局",
|
||||
"promotionConfirmTitle": "确认提交提升申请",
|
||||
"promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?",
|
||||
"promotionSuccessTitle": "提升申请已提交",
|
||||
"promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。",
|
||||
"promotionDuplicateTitle": "已存在待处理提升申请",
|
||||
"promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。",
|
||||
"promotionErrorTitle": "提交提升申请失败",
|
||||
"emptyTitle": "还没有技能",
|
||||
"emptyDescription": "开始发布你的第一个技能吧",
|
||||
"publishSkill": "发布技能"
|
||||
|
|
@ -568,6 +576,16 @@
|
|||
"rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。",
|
||||
"rereleaseErrorTitle": "重新发布版本失败",
|
||||
"yankVersion": "撤回当前版本",
|
||||
"promoteToGlobal": "申请提升到全局",
|
||||
"promotionSectionTitle": "提升到全局",
|
||||
"promotionSectionDescription": "将当前已发布版本 v{{version}} 提交到全局空间审核。",
|
||||
"promotionConfirmTitle": "确认提交提升申请",
|
||||
"promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?",
|
||||
"promotionSuccessTitle": "提升申请已提交",
|
||||
"promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。",
|
||||
"promotionDuplicateTitle": "已存在待处理提升申请",
|
||||
"promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。",
|
||||
"promotionErrorTitle": "提交提升申请失败",
|
||||
"reportSkill": "举报技能",
|
||||
"reportDialogTitle": "举报技能",
|
||||
"reportDialogDescription": "请填写举报原因,帮助管理员快速判断和处理。",
|
||||
|
|
|
|||
|
|
@ -6,9 +6,20 @@ import { Card } from '@/shared/ui/card'
|
|||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { useArchiveSkill, useMySkills, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
|
||||
import { useArchiveSkill, useMySkills, useSubmitPromotion, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
function isDuplicatePromotionMessage(message?: string): boolean {
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
|
||||
return message.includes('promotion.duplicate_pending')
|
||||
|| message.includes('已有待处理的提升申请')
|
||||
|| message.includes('Duplicate pending promotion')
|
||||
}
|
||||
|
||||
export function MySkillsPage() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -16,10 +27,12 @@ export function MySkillsPage() {
|
|||
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null)
|
||||
const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null)
|
||||
const { data: skills, isLoading } = useMySkills()
|
||||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
const withdrawMutation = useWithdrawSkillReview()
|
||||
const submitPromotionMutation = useSubmitPromotion()
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({ to: `/space/${namespace}/${slug}` })
|
||||
|
|
@ -112,6 +125,30 @@ export function MySkillsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleSubmitPromotion = async () => {
|
||||
if (!promotionTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await submitPromotionMutation.mutateAsync({
|
||||
sourceSkillId: promotionTarget.skillId,
|
||||
sourceVersionId: promotionTarget.versionId,
|
||||
})
|
||||
toast.success(
|
||||
t('mySkills.promotionSuccessTitle'),
|
||||
t('mySkills.promotionSuccessDescription', { skill: promotionTarget.name, version: promotionTarget.version }),
|
||||
)
|
||||
setPromotionTarget(null)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) {
|
||||
toast.error(t('mySkills.promotionDuplicateTitle'), t('mySkills.promotionDuplicateDescription'))
|
||||
return
|
||||
}
|
||||
toast.error(t('mySkills.promotionErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -194,6 +231,22 @@ export function MySkillsPage() {
|
|||
>
|
||||
{t('mySkills.withdrawReview')}
|
||||
</Button>
|
||||
) : skill.canSubmitPromotion && skill.latestVersionId && skill.latestVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setPromotionTarget({
|
||||
skillId: skill.id,
|
||||
versionId: skill.latestVersionId!,
|
||||
name: skill.displayName,
|
||||
version: skill.latestVersion!,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('mySkills.promoteToGlobal')}
|
||||
</Button>
|
||||
) : skill.status === 'ARCHIVED' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -245,6 +298,19 @@ export function MySkillsPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!promotionTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPromotionTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.promotionConfirmTitle')}
|
||||
description={promotionTarget ? t('mySkills.promotionConfirmDescription', { skill: promotionTarget.name, version: promotionTarget.version }) : ''}
|
||||
confirmText={t('mySkills.promoteToGlobal')}
|
||||
onConfirm={handleSubmitPromotion}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { InstallCommand } from '@/features/skill/install-command'
|
|||
import { RatingInput } from '@/features/social/rating-input'
|
||||
import { StarButton } from '@/features/social/star-button'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { adminApi, WEB_API_PREFIX } from '@/api/client'
|
||||
import { adminApi, ApiError, WEB_API_PREFIX } from '@/api/client'
|
||||
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
useArchiveSkill,
|
||||
useDeleteSkillVersion,
|
||||
useRereleaseSkillVersion,
|
||||
useSubmitPromotion,
|
||||
useUnarchiveSkill,
|
||||
useWithdrawSkillReview,
|
||||
} from '@/shared/hooks/use-skill-queries'
|
||||
|
|
@ -56,6 +57,16 @@ function parseMetadataJson(parsed?: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function isDuplicatePromotionMessage(message?: string): boolean {
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
|
||||
return message.includes('promotion.duplicate_pending')
|
||||
|| message.includes('已有待处理的提升申请')
|
||||
|| message.includes('Duplicate pending promotion')
|
||||
}
|
||||
|
||||
export function SkillDetailPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -66,6 +77,7 @@ export function SkillDetailPage() {
|
|||
const [reportDetails, setReportDetails] = useState('')
|
||||
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
|
||||
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
|
||||
const [promotionConfirmOpen, setPromotionConfirmOpen] = useState(false)
|
||||
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
|
||||
const [withdrawVersionTarget, setWithdrawVersionTarget] = useState<string | null>(null)
|
||||
const [rereleaseTarget, setRereleaseTarget] = useState<string | null>(null)
|
||||
|
|
@ -116,6 +128,7 @@ export function SkillDetailPage() {
|
|||
const deleteVersionMutation = useDeleteSkillVersion()
|
||||
const withdrawReviewMutation = useWithdrawSkillReview()
|
||||
const rereleaseVersionMutation = useRereleaseSkillVersion()
|
||||
const submitPromotionMutation = useSubmitPromotion()
|
||||
const reportMutation = useSubmitSkillReport(namespace, slug)
|
||||
|
||||
const handleDownload = () => {
|
||||
|
|
@ -331,6 +344,30 @@ export function SkillDetailPage() {
|
|||
setDiffCompareVersion(compareVersion)
|
||||
}
|
||||
|
||||
const handleSubmitPromotion = async () => {
|
||||
if (!skill?.latestVersionId) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await submitPromotionMutation.mutateAsync({
|
||||
sourceSkillId: skill.id,
|
||||
sourceVersionId: skill.latestVersionId,
|
||||
})
|
||||
toast.success(
|
||||
t('skillDetail.promotionSuccessTitle'),
|
||||
t('skillDetail.promotionSuccessDescription', { skill: skill.displayName, version: skill.latestVersion ?? '' }),
|
||||
)
|
||||
setPromotionConfirmOpen(false)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) {
|
||||
toast.error(t('skillDetail.promotionDuplicateTitle'), t('skillDetail.promotionDuplicateDescription'))
|
||||
return
|
||||
}
|
||||
toast.error(t('skillDetail.promotionErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingSkill) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
|
|
@ -626,6 +663,18 @@ export function SkillDetailPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{skill.canSubmitPromotion && skill.latestVersion && skill.latestVersionId && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.promotionSectionTitle')}</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('skillDetail.promotionSectionDescription', { version: skill.latestVersion })}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setPromotionConfirmOpen(true)} disabled={submitPromotionMutation.isPending}>
|
||||
{submitPromotionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.promoteToGlobal')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{governanceVisible && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.governance')}</div>
|
||||
|
|
@ -680,6 +729,18 @@ export function SkillDetailPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={promotionConfirmOpen}
|
||||
onOpenChange={setPromotionConfirmOpen}
|
||||
title={t('skillDetail.promotionConfirmTitle')}
|
||||
description={t('skillDetail.promotionConfirmDescription', {
|
||||
skill: skill.displayName,
|
||||
version: skill.latestVersion ?? '',
|
||||
})}
|
||||
confirmText={t('skillDetail.promoteToGlobal')}
|
||||
onConfirm={handleSubmitPromotion}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={archiveConfirmOpen}
|
||||
onOpenChange={setArchiveConfirmOpen}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole } from '@/api/types'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, promotionApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
|
||||
|
||||
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
|
|
@ -72,6 +72,15 @@ async function getNamespaceMembers(slug: string): Promise<NamespaceMember[]> {
|
|||
return namespaceApi.listMembers(slug)
|
||||
}
|
||||
|
||||
async function submitPromotion(params: { sourceSkillId: number; sourceVersionId: number }): Promise<void> {
|
||||
const globalNamespace = await namespaceApi.getDetail('global')
|
||||
await promotionApi.submit({
|
||||
sourceSkillId: params.sourceSkillId,
|
||||
sourceVersionId: params.sourceVersionId,
|
||||
targetNamespaceId: globalNamespace.id,
|
||||
})
|
||||
}
|
||||
|
||||
async function searchNamespaceMemberCandidates(params: { slug: string; search: string }): Promise<NamespaceCandidateUser[]> {
|
||||
return namespaceApi.searchMemberCandidates(params.slug, params.search)
|
||||
}
|
||||
|
|
@ -210,6 +219,19 @@ export function useNamespaceMemberCandidates(slug: string, search: string, enabl
|
|||
})
|
||||
}
|
||||
|
||||
export function useSubmitPromotion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: submitPromotion,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['promotions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['governance'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function invalidateNamespaceQueries(queryClient: ReturnType<typeof useQueryClient>, slug: string) {
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', slug] })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue