mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-11 22:51:04 +00:00
fix(governance): restore hidden skill management
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
19cc56be9e
commit
5c5634dd22
11 changed files with 190 additions and 32 deletions
|
|
@ -79,7 +79,9 @@ public class MySkillAppService {
|
|||
MySkillFilter normalizedFilter = parseFilter(filter);
|
||||
|
||||
Page<Skill> skillPage;
|
||||
if (normalizedFilter == MySkillFilter.ALL
|
||||
if (normalizedFilter == MySkillFilter.HIDDEN) {
|
||||
skillPage = listHiddenSkills(page, size, keyword, namespace, platformRoles);
|
||||
} else if (normalizedFilter == MySkillFilter.ALL
|
||||
&& (keyword == null || keyword.isBlank())
|
||||
&& (namespace == null || namespace.isBlank())) {
|
||||
skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size));
|
||||
|
|
@ -92,6 +94,28 @@ public class MySkillAppService {
|
|||
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
|
||||
}
|
||||
|
||||
private Page<Skill> listHiddenSkills(int page,
|
||||
int size,
|
||||
String keyword,
|
||||
String namespace,
|
||||
java.util.Set<String> platformRoles) {
|
||||
PageRequest pageable = PageRequest.of(page, size);
|
||||
if (!platformRoles.contains("SUPER_ADMIN")) {
|
||||
return Page.empty(pageable);
|
||||
}
|
||||
if ((keyword == null || keyword.isBlank()) && (namespace == null || namespace.isBlank())) {
|
||||
return skillRepository.findByHiddenTrue(pageable);
|
||||
}
|
||||
|
||||
Long namespaceId = resolveNamespaceId(namespace);
|
||||
String normalizedKeyword = normalizeKeyword(keyword);
|
||||
List<Skill> filtered = skillRepository.findByHiddenTrue().stream()
|
||||
.filter(skill -> matchesNamespace(skill, namespaceId))
|
||||
.filter(skill -> matchesKeyword(skill, normalizedKeyword))
|
||||
.toList();
|
||||
return page(filtered, page, size);
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size) {
|
||||
Page<com.iflytek.skillhub.domain.social.SkillStar> starPage = skillStarRepository.findByUserId(
|
||||
userId,
|
||||
|
|
@ -150,17 +174,10 @@ public class MySkillAppService {
|
|||
List<Skill> skills = skillRepository.findByOwnerId(userId);
|
||||
|
||||
// Namespace filter
|
||||
Long namespaceId = null;
|
||||
if (namespace != null && !namespace.isBlank()) {
|
||||
namespaceId = namespaceRepository.findBySlug(namespace.trim())
|
||||
.map(Namespace::getId)
|
||||
.orElse(-1L);
|
||||
}
|
||||
Long namespaceId = resolveNamespaceId(namespace);
|
||||
|
||||
final Long finalNamespaceId = namespaceId;
|
||||
String normalizedKeyword = keyword != null && !keyword.isBlank()
|
||||
? keyword.trim().toLowerCase(java.util.Locale.ROOT)
|
||||
: null;
|
||||
String normalizedKeyword = normalizeKeyword(keyword);
|
||||
|
||||
List<Skill> filtered = skills.stream()
|
||||
.filter(skill -> matchesNamespace(skill, finalNamespaceId))
|
||||
|
|
@ -168,13 +185,28 @@ public class MySkillAppService {
|
|||
.filter(skill -> matchesFilter(skill, filter, platformRoles))
|
||||
.toList();
|
||||
|
||||
int fromIndex = Math.min(page * size, filtered.size());
|
||||
int toIndex = Math.min(fromIndex + size, filtered.size());
|
||||
return new PageImpl<>(
|
||||
filtered.subList(fromIndex, toIndex),
|
||||
PageRequest.of(page, size),
|
||||
filtered.size()
|
||||
);
|
||||
return page(filtered, page, size);
|
||||
}
|
||||
|
||||
private Long resolveNamespaceId(String namespace) {
|
||||
if (namespace == null || namespace.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return namespaceRepository.findBySlug(namespace.trim())
|
||||
.map(Namespace::getId)
|
||||
.orElse(-1L);
|
||||
}
|
||||
|
||||
private String normalizeKeyword(String keyword) {
|
||||
return keyword != null && !keyword.isBlank()
|
||||
? keyword.trim().toLowerCase(java.util.Locale.ROOT)
|
||||
: null;
|
||||
}
|
||||
|
||||
private Page<Skill> page(List<Skill> skills, int page, int size) {
|
||||
int fromIndex = Math.min(page * size, skills.size());
|
||||
int toIndex = Math.min(fromIndex + size, skills.size());
|
||||
return new PageImpl<>(skills.subList(fromIndex, toIndex), PageRequest.of(page, size), skills.size());
|
||||
}
|
||||
|
||||
private boolean matchesNamespace(Skill skill, Long namespaceId) {
|
||||
|
|
|
|||
|
|
@ -230,14 +230,12 @@ class MySkillAppServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_filtersHiddenOnlyForSuperAdmins() {
|
||||
Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "user-1");
|
||||
void listMySkills_listsHiddenSkillsAcrossOwnersOnlyForSuperAdmins() {
|
||||
Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "publisher");
|
||||
hiddenSkill.setHidden(true);
|
||||
Skill publishedSkill = createSkill(4L, 101L, "published-skill", "user-1");
|
||||
SkillVersion hiddenVersion = createVersion(3L, 33L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
|
||||
|
||||
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(hiddenSkill, publishedSkill));
|
||||
given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of(hiddenVersion));
|
||||
given(skillRepository.findByHiddenTrue(PageRequest.of(0, 10)))
|
||||
.willReturn(new PageImpl<>(List.of(hiddenSkill), PageRequest.of(0, 10), 1));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
|
||||
|
||||
var regularUserResult = service.listMySkills("user-1", 0, 10, "HIDDEN", Set.of("USER"));
|
||||
|
|
@ -248,6 +246,14 @@ class MySkillAppServiceTest {
|
|||
assertThat(superAdminResult.items()).extracting("slug").containsExactly("hidden-skill");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_doesNotExposeHiddenSkillsToSkillAdmins() {
|
||||
var result = service.listMySkills("skill-admin", 0, 10, "HIDDEN", Set.of("SKILL_ADMIN"));
|
||||
|
||||
assertThat(result.total()).isZero();
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMySkills_exposesRejectedOwnerPreviewInSummary() {
|
||||
Skill skill = createSkill(5L, 101L, "rejected-skill", "user-1");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ public interface SkillRepository {
|
|||
void delete(Skill skill);
|
||||
List<Skill> findByOwnerId(String ownerId);
|
||||
Page<Skill> findByOwnerId(String ownerId, Pageable pageable);
|
||||
List<Skill> findByHiddenTrue();
|
||||
Page<Skill> findByHiddenTrue(Pageable pageable);
|
||||
void incrementDownloadCount(Long skillId);
|
||||
void incrementSubscriptionCount(Long skillId);
|
||||
void decrementSubscriptionCount(Long skillId);
|
||||
|
|
|
|||
|
|
@ -87,6 +87,16 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
|
|||
return delegate.findByOwnerId(ownerId, pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findByHiddenTrue() {
|
||||
return delegate.findByHiddenTrue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<Skill> findByHiddenTrue(Pageable pageable) {
|
||||
return delegate.findByHiddenTrue(pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementDownloadCount(Long skillId) {
|
||||
delegate.incrementDownloadCount(skillId);
|
||||
|
|
|
|||
|
|
@ -34,12 +34,24 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
|
|||
Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
|
||||
List<Skill> findByOwnerId(String ownerId);
|
||||
Page<Skill> findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable);
|
||||
List<Skill> findByHiddenTrueOrderByUpdatedAtDesc();
|
||||
Page<Skill> findByHiddenTrueOrderByUpdatedAtDesc(Pageable pageable);
|
||||
|
||||
@Override
|
||||
default Page<Skill> findByOwnerId(String ownerId, Pageable pageable) {
|
||||
return findByOwnerIdOrderByUpdatedAtDesc(ownerId, pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
default List<Skill> findByHiddenTrue() {
|
||||
return findByHiddenTrueOrderByUpdatedAtDesc();
|
||||
}
|
||||
|
||||
@Override
|
||||
default Page<Skill> findByHiddenTrue(Pageable pageable) {
|
||||
return findByHiddenTrueOrderByUpdatedAtDesc(pageable);
|
||||
}
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId")
|
||||
|
|
|
|||
13
web/src/features/admin/use-admin-skills.ts
Normal file
13
web/src/features/admin/use-admin-skills.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
export function useRestoreHiddenSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (skillId: number) => adminApi.unhideSkill(skillId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -386,6 +386,12 @@
|
|||
"unarchiveSuccessTitle": "Skill restored",
|
||||
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.",
|
||||
"unarchiveErrorTitle": "Failed to restore skill",
|
||||
"restoreHidden": "Restore visibility",
|
||||
"restoreHiddenConfirmTitle": "Restore hidden skill",
|
||||
"restoreHiddenConfirmDescription": "\"{{skill}}\" will return to ordinary product surfaces.",
|
||||
"restoreHiddenSuccessTitle": "Skill visibility restored",
|
||||
"restoreHiddenSuccessDescription": "\"{{skill}}\" was removed from the hidden skill list.",
|
||||
"restoreHiddenErrorTitle": "Failed to restore hidden skill",
|
||||
"withdrawReview": "Withdraw Review",
|
||||
"withdrawConfirmTitle": "Withdraw upload",
|
||||
"withdrawConfirmDescription": "After withdrawal, \"{{skill}}\" will no longer be reviewed and the pending version will be deleted.",
|
||||
|
|
|
|||
|
|
@ -386,6 +386,12 @@
|
|||
"unarchiveSuccessTitle": "Скилл восстановлен",
|
||||
"unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.",
|
||||
"unarchiveErrorTitle": "Не удалось восстановить скилл",
|
||||
"restoreHidden": "Вернуть видимость",
|
||||
"restoreHiddenConfirmTitle": "Восстановить скрытый скилл",
|
||||
"restoreHiddenConfirmDescription": "«{{skill}}» снова появится в обычных разделах продукта.",
|
||||
"restoreHiddenSuccessTitle": "Видимость скилла восстановлена",
|
||||
"restoreHiddenSuccessDescription": "«{{skill}}» удалён из списка скрытых скиллов.",
|
||||
"restoreHiddenErrorTitle": "Не удалось восстановить скрытый скилл",
|
||||
"withdrawReview": "Отозвать рецензию",
|
||||
"withdrawConfirmTitle": "Отозвать загрузку",
|
||||
"withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.",
|
||||
|
|
|
|||
|
|
@ -386,6 +386,12 @@
|
|||
"unarchiveSuccessTitle": "技能已恢复",
|
||||
"unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。",
|
||||
"unarchiveErrorTitle": "恢复技能失败",
|
||||
"restoreHidden": "恢复显示",
|
||||
"restoreHiddenConfirmTitle": "确认恢复隐藏技能",
|
||||
"restoreHiddenConfirmDescription": "恢复后“{{skill}}”会重新进入普通用户可见的产品入口。",
|
||||
"restoreHiddenSuccessTitle": "技能已恢复显示",
|
||||
"restoreHiddenSuccessDescription": "“{{skill}}”已从隐藏技能列表移除。",
|
||||
"restoreHiddenErrorTitle": "恢复隐藏技能失败",
|
||||
"withdrawReview": "撤销审核",
|
||||
"withdrawConfirmTitle": "确认撤销上传",
|
||||
"withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。",
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
const navigateMock = vi.fn()
|
||||
const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = []
|
||||
const useMySkillsMock = vi.fn()
|
||||
const searchMock: { filter?: string } = {}
|
||||
let isSuperAdmin = false
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useLocation: () => ({ pathname: '/dashboard/skills' }),
|
||||
useSearch: () => ({}),
|
||||
useSearch: () => searchMock,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
|
|
@ -23,7 +25,7 @@ vi.mock('react-i18next', async () => {
|
|||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: () => false }),
|
||||
useAuth: () => ({ hasRole: (role: string) => role === 'SUPER_ADMIN' && isSuperAdmin }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
|
|
@ -79,6 +81,10 @@ vi.mock('@/shared/hooks/use-debounce', () => ({
|
|||
useDebounce: (value: string) => value,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/admin/use-admin-skills', () => ({
|
||||
useRestoreHiddenSkill: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/skill-lifecycle', () => ({
|
||||
getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }),
|
||||
getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }),
|
||||
|
|
@ -114,6 +120,8 @@ describe('MySkillsPage', () => {
|
|||
beforeEach(() => {
|
||||
navigateMock.mockReset()
|
||||
buttonRecords.length = 0
|
||||
delete searchMock.filter
|
||||
isSuperAdmin = false
|
||||
useMySkillsMock.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
|
|
@ -216,6 +224,17 @@ describe('MySkillsPage', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('shows a direct restore action instead of owner actions in the hidden governance view', () => {
|
||||
searchMock.filter = 'HIDDEN'
|
||||
isSuperAdmin = true
|
||||
|
||||
renderToStaticMarkup(createElement(MySkillsPage))
|
||||
|
||||
expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(true)
|
||||
expect(buttonRecords.some((button) => button.label === 'mySkills.update')).toBe(false)
|
||||
expect(buttonRecords.some((button) => button.label === 'mySkills.archive')).toBe(false)
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof MySkillsPage).toBe('function')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/sh
|
|||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries'
|
||||
import { useDebounce } from '@/shared/hooks/use-debounce'
|
||||
import { useRestoreHiddenSkill } from '@/features/admin/use-admin-skills'
|
||||
import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
|
@ -63,6 +64,7 @@ export function MySkillsPage() {
|
|||
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 [restoreHiddenTarget, setRestoreHiddenTarget] = useState<{ skillId: number; name: string } | null>(null)
|
||||
|
||||
const updateSearch = useCallback((next: Partial<typeof search>, options?: { replace?: boolean }) => {
|
||||
navigate({
|
||||
|
|
@ -102,6 +104,7 @@ export function MySkillsPage() {
|
|||
const unarchiveMutation = useUnarchiveSkill()
|
||||
const withdrawMutation = useWithdrawSkillReview()
|
||||
const submitPromotionMutation = useSubmitPromotion()
|
||||
const restoreHiddenMutation = useRestoreHiddenSkill()
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({
|
||||
|
|
@ -270,6 +273,23 @@ export function MySkillsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleRestoreHiddenSkill = async () => {
|
||||
if (!restoreHiddenTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await restoreHiddenMutation.mutateAsync(restoreHiddenTarget.skillId)
|
||||
toast.success(
|
||||
t('mySkills.restoreHiddenSuccessTitle'),
|
||||
t('mySkills.restoreHiddenSuccessDescription', { skill: restoreHiddenTarget.name }),
|
||||
)
|
||||
setRestoreHiddenTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('mySkills.restoreHiddenErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -362,8 +382,8 @@ export function MySkillsPage() {
|
|||
return (
|
||||
<Card
|
||||
key={skill.id}
|
||||
className={`p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
className={`p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)} ${filter === 'HIDDEN' ? '' : 'cursor-pointer'}`}
|
||||
onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
|
|
@ -407,7 +427,18 @@ export function MySkillsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
{skill.status !== 'ARCHIVED' && (
|
||||
{filter === 'HIDDEN' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setRestoreHiddenTarget({ skillId: skill.id, name: skill.displayName })
|
||||
}}
|
||||
>
|
||||
{t('mySkills.restoreHidden')}
|
||||
</Button>
|
||||
) : skill.status !== 'ARCHIVED' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -419,7 +450,7 @@ export function MySkillsPage() {
|
|||
{t('mySkills.update')}
|
||||
</Button>
|
||||
)}
|
||||
{hasPendingPreview && ownerPreviewVersion ? (
|
||||
{filter === 'HIDDEN' ? null : hasPendingPreview && ownerPreviewVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -482,9 +513,11 @@ export function MySkillsPage() {
|
|||
{t('mySkills.archive')}
|
||||
</Button>
|
||||
) : null}
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{filter === 'HIDDEN' ? null : (
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -515,6 +548,19 @@ export function MySkillsPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!restoreHiddenTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRestoreHiddenTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.restoreHiddenConfirmTitle')}
|
||||
description={restoreHiddenTarget ? t('mySkills.restoreHiddenConfirmDescription', { skill: restoreHiddenTarget.name }) : ''}
|
||||
confirmText={t('mySkills.restoreHidden')}
|
||||
onConfirm={handleRestoreHiddenSkill}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!promotionTarget}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue