feat(my-skills): add keyword search, namespace filter and clickable pagination

Add comprehensive filtering and search capabilities to the My Skills page:

- Keyword search: search by skill name, slug, or description
- Namespace filter: filter skills by namespace
- Clickable pagination: page number buttons with smart ellipsis
- State preservation: sync search state to URL, restore when returning from detail page
- Debounced search: 300ms debounce to avoid excessive queries
- Fix: hide stale rejected preview badge when newer version is published

Backend changes:
- MySkillAppService: add keyword and namespace filtering logic
- SkillLifecycleProjectionService: only show preview versions newer than published
- MeController: add keyword and namespace query parameters
- 6 new test cases covering search and filter scenarios

Frontend changes:
- my-skills.tsx: search input, namespace dropdown, URL state sync
- pagination.tsx: clickable page numbers with ellipsis
- use-user-queries.ts: prevent flicker on query transitions
- skill-detail.tsx: remove invalid rejected badge display
- router.tsx: URL parameter validation
- i18n: add search-related translation keys

Synced from SAAS commits:
- 939fa749 (feat: search and filters)
- dc14df6c (fix: search flicker)
- 0168ea81 (fix: rejected badge)
- c9eefa93 (fix: stale preview)

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-06-05 15:59:50 +08:00
parent 1ec93db0d6
commit 2fc200a00b
13 changed files with 438 additions and 81 deletions

View file

