refactor: clean model selector and add more tests
This commit is contained in:
parent
3339629747
commit
f35e6cdae8
@ -6,6 +6,216 @@ import { IconChevronDown, IconLoader2, IconRefresh } from '@tabler/icons-react'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
|
||||||
|
// Hook for the dropdown position
|
||||||
|
function useDropdownPosition(open: boolean, containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 })
|
||||||
|
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 4,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: rect.width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
// Update the position when the dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateDropdownPosition()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open, updateDropdownPosition])
|
||||||
|
|
||||||
|
// Update the position when the window is resized
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
updateDropdownPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
window.addEventListener('scroll', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
window.removeEventListener('scroll', handleResize)
|
||||||
|
}
|
||||||
|
}, [open, updateDropdownPosition])
|
||||||
|
|
||||||
|
return { dropdownPosition, updateDropdownPosition }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Components for the different sections of the dropdown
|
||||||
|
const ErrorSection = ({ error, onRefresh, t }: { error: string; onRefresh?: () => void; t: (key: string) => string }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-destructive">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-destructive font-medium">{t('common:failedToLoadModels')}</span>
|
||||||
|
{onRefresh && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRefresh?.()
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10 text-main-view-fg"
|
||||||
|
aria-label="Refresh models"
|
||||||
|
>
|
||||||
|
<IconRefresh className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-main-view-fg/50 mt-0">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LoadingSection = ({ t }: { t: (key: string) => string }) => (
|
||||||
|
<div className="flex items-center justify-center px-3 py-3 text-sm text-main-view-fg/50">
|
||||||
|
<IconLoader2 className="h-4 w-4 animate-spin mr-2 text-main-view-fg/50" />
|
||||||
|
<span className="text-sm text-main-view-fg/50">{t('common:loading')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const EmptySection = ({ inputValue, t }: { inputValue: string; t: (key: string, options?: Record<string, string>) => string }) => (
|
||||||
|
<div className="px-3 py-3 text-sm text-main-view-fg/50 text-center">
|
||||||
|
{inputValue.trim() ? (
|
||||||
|
<span className="text-main-view-fg/50">{t('common:noModelsFoundFor', { searchValue: inputValue })}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-main-view-fg/50">{t('common:noModels')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ModelsList = ({
|
||||||
|
filteredModels,
|
||||||
|
value,
|
||||||
|
highlightedIndex,
|
||||||
|
onModelSelect,
|
||||||
|
onHighlight
|
||||||
|
}: {
|
||||||
|
filteredModels: string[]
|
||||||
|
value: string
|
||||||
|
highlightedIndex: number
|
||||||
|
onModelSelect: (model: string) => void
|
||||||
|
onHighlight: (index: number) => void
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{filteredModels.map((model, index) => (
|
||||||
|
<div
|
||||||
|
key={model}
|
||||||
|
data-model={model}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onModelSelect(model)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onHighlight(index)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg',
|
||||||
|
value === model && 'bg-main-view-fg/12 shadow-sm',
|
||||||
|
highlightedIndex === index && 'bg-main-view-fg/20 shadow-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm truncate text-main-view-fg">{model}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Custom hook for keyboard navigation
|
||||||
|
function useKeyboardNavigation(
|
||||||
|
open: boolean,
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
models: string[],
|
||||||
|
filteredModels: string[],
|
||||||
|
highlightedIndex: number,
|
||||||
|
setHighlightedIndex: React.Dispatch<React.SetStateAction<number>>,
|
||||||
|
onModelSelect: (model: string) => void,
|
||||||
|
dropdownRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
) {
|
||||||
|
const keyRepeatTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Scroll to the highlighted element
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightedIndex >= 0 && dropdownRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const modelElements = dropdownRef.current?.querySelectorAll('[data-model]')
|
||||||
|
const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement
|
||||||
|
if (highlightedElement) {
|
||||||
|
highlightedElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
behavior: 'auto'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, dropdownRef])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
// Open the dropdown with the arrows if closed
|
||||||
|
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||||
|
if (models.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(true)
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
if (keyRepeatTimeoutRef.current) {
|
||||||
|
clearTimeout(keyRepeatTimeoutRef.current)
|
||||||
|
}
|
||||||
|
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0))
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
if (keyRepeatTimeoutRef.current) {
|
||||||
|
clearTimeout(keyRepeatTimeoutRef.current)
|
||||||
|
}
|
||||||
|
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1))
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) {
|
||||||
|
onModelSelect(filteredModels[highlightedIndex])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowLeft':
|
||||||
|
setOpen(false)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
break
|
||||||
|
case 'PageUp':
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
break
|
||||||
|
case 'PageDown':
|
||||||
|
setHighlightedIndex(filteredModels.length - 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect])
|
||||||
|
|
||||||
|
// Cleanup the timeout
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = keyRepeatTimeoutRef.current
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { handleKeyDown }
|
||||||
|
}
|
||||||
|
|
||||||
type ModelComboboxProps = {
|
type ModelComboboxProps = {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
@ -31,12 +241,10 @@ export function ModelCombobox({
|
|||||||
}: ModelComboboxProps) {
|
}: ModelComboboxProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [inputValue, setInputValue] = useState(value)
|
const [inputValue, setInputValue] = useState(value)
|
||||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 })
|
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
const keyRepeatTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Sync input value with prop value
|
// Sync input value with prop value
|
||||||
@ -44,47 +252,36 @@ export function ModelCombobox({
|
|||||||
setInputValue(value)
|
setInputValue(value)
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
// Simple position calculation
|
// Hook for the dropdown position
|
||||||
const updateDropdownPosition = useCallback(() => {
|
const { dropdownPosition } = useDropdownPosition(open, containerRef)
|
||||||
if (containerRef.current) {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect()
|
|
||||||
setDropdownPosition({
|
|
||||||
top: rect.bottom + window.scrollY + 4,
|
|
||||||
left: rect.left + window.scrollX,
|
|
||||||
width: rect.width,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Update position when opening
|
// Optimized model filtering
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
if (!inputValue.trim()) return models
|
||||||
|
const searchValue = inputValue.toLowerCase()
|
||||||
|
return models.filter((model) => model.toLowerCase().includes(searchValue))
|
||||||
|
}, [models, inputValue])
|
||||||
|
|
||||||
|
// Reset highlighted index when filtered models change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
setHighlightedIndex(-1)
|
||||||
// Use requestAnimationFrame to ensure DOM is ready
|
}, [filteredModels])
|
||||||
requestAnimationFrame(() => {
|
|
||||||
updateDropdownPosition()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [open, updateDropdownPosition])
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close the dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
const handleClickOutside = (event: Event) => {
|
const handleClickOutside = (event: Event) => {
|
||||||
const target = event.target as Node
|
const target = event.target as Node
|
||||||
// Check if click is inside our container or dropdown
|
|
||||||
const isInsideContainer = containerRef.current?.contains(target)
|
const isInsideContainer = containerRef.current?.contains(target)
|
||||||
const isInsideDropdown = dropdownRef.current?.contains(target)
|
const isInsideDropdown = dropdownRef.current?.contains(target)
|
||||||
|
|
||||||
// Only close if click is outside both container and dropdown
|
|
||||||
if (!isInsideContainer && !isInsideDropdown) {
|
if (!isInsideContainer && !isInsideDropdown) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setDropdownPosition({ top: 0, left: 0, width: 0 })
|
|
||||||
setHighlightedIndex(-1)
|
setHighlightedIndex(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use multiple event types to ensure we catch all interactions
|
|
||||||
const events = ['mousedown', 'touchstart']
|
const events = ['mousedown', 'touchstart']
|
||||||
events.forEach(eventType => {
|
events.forEach(eventType => {
|
||||||
document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true })
|
document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true })
|
||||||
@ -97,127 +294,60 @@ export function ModelCombobox({
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Cleanup: close dropdown when component unmounts
|
// Cleanup: close the dropdown when the component is unmounted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = keyRepeatTimeoutRef.current
|
|
||||||
return () => {
|
return () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setDropdownPosition({ top: 0, left: 0, width: 0 })
|
|
||||||
setHighlightedIndex(-1)
|
setHighlightedIndex(-1)
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Filter models based on input
|
// Handler for the input change
|
||||||
const filteredModels = useMemo(() => {
|
const handleInputChange = useCallback((newValue: string) => {
|
||||||
if (!inputValue.trim()) return models
|
|
||||||
|
|
||||||
return models.filter((model) =>
|
|
||||||
model.toLowerCase().includes(inputValue.toLowerCase())
|
|
||||||
)
|
|
||||||
}, [models, inputValue])
|
|
||||||
|
|
||||||
// Reset highlighted index when filtered models change
|
|
||||||
useEffect(() => {
|
|
||||||
setHighlightedIndex(-1)
|
|
||||||
}, [filteredModels])
|
|
||||||
|
|
||||||
// Scroll to highlighted item with debouncing to handle key repeat
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlightedIndex >= 0 && dropdownRef.current && !loading && !error) {
|
|
||||||
// Use requestAnimationFrame to ensure smooth scrolling and avoid conflicts
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// Find all model elements (they have the data-model attribute)
|
|
||||||
const modelElements = dropdownRef.current?.querySelectorAll('[data-model]')
|
|
||||||
const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement
|
|
||||||
if (highlightedElement) {
|
|
||||||
highlightedElement.scrollIntoView({
|
|
||||||
block: 'nearest',
|
|
||||||
behavior: 'auto'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [highlightedIndex, error, loading])
|
|
||||||
|
|
||||||
// Handle input change
|
|
||||||
const handleInputChange = (newValue: string) => {
|
|
||||||
setInputValue(newValue)
|
setInputValue(newValue)
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
|
|
||||||
// Only open dropdown if user is actively typing and there are models
|
// Open the dropdown if the user types and there are models
|
||||||
if (newValue.trim() && models.length > 0) {
|
if (newValue.trim() && models.length > 0) {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
} else {
|
} else {
|
||||||
// Don't auto-open on empty input - wait for user interaction
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}, [onChange, models.length])
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handler for the model selection
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleModelSelect = useCallback((model: string) => {
|
||||||
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
||||||
// Open dropdown on arrow keys if there are models
|
|
||||||
if (models.length > 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
setOpen(true)
|
|
||||||
setHighlightedIndex(0)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
if (keyRepeatTimeoutRef.current) {
|
|
||||||
clearTimeout(keyRepeatTimeoutRef.current)
|
|
||||||
}
|
|
||||||
setHighlightedIndex((prev) =>
|
|
||||||
filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
if (keyRepeatTimeoutRef.current) {
|
|
||||||
clearTimeout(keyRepeatTimeoutRef.current)
|
|
||||||
}
|
|
||||||
setHighlightedIndex((prev) =>
|
|
||||||
filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault()
|
|
||||||
if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) {
|
|
||||||
handleModelSelect(filteredModels[highlightedIndex])
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'ArrowRight':
|
|
||||||
case 'ArrowLeft':
|
|
||||||
setOpen(false)
|
|
||||||
setHighlightedIndex(-1)
|
|
||||||
break
|
|
||||||
case 'PageUp':
|
|
||||||
setHighlightedIndex(0)
|
|
||||||
break
|
|
||||||
case 'PageDown':
|
|
||||||
setHighlightedIndex(filteredModels.length - 1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle model selection from dropdown
|
|
||||||
const handleModelSelect = (model: string) => {
|
|
||||||
setInputValue(model)
|
setInputValue(model)
|
||||||
onChange(model)
|
onChange(model)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setDropdownPosition({ top: 0, left: 0, width: 0 })
|
|
||||||
setHighlightedIndex(-1)
|
setHighlightedIndex(-1)
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
// Hook for the keyboard navigation
|
||||||
|
const { handleKeyDown } = useKeyboardNavigation(
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
models,
|
||||||
|
filteredModels,
|
||||||
|
highlightedIndex,
|
||||||
|
setHighlightedIndex,
|
||||||
|
handleModelSelect,
|
||||||
|
dropdownRef
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler for the dropdown opening
|
||||||
|
const handleDropdownToggle = useCallback(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
setOpen(!open)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Handler for the input click
|
||||||
|
const handleInputClick = useCallback(() => {
|
||||||
|
if (models.length > 0) {
|
||||||
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
}, [models.length])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)} ref={containerRef}>
|
<div className={cn('relative', className)} ref={containerRef}>
|
||||||
@ -227,12 +357,7 @@ export function ModelCombobox({
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => handleInputChange(e.target.value)}
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onClick={() => {
|
onClick={handleInputClick}
|
||||||
// Open dropdown on click if models are available
|
|
||||||
if (models.length > 0) {
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="pr-8"
|
className="pr-8"
|
||||||
@ -243,14 +368,8 @@ export function ModelCombobox({
|
|||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
// Prevent losing focus from input
|
onClick={handleDropdownToggle}
|
||||||
e.preventDefault()
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
inputRef.current?.focus()
|
|
||||||
setOpen(!open)
|
|
||||||
}}
|
|
||||||
className="absolute right-1 top-1/2 h-6 w-6 p-0 -translate-y-1/2 no-underline hover:bg-main-view-fg/10"
|
className="absolute right-1 top-1/2 h-6 w-6 p-0 -translate-y-1/2 no-underline hover:bg-main-view-fg/10"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -274,89 +393,30 @@ export function ModelCombobox({
|
|||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
data-dropdown="model-combobox"
|
data-dropdown="model-combobox"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
// Prevent interaction with underlying elements
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation()
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
}}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
|
||||||
// Prevent click from bubbling up and closing modal
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
// Allow default behavior for scrolling and selection
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onWheel={(e) => {
|
|
||||||
// Allow wheel events for scrolling
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Error state */}
|
{/* Error state */}
|
||||||
{error && (
|
{error && <ErrorSection error={error} onRefresh={onRefresh} t={t} />}
|
||||||
<div className="px-3 py-2 text-sm text-destructive">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-destructive font-medium">{t('common:failedToLoadModels')}</span>
|
|
||||||
{onRefresh && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onRefresh?.()
|
|
||||||
}}
|
|
||||||
className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10 text-main-view-fg"
|
|
||||||
>
|
|
||||||
<IconRefresh className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-main-view-fg/50 mt-0">{error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{loading && (
|
{loading && <LoadingSection t={t} />}
|
||||||
<div className="flex items-center justify-center px-3 py-3 text-sm text-main-view-fg/50">
|
|
||||||
<IconLoader2 className="h-4 w-4 animate-spin mr-2 text-main-view-fg/50" />
|
|
||||||
<span className="text-sm text-main-view-fg/50">{t('common:loading')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Models list */}
|
{/* Models list */}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
filteredModels.length === 0 ? (
|
||||||
{filteredModels.length === 0 ? (
|
<EmptySection inputValue={inputValue} t={t} />
|
||||||
<div className="px-3 py-3 text-sm text-main-view-fg/50 text-center">
|
|
||||||
{inputValue.trim() ? (
|
|
||||||
<span className="text-main-view-fg/50">{t('common:noModelsFoundFor', { searchValue: inputValue })}</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-main-view-fg/50">{t('common:noModels')}</span>
|
<ModelsList
|
||||||
)}
|
filteredModels={filteredModels}
|
||||||
</div>
|
value={value}
|
||||||
) : (
|
highlightedIndex={highlightedIndex}
|
||||||
<>
|
onModelSelect={handleModelSelect}
|
||||||
{/* Available models */}
|
onHighlight={setHighlightedIndex}
|
||||||
{filteredModels.map((model, index) => (
|
/>
|
||||||
<div
|
)
|
||||||
key={model}
|
|
||||||
data-model={model}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleModelSelect(model)
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg',
|
|
||||||
value === model && 'bg-main-view-fg/12 shadow-sm',
|
|
||||||
highlightedIndex === index && 'bg-main-view-fg/20 shadow-md'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-sm truncate text-main-view-fg">{model}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@ -1,57 +1,89 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
import { ModelCombobox } from '../ModelCombobox'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { ModelCombobox } from '../ModelCombobox'
|
||||||
|
|
||||||
|
// Mock translation hook
|
||||||
|
vi.mock('@/i18n/react-i18next-compat', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, string>) => {
|
||||||
|
if (key === 'common:failedToLoadModels') return 'Failed to load models'
|
||||||
|
if (key === 'common:loading') return 'Loading'
|
||||||
|
if (key === 'common:noModelsFoundFor') return `No models found for "${options?.searchValue}"`
|
||||||
|
if (key === 'common:noModels') return 'No models available'
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('ModelCombobox', () => {
|
describe('ModelCombobox', () => {
|
||||||
|
const mockOnChange = vi.fn()
|
||||||
|
const mockOnRefresh = vi.fn()
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
value: '',
|
value: '',
|
||||||
onChange: vi.fn(),
|
onChange: mockOnChange,
|
||||||
models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku'],
|
models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku'],
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockUser = userEvent.setup()
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
Element.prototype.getBoundingClientRect = vi.fn(() => ({
|
||||||
|
width: 300,
|
||||||
|
height: 40,
|
||||||
|
top: 100,
|
||||||
|
left: 50,
|
||||||
|
bottom: 140,
|
||||||
|
right: 350,
|
||||||
|
x: 50,
|
||||||
|
y: 100,
|
||||||
|
toJSON: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
it('renders input field with default placeholder', () => {
|
||||||
vi.restoreAllMocks()
|
act(() => {
|
||||||
})
|
|
||||||
|
|
||||||
describe('Basic Rendering', () => {
|
|
||||||
it('should render input field with placeholder', () => {
|
|
||||||
render(<ModelCombobox {...defaultProps} />)
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
})
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
expect(input).toBeInTheDocument()
|
expect(input).toBeInTheDocument()
|
||||||
expect(input).toHaveAttribute('placeholder', 'Type or select a model...')
|
expect(input).toHaveAttribute('placeholder', 'Type or select a model...')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render custom placeholder', () => {
|
it('renders custom placeholder', () => {
|
||||||
|
act(() => {
|
||||||
render(<ModelCombobox {...defaultProps} placeholder="Choose a model" />)
|
render(<ModelCombobox {...defaultProps} placeholder="Choose a model" />)
|
||||||
|
})
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
expect(input).toHaveAttribute('placeholder', 'Choose a model')
|
expect(input).toHaveAttribute('placeholder', 'Choose a model')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render dropdown trigger button', () => {
|
it('renders dropdown trigger button', () => {
|
||||||
|
act(() => {
|
||||||
render(<ModelCombobox {...defaultProps} />)
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
})
|
||||||
|
|
||||||
const button = screen.getByRole('button')
|
const button = screen.getByRole('button')
|
||||||
expect(button).toBeInTheDocument()
|
expect(button).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display current value in input', () => {
|
it('displays current value in input', () => {
|
||||||
|
act(() => {
|
||||||
render(<ModelCombobox {...defaultProps} value="gpt-4" />)
|
render(<ModelCombobox {...defaultProps} value="gpt-4" />)
|
||||||
|
})
|
||||||
|
|
||||||
const input = screen.getByDisplayValue('gpt-4')
|
const input = screen.getByDisplayValue('gpt-4')
|
||||||
expect(input).toBeInTheDocument()
|
expect(input).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply custom className', () => {
|
it('applies custom className', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ModelCombobox {...defaultProps} className="custom-class" />
|
<ModelCombobox {...defaultProps} className="custom-class" />
|
||||||
)
|
)
|
||||||
@ -59,11 +91,11 @@ describe('ModelCombobox', () => {
|
|||||||
const wrapper = container.firstChild as HTMLElement
|
const wrapper = container.firstChild as HTMLElement
|
||||||
expect(wrapper).toHaveClass('custom-class')
|
expect(wrapper).toHaveClass('custom-class')
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe('Disabled State', () => {
|
it('disables input when disabled prop is true', () => {
|
||||||
it('should disable input when disabled prop is true', () => {
|
act(() => {
|
||||||
render(<ModelCombobox {...defaultProps} disabled />)
|
render(<ModelCombobox {...defaultProps} disabled />)
|
||||||
|
})
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
const button = screen.getByRole('button')
|
const button = screen.getByRole('button')
|
||||||
@ -72,99 +104,82 @@ describe('ModelCombobox', () => {
|
|||||||
expect(button).toBeDisabled()
|
expect(button).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not open dropdown when disabled', async () => {
|
it('shows loading spinner in trigger button', () => {
|
||||||
render(<ModelCombobox {...defaultProps} disabled />)
|
act(() => {
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
|
||||||
await mockUser.click(input)
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Loading State', () => {
|
|
||||||
it('should show loading spinner in trigger button', () => {
|
|
||||||
render(<ModelCombobox {...defaultProps} loading />)
|
render(<ModelCombobox {...defaultProps} loading />)
|
||||||
|
})
|
||||||
|
|
||||||
const button = screen.getByRole('button')
|
const button = screen.getByRole('button')
|
||||||
const spinner = button.querySelector('.animate-spin')
|
const spinner = button.querySelector('.animate-spin')
|
||||||
expect(spinner).toBeInTheDocument()
|
expect(spinner).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show loading spinner when loading prop is true', () => {
|
it('shows loading section when dropdown is opened during loading', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
render(<ModelCombobox {...defaultProps} loading />)
|
render(<ModelCombobox {...defaultProps} loading />)
|
||||||
|
|
||||||
const spinner = screen.getByRole('button').querySelector('.animate-spin')
|
// Click input to trigger dropdown opening
|
||||||
expect(spinner).toBeInTheDocument()
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
// Wait for dropdown to appear and check loading section
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Loading')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Input Interactions', () => {
|
it('calls onChange when typing', async () => {
|
||||||
it('should call onChange when typing', async () => {
|
const user = userEvent.setup()
|
||||||
const mockOnChange = vi.fn()
|
const localMockOnChange = vi.fn()
|
||||||
render(<ModelCombobox {...defaultProps} onChange={mockOnChange} />)
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
await mockUser.type(input, 'g')
|
await user.type(input, 'g')
|
||||||
|
|
||||||
expect(mockOnChange).toHaveBeenCalledWith('g')
|
expect(localMockOnChange).toHaveBeenCalledWith('g')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update input value when typing', async () => {
|
it('updates input value when typing', async () => {
|
||||||
const mockOnChange = vi.fn()
|
const user = userEvent.setup()
|
||||||
render(<ModelCombobox {...defaultProps} onChange={mockOnChange} />)
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
await mockUser.type(input, 'test')
|
await user.type(input, 'test')
|
||||||
|
|
||||||
expect(input).toHaveValue('test')
|
expect(input).toHaveValue('test')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle input focus', async () => {
|
it('handles input focus', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
render(<ModelCombobox {...defaultProps} />)
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
await mockUser.click(input)
|
await user.click(input)
|
||||||
|
|
||||||
expect(input).toHaveFocus()
|
expect(input).toHaveFocus()
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe('Props Validation', () => {
|
it('renders with empty models array', () => {
|
||||||
it('should render with empty models array', () => {
|
act(() => {
|
||||||
render(<ModelCombobox {...defaultProps} models={[]} />)
|
render(<ModelCombobox {...defaultProps} models={[]} />)
|
||||||
|
})
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
expect(input).toBeInTheDocument()
|
expect(input).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render with models array', () => {
|
it('renders with models array', () => {
|
||||||
|
act(() => {
|
||||||
render(<ModelCombobox {...defaultProps} models={['model1', 'model2']} />)
|
render(<ModelCombobox {...defaultProps} models={['model1', 'model2']} />)
|
||||||
|
})
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
expect(input).toBeInTheDocument()
|
expect(input).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render with all props', () => {
|
it('handles mount and unmount without errors', () => {
|
||||||
render(
|
|
||||||
<ModelCombobox
|
|
||||||
{...defaultProps}
|
|
||||||
loading
|
|
||||||
error="Error message"
|
|
||||||
onRefresh={vi.fn()}
|
|
||||||
placeholder="Custom placeholder"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
|
||||||
expect(input).toBeInTheDocument()
|
|
||||||
expect(input).toBeDisabled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Component Lifecycle', () => {
|
|
||||||
it('should handle mount and unmount without errors', () => {
|
|
||||||
const { unmount } = render(<ModelCombobox {...defaultProps} />)
|
const { unmount } = render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
@ -174,7 +189,7 @@ describe('ModelCombobox', () => {
|
|||||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle props changes', () => {
|
it('handles props changes', () => {
|
||||||
const { rerender } = render(<ModelCombobox {...defaultProps} value="" />)
|
const { rerender } = render(<ModelCombobox {...defaultProps} value="" />)
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('')).toBeInTheDocument()
|
||||||
@ -184,7 +199,7 @@ describe('ModelCombobox', () => {
|
|||||||
expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle models array changes', () => {
|
it('handles models array changes', () => {
|
||||||
const { rerender } = render(<ModelCombobox {...defaultProps} models={[]} />)
|
const { rerender } = render(<ModelCombobox {...defaultProps} models={[]} />)
|
||||||
|
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
@ -193,5 +208,302 @@ describe('ModelCombobox', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not open dropdown when clicking input with no models', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} models={[]} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
// Should focus but not open dropdown
|
||||||
|
expect(input).toHaveFocus()
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts error prop without crashing', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} error="Test error message" />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'Type or select a model...')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with all props', () => {
|
||||||
|
act(() => {
|
||||||
|
render(
|
||||||
|
<ModelCombobox
|
||||||
|
{...defaultProps}
|
||||||
|
loading
|
||||||
|
error="Error message"
|
||||||
|
onRefresh={mockOnRefresh}
|
||||||
|
placeholder="Custom placeholder"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
expect(input).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown when clicking trigger button', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
await user.click(button)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown when clicking input', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
expect(input).toHaveFocus()
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters models based on input value', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'gpt-4')
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows filtered models in dropdown when typing', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Type 'gpt' to trigger dropdown opening
|
||||||
|
await user.type(input, 'gpt')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Should show GPT models
|
||||||
|
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
|
// Should not show Claude
|
||||||
|
expect(screen.queryByText('claude-3-haiku')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles case insensitive filtering', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'GPT')
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('GPT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no models match filter', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Type something that doesn't match any model to trigger dropdown + empty state
|
||||||
|
await user.type(input, 'nonexistent')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
// Should show empty state message
|
||||||
|
expect(screen.getByText('No models found for "nonexistent"')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects model from dropdown when clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const modelOption = screen.getByText('gpt-4')
|
||||||
|
expect(modelOption).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelOption = screen.getByText('gpt-4')
|
||||||
|
await user.click(modelOption)
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt-4')
|
||||||
|
expect(input).toHaveValue('gpt-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits input value with Enter key', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'gpt')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates input value when typing', () => {
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(input, { target: { value: 'gpt-4' } })
|
||||||
|
|
||||||
|
expect(input).toHaveValue('gpt-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays error message in dropdown', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} error="Network connection failed" />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Click input to open dropdown
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
// Error messages should be displayed
|
||||||
|
expect(screen.getByText('Failed to load models')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Network connection failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onRefresh when refresh button is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnRefresh = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} error="Network error" onRefresh={localMockOnRefresh} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Click input to open dropdown
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open with error section
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
const refreshButton = document.querySelector('[aria-label="Refresh models"]')
|
||||||
|
expect(refreshButton).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshButton = document.querySelector('[aria-label="Refresh models"]')
|
||||||
|
if (refreshButton) {
|
||||||
|
await user.click(refreshButton)
|
||||||
|
expect(localMockOnRefresh).toHaveBeenCalledTimes(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown when pressing ArrowDown', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
|
||||||
|
expect(input).toHaveFocus()
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates through models with arrow keys', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
|
||||||
|
// ArrowDown should open dropdown
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to second item
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const secondModel = screen.getByText('gpt-4')
|
||||||
|
const modelElement = secondModel.closest('[data-model]')
|
||||||
|
expect(modelElement).toHaveClass('bg-main-view-fg/20')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles Enter key to select highlighted model', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Type 'gpt' to open dropdown and filter models
|
||||||
|
await user.type(input, 'gpt')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open with filtered models
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to highlight first model and select it
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt-3.5-turbo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dropdown with ArrowLeft key', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
|
||||||
|
// ArrowDown should open dropdown
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ArrowLeft should close dropdown
|
||||||
|
await user.keyboard('{ArrowLeft}')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user