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
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:
@@ -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