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 {
switch (field) {
case 'name':
return item.name ?? item.neighbor
return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
case 'neighbor':
return item.neighbor
case 'session_state':
@@ -68,7 +68,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
return `${item.endpoint} ${item.agent_domain ?? ''}`
return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
case 'role':
return item.role
default:
@@ -2,9 +2,17 @@ import {
createFilterQuery,
createFilterRule,
flattenFilterConditions,
flattenFilterRules,
isFilterRule,
updateFilterRule,
type FilterCondition,
} 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. */
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[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
@@ -5,10 +5,14 @@ import {
type RowSelectionState,
type SortingState,
} 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 { StatusToggleGroup } from '@/components/status-toggle-group'
import {
StatusToggleGroup,
type StatusToggleTone,
} from '@/components/status-toggle-group'
import { Badge } from '@/components/reui/badge'
import {
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 { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
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 {
Frame,
@@ -29,11 +34,27 @@ import {
} from '@/components/reui/frame'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
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 { Skeleton } from '@evobgp/ui/components/skeleton'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
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 {
FrameDataGrid,
applyKitActionColumn,
@@ -48,6 +69,8 @@ export interface ResourcePageTab {
id: string
label: string
count?: number
icon?: LucideIcon
tone?: StatusToggleTone
}
type SimpleGridPassthrough<T extends object> = Pick<
@@ -286,11 +309,33 @@ function ResourcePageFiltered<T extends object>({
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
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 isQueryControlled = controlledQuery !== undefined
const filterQuery = isQueryControlled ? controlledQuery : internalQuery
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 [rowSelection, setRowSelection] = useState<RowSelectionState>({})
@@ -372,19 +417,38 @@ function ResourcePageFiltered<T extends object>({
[onTabChange, resetPagination],
)
const handleFiltersChange = useCallback(
(next: FilterQuery) => {
setFilterQuery(next)
const handleSearchChange = useCallback(
(value: string) => {
if (!primaryTextField) return
setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value))
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(() => {
onClearFilters?.()
if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery())
if (!isQueryControlled) {
setInternalQuery(
primaryTextField
? createTextFilterQuery(primaryTextField.id)
: createEmptyFilterQuery(),
)
}
resetPagination()
}, [onClearFilters, isQueryControlled, resetPagination])
}, [onClearFilters, isQueryControlled, primaryTextField, resetPagination])
const countedTabs = useMemo(
() =>
@@ -392,6 +456,8 @@ function ResourcePageFiltered<T extends object>({
id: tab.id,
label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0,
icon: tab.icon,
tone: tab.tone,
})),
[tabs, tabCounts],
)
@@ -466,12 +532,27 @@ function ResourcePageFiltered<T extends object>({
<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 min-w-0 flex-wrap items-center gap-2">
{showFilters ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{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
query={filterQuery}
fields={filterFields}
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
@@ -479,10 +560,20 @@ function ResourcePageFiltered<T extends object>({
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
<div />
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{countedTabs.length > 0 ? (
<StatusToggleGroup
items={countedTabs}
@@ -491,16 +582,13 @@ function ResourcePageFiltered<T extends object>({
aria-label="Фильтр по статусу"
/>
) : null}
{!showFilters && countedTabs.length === 0 ? <div /> : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{showFilters ? (
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<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 { 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 = {
id: string
label: string
count?: number
icon?: LucideIcon
tone?: StatusToggleTone
}
interface StatusToggleGroupProps {
@@ -15,10 +27,66 @@ interface StatusToggleGroupProps {
'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.
* @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/docs/components/base/badge
*/
export function StatusToggleGroup({
items,
@@ -36,19 +104,30 @@ export function StatusToggleGroup({
if (next) onValueChange(next)
}}
variant="outline"
size="sm"
size="default"
spacing={0}
aria-label={ariaLabel}
className={cn('w-fit', className)}
>
{items.map((item) => (
<ToggleGroupItem key={item.id} value={item.id} className="gap-1.5 px-2.5">
<span>{item.label}</span>
{item.count !== undefined ? (
<span className="text-muted-foreground tabular-nums">{item.count}</span>
) : null}
</ToggleGroupItem>
))}
{items.map((item) => {
const tone = resolveTone(item)
const Icon = resolveIcon(item)
return (
<ToggleGroupItem key={item.id} value={item.id} className="gap-1.5 px-2.5">
<Icon
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>
)
}
File diff suppressed because one or more lines are too long