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)
}} }}
......
/*
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() { ...@@ -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