Compare commits

..
1 Commits
Author SHA1 Message Date
Denozordec e469c421ca 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.
2026-08-23 13:16:00 +07:00
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