@ -34,6 +34,8 @@ public class MeController extends BaseApiController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String filter,
@RequestParam(required = false) String q,
@RequestParam(required = false) String namespace,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
@ -41,7 +43,7 @@ public class MeController extends BaseApiController {
return ok(
"response.success.read",
mySkillAppService.listMySkills(principal.userId(), page, size, filter, principal.platformRoles())
mySkillAppService.listMySkills(principal.userId(), page, size, filter, q, namespace, principal.platformRoles())
);
}

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
@ -36,6 +38,7 @@ public class MySkillAppService {
private final SkillSubscriptionRepository skillSubscriptionRepository;
private final MySkillQueryRepository mySkillQueryRepository;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
private final NamespaceRepository namespaceRepository;
public MySkillAppService(
SkillRepository skillRepository,
@ -43,17 +46,19 @@ public class MySkillAppService {
SkillStarRepository skillStarRepository,
SkillSubscriptionRepository skillSubscriptionRepository,
MySkillQueryRepository mySkillQueryRepository,
SkillLifecycleProjectionService skillLifecycleProjectionService) {
SkillLifecycleProjectionService skillLifecycleProjectionService,
NamespaceRepository namespaceRepository) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillStarRepository = skillStarRepository;
this.skillSubscriptionRepository = skillSubscriptionRepository;
this.mySkillQueryRepository = mySkillQueryRepository;
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
this.namespaceRepository = namespaceRepository;
}
public PageResponse<SkillSummaryResponse> listMySkills(String userId, int page, int size) {
return listMySkills(userId, page, size, null, java.util.Set.of());
return listMySkills(userId, page, size, null, null, null, java.util.Set.of());
}
public PageResponse<SkillSummaryResponse> listMySkills(String userId,
@ -61,10 +66,27 @@ public class MySkillAppService {
int size,
String filter,
java.util.Set<String> platformRoles) {
return listMySkills(userId, page, size, filter, null, null, platformRoles);
}
public PageResponse<SkillSummaryResponse> listMySkills(String userId,
int page,
int size,
String filter,
String keyword,
String namespace,
java.util.Set<String> platformRoles) {
MySkillFilter normalizedFilter = parseFilter(filter);
Page<Skill> skillPage = normalizedFilter == MySkillFilter.ALL
? skillRepository.findByOwnerId(userId, PageRequest.of(page, size))
: filterSkillsByLifecycle(userId, page, size, normalizedFilter, platformRoles);
Page<Skill> skillPage;
if (normalizedFilter == MySkillFilter.ALL
&& (keyword == null || keyword.isBlank())
&& (namespace == null || namespace.isBlank())) {
skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size));
} else {
skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles);
}
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId);
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
@ -118,15 +140,34 @@ public class MySkillAppService {
return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize());
}
private Page<Skill> filterSkillsByLifecycle(String userId,
int page,
int size,
MySkillFilter filter,
java.util.Set<String> platformRoles) {
private Page<Skill> filterSkills(String userId,
int page,
int size,
MySkillFilter filter,
String keyword,
String namespace,
java.util.Set<String> platformRoles) {
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);
}
final Long finalNamespaceId = namespaceId;
String normalizedKeyword = keyword != null && !keyword.isBlank()
? keyword.trim().toLowerCase(java.util.Locale.ROOT)
: null;
List<Skill> filtered = skills.stream()
.filter(skill -> matchesNamespace(skill, finalNamespaceId))
.filter(skill -> matchesKeyword(skill, normalizedKeyword))
.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<>(
@ -136,6 +177,35 @@ public class MySkillAppService {
);
}
private boolean matchesNamespace(Skill skill, Long namespaceId) {
if (namespaceId == null) {
return true;
}
if (namespaceId == -1L) {
return false;
}
return skill.getNamespaceId().equals(namespaceId);
}
private boolean matchesKeyword(Skill skill, String keyword) {
if (keyword == null) {
return true;
}
String displayName = skill.getDisplayName() != null ? skill.getDisplayName().toLowerCase(java.util.Locale.ROOT) : "";
String slug = skill.getSlug() != null ? skill.getSlug().toLowerCase(java.util.Locale.ROOT) : "";
String summary = skill.getSummary() != null ? skill.getSummary().toLowerCase(java.util.Locale.ROOT) : "";
return displayName.contains(keyword) || slug.contains(keyword) || summary.contains(keyword);
}
private Page<Skill> filterSkillsByLifecycle(String userId,
int page,
int size,
MySkillFilter filter,
java.util.Set<String> platformRoles) {
return filterSkills(userId, page, size, filter, null, null, platformRoles);
}
private boolean matchesFilter(Skill skill, MySkillFilter filter, java.util.Set<String> platformRoles) {
if (filter == MySkillFilter.HIDDEN) {
return platformRoles.contains("SUPER_ADMIN") && skill.isHidden();

View file

@ -75,7 +75,8 @@ class MySkillAppServiceTest {
skillStarRepository,
skillSubscriptionRepository,
mySkillQueryRepository,
skillLifecycleProjectionService
skillLifecycleProjectionService,
namespaceRepository
);
}
@ -265,6 +266,107 @@ class MySkillAppServiceTest {
assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("REJECTED");
}
@Test
void listMySkills_hidesStaleRejectedVersionOlderThanPublished() {
Skill skill = createSkill(6L, 101L, "recovered-skill", "user-1");
SkillVersion rejectedVersion = createVersion(6L, 60L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z");
SkillVersion publishedVersion = createVersion(6L, 61L, "2.0.0", SkillVersionStatus.PUBLISHED, "2026-03-16T09:30:00Z");
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
var result = service.listMySkills("user-1", 0, 10);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("PUBLISHED");
assertThat(result.items().get(0).headlineVersion().version()).isEqualTo("2.0.0");
assertThat(result.items().get(0).ownerPreviewVersion()).isNull();
}
@Test
void listMySkills_filtersByKeywordAcrossDisplayNameSlugAndSummary() {
Skill alpha = createSkill(1L, 101L, "alpha-tool", "user-1");
alpha.setDisplayName("Alpha Assistant");
Skill beta = createSkill(2L, 101L, "beta-tool", "user-1");
beta.setDisplayName("Beta Tool");
beta.setSummary("This tool helps with alpha testing");
Skill gamma = createSkill(3L, 101L, "gamma-tool", "user-1");
gamma.setDisplayName("Gamma Service");
SkillVersion publishedVersion = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(alpha, beta, gamma));
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(publishedVersion));
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of());
given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of());
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
var result = service.listMySkills("user-1", 0, 10, null, "alpha", null, Set.of("USER"));
assertThat(result.total()).isEqualTo(2);
assertThat(result.items()).extracting("slug")
.containsExactlyInAnyOrder("alpha-tool", "beta-tool");
}
@Test
void listMySkills_filtersByNamespaceSlug() {
Skill aiSkill = createSkill(1L, 101L, "ai-tool", "user-1");
Skill mlSkill = createSkill(2L, 102L, "ml-tool", "user-1");
SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiSkill, mlSkill));
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1));
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(v2));
given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai")));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
var result = service.listMySkills("user-1", 0, 10, null, null, "team-ai", Set.of("USER"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.items()).extracting("slug").containsExactly("ai-tool");
}
@Test
void listMySkills_returnsEmptyWhenNamespaceSlugNotFound() {
Skill skill = createSkill(1L, 101L, "ai-tool", "user-1");
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill));
given(namespaceRepository.findBySlug("missing-namespace")).willReturn(java.util.Optional.empty());
var result = service.listMySkills("user-1", 0, 10, null, null, "missing-namespace", Set.of("USER"));
assertThat(result.total()).isZero();
assertThat(result.items()).isEmpty();
}
@Test
void listMySkills_combinesKeywordNamespaceAndStatusFilters() {
Skill aiAlpha = createSkill(1L, 101L, "ai-alpha", "user-1");
aiAlpha.setDisplayName("AI Alpha");
Skill aiBeta = createSkill(2L, 101L, "ai-beta", "user-1");
aiBeta.setDisplayName("AI Beta");
Skill mlAlpha = createSkill(3L, 102L, "ml-alpha", "user-1");
mlAlpha.setDisplayName("ML Alpha");
SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z");
SkillVersion v3 = createVersion(3L, 30L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z");
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiAlpha, aiBeta, mlAlpha));
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1));
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(v2));
given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of(v3));
given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai")));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
var result = service.listMySkills("user-1", 0, 10, "PUBLISHED", "alpha", "team-ai", Set.of("USER"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.items()).extracting("slug").containsExactly("ai-alpha");
}
private Skill createSkill(Long id, Long namespaceId, String slug, String ownerId) {
Skill skill = new Skill(namespaceId, slug, ownerId, SkillVisibility.PUBLIC);
skill.setDisplayName(slug);

View file

@ -39,6 +39,10 @@ public class SkillLifecycleProjectionService {
ResolutionMode resolutionMode
) {}
private static final Comparator<SkillVersion> RECENCY = Comparator
.comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder()));
private final SkillVersionRepository skillVersionRepository;
public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) {
@ -46,22 +50,26 @@ public class SkillLifecycleProjectionService {
}
public Projection projectForViewer(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill));
VersionProjection ownerPreviewVersion = toProjection(resolveOwnerPendingPreview(skill, currentUserId, userNsRoles));
VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion;
ResolutionMode resolutionMode = headlineVersion == null
? ResolutionMode.NONE
: publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW;
return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode);
SkillVersion published = resolvePublishedVersion(skill);
SkillVersion preview = canManage(skill, currentUserId, userNsRoles)
? resolveNewerNonPublishedVersion(skill, published)
: null;
return buildProjection(published, preview);
}
public Projection projectForOwnerSummary(Skill skill) {
VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill));
VersionProjection ownerPreviewVersion = toProjection(resolveNewestNonPublishedVersion(skill));
SkillVersion published = resolvePublishedVersion(skill);
SkillVersion preview = resolveNewerNonPublishedVersion(skill, published);
return buildProjection(published, preview);
}
private Projection buildProjection(SkillVersion published, SkillVersion preview) {
VersionProjection publishedVersion = toProjection(published);
VersionProjection ownerPreviewVersion = toProjection(preview);
VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion;
ResolutionMode resolutionMode = headlineVersion == null
? ResolutionMode.NONE
: publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW;
ResolutionMode resolutionMode = headlineVersion == null ? ResolutionMode.NONE
: publishedVersion != null ? ResolutionMode.PUBLISHED
: ResolutionMode.OWNER_PREVIEW;
return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode);
}
@ -116,27 +124,19 @@ public class SkillLifecycleProjectionService {
}
/**
* Returns the newest non-published version the owner can preview.
* Includes PENDING_REVIEW, REJECTED, DRAFT, SCANNING, SCAN_FAILED any status
* that isn't already covered by the published projection and isn't yanked.
* Returns the newest non-published version (PENDING_REVIEW, REJECTED, DRAFT, SCANNING,
* SCAN_FAILED) that represents a NEW round of work layered on top of the current published
* version. A non-published version that is older than the published version is treated as
* settled history (e.g. an early rejected attempt later superseded by a published release)
* and is intentionally not surfaced, so the owner does not see a stale preview/rejected badge
* next to an already-published skill.
*/
private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (!canManage(skill, currentUserId, userNsRoles)) {
return null;
}
private SkillVersion resolveNewerNonPublishedVersion(Skill skill, SkillVersion publishedVersion) {
return skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(v -> v.getStatus() != SkillVersionStatus.PUBLISHED
&& v.getStatus() != SkillVersionStatus.YANKED)
.max(versionComparator())
.orElse(null);
}
private SkillVersion resolveNewestNonPublishedVersion(Skill skill) {
List<SkillVersion> versions = skillVersionRepository.findBySkillId(skill.getId());
return versions.stream()
.filter(version -> version.getStatus() != SkillVersionStatus.PUBLISHED
&& version.getStatus() != SkillVersionStatus.YANKED)
.max(versionComparator())
.filter(version -> publishedVersion == null || RECENCY.compare(version, publishedVersion) > 0)
.max(RECENCY)
.orElse(null);
}

View file

@ -1024,13 +1024,19 @@ export const governanceApi = {
}
export const meApi = {
async getSkills(params?: { page?: number; size?: number; filter?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
async getSkills(params?: { page?: number; size?: number; filter?: string; q?: string; namespace?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params?.page ?? 0))
searchParams.set('size', String(params?.size ?? 10))
if (params?.filter) {
searchParams.set('filter', params.filter)
}
if (params?.q) {
searchParams.set('q', params.q)
}
if (params?.namespace) {
searchParams.set('namespace', params.namespace)
}
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`)
},

View file

@ -253,6 +253,12 @@ const dashboardSkillsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/skills',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): { page?: number; q?: string; namespace?: string; filter?: string } => ({
page: typeof search.page === 'number' ? search.page : undefined,
q: typeof search.q === 'string' && search.q ? search.q : undefined,
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined,
filter: typeof search.filter === 'string' && search.filter ? search.filter : undefined,
}),
component: MySkillsPage,
})

View file

@ -339,6 +339,12 @@
"mySkills": {
"title": "My Skills",
"subtitle": "Manage your published skills",
"searchPlaceholder": "Search by name, slug, or description",
"namespaceFilterLabel": "Filter by namespace",
"namespaceFilterAll": "All namespaces",
"clearSearch": "Clear filters",
"emptySearchTitle": "No matching skills",
"emptySearchDescription": "Try adjusting your keyword or switching namespace.",
"filters": {
"ALL": "All",
"PENDING_REVIEW": "Pending Review",
@ -1277,7 +1283,8 @@
"prev": "Previous",
"next": "Next",
"pagePrefix": "Page",
"pageSuffix": ""
"pageSuffix": "",
"goToPage": "Go to page {{page}}"
},
"user": {
"menu": {

View file

@ -339,6 +339,12 @@
"mySkills": {
"title": "我的技能",
"subtitle": "管理你发布的技能",
"searchPlaceholder": "搜索技能名称、Slug 或描述",
"namespaceFilterLabel": "按命名空间过滤",
"namespaceFilterAll": "全部命名空间",
"clearSearch": "清除筛选",
"emptySearchTitle": "未找到匹配的技能",
"emptySearchDescription": "试试调整关键字或切换命名空间",
"filters": {
"ALL": "全部",
"PENDING_REVIEW": "待审核",
@ -1278,7 +1284,8 @@
"prev": "上一页",
"next": "下一页",
"pagePrefix": "第",
"pageSuffix": "页"
"pageSuffix": "页",
"goToPage": "第 {{page}} 页"
},
"user": {
"menu": {

View file

@ -1,22 +1,28 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { EmptyState } from '@/shared/components/empty-state'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
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 { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle'
import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
import { buildReturnTo } from '@/shared/lib/auth-route'
import { ApiError } from '@/api/client'
import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters'
const PAGE_SIZE = 10
const ALL_NAMESPACES_VALUE = '__all_namespaces__'
/**
* Dashboard page for skills owned by the current user.
@ -36,18 +42,62 @@ function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending'
export function MySkillsPage() {
const navigate = useNavigate()
const location = useLocation()
const search = useSearch({ from: '/dashboard/skills' })
const { t } = useTranslation()
const { hasRole } = useAuth()
const [page, setPage] = useState(0)
const [filter, setFilter] = useState<MySkillFilter>('ALL')
// The URL is the source of truth for page / filter / namespace / keyword so the
// search context survives navigating into a skill and back via the returnTo link.
const page = search.page ?? 0
const filter = (search.filter as MySkillFilter) ?? 'ALL'
const namespaceFilter = search.namespace ?? ''
const keyword = search.q ?? ''
// Keep an instant-feedback copy of the keyword input, debounced before it is
// pushed to the URL so each keystroke does not create a history entry or query.
const [keywordInput, setKeywordInput] = useState(keyword)
const debouncedKeyword = useDebounce(keywordInput.trim(), 300)
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: skillPage, isLoading } = useMySkills({ page, size: PAGE_SIZE, filter: filter === 'ALL' ? undefined : filter })
const updateSearch = (next: Partial<typeof search>, options?: { replace?: boolean }) => {
navigate({
to: '/dashboard/skills',
search: (prev) => ({ ...prev, ...next }),
replace: options?.replace,
})
}
// Push the debounced keyword to the URL (reset page to 0 when search changes)
useEffect(() => {
if (debouncedKeyword !== keyword) {
updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true })
}
}, [debouncedKeyword])
// Sync keywordInput when navigating back via returnTo
useEffect(() => {
setKeywordInput(keyword)
}, [keyword])
const { data: skillPage, isLoading } = useMySkills({
page,
size: PAGE_SIZE,
filter: filter === 'ALL' ? undefined : filter,
q: keyword || undefined,
namespace: namespaceFilter || undefined,
})
const { data: namespacesPage } = useMyNamespaces({ page: 0, size: 100 })
const namespaceOptions = namespacesPage?.items ?? []
const skills = skillPage?.items ?? []
const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1
const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN'))
const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== ''
const emptyStateKey = getMySkillEmptyStateKey(filter)
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
@ -57,10 +107,15 @@ export function MySkillsPage() {
const handleSkillClick = (namespace: string, slug: string) => {
navigate({
to: `/space/${namespace}/${encodeURIComponent(slug)}`,
search: { returnTo: '/dashboard/skills' },
search: { returnTo: buildReturnTo(location) },
})
}
const handleClearSearch = () => {
setKeywordInput('')
updateSearch({ q: undefined, namespace: undefined, page: 0 })
}
const handleUpdateSkill = (namespace: string, visibility?: string) => {
navigate({
to: '/dashboard/publish',
@ -238,21 +293,61 @@ export function MySkillsPage() {
)}
/>
<div className="flex flex-wrap gap-2">
{availableFilters.map((option) => (
<Button
key={option}
type="button"
size="sm"
variant={filter === option ? 'default' : 'outline'}
onClick={() => {
setFilter(option)
setPage(0)
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
type="search"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
placeholder={t('mySkills.searchPlaceholder')}
aria-label={t('mySkills.searchPlaceholder')}
className="sm:max-w-md"
/>
<Select
value={namespaceFilter || ALL_NAMESPACES_VALUE}
onValueChange={(value) => {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
{t(`mySkills.filters.${option}`)}
</Button>
))}
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
{namespaceOptions.map((ns) => (
<SelectItem key={ns.id} value={ns.slug}>
@{ns.slug}
</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveSearch ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleClearSearch}
>
{t('mySkills.clearSearch')}
</Button>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{availableFilters.map((option) => (
<Button
key={option}
type="button"
size="sm"
variant={filter === option ? 'default' : 'outline'}
onClick={() => {
updateSearch({ filter: option === 'ALL' ? undefined : option, page: 0 })
}}
>
{t(`mySkills.filters.${option}`)}
</Button>
))}
</div>
</div>
{skillPage && skillPage.total > 0 ? (
@ -400,17 +495,23 @@ export function MySkillsPage() {
</div>
{skillPage.total > PAGE_SIZE ? (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
<Pagination page={page} totalPages={totalPages} onPageChange={(next) => updateSearch({ page: next })} />
) : null}
</>
) : (
<EmptyState
title={t(emptyStateKey.title)}
description={t(emptyStateKey.description)}
title={hasActiveSearch ? t('mySkills.emptySearchTitle') : t(emptyStateKey.title)}
description={hasActiveSearch ? t('mySkills.emptySearchDescription') : t(emptyStateKey.description)}
action={
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('mySkills.publishSkill')}
</Button>
hasActiveSearch ? (
<Button size="lg" variant="outline" onClick={handleClearSearch}>
{t('mySkills.clearSearch')}
</Button>
) : (
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('mySkills.publishSkill')}
</Button>
)
}
/>
)}

View file

@ -186,7 +186,7 @@ export function SearchPage() {
}
const handleSkillClick = (namespace: string, slug: string) => {
navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` })
navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, search: { returnTo: `${window.location.pathname}${window.location.search}` } })
}
const filteredStarredSkills = starredOnly

