mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 22:28:46 +00: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 AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<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',
|
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
|
||||||
)}>
|
)}>
|
||||||
<AppTypeSelectTrigger values={value} />
|
<AppTypeSelectTrigger values={value} />
|
||||||
{value && value.length > 0 && <div className='h-4 w-4' onClick={(e) => {
|
{value && value.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.operation.clear')}
|
||||||
|
className="group h-4 w-4"
|
||||||
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onChange([])
|
onChange([])
|
||||||
}}>
|
}}
|
||||||
<RiCloseCircleFill className='h-3.5 w-3.5 cursor-pointer text-text-quaternary hover:text-text-tertiary' />
|
>
|
||||||
</div>}
|
<RiCloseCircleFill
|
||||||
|
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemTrigger>
|
</PortalToFollowElemTrigger>
|
||||||
<PortalToFollowElemContent className='z-[1002]'>
|
<PortalToFollowElemContent className='z-[1002]'>
|
||||||
|
|||||||
Reference in New Issue
Block a user