dify/web/app/components/step-by-step-tour/__tests__/dropdown-menu.spec.tsx
Jingyi cb64446fa3
feat(web): add step-by-step tour shell (#38785)
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-20 09:23:25 +00:00

183 lines
6.3 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
getStepByStepTourDropdownMenuContentProps,
useStepByStepTourControlledDropdown,
} from '../dropdown-menu'
const STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR = 'data-step-by-step-tour-highlight-part'
const getHighlightPartValue = (props: unknown): string | undefined => {
if (!props || typeof props !== 'object') return undefined
const value = (props as Record<string, unknown>)[STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR]
return typeof value === 'string' ? value : undefined
}
const noopKeyboardHandler = () => {}
function TestDropdown({
allowTriggerCloseWhileControlled,
controlledOpen,
}: {
allowTriggerCloseWhileControlled?: boolean
controlledOpen?: boolean
}) {
const menu = useStepByStepTourControlledDropdown({
allowTriggerCloseWhileControlled,
controlledOpen,
})
return (
<>
<button type="button" onClick={() => menu.onOpenChange(!menu.open)}>
Toggle menu
</button>
<button type="button" onClick={menu.close}>
Close from action
</button>
<div
aria-label="dropdown"
data-controlled={String(menu.controlled)}
data-open={String(menu.open)}
/>
</>
)
}
describe('useStepByStepTourControlledDropdown', () => {
it('keeps ordinary dropdown toggle behavior when no tour controls it', () => {
render(<TestDropdown />)
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' }))
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' }))
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false')
})
it('opens for a tour step without locking the dropdown open', () => {
render(<TestDropdown controlledOpen />)
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true')
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-controlled', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' }))
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false')
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-controlled', 'false')
})
it('can keep a tour-opened dropdown locked until the tour leaves the step', () => {
render(<TestDropdown controlledOpen allowTriggerCloseWhileControlled={false} />)
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' }))
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true')
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-controlled', 'true')
})
it('closes when the tour leaves the dropdown step', async () => {
const { rerender } = render(<TestDropdown controlledOpen />)
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true')
rerender(<TestDropdown />)
await waitFor(() => {
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false')
})
})
})
describe('getStepByStepTourDropdownMenuContentProps', () => {
it('blocks presentation menu interactions without letting clicks bubble through', () => {
const onAction = vi.fn()
const onBackgroundClick = vi.fn()
const { popupClassName, popupProps, positionerProps } =
getStepByStepTourDropdownMenuContentProps({
highlightPart: 'tour-menu',
interactionMode: 'presentation',
})
render(
<div role="button" tabIndex={0} onClick={onBackgroundClick} onKeyDown={noopKeyboardHandler}>
<div
data-testid="positioner"
data-step-by-step-tour-highlight-part={getHighlightPartValue(positionerProps)}
onClickCapture={positionerProps?.onClickCapture}
onKeyDownCapture={positionerProps?.onKeyDownCapture}
onMouseDownCapture={positionerProps?.onMouseDownCapture}
onPointerDownCapture={positionerProps?.onPointerDownCapture}
>
<div
role="menu"
aria-hidden={popupProps?.['aria-hidden']}
onClickCapture={popupProps?.onClickCapture}
onKeyDownCapture={popupProps?.onKeyDownCapture}
onMouseDownCapture={popupProps?.onMouseDownCapture}
onPointerDownCapture={popupProps?.onPointerDownCapture}
>
<button type="button" role="menuitem" onClick={onAction}>
Delete
</button>
</div>
</div>
</div>,
)
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete', hidden: true }))
expect(onAction).not.toHaveBeenCalled()
expect(onBackgroundClick).not.toHaveBeenCalled()
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByTestId('positioner')).toHaveAttribute(
'data-step-by-step-tour-highlight-part',
'tour-menu',
)
expect(popupClassName).toContain('pointer-events-none')
})
it('leaves interactive menus clickable without bubbling through', () => {
const onAction = vi.fn()
const onBackgroundClick = vi.fn()
const onPopupClick = vi.fn()
const { popupProps, positionerProps } = getStepByStepTourDropdownMenuContentProps({
highlightPart: 'tour-menu',
interactionMode: 'interactive',
popupProps: {
onClick: onPopupClick,
},
})
render(
<div role="button" tabIndex={0} onClick={onBackgroundClick} onKeyDown={noopKeyboardHandler}>
<div data-step-by-step-tour-highlight-part={getHighlightPartValue(positionerProps)}>
<div
role="menu"
tabIndex={-1}
aria-hidden={popupProps?.['aria-hidden']}
onClick={popupProps?.onClick}
onKeyDown={noopKeyboardHandler}
>
<button type="button" role="menuitem" onClick={onAction}>
Create
</button>
</div>
</div>
</div>,
)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create' }))
expect(onAction).toHaveBeenCalledTimes(1)
expect(onPopupClick).toHaveBeenCalledTimes(1)
expect(onBackgroundClick).not.toHaveBeenCalled()
})
})