View file

@ -172,7 +172,6 @@ export function SkillDetailPage() {
&& ['PENDING_REVIEW', 'SCANNING', 'SCAN_FAILED'].includes(headlineVersion?.status ?? '')
const hasPendingOwnerPreview = ownerPreviewVersion?.status === 'PENDING_REVIEW'
const hasRejectedOwnerPreview = ownerPreviewVersion?.status === 'REJECTED'
const hasRejectedVersion = versions?.some((v) => v.status === 'REJECTED') ?? false
const hasPublishedPendingReview = Boolean(publishedVersion && hasPendingOwnerPreview)
const canInteract = skill?.canInteract ?? true
const canReport = skill?.canReport ?? true
@ -762,7 +761,7 @@ export function SkillDetailPage() {
{t('skillDetail.versionStatusPendingReview')}
</span>
)}
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview || hasRejectedVersion) && skill.canManageLifecycle && (
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && (
<span className="badge-soft" style={{ background: '#fee2e2', color: '#991b1b' }}>
{t('skillDetail.rejectedBadge')}
</span>

View file

@ -7,8 +7,43 @@ interface PaginationProps {
onPageChange: (page: number) => void
}
type PageItem = number | 'ellipsis'
/**
* Builds the list of page slots to render. Always shows the first and last page,
* the current page, and one neighbour on each side, collapsing the rest into
* ellipsis markers. Pages are 0-indexed internally; labels are 1-indexed.
*/
function buildPageItems(current: number, totalPages: number): PageItem[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i)
}
const items: PageItem[] = []
const first = 0
const last = totalPages - 1
const start = Math.max(first + 1, current - 1)
const end = Math.min(last - 1, current + 1)
items.push(first)
if (start > first + 1) {
items.push('ellipsis')
}
for (let i = start; i <= end; i += 1) {
items.push(i)
}
if (end < last - 1) {
items.push('ellipsis')
}
items.push(last)
return items
}
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
const { t } = useTranslation()
const pageItems = buildPageItems(page, totalPages)
return (
<div className="flex items-center justify-center gap-3 py-4">
<Button
@ -20,13 +55,34 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
>
{t('pagination.prev')}
</Button>
<div className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-secondary/40 text-sm font-medium text-foreground">
<span className="text-muted-foreground">{t('pagination.pagePrefix')}</span>
<span className="text-primary">{page + 1}</span>
<span className="text-muted-foreground">/</span>
<span>{totalPages}</span>
{t('pagination.pageSuffix') && <span className="text-muted-foreground">{t('pagination.pageSuffix')}</span>}
<div className="flex items-center gap-1.5">
{pageItems.map((item, index) =>
item === 'ellipsis' ? (
<span
key={`ellipsis-${index}`}
className="px-2 text-sm text-muted-foreground select-none"
aria-hidden="true"
>
</span>
) : (
<Button
key={item}
type="button"
variant={item === page ? 'default' : 'ghost'}
size="sm"
onClick={() => onPageChange(item)}
aria-label={t('pagination.goToPage', { page: item + 1 })}
aria-current={item === page ? 'page' : undefined}
className="min-w-[2.25rem] h-9 px-2"
>
{item + 1}
</Button>
),
)}
</div>
<Button
variant="outline"
size="sm"

View file

@ -1,8 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
import type { SkillSummary, PagedResponse } from '@/api/types'
import { meApi, promotionApi, namespaceApi } from '@/api/client'
async function getMySkills(params: { page?: number; size?: number; filter?: string } = {}): Promise<PagedResponse<SkillSummary>> {
async function getMySkills(params: { page?: number; size?: number; filter?: string; q?: string; namespace?: string } = {}): Promise<PagedResponse<SkillSummary>> {
return meApi.getSkills(params)
}
@ -31,10 +31,11 @@ async function submitPromotion(params: { sourceSkillId: number; sourceVersionId:
})
}
export function useMySkills(params: { page?: number; size?: number; filter?: string } = {}) {
export function useMySkills(params: { page?: number; size?: number; filter?: string; q?: string; namespace?: string } = {}) {
return useQuery({
queryKey: ['skills', 'my', params],
queryFn: () => getMySkills(params),
placeholderData: keepPreviousData,
})
}