feat(web): link skill labels to search and wrap filter chips

Make skill-detail label chips navigate to /search?label=… and allow
the search filter row to wrap when many labels are present.

Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com>
This commit is contained in:
Mikhail Neradkov 2026-07-29 22:48:58 +03:00
parent e9cd8322a0
commit 90ae071e44
6 changed files with 76 additions and 11 deletions

View file

@ -158,6 +158,12 @@ describe('SearchPage', () => {
expect(findButton('Official').variant).toBe('outline')
})
it('wraps the filter chip row so many labels can flow onto multiple lines', () => {
const html = renderToStaticMarkup(<SearchPage />)
expect(html).toContain('flex flex-wrap items-center gap-2')
})
it('toggles the selected label off and resets paging', () => {
renderToStaticMarkup(<SearchPage />)

View file

@ -272,8 +272,8 @@ export function SearchPage() {
</div>
) : null}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
<div className="flex flex-wrap items-center gap-2">
<span className="shrink-0 text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
<Button
variant={starredOnly ? 'default' : 'outline'}
size="sm"

View file

@ -3,7 +3,7 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MouseEvent } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import type { SkillFile } from '@/api/types'
const toastMocks = vi.hoisted(() => ({
@ -32,6 +32,21 @@ vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ namespace: 'global', slug: 'demo-skill' }),
useRouterState: () => ({ pathname: '/space/global/demo-skill', searchStr: '', hash: '' }),
useSearch: () => ({ returnTo: '/dashboard/skills' }),
Link: ({
to,
search,
children,
className,
}: {
to: string
search?: Record<string, unknown>
children?: ReactNode
className?: string
}) => (
<a href={to} data-search={JSON.stringify(search ?? {})} className={className}>
{children}
</a>
),
}))
vi.mock('react-i18next', async () => {
@ -349,6 +364,25 @@ describe('SkillDetailPage', () => {
expect(html).toContain('skillDetail.addLabel')
})
it('links skill label chips to the search page filtered by that label', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({
ownerId: 'someone-else',
canManageLifecycle: false,
labels: [{ slug: 'code-generation', type: 'RECOMMENDED', displayName: 'Code Generation' }],
}),
isLoading: false,
isFetching: false,
error: null,
})
const html = renderToStaticMarkup(<SkillDetailPage />)
expect(html).toContain('href="/search"')
expect(html).toContain('&quot;label&quot;:&quot;code-generation&quot;')
expect(html).toContain('Code Generation')
})
it('hides the label management panel when the viewer lacks label permissions', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
@ -30,7 +30,7 @@ import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
import { SecurityAuditSummary } from '@/features/security-audit/security-audit-summary'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { incrementSkillDownloadCount } from '@/shared/lib/skill-download-cache'
import { getSkillSquareSearch, normalizeSkillDetailReturnTo } from '@/shared/lib/skill-navigation'
import { getSkillLabelSearch, getSkillSquareSearch, normalizeSkillDetailReturnTo } from '@/shared/lib/skill-navigation'
import { formatCompactCount } from '@/shared/lib/number-format'
import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation'
import { getHeadlineVersion, getOwnerPreviewVersion, getPublishedVersion } from '@/shared/lib/skill-lifecycle'
@ -830,17 +830,19 @@ export function SkillDetailPage() {
{(skill.labels?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-2">
{skill.labels!.map((label) => (
<span
<Link
key={label.slug}
to="/search"
search={getSkillLabelSearch(label.slug)}
className={cn(
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium',
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2',
label.type === 'PRIVILEGED'
? 'border-amber-500/40 bg-amber-100 text-amber-900'
: 'border-slate-300 bg-slate-100 text-slate-800',
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80'
: 'border-slate-300 bg-slate-100 text-slate-800 hover:bg-slate-200/80',
)}
>
{label.displayName}
</span>
</Link>
))}
</div>
)}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getSkillSquareSearch, normalizeSkillDetailReturnTo } from './skill-navigation'
import { getSkillSquareSearch, getSkillLabelSearch, normalizeSkillDetailReturnTo } from './skill-navigation'
describe('getSkillSquareSearch', () => {
it('returns the default search params for the skill square', () => {
@ -12,6 +12,18 @@ describe('getSkillSquareSearch', () => {
})
})
describe('getSkillLabelSearch', () => {
it('returns search params that filter by the given label', () => {
expect(getSkillLabelSearch('code-generation')).toEqual({
q: '',
label: 'code-generation',
sort: 'newest',
page: 0,
starredOnly: false,
})
})
})
describe('normalizeSkillDetailReturnTo', () => {
it('returns the provided dashboard route when coming from my skills', () => {
expect(normalizeSkillDetailReturnTo('/dashboard/skills')).toBe('/dashboard/skills')

View file

@ -10,6 +10,17 @@ export function getSkillSquareSearch() {
}
}
/** Search params for browsing skills that share a given label (from detail chips). */
export function getSkillLabelSearch(label: string) {
return {
q: '',
label,
sort: 'newest' as const,
page: 0,
starredOnly: false,
}
}
export function normalizeSkillDetailReturnTo(returnTo?: string) {
return returnTo && returnTo.startsWith('/') ? returnTo : undefined
}