mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
Merge pull request #482 from iflytek/feature/namespace-search-download
feat(skill): add namespace search and bundle download
This commit is contained in:
commit
d58c934d64
18 changed files with 456 additions and 60 deletions
|
|
@ -73,6 +73,23 @@ class RouteSecurityPolicyRegistryTest {
|
|||
assertTrue(matchedWeb);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizationPolicies_shouldNotDeclareNamespaceBundleDownloadRoutes() {
|
||||
String v1Route = "/api/v1/namespaces/*/skills/" + "download";
|
||||
String webRoute = "/api/web/namespaces/*/skills/" + "download";
|
||||
boolean matchedV1 = registry.authorizationPolicies().stream()
|
||||
.anyMatch(policy -> policy.method() == HttpMethod.GET
|
||||
&& v1Route.equals(policy.pattern()));
|
||||
boolean matchedWeb = registry.authorizationPolicies().stream()
|
||||
.anyMatch(policy -> policy.method() == HttpMethod.GET
|
||||
&& webRoute.equals(policy.pattern()));
|
||||
|
||||
assertFalse(matchedV1);
|
||||
assertFalse(matchedWeb);
|
||||
assertFalse(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/" + "download", Set.of()).allowed());
|
||||
assertFalse(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/" + "download", Set.of()).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiTokenPolicySupportsNativeCliRoutes() {
|
||||
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/auth/whoami", Set.of()).allowed());
|
||||
|
|
|
|||
|
|
@ -168,16 +168,20 @@ public class SkillDownloadService {
|
|||
|
||||
// Only increment download count for PUBLISHED versions
|
||||
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
skillRepository.incrementDownloadCount(skill.getId());
|
||||
skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId());
|
||||
eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId()));
|
||||
recordPublishedDownload(skill, version);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void recordPublishedDownload(Skill skill, SkillVersion version) {
|
||||
skillRepository.incrementDownloadCount(skill.getId());
|
||||
skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId());
|
||||
eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId()));
|
||||
}
|
||||
|
||||
private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) {
|
||||
|
||||
String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId());
|
||||
String storageKey = buildBundleStorageKey(skill, version);
|
||||
|
||||
DownloadResult result;
|
||||
if (objectStorageService.exists(storageKey)) {
|
||||
|
|
@ -204,6 +208,10 @@ public class SkillDownloadService {
|
|||
return result;
|
||||
}
|
||||
|
||||
private String buildBundleStorageKey(Skill skill, SkillVersion version) {
|
||||
return String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId());
|
||||
}
|
||||
|
||||
private DownloadResult buildBundleFromFiles(Skill skill, SkillVersion version) {
|
||||
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId()).stream()
|
||||
.filter(file -> objectStorageService.exists(file.getStorageKey()))
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
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.*;
|
||||
import com.iflytek.skillhub.storage.ObjectMetadata;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
|
|
|
|||
106
web/e2e/namespace-search.spec.ts
Normal file
106
web/e2e/namespace-search.spec.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
|
||||
function waitForSkillSearch(page: Page, options: { namespace?: string; q?: string; sort?: string }) {
|
||||
return page.waitForResponse((response) => {
|
||||
if (!response.ok() || !response.url().includes('/api/web/skills?')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const url = new URL(response.url())
|
||||
const namespace = url.searchParams.get('namespace') ?? ''
|
||||
const query = url.searchParams.get('q') ?? ''
|
||||
const sort = url.searchParams.get('sort') ?? ''
|
||||
|
||||
return namespace === (options.namespace ?? '')
|
||||
&& query === (options.q ?? '')
|
||||
&& (!options.sort || sort === options.sort)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Namespace Search (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
'X-Mock-User-Id': 'local-admin',
|
||||
})
|
||||
})
|
||||
|
||||
test('submits @namespace keyword search and clears the namespace filter', async ({ page }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
try {
|
||||
const namespace = await builder.createNamespace('e2e-pm-search')
|
||||
const otherNamespace = await builder.createNamespace('e2e-dev-search')
|
||||
const namespaceSkill = await builder.publishSkill(namespace.slug, {
|
||||
name: 'roadmap-discovery',
|
||||
description: 'Roadmap planning skill for namespace search regression.',
|
||||
})
|
||||
const otherSkill = await builder.publishSkill(otherNamespace.slug, {
|
||||
name: 'roadmap-backend',
|
||||
description: 'Roadmap planning skill outside the selected namespace.',
|
||||
})
|
||||
await builder.waitForSearchResults('roadmap', [namespaceSkill.slug, otherSkill.slug])
|
||||
|
||||
await page.goto('/search')
|
||||
await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} roadmap`)
|
||||
|
||||
const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'roadmap' })
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
||||
await filteredSearch
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`))
|
||||
await expect(page).toHaveURL(/q=roadmap/)
|
||||
await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible()
|
||||
await expect(page.getByText(`@${otherNamespace.slug}`)).toHaveCount(0)
|
||||
|
||||
await page.goto(`/search?q=roadmap&namespace=${namespace.slug}&sort=downloads&page=1&starredOnly=false`)
|
||||
await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible()
|
||||
|
||||
const unfilteredSearch = waitForSkillSearch(page, { q: 'roadmap', sort: 'downloads' })
|
||||
await page.getByRole('button', { name: `@${namespace.slug}` }).click()
|
||||
await unfilteredSearch
|
||||
|
||||
await expect(page).toHaveURL(/q=roadmap/)
|
||||
await expect(page).toHaveURL(/sort=downloads/)
|
||||
await expect(page).toHaveURL(/page=0/)
|
||||
await expect(page).not.toHaveURL(new RegExp(`namespace=${namespace.slug}`))
|
||||
await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: otherSkill.slug })).toBeVisible()
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('supports a sixty-four character namespace slug in search input', async ({ page }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
try {
|
||||
const namespace = await builder.createNamespace('e2e-namespace-64-slug-search-case-alphaab')
|
||||
expect(namespace.slug).toHaveLength(64)
|
||||
const skill = await builder.publishSkill(namespace.slug, {
|
||||
name: 'boundary-search-agent',
|
||||
description: 'Boundary namespace search regression skill.',
|
||||
})
|
||||
await builder.waitForSearchResult('boundary', skill.slug)
|
||||
|
||||
await page.goto('/search')
|
||||
await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} boundary`)
|
||||
|
||||
const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'boundary' })
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
||||
await filteredSearch
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`))
|
||||
await expect(page).toHaveURL(/q=boundary/)
|
||||
await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: skill.slug })).toBeVisible()
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
115
web/src/api/generated/schema.d.ts
vendored
115
web/src/api/generated/schema.d.ts
vendored
|
|
@ -916,6 +916,38 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/namespaces/{slug}/transfer-ownership": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["transferOwnership"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/namespaces/{slug}/transfer-ownership": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["transferOwnership_1"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/namespaces/{slug}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3685,6 +3717,21 @@ export interface components {
|
|||
/** Format: int64 */
|
||||
targetNamespaceId?: number;
|
||||
};
|
||||
TransferOwnershipRequest: {
|
||||
newOwnerId: string;
|
||||
};
|
||||
ApiResponseMessageResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["MessageResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
MessageResponse: {
|
||||
message?: string;
|
||||
};
|
||||
BatchMemberRequest: {
|
||||
members: components["schemas"]["MemberRequest"][];
|
||||
};
|
||||
|
|
@ -3781,18 +3828,6 @@ export interface components {
|
|||
AuthorizeRequest: {
|
||||
userCode?: string;
|
||||
};
|
||||
ApiResponseMessageResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["MessageResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
MessageResponse: {
|
||||
message?: string;
|
||||
};
|
||||
SessionBootstrapRequest: {
|
||||
provider: string;
|
||||
};
|
||||
|
|
@ -3977,8 +4012,8 @@ export interface components {
|
|||
valid?: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
resolvedSlug?: string | null;
|
||||
resolvedVersion?: string | null;
|
||||
resolvedSlug?: string;
|
||||
resolvedVersion?: string;
|
||||
};
|
||||
UpdateProfileRequest: {
|
||||
displayName?: string;
|
||||
|
|
@ -7035,6 +7070,58 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
transferOwnership: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TransferOwnershipRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseMessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
transferOwnership_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TransferOwnershipRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseMessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
restoreNamespace: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -199,9 +199,10 @@ const searchRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'search',
|
||||
component: SearchPage,
|
||||
validateSearch: (search: Record<string, unknown>): { q: string; label?: string; sort: string; page: number; starredOnly: boolean } => {
|
||||
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => {
|
||||
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,
|
||||
sort: (search.sort as string) || 'newest',
|
||||
page: Number(search.page) || 0,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as mod from './search-bar'
|
|||
|
||||
/**
|
||||
* search-bar.tsx exports the SearchBar component. The component delegates
|
||||
* its max-length constraint to the shared MAX_SEARCH_QUERY_LENGTH constant
|
||||
* its max-length constraint to the shared namespace-aware search input limit
|
||||
* (tested in search-query.test.ts). Controlled/uncontrolled mode logic and
|
||||
* submit/clear handlers are component-internal with no exported helpers.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2, Search, X } from 'lucide-react'
|
||||
import { MAX_SEARCH_QUERY_LENGTH } from '@/shared/lib/search-query'
|
||||
import { MAX_SEARCH_INPUT_LENGTH } from '@/shared/lib/search-query'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching =
|
|||
type="text"
|
||||
value={currentQuery}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
maxLength={MAX_SEARCH_QUERY_LENGTH}
|
||||
maxLength={MAX_SEARCH_INPUT_LENGTH}
|
||||
placeholder={placeholder || t('searchBar.placeholder')}
|
||||
className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@
|
|||
"noStarredResults": "No starred skills found",
|
||||
"noStarredResultsFor": "No starred skills match \"{{q}}\"",
|
||||
"noStarredSkills": "You have not starred any skills yet",
|
||||
"namespaceFilter": "@{{namespace}}",
|
||||
"enterKeyword": "Please enter a search keyword",
|
||||
"results": "{{count}} skills found",
|
||||
"resultCount": "Found <1>{{count}}</1> results",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@
|
|||
"noStarredResults": "未找到已收藏技能",
|
||||
"noStarredResultsFor": "已收藏技能中没有与 \"{{q}}\" 相关的结果",
|
||||
"noStarredSkills": "你还没有收藏任何技能",
|
||||
"namespaceFilter": "@{{namespace}}",
|
||||
"enterKeyword": "请输入搜索关键词",
|
||||
"results": "找到 {{count}} 个技能",
|
||||
"resultCount": "找到 <1>{{count}}</1> 个结果",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
|
|
@ -64,20 +64,20 @@ export function MySkillsPage() {
|
|||
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 updateSearch = (next: Partial<typeof search>, options?: { replace?: boolean }) => {
|
||||
const updateSearch = useCallback((next: Partial<typeof search>, options?: { replace?: boolean }) => {
|
||||
navigate({
|
||||
to: '/dashboard/skills',
|
||||
search: (prev) => ({ ...prev, ...next }),
|
||||
replace: options?.replace,
|
||||
})
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
// 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])
|
||||
}, [debouncedKeyword, keyword, updateSearch])
|
||||
|
||||
// Sync keywordInput when navigating back via returnTo
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const buttonRecords: Array<{ label: string }> = []
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
|
|
@ -23,6 +26,14 @@ vi.mock('@/features/skill/skill-card', () => ({
|
|||
SkillCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children?: ReactNode }) => {
|
||||
const label = Array.isArray(children) ? children.join('') : String(children ?? '')
|
||||
buttonRecords.push({ label })
|
||||
return <button>{children}</button>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/skeleton-loader', () => ({
|
||||
SkeletonList: () => null,
|
||||
}))
|
||||
|
|
@ -38,7 +49,26 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
|||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSearchSkills: () => ({
|
||||
data: { items: [] },
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
displayName: 'Demo Skill',
|
||||
summary: 'summary',
|
||||
namespace: 'global',
|
||||
slug: 'demo',
|
||||
downloadCount: 1,
|
||||
starCount: 1,
|
||||
ratingCount: 0,
|
||||
updatedAt: '2026-03-20T00:00:00Z',
|
||||
canSubmitPromotion: false,
|
||||
publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
|
@ -47,6 +77,14 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
|||
import { NamespacePage } from './namespace'
|
||||
|
||||
describe('NamespacePage', () => {
|
||||
beforeEach(() => {
|
||||
buttonRecords.length = 0
|
||||
useNamespaceDetailMock.mockReturnValue({
|
||||
data: { id: 1, slug: 'global', displayName: 'Global', type: 'GLOBAL', status: 'ACTIVE' },
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof NamespacePage).toBe('function')
|
||||
})
|
||||
|
|
@ -60,4 +98,11 @@ describe('NamespacePage', () => {
|
|||
const html = renderToStaticMarkup(<NamespacePage />)
|
||||
expect(html).toContain('namespace.notFound')
|
||||
})
|
||||
|
||||
it('does not render namespace distribution controls when skills are available', () => {
|
||||
const html = renderToStaticMarkup(<NamespacePage />)
|
||||
|
||||
expect(buttonRecords).toHaveLength(0)
|
||||
expect(html).not.toContain('type="checkbox"')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function NamespacePage() {
|
|||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{skillsData.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
|
||||
<div key={skill.id} className={`relative animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.slug)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const navigateMock = vi.fn()
|
|||
const useSearchMock = vi.fn()
|
||||
const buttonRecords: Array<{ label: string; variant?: string | null; onClick?: (() => void) | undefined }> = []
|
||||
const paginationProps: Array<{ onPageChange: (page: number) => void }> = []
|
||||
const searchBarProps: Array<{ value?: string; onSearch?: (query: string) => void }> = []
|
||||
const searchSkillParams: Array<Record<string, unknown>> = []
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
|
|
@ -34,7 +36,10 @@ vi.mock('@/features/auth/use-auth', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/features/search/search-bar', () => ({
|
||||
SearchBar: () => <div>search-bar</div>,
|
||||
SearchBar: (props: { value?: string; onSearch?: (query: string) => void }) => {
|
||||
searchBarProps.push(props)
|
||||
return <div>search-bar</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/skill-card', () => ({
|
||||
|
|
@ -85,7 +90,10 @@ vi.mock('@/app/page-shell-style', () => ({
|
|||
const useSearchSkillsMock = vi.fn()
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSearchSkills: () => useSearchSkillsMock(),
|
||||
useSearchSkills: (params: Record<string, unknown>) => {
|
||||
searchSkillParams.push(params)
|
||||
return useSearchSkillsMock()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-label-queries', () => ({
|
||||
|
|
@ -120,8 +128,11 @@ describe('SearchPage', () => {
|
|||
navigateMock.mockReset()
|
||||
buttonRecords.length = 0
|
||||
paginationProps.length = 0
|
||||
searchBarProps.length = 0
|
||||
searchSkillParams.length = 0
|
||||
useSearchMock.mockReturnValue({
|
||||
q: 'agent',
|
||||
namespace: 'team-ai',
|
||||
label: 'code-generation',
|
||||
sort: 'downloads',
|
||||
page: 1,
|
||||
|
|
@ -156,6 +167,7 @@ describe('SearchPage', () => {
|
|||
to: '/search',
|
||||
search: {
|
||||
q: 'agent',
|
||||
namespace: 'team-ai',
|
||||
label: '',
|
||||
sort: 'downloads',
|
||||
page: 0,
|
||||
|
|
@ -173,6 +185,7 @@ describe('SearchPage', () => {
|
|||
to: '/search',
|
||||
search: {
|
||||
q: 'agent',
|
||||
namespace: 'team-ai',
|
||||
label: 'code-generation',
|
||||
sort: 'newest',
|
||||
page: 0,
|
||||
|
|
@ -191,6 +204,7 @@ describe('SearchPage', () => {
|
|||
to: '/search',
|
||||
search: {
|
||||
q: 'agent',
|
||||
namespace: 'team-ai',
|
||||
label: 'code-generation',
|
||||
sort: 'downloads',
|
||||
page: 2,
|
||||
|
|
@ -201,6 +215,7 @@ describe('SearchPage', () => {
|
|||
to: '/search',
|
||||
search: {
|
||||
q: 'agent',
|
||||
namespace: 'team-ai',
|
||||
label: 'code-generation',
|
||||
sort: 'downloads',
|
||||
page: 0,
|
||||
|
|
@ -209,6 +224,38 @@ describe('SearchPage', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('passes the namespace URL state into skill search', () => {
|
||||
renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
expect(searchSkillParams[0]).toMatchObject({
|
||||
q: 'agent',
|
||||
namespace: 'team-ai',
|
||||
label: 'code-generation',
|
||||
sort: 'downloads',
|
||||
page: 1,
|
||||
size: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts a leading namespace token from the search input', () => {
|
||||
renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
searchBarProps[0]?.onSearch?.('@product-team onboarding')
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: '/search',
|
||||
search: {
|
||||
q: 'onboarding',
|
||||
namespace: 'product-team',
|
||||
label: 'code-generation',
|
||||
sort: 'downloads',
|
||||
page: 0,
|
||||
starredOnly: false,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the default skill list when the empty query still returns items', () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
q: '',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Pagination } from '@/shared/components/pagination'
|
|||
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { useVisibleLabels } from '@/shared/hooks/use-label-queries'
|
||||
import { useMyStars } from '@/shared/hooks/use-user-queries'
|
||||
import { normalizeSearchQuery } from '@/shared/lib/search-query'
|
||||
import { formatNamespaceSearchInput, normalizeSearchQuery, parseNamespaceSearchInput } from '@/shared/lib/search-query'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
|
||||
|
||||
|
|
@ -55,17 +55,22 @@ function scrollToTopOnPageChange() {
|
|||
* Search text, sorting, pagination, and the starred-only filter are mirrored into router search
|
||||
* params so the page can be shared, restored, and revisited without losing state.
|
||||
*/
|
||||
function filterStarredSkills(skills: SkillSummary[], query: string): SkillSummary[] {
|
||||
function filterStarredSkills(skills: SkillSummary[], query: string, namespace: string): SkillSummary[] {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) {
|
||||
return skills
|
||||
}
|
||||
const normalizedNamespace = namespace.trim().toLowerCase()
|
||||
|
||||
return skills.filter((skill) =>
|
||||
[skill.displayName, skill.summary, skill.namespace, skill.slug]
|
||||
.filter(Boolean)
|
||||
.some((value) => value!.toLowerCase().includes(normalizedQuery))
|
||||
)
|
||||
return skills.filter((skill) => {
|
||||
const matchesNamespace = !normalizedNamespace || skill.namespace.toLowerCase() === normalizedNamespace
|
||||
if (!matchesNamespace) {
|
||||
return false
|
||||
}
|
||||
if (!normalizedQuery) {
|
||||
return true
|
||||
}
|
||||
return [skill.displayName, skill.summary, skill.namespace, skill.slug]
|
||||
.filter(Boolean)
|
||||
.some((value) => value!.toLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
}
|
||||
|
||||
function sortStarredSkills(skills: SkillSummary[], sort: string): SkillSummary[] {
|
||||
|
|
@ -86,16 +91,17 @@ export function SearchPage() {
|
|||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const q = normalizeSearchQuery(searchParams.q || '')
|
||||
const namespace = (searchParams.namespace || '').replace(/^@/, '')
|
||||
const selectedLabel = searchParams.label || ''
|
||||
const sort = searchParams.sort || 'newest'
|
||||
const page = searchParams.page ?? 0
|
||||
const starredOnly = searchParams.starredOnly ?? false
|
||||
const [queryInput, setQueryInput] = useState(q)
|
||||
const [queryInput, setQueryInput] = useState(formatNamespaceSearchInput(namespace, q))
|
||||
const previousPageRef = useRef(page)
|
||||
|
||||
useEffect(() => {
|
||||
setQueryInput(q)
|
||||
}, [q])
|
||||
setQueryInput(formatNamespaceSearchInput(namespace, q))
|
||||
}, [namespace, q])
|
||||
|
||||
useEffect(() => {
|
||||
if (previousPageRef.current !== page) {
|
||||
|
|
@ -113,6 +119,7 @@ export function SearchPage() {
|
|||
|
||||
const { data, isLoading, isFetching } = useSearchSkills({
|
||||
q,
|
||||
namespace: namespace || undefined,
|
||||
label: selectedLabel || undefined,
|
||||
sort,
|
||||
page,
|
||||
|
|
@ -128,47 +135,51 @@ export function SearchPage() {
|
|||
useEffect(() => {
|
||||
// Debounce URL updates while the user is typing so query state stays shareable without
|
||||
// triggering a navigation on every keystroke.
|
||||
const normalizedQuery = normalizeSearchQuery(queryInput)
|
||||
if (normalizedQuery === q) {
|
||||
const parsedInput = parseNamespaceSearchInput(queryInput)
|
||||
if (parsedInput.query === q && parsedInput.namespace === namespace) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
if (!parsedInput.query && !parsedInput.namespace) {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 })
|
||||
navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
|
||||
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [navigate, page, q, queryInput, selectedLabel, sort, starredOnly])
|
||||
}, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly])
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
const normalizedQuery = normalizeSearchQuery(query)
|
||||
const parsedInput = parseNamespaceSearchInput(query)
|
||||
setQueryInput(query)
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
|
||||
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSortChange = (newSort: string) => {
|
||||
navigate({ to: '/search', search: { q, label: selectedLabel, sort: newSort, page: 0, starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } })
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
blurActiveElement()
|
||||
navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } })
|
||||
}
|
||||
|
||||
const handleLabelToggle = (label: string) => {
|
||||
const nextLabel = selectedLabel === label ? '' : label
|
||||
navigate({ to: '/search', search: { q, label: nextLabel, sort, page: 0, starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } })
|
||||
}
|
||||
|
||||
const handleNamespaceClear = () => {
|
||||
navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } })
|
||||
}
|
||||
|
||||
const handleStarredToggle = () => {
|
||||
|
|
@ -182,7 +193,7 @@ export function SearchPage() {
|
|||
return
|
||||
}
|
||||
|
||||
navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } })
|
||||
}
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
|
|
@ -190,7 +201,7 @@ export function SearchPage() {
|
|||
}
|
||||
|
||||
const filteredStarredSkills = starredOnly
|
||||
? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q), sort)
|
||||
? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q, namespace), sort)
|
||||
: []
|
||||
const starredPageItems = starredOnly
|
||||
? filteredStarredSkills.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
|
|
@ -280,6 +291,15 @@ export function SearchPage() {
|
|||
{label.displayName}
|
||||
</Button>
|
||||
))}
|
||||
{namespace ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleNamespaceClear}
|
||||
>
|
||||
{t('search.namespaceFilter', { namespace })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function ConfirmDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid={contentTestId}>
|
||||
<DialogContent data-testid={contentTestId} aria-label={title}>
|
||||
<DialogHeader className="min-w-0 text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{title}</DialogTitle>
|
||||
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery } from './search-query'
|
||||
import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery, parseNamespaceSearchInput } from './search-query'
|
||||
|
||||
describe('normalizeSearchQuery', () => {
|
||||
it('trims whitespace around the query', () => {
|
||||
|
|
@ -13,3 +13,36 @@ describe('normalizeSearchQuery', () => {
|
|||
expect(normalizeSearchQuery(query)).toBe('a'.repeat(MAX_SEARCH_QUERY_LENGTH))
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNamespaceSearchInput', () => {
|
||||
it('extracts a leading namespace token and keeps the remaining query', () => {
|
||||
expect(parseNamespaceSearchInput('@team-ai release notes')).toEqual({
|
||||
namespace: 'team-ai',
|
||||
query: 'release notes',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a bare namespace token as a namespace-only search', () => {
|
||||
expect(parseNamespaceSearchInput('@product')).toEqual({
|
||||
namespace: 'product',
|
||||
query: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts a sixty-four character namespace before limiting the keyword', () => {
|
||||
const namespace = 'a'.repeat(64)
|
||||
const query = 'release-notes '.repeat(8)
|
||||
|
||||
expect(parseNamespaceSearchInput(`@${namespace} ${query}`)).toEqual({
|
||||
namespace,
|
||||
query: query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH),
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves ordinary search text unchanged', () => {
|
||||
expect(parseNamespaceSearchInput('meeting assistant')).toEqual({
|
||||
namespace: '',
|
||||
query: 'meeting assistant',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,36 @@
|
|||
export const MAX_SEARCH_QUERY_LENGTH = 50
|
||||
export const MAX_NAMESPACE_SLUG_LENGTH = 64
|
||||
export const MAX_SEARCH_INPUT_LENGTH = MAX_NAMESPACE_SLUG_LENGTH + MAX_SEARCH_QUERY_LENGTH + 2
|
||||
|
||||
export function normalizeSearchQuery(query: string): string {
|
||||
return query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH)
|
||||
}
|
||||
|
||||
export interface NamespaceSearchInput {
|
||||
namespace: string
|
||||
query: string
|
||||
}
|
||||
|
||||
const LEADING_NAMESPACE_PATTERN = /^@([a-zA-Z0-9][a-zA-Z0-9-]{0,63})(?:\s+|$)(.*)$/
|
||||
|
||||
export function parseNamespaceSearchInput(input: string): NamespaceSearchInput {
|
||||
const trimmed = input.trim()
|
||||
const match = trimmed.match(LEADING_NAMESPACE_PATTERN)
|
||||
if (!match) {
|
||||
return { namespace: '', query: normalizeSearchQuery(trimmed) }
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: match[1],
|
||||
query: normalizeSearchQuery(match[2] ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatNamespaceSearchInput(namespace: string, query: string): string {
|
||||
const normalizedNamespace = namespace.trim().replace(/^@/, '')
|
||||
const normalizedQuery = normalizeSearchQuery(query)
|
||||
if (!normalizedNamespace) {
|
||||
return normalizedQuery
|
||||
}
|
||||
return normalizedQuery ? `@${normalizedNamespace} ${normalizedQuery}` : `@${normalizedNamespace}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue