Skip to content

Commit 6dc62dd

Browse files
authored
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>
1 parent 531d59c commit 6dc62dd

9 files changed

Lines changed: 144 additions & 26 deletions

File tree

web/e2e/public-skill-detail-anonymous.spec.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ function latestSeed(seed: PreparedSearchSeed) {
1111
}
1212
}
1313

14+
function escapeRegExp(value: string) {
15+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
16+
}
17+
1418
let seeded: PreparedSearchSeed | undefined
1519

1620
test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
@@ -40,7 +44,21 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
4044
await expect(page).not.toHaveURL(/\/login\?returnTo=/)
4145
await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
4246
await expect(page.getByText('Install', { exact: true })).toBeVisible()
43-
await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible()
47+
const clawhubTarget = current.skill.namespace === 'global'
48+
? current.skill.slug
49+
: `${current.skill.namespace}--${current.skill.slug}`
50+
const skillhubNamespace = current.skill.namespace === 'global'
51+
? ''
52+
: ` --namespace ${current.skill.namespace}`
53+
54+
await expect(page.getByRole('tab', { name: 'ClawHub CLI' })).toHaveAttribute('aria-selected', 'true')
55+
await expect(page.getByText(new RegExp(`npx clawhub install ${escapeRegExp(clawhubTarget)} --registry`))).toBeVisible()
56+
await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toBeVisible()
57+
58+
await page.getByRole('tab', { name: 'SkillHub CLI' }).click()
59+
60+
await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toHaveAttribute('aria-selected', 'true')
61+
await expect(page.getByText(new RegExp(`npx @astron-team/skillhub@latest install ${escapeRegExp(current.skill.slug)}${escapeRegExp(skillhubNamespace)} --registry`))).toBeVisible()
4462
await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible()
4563
})
4664
})

web/e2e/reviews-pagination.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ test.describe('Review Management Pagination (Real API)', () => {
4141

4242
await page.goto('/dashboard/reviews')
4343
await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible()
44+
await expect(page.getByRole('tab', { name: 'Skill Reviews' })).toBeVisible()
4445

4546
const tabMeta: Record<ReviewStatus, { tabLabel: string; summaryPrefix: string }> = {
4647
PENDING: { tabLabel: 'Pending', summaryPrefix: 'Total' },
@@ -49,7 +50,7 @@ test.describe('Review Management Pagination (Real API)', () => {
4950
}
5051

5152
for (const status of statuses) {
52-
await page.getByRole('button', { name: tabMeta[status].tabLabel }).click()
53+
await page.getByRole('tab', { name: tabMeta[status].tabLabel }).click()
5354

5455
const meta = metaByStatus.get(status)
5556
if (!meta) {

web/e2e/skill-subscription.spec.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,31 +45,14 @@ test.describe('Skill Subscription (Real API)', () => {
4545
const subscribeButton = page.getByRole('button', { name: /Subscribe/ })
4646
await expect(subscribeButton).toBeVisible()
4747

48-
const initialCount = await subscribeButton.textContent()
49-
const initialCountMatch = initialCount?.match(/\((\d+)\)/)
50-
const initialCountValue = initialCountMatch ? Number.parseInt(initialCountMatch[1], 10) : 0
51-
5248
await subscribeButton.click()
5349

5450
await expect(page.getByRole('button', { name: /Subscribed/ })).toBeVisible()
5551

5652
const subscribedButton = page.getByRole('button', { name: /Subscribed/ })
57-
const subscribedCount = await subscribedButton.textContent()
58-
const subscribedCountMatch = subscribedCount?.match(/\((\d+)\)/)
59-
const subscribedCountValue = subscribedCountMatch ? Number.parseInt(subscribedCountMatch[1], 10) : 0
60-
61-
expect(subscribedCountValue).toBe(initialCountValue + 1)
62-
6353
await subscribedButton.click()
6454

6555
await expect(page.getByRole('button', { name: /Subscribe/ })).toBeVisible()
66-
67-
const unsubscribedButton = page.getByRole('button', { name: /Subscribe/ })
68-
const unsubscribedCount = await unsubscribedButton.textContent()
69-
const unsubscribedCountMatch = unsubscribedCount?.match(/\((\d+)\)/)
70-
const unsubscribedCountValue = unsubscribedCountMatch ? Number.parseInt(unsubscribedCountMatch[1], 10) : 0
71-
72-
expect(unsubscribedCountValue).toBe(initialCountValue)
7356
} finally {
7457
await adminBuilder.cleanup()
7558
await adminContext.close()

web/src/features/skill/install-command.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { createElement } from 'react'
22
import { renderToStaticMarkup } from 'react-dom/server'
33
import { afterEach, describe, expect, it, vi } from 'vitest'
4-
import { InstallCommand, buildInstallCommand, buildInstallTarget, getBaseUrl } from './install-command'
4+
import {
5+
InstallCommand,
6+
buildInstallCommand,
7+
buildInstallTarget,
8+
buildSkillhubInstallCommand,
9+
getBaseUrl,
10+
} from './install-command'
511

612
vi.mock('react-i18next', () => ({
713
useTranslation: () => ({
@@ -62,6 +68,18 @@ describe('install-command', () => {
6268
)
6369
})
6470

71+
it('builds a one-line SkillHub npx command for the global namespace', () => {
72+
expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe(
73+
'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn',
74+
)
75+
})
76+
77+
it('builds a one-line SkillHub npx command with namespace for team skills', () => {
78+
expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn')).toBe(
79+
'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn',
80+
)
81+
})
82+
6583
it('uses the runtime app base url when available', () => {
6684
setMockWindow('https://app.example.com')
6785

@@ -92,4 +110,33 @@ describe('install-command', () => {
92110
expect(html).toContain('leading-relaxed')
93111
expect(html).toContain('break-all')
94112
})
113+
114+
it('renders install method tabs with only a short active underline', () => {
115+
setMockWindow('https://app.example.com')
116+
117+
const html = renderToStaticMarkup(createElement(InstallCommand, {
118+
namespace: 'global',
119+
slug: 'meeting-minutes-generator',
120+
}))
121+
122+
expect(html).toContain('after:w-6')
123+
expect(html).toContain('after:h-0.5')
124+
expect(html).not.toContain('rounded-lg border bg-background/80 p-1')
125+
expect(html).not.toContain('flex-1 rounded-md')
126+
})
127+
128+
it('renders ClawHub CLI as the default install method', () => {
129+
setMockWindow('https://app.example.com')
130+
131+
const html = renderToStaticMarkup(createElement(InstallCommand, {
132+
namespace: 'team-alpha',
133+
slug: 'meeting-minutes-generator',
134+
}))
135+
136+
expect(html).toContain('skillDetail.installMethodClawhub')
137+
expect(html).toContain('skillDetail.installMethodSkillhub')
138+
expect(html).toContain('aria-selected="true"')
139+
expect(html).toContain('npx clawhub install team-alpha--meeting-minutes-generator --registry https://app.example.com')
140+
expect(html).not.toContain('npx @astron-team/skillhub@latest install meeting-minutes-generator --namespace team-alpha --registry https://app.example.com')
141+
})
95142
})

web/src/features/skill/install-command.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { Check, Copy } from 'lucide-react'
44
import { Button } from '@/shared/ui/button'
5+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
56
import { useCopyToClipboard } from '@/shared/lib/clipboard'
67

78
interface InstallCommandProps {
@@ -33,13 +34,21 @@ export function buildInstallCommand(namespace: string, slug: string, baseUrl: st
3334
return `npx clawhub install ${installTarget} --registry ${baseUrl}`
3435
}
3536

36-
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
37-
const { t } = useTranslation()
38-
const [copied, copy] = useCopyToClipboard()
37+
export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string {
38+
const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}`
39+
return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`
40+
}
3941

40-
const baseUrl = useMemo(() => getBaseUrl(), [])
42+
interface CommandBlockProps {
43+
command: string
44+
}
45+
46+
const installMethodTabTriggerClass =
47+
"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"
4148

42-
const command = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
49+
function CommandBlock({ command }: CommandBlockProps) {
50+
const { t } = useTranslation()
51+
const [copied, copy] = useCopyToClipboard()
4352

4453
const handleCopy = async () => {
4554
try {
@@ -70,3 +79,29 @@ export function InstallCommand({ namespace, slug }: InstallCommandProps) {
7079
</div>
7180
)
7281
}
82+
83+
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
84+
const { t } = useTranslation()
85+
const baseUrl = useMemo(() => getBaseUrl(), [])
86+
const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
87+
const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
88+
89+
return (
90+
<Tabs defaultValue="clawhub" className="space-y-3">
91+
<TabsList className="w-full gap-6 border-border/70 bg-transparent p-0 text-xs">
92+
<TabsTrigger value="clawhub" className={installMethodTabTriggerClass}>
93+
{t('skillDetail.installMethodClawhub')}
94+
</TabsTrigger>
95+
<TabsTrigger value="skillhub" className={installMethodTabTriggerClass}>
96+
{t('skillDetail.installMethodSkillhub')}
97+
</TabsTrigger>
98+
</TabsList>
99+
<TabsContent value="clawhub">
100+
<CommandBlock command={clawhubCommand} />
101+
</TabsContent>
102+
<TabsContent value="skillhub">
103+
<CommandBlock command={skillhubCommand} />
104+
</TabsContent>
105+
</Tabs>
106+
)
107+
}

web/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@
807807
"namespaceLabel": "Namespace",
808808
"loginToRate": "Login to star and rate",
809809
"install": "Install",
810+
"installMethodClawhub": "ClawHub CLI",
811+
"installMethodSkillhub": "SkillHub CLI",
810812
"download": "Download",
811813
"labelsSectionTitle": "Labels",
812814
"labelsSectionDescription": "Attach or remove recommended labels that help users filter and discover this skill.",

web/src/i18n/locales/zh.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@
807807
"namespaceLabel": "命名空间",
808808
"loginToRate": "登录后可以收藏和评分",
809809
"install": "安装",
810+
"installMethodClawhub": "ClawHub CLI",
811+
"installMethodSkillhub": "SkillHub CLI",
810812
"download": "下载",
811813
"labelsSectionTitle": "标签管理",
812814
"labelsSectionDescription": "为这个技能挂载或移除推荐标签,帮助用户筛选和发现。",

web/src/shared/ui/tabs.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createElement } from 'react'
2+
import { renderToStaticMarkup } from 'react-dom/server'
13
import { describe, expect, it } from 'vitest'
24
import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'
35

@@ -28,4 +30,29 @@ describe('Tabs components', () => {
2830
expect(typeof TabsContent).toBe('function')
2931
expect(TabsContent.name).toBe('TabsContent')
3032
})
33+
34+
it('renders semantic tablist, tab, and tabpanel roles', () => {
35+
const html = renderToStaticMarkup(
36+
createElement(Tabs, {
37+
defaultValue: 'clawhub',
38+
children: [
39+
createElement(TabsList, {
40+
key: 'list',
41+
children: [
42+
createElement(TabsTrigger, { key: 'clawhub', value: 'clawhub', children: 'ClawHub CLI' }),
43+
createElement(TabsTrigger, { key: 'skillhub', value: 'skillhub', children: 'SkillHub CLI' }),
44+
],
45+
}),
46+
createElement(TabsContent, { key: 'clawhub-content', value: 'clawhub', children: 'clawhub command' }),
47+
createElement(TabsContent, { key: 'skillhub-content', value: 'skillhub', children: 'skillhub command' }),
48+
],
49+
}),
50+
)
51+
52+
expect(html).toContain('role="tablist"')
53+
expect(html).toContain('role="tab"')
54+
expect(html).toContain('aria-selected="true"')
55+
expect(html).toContain('aria-selected="false"')
56+
expect(html).toContain('role="tabpanel"')
57+
})
3158
})

web/src/shared/ui/tabs.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ interface TabsListProps {
4343
export function TabsList({ children, className }: TabsListProps) {
4444
return (
4545
<div
46+
role="tablist"
4647
className={cn(
4748
'inline-flex items-center gap-6 border-b text-sm',
4849
className
@@ -69,6 +70,8 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
6970
return (
7071
<button
7172
type="button"
73+
role="tab"
74+
aria-selected={isActive}
7275
onClick={() => context.setValue(value)}
7376
data-state={isActive ? 'active' : 'inactive'}
7477
className={cn(
@@ -96,5 +99,5 @@ export function TabsContent({ value, children, className }: TabsContentProps) {
9699

97100
if (context.value !== value) return null
98101

99-
return <div className={cn('animate-fade-in', className)}>{children}</div>
102+
return <div role="tabpanel" className={cn('animate-fade-in', className)}>{children}</div>
100103
}

0 commit comments

Comments
 (0)