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:
dongmucat 2026-06-05 17:27:34 +08:00 committed by GitHub
parent 531d59caf2
commit 6dc62ddfb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 144 additions and 26 deletions

View file

@ -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()
})
})

View file

@ -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) {

View file

@ -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()

View file

@ -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')
})
})

View file

@ -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>
)
}

View file

@ -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.",

View file

@ -807,6 +807,8 @@
"namespaceLabel": "命名空间",
"loginToRate": "登录后可以收藏和评分",
"install": "安装",
"installMethodClawhub": "ClawHub CLI",
"installMethodSkillhub": "SkillHub CLI",
"download": "下载",
"labelsSectionTitle": "标签管理",
"labelsSectionDescription": "为这个技能挂载或移除推荐标签,帮助用户筛选和发现。",

View file

@ -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"')
})
})

View file

@ -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>
}