Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e469c421ca |
@@ -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
Reference in New Issue
Block a user