mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(web): add install method tabs (#496)
* feat(web): add install method tabs Signed-off-by: dongmucat <1127093059@qq.com> * test(web): stabilize real service e2e checks Signed-off-by: dongmucat <1127093059@qq.com> * style(web): simplify install tab indicator Signed-off-by: dongmucat <1127093059@qq.com> --------- Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
531d59caf2
commit
6dc62ddfb8
9 changed files with 144 additions and 26 deletions
|
|
@ -11,6 +11,10 @@ function latestSeed(seed: PreparedSearchSeed) {
|
|||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
let seeded: PreparedSearchSeed | undefined
|
||||
|
||||
test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
|
||||
|
|
@ -40,7 +44,21 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
|
|||
await expect(page).not.toHaveURL(/\/login\?returnTo=/)
|
||||
await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Install', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible()
|
||||
const clawhubTarget = current.skill.namespace === 'global'
|
||||
? current.skill.slug
|
||||
: `${current.skill.namespace}--${current.skill.slug}`
|
||||
const skillhubNamespace = current.skill.namespace === 'global'
|
||||
? ''
|
||||
: ` --namespace ${current.skill.namespace}`
|
||||
|
||||
await expect(page.getByRole('tab', { name: 'ClawHub CLI' })).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(page.getByText(new RegExp(`npx clawhub install ${escapeRegExp(clawhubTarget)} --registry`))).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toBeVisible()
|
||||
|
||||
await page.getByRole('tab', { name: 'SkillHub CLI' }).click()
|
||||
|
||||
await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(page.getByText(new RegExp(`npx @astron-team/skillhub@latest install ${escapeRegExp(current.skill.slug)}${escapeRegExp(skillhubNamespace)} --registry`))).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ test.describe('Review Management Pagination (Real API)', () => {
|
|||
|
||||
await page.goto('/dashboard/reviews')
|
||||
await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: 'Skill Reviews' })).toBeVisible()
|
||||
|
||||
const tabMeta: Record<ReviewStatus, { tabLabel: string; summaryPrefix: string }> = {
|
||||
PENDING: { tabLabel: 'Pending', summaryPrefix: 'Total' },
|
||||
|
|
@ -49,7 +50,7 @@ test.describe('Review Management Pagination (Real API)', () => {
|
|||
}
|
||||
|
||||
for (const status of statuses) {
|
||||
await page.getByRole('button', { name: tabMeta[status].tabLabel }).click()
|
||||
await page.getByRole('tab', { name: tabMeta[status].tabLabel }).click()
|
||||
|
||||
const meta = metaByStatus.get(status)
|
||||
if (!meta) {
|
||||
|
|
|
|||
|
|
@ -45,31 +45,14 @@ test.describe('Skill Subscription (Real API)', () => {
|
|||
const subscribeButton = page.getByRole('button', { name: /Subscribe/ })
|
||||
await expect(subscribeButton).toBeVisible()
|
||||
|
||||
const initialCount = await subscribeButton.textContent()
|
||||
const initialCountMatch = initialCount?.match(/\((\d+)\)/)
|
||||
const initialCountValue = initialCountMatch ? Number.parseInt(initialCountMatch[1], 10) : 0
|
||||
|
||||
await subscribeButton.click()
|
||||
|
||||
await expect(page.getByRole('button', { name: /Subscribed/ })).toBeVisible()
|
||||
|
||||
const subscribedButton = page.getByRole('button', { name: /Subscribed/ })
|
||||
const subscribedCount = await subscribedButton.textContent()
|
||||
const subscribedCountMatch = subscribedCount?.match(/\((\d+)\)/)
|
||||
const subscribedCountValue = subscribedCountMatch ? Number.parseInt(subscribedCountMatch[1], 10) : 0
|
||||
|
||||
expect(subscribedCountValue).toBe(initialCountValue + 1)
|
||||
|
||||
await subscribedButton.click()
|
||||
|
||||
await expect(page.getByRole('button', { name: /Subscribe/ })).toBeVisible()
|
||||
|
||||
const unsubscribedButton = page.getByRole('button', { name: /Subscribe/ })
|
||||
const unsubscribedCount = await unsubscribedButton.textContent()
|
||||
const unsubscribedCountMatch = unsubscribedCount?.match(/\((\d+)\)/)
|
||||
const unsubscribedCountValue = unsubscribedCountMatch ? Number.parseInt(unsubscribedCountMatch[1], 10) : 0
|
||||
|
||||
expect(unsubscribedCountValue).toBe(initialCountValue)
|
||||
} finally {
|
||||
await adminBuilder.cleanup()
|
||||
await adminContext.close()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InstallCommand, buildInstallCommand, buildInstallTarget, getBaseUrl } from './install-command'
|
||||
import {
|
||||
InstallCommand,
|
||||
buildInstallCommand,
|
||||
buildInstallTarget,
|
||||
buildSkillhubInstallCommand,
|
||||
getBaseUrl,
|
||||
} from './install-command'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
|
|
@ -62,6 +68,18 @@ describe('install-command', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('builds a one-line SkillHub npx command for the global namespace', () => {
|
||||
expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe(
|
||||
'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a one-line SkillHub npx command with namespace for team skills', () => {
|
||||
expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn')).toBe(
|
||||
'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the runtime app base url when available', () => {
|
||||
setMockWindow('https://app.example.com')
|
||||
|
||||
|
|
@ -92,4 +110,33 @@ describe('install-command', () => {
|
|||
expect(html).toContain('leading-relaxed')
|
||||
expect(html).toContain('break-all')
|
||||
})
|
||||
|
||||
it('renders install method tabs with only a short active underline', () => {
|
||||
setMockWindow('https://app.example.com')
|
||||
|
||||
const html = renderToStaticMarkup(createElement(InstallCommand, {
|
||||
namespace: 'global',
|
||||
slug: 'meeting-minutes-generator',
|
||||
}))
|
||||
|
||||
expect(html).toContain('after:w-6')
|
||||
expect(html).toContain('after:h-0.5')
|
||||
expect(html).not.toContain('rounded-lg border bg-background/80 p-1')
|
||||
expect(html).not.toContain('flex-1 rounded-md')
|
||||
})
|
||||
|
||||
it('renders ClawHub CLI as the default install method', () => {
|
||||
setMockWindow('https://app.example.com')
|
||||
|
||||
const html = renderToStaticMarkup(createElement(InstallCommand, {
|
||||
namespace: 'team-alpha',
|
||||
slug: 'meeting-minutes-generator',
|
||||
}))
|
||||
|
||||
expect(html).toContain('skillDetail.installMethodClawhub')
|
||||
expect(html).toContain('skillDetail.installMethodSkillhub')
|
||||
expect(html).toContain('aria-selected="true"')
|
||||
expect(html).toContain('npx clawhub install team-alpha--meeting-minutes-generator --registry https://app.example.com')
|
||||
expect(html).not.toContain('npx @astron-team/skillhub@latest install meeting-minutes-generator --namespace team-alpha --registry https://app.example.com')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { useCopyToClipboard } from '@/shared/lib/clipboard'
|
||||
|
||||
interface InstallCommandProps {
|
||||
|
|
@ -33,14 +34,22 @@ export function buildInstallCommand(namespace: string, slug: string, baseUrl: st
|
|||
return `npx clawhub install ${installTarget} --registry ${baseUrl}`
|
||||
}
|
||||
|
||||
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
|
||||
export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string {
|
||||
const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}`
|
||||
return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`
|
||||
}
|
||||
|
||||
interface CommandBlockProps {
|
||||
command: string
|
||||
}
|
||||
|
||||
const installMethodTabTriggerClass =
|
||||
"relative border-b-0 px-1 py-2 text-xs after:absolute after:bottom-[-1px] after:left-1/2 after:h-0.5 after:w-6 after:-translate-x-1/2 after:rounded-full after:bg-transparent after:content-[''] data-[state=active]:after:bg-primary"
|
||||
|
||||
function CommandBlock({ command }: CommandBlockProps) {
|
||||
const { t } = useTranslation()
|
||||
const [copied, copy] = useCopyToClipboard()
|
||||
|
||||
const baseUrl = useMemo(() => getBaseUrl(), [])
|
||||
|
||||
const command = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copy(command)
|
||||
|
|
@ -70,3 +79,29 @@ export function InstallCommand({ namespace, slug }: InstallCommandProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
|
||||
const { t } = useTranslation()
|
||||
const baseUrl = useMemo(() => getBaseUrl(), [])
|
||||
const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
|
||||
const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="clawhub" className="space-y-3">
|
||||
<TabsList className="w-full gap-6 border-border/70 bg-transparent p-0 text-xs">
|
||||
<TabsTrigger value="clawhub" className={installMethodTabTriggerClass}>
|
||||
{t('skillDetail.installMethodClawhub')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skillhub" className={installMethodTabTriggerClass}>
|
||||
{t('skillDetail.installMethodSkillhub')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="clawhub">
|
||||
<CommandBlock command={clawhubCommand} />
|
||||
</TabsContent>
|
||||
<TabsContent value="skillhub">
|
||||
<CommandBlock command={skillhubCommand} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -807,6 +807,8 @@
|
|||
"namespaceLabel": "Namespace",
|
||||
"loginToRate": "Login to star and rate",
|
||||
"install": "Install",
|
||||
"installMethodClawhub": "ClawHub CLI",
|
||||
"installMethodSkillhub": "SkillHub CLI",
|
||||
"download": "Download",
|
||||
"labelsSectionTitle": "Labels",
|
||||
"labelsSectionDescription": "Attach or remove recommended labels that help users filter and discover this skill.",
|
||||
|
|
|
|||
|
|
@ -807,6 +807,8 @@
|
|||
"namespaceLabel": "命名空间",
|
||||
"loginToRate": "登录后可以收藏和评分",
|
||||
"install": "安装",
|
||||
"installMethodClawhub": "ClawHub CLI",
|
||||
"installMethodSkillhub": "SkillHub CLI",
|
||||
"download": "下载",
|
||||
"labelsSectionTitle": "标签管理",
|
||||
"labelsSectionDescription": "为这个技能挂载或移除推荐标签,帮助用户筛选和发现。",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'
|
||||
|
||||
|
|
@ -28,4 +30,29 @@ describe('Tabs components', () => {
|
|||
expect(typeof TabsContent).toBe('function')
|
||||
expect(TabsContent.name).toBe('TabsContent')
|
||||
})
|
||||
|
||||
it('renders semantic tablist, tab, and tabpanel roles', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(Tabs, {
|
||||
defaultValue: 'clawhub',
|
||||
children: [
|
||||
createElement(TabsList, {
|
||||
key: 'list',
|
||||
children: [
|
||||
createElement(TabsTrigger, { key: 'clawhub', value: 'clawhub', children: 'ClawHub CLI' }),
|
||||
createElement(TabsTrigger, { key: 'skillhub', value: 'skillhub', children: 'SkillHub CLI' }),
|
||||
],
|
||||
}),
|
||||
createElement(TabsContent, { key: 'clawhub-content', value: 'clawhub', children: 'clawhub command' }),
|
||||
createElement(TabsContent, { key: 'skillhub-content', value: 'skillhub', children: 'skillhub command' }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('role="tablist"')
|
||||
expect(html).toContain('role="tab"')
|
||||
expect(html).toContain('aria-selected="true"')
|
||||
expect(html).toContain('aria-selected="false"')
|
||||
expect(html).toContain('role="tabpanel"')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ interface TabsListProps {
|
|||
export function TabsList({ children, className }: TabsListProps) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-6 border-b text-sm',
|
||||
className
|
||||
|
|
@ -69,6 +70,8 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
|
|||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => context.setValue(value)}
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
className={cn(
|
||||
|
|
@ -96,5 +99,5 @@ export function TabsContent({ value, children, className }: TabsContentProps) {
|
|||
|
||||
if (context.value !== value) return null
|
||||
|
||||
return <div className={cn('animate-fade-in', className)}>{children}</div>
|
||||
return <div role="tabpanel" className={cn('animate-fade-in', className)}>{children}</div>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue