feat(web): enhance status toggle group and filter utilities
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 24s
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m37s
CD / publish (push) Successful in 2m53s

- Introduced new properties for status toggle items, including `icon` and `tone`, to improve visual representation.
- Updated the `StatusToggleGroup` component to utilize these new properties for better user feedback.
- Enhanced filter utilities with additional functions for managing filter queries, including `getPrimaryTextField`, `getExtraFilterFields`, and `setFilterTextValue`.
- Improved the `ResourcePageFiltered` component to integrate search functionality and manage filter states more effectively.

This update aims to streamline the user experience in managing statuses and filters across the application.
This commit is contained in:
Denozordec
2026-08-23 13:16:00 +07:00
parent 094f75913b
commit e469c421ca
6 changed files with 294 additions and 30 deletions
@@ -57,7 +57,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown { export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) { switch (field) {
case 'name': case 'name':
return item.name ?? item.neighbor return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
case 'neighbor': case 'neighbor':
return item.neighbor return item.neighbor
case 'session_state': case 'session_state':
@@ -68,7 +68,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown { export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) { switch (field) {
case 'endpoint': case 'endpoint':
return `${item.endpoint} ${item.agent_domain ?? ''}` return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
case 'role': case 'role':
return item.role return item.role
default: default:
@@ -2,9 +2,17 @@ import {
createFilterQuery, createFilterQuery,
createFilterRule, createFilterRule,
flattenFilterConditions, flattenFilterConditions,
flattenFilterRules,
isFilterRule,
updateFilterRule,
type FilterCondition, type FilterCondition,
} from '@/components/reui/filters/filters-query' } from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types' import type {
FilterField,
FilterGroupNode,
FilterNode,
FilterQuery,
} from '@/components/reui/filters/filters-types'
/** Operators that take no value, so an empty `values` list is expected. */ /** Operators that take no value, so an empty `values` list is expected. */
const VALUELESS_OPERATORS = new Set(['empty', 'not_empty']) const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
@@ -131,6 +139,95 @@ export function createSearchFilterField(
} }
} }
export function getPrimaryTextField(fields: FilterField[]): FilterField | undefined {
return fields.find((field) => field.type === 'text' && !field.fields)
}
export function getExtraFilterFields(
fields: FilterField[],
primaryId?: string,
): FilterField[] {
if (!primaryId) return fields
return fields.filter((field) => field.id !== primaryId)
}
function ruleFieldId(path: string[]): string | undefined {
return path[0]
}
export function getFilterTextValue(query: FilterQuery, fieldId: string): string {
const rule = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (rule?.value == null) return ''
if (Array.isArray(rule.value)) return rule.value.map(String).join(' ')
return String(rule.value)
}
export function setFilterTextValue(
query: FilterQuery,
fieldId: string,
text: string,
): FilterQuery {
const existing = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (existing) {
return updateFilterRule(query, existing.id, {
operator: 'contains',
value: text,
})
}
return {
...query,
rules: [
createFilterRule({
id: `${fieldId}-1`,
path: [fieldId],
operator: 'contains',
value: text,
}),
...query.rules,
],
}
}
function mapGroupRules<V>(
group: FilterGroupNode<V>,
map: (node: FilterNode<V>) => FilterNode<V> | null,
): FilterGroupNode<V> {
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (isFilterRule(child)) {
const next = map(child)
if (next) rules.push(next)
continue
}
const nested = mapGroupRules(child, map)
if (nested.rules.length > 0) rules.push(nested)
}
return { ...group, rules }
}
export function stripFieldFromQuery(query: FilterQuery, fieldId: string): FilterQuery {
return mapGroupRules(query, (node) => {
if (isFilterRule(node) && ruleFieldId(node.path) === fieldId) return null
return node
})
}
export function mergeFieldRules(
extraQuery: FilterQuery,
sourceQuery: FilterQuery,
fieldId: string,
): FilterQuery {
const searchRules = flattenFilterRules(sourceQuery).filter(
(rule) => ruleFieldId(rule.path) === fieldId,
)
const strippedExtra = stripFieldFromQuery(extraQuery, fieldId)
if (searchRules.length === 0) return strippedExtra
return {
...strippedExtra,
rules: [...searchRules, ...strippedExtra.rules],
}
}
export function renderSelectedCount(values: unknown[]) { export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…' if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано` if (values.length > 1) return `${values.length} выбрано`
@@ -5,10 +5,14 @@ import {
type RowSelectionState, type RowSelectionState,
type SortingState, type SortingState,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { StatusToggleGroup } from '@/components/status-toggle-group' import {
StatusToggleGroup,
type StatusToggleTone,
} from '@/components/status-toggle-group'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { import {
DataGrid, DataGrid,
@@ -18,6 +22,7 @@ import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagina
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Filters } from '@/components/reui/filters/filters' import { Filters } from '@/components/reui/filters/filters'
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import { import {
Frame, Frame,
@@ -29,11 +34,27 @@ import {
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { Separator } from '@evobgp/ui/components/separator' import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults' import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n' import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
import { applyFiltersToData, createEmptyFilterQuery } from './filter-utils' import {
applyFiltersToData,
createEmptyFilterQuery,
createTextFilterQuery,
getActiveFilters,
getExtraFilterFields,
getFilterTextValue,
getPrimaryTextField,
mergeFieldRules,
setFilterTextValue,
stripFieldFromQuery,
} from './filter-utils'
import { import {
FrameDataGrid, FrameDataGrid,
applyKitActionColumn, applyKitActionColumn,
@@ -48,6 +69,8 @@ export interface ResourcePageTab {
id: string id: string
label: string label: string
count?: number count?: number
icon?: LucideIcon
tone?: StatusToggleTone
} }
type SimpleGridPassthrough<T extends object> = Pick< type SimpleGridPassthrough<T extends object> = Pick<
@@ -286,11 +309,33 @@ function ResourcePageFiltered<T extends object>({
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab const activeTab = controlledTab ?? internalTab
const showFilters = filterFields.length > 0 const showFilters = filterFields.length > 0
const primaryTextField = useMemo(() => getPrimaryTextField(filterFields), [filterFields])
const extraFilterFields = useMemo(
() => getExtraFilterFields(filterFields, primaryTextField?.id),
[filterFields, primaryTextField],
)
const showSearch = Boolean(primaryTextField)
const showFiltersPopover = extraFilterFields.length > 0
const [internalQuery, setInternalQuery] = useState<FilterQuery>(createEmptyFilterQuery) const [internalQuery, setInternalQuery] = useState<FilterQuery>(createEmptyFilterQuery)
const isQueryControlled = controlledQuery !== undefined const isQueryControlled = controlledQuery !== undefined
const filterQuery = isQueryControlled ? controlledQuery : internalQuery const filterQuery = isQueryControlled ? controlledQuery : internalQuery
const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery
const searchText = primaryTextField
? getFilterTextValue(filterQuery, primaryTextField.id)
: ''
const extraQuery = useMemo(
() =>
primaryTextField
? stripFieldFromQuery(filterQuery, primaryTextField.id)
: filterQuery,
[filterQuery, primaryTextField],
)
const extraActiveCount = useMemo(
() => getActiveFilters(flattenFilterConditions(extraQuery)).length,
[extraQuery],
)
const hasDirtyFilters = searchText.trim() !== '' || extraActiveCount > 0
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
@@ -372,19 +417,38 @@ function ResourcePageFiltered<T extends object>({
[onTabChange, resetPagination], [onTabChange, resetPagination],
) )
const handleFiltersChange = useCallback( const handleSearchChange = useCallback(
(next: FilterQuery) => { (value: string) => {
setFilterQuery(next) if (!primaryTextField) return
setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value))
resetPagination() resetPagination()
}, },
[setFilterQuery, resetPagination], [filterQuery, primaryTextField, setFilterQuery, resetPagination],
)
const handleFiltersChange = useCallback(
(next: FilterQuery) => {
setFilterQuery(
primaryTextField
? mergeFieldRules(next, filterQuery, primaryTextField.id)
: next,
)
resetPagination()
},
[filterQuery, primaryTextField, setFilterQuery, resetPagination],
) )
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
onClearFilters?.() onClearFilters?.()
if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery()) if (!isQueryControlled) {
setInternalQuery(
primaryTextField
? createTextFilterQuery(primaryTextField.id)
: createEmptyFilterQuery(),
)
}
resetPagination() resetPagination()
}, [onClearFilters, isQueryControlled, resetPagination]) }, [onClearFilters, isQueryControlled, primaryTextField, resetPagination])
const countedTabs = useMemo( const countedTabs = useMemo(
() => () =>
@@ -392,6 +456,8 @@ function ResourcePageFiltered<T extends object>({
id: tab.id, id: tab.id,
label: tab.label, label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0, count: tabCounts[tab.id] ?? tab.count ?? 0,
icon: tab.icon,
tone: tab.tone,
})), })),
[tabs, tabCounts], [tabs, tabCounts],
) )
@@ -466,12 +532,27 @@ function ResourcePageFiltered<T extends object>({
<FramePanel className="p-0 shadow-none!"> <FramePanel className="p-0 shadow-none!">
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5"> <div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{showFilters ? ( {showSearch && primaryTextField ? (
<InputGroup className="h-8 w-full min-w-[12rem] max-w-sm">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
{showFiltersPopover ? (
<Filters <Filters
query={filterQuery} query={extraQuery}
fields={filterFields} fields={extraFilterFields}
onQueryChange={handleFiltersChange} onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default" size="default"
labels={FILTERS_LABELS_RU} labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU} operatorLabels={FILTERS_OPERATOR_LABELS_RU}
@@ -479,10 +560,20 @@ function ResourcePageFiltered<T extends object>({
<Button type="button" variant="outline" aria-label="Фильтры"> <Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" /> <FilterIcon className="size-4" aria-hidden="true" />
Фильтры Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button> </Button>
} }
/> />
) : null} ) : null}
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
<div />
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{countedTabs.length > 0 ? ( {countedTabs.length > 0 ? (
<StatusToggleGroup <StatusToggleGroup
items={countedTabs} items={countedTabs}
@@ -491,16 +582,13 @@ function ResourcePageFiltered<T extends object>({
aria-label="Фильтр по статусу" aria-label="Фильтр по статусу"
/> />
) : null} ) : null}
{!showFilters && countedTabs.length === 0 ? <div /> : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra} {toolbarExtra}
{selectedCount > 0 ? ( {selectedCount > 0 ? (
<Badge size="sm" variant="secondary"> <Badge size="sm" variant="secondary">
{selectedCount} выбрано {selectedCount} выбрано
</Badge> </Badge>
) : null} ) : null}
{showFilters ? ( {hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}> <Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" /> <FilterXIcon className="size-4" aria-hidden="true" />
Сбросить Сбросить
@@ -1,10 +1,22 @@
import type { ComponentProps } from 'react'
import type { LucideIcon } from 'lucide-react'
import { CircleCheck, CircleOff, Clock, ListFilter } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group' import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
export type StatusToggleTone = 'neutral' | 'success' | 'warning' | 'info' | 'destructive'
export type StatusToggleItem = { export type StatusToggleItem = {
id: string id: string
label: string label: string
count?: number count?: number
icon?: LucideIcon
tone?: StatusToggleTone
} }
interface StatusToggleGroupProps { interface StatusToggleGroupProps {
@@ -15,10 +27,66 @@ interface StatusToggleGroupProps {
'aria-label'?: string 'aria-label'?: string
} }
const TONE_BY_ID: Record<string, StatusToggleTone> = {
all: 'neutral',
enabled: 'success',
online: 'success',
established: 'success',
succeeded: 'success',
pending: 'warning',
queued: 'warning',
running: 'info',
active: 'info',
disabled: 'destructive',
offline: 'destructive',
failed: 'destructive',
}
const ICON_BY_ID: Record<string, LucideIcon> = {
all: ListFilter,
enabled: CircleCheck,
online: CircleCheck,
established: CircleCheck,
succeeded: CircleCheck,
pending: Clock,
queued: Clock,
running: Clock,
active: Clock,
disabled: CircleOff,
offline: CircleOff,
failed: CircleOff,
}
const BADGE_VARIANT: Record<StatusToggleTone, BadgeVariant> = {
neutral: 'outline',
success: 'success-light',
warning: 'warning-light',
info: 'info-light',
destructive: 'destructive-light',
}
const ICON_CLASS: Record<StatusToggleTone, string> = {
neutral: 'text-muted-foreground',
success: 'text-success',
warning: 'text-warning',
info: 'text-info',
destructive: 'text-destructive',
}
function resolveTone(item: StatusToggleItem): StatusToggleTone {
return item.tone ?? TONE_BY_ID[item.id] ?? 'neutral'
}
function resolveIcon(item: StatusToggleItem): LucideIcon {
return item.icon ?? ICON_BY_ID[item.id] ?? ListFilter
}
/** /**
* Grid status filter — native ToggleGroup in the ResourcePage toolbar. * Grid status filter — native ToggleGroup in the ResourcePage toolbar.
* @see https://reui.io/preview/base/components/c-toggle-group-5 * @see https://reui.io/preview/base/components/c-toggle-group-5
* @see https://reui.io/preview/base/components/c-toggle-group-13
* @see https://reui.io/components/toggle-group * @see https://reui.io/components/toggle-group
* @see https://reui.io/docs/components/base/badge
*/ */
export function StatusToggleGroup({ export function StatusToggleGroup({
items, items,
@@ -36,19 +104,30 @@ export function StatusToggleGroup({
if (next) onValueChange(next) if (next) onValueChange(next)
}} }}
variant="outline" variant="outline"
size="sm" size="default"
spacing={0} spacing={0}
aria-label={ariaLabel} aria-label={ariaLabel}
className={cn('w-fit', className)} className={cn('w-fit', className)}
> >
{items.map((item) => ( {items.map((item) => {
<ToggleGroupItem key={item.id} value={item.id} className="gap-1.5 px-2.5"> const tone = resolveTone(item)
<span>{item.label}</span> const Icon = resolveIcon(item)
{item.count !== undefined ? ( return (
<span className="text-muted-foreground tabular-nums">{item.count}</span> <ToggleGroupItem key={item.id} value={item.id} className="gap-1.5 px-2.5">
) : null} <Icon
</ToggleGroupItem> className={cn('size-3.5', ICON_CLASS[tone])}
))} data-icon="inline-start"
aria-hidden="true"
/>
<span>{item.label}</span>
{item.count !== undefined ? (
<Badge variant={BADGE_VARIANT[tone]} size="xs" radius="full" className="tabular-nums">
{item.count}
</Badge>
) : null}
</ToggleGroupItem>
)
})}
</ToggleGroup> </ToggleGroup>
) )
} }
File diff suppressed because one or more lines are too long