mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
refactor(web): improve a11y and design-system consistency for date/time picker and auto-update strategy picker (#35627)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
5ad05cf98a
commit
ea47036a5d
@ -3506,11 +3506,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
|
||||
@ -43,7 +43,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
expect(item).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
@ -54,7 +54,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
})
|
||||
@ -100,7 +100,7 @@ describe('OptionListItem', () => {
|
||||
Clickable
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@ -111,7 +111,7 @@ describe('OptionListItem', () => {
|
||||
Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
|
||||
})
|
||||
@ -126,7 +126,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import OptionList from '../option-list'
|
||||
|
||||
describe('OptionList', () => {
|
||||
it('should render a scrollable list with hidden scrollbar styles', () => {
|
||||
render(
|
||||
<OptionList>
|
||||
<li>Item</li>
|
||||
</OptionList>,
|
||||
)
|
||||
|
||||
const list = screen.getByRole('list')
|
||||
|
||||
expect(list).toHaveClass('overflow-y-auto')
|
||||
expect(list).toHaveClass('[scrollbar-width:none]')
|
||||
expect(list).toHaveClass('[&::-webkit-scrollbar]:hidden')
|
||||
})
|
||||
|
||||
it('should append caller className after default classes', () => {
|
||||
render(
|
||||
<OptionList className="custom-list">
|
||||
<li>Item</li>
|
||||
</OptionList>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('list')).toHaveClass('custom-list')
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
@ -7,7 +7,8 @@ type OptionListItemProps = {
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
noAutoScroll?: boolean
|
||||
} & React.LiHTMLAttributes<HTMLLIElement>
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const OptionListItem: FC<OptionListItemProps> = ({
|
||||
isSelected,
|
||||
@ -25,16 +26,21 @@ const OptionListItem: FC<OptionListItemProps> = ({
|
||||
return (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text',
|
||||
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text outline-hidden',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset',
|
||||
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
|
||||
type OptionListProps = {
|
||||
children: ReactNode
|
||||
} & HTMLAttributes<HTMLUListElement>
|
||||
|
||||
const optionListClassName = cn(
|
||||
'flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]',
|
||||
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
)
|
||||
|
||||
const OptionList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: OptionListProps) => {
|
||||
return (
|
||||
<ul className={cn(optionListClassName, className)} {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(OptionList)
|
||||
@ -64,13 +64,13 @@ describe('TimePickerOptions', () => {
|
||||
it('should render selected hour in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedHour = screen.getAllByRole('listitem').find(item => item.textContent === '05')
|
||||
const selectedHour = screen.getAllByRole('button').find(item => item.textContent === '05')
|
||||
expect(selectedHour)!.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
it('should render selected minute in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedMinute = screen.getAllByRole('listitem').find(item => item.textContent === '30')
|
||||
const selectedMinute = screen.getAllByRole('button').find(item => item.textContent === '30')
|
||||
expect(selectedMinute)!.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { TimeOptionsProps } from '../types'
|
||||
import * as React from 'react'
|
||||
import OptionList from '../common/option-list'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
import { useTimeOptions } from '../hooks'
|
||||
|
||||
@ -16,7 +17,7 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-1 p-2">
|
||||
{/* Hour */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
hourOptions.map((hour) => {
|
||||
const isSelected = selectedTime?.format('hh') === hour
|
||||
@ -31,9 +32,9 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Minute */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
(minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => {
|
||||
const isSelected = selectedTime?.format('mm') === minute
|
||||
@ -48,9 +49,9 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Period */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
periodOptions.map((period) => {
|
||||
const isSelected = selectedTime?.format('A') === period
|
||||
@ -66,7 +67,7 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { YearAndMonthPickerOptionsProps } from '../types'
|
||||
import * as React from 'react'
|
||||
import OptionList from '../common/option-list'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
import { useMonths, useYearOptions } from '../hooks'
|
||||
|
||||
@ -16,7 +17,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-1 p-2">
|
||||
{/* Month Picker */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
months.map((month, index) => {
|
||||
const isSelected = selectedMonth === index
|
||||
@ -31,9 +32,9 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Year Picker */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
yearOptions.map((year) => {
|
||||
const isSelected = selectedYear === year
|
||||
@ -48,7 +49,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AutoUpdateConfig } from '../types'
|
||||
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
@ -803,165 +804,103 @@ describe('auto-update-setting', () => {
|
||||
})
|
||||
|
||||
describe('StrategyPicker (strategy-picker.tsx)', () => {
|
||||
const defaultProps = {
|
||||
value: AUTO_UPDATE_STRATEGY.disabled,
|
||||
onChange: vi.fn(),
|
||||
const i18nKeyByStrategy: Record<AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest'> = {
|
||||
[AUTO_UPDATE_STRATEGY.disabled]: 'disabled',
|
||||
[AUTO_UPDATE_STRATEGY.fixOnly]: 'fixOnly',
|
||||
[AUTO_UPDATE_STRATEGY.latest]: 'latest',
|
||||
}
|
||||
|
||||
const triggerName = (strategy: AUTO_UPDATE_STRATEGY) =>
|
||||
new RegExp(`plugin\\.autoUpdate\\.strategy\\.${i18nKeyByStrategy[strategy]}\\.name`, 'i')
|
||||
|
||||
const findOption = async (key: 'disabled' | 'fixOnly' | 'latest') => {
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
const option = options.find(item =>
|
||||
item.textContent?.includes(`plugin.autoUpdate.strategy.${key}.name`),
|
||||
)
|
||||
if (!option)
|
||||
throw new Error(`Strategy option "${key}" not found`)
|
||||
return option
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render trigger button with current strategy label', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />)
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render dropdown content when closed', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all strategy options when open', () => {
|
||||
// Arrange
|
||||
mockPortalOpen = true
|
||||
it('should render all strategy options when open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) }))
|
||||
|
||||
// Wait for portal to open
|
||||
if (mockPortalOpen) {
|
||||
// Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown)
|
||||
expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument()
|
||||
}
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
expect(options).toHaveLength(3)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name'))).toBe(true)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name'))).toBe(true)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle dropdown when trigger is clicked', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
|
||||
// Assert - initially closed
|
||||
expect(mockPortalOpen).toBe(false)
|
||||
|
||||
// Act - click trigger
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Assert - portal trigger element should still be in document
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with fixOnly when Bug Fixes Only option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Bug Fixes Only" option
|
||||
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
|
||||
expect(fixOnlyOption).toBeInTheDocument()
|
||||
fireEvent.click(fixOnlyOption!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
||||
})
|
||||
|
||||
it('should call onChange with latest when Latest Version option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Latest Version" option
|
||||
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
|
||||
expect(latestOption).toBeInTheDocument()
|
||||
fireEvent.click(latestOption!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
|
||||
})
|
||||
|
||||
it('should call onChange with disabled when Disabled option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Disabled" option - need to find the one in the dropdown, not the button
|
||||
const disabledOptions = screen.getAllByText('plugin.autoUpdate.strategy.disabled.name')
|
||||
// The second one should be in the dropdown
|
||||
const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]'))
|
||||
expect(dropdownOption).toBeInTheDocument()
|
||||
fireEvent.click(dropdownOption!.closest('div[class*="cursor-pointer"]')!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.disabled)
|
||||
})
|
||||
|
||||
it('should stop event propagation when option is clicked', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
const parentClickHandler = vi.fn()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<div onClick={parentClickHandler}>
|
||||
<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Click an option
|
||||
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(fixOnlyOption!)
|
||||
|
||||
// Assert - onChange is called but parent click handler should not propagate
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
||||
})
|
||||
|
||||
it('should render check icon for currently selected option', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
|
||||
// Act - render with fixOnly selected
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert - RiCheckLine should be rendered (check icon)
|
||||
// Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent)
|
||||
const allFixOnlyTexts = screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]'))
|
||||
const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]')
|
||||
expect(optionContainer).toBeInTheDocument()
|
||||
// The check icon SVG should exist within the option
|
||||
expect(optionContainer?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render check icon for non-selected options', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
|
||||
// Act - render with disabled selected
|
||||
it('should open and close the menu when the trigger is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert - check the Latest Version option should not have check icon
|
||||
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
|
||||
// The svg should only be in selected option, not in non-selected
|
||||
const checkIconContainer = latestOption?.querySelector('div.mr-1')
|
||||
// Non-selected option should have empty check icon container
|
||||
expect(checkIconContainer?.querySelector('svg')).toBeNull()
|
||||
const trigger = screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each<[AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest', AUTO_UPDATE_STRATEGY]>([
|
||||
[AUTO_UPDATE_STRATEGY.disabled, 'fixOnly', AUTO_UPDATE_STRATEGY.fixOnly],
|
||||
[AUTO_UPDATE_STRATEGY.disabled, 'latest', AUTO_UPDATE_STRATEGY.latest],
|
||||
[AUTO_UPDATE_STRATEGY.fixOnly, 'disabled', AUTO_UPDATE_STRATEGY.disabled],
|
||||
])('should call onChange with %s -> %s when option is selected', async (initial, optionKey, expected) => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<StrategyPicker value={initial} onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(initial) }))
|
||||
await user.click(await findOption(optionKey))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('should mark only the currently selected option with aria-checked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
|
||||
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
const checked = options.filter(o => o.getAttribute('aria-checked') === 'true')
|
||||
|
||||
expect(checked).toHaveLength(1)
|
||||
expect(checked[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
})
|
||||
|
||||
it('should render the check indicator inside the selected option only', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
|
||||
|
||||
const fixOnlyOption = await findOption('fixOnly')
|
||||
const latestOption = await findOption('latest')
|
||||
|
||||
expect(fixOnlyOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
expect(latestOption.querySelector('.i-ri-check-line')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1280,7 +1219,9 @@ describe('auto-update-setting', () => {
|
||||
render(<AutoUpdateSetting {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show time picker when strategy is not disabled', () => {
|
||||
@ -1407,16 +1348,27 @@ describe('auto-update-setting', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated strategy when strategy changes', () => {
|
||||
it('should call onChange with updated strategy when strategy changes', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const payload = createMockAutoUpdateConfig()
|
||||
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
||||
|
||||
// Act
|
||||
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
||||
|
||||
// Assert - component renders with strategy picker
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
|
||||
)
|
||||
const latestOption = (await screen.findAllByRole('menuitemradio')).find(item =>
|
||||
item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'),
|
||||
)!
|
||||
await user.click(latestOption)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onChange with updated time when time changes', () => {
|
||||
|
||||
@ -1,62 +1,12 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import StrategyPicker from '../strategy-picker'
|
||||
import { AUTO_UPDATE_STRATEGY } from '../types'
|
||||
|
||||
let portalOpen = false
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <span data-testid="picker-button">{children}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const _React = await import('react')
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
portalOpen = open
|
||||
return <div>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void
|
||||
}) => (
|
||||
<button
|
||||
data-testid="trigger"
|
||||
onClick={() => onClick({
|
||||
stopPropagation: vi.fn(),
|
||||
nativeEvent: { stopImmediatePropagation: vi.fn() },
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
|
||||
}
|
||||
})
|
||||
const triggerName = (key: string) => new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i')
|
||||
|
||||
describe('StrategyPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
portalOpen = false
|
||||
})
|
||||
|
||||
it('renders the selected strategy label in the trigger', () => {
|
||||
render(
|
||||
<StrategyPicker
|
||||
@ -65,10 +15,12 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
expect(screen.getByRole('button', { name: triggerName('fixOnly') })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the option list when the trigger is clicked', () => {
|
||||
it('opens the option list when the trigger is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<StrategyPicker
|
||||
value={AUTO_UPDATE_STRATEGY.disabled}
|
||||
@ -76,14 +28,33 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1)
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
expect(options).toHaveLength(3)
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when a new strategy is selected', () => {
|
||||
it('marks only the currently selected strategy as checked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<StrategyPicker
|
||||
value={AUTO_UPDATE_STRATEGY.fixOnly}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName('fixOnly') }))
|
||||
|
||||
const checkedOptions = (await screen.findAllByRole('menuitemradio'))
|
||||
.filter(item => item.getAttribute('aria-checked') === 'true')
|
||||
|
||||
expect(checkedOptions).toHaveLength(1)
|
||||
expect(checkedOptions[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
})
|
||||
|
||||
it('calls onChange and closes the menu when a new strategy is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<StrategyPicker
|
||||
@ -92,9 +63,12 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
|
||||
const latestOption = (await screen.findAllByRole('menuitemradio'))
|
||||
.find(item => item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))!
|
||||
await user.click(latestOption)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
|
||||
expect(await screen.findByRole('button', { name: triggerName('disabled') })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { AUTO_UPDATE_STRATEGY } from './types'
|
||||
|
||||
const i18nPrefix = 'autoUpdate.strategy'
|
||||
@ -42,58 +41,48 @@ const StrategyPicker = ({
|
||||
},
|
||||
]
|
||||
const selectedOption = options.find(option => option.value === value)
|
||||
const handleValueChange = (nextValue: string) => {
|
||||
onChange(nextValue as AUTO_UPDATE_STRATEGY)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-end"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
<DropdownMenuTrigger render={<Button size="small" />}>
|
||||
{selectedOption?.label}
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="top-end"
|
||||
sideOffset={4}
|
||||
className="z-99"
|
||||
popupClassName="w-[280px] p-1"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
{selectedOption?.label}
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-99">
|
||||
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
onChange(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="mr-1 w-4 shrink-0">
|
||||
{
|
||||
value === option.value && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
|
||||
</div>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="mx-0 h-auto items-start gap-1 p-2 pr-3"
|
||||
>
|
||||
<div className="mr-1 flex w-4 shrink-0 justify-center pt-0.5">
|
||||
<DropdownMenuRadioItemIndicator className="ml-0" />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<div className="grow">
|
||||
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user