diff --git a/openspec/changes/improve-my-skills-management/tasks.md b/openspec/changes/improve-my-skills-management/tasks.md index 664787851..faff71f22 100644 --- a/openspec/changes/improve-my-skills-management/tasks.md +++ b/openspec/changes/improve-my-skills-management/tasks.md @@ -5,9 +5,9 @@ ## 2. 实现 - [x] 统一筛选面板及真实结果数量 -- [ ] 独立卡片、主要操作与更多菜单 -- [ ] 详情返回上下文与居中版本比较 -- [ ] 中英文、窄屏与键盘行为 +- [x] 独立卡片、主要操作与更多菜单 +- [x] 详情返回上下文与居中版本比较 +- [x] 中英文、窄屏与键盘行为 ## 3. 验证与交付 - [x] 筛选首片针对性测试与静态检查 diff --git a/web/src/pages/dashboard/my-skills-interactions.test.tsx b/web/src/pages/dashboard/my-skills-interactions.test.tsx index 13ba0c59a..e26c546e8 100644 --- a/web/src/pages/dashboard/my-skills-interactions.test.tsx +++ b/web/src/pages/dashboard/my-skills-interactions.test.tsx @@ -1,7 +1,7 @@ /** @vitest-environment jsdom */ import { StrictMode } from 'react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const navigate = vi.fn() const copy = vi.fn() @@ -9,7 +9,9 @@ const retryVersions = vi.fn() const retryCompare = vi.fn() const useVersions = vi.fn() const useCompare = vi.fn() -let skill = makeSkill() +vi.hoisted(() => { vi.stubEnv('BASE_URL', '/skillhub/') }) +let skill: ReturnType & { ownerPreviewVersion?: { id: number; version: string; status: string } } = makeSkill() +let showEmpty = false let superAdmin = false let search: { filter?: string; q?: string; namespace?: string; page?: number } = {} @@ -24,7 +26,7 @@ function makeSkill() { vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigate, - useLocation: () => ({ pathname: '/dashboard/skills', searchStr: '?page=2' }), + useLocation: () => ({ pathname: '/dashboard/skills', searchStr: `?${new URLSearchParams(Object.entries(search).map(([key, value]) => [key, String(value)]))}` }), useSearch: () => search, })) vi.mock('react-i18next', async () => ({ @@ -33,7 +35,7 @@ vi.mock('react-i18next', async () => ({ })) vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ user: { userId: 'reader' }, hasRole: () => superAdmin }) })) vi.mock('@/shared/hooks/use-user-queries', () => ({ - useMySkills: () => ({ data: { items: [skill], total: 1, size: 10 }, isLoading: false }), + useMySkills: () => ({ data: { items: showEmpty ? [] : [skill], total: showEmpty ? 0 : 1, size: 10 }, isLoading: false }), useSubmitPromotion: () => ({ mutateAsync: vi.fn() }), })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: [] }) })) @@ -62,6 +64,7 @@ async function openMenu() { beforeEach(() => { skill = makeSkill() + showEmpty = false search = {} superAdmin = false navigate.mockReset() @@ -80,6 +83,7 @@ beforeEach(() => { vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) }) afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) +afterAll(() => vi.unstubAllEnvs()) describe('My Skills interactions with real menu and dialog', () => { it('loads comparison on demand and returns focus after Escape without navigating', async () => { @@ -140,7 +144,7 @@ describe('My Skills interactions with real menu and dialog', () => { search = { filter: 'PUBLISHED', page: 2 } const first = render() fireEvent.click(screen.getByRole('link', { name: 'Demo' })) - expect(navigate.mock.calls[0][0].search.returnTo).toBe('/dashboard/skills?page=2') + expect(navigate.mock.calls[0][0].search.returnTo).toBe('/dashboard/skills?filter=PUBLISHED&page=2') first.unmount() render() await waitFor(() => expect(window.scrollTo).toHaveBeenCalledWith({ top: 450, behavior: 'instant' })) @@ -189,4 +193,60 @@ describe('My Skills interactions with real menu and dialog', () => { await screen.findByText('skillCompare.loading') }) + + it.each(['ordinary', 'ctrlKey', 'metaKey'] as const)('retains deployment prefix and return context when opening with %s', (modifier) => { + search = { filter: 'PUBLISHED', q: 'a skill', namespace: 'team', page: 2 } + skill.ownerPreviewVersion = { id: 12, version: '1.2.0', status: 'PENDING_REVIEW' } + render() + const link = screen.getByRole('link', { name: 'Demo' }) + const href = new URL(link.getAttribute('href') ?? '', 'https://example.test') + expect(href.pathname).toBe('/skillhub/space/team/demo') + expect(href.searchParams.get('returnTo')).toBe('/dashboard/skills?filter=PUBLISHED&q=a+skill&namespace=team&page=2') + expect(href.searchParams.get('version')).toBe('1.2.0') + // Prevent jsdom's unsupported browser navigation after checking the native href. + link.addEventListener('click', event => event.preventDefault(), { once: true }) + fireEvent.click(link, modifier === 'ordinary' ? {} : { [modifier]: true }) + if (modifier === 'ordinary') { + expect(navigate).toHaveBeenCalledWith({ + to: '/space/team/demo', + search: { returnTo: href.searchParams.get('returnTo'), version: '1.2.0' }, + }) + } else expect(navigate).not.toHaveBeenCalled() + }) + + it('clears all four URL conditions from the empty-state action', () => { + search = { filter: 'PUBLISHED', q: 'missing', namespace: 'team', page: 2 } + showEmpty = true + render() + const emptyStateClear = screen.getAllByRole('button', { name: 'mySkills.clearSearch' }).at(-1) + if (!emptyStateClear) throw new Error('Missing empty-state clear action') + fireEvent.click(emptyStateClear) + expect(navigate).toHaveBeenCalledOnce() + expect(navigate.mock.calls[0][0].search(search)).toEqual({ filter: undefined, q: undefined, namespace: undefined, page: 0 }) + }) + + it.each([ + ['archive', 'archiveConfirmTitle'], + ['unarchive', 'unarchiveConfirmTitle'], + ['withdrawReview', 'withdrawConfirmTitle'], + ['promoteToGlobal', 'promotionConfirmTitle'], + ['restoreHidden', 'restoreHiddenConfirmTitle'], + ])('restores the more trigger after Escape and Cancel on %s', async (action, title) => { + if (action === 'unarchive') skill.status = 'ARCHIVED' + if (action === 'withdrawReview') skill.ownerPreviewVersion = { id: 12, version: '1.2.0', status: 'PENDING_REVIEW' } + if (action === 'promoteToGlobal') skill.canSubmitPromotion = true + if (action === 'restoreHidden') { search = { filter: 'HIDDEN' }; superAdmin = true } + render() + for (const closeViaEscape of [true, false]) { + const trigger = await openMenu() + fireEvent.click(screen.getByRole('menuitem', { name: `mySkills.${action}` })) + const dialog = await screen.findByRole('dialog', { name: `mySkills.${title}` }) + if (closeViaEscape) fireEvent.keyDown(dialog, { key: 'Escape' }) + else fireEvent.click(screen.getByRole('button', { name: 'dialog.cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + await waitFor(() => expect(document.activeElement).toBe(trigger)) + expect(navigate).not.toHaveBeenCalled() + } + }) + }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index ba9704ac6..c310af2b3 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -23,6 +23,7 @@ import { useRestoreHiddenSkill } from '@/features/admin/use-admin-skills' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' +import { withBasePath } from '@/shared/lib/base-path' import { buildReturnTo } from '@/shared/lib/auth-route' import { SkillVisibilityDialog } from '@/features/skill/skill-visibility-dialog' import { SkillAvatar } from '@/features/skill/skill-avatar' @@ -123,6 +124,11 @@ export function MySkillsPage() { return () => cancelAnimationFrame(frame) }, [isLoading, scrollKey]) + const restoreMoreFocus = (event: Event) => { + event.preventDefault() + compareTrigger.current?.focus() + } + const handleSkillClick = (namespace: string, slug: string, version?: string) => { rememberMySkillsScroll(scrollKey, window.scrollY) navigate({ @@ -131,11 +137,6 @@ export function MySkillsPage() { }) } - const handleClearSearch = () => { - setKeywordInput('') - updateSearch({ q: undefined, namespace: undefined, page: 0 }) - } - const handleClearFilters = () => { setKeywordInput('') updateSearch({ q: undefined, namespace: undefined, filter: undefined, page: 0 }) @@ -435,7 +436,7 @@ export function MySkillsPage() {

- {hidden ? displayName : { event.stopPropagation() @@ -466,7 +467,7 @@ export function MySkillsPage() { event.stopPropagation()} - onCloseAutoFocus={event => { if (compareTarget || visibilityTarget) event.preventDefault() }}> + onCloseAutoFocus={event => { if (compareTarget || visibilityTarget || archiveTarget || unarchiveTarget || withdrawTarget || promotionTarget || restoreHiddenTarget) event.preventDefault() }}> {!hidden && } { try { await copyToClipboard(coordinate); toast.success(t('mySkills.coordinateCopied')) } @@ -531,7 +532,7 @@ export function MySkillsPage() { description={hasActiveSearch ? t('mySkills.emptySearchDescription') : t(emptyStateKey.description)} action={ hasActiveSearch ? ( - ) : undefined @@ -540,10 +541,10 @@ export function MySkillsPage() { )} {visibilityTarget && { if (!open) setVisibilityTarget(null) }} - onCloseAutoFocus={event => { event.preventDefault(); compareTrigger.current?.focus() }} />} + onCloseAutoFocus={restoreMoreFocus} />} { if (!open) setCompareTarget(null) }}> { event.preventDefault(); compareTrigger.current?.focus() }}> + onCloseAutoFocus={restoreMoreFocus}> {t('mySkills.compareVersions')} {compareTarget?.name} @@ -553,7 +554,7 @@ export function MySkillsPage() { - { if (!open) { @@ -567,6 +568,7 @@ export function MySkillsPage() { /> skill.id === promotionTarget?.skillId)?.managementState?.promotionRole === 'TEAM_SOURCE' ? 'skill.promotion.update.confirm' : 'skill.promotion.create.confirm'} cancelAnalyticsId={skillPage?.items.find((skill) => skill.id === promotionTarget?.skillId)?.managementState?.promotionRole === 'TEAM_SOURCE' ? 'skill.promotion.update.cancel' : 'skill.promotion.create.cancel'} open={!!promotionTarget} @@ -581,7 +583,7 @@ export function MySkillsPage() { onConfirm={handleSubmitPromotion} /> - { if (!open) { @@ -594,7 +596,7 @@ export function MySkillsPage() { onConfirm={handleArchiveSkill} /> - { if (!open) { @@ -607,7 +609,7 @@ export function MySkillsPage() { onConfirm={handleUnarchiveSkill} /> - { if (!open) { diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx index 1e73f4d78..8827ac073 100644 --- a/web/src/shared/components/confirm-dialog.tsx +++ b/web/src/shared/components/confirm-dialog.tsx @@ -24,6 +24,7 @@ interface ConfirmDialogProps { contentTestId?: string confirmButtonTestId?: string closeOnConfirm?: boolean + onCloseAutoFocus?: (event: Event) => void } export function ConfirmDialog({ @@ -40,6 +41,7 @@ export function ConfirmDialog({ confirmAnalyticsId, cancelAnalyticsId, closeOnConfirm = true, + onCloseAutoFocus, }: ConfirmDialogProps) { const { t } = useTranslation() const openRef = useRef(open) @@ -65,7 +67,7 @@ export function ConfirmDialog({ return ( { if (!pendingRef.current) onOpenChange(next) }}> - + {title} {description && {description}}