Commit 8f72ecbb by CaIon

feat(usage-logs): add searchable group filter

Reuse the shared Combobox to suggest groups for the active log view while allowing historical group names to be entered manually. Exclude the automatic routing pseudo-group from suggestions and preserve masking, reset, URL navigation, and mobile drawer behavior.

Extend the existing Combobox with keyboard event forwarding and accessible labels, preserve the selected custom value on focus, and close suggestions on blur. Enter confirms a selection before submitting the filter.

Validation: 25 tests passed across the shared Combobox and log group, type, and mobile filter suites. TypeScript, scoped lint, formatting, and git diff checks passed.
parent 2bec3706
......@@ -41,6 +41,9 @@ interface ComboboxInputProps {
id?: string
allowCustomValue?: boolean
openOnFocus?: boolean
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
'aria-label'?: string
'aria-labelledby'?: string
}
export function ComboboxInput({
......@@ -53,10 +56,15 @@ export function ComboboxInput({
id,
allowCustomValue = false,
openOnFocus = true,
onKeyDown,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: ComboboxInputProps) {
const { t } = useTranslation()
const listId = React.useId()
const [open, setOpen] = React.useState(false)
const [searchValue, setSearchValue] = React.useState('')
const [searchChanged, setSearchChanged] = React.useState(false)
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
......@@ -69,14 +77,14 @@ export function ComboboxInput({
const displayValue = open ? searchValue : (selectedOption?.label ?? value)
const filteredOptions = React.useMemo(() => {
if (!searchValue.trim()) return options
if (!searchChanged || !searchValue.trim()) return options
const search = searchValue.toLowerCase().trim()
return options.filter(
(option) =>
option.label.toLowerCase().includes(search) ||
option.value.toLowerCase().includes(search)
)
}, [options, searchValue])
}, [options, searchValue, searchChanged])
// Reset highlight when filtered options change
React.useEffect(() => {
......@@ -110,6 +118,9 @@ export function ComboboxInput({
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault()
setSearchValue(allowCustomValue ? value : '')
setSearchChanged(false)
setOpen(true)
return
}
......@@ -130,12 +141,14 @@ export function ComboboxInput({
)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
e.preventDefault()
handleSelect(filteredOptions[highlightedIndex].value)
} else if (allowCustomValue && searchValue.trim()) {
e.preventDefault()
handleSelect(searchValue.trim())
} else {
if (!onKeyDown) e.preventDefault()
// No highlighted option, just close the dropdown and keep current value
setOpen(false)
setSearchValue('')
......@@ -168,7 +181,17 @@ export function ComboboxInput({
id={id}
type='text'
role='combobox'
aria-expanded={open}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-expanded={!!showDropdown}
aria-controls={
showDropdown && filteredOptions.length > 0 ? listId : undefined
}
aria-activedescendant={
showDropdown && highlightedIndex >= 0
? `${listId}-${highlightedIndex}`
: undefined
}
aria-haspopup='listbox'
aria-autocomplete='list'
autoComplete='off'
......@@ -177,6 +200,7 @@ export function ComboboxInput({
onChange={(e) => {
const nextValue = e.target.value
setSearchValue(nextValue)
setSearchChanged(true)
if (allowCustomValue) {
onValueChange(nextValue)
}
......@@ -185,17 +209,27 @@ export function ComboboxInput({
onPointerDown={() => {
pointerFocusRef.current = true
if (document.activeElement === inputRef.current && !open) {
setSearchValue(allowCustomValue ? value : '')
setSearchChanged(false)
setOpen(true)
}
}}
onFocus={() => {
setSearchValue(allowCustomValue && !selectedOption ? value : '')
setSearchValue(allowCustomValue ? value : '')
setSearchChanged(false)
if (openOnFocus || pointerFocusRef.current) {
setOpen(true)
}
pointerFocusRef.current = false
}}
onKeyDown={handleKeyDown}
onBlur={() => {
setOpen(false)
setSearchValue('')
}}
onKeyDown={(event) => {
handleKeyDown(event)
if (!event.defaultPrevented) onKeyDown?.(event)
}}
className={cn('pr-9', className)}
/>
<ChevronsUpDown className='pointer-events-none absolute top-1/2 right-3 size-4 shrink-0 -translate-y-1/2 opacity-50' />
......@@ -205,12 +239,14 @@ export function ComboboxInput({
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
id={listId}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listId}-${index}`}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
......
......@@ -55,6 +55,7 @@ type LegacyComboboxProps = {
disabled?: boolean
name?: string
onBlur?: React.FocusEventHandler<HTMLInputElement>
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
ref?: React.Ref<HTMLInputElement>
'aria-label'?: string
'aria-labelledby'?: string
......@@ -76,6 +77,9 @@ function Combobox(
return (
<LegacyComboboxInput
id={props.id}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
onKeyDown={props.onKeyDown}
options={props.options}
value={props.value ?? ''}
onValueChange={(value) => props.onValueChange?.(value)}
......@@ -131,6 +135,7 @@ function OptionCombobox(props: LegacyComboboxProps) {
id={props.id}
disabled={props.disabled}
onBlur={props.onBlur}
onKeyDown={props.onKeyDown}
onFocus={() => {
if (props.openOnFocus !== false) setOpen(true)
}}
......
......@@ -54,9 +54,12 @@ function FilterFixture() {
}
async function renderFilter() {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: { quota: 0, rpm: 0, tpm: 0 } },
})
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data: url === '/api/user/self/groups' ? {} : { quota: 0, rpm: 0, tpm: 0 },
},
}))
const root = createRootRoute()
const auth = createRoute({ getParentRoute: () => root, id: '_authenticated' })
const logs = createRoute({
......@@ -77,7 +80,7 @@ async function renderFilter() {
<RouterProvider router={router} />
</QueryClientProvider>
)
await screen.findByRole('combobox')
await screen.findByRole('combobox', { name: 'Type' })
return router
}
......@@ -88,7 +91,7 @@ afterEach(() => {
it('marks only retired log types as deprecated while keeping historical filters selectable', async () => {
const router = await renderFilter()
await userEvent.click(screen.getByRole('combobox'))
await userEvent.click(screen.getByRole('combobox', { name: 'Type' }))
for (const label of ['Manage', 'Login']) {
expect(
within(
......@@ -111,12 +114,14 @@ it('marks only retired log types as deprecated while keeping historical filters
).not.toBeInTheDocument()
}
await userEvent.click(screen.getByRole('option', { name: /^Manage/ }))
expect(screen.getByRole('combobox')).toHaveTextContent('Deprecated')
expect(screen.getByRole('combobox', { name: 'Type' })).toHaveTextContent(
'Deprecated'
)
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({ type: ['3'], page: 1 })
)
await userEvent.click(screen.getByRole('combobox'))
await userEvent.click(screen.getByRole('combobox', { name: 'Type' }))
await userEvent.click(screen.getByRole('option', { name: /^Login/ }))
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() =>
......
......@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useQueryClient, useIsFetching, useQuery } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import type { Table } from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
......@@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import {
Select,
SelectContent,
......@@ -38,7 +39,9 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getGroups } from '@/features/users/api'
import { useMediaQuery } from '@/hooks'
import { getUserGroups } from '@/lib/api'
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
import { buildSearchParams } from '../lib/filter'
......@@ -122,6 +125,24 @@ export function CommonLogsFilterBar<TData>(
const { isAdminView: isAdmin } = useLogsViewScope()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const { data: adminGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
enabled: isAdmin,
})
const { data: userGroups } = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
enabled: !isAdmin,
})
const groupOptions = useMemo(() => {
const groups = isAdmin
? (adminGroups?.data ?? [])
: Object.keys(userGroups?.data ?? {})
return groups
.filter((group) => group !== 'auto')
.map((group) => ({ label: group, value: group }))
}, [isAdmin, adminGroups, userGroups])
const searchState = useMemo<CommonLogDraft>(() => {
const { start, end } = getDefaultTimeRange()
......@@ -322,12 +343,16 @@ export function CommonLogsFilterBar<TData>(
</LogsFilterField>
)
const groupFilter = (
<LogsFilterField>
<LogsFilterInput
<LogsFilterField className={sensitiveInputClass}>
<Combobox
options={groupOptions}
allowCustomValue
aria-label={t('Group')}
emptyText={t('No group found.')}
placeholder={t('Group')}
className={sensitiveInputClass}
className='h-8 min-w-0 text-sm leading-5'
value={filters.group || ''}
onChange={(e) => handleChange('group', e.target.value)}
onValueChange={(value) => handleChange('group', value ?? '')}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
......@@ -354,6 +379,7 @@ export function CommonLogsFilterBar<TData>(
}}
>
<SelectTrigger
aria-label={t('Type')}
aria-description={
selectedLogType?.deprecated ? deprecatedTypeDescription : undefined
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment