Files
urbanLifeline/dify/web/app/components/base/drawer/index.spec.tsx
2025-12-01 17:21:38 +08:00

676 lines
19 KiB
TypeScript

import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Drawer from './index'
import type { IDrawerProps } from './index'
// Capture dialog onClose for testing
let capturedDialogOnClose: (() => void) | null = null
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock @headlessui/react
jest.mock('@headlessui/react', () => ({
Dialog: ({ children, open, onClose, className, unmount }: {
children: React.ReactNode
open: boolean
onClose: () => void
className: string
unmount: boolean
}) => {
capturedDialogOnClose = onClose
if (!open)
return null
return (
<div
data-testid="dialog"
data-open={open}
data-unmount={unmount}
className={className}
role="dialog"
>
{children}
</div>
)
},
DialogBackdrop: ({ children, className, onClick }: {
children?: React.ReactNode
className: string
onClick: () => void
}) => (
<div
data-testid="dialog-backdrop"
className={className}
onClick={onClick}
>
{children}
</div>
),
DialogTitle: ({ children, as: _as, className, ...props }: {
children: React.ReactNode
as?: string
className?: string
}) => (
<div data-testid="dialog-title" className={className} {...props}>
{children}
</div>
),
}))
// Mock XMarkIcon
jest.mock('@heroicons/react/24/outline', () => ({
XMarkIcon: ({ className, onClick }: { className: string; onClick?: () => void }) => (
<svg data-testid="close-icon" className={className} onClick={onClick} />
),
}))
// Helper function to render Drawer with default props
const defaultProps: IDrawerProps = {
isOpen: true,
onClose: jest.fn(),
children: <div data-testid="drawer-content">Content</div>,
}
const renderDrawer = (props: Partial<IDrawerProps> = {}) => {
const mergedProps = { ...defaultProps, ...props }
return render(<Drawer {...mergedProps} />)
}
describe('Drawer', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedDialogOnClose = null
})
// Basic rendering tests
describe('Rendering', () => {
it('should render when isOpen is true', () => {
// Arrange & Act
renderDrawer({ isOpen: true })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByTestId('drawer-content')).toBeInTheDocument()
})
it('should not render when isOpen is false', () => {
// Arrange & Act
renderDrawer({ isOpen: false })
// Assert
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render children content', () => {
// Arrange
const childContent = <p data-testid="custom-child">Custom Content</p>
// Act
renderDrawer({ children: childContent })
// Assert
expect(screen.getByTestId('custom-child')).toBeInTheDocument()
expect(screen.getByText('Custom Content')).toBeInTheDocument()
})
})
// Title and description tests
describe('Title and Description', () => {
it('should render title when provided', () => {
// Arrange & Act
renderDrawer({ title: 'Test Title' })
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should not render title when not provided', () => {
// Arrange & Act
renderDrawer({ title: '' })
// Assert
const titles = screen.queryAllByTestId('dialog-title')
const titleWithText = titles.find(el => el.textContent !== '')
expect(titleWithText).toBeUndefined()
})
it('should render description when provided', () => {
// Arrange & Act
renderDrawer({ description: 'Test Description' })
// Assert
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
it('should not render description when not provided', () => {
// Arrange & Act
renderDrawer({ description: '' })
// Assert
expect(screen.queryByText('Test Description')).not.toBeInTheDocument()
})
it('should render both title and description together', () => {
// Arrange & Act
renderDrawer({
title: 'My Title',
description: 'My Description',
})
// Assert
expect(screen.getByText('My Title')).toBeInTheDocument()
expect(screen.getByText('My Description')).toBeInTheDocument()
})
})
// Close button tests
describe('Close Button', () => {
it('should render close icon when showClose is true', () => {
// Arrange & Act
renderDrawer({ showClose: true })
// Assert
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
})
it('should not render close icon when showClose is false', () => {
// Arrange & Act
renderDrawer({ showClose: false })
// Assert
expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument()
})
it('should not render close icon by default', () => {
// Arrange & Act
renderDrawer({})
// Assert
expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument()
})
it('should call onClose when close icon is clicked', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ showClose: true, onClose })
// Act
fireEvent.click(screen.getByTestId('close-icon'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Backdrop/Mask tests
describe('Backdrop and Mask', () => {
it('should render backdrop when noOverlay is false', () => {
// Arrange & Act
renderDrawer({ noOverlay: false })
// Assert
expect(screen.getByTestId('dialog-backdrop')).toBeInTheDocument()
})
it('should not render backdrop when noOverlay is true', () => {
// Arrange & Act
renderDrawer({ noOverlay: true })
// Assert
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument()
})
it('should apply mask background when mask is true', () => {
// Arrange & Act
renderDrawer({ mask: true })
// Assert
const backdrop = screen.getByTestId('dialog-backdrop')
expect(backdrop.className).toContain('bg-black/30')
})
it('should not apply mask background when mask is false', () => {
// Arrange & Act
renderDrawer({ mask: false })
// Assert
const backdrop = screen.getByTestId('dialog-backdrop')
expect(backdrop.className).not.toContain('bg-black/30')
})
it('should call onClose when backdrop is clicked and clickOutsideNotOpen is false', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: false })
// Act
fireEvent.click(screen.getByTestId('dialog-backdrop'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when backdrop is clicked and clickOutsideNotOpen is true', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: true })
// Act
fireEvent.click(screen.getByTestId('dialog-backdrop'))
// Assert
expect(onClose).not.toHaveBeenCalled()
})
})
// Footer tests
describe('Footer', () => {
it('should render default footer with cancel and save buttons when footer is undefined', () => {
// Arrange & Act
renderDrawer({ footer: undefined })
// Assert
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
})
it('should not render footer when footer is null', () => {
// Arrange & Act
renderDrawer({ footer: null })
// Assert
expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.save')).not.toBeInTheDocument()
})
it('should render custom footer when provided', () => {
// Arrange
const customFooter = <div data-testid="custom-footer">Custom Footer</div>
// Act
renderDrawer({ footer: customFooter })
// Assert
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument()
})
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const onCancel = jest.fn()
renderDrawer({ onCancel })
// Act
const cancelButton = screen.getByText('common.operation.cancel')
fireEvent.click(cancelButton)
// Assert
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onOk when save button is clicked', () => {
// Arrange
const onOk = jest.fn()
renderDrawer({ onOk })
// Act
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
// Assert
expect(onOk).toHaveBeenCalledTimes(1)
})
it('should not throw when onCancel is not provided and cancel is clicked', () => {
// Arrange
renderDrawer({ onCancel: undefined })
// Act & Assert
expect(() => {
fireEvent.click(screen.getByText('common.operation.cancel'))
}).not.toThrow()
})
it('should not throw when onOk is not provided and save is clicked', () => {
// Arrange
renderDrawer({ onOk: undefined })
// Act & Assert
expect(() => {
fireEvent.click(screen.getByText('common.operation.save'))
}).not.toThrow()
})
})
// Custom className tests
describe('Custom ClassNames', () => {
it('should apply custom dialogClassName', () => {
// Arrange & Act
renderDrawer({ dialogClassName: 'custom-dialog-class' })
// Assert
expect(screen.getByRole('dialog').className).toContain('custom-dialog-class')
})
it('should apply custom dialogBackdropClassName', () => {
// Arrange & Act
renderDrawer({ dialogBackdropClassName: 'custom-backdrop-class' })
// Assert
expect(screen.getByTestId('dialog-backdrop').className).toContain('custom-backdrop-class')
})
it('should apply custom containerClassName', () => {
// Arrange & Act
const { container } = renderDrawer({ containerClassName: 'custom-container-class' })
// Assert
const containerDiv = container.querySelector('.custom-container-class')
expect(containerDiv).toBeInTheDocument()
})
it('should apply custom panelClassName', () => {
// Arrange & Act
const { container } = renderDrawer({ panelClassName: 'custom-panel-class' })
// Assert
const panelDiv = container.querySelector('.custom-panel-class')
expect(panelDiv).toBeInTheDocument()
})
})
// Position tests
describe('Position', () => {
it('should apply center position class when positionCenter is true', () => {
// Arrange & Act
const { container } = renderDrawer({ positionCenter: true })
// Assert
const containerDiv = container.querySelector('.\\!justify-center')
expect(containerDiv).toBeInTheDocument()
})
it('should use end position by default when positionCenter is false', () => {
// Arrange & Act
const { container } = renderDrawer({ positionCenter: false })
// Assert
const containerDiv = container.querySelector('.justify-end')
expect(containerDiv).toBeInTheDocument()
})
})
// Unmount prop tests
describe('Unmount Prop', () => {
it('should pass unmount prop to Dialog component', () => {
// Arrange & Act
renderDrawer({ unmount: true })
// Assert
expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('true')
})
it('should default unmount to false', () => {
// Arrange & Act
renderDrawer({})
// Assert
expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('false')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty string title', () => {
// Arrange & Act
renderDrawer({ title: '' })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle empty string description', () => {
// Arrange & Act
renderDrawer({ description: '' })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle special characters in title', () => {
// Arrange
const specialTitle = '<script>alert("xss")</script>'
// Act
renderDrawer({ title: specialTitle })
// Assert
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle very long title', () => {
// Arrange
const longTitle = 'A'.repeat(500)
// Act
renderDrawer({ title: longTitle })
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle complex children with multiple elements', () => {
// Arrange
const complexChildren = (
<div data-testid="complex-children">
<h1>Heading</h1>
<p>Paragraph</p>
<input data-testid="input-element" />
<button data-testid="button-element">Button</button>
</div>
)
// Act
renderDrawer({ children: complexChildren })
// Assert
expect(screen.getByTestId('complex-children')).toBeInTheDocument()
expect(screen.getByText('Heading')).toBeInTheDocument()
expect(screen.getByText('Paragraph')).toBeInTheDocument()
expect(screen.getByTestId('input-element')).toBeInTheDocument()
expect(screen.getByTestId('button-element')).toBeInTheDocument()
})
it('should handle null children gracefully', () => {
// Arrange & Act
renderDrawer({ children: null as unknown as React.ReactNode })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle undefined footer without crashing', () => {
// Arrange & Act
renderDrawer({ footer: undefined })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle rapid open/close toggles', () => {
// Arrange
const onClose = jest.fn()
const { rerender } = render(
<Drawer {...defaultProps} isOpen={true} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
// Act - Toggle multiple times
rerender(
<Drawer {...defaultProps} isOpen={false} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
rerender(
<Drawer {...defaultProps} isOpen={true} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
rerender(
<Drawer {...defaultProps} isOpen={false} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
// Assert
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
// Combined prop scenarios
describe('Combined Prop Scenarios', () => {
it('should render with all optional props', () => {
// Arrange & Act
renderDrawer({
title: 'Full Feature Title',
description: 'Full Feature Description',
dialogClassName: 'custom-dialog',
dialogBackdropClassName: 'custom-backdrop',
containerClassName: 'custom-container',
panelClassName: 'custom-panel',
showClose: true,
mask: true,
positionCenter: true,
unmount: true,
noOverlay: false,
footer: <div data-testid="custom-full-footer">Footer</div>,
})
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Full Feature Title')).toBeInTheDocument()
expect(screen.getByText('Full Feature Description')).toBeInTheDocument()
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
expect(screen.getByTestId('custom-full-footer')).toBeInTheDocument()
})
it('should render minimal drawer with only required props', () => {
// Arrange
const minimalProps: IDrawerProps = {
isOpen: true,
onClose: jest.fn(),
children: <div>Minimal Content</div>,
}
// Act
render(<Drawer {...minimalProps} />)
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Minimal Content')).toBeInTheDocument()
})
it('should handle showClose with title simultaneously', () => {
// Arrange & Act
renderDrawer({
title: 'Title with Close',
showClose: true,
})
// Assert
expect(screen.getByText('Title with Close')).toBeInTheDocument()
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
})
it('should handle noOverlay with clickOutsideNotOpen', () => {
// Arrange
const onClose = jest.fn()
// Act
renderDrawer({
noOverlay: true,
clickOutsideNotOpen: true,
onClose,
})
// Assert - backdrop should not exist
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument()
})
})
// Dialog onClose callback tests (e.g., Escape key)
describe('Dialog onClose Callback', () => {
it('should call onClose when Dialog triggers close and clickOutsideNotOpen is false', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: false })
// Act - Simulate Dialog's onClose (e.g., pressing Escape)
capturedDialogOnClose?.()
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when Dialog triggers close and clickOutsideNotOpen is true', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: true })
// Act - Simulate Dialog's onClose (e.g., pressing Escape)
capturedDialogOnClose?.()
// Assert
expect(onClose).not.toHaveBeenCalled()
})
it('should call onClose by default when Dialog triggers close', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose })
// Act
capturedDialogOnClose?.()
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Event handler interaction tests
describe('Event Handler Interactions', () => {
it('should handle multiple consecutive close icon clicks', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ showClose: true, onClose })
// Act
const closeIcon = screen.getByTestId('close-icon')
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
// Assert
expect(onClose).toHaveBeenCalledTimes(3)
})
it('should handle onCancel and onOk being the same function', () => {
// Arrange
const handler = jest.fn()
renderDrawer({ onCancel: handler, onOk: handler })
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(handler).toHaveBeenCalledTimes(2)
})
})
})