mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
parent
e9cd8322a0
commit
90ae071e44
6 changed files with 76 additions and 11 deletions
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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('"label":"code-generation"')
|
||||
expect(html).toContain('Code Generation')
|
||||
})
|
||||
|
||||
it('hides the label management panel when the viewer lacks label permissions', () => {
|
||||
useSkillDetailMock.mockReturnValue({
|
||||
data: createSkill({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue