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)
}}
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { CommonLogsFilterBar } from '../common-logs-filter-bar'
import { UsageLogsProvider } from '../usage-logs-provider'
const pointerCaptureDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'setPointerCapture'
)
function FilterFixture() {
const table = useReactTable({
data: [],
columns: [],
getCoreRowModel: getCoreRowModel(),
})
return (
<UsageLogsProvider>
<CommonLogsFilterBar table={table} />
</UsageLogsProvider>
)
}
async function renderFilter(
initialEntry = '/usage-logs/common',
groups: Record<string, { desc: string; ratio: number }> | null = {
default: { desc: '', ratio: 1 },
premium: { desc: '', ratio: 2 },
}
) {
vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === '/api/user/self/groups' || url === '/api/group/') {
if (groups === null) throw new Error('Group loading failed')
return {
data: {
success: true,
data: url === '/api/group/' ? Object.keys(groups) : groups,
},
}
}
return { data: { success: true, data: { quota: 0, rpm: 0, tpm: 0 } } }
})
const root = createRootRoute()
const auth = createRoute({ getParentRoute: () => root, id: '_authenticated' })
const logs = createRoute({
getParentRoute: () => auth,
path: '/usage-logs/$section',
component: FilterFixture,
validateSearch: (search: Record<string, unknown>) => search,
})
const router = createRouter({
routeTree: root.addChildren([auth.addChildren([logs])]),
history: createMemoryHistory({ initialEntries: [initialEntry] }),
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
)
if (window.matchMedia('(max-width: 640px)').matches) {
await userEvent.click(
await screen.findByRole('button', { name: /^Filter/ })
)
}
await screen.findByRole('combobox', { name: 'Group' })
return router
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
useAuthStore.getState().auth.setUser(null)
if (pointerCaptureDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
'setPointerCapture',
pointerCaptureDescriptor
)
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture')
}
})
it('loads personal groups and filters choices without submitting until Search', async () => {
const router = await renderFilter()
const input = screen.getByRole('combobox', { name: 'Group' })
await userEvent.click(input)
expect(await screen.findByRole('option', { name: 'default' })).toBeVisible()
await userEvent.type(input, 'prem')
expect(
screen.queryByRole('option', { name: 'default' })
).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('option', { name: 'premium' }))
expect(input).toHaveValue('premium')
expect(router.state.location.search).not.toHaveProperty('group')
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({
group: 'premium',
page: 1,
})
)
expect(api.get).toHaveBeenCalledWith('/api/user/self/groups')
expect(api.get).not.toHaveBeenCalledWith('/api/group/')
})
it('loads all groups in the administrator view', async () => {
useAuthStore.getState().auth.setUser({ id: 1, username: 'admin', role: 10 })
await renderFilter()
await userEvent.click(screen.getByRole('combobox', { name: 'Group' }))
expect(await screen.findByRole('option', { name: 'premium' })).toBeVisible()
expect(api.get).toHaveBeenCalledWith('/api/group/')
expect(api.get).not.toHaveBeenCalledWith('/api/user/self/groups')
})
it('confirms a keyboard choice before Enter submits the selected group', async () => {
const router = await renderFilter()
const input = screen.getByRole('combobox', { name: 'Group' })
await userEvent.click(input)
await screen.findByRole('option', { name: 'default' })
await userEvent.keyboard('{ArrowDown}{Enter}')
expect(input).toHaveValue('default')
expect(input).toHaveAttribute('aria-expanded', 'false')
expect(router.state.location.search).not.toHaveProperty('group')
await userEvent.keyboard('{Enter}')
await waitFor(() =>
expect(router.state.location.search).toMatchObject({ group: 'default' })
)
})
it.each([{}, null])(
'preserves historical input and supports clearing when groups are unavailable (%s)',
async (groups) => {
const router = await renderFilter(
'/usage-logs/common?group=retired',
groups
)
const input = screen.getByRole('combobox', { name: 'Group' })
expect(input).toHaveValue('retired')
await userEvent.clear(input)
await userEvent.type(input, 'historical')
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({
group: 'historical',
})
)
await userEvent.clear(input)
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).not.toHaveProperty('group')
)
}
)
it('resets the selected group and restores a group from URL navigation', async () => {
const router = await renderFilter('/usage-logs/common?group=premium')
const input = screen.getByRole('combobox', { name: 'Group' })
expect(input).toHaveValue('premium')
await userEvent.click(screen.getByRole('button', { name: 'Reset' }))
await waitFor(() => expect(input).toHaveValue(''))
expect(router.state.location.search).not.toHaveProperty('group')
await router.history.push('/usage-logs/common?group=retired')
await waitFor(() => expect(input).toHaveValue('retired'))
})
it('keeps a selected group visible on focus and can clear it without choosing another option', async () => {
const router = await renderFilter('/usage-logs/common?group=premium')
const input = screen.getByRole('combobox', { name: 'Group' })
await userEvent.click(input)
expect(input).toHaveValue('premium')
expect(await screen.findByRole('option', { name: 'default' })).toBeVisible()
await userEvent.clear(input)
await userEvent.keyboard('{Enter}')
await waitFor(() =>
expect(router.state.location.search).not.toHaveProperty('group')
)
})
it('keeps the compact input and masks the dropdown together with other sensitive filters', async () => {
await renderFilter()
const input = screen.getByRole('combobox', { name: 'Group' })
expect(input).toHaveClass('h-8', 'text-sm', 'leading-5')
await userEvent.click(screen.getByRole('button', { name: /^Hide$/ }))
await userEvent.click(input)
const option = await screen.findByRole('option', { name: 'premium' })
const maskedField = input.closest('.\\[-webkit-text-security\\:disc\\]')
expect(maskedField).not.toBeNull()
expect(maskedField).toContainElement(option)
await userEvent.keyboard('{Escape}')
expect(input).toHaveAttribute('aria-expanded', 'false')
await userEvent.tab()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('lets mobile users select a long group name inside the filter drawer and submit it', async () => {
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
})
const originalMatchMedia = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...originalMatchMedia(query),
matches: query === '(max-width: 640px)',
}))
const longGroup = 'enterprise-team-with-a-long-group-name'
const router = await renderFilter('/usage-logs/common', {
[longGroup]: { desc: '', ratio: 1 },
})
const dialog = screen.getByRole('dialog')
const input = within(dialog).getByRole('combobox', { name: 'Group' })
await userEvent.click(input)
const option = await within(dialog).findByRole('option', { name: longGroup })
expect(option).toBeVisible()
await userEvent.click(option)
expect(input).toHaveValue(longGroup)
expect(dialog).toBeVisible()
await userEvent.click(within(dialog).getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({ group: longGroup })
)
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
})
it.each([1, 10])(
'excludes only auto from group choices for role %s',
async (role) => {
useAuthStore.getState().auth.setUser({ id: 1, username: 'viewer', role })
const router = await renderFilter('/usage-logs/common', {
auto: { desc: '', ratio: 1 },
'auto-team': { desc: '', ratio: 1 },
})
const input = screen.getByRole('combobox', { name: 'Group' })
await userEvent.click(input)
const option = await screen.findByRole('option', { name: 'auto-team' })
expect(
screen.queryByRole('option', { name: 'auto' })
).not.toBeInTheDocument()
await userEvent.click(option)
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({ group: 'auto-team' })
)
}
)
it('keeps historical auto values editable when auto is the only available group', async () => {
const router = await renderFilter('/usage-logs/common?group=auto', {
auto: { desc: '', ratio: 1 },
})
const input = screen.getByRole('combobox', { name: 'Group' })
expect(input).toHaveValue('auto')
await userEvent.click(input)
expect(screen.queryByRole('option', { name: 'auto' })).not.toBeInTheDocument()
await userEvent.clear(input)
await userEvent.type(input, 'retired')
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({ group: 'retired' })
)
})
......@@ -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