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