mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 17:27:16 -05:00
chore: add AppTypeSelector tests and improve clear button accessibility (#29791)
This commit is contained in:
144
web/app/components/app/type-selector/index.spec.tsx
Normal file
144
web/app/components/app/type-selector/index.spec.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
jest.mock('react-i18next')
|
||||
|
||||
describe('AppTypeSelector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers default rendering and the closed dropdown state.
|
||||
describe('Rendering', () => {
|
||||
it('should render "all types" trigger when no types selected', () => {
|
||||
render(<AppTypeSelector value={[]} onChange={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers prop-driven trigger variants (empty, single, multiple).
|
||||
describe('Props', () => {
|
||||
it('should render selected type label and clear button when a single type is selected', () => {
|
||||
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon-only trigger when multiple types are selected', () => {
|
||||
render(<AppTypeSelector value={[AppModeEnum.CHAT, AppModeEnum.WORKFLOW]} onChange={jest.fn()} />)
|
||||
|
||||
expect(screen.queryByText('app.typeSelector.all')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.typeSelector.chatbot')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers opening/closing the dropdown and selection updates.
|
||||
describe('User interactions', () => {
|
||||
it('should toggle option list when clicking the trigger', () => {
|
||||
render(<AppTypeSelector value={[]} onChange={jest.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('app.typeSelector.all'))
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('app.typeSelector.all'))
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with added type when selecting an unselected item', () => {
|
||||
const onChange = jest.fn()
|
||||
render(<AppTypeSelector value={[]} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.typeSelector.all'))
|
||||
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
|
||||
})
|
||||
|
||||
it('should call onChange with removed type when selecting an already-selected item', () => {
|
||||
const onChange = jest.fn()
|
||||
render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.typeSelector.workflow'))
|
||||
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should call onChange with appended type when selecting an additional item', () => {
|
||||
const onChange = jest.fn()
|
||||
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.typeSelector.chatbot'))
|
||||
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
|
||||
})
|
||||
|
||||
it('should clear selection without opening the dropdown when clicking clear button', () => {
|
||||
const onChange = jest.fn()
|
||||
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppTypeLabel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers label mapping for each supported app type.
|
||||
it.each([
|
||||
[AppModeEnum.CHAT, 'app.typeSelector.chatbot'],
|
||||
[AppModeEnum.AGENT_CHAT, 'app.typeSelector.agent'],
|
||||
[AppModeEnum.COMPLETION, 'app.typeSelector.completion'],
|
||||
[AppModeEnum.ADVANCED_CHAT, 'app.typeSelector.advanced'],
|
||||
[AppModeEnum.WORKFLOW, 'app.typeSelector.workflow'],
|
||||
] as const)('should render label %s for type %s', (_type, expectedLabel) => {
|
||||
render(<AppTypeLabel type={_type} />)
|
||||
expect(screen.getByText(expectedLabel)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Covers fallback behavior for unexpected app mode values.
|
||||
it('should render empty label for unknown type', () => {
|
||||
const { container } = render(<AppTypeLabel type={'unknown' as AppModeEnum} />)
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppTypeIcon', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers icon rendering for each supported app type.
|
||||
it.each([
|
||||
[AppModeEnum.CHAT],
|
||||
[AppModeEnum.AGENT_CHAT],
|
||||
[AppModeEnum.COMPLETION],
|
||||
[AppModeEnum.ADVANCED_CHAT],
|
||||
[AppModeEnum.WORKFLOW],
|
||||
] as const)('should render icon for type %s', (type) => {
|
||||
const { container } = render(<AppTypeIcon type={type} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Covers fallback behavior for unexpected app mode values.
|
||||
it('should render nothing for unknown type', () => {
|
||||
const { container } = render(<AppTypeIcon type={'unknown' as AppModeEnum} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
|
||||
|
||||
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
@@ -37,12 +38,21 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
|
||||
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
|
||||
)}>
|
||||
<AppTypeSelectTrigger values={value} />
|
||||
{value && value.length > 0 && <div className='h-4 w-4' onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}}>
|
||||
<RiCloseCircleFill className='h-3.5 w-3.5 cursor-pointer text-text-quaternary hover:text-text-tertiary' />
|
||||
</div>}
|
||||
{value && value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.operation.clear')}
|
||||
className="group h-4 w-4"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}}
|
||||
>
|
||||
<RiCloseCircleFill
|
||||
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1002]'>
|
||||
|
||||
Reference in New Issue
Block a user