mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
fix(web): preserve list navigation and menu dialog focus
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
efaa70e67c
commit
d841fca194
4 changed files with 87 additions and 23 deletions
|
|
@ -5,9 +5,9 @@
|
|||
|
||||
## 2. 实现
|
||||
- [x] 统一筛选面板及真实结果数量
|
||||
- [ ] 独立卡片、主要操作与更多菜单
|
||||
- [ ] 详情返回上下文与居中版本比较
|
||||
- [ ] 中英文、窄屏与键盘行为
|
||||
- [x] 独立卡片、主要操作与更多菜单
|
||||
- [x] 详情返回上下文与居中版本比较
|
||||
- [x] 中英文、窄屏与键盘行为
|
||||
|
||||
## 3. 验证与交付
|
||||
- [x] 筛选首片针对性测试与静态检查
|
||||
|
|
|
|||
|
|
@ -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<typeof makeSkill> & { 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(<MySkillsPage />)
|
||||
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(<StrictMode><MySkillsPage /></StrictMode>)
|
||||
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(<MySkillsPage />)
|
||||
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(<MySkillsPage />)
|
||||
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(<MySkillsPage />)
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<h3 className="min-w-0 break-words text-lg font-semibold [overflow-wrap:anywhere]">
|
||||
{hidden ? displayName : <a href={`/space/${skill.namespace}/${encodeURIComponent(skill.slug)}?${detailSearch}`}
|
||||
{hidden ? displayName : <a href={withBasePath(`/space/${skill.namespace}/${encodeURIComponent(skill.slug)}?${detailSearch}`)}
|
||||
className="rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
|
@ -466,7 +467,7 @@ export function MySkillsPage() {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 p-1.5" onClick={event => event.stopPropagation()}
|
||||
onCloseAutoFocus={event => { if (compareTarget || visibilityTarget) event.preventDefault() }}>
|
||||
onCloseAutoFocus={event => { if (compareTarget || visibilityTarget || archiveTarget || unarchiveTarget || withdrawTarget || promotionTarget || restoreHiddenTarget) event.preventDefault() }}>
|
||||
{!hidden && <DropdownMenuItem onSelect={openDetail}><Eye aria-hidden="true" className="mr-2 h-4 w-4" />{t('mySkills.viewDetail')}</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={async () => {
|
||||
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 ? (
|
||||
<Button data-analytics-id="skill.owned.clear" size="lg" variant="outline" onClick={handleClearSearch}>
|
||||
<Button data-analytics-id="skill.owned.clear" size="lg" variant="outline" onClick={handleClearFilters}>
|
||||
{t('mySkills.clearSearch')}
|
||||
</Button>
|
||||
) : undefined
|
||||
|
|
@ -540,10 +541,10 @@ export function MySkillsPage() {
|
|||
)}
|
||||
|
||||
{visibilityTarget && <SkillVisibilityDialog {...visibilityTarget} open hideTrigger onOpenChange={open => { if (!open) setVisibilityTarget(null) }}
|
||||
onCloseAutoFocus={event => { event.preventDefault(); compareTrigger.current?.focus() }} />}
|
||||
onCloseAutoFocus={restoreMoreFocus} />}
|
||||
<Dialog open={!!compareTarget} onOpenChange={open => { if (!open) setCompareTarget(null) }}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] max-w-6xl max-h-[85vh] gap-3 p-4 sm:p-6"
|
||||
onCloseAutoFocus={event => { event.preventDefault(); compareTrigger.current?.focus() }}>
|
||||
onCloseAutoFocus={restoreMoreFocus}>
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>{t('mySkills.compareVersions')}</DialogTitle>
|
||||
<DialogDescription className="break-all">{compareTarget?.name}</DialogDescription>
|
||||
|
|
@ -553,7 +554,7 @@ export function MySkillsPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog confirmAnalyticsId="skill.restore.confirm" cancelAnalyticsId="skill.restore.cancel"
|
||||
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.restore.confirm" cancelAnalyticsId="skill.restore.cancel"
|
||||
open={!!restoreHiddenTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
|
@ -567,6 +568,7 @@ export function MySkillsPage() {
|
|||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
onCloseAutoFocus={restoreMoreFocus}
|
||||
confirmAnalyticsId={skillPage?.items.find((skill) => 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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog confirmAnalyticsId="skill.archive.confirm" cancelAnalyticsId="skill.archive.cancel"
|
||||
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.archive.confirm" cancelAnalyticsId="skill.archive.cancel"
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
|
@ -594,7 +596,7 @@ export function MySkillsPage() {
|
|||
onConfirm={handleArchiveSkill}
|
||||
/>
|
||||
|
||||
<ConfirmDialog confirmAnalyticsId="skill.unarchive.confirm" cancelAnalyticsId="skill.unarchive.cancel"
|
||||
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.unarchive.confirm" cancelAnalyticsId="skill.unarchive.cancel"
|
||||
open={!!unarchiveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
|
@ -607,7 +609,7 @@ export function MySkillsPage() {
|
|||
onConfirm={handleUnarchiveSkill}
|
||||
/>
|
||||
|
||||
<ConfirmDialog confirmAnalyticsId="skill.review.withdraw.confirm" cancelAnalyticsId="skill.review.withdraw.cancel"
|
||||
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.review.withdraw.confirm" cancelAnalyticsId="skill.review.withdraw.cancel"
|
||||
open={!!withdrawTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!pendingRef.current) onOpenChange(next) }}>
|
||||
<DialogContent closeAnalyticsId={cancelAnalyticsId} data-testid={contentTestId} aria-label={title} hideClose={pending}>
|
||||
<DialogContent onCloseAutoFocus={onCloseAutoFocus} closeAnalyticsId={cancelAnalyticsId} data-testid={contentTestId} aria-label={title} hideClose={pending}>
|
||||
<DialogHeader className="min-w-0 text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{title}</DialogTitle>
|
||||
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue