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