From a838078cd9d096404b55c3703538228409b297d5 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:06:38 +0800 Subject: [PATCH] fix(web): separate skill and suite discovery Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- web/src/app/router.tsx | 3 +- web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/ru.json | 2 + web/src/i18n/locales/zh.json | 2 + web/src/pages/search.test.tsx | 39 ++-- web/src/pages/search.tsx | 80 ++----- web/src/pages/suite-detail.test.tsx | 13 +- web/src/pages/suite-detail.tsx | 344 ++++++++++++++++------------ 8 files changed, 257 insertions(+), 228 deletions(-) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 9e35f45e..dc55d5a8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -228,12 +228,11 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; namespace?: string; label?: string; resourceType?: 'SKILL' | 'SUITE'; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { 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, - resourceType: search.resourceType === 'SKILL' || search.resourceType === 'SUITE' ? search.resourceType : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e843fc60..0016299d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1763,6 +1763,8 @@ "available": "Available", "degraded": "Degraded", "installCommand": "Install command", + "copyInstallCommand": "Copy install command", + "detailsSidebar": "Suite information and actions", "commandCopied": "Install command copied", "degradedDescription": "A member or its access changed. Restore every member before installing this suite.", "membersTitle": "Included skills ({{count}})", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index cb03f5c3..13c82fae 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1760,6 +1760,8 @@ "available": "Доступно", "degraded": "Ограничено", "installCommand": "Команда установки", + "copyInstallCommand": "Копировать команду установки", + "detailsSidebar": "Информация о наборе и действия", "commandCopied": "Команда установки скопирована", "degradedDescription": "Участник или права доступа изменились. Восстановите всех участников перед установкой.", "membersTitle": "Включённые навыки ({{count}})", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 41c97c2f..6b281d7e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1762,6 +1762,8 @@ "available": "可用", "degraded": "已降级", "installCommand": "安装命令", + "copyInstallCommand": "复制安装命令", + "detailsSidebar": "套件信息与操作", "commandCopied": "已复制安装命令", "degradedDescription": "成员失效或权限发生变化,恢复所有成员后才能安装。", "membersTitle": "包含的技能({{count}})", diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index baa17668..228cd2ec 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -46,10 +46,6 @@ vi.mock('@/features/skill/skill-card', () => ({ SkillCard: () =>
skill-card
, })) -vi.mock('@/features/suite/resource-card', () => ({ - ResourceCard: () =>
resource-card
, -})) - vi.mock('@/shared/components/skeleton-loader', () => ({ SkeletonList: () =>
skeleton
, })) @@ -92,7 +88,6 @@ vi.mock('@/app/page-shell-style', () => ({ })) const useSearchSkillsMock = vi.fn() -const useResourceSearchMock = vi.fn() vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSearchSkills: (params: Record) => { @@ -101,10 +96,6 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ }, })) -vi.mock('@/shared/hooks/use-suite-queries', () => ({ - useResourceSearch: () => useResourceSearchMock(), -})) - vi.mock('@/shared/hooks/use-label-queries', () => ({ useVisibleLabels: () => ({ data: [ @@ -157,11 +148,6 @@ describe('SearchPage', () => { isLoading: false, isFetching: false, }) - useResourceSearchMock.mockReturnValue({ - data: { items: [], total: 0, page: 0, size: 12 }, - isLoading: false, - isFetching: false, - }) }) it('marks the selected label button as active on initial render', () => { @@ -284,9 +270,9 @@ describe('SearchPage', () => { page: 0, starredOnly: false, }) - useResourceSearchMock.mockReturnValue({ + useSearchSkillsMock.mockReturnValue({ data: { - items: [{ resourceType: 'SKILL', detailUrl: '/space/global/demo', id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', version: '1.0.0', visibility: 'PUBLIC', installCount: 1, available: true, updatedAt: '2026-03-20T00:00:00Z' }], + 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 }], total: 1, page: 0, size: 12, @@ -297,7 +283,8 @@ describe('SearchPage', () => { const html = renderToStaticMarkup() - expect(html).toContain('resource-card') + expect(html).toContain('skill-card') + expect(html).not.toContain('suite.resourceTypeSuite') expect(html).not.toContain('empty-state') }) @@ -309,7 +296,7 @@ describe('SearchPage', () => { page: 0, starredOnly: false, }) - useResourceSearchMock.mockReturnValue({ + useSearchSkillsMock.mockReturnValue({ data: { items: [], total: 0, @@ -326,4 +313,20 @@ describe('SearchPage', () => { expect(html).toContain('search.noResults') expect(html).not.toContain('search.enterKeyword') }) + + it('keeps search skill-only when an obsolete Suite type query parameter is present', () => { + useSearchMock.mockReturnValue({ + q: 'workflow', + resourceType: 'SUITE', + sort: 'newest', + page: 0, + starredOnly: false, + }) + + const html = renderToStaticMarkup() + + expect(searchSkillParams[0]).not.toHaveProperty('resourceType') + expect(html).toContain('skill-card') + expect(html).not.toContain('suite.resourceTypeSuite') + }) }) diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 67793817..4da8656d 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -2,16 +2,14 @@ import { startTransition, useEffect, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' -import type { ResourceType, SkillSummary } from '@/api/types' +import type { SkillSummary } from '@/api/types' import { useAuth } from '@/features/auth/use-auth' import { SearchBar } from '@/features/search/search-bar' import { SkillCard } from '@/features/skill/skill-card' -import { ResourceCard } from '@/features/suite/resource-card' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' -import { useResourceSearch } from '@/shared/hooks/use-suite-queries' import { useVisibleLabels } from '@/shared/hooks/use-label-queries' import { useMyStars } from '@/shared/hooks/use-user-queries' import { toRouterPath } from '@/shared/lib/base-path' @@ -96,7 +94,6 @@ export function SearchPage() { const q = normalizeSearchQuery(searchParams.q || '') const namespace = (searchParams.namespace || '').replace(/^@/, '') const selectedLabel = searchParams.label || '' - const resourceType = searchParams.resourceType const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false @@ -121,7 +118,6 @@ export function SearchPage() { previousPageRef.current = page }, [page]) - const useLegacySkillSearch = starredOnly || Boolean(selectedLabel) const { data, isLoading, isFetching } = useSearchSkills({ q, namespace: namespace || undefined, @@ -130,19 +126,7 @@ export function SearchPage() { page, size: PAGE_SIZE, starredOnly, - }, useLegacySkillSearch) - const { - data: resourceData, - isLoading: isLoadingResources, - isFetching: isFetchingResources, - } = useResourceSearch({ - q, - namespace: namespace || undefined, - resourceType, - sort, - page, - size: PAGE_SIZE, - }, !useLegacySkillSearch) + }, !starredOnly) const { data: labels } = useVisibleLabels() const { data: starredSkills, @@ -159,44 +143,44 @@ export function SearchPage() { if (!parsedInput.query && !parsedInput.namespace) { startTransition(() => { - navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, resourceType, 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: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, resourceType, 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, namespace, page, q, queryInput, resourceType, selectedLabel, sort, starredOnly]) + }, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) const handleSearch = (query: string) => { const parsedInput = parseNamespaceSearchInput(query) setQueryInput(query) startTransition(() => { - navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, resourceType, 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, namespace, label: selectedLabel, resourceType, 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, namespace, label: selectedLabel, resourceType, 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, namespace, label: nextLabel, resourceType: undefined, 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, resourceType, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } }) } const handleStarredToggle = () => { @@ -210,7 +194,7 @@ export function SearchPage() { return } - navigate({ to: '/search', search: { q, namespace, label: selectedLabel, resourceType: undefined, sort, page: 0, starredOnly: !starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) } const handleSkillClick = (namespace: string, slug: string) => { @@ -220,13 +204,6 @@ export function SearchPage() { }) } - const handleResourceTypeChange = (nextType?: ResourceType) => { - navigate({ - to: '/search', - search: { q, namespace, label: '', resourceType: nextType, sort, page: 0, starredOnly: false }, - }) - } - const filteredStarredSkills = starredOnly ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q, namespace), sort) : [] @@ -235,26 +212,19 @@ export function SearchPage() { : [] const totalPages = starredOnly ? Math.ceil(filteredStarredSkills.length / PAGE_SIZE) - : useLegacySkillSearch && data + : data ? Math.ceil(data.total / data.size) - : resourceData - ? Math.ceil(resourceData.total / resourceData.size) - : 0 + : 0 const displayItems = starredOnly ? starredPageItems - : useLegacySkillSearch - ? (data?.items ?? []) - : [] - const resourceItems = useLegacySkillSearch ? [] : (resourceData?.items ?? []) - const isPageLoading = starredOnly ? isLoadingStarred : useLegacySkillSearch ? isLoading : isLoadingResources + : (data?.items ?? []) + const isPageLoading = starredOnly ? isLoadingStarred : isLoading const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred - : useLegacySkillSearch - ? isFetching && !isLoading - : isFetchingResources && !isLoadingResources + : isFetching && !isLoading const resultCount = starredOnly ? filteredStarredSkills.length - : useLegacySkillSearch ? (data?.total ?? 0) : (resourceData?.total ?? 0) + : (data?.total ?? 0) return (
@@ -313,10 +283,6 @@ export function SearchPage() { ) : null}
- {t('suite.resourceType')} - - - {t('search.filters.label')}
{totalPages > 1 && ( { expect(deletedLabels.every((label) => label.closest('a') === null)).toBe(true) expect(screen.getAllByRole('link')).toHaveLength(1) }) + + it('places Suite metadata and installation in the detail sidebar', () => { + mocks.detail = { data: suite(), isLoading: false, error: null } + + render() + + const sidebar = screen.getByRole('complementary', { name: 'suite.detailsSidebar' }) + expect(within(sidebar).getByText('v1.0.0')).not.toBeNull() + expect(within(sidebar).getByText('suite.installCommand')).not.toBeNull() + expect(within(sidebar).getByLabelText('suite.copyInstallCommand')).not.toBeNull() + }) }) diff --git a/web/src/pages/suite-detail.tsx b/web/src/pages/suite-detail.tsx index fb03132e..933836dc 100644 --- a/web/src/pages/suite-detail.tsx +++ b/web/src/pages/suite-detail.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { AlertTriangle, ArrowUpRight, Boxes, CheckCircle2, Copy, Wrench } from 'lucide-react' +import { AlertTriangle, ArrowUpRight, Boxes, CheckCircle2, Copy, Terminal, Wrench } from 'lucide-react' import { useSuiteDetail, useSuiteVersions, useSubmitSuite } from '@/shared/hooks/use-suite-queries' import { suiteBlockingReasonLabel, suiteStatusLabel, suiteVisibilityLabel } from '@/features/suite/suite-labels' import { SuiteManagementActions } from '@/features/suite/suite-management-actions' @@ -10,7 +10,9 @@ import { Card } from '@/shared/ui/card' import { Button } from '@/shared/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { SkeletonList } from '@/shared/components/skeleton-loader' +import { NamespaceBadge } from '@/shared/components/namespace-badge' import { toast } from '@/shared/lib/toast' +import { cn } from '@/shared/lib/utils' import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' export function SuiteDetailPage() { @@ -48,161 +50,213 @@ export function SuiteDetailPage() { } } + const hasPrimaryActions = suite.allowedActions.includes('EDIT') + || suite.allowedActions.includes('SUBMIT') + || suite.allowedActions.includes('PUBLISH_PRIVATE') + return ( -
-
-
-
- @{suite.namespace}/{suite.slug} +
+
+
+
+
+ + + +
+

+ {suite.displayName} +

+

@{suite.namespace}/{suite.slug}

+

+ {suite.summary || t('suite.noSummary')} +

-

{suite.displayName}

-

{suite.summary || t('suite.noSummary')}

-
-
- {suite.allowedActions.includes('EDIT') ? ( - + + {!suite.available ? ( + +
+
+
) : null} - {suite.allowedActions.includes('SUBMIT') || suite.allowedActions.includes('PUBLISH_PRIVATE') ? ( - + + + + {t('suite.overviewTab')} + {t('suite.membersTab', { count: suite.members.length })} + + + + + {suite.overview ? ( + + ) : ( +

+ {suite.summary || t('suite.noOverview')} +

+ )} +
+
+ + +
+ {suite.members.map((member) => { + const memberName = member.displayName || `@${member.namespace}/${member.slug}` + const content = ( + +
+
+

+ + {memberName} +

+

+ @{member.namespace}/{member.slug} +

+
+ {member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? ( +
+ {member.summary ? ( +

{member.summary}

+ ) : null} +
+ + {t('suite.pinnedVersion', { version: member.version })} + + {member.entry ? ( + + {t('suite.entrySkill')} + + ) : null} + + {member.blockingReason + ? suiteBlockingReasonLabel(t, member.blockingReason) + : t('suite.available')} + +
+
+ ) + + return member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? ( + + {content} + + ) : ( +
{content}
+ ) + })} +
+
+
+
+ +
-
-
-

{t('suite.version')}

v{suite.version}

-

{t('suite.status')}

{suiteStatusLabel(t, suite.status)}

-

{t('suite.visibility')}

{suiteVisibilityLabel(t, suite.visibility)}

- -

{t('suite.installStatus')}

-

- {suite.available ? : } - {suite.available ? t('suite.available') : t('suite.degraded')} -

-
-
- - -

{t('suite.installCommand')}

-
- {command} - -
- {!suite.available ?

{t('suite.degradedDescription')}

: null} -
- - - - {t('suite.overviewTab')} - {t('suite.membersTab', { count: suite.members.length })} - - - - -

{t('suite.overviewTitle')}

-
- {suite.overview ? ( - - ) : ( -

- {suite.summary || t('suite.noOverview')} -

- )} + +
+ {t('suite.version')} + v{suite.version} +
+
+
+ {t('suite.status')} + {suiteStatusLabel(t, suite.status)} +
+
+
+ {t('suite.visibility')} + {suiteVisibilityLabel(t, suite.visibility)} +
+
+
+ {t('suite.installStatus')} + + {suite.available + ?
- - -
- {suite.members.map((member) => { - const memberName = member.displayName || `@${member.namespace}/${member.slug}` - const content = ( - -
-
-

- - {memberName} -

-

- @{member.namespace}/{member.slug} -

-
- {member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? ( -
- {member.summary ? ( -

{member.summary}

- ) : null} -
- - {t('suite.pinnedVersion', { version: member.version })} - - {member.entry ? ( - - {t('suite.entrySkill')} - - ) : null} - - {member.blockingReason - ? suiteBlockingReasonLabel(t, member.blockingReason) - : t('suite.available')} - -
-
- ) - - return member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? ( - - {content} - - ) : ( -
{content}
- ) - })} -
-
- - - {versions?.length ? ( - -

{t('suite.versionHistory')}

-
- {versions.map((item) => ( + +
+
+
+ {command} - ))} -
-
- ) : null} + aria-label={t('suite.copyInstallCommand')} + onClick={async () => { + await navigator.clipboard.writeText(command) + toast.success(t('suite.commandCopied')) + }} + > +
+
- + {versions?.length ? ( + +

{t('suite.versionHistory')}

+
+ {versions.map((item) => ( + + ))} +
+
+ ) : null} + + + +
) }