row.id}
+ emptyTitle="Нет ревизий"
+ pagination={false}
isLoading={isLoading}
- emptyMessage="Нет ревизий"
- showPagination={false}
- tableLayout={DATA_GRID_DENSE_LAYOUT}
/>
)
}
diff --git a/apps/web/src/components/data-grid-cell.tsx b/apps/web/src/components/data-grid-cell.tsx
index 7c3ed7f..78f2a2a 100644
--- a/apps/web/src/components/data-grid-cell.tsx
+++ b/apps/web/src/components/data-grid-cell.tsx
@@ -1,30 +1,38 @@
+import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
+import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@evobgp/ui/lib/utils'
-const ACCENT_CLASS = {
- primary: 'font-medium text-primary',
- default: 'font-medium text-foreground',
- mono: 'font-mono text-sm text-primary',
-} as const
-
-export function DataGridPrimaryCell({
+/**
+ * Name cell DNA — IconTile elevated size-10.5 + truncate.
+ * Preview: https://reui.io/preview/base/stats-12
+ * Docs: https://reui.io/docs/components/base/icon-tile
+ */
+export function DataGridNameCell({
+ icon: Icon,
title,
subtitle,
- accent = 'default',
+ iconClassName = 'text-muted-foreground',
className,
}: {
+ icon: LucideIcon
title: ReactNode
subtitle?: ReactNode
- accent?: keyof typeof ACCENT_CLASS
+ iconClassName?: string
className?: string
}) {
return (
-
-
{title}
- {subtitle ? (
-
{subtitle}
- ) : null}
+
+
+
+
+
+ {title}
+ {subtitle ? (
+ {subtitle}
+ ) : null}
+
)
}
@@ -42,3 +50,16 @@ export function DataGridMutedCell({
)
}
+
+/** Mono / secondary cell without semantic primary color. */
+export function DataGridMonoCell({
+ children,
+ className,
+}: {
+ children: ReactNode
+ className?: string
+}) {
+ return (
+
{children}
+ )
+}
diff --git a/apps/web/src/components/data-grid-shell.tsx b/apps/web/src/components/data-grid-shell.tsx
deleted file mode 100644
index 9984623..0000000
--- a/apps/web/src/components/data-grid-shell.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import type { ReactNode } from 'react'
-import type { Table } from '@tanstack/react-table'
-
-import { cn } from '@evobgp/ui/lib/utils'
-
-import { DataGridToolbar } from '@/components/data-grid-toolbar'
-import {
- panelCardContentFlushClassName,
- panelCardFooterClassName,
-} from '@/components/panel-card'
-import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
-import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
-import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
-import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
-import { FrameFooter } from '@/components/reui/frame'
-import {
- DATA_GRID_MESSAGES_RU,
- DATA_GRID_PAGINATION_RU,
- DATA_GRID_TABLE_CLASS_NAMES,
- DATA_GRID_TABLE_LAYOUT,
-} from '@/lib/data-grid-defaults'
-
-interface DataGridShellProps
{
- table: Table
- recordCount: number
- isLoading?: boolean
- emptyMessage?: ReactNode
- showPagination?: boolean
- tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
- className?: string
- onRowClick?: (row: TData) => void
-}
-
-export function DataGridShell({
- table,
- recordCount,
- isLoading = false,
- emptyMessage,
- showPagination = true,
- tableLayout = DATA_GRID_TABLE_LAYOUT,
- className,
- onRowClick,
-}: DataGridShellProps) {
- return (
-
-
-
-
- {showPagination ? (
-
-
-
- ) : null}
-
- )
-}
-
-/** @deprecated Prefer FrameDataGrid from @/components/reui-kit */
-export function DataGridCard({
- title,
- description,
- actions,
- children,
- className,
-}: {
- title?: ReactNode
- description?: ReactNode
- actions?: ReactNode
- children: ReactNode
- className?: string
-}) {
- return (
-
- {children}
-
- )
-}
-
-interface DataGridSectionProps extends DataGridShellProps {
- searchValue: string
- onSearchChange: (value: string) => void
- searchPlaceholder?: string
- toolbarFilters?: ReactNode
- toolbarActions?: ReactNode
- beforeGrid?: ReactNode
-}
-
-export function DataGridSection({
- searchValue,
- onSearchChange,
- searchPlaceholder,
- toolbarFilters,
- toolbarActions,
- beforeGrid,
- ...shellProps
-}: DataGridSectionProps) {
- return (
- <>
-
- {beforeGrid}
-
- >
- )
-}
diff --git a/apps/web/src/components/data-grid-toolbar.tsx b/apps/web/src/components/data-grid-toolbar.tsx
deleted file mode 100644
index 195a363..0000000
--- a/apps/web/src/components/data-grid-toolbar.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import type { ReactNode } from 'react'
-
-import { Field } from '@evobgp/ui/components/field'
-import {
- InputGroup,
- InputGroupAddon,
- InputGroupButton,
- InputGroupInput,
-} from '@evobgp/ui/components/input-group'
-import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
-
-import { panelCardInsetClassName } from '@/components/panel-card'
-import { cn } from '@evobgp/ui/lib/utils'
-
-interface DataGridToolbarProps {
- searchValue: string
- onSearchChange: (value: string) => void
- searchPlaceholder?: string
- /** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
- filters?: ReactNode
- actions?: ReactNode
- className?: string
-}
-
-/** Search + optional ReUI Filters row for Frame data grids. */
-export function DataGridToolbar({
- searchValue,
- onSearchChange,
- searchPlaceholder = 'Поиск…',
- filters,
- actions,
- className,
-}: DataGridToolbarProps) {
- return (
-
-
-
-
-
-
- onSearchChange(event.target.value)}
- aria-label={searchPlaceholder}
- />
-
- {searchValue.length > 0 ? (
- onSearchChange('')}
- >
-
-
- ) : null}
- {filters ? (
- filters
- ) : (
-
-
-
- )}
-
-
-
- {actions ?
{actions}
: null}
-
- )
-}
diff --git a/apps/web/src/components/directories/directories-communities-grid.tsx b/apps/web/src/components/directories/directories-communities-grid.tsx
index 84d6479..80e4ab0 100644
--- a/apps/web/src/components/directories/directories-communities-grid.tsx
+++ b/apps/web/src/components/directories/directories-communities-grid.tsx
@@ -1,39 +1,52 @@
-import { ColumnDef } from '@tanstack/react-table'
-import { useMemo } from 'react'
+import { useMemo, useState, type ReactNode } from 'react'
+import { Tags } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
-import { DataGridPrimaryCell } from '@/components/data-grid-cell'
-import { DataGridSection } from '@/components/data-grid-shell'
+import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
-import { useClientDataGrid } from '@/hooks/use-client-data-grid'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
+import {
+ ResourcePage,
+ createSearchFilterField,
+ createTextFilterQuery,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import type { BgpCommunity } from '@/types/api'
+const filterFields: FilterField[] = [
+ createSearchFilterField('search', 'Поиск', 'Поиск community…'),
+]
+
export function DirectoriesCommunitiesGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
+ actions,
}: {
items: BgpCommunity[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: BgpCommunity) => void
+ actions?: ReactNode
}) {
- const columns = useMemo[]>(() => {
- const cols: ColumnDef[] = [
+ const [filterQuery, setFilterQuery] = useState(() =>
+ createTextFilterQuery('search'),
+ )
+
+ const columns = useMemo[]>(() => {
+ const cols: DataGridColumnDef[] = [
{
accessorKey: 'title',
header: ({ column }) => ,
- cell: ({ row }) => ,
+ cell: ({ row }) => ,
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'community',
header: ({ column }) => ,
- cell: ({ row }) => (
-
- ),
+ cell: ({ row }) => {row.original.community},
meta: { headerTitle: 'Значение' },
},
{
@@ -51,9 +64,7 @@ export function DirectoriesCommunitiesGrid({
enableSorting: false,
enableHiding: false,
header: () => Действия,
- cell: ({ row }) => (
- onEdit(row.original)} />
- ),
+ cell: ({ row }) => onEdit(row.original)} />,
meta: { headerTitle: 'Действия' },
})
}
@@ -61,22 +72,22 @@ export function DirectoriesCommunitiesGrid({
return cols
}, [canWrite, onEdit])
- const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
- data: items,
- columns,
- getSearchText: (row) => `${row.title} ${row.community}`,
- getRowId: (row) => row.id,
- })
-
return (
- setFilterQuery(createTextFilterQuery('search'))}
+ getFilterFieldValue={(row) => `${row.title} ${row.community}`}
+ columns={columns}
+ data={items}
+ getRowId={(row) => row.id}
isLoading={isLoading}
- emptyMessage="Нет community"
- searchValue={globalFilter}
- onSearchChange={setGlobalFilter}
- searchPlaceholder="Поиск community…"
+ primaryAction={actions}
+ pinLastColumn={Boolean(canWrite && onEdit)}
+ emptyState={{ title: 'Нет community', action: actions }}
/>
)
}
diff --git a/apps/web/src/components/directories/directories-doh-grid.tsx b/apps/web/src/components/directories/directories-doh-grid.tsx
index 6591808..ded0d79 100644
--- a/apps/web/src/components/directories/directories-doh-grid.tsx
+++ b/apps/web/src/components/directories/directories-doh-grid.tsx
@@ -1,36 +1,51 @@
-import { ColumnDef } from '@tanstack/react-table'
-import { useMemo } from 'react'
+import { useMemo, useState, type ReactNode } from 'react'
+import { Globe } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
-import { DataGridPrimaryCell } from '@/components/data-grid-cell'
-import { DataGridSection } from '@/components/data-grid-shell'
+import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
-import { useClientDataGrid } from '@/hooks/use-client-data-grid'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
+import {
+ ResourcePage,
+ createSearchFilterField,
+ createTextFilterQuery,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import type { DohProfile } from '@/types/api'
+const filterFields: FilterField[] = [
+ createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
+]
+
export function DirectoriesDohGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
+ actions,
}: {
items: DohProfile[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: DohProfile) => void
+ actions?: ReactNode
}) {
- const columns = useMemo[]>(() => {
- const cols: ColumnDef[] = [
+ const [filterQuery, setFilterQuery] = useState(() =>
+ createTextFilterQuery('search'),
+ )
+
+ const columns = useMemo[]>(() => {
+ const cols: DataGridColumnDef[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.url,
header: ({ column }) => ,
cell: ({ row }) => (
-
),
meta: { headerTitle: 'Название' },
@@ -38,7 +53,7 @@ export function DirectoriesDohGrid({
{
accessorKey: 'url',
header: ({ column }) => ,
- cell: ({ row }) => ,
+ cell: ({ row }) => {row.original.url},
meta: { headerTitle: 'URL' },
},
{
@@ -56,9 +71,7 @@ export function DirectoriesDohGrid({
enableSorting: false,
enableHiding: false,
header: () => Действия,
- cell: ({ row }) => (
- onEdit(row.original)} />
- ),
+ cell: ({ row }) => onEdit(row.original)} />,
meta: { headerTitle: 'Действия' },
})
}
@@ -66,22 +79,22 @@ export function DirectoriesDohGrid({
return cols
}, [canWrite, onEdit])
- const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
- data: items,
- columns,
- getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
- getRowId: (row) => row.id,
- })
-
return (
- setFilterQuery(createTextFilterQuery('search'))}
+ getFilterFieldValue={(row) => `${row.name ?? ''} ${row.url}`}
+ columns={columns}
+ data={items}
+ getRowId={(row) => row.id}
isLoading={isLoading}
- emptyMessage="Нет DoH профилей"
- searchValue={globalFilter}
- onSearchChange={setGlobalFilter}
- searchPlaceholder="Поиск DoH профилей…"
+ primaryAction={actions}
+ pinLastColumn={Boolean(canWrite && onEdit)}
+ emptyState={{ title: 'Нет DoH профилей', action: actions }}
/>
)
}
diff --git a/apps/web/src/components/lookup/lookup-add-step.tsx b/apps/web/src/components/lookup/lookup-add-step.tsx
index 34cb8e3..2b21a1f 100644
--- a/apps/web/src/components/lookup/lookup-add-step.tsx
+++ b/apps/web/src/components/lookup/lookup-add-step.tsx
@@ -5,14 +5,20 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
-import { CommunitySelect } from '@/components/modules/community-select'
import { LoadingButton } from '@/components/loading-button'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
-import { SelectMenu } from '@/components/select-field'
+import {
+ Cascader,
+ CascaderContent,
+ CascaderPanel,
+ CascaderTrigger,
+} from '@/components/reui/cascader/cascader'
+import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
+import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
import { ApiError, apiMutate } from '@/lib/api-client'
import type {
BgpCommunity,
@@ -22,8 +28,10 @@ import type {
} from '@/types/api'
/**
- * Lookup wizard step 3 — module + community (bare content for single Frame).
+ * Lookup wizard step 3 — cascader module → community (wizard-2).
* @see https://reui.io/preview/base/wizard-2
+ * @see https://reui.io/docs/components/base/cascader
+ * @see https://reui.io/docs/components/base/stepper
*/
function hostPrefixFromIp(ip: string): string {
@@ -34,6 +42,31 @@ function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
}
+type LookupPick = { kind: 'module' } | { kind: 'community'; communityId: string }
+
+function encodeModuleValue(moduleId: string): string {
+ return `m:${moduleId}`
+}
+
+function encodeCommunityValue(moduleId: string, communityId: string): string {
+ return `c:${moduleId}:${communityId}`
+}
+
+function parsePick(
+ value: string,
+): { moduleId: string; communityId: string | null } | null {
+ if (value.startsWith('m:')) {
+ return { moduleId: value.slice(2), communityId: null }
+ }
+ if (value.startsWith('c:')) {
+ const rest = value.slice(2)
+ const sep = rest.indexOf(':')
+ if (sep <= 0) return null
+ return { moduleId: rest.slice(0, sep), communityId: rest.slice(sep + 1) }
+ }
+ return null
+}
+
export function LookupAddStep({
data,
modules,
@@ -48,49 +81,58 @@ export function LookupAddStep({
onAdded: () => void | Promise
}) {
const wantedType = moduleTypeForKind(data.query_kind)
+ const allowEmptyCommunity = data.query_kind === 'domain'
const eligible = useMemo(
() => modules.filter((m) => m.type === wantedType),
[modules, wantedType],
)
- const [moduleId, setModuleId] = useState('')
- const [communityId, setCommunityId] = useState(null)
+ const [pick, setPick] = useState('')
const [saving, setSaving] = useState(false)
+ const cascaderItems = useMemo[]>(
+ () =>
+ eligible.map((mod) => ({
+ value: encodeModuleValue(mod.id),
+ label: mod.name,
+ description: mod.id,
+ data: { kind: 'module' },
+ children: communities.map((community) => ({
+ value: encodeCommunityValue(mod.id, community.id),
+ label: community.title,
+ description: community.community,
+ data: { kind: 'community', communityId: community.id },
+ })),
+ })),
+ [eligible, communities],
+ )
+
useEffect(() => {
if (eligible.length === 0) {
- setModuleId('')
+ setPick('')
return
}
- setModuleId((prev) =>
- prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
- )
+ const first = eligible[0]!
+ const defaultCommunity = first.default_community_id
+ setPick((prev) => {
+ if (prev && parsePick(prev)?.moduleId && eligible.some((m) => m.id === parsePick(prev)?.moduleId)) {
+ return prev
+ }
+ if (defaultCommunity) return encodeCommunityValue(first.id, defaultCommunity)
+ return encodeModuleValue(first.id)
+ })
}, [eligible])
- useEffect(() => {
- const mod = eligible.find((m) => m.id === moduleId)
- if (!mod) {
- setCommunityId(null)
- return
- }
- setCommunityId(mod.default_community_id ?? null)
- }, [moduleId, eligible])
-
- const moduleItems = useMemo(
- () =>
- eligible.map((m) => ({
- value: m.id,
- label: m.name,
- })),
- [eligible],
- )
+ const parsed = pick ? parsePick(pick) : null
+ const moduleId = parsed?.moduleId ?? ''
+ const communityId = parsed?.communityId ?? null
async function handleAdd() {
if (!moduleId) {
toast.error('Выберите модуль')
return
}
- if (data.query_kind !== 'domain' && !communityId) {
+ if (!allowEmptyCommunity && !communityId) {
toast.error('Укажите community')
return
}
@@ -151,28 +193,34 @@ export function LookupAddStep({
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
-
-
- Модуль ({wantedType})
- {
- if (v) setModuleId(v)
- }}
- />
-
-
-
+
+ Модуль и community
+
+
+
+
+
+
+
+
+
+
),
meta: { headerTitle: 'Совпадение' },
},
@@ -76,13 +92,15 @@ export function LookupMatchesGrid({
const title = row.original.community_title?.trim()
const value = row.original.community?.trim()
if (!title && !value) {
- return —
+ return —
}
return (
-
+
+ {title || value || '—'}
+ {title && value && title !== value ? (
+ {value}
+ ) : null}
+
)
},
meta: { headerTitle: 'Community' },
@@ -95,7 +113,7 @@ export function LookupMatchesGrid({
row.original.source ? (
{row.original.source}
) : (
- —
+ —
),
meta: { headerTitle: 'Источник' },
},
@@ -103,35 +121,29 @@ export function LookupMatchesGrid({
[],
)
- const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
- data: items,
- columns,
- getSearchText: (row) =>
- `${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
- getRowId: (row) =>
- `${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
- })
-
return (
-
-
-
Совпадения
-
- Entries и snapshots · клик по строке открывает модуль
-
-
-
- void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
- }
- />
-
+ setFilterQuery(createTextFilterQuery('search'))}
+ getFilterFieldValue={(item, field) => {
+ if (field !== 'search') return undefined
+ return `${item.layer} ${item.module_name} ${item.module_type} ${item.matched_value} ${item.community ?? ''} ${item.community_title ?? ''} ${item.source ?? ''}`
+ }}
+ columns={columns}
+ data={items}
+ getRowId={(row) =>
+ `${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`
+ }
+ isLoading={isLoading}
+ onRowClick={(row) =>
+ void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
+ }
+ emptyState={{ title: 'Нет совпадений' }}
+ />
)
}
diff --git a/apps/web/src/components/modules/module-entries-grid.tsx b/apps/web/src/components/modules/module-entries-grid.tsx
index bde99de..4509812 100644
--- a/apps/web/src/components/modules/module-entries-grid.tsx
+++ b/apps/web/src/components/modules/module-entries-grid.tsx
@@ -1,17 +1,20 @@
-import { ColumnDef } from '@tanstack/react-table'
-import { Pencil, Trash2 } from 'lucide-react'
-import { useMemo } from 'react'
+import { Pencil, SearchIcon, Trash2 } from 'lucide-react'
+import { useMemo, useState, type ReactNode } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge'
-import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
-import { DataGridSection } from '@/components/data-grid-shell'
+import { DataGridMonoCell, DataGridMutedCell } from '@/components/data-grid-cell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
+import {
+ ResourcePage,
+ createTextFilterQuery,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers'
import { cdnSourceKindRu } from '@/lib/ui-labels'
-import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type {
AsEntry,
BgpCommunity,
@@ -27,6 +30,18 @@ type DeleteTarget =
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
+type EntryRow = DomainEntry | IpRangeEntry | CdnSource | AsEntry
+
+const filterFields: FilterField[] = [
+ {
+ id: 'search',
+ label: 'Поиск',
+ icon: ,
+ type: 'text',
+ placeholder: 'Поиск записей…',
+ },
+]
+
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
@@ -47,6 +62,12 @@ function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () =>
)
}
+function searchText(row: EntryRow): string {
+ return Object.values(row as Record
)
+ .filter((value) => typeof value === 'string' || typeof value === 'number')
+ .join(' ')
+}
+
export function ModuleEntriesGrid({
mod,
rows,
@@ -54,6 +75,14 @@ export function ModuleEntriesGrid({
onEdit,
onDelete,
isLoading = false,
+ isError = false,
+ error = null,
+ onRetry,
+ title,
+ description,
+ actions,
+ emptyTitle = 'Нет записей',
+ emptyDescription,
}: {
mod: ModuleRow
rows: Record[]
@@ -61,198 +90,207 @@ export function ModuleEntriesGrid({
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
isLoading?: boolean
+ isError?: boolean
+ error?: Error | null
+ onRetry?: () => void
+ title: string
+ description?: string
+ actions?: ReactNode
+ emptyTitle?: string
+ emptyDescription?: string
}) {
- const columns = useMemo(() => {
+ const [filterQuery, setFilterQuery] = useState(() =>
+ createTextFilterQuery('search'),
+ )
+
+ const columns = useMemo[]>(() => {
if (mod.type === 'DOMAINS') {
return [
{
accessorKey: 'fqdn',
- header: ({ column }: { column: { id: string } }) => (
-
- ),
- cell: ({ row }: { row: { original: DomainEntry } }) => (
-
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ {(row.original as DomainEntry).fqdn}
),
+ meta: { headerTitle: 'FQDN' },
},
{
id: 'community',
header: 'Community',
- cell: ({ row }: { row: { original: DomainEntry } }) => (
-
- {communityLabel(row.original.community_id, communities)}
-
+ cell: ({ row }) => (
+
+ {communityLabel((row.original as DomainEntry).community_id, communities)}
+
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
- cell: ({ row }: { row: { original: DomainEntry } }) => (
+ cell: ({ row }) => (
onEdit({ kind: 'domain', entry: row.original })}
- onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
+ onEdit={() => onEdit({ kind: 'domain', entry: row.original as DomainEntry })}
+ onDelete={() => onDelete({ kind: 'domain', entry: row.original as DomainEntry })}
/>
),
},
- ] as ColumnDef[]
+ ]
}
if (mod.type === 'IP_RANGES') {
return [
{
accessorKey: 'prefix',
- header: ({ column }: { column: { id: string } }) => (
-
- ),
- cell: ({ row }: { row: { original: IpRangeEntry } }) => (
-
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+ {(row.original as IpRangeEntry).prefix}
),
+ meta: { headerTitle: 'Префикс (CIDR)' },
},
{
id: 'community',
header: 'Community',
- cell: ({ row }: { row: { original: IpRangeEntry } }) => (
-
- {communityLabel(row.original.community_id, communities)}
-
+ cell: ({ row }) => (
+
+ {communityLabel((row.original as IpRangeEntry).community_id, communities)}
+
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
- cell: ({ row }: { row: { original: IpRangeEntry } }) => (
+ cell: ({ row }) => (
onEdit({ kind: 'ip-range', entry: row.original })}
- onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
+ onEdit={() => onEdit({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
+ onDelete={() => onDelete({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
/>
),
},
- ] as ColumnDef[]
+ ]
}
if (mod.type === 'CDN_CIDRS') {
return [
{
accessorKey: 'url',
- header: ({ column }: { column: { id: string } }) => (
-
- ),
- cell: ({ row }: { row: { original: CdnSource } }) => (
-
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {(row.original as CdnSource).url}
+
),
+ meta: { headerTitle: 'URL' },
},
{
accessorKey: 'source_kind',
header: 'Тип',
- cell: ({ row }: { row: { original: CdnSource } }) => (
- {cdnSourceKindRu(row.original.source_kind)}
+ cell: ({ row }) => (
+ {cdnSourceKindRu((row.original as CdnSource).source_kind)}
),
},
{
id: 'community',
header: 'Community',
- cell: ({ row }: { row: { original: CdnSource } }) => (
-
- {communityLabel(row.original.community_id, communities)}
-
+ cell: ({ row }) => (
+
+ {communityLabel((row.original as CdnSource).community_id, communities)}
+
),
},
{
id: 'last_refreshed_at',
- accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
- header: ({ column }: { column: { id: string } }) => (
-
- ),
- cell: ({ row }: { row: { original: CdnSource } }) => (
- {formatDateTime(row.original.last_refreshed_at)}
+ accessorFn: (row) => (row as CdnSource).last_refreshed_at ?? '',
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {formatDateTime((row.original as CdnSource).last_refreshed_at)}
+
),
+ meta: { headerTitle: 'Обновлено' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
- cell: ({ row }: { row: { original: CdnSource } }) => (
+ cell: ({ row }) => (
onEdit({ kind: 'cdn', entry: row.original })}
- onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
+ onEdit={() => onEdit({ kind: 'cdn', entry: row.original as CdnSource })}
+ onDelete={() => onDelete({ kind: 'cdn', entry: row.original as CdnSource })}
/>
),
},
- ] as ColumnDef[]
+ ]
}
return [
{
accessorKey: 'asn',
- header: ({ column }: { column: { id: string } }) => (
-
- ),
- cell: ({ row }: { row: { original: AsEntry } }) => (
- {row.original.asn}
- ),
+ header: ({ column }) => ,
+ cell: ({ row }) => {(row.original as AsEntry).asn},
+ meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'asn_name',
header: 'Имя',
- cell: ({ row }: { row: { original: AsEntry } }) => (
- {row.original.asn_name ?? '—'}
+ cell: ({ row }) => (
+ {(row.original as AsEntry).asn_name ?? '—'}
),
},
{
accessorKey: 'prefix_count',
header: 'Префиксов',
- cell: ({ row }: { row: { original: AsEntry } }) => (
- {row.original.prefix_count ?? '—'}
+ cell: ({ row }) => (
+ {(row.original as AsEntry).prefix_count ?? '—'}
),
},
{
id: 'community',
header: 'Community',
- cell: ({ row }: { row: { original: AsEntry } }) => (
-
- {communityLabel(row.original.community_id, communities)}
-
+ cell: ({ row }) => (
+
+ {communityLabel((row.original as AsEntry).community_id, communities)}
+
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
- cell: ({ row }: { row: { original: AsEntry } }) => (
+ cell: ({ row }) => (
onEdit({ kind: 'as', entry: row.original })}
- onDelete={() => onDelete({ kind: 'as', entry: row.original })}
+ onEdit={() => onEdit({ kind: 'as', entry: row.original as AsEntry })}
+ onDelete={() => onDelete({ kind: 'as', entry: row.original as AsEntry })}
/>
),
},
- ] as ColumnDef[]
+ ]
}, [communities, mod.type, onDelete, onEdit])
- type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
- const data = rows as unknown as RowType[]
-
- const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
- data,
- columns: columns as ColumnDef[],
- getSearchText: (row) => {
- const r = row as Record
- return Object.values(r)
- .filter((v) => typeof v === 'string' || typeof v === 'number')
- .join(' ')
- },
- getRowId: (row) => row.id,
- })
+ const data = rows as unknown as EntryRow[]
return (
- setFilterQuery(createTextFilterQuery('search'))}
+ getFilterFieldValue={(item, field) => {
+ if (field !== 'search') return undefined
+ return searchText(item)
+ }}
+ columns={columns}
+ data={data}
+ getRowId={(row) => row.id}
isLoading={isLoading}
- emptyMessage="Нет записей"
- searchValue={globalFilter}
- onSearchChange={setGlobalFilter}
- searchPlaceholder="Поиск записей…"
+ isError={isError}
+ error={error}
+ onRetry={onRetry}
+ primaryAction={actions}
+ pinLastColumn
+ emptyState={{ title: emptyTitle, description: emptyDescription, action: actions }}
/>
)
}
diff --git a/apps/web/src/components/modules/module-entries-section.tsx b/apps/web/src/components/modules/module-entries-section.tsx
index bbd942e..f60f00e 100644
--- a/apps/web/src/components/modules/module-entries-section.tsx
+++ b/apps/web/src/components/modules/module-entries-section.tsx
@@ -5,9 +5,6 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
-import { FrameDataGrid } from '@/components/reui-kit'
-import { QueryState } from '@/components/query-state'
-import { TableSkeleton } from '@/components/skeletons'
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
@@ -126,47 +123,37 @@ export function ModuleEntriesSection({
}
}
+ const addButton = (
+
+ )
+
return (
<>
-
-
- Добавить
-
- }
- >
- }
- onRetry={onRetry}
- >
- {(rows) => (
- {
- if (target.kind === 'domain') setEditDomain(target.entry)
- if (target.kind === 'ip-range') setEditIpRange(target.entry)
- if (target.kind === 'cdn') setEditCdn(target.entry)
- if (target.kind === 'as') setEditAs(target.entry)
- setDialogOpen(true)
- }}
- onDelete={setDeleteTarget}
- />
- )}
-
-
+ actions={addButton}
+ emptyTitle={meta.emptyTitle}
+ emptyDescription={meta.emptyDescription}
+ onEdit={(target) => {
+ if (target.kind === 'domain') setEditDomain(target.entry)
+ if (target.kind === 'ip-range') setEditIpRange(target.entry)
+ if (target.kind === 'cdn') setEditCdn(target.entry)
+ if (target.kind === 'as') setEditAs(target.entry)
+ setDialogOpen(true)
+ }}
+ onDelete={setDeleteTarget}
+ />
{mod.type === 'DOMAINS' ? (
,
+ type: 'text',
+ placeholder: 'Название или тип…',
+ },
+ {
+ id: 'type',
+ label: 'Тип',
+ type: 'select',
+ searchable: false,
+ options: TYPE_OPTIONS,
+ renderValue: ({ values }) => renderSingleSelectedLabel(values, TYPE_OPTIONS),
+ },
+]
+
+function getFilterFieldValue(item: ModuleRow, field: string): unknown {
+ switch (field) {
+ case 'search':
+ return `${item.name} ${item.type} ${moduleTypeRu(item.type)} ${item.enabled ? 'включён' : 'выключен'}`
+ case 'type':
+ return item.type
+ default:
+ return undefined
+ }
+}
+
+function tabFilter(item: ModuleRow, tabId: string): boolean {
+ if (tabId === 'enabled') return item.enabled !== false
+ if (tabId === 'disabled') return item.enabled === false
+ return true
+}
+
export function ModulesListGrid({
items,
isLoading = false,
+ title = 'Все модули',
+ actions,
}: {
items: ModuleRow[]
isLoading?: boolean
+ title?: string
+ actions?: ReactNode
}) {
const navigate = useNavigate()
+ const [filterQuery, setFilterQuery] = useState(() =>
+ createTextFilterQuery('search'),
+ )
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => ,
cell: ({ row }) => (
-
-
-
-
+
),
meta: { headerTitle: 'Название' },
},
@@ -65,34 +121,32 @@ export function ModulesListGrid({
: '—'}
),
- sortingFn: (a, b) => {
- const av = a.original.last_refreshed_at ?? ''
- const bv = b.original.last_refreshed_at ?? ''
- return av.localeCompare(bv)
- },
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
- const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
- data: items,
- columns,
- getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
- getRowId: (row) => row.id,
- })
-
return (
- setFilterQuery(createTextFilterQuery('search'))}
+ getFilterFieldValue={getFilterFieldValue}
+ columns={columns}
+ data={items}
+ getRowId={(row) => row.id}
isLoading={isLoading}
- emptyMessage="Нет модулей"
- searchValue={globalFilter}
- onSearchChange={setGlobalFilter}
- searchPlaceholder="Поиск модулей…"
- onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
+ primaryAction={actions}
+ onRowClick={(row) =>
+ void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
+ }
+ emptyState={{ title: 'Нет модулей' }}
/>
)
}
diff --git a/apps/web/src/components/monitoring/monitoring-ready-grid.tsx b/apps/web/src/components/monitoring/monitoring-ready-grid.tsx
index f4c140d..4d50e68 100644
--- a/apps/web/src/components/monitoring/monitoring-ready-grid.tsx
+++ b/apps/web/src/components/monitoring/monitoring-ready-grid.tsx
@@ -1,12 +1,10 @@
-import { ColumnDef } from '@tanstack/react-table'
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react'
-import { DataGridPrimaryCell } from '@/components/data-grid-cell'
-import { DataGridSection } from '@/components/data-grid-shell'
+import { DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
-import { useClientDataGrid } from '@/hooks/use-client-data-grid'
+import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
import {
isReadyCheckOk,
isSystemReady,
@@ -14,8 +12,6 @@ import {
} from '@/lib/metrics'
import { readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring'
-import { Item, ItemMedia } from '@evobgp/ui/components/item'
-import { cn } from '@evobgp/ui/lib/utils'
const READY_CHECK_ICONS: Record = {
postgres: Database,
@@ -79,32 +75,19 @@ export function MonitoringReadyGrid({
return rows
}, [health?.ok, ready.checks, ready.status])
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
accessorKey: 'label',
header: ({ column }) => ,
- cell: ({ row }) => {
- const Icon = row.original.icon
- return (
-
- -
-
-
-
-
-
-
- )
- },
+ cell: ({ row }) => (
+
+ ),
meta: { headerTitle: 'Проверка' },
},
{
@@ -112,9 +95,7 @@ export function MonitoringReadyGrid({
enableSorting: false,
header: 'Статус',
cell: ({ row }) => (
-
-
-
+
),
meta: { headerTitle: 'Статус' },
},
@@ -122,23 +103,15 @@ export function MonitoringReadyGrid({
[],
)
- const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
- data,
- columns,
- getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
- getRowId: (row) => row.id,
- pageSize: 20,
- })
-
return (
- row.id}
+ emptyTitle="Нет проверок"
+ pagination={false}
/>
)
}
diff --git a/apps/web/src/components/network/network-discovered-peers-card.tsx b/apps/web/src/components/network/network-discovered-peers-card.tsx
index 569d311..8296edf 100644
--- a/apps/web/src/components/network/network-discovered-peers-card.tsx
+++ b/apps/web/src/components/network/network-discovered-peers-card.tsx
@@ -1,24 +1,23 @@
import { useMemo, useState } from 'react'
-import type { ColumnDef } from '@tanstack/react-table'
-import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
+import { CheckIcon, Network, SearchIcon, XIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
-import { DataGridPrimaryCell } from '@/components/data-grid-cell'
+import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
- createFilter,
- type Filter,
- type FilterFieldConfig,
-} from '@/components/reui/filters'
-import { ResourcePage } from '@/components/reui-kit'
+ ResourcePage,
+ createTextFilterQuery,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import {
useApproveDiscoveredPeerMutation,
@@ -40,17 +39,12 @@ function speakerLabel(s: SpeakerRow): string {
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
}
-function createDefaultFilters(): Filter[] {
- return [createFilter('neighbor', 'contains', [''])]
-}
-
-const filterFields: FilterFieldConfig[] = [
+const filterFields: FilterField[] = [
{
- key: 'neighbor',
+ id: 'neighbor',
label: 'Сосед',
icon: ,
type: 'text',
- className: 'w-48',
placeholder: 'IP или ID соседа…',
},
]
@@ -74,7 +68,9 @@ export function NetworkDiscoveredPeersCard({
}: NetworkDiscoveredPeersCardProps) {
const approveMutation = useApproveDiscoveredPeerMutation()
const rejectMutation = useRejectDiscoveredPeerMutation()
- const [filters, setFilters] = useState(createDefaultFilters)
+ const [filterQuery, setFilterQuery] = useState(() =>
+ createTextFilterQuery('neighbor'),
+ )
const [approveTarget, setApproveTarget] = useState(null)
const [rejectTarget, setRejectTarget] = useState(null)
const [name, setName] = useState('')
@@ -88,7 +84,7 @@ export function NetworkDiscoveredPeersCard({
[speakers],
)
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
id: 'neighbor_id',
@@ -97,10 +93,10 @@ export function NetworkDiscoveredPeersCard({
),
cell: ({ row }) => (
-
),
meta: { headerTitle: 'ID соседа' },
@@ -109,7 +105,7 @@ export function NetworkDiscoveredPeersCard({
accessorKey: 'remote_asn',
header: ({ column }) => ,
cell: ({ row }) => (
- {row.original.remote_asn || '—'}
+ {row.original.remote_asn || '—'}
),
meta: { headerTitle: 'ASN' },
},
@@ -186,9 +182,9 @@ export function NetworkDiscoveredPeersCard({
title="На одобрение"
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
filterFields={filterFields}
- filters={filters}
- onFiltersChange={setFilters}
- onClearFilters={() => setFilters(createDefaultFilters())}
+ filterQuery={filterQuery}
+ onFilterQueryChange={setFilterQuery}
+ onClearFilters={() => setFilterQuery(createTextFilterQuery('neighbor'))}
getFilterFieldValue={getFilterFieldValue}
columns={columns}
data={items}
@@ -197,6 +193,7 @@ export function NetworkDiscoveredPeersCard({
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
+ pinLastColumn
emptyState={{
title: 'Нет ожидающих пиров',
description:
diff --git a/apps/web/src/components/network/network-peers-card.tsx b/apps/web/src/components/network/network-peers-card.tsx
index a7ca332..1dd077f 100644
--- a/apps/web/src/components/network/network-peers-card.tsx
+++ b/apps/web/src/components/network/network-peers-card.tsx
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react'
-import type { ColumnDef } from '@tanstack/react-table'
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
@@ -11,12 +10,13 @@ import {
peerColumns,
peerTabFilter,
} from '@/components/network/network-peers-grid'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
- createFilter,
- type Filter,
- type FilterFieldConfig,
-} from '@/components/reui/filters'
-import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
+ ResourcePage,
+ createTextFilterQuery,
+ renderSingleSelectedLabel,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import { useDeletePeerMutation } from '@/queries/network'
import type { PeerRow, SpeakerRow } from '@/types/api'
@@ -46,37 +46,29 @@ const SESSION_STATE_OPTIONS = [
{ value: 'OpenConfirm', label: 'Open подтверждён' },
]
-function createDefaultPeerFilters(): Filter[] {
- return [createFilter('name', 'contains', [''])]
-}
-
-const peerFilterFields: FilterFieldConfig[] = [
+const peerFilterFields: FilterField[] = [
{
- key: 'name',
+ id: 'name',
label: 'Имя',
icon: ,
type: 'text',
- className: 'w-48',
placeholder: 'Поиск по имени…',
},
{
- key: 'neighbor',
+ id: 'neighbor',
label: 'Сосед',
icon: ,
type: 'text',
- className: 'w-48',
placeholder: 'Адрес соседа…',
},
{
- key: 'session_state',
+ id: 'session_state',
label: 'Состояние',
icon: ,
type: 'select',
searchable: false,
- className: 'w-[160px]',
options: SESSION_STATE_OPTIONS,
- customValueRenderer: (values) =>
- renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
+ renderValue: ({ values }) => renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
},
]
@@ -100,7 +92,7 @@ export function NetworkPeersCard({
const deleteMutation = useDeletePeerMutation()
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null)
- const [filters, setFilters] = useState(createDefaultPeerFilters)
+ const [filterQuery, setFilterQuery] = useState(() => createTextFilterQuery('name'))
const addButton = useMemo(
() => (
@@ -112,7 +104,7 @@ export function NetworkPeersCard({
[],
)
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
...peerColumns,
{
@@ -147,9 +139,9 @@ export function NetworkPeersCard({
tabs={PEER_TABS}
tabFilter={peerTabFilter}
filterFields={peerFilterFields}
- filters={filters}
- onFiltersChange={setFilters}
- onClearFilters={() => setFilters(createDefaultPeerFilters())}
+ filterQuery={filterQuery}
+ onFilterQueryChange={setFilterQuery}
+ onClearFilters={() => setFilterQuery(createTextFilterQuery('name'))}
getFilterFieldValue={getPeerFilterFieldValue}
columns={columns}
data={items}
@@ -159,6 +151,7 @@ export function NetworkPeersCard({
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
+ pinLastColumn
emptyState={{
title: 'Нет пиров',
description: 'Добавьте первого BGP-соседа.',
diff --git a/apps/web/src/components/network/network-peers-grid.tsx b/apps/web/src/components/network/network-peers-grid.tsx
index f064bf8..f467cf7 100644
--- a/apps/web/src/components/network/network-peers-grid.tsx
+++ b/apps/web/src/components/network/network-peers-grid.tsx
@@ -1,22 +1,23 @@
-import type { ColumnDef } from '@tanstack/react-table'
+import { Share2 } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
-import { DataGridPrimaryCell } from '@/components/data-grid-cell'
+import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
+import type { DataGridColumnDef } from '@/components/reui-kit'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api'
-export const peerColumns: ColumnDef[] = [
+export const peerColumns: DataGridColumnDef[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => ,
cell: ({ row }) => (
-
),
meta: { headerTitle: 'Имя' },
@@ -24,16 +25,14 @@ export const peerColumns: ColumnDef[] = [
{
accessorKey: 'neighbor',
header: ({ column }) => ,
- cell: ({ row }) => (
-
- ),
+ cell: ({ row }) => {row.original.neighbor},
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => ,
cell: ({ row }) => (
- {row.original.remote_asn ?? '—'}
+ {row.original.remote_asn ?? '—'}
),
meta: { headerTitle: 'ASN' },
},
diff --git a/apps/web/src/components/network/network-speakers-card.tsx b/apps/web/src/components/network/network-speakers-card.tsx
index 62c087e..5eae7cf 100644
--- a/apps/web/src/components/network/network-speakers-card.tsx
+++ b/apps/web/src/components/network/network-speakers-card.tsx
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react'
-import type { ColumnDef } from '@tanstack/react-table'
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
@@ -11,12 +10,13 @@ import {
speakerColumns,
speakerTabFilter,
} from '@/components/network/network-speakers-grid'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
- createFilter,
- type Filter,
- type FilterFieldConfig,
-} from '@/components/reui/filters'
-import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
+ ResourcePage,
+ createTextFilterQuery,
+ renderSingleSelectedLabel,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import { useDeleteSpeakerMutation } from '@/queries/network'
import type { SpeakerRow } from '@/types/api'
@@ -40,29 +40,22 @@ const ROLE_OPTIONS = [
{ value: 'master', label: 'Мастер' },
]
-function createDefaultSpeakerFilters(): Filter[] {
- return [createFilter('endpoint', 'contains', [''])]
-}
-
-const speakerFilterFields: FilterFieldConfig[] = [
+const speakerFilterFields: FilterField[] = [
{
- key: 'endpoint',
+ id: 'endpoint',
label: 'Конечная точка',
icon: ,
type: 'text',
- className: 'w-52',
placeholder: 'Адрес агента…',
},
{
- key: 'role',
+ id: 'role',
label: 'Роль',
icon: ,
type: 'select',
searchable: false,
- className: 'w-[140px]',
options: ROLE_OPTIONS,
- customValueRenderer: (values) =>
- renderSingleSelectedLabel(values, ROLE_OPTIONS),
+ renderValue: ({ values }) => renderSingleSelectedLabel(values, ROLE_OPTIONS),
},
]
@@ -84,7 +77,9 @@ export function NetworkSpeakersCard({
const deleteMutation = useDeleteSpeakerMutation()
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null)
- const [filters, setFilters] = useState(createDefaultSpeakerFilters)
+ const [filterQuery, setFilterQuery] = useState(() =>
+ createTextFilterQuery('endpoint'),
+ )
const addButton = useMemo(
() => (
@@ -96,7 +91,7 @@ export function NetworkSpeakersCard({
[],
)
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
...speakerColumns,
{
@@ -131,9 +126,9 @@ export function NetworkSpeakersCard({
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
- filters={filters}
- onFiltersChange={setFilters}
- onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
+ filterQuery={filterQuery}
+ onFilterQueryChange={setFilterQuery}
+ onClearFilters={() => setFilterQuery(createTextFilterQuery('endpoint'))}
getFilterFieldValue={getSpeakerFilterFieldValue}
columns={columns}
data={items}
@@ -143,6 +138,7 @@ export function NetworkSpeakersCard({
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
+ pinLastColumn
emptyState={{
title: 'Нет спикеров',
description: 'Добавьте первого BIRD-агента на ноде.',
diff --git a/apps/web/src/components/network/network-speakers-grid.tsx b/apps/web/src/components/network/network-speakers-grid.tsx
index f148302..bdc2770 100644
--- a/apps/web/src/components/network/network-speakers-grid.tsx
+++ b/apps/web/src/components/network/network-speakers-grid.tsx
@@ -1,19 +1,24 @@
-import type { ColumnDef } from '@tanstack/react-table'
+import { ServerCog } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
-import { DataGridPrimaryCell } from '@/components/data-grid-cell'
+import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
+import type { DataGridColumnDef } from '@/components/reui-kit'
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
-export const speakerColumns: ColumnDef[] = [
+export const speakerColumns: DataGridColumnDef[] = [
{
accessorKey: 'endpoint',
header: ({ column }) => ,
cell: ({ row }) => (
-
+
),
meta: { headerTitle: 'Конечная точка' },
},
@@ -51,9 +56,9 @@ export const speakerColumns: ColumnDef[] = [
const live = row.original.live
if (!live) return '—'
return (
-
+
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
-
+
)
},
meta: { headerTitle: 'BGP' },
@@ -63,7 +68,7 @@ export const speakerColumns: ColumnDef[] = [
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
- return item.endpoint
+ return `${item.endpoint} ${item.agent_domain ?? ''}`
case 'role':
return item.role
default:
diff --git a/apps/web/src/components/operations/operations-jobs-card.tsx b/apps/web/src/components/operations/operations-jobs-card.tsx
index e2312cf..42c41a9 100644
--- a/apps/web/src/components/operations/operations-jobs-card.tsx
+++ b/apps/web/src/components/operations/operations-jobs-card.tsx
@@ -1,34 +1,18 @@
-import { useMemo, useState } from 'react'
+import { useState } from 'react'
+import { LayoutGrid, Table2 } from 'lucide-react'
-import { FrameDataGrid } from '@/components/reui-kit'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
+import { OperationsJobsKanban } from '@/components/operations/operations-jobs-kanban'
import { QueryState } from '@/components/query-state'
-import { TableSkeleton } from '@/components/skeletons'
-import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
+import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
-type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
-
-function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
- if (tab === 'all') return items
- if (tab === 'active') return items.filter((j) => j.status === 'running' || j.status === 'queued')
- if (tab === 'failed')
- return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
- return items.filter((j) => j.status === 'succeeded')
-}
-
-function tabCounts(items: JobRow[]) {
- return {
- all: items.length,
- active: items.filter((j) => j.status === 'running' || j.status === 'queued').length,
- failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
- .length,
- succeeded: items.filter((j) => j.status === 'succeeded').length,
- }
-}
-
-/** data-grid-filtering-1 style jobs card with status tabs. */
+/**
+ * Jobs ResourcePage + optional Kanban board (не замена грида).
+ * @see https://reui.io/preview/base/data-grid-filtering-2
+ * @see https://reui.io/docs/components/base/kanban
+ */
export function OperationsJobsCard({
jobs,
nameById,
@@ -46,41 +30,54 @@ export function OperationsJobsCard({
error: unknown
onRetry: () => void
}) {
- const [tab, setTab] = useState('all')
- const counts = useMemo(() => tabCounts(jobs), [jobs])
- const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
+ const [view, setView] = useState<'table' | 'board'>('table')
return (
-
-
-
setTab(v as JobTab)} className="w-full">
-
- Все ({counts.all})
- Активные ({counts.active})
- Успешные ({counts.succeeded})
- Ошибки ({counts.failed})
-
-
+
+
+
{
+ const next = values[0]
+ if (next === 'table' || next === 'board') setView(next)
+ }}
+ variant="outline"
+ size="sm"
+ aria-label="Вид задач"
+ className="w-fit"
+ >
+
+
+ Таблица
+
+
+
+ Доска
+
+
}
+ empty={false}
onRetry={onRetry}
>
- {(items) => (
-
0}
- />
- )}
+ {(items) =>
+ view === 'board' ? (
+
+ ) : (
+ 0}
+ />
+ )
+ }
-
+
)
}
diff --git a/apps/web/src/components/operations/operations-jobs-grid.tsx b/apps/web/src/components/operations/operations-jobs-grid.tsx
index ad8547d..e8d549a 100644
--- a/apps/web/src/components/operations/operations-jobs-grid.tsx
+++ b/apps/web/src/components/operations/operations-jobs-grid.tsx
@@ -1,20 +1,48 @@
-import { ColumnDef } from '@tanstack/react-table'
+import { useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
-import { useMemo } from 'react'
+import { ListTodo, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
-
-import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
-import { DataGridSection } from '@/components/data-grid-shell'
+import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
-import { useClientDataGrid } from '@/hooks/use-client-data-grid'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
+import {
+ ResourcePage,
+ createTextFilterQuery,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
+const JOB_TABS = [
+ { id: 'all', label: 'Все' },
+ { id: 'active', label: 'Активные' },
+ { id: 'succeeded', label: 'Успешные' },
+ { id: 'failed', label: 'Ошибки' },
+]
+
+const filterFields: FilterField[] = [
+ {
+ id: 'search',
+ label: 'Поиск',
+ icon:
,
+ type: 'text',
+ placeholder: 'Поиск задач…',
+ },
+]
+
+function tabFilter(item: JobRow, tabId: string): boolean {
+ if (tabId === 'active') return item.status === 'running' || item.status === 'queued'
+ if (tabId === 'failed')
+ return ['failed', 'error', 'cancelled'].includes(item.status.toLowerCase())
+ if (tabId === 'succeeded') return item.status === 'succeeded'
+ return true
+}
+
export function OperationsJobsGrid({
items,
nameById,
@@ -26,6 +54,9 @@ export function OperationsJobsGrid({
qc: QueryClient
isLoading?: boolean
}) {
+ const [filterQuery, setFilterQuery] = useState
(() =>
+ createTextFilterQuery('search'),
+ )
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
@@ -35,13 +66,14 @@ export function OperationsJobsGrid({
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => ,
cell: ({ row }) => (
- {
- const moduleName = row.meta?.module_id
- ? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
- : ''
- return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
- },
- getRowId: (row) => row.job_id,
- })
-
return (
- setFilterQuery(createTextFilterQuery('search'))}
+ getFilterFieldValue={(item, field) => {
+ if (field !== 'search') return undefined
+ const moduleName = item.meta?.module_id
+ ? (nameById.get(String(item.meta.module_id)) ?? String(item.meta.module_id))
+ : ''
+ return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${moduleName}`
+ }}
+ columns={columns}
+ data={items}
+ getRowId={(row) => row.job_id}
isLoading={isLoading}
- emptyMessage="Нет задач"
- searchValue={globalFilter}
- onSearchChange={setGlobalFilter}
- searchPlaceholder="Поиск задач…"
+ pinLastColumn
+ virtualization={items.length > 80}
+ emptyState={{ title: 'Нет задач' }}
/>
)
}
diff --git a/apps/web/src/components/operations/operations-jobs-kanban.tsx b/apps/web/src/components/operations/operations-jobs-kanban.tsx
new file mode 100644
index 0000000..bd98370
--- /dev/null
+++ b/apps/web/src/components/operations/operations-jobs-kanban.tsx
@@ -0,0 +1,113 @@
+import { useMemo } from 'react'
+
+import { DataGridMutedCell } from '@/components/data-grid-cell'
+import { StatusBadge } from '@/components/status-badge'
+import {
+ Kanban,
+ KanbanBoard,
+ KanbanColumn,
+ KanbanColumnContent,
+ KanbanItem,
+} from '@/components/reui/kanban'
+import { Frame, FramePanel } from '@/components/reui/frame'
+import { jobKindRu } from '@/lib/ui-labels'
+import type { JobRow } from '@/types/api'
+
+/**
+ * Operations jobs board view — ReUI Kanban (read-only columns).
+ * @see https://reui.io/docs/components/base/kanban
+ * @see https://reui.io/docs/components/base/frame
+ */
+
+const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
+
+const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
+ queued: 'Очередь',
+ running: 'Выполняются',
+ succeeded: 'Успешные',
+ failed: 'Ошибки',
+}
+
+function columnForStatus(status: string): (typeof COLUMN_ORDER)[number] {
+ const s = status.toLowerCase()
+ if (s === 'queued') return 'queued'
+ if (s === 'running') return 'running'
+ if (s === 'succeeded') return 'succeeded'
+ return 'failed'
+}
+
+function emptyColumns(): Record {
+ return {
+ queued: [],
+ running: [],
+ succeeded: [],
+ failed: [],
+ }
+}
+
+export function OperationsJobsKanban({
+ items,
+ nameById,
+}: {
+ items: JobRow[]
+ nameById: Map
+}) {
+ const columns = useMemo(() => {
+ const next = emptyColumns()
+ for (const job of items) {
+ next[columnForStatus(job.status)].push(job)
+ }
+ return next
+ }, [items])
+
+ return (
+
+
+ undefined}
+ getItemValue={(job) => job.job_id}
+ >
+
+ {COLUMN_ORDER.map((columnId) => (
+
+
+
{COLUMN_LABELS[columnId]}
+
+ {columns[columnId]?.length ?? 0}
+
+
+
+ {(columns[columnId] ?? []).map((job) => (
+
+
+ {jobKindRu(job.kind)}
+
+
+ {job.meta?.module_id ? (
+
+ {nameById.get(String(job.meta.module_id)) ??
+ String(job.meta.module_id)}
+
+ ) : null}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/operations/operations-revisions-grid.tsx b/apps/web/src/components/operations/operations-revisions-grid.tsx
index f7725a3..7da6584 100644
--- a/apps/web/src/components/operations/operations-revisions-grid.tsx
+++ b/apps/web/src/components/operations/operations-revisions-grid.tsx
@@ -1,20 +1,32 @@
-import { ColumnDef } from '@tanstack/react-table'
+import { useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
-import { RefreshCw } from 'lucide-react'
-import { useMemo } from 'react'
+import { GitCommitHorizontal, RefreshCw, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
-
-import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
-import { DataGridSection } from '@/components/data-grid-shell'
+import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
-import { useClientDataGrid } from '@/hooks/use-client-data-grid'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
+import {
+ ResourcePage,
+ createTextFilterQuery,
+ type DataGridColumnDef,
+} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client'
import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
+const filterFields: FilterField[] = [
+ {
+ id: 'search',
+ label: 'Поиск',
+ icon: ,
+ type: 'text',
+ placeholder: 'Поиск ревизий…',
+ },
+]
+
export function OperationsRevisionsGrid({
items,
qc,
@@ -24,6 +36,9 @@ export function OperationsRevisionsGrid({
qc: QueryClient
isLoading?: boolean
}) {
+ const [filterQuery, setFilterQuery] = useState(() =>
+ createTextFilterQuery('search'),
+ )
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
@@ -34,14 +49,14 @@ export function OperationsRevisionsGrid({
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => ,
cell: ({ row }) => (
-
+
),
meta: { headerTitle: 'ID' },
},
@@ -88,22 +103,21 @@ export function OperationsRevisionsGrid({
[rollbackMutation],
)
- const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
- data: items,
- columns,
- getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
- getRowId: (row) => row.id,
- })
-
return (
- setFilterQuery(createTextFilterQuery('search'))}
+ getFilterFieldValue={(item) => `${item.id} ${item.materialized_prefix_count}`}
+ columns={columns}
+ data={items}
+ getRowId={(row) => row.id}
isLoading={isLoading}
- emptyMessage="Нет ревизий"
- searchValue={globalFilter}
- onSearchChange={setGlobalFilter}
- searchPlaceholder="Поиск ревизий…"
+ pinLastColumn
+ virtualization={items.length > 80}
+ emptyState={{ title: 'Нет ревизий' }}
/>
)
}
diff --git a/apps/web/src/components/panel-card.tsx b/apps/web/src/components/panel-card.tsx
index 71d8483..3d7b6bf 100644
--- a/apps/web/src/components/panel-card.tsx
+++ b/apps/web/src/components/panel-card.tsx
@@ -11,8 +11,9 @@ import {
import { cn } from '@evobgp/ui/lib/utils'
/**
- * Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
+ * Frame shell for analytics / comparison panels (not list grids).
* @see https://reui.io/docs/components/base/frame
+ * @see https://reui.io/preview/base/chart-1
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
diff --git a/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts
new file mode 100644
index 0000000..b68774f
--- /dev/null
+++ b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+
+import { kitDataGridTableClassNames, kitDataGridTableLayout } from './frame-data-grid'
+
+describe('kitDataGridTableLayout', () => {
+ it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
+ const layout = kitDataGridTableLayout()
+ expect(layout.dense).toBe(true)
+ expect(layout.headerBackground).toBe(false)
+ expect(layout.width).toBe('fixed')
+ expect(layout.headerSticky).toBe(false)
+ expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
+ expect(layout.columnsPinnable).toBe(false)
+ })
+
+ it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
+ expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
+ })
+
+ it('именованные opts: auto + pin', () => {
+ const layout = kitDataGridTableLayout({
+ width: 'auto',
+ columnsPinnable: true,
+ })
+ expect(layout.width).toBe('auto')
+ expect(layout.columnsPinnable).toBe(true)
+ expect(layout.headerBackground).toBe(false)
+ })
+})
diff --git a/apps/web/src/components/reui-kit/expandable-resource-grid.tsx b/apps/web/src/components/reui-kit/expandable-resource-grid.tsx
new file mode 100644
index 0000000..405485f
--- /dev/null
+++ b/apps/web/src/components/reui-kit/expandable-resource-grid.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from 'react'
+
+import { FrameDataGrid, type FrameDataGridProps } from './frame-data-grid'
+
+/**
+ * Frame + DataGrid with expandable rows.
+ * Preview: https://reui.io/preview/base/components/c-data-grid-8
+ * Docs: https://reui.io/docs/components/base/data-grid
+ */
+export function ExpandableResourceGrid({
+ expandedContent,
+ getRowCanExpand,
+ ...props
+}: FrameDataGridProps & {
+ expandedContent: (row: TData) => ReactNode
+ getRowCanExpand?: (row: TData) => boolean
+}) {
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/reui-kit/filter-utils.ts b/apps/web/src/components/reui-kit/filter-utils.ts
index a896d19..9ba7be6 100644
--- a/apps/web/src/components/reui-kit/filter-utils.ts
+++ b/apps/web/src/components/reui-kit/filter-utils.ts
@@ -1,8 +1,18 @@
-import type { Filter } from '@/components/reui/filters'
+import {
+ createFilterQuery,
+ createFilterRule,
+ flattenFilterConditions,
+ type FilterCondition,
+} from '@/components/reui/filters/filters-query'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
-export function getActiveFilters(filters: Filter[]) {
+/** Operators that take no value, so an empty `values` list is expected. */
+const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
+
+export function getActiveFilters(filters: FilterCondition[]) {
return filters.filter((filter) => {
- const { values } = filter
+ const { operator, values } = filter
+ if (VALUELESS_OPERATORS.has(operator)) return true
if (!values || values.length === 0) return false
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
return false
@@ -17,63 +27,110 @@ export function getActiveFilters(filters: Filter[]) {
})
}
-export function applyFiltersToData(
+function matchesFilterCondition(
+ fieldValue: unknown,
+ operator: string,
+ values: unknown[],
+): boolean {
+ switch (operator) {
+ case 'is':
+ case 'eq':
+ return values.includes(fieldValue)
+ case 'is_not':
+ case 'neq':
+ return !values.includes(fieldValue)
+ case 'is_any_of':
+ case 'has_any_of':
+ return values.some((value) => fieldValue === value)
+ case 'is_none_of':
+ case 'is_not_any_of':
+ case 'has_none_of':
+ return !values.some((value) => fieldValue === value)
+ case 'contains': {
+ const tokens = values.map((value) => String(value).trim()).filter(Boolean)
+ if (tokens.length === 0) return true
+ return tokens.some((token) =>
+ String(fieldValue).toLowerCase().includes(token.toLowerCase()),
+ )
+ }
+ case 'not_contains':
+ return !values.some((value) =>
+ String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
+ )
+ case 'starts_with':
+ return values.some((value) =>
+ String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
+ )
+ case 'ends_with':
+ return values.some((value) =>
+ String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
+ )
+ case 'empty':
+ return fieldValue === '' || fieldValue == null
+ case 'not_empty':
+ return fieldValue !== '' && fieldValue != null
+ default:
+ return true
+ }
+}
+
+export function applyFilterConditionsToData(
data: T[],
- filters: Filter[],
+ filters: FilterCondition[],
getFieldValue: (item: T, field: string) => unknown,
): T[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
- const { field, operator, values } = filter
+ const { field, operator, values, negated } = filter
result = result.filter((item) => {
const raw = getFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
-
- switch (operator) {
- case 'is':
- return values.includes(fieldValue)
- case 'is_not':
- return !values.includes(fieldValue)
- case 'is_any_of':
- return values.some((value) => fieldValue === value)
- case 'is_not_any_of':
- return !values.some((value) => fieldValue === value)
- case 'contains': {
- const tokens = values
- .map((value) => String(value).trim())
- .filter(Boolean)
- if (tokens.length === 0) return true
- return tokens.some((token) =>
- String(fieldValue).toLowerCase().includes(token.toLowerCase()),
- )
- }
- case 'not_contains':
- return !values.some((value) =>
- String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
- )
- case 'starts_with':
- return values.some((value) =>
- String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
- )
- case 'ends_with':
- return values.some((value) =>
- String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
- )
- case 'empty':
- return fieldValue === '' || fieldValue == null
- case 'not_empty':
- return fieldValue !== '' && fieldValue != null
- default:
- return true
- }
+ const matches = matchesFilterCondition(fieldValue, operator, values)
+ return negated ? !matches : matches
})
}
return result
}
+export function applyFiltersToData(
+ data: T[],
+ query: FilterQuery,
+ getFieldValue: (item: T, field: string) => unknown,
+): T[] {
+ return applyFilterConditionsToData(data, flattenFilterConditions(query), getFieldValue)
+}
+
+export function createEmptyFilterQuery(): FilterQuery {
+ return createFilterQuery()
+}
+
+export function createTextFilterQuery(fieldId: string, id = `${fieldId}-1`): FilterQuery {
+ return createFilterQuery([
+ createFilterRule({
+ id,
+ path: [fieldId],
+ operator: 'contains',
+ value: '',
+ }),
+ ])
+}
+
+export function createSearchFilterField(
+ id: string,
+ label: string,
+ placeholder: string,
+): FilterField {
+ return {
+ id,
+ label,
+ type: 'text',
+ placeholder,
+ }
+}
+
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
diff --git a/apps/web/src/components/reui-kit/frame-data-grid.tsx b/apps/web/src/components/reui-kit/frame-data-grid.tsx
index b45da0a..948d4f3 100644
--- a/apps/web/src/components/reui-kit/frame-data-grid.tsx
+++ b/apps/web/src/components/reui-kit/frame-data-grid.tsx
@@ -1,52 +1,461 @@
-import type { ReactNode } from 'react'
+import {
+ cloneElement,
+ isValidElement,
+ useEffect,
+ useState,
+ type ReactElement,
+ type ReactNode,
+} from 'react'
+import {
+ useTable,
+ type ColumnDef,
+ type ColumnVisibilityState,
+ type ExpandedState,
+ type OnChangeFn,
+ type PaginationState,
+ type RowSelectionState,
+ type SortingState,
+} from '@tanstack/react-table'
+import { Columns3Icon } from 'lucide-react'
+import { Button } from '@evobgp/ui/components/button'
+import { Separator } from '@evobgp/ui/components/separator'
+import { cn } from '@evobgp/ui/lib/utils'
+import {
+ DataGrid,
+ dataGridFeatures,
+ type DataGridFeatures,
+ type DataGridTableInstance,
+} from '@/components/reui/data-grid/data-grid'
+import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
+import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
+import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
+import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
+import {
+ DataGridTable,
+ DataGridTableRowExpand,
+ DataGridTableRowSelect,
+ DataGridTableRowSelectAll,
+} from '@/components/reui/data-grid/data-grid-table'
+import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
import {
Frame,
FrameDescription,
+ FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
-import { cn } from '@evobgp/ui/lib/utils'
+import { EmptyState } from '@/components/empty-state'
+import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
+
+export type DataGridColumnDef = ColumnDef
/**
- * Frame shell for list/grid sections (replaces DataGridCard Card-named API).
- * @see https://reui.io/preview/base/data-grid-filtering-2
- * @see https://reui.io/docs/components/base/frame
+ * Единый tableLayout для всех ops-гридов.
+ * Visual SoT: установленный data-grid-filtering-2 (`dense: true`, без zebra).
+ * Preview: https://reui.io/preview/base/data-grid-filtering-2
+ * Docs: https://reui.io/docs/components/base/data-grid
*/
-export function FrameDataGrid({
+export function kitDataGridTableLayout(
+ opts: {
+ dense?: boolean
+ width?: 'fixed' | 'auto'
+ columnsPinnable?: boolean
+ columnsVisibility?: boolean
+ } = {},
+) {
+ return {
+ dense: opts.dense ?? true,
+ rowBorder: true,
+ headerSticky: false,
+ headerBackground: false,
+ headerBorder: true,
+ width: opts.width ?? ('fixed' as const),
+ columnsVisibility: opts.columnsVisibility ?? false,
+ columnsResizable: false,
+ columnsPinnable: opts.columnsPinnable ?? false,
+ columnsMovable: false,
+ rowsDraggable: false,
+ rowsPinnable: false,
+ }
+}
+
+/** filtering-2 edge inset — единственный tableClassNames в kit, не в domain. */
+export const kitDataGridTableClassNames = {
+ edgeCell: 'first:ps-3 last:pe-3',
+} as const
+
+function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
+ try {
+ const raw = localStorage.getItem(key)
+ if (!raw) return undefined
+ return JSON.parse(raw) as ColumnVisibilityState
+ } catch {
+ return undefined
+ }
+}
+
+export { loadStoredColumnVisibility }
+
+function applyColumnPinControls(
+ columns: DataGridColumnDef[],
+ columnPinControls: boolean,
+): DataGridColumnDef[] {
+ return columns.map((col) => {
+ const origHeader = col.header
+ if (typeof origHeader !== 'function') return col
+ return {
+ ...col,
+ header: (ctx) => {
+ const node = origHeader(ctx)
+ if (isValidElement(node) && node.type === DataGridColumnHeader) {
+ return cloneElement(node as ReactElement<{ pinnable?: boolean }>, {
+ pinnable: columnPinControls,
+ })
+ }
+ return node
+ },
+ } as DataGridColumnDef
+ })
+}
+
+export interface FrameDataGridProps {
+ title?: ReactNode
+ description?: ReactNode
+ actions?: ReactNode
+ columns: DataGridColumnDef[]
+ data: TData[]
+ rowId?: (row: TData, index: number) => string
+ emptyTitle?: string
+ emptyDescription?: string
+ emptyAction?: ReactNode
+ onRowClick?: (row: TData) => void
+ pagination?: boolean
+ pageSize?: number
+ footerContent?: ReactNode
+ dense?: boolean
+ pinLastColumn?: boolean
+ initialSorting?: SortingState
+ virtualization?: boolean
+ height?: number
+ enableRowSelection?: boolean
+ onRowSelectionChange?: (selectedIds: string[]) => void
+ enableColumnVisibility?: boolean
+ columnVisibility?: ColumnVisibilityState
+ onColumnVisibilityChange?: OnChangeFn
+ columnVisibilityTrigger?: boolean
+ columnVisibilityStorageKey?: string
+ initialColumnVisibility?: ColumnVisibilityState
+ className?: string
+ expandedContent?: (row: TData) => ReactNode
+ getRowCanExpand?: (row: TData) => boolean
+ pinLeftColumnIds?: string[]
+ horizontalScroll?: boolean
+ tableWidth?: 'fixed' | 'auto'
+ columnPinControls?: boolean
+ isLoading?: boolean
+}
+
+function DataGridSectionHeader({
title,
description,
actions,
- children,
- className,
}: {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
- children: ReactNode
- className?: string
}) {
- const hasHeader = Boolean(title || description || actions)
+ if (!title && !description && !actions) return null
+
return (
-
-
- {hasHeader ? (
-
-
- {title ? {title} : null}
- {description ? (
- {description}
- ) : null}
-
- {actions ? (
-
- {actions}
-
- ) : null}
-
+
+
+ {title ?
{title} : null}
+ {description ? (
+
{description}
) : null}
-
{children}
+
+ {actions ? (
+ {actions}
+ ) : null}
+
+ )
+}
+
+function FrameDataGridBody({
+ table,
+ data,
+ emptyTitle,
+ onRowClick,
+ dense,
+ virtualization,
+ height,
+ footerContent,
+ showPagination,
+ enableColumnVisibility,
+ columnsPinnable,
+ tableWidth,
+}: {
+ table: DataGridTableInstance
+ data: TData[]
+ emptyTitle: string
+ onRowClick?: (row: TData) => void
+ dense: boolean
+ virtualization: boolean
+ height: number
+ footerContent?: ReactNode
+ showPagination: boolean
+ enableColumnVisibility: boolean
+ columnsPinnable: boolean
+ tableWidth: 'fixed' | 'auto'
+}) {
+ const tableNode = virtualization ? (
+
+ ) : (
+
+ )
+
+ return (
+
+ {virtualization ? (
+
+ {tableNode}
+
+ ) : (
+ {tableNode}
+ )}
+ {showPagination ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+
+ )
+}
+
+export function FrameDataGrid({
+ title,
+ description,
+ actions,
+ columns,
+ data,
+ rowId,
+ emptyTitle = 'Нет записей',
+ emptyDescription,
+ emptyAction,
+ onRowClick,
+ pagination,
+ pageSize = 10,
+ footerContent,
+ dense = true,
+ pinLastColumn = false,
+ initialSorting,
+ virtualization = false,
+ height = 480,
+ enableRowSelection = false,
+ onRowSelectionChange,
+ enableColumnVisibility = false,
+ columnVisibility: columnVisibilityProp,
+ onColumnVisibilityChange,
+ columnVisibilityTrigger,
+ columnVisibilityStorageKey,
+ initialColumnVisibility,
+ className,
+ expandedContent,
+ getRowCanExpand,
+ pinLeftColumnIds,
+ tableWidth = 'fixed',
+ columnPinControls = false,
+}: FrameDataGridProps) {
+ const showPagination = pagination ?? true
+ const [sorting, setSorting] = useState(initialSorting ?? [])
+ const [rowSelection, setRowSelection] = useState({})
+ const [expanded, setExpanded] = useState({})
+ const [paginationState, setPaginationState] = useState({
+ pageIndex: 0,
+ pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
+ })
+ const [internalColumnVisibility, setInternalColumnVisibility] = useState(
+ () => {
+ const stored = columnVisibilityStorageKey
+ ? loadStoredColumnVisibility(columnVisibilityStorageKey)
+ : undefined
+ return { ...initialColumnVisibility, ...stored }
+ },
+ )
+
+ const isColumnVisibilityControlled = columnVisibilityProp !== undefined
+ const columnVisibility = isColumnVisibilityControlled
+ ? columnVisibilityProp
+ : internalColumnVisibility
+ const setColumnVisibility: OnChangeFn = isColumnVisibilityControlled
+ ? (onColumnVisibilityChange ?? (() => undefined))
+ : setInternalColumnVisibility
+
+ useEffect(() => {
+ setPaginationState((current) => ({
+ pageIndex: showPagination ? current.pageIndex : 0,
+ pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
+ }))
+ }, [pageSize, showPagination])
+
+ useEffect(() => {
+ if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
+ localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
+ }, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
+
+ const selectColumn: DataGridColumnDef = {
+ id: 'select',
+ header: () => ,
+ cell: ({ row }) => ,
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ meta: { cellClassName: 'w-10' },
+ }
+
+ const expandColumn: DataGridColumnDef = {
+ id: 'expand',
+ header: () => null,
+ cell: ({ row }) => ,
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ meta: {
+ cellClassName: 'w-10',
+ expandedContent,
+ },
+ }
+
+ const tableColumns: DataGridColumnDef[] = applyColumnPinControls(
+ [
+ ...(expandedContent ? [expandColumn] : []),
+ ...(enableRowSelection ? [selectColumn] : []),
+ ...columns,
+ ],
+ columnPinControls,
+ )
+
+ const lastColId = pinLastColumn ? (tableColumns[tableColumns.length - 1]?.id ?? '') : ''
+ const pinLeft = pinLeftColumnIds ?? []
+ const enablePinning = pinLastColumn || pinLeft.length > 0
+ const columnPinning = {
+ start: pinLeft,
+ end: pinLastColumn && lastColId ? [lastColId] : [],
+ }
+
+ const table = useTable({
+ features: dataGridFeatures,
+ data,
+ columns: tableColumns,
+ state: {
+ sorting,
+ pagination: paginationState,
+ columnVisibility,
+ expanded,
+ ...(enablePinning ? { columnPinning } : {}),
+ ...(enableRowSelection ? { rowSelection } : {}),
+ },
+ onSortingChange: setSorting,
+ onPaginationChange: setPaginationState,
+ onExpandedChange: setExpanded,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: enableRowSelection
+ ? (updater) => {
+ setRowSelection((prev) => {
+ const next = typeof updater === 'function' ? updater(prev) : updater
+ if (onRowSelectionChange && rowId) {
+ const ids = Object.keys(next).filter((k) => next[k])
+ onRowSelectionChange(ids)
+ }
+ return next
+ })
+ }
+ : undefined,
+ initialState: enablePinning ? { columnPinning } : undefined,
+ getRowId: rowId ? (row, index) => rowId(row, index) : undefined,
+ getRowCanExpand: expandedContent
+ ? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
+ : undefined,
+ enableRowSelection,
+ enableHiding: enableColumnVisibility,
+ })
+
+ const showColumnVisibilityTrigger =
+ enableColumnVisibility && (columnVisibilityTrigger ?? true)
+
+ const columnVisibilityAction = showColumnVisibilityTrigger ? (
+
+
+ Колонки
+
+ }
+ />
+ ) : null
+
+ const headerActions = actions ? (
+
+ {columnVisibilityAction}
+ {actions}
+
+ ) : (
+ columnVisibilityAction
+ )
+
+ const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
+
+ if (data.length === 0) {
+ return (
+
+ {hasHeader ? (
+
+ ) : null}
+
+
+
+
+ )
+ }
+
+ return (
+
+ {hasHeader ? (
+
+ ) : null}
+
+
)
diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts
index 8f6b77b..5716b49 100644
--- a/apps/web/src/components/reui-kit/index.ts
+++ b/apps/web/src/components/reui-kit/index.ts
@@ -1,5 +1,9 @@
export {
applyFiltersToData,
+ applyFilterConditionsToData,
+ createEmptyFilterQuery,
+ createSearchFilterField,
+ createTextFilterQuery,
getActiveFilters,
renderSelectedCount,
renderSingleSelectedLabel,
@@ -22,6 +26,13 @@ export {
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
-export { FrameDataGrid } from './frame-data-grid'
+export {
+ FrameDataGrid,
+ kitDataGridTableClassNames,
+ kitDataGridTableLayout,
+ type DataGridColumnDef,
+ type FrameDataGridProps,
+} from './frame-data-grid'
+export { ExpandableResourceGrid } from './expandable-resource-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell } from './settings-shell'
diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx
index cdc1526..3f71d84 100644
--- a/apps/web/src/components/reui-kit/resource-page.tsx
+++ b/apps/web/src/components/reui-kit/resource-page.tsx
@@ -1,10 +1,6 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
- getCoreRowModel,
- getPaginationRowModel,
- getSortedRowModel,
- useReactTable,
- type ColumnDef,
+ useTable,
type PaginationState,
type RowSelectionState,
type SortingState,
@@ -12,16 +8,17 @@ import {
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
+import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
-import { DataGrid } from '@/components/reui/data-grid/data-grid'
+import {
+ DataGrid,
+ dataGridFeatures,
+} from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
-import {
- Filters,
- type Filter,
- type FilterFieldConfig,
-} from '@/components/reui/filters'
+import { Filters } from '@/components/reui/filters/filters'
+import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
@@ -30,18 +27,20 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
+import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Button } from '@evobgp/ui/components/button'
import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton'
-import {
- Alert,
- AlertDescription,
- AlertTitle,
-} from '@/components/reui/alert'
-import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
-import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
-import { applyFiltersToData } from './filter-utils'
+import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
+import { applyFiltersToData, createEmptyFilterQuery } from './filter-utils'
+import {
+ FrameDataGrid,
+ kitDataGridTableClassNames,
+ kitDataGridTableLayout,
+ type DataGridColumnDef,
+ type FrameDataGridProps,
+} from './frame-data-grid'
export interface ResourcePageTab {
id: string
@@ -49,21 +48,44 @@ export interface ResourcePageTab {
count?: number
}
-export interface ResourcePageProps {
- title: string
+type SimpleGridPassthrough = Pick<
+ FrameDataGridProps,
+ | 'onRowClick'
+ | 'pagination'
+ | 'footerContent'
+ | 'dense'
+ | 'initialSorting'
+ | 'virtualization'
+ | 'height'
+ | 'onRowSelectionChange'
+ | 'enableColumnVisibility'
+ | 'columnVisibility'
+ | 'onColumnVisibilityChange'
+ | 'columnVisibilityTrigger'
+ | 'columnVisibilityStorageKey'
+ | 'initialColumnVisibility'
+ | 'className'
+ | 'tableWidth'
+ | 'horizontalScroll'
+ | 'pinLeftColumnIds'
+ | 'columnPinControls'
+>
+
+export interface ResourcePageProps extends SimpleGridPassthrough {
+ title?: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
- filterFields: FilterFieldConfig[]
- filters: Filter[]
- onFiltersChange: (filters: Filter[]) => void
+ filterFields?: FilterField[]
+ filterQuery?: FilterQuery
+ onFilterQueryChange?: (query: FilterQuery) => void
onClearFilters?: () => void
- getFilterFieldValue: (item: T, field: string) => unknown
- columns: ColumnDef[]
+ getFilterFieldValue?: (item: T, field: string) => unknown
+ columns: DataGridColumnDef[]
data: T[]
- getRowId: (row: T) => string
+ getRowId: (row: T, index?: number) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
@@ -79,6 +101,11 @@ export interface ResourcePageProps {
}) => ReactNode
toolbarExtra?: ReactNode
hideHeader?: boolean
+ pinLastColumn?: boolean
+ emptyTitle?: string
+ emptyDescription?: string
+ emptyAction?: ReactNode
+ actions?: ReactNode
}
function ResourcePageSkeleton() {
@@ -100,18 +127,35 @@ function ResourcePageSkeleton() {
)
}
-export function ResourcePage({
- title,
+function ResourceLoadError({
+ error,
+ onRetry,
+}: {
+ error?: Error | null
+ onRetry?: () => void
+}) {
+ return (
+
+
+ Ошибка загрузки
+
+ {error?.message ?? 'Не удалось загрузить данные'}
+ {onRetry ? (
+
+ ) : null}
+
+
+ )
+}
+
+const noopQueryChange = (_query: FilterQuery) => {}
+const defaultGetFilterFieldValue = (_item: unknown, _field: string) => undefined
+
+function ResourcePageSimple({
+ title = '',
description,
- tabs,
- activeTab: controlledTab,
- onTabChange,
- tabFilter,
- filterFields,
- filters,
- onFiltersChange,
- onClearFilters,
- getFilterFieldValue,
columns,
data,
getRowId,
@@ -120,15 +164,130 @@ export function ResourcePage({
error = null,
onRetry,
primaryAction,
+ actions,
+ emptyState,
+ emptyTitle,
+ emptyDescription,
+ emptyAction,
+ pageSize = 10,
+ enableRowSelection = false,
+ toolbarExtra,
+ hideHeader = false,
+ pinLastColumn = false,
+ onRowClick,
+ pagination,
+ footerContent,
+ dense,
+ initialSorting,
+ virtualization,
+ height,
+ onRowSelectionChange,
+ enableColumnVisibility,
+ columnVisibility,
+ onColumnVisibilityChange,
+ columnVisibilityTrigger,
+ columnVisibilityStorageKey,
+ initialColumnVisibility,
+ className,
+ tableWidth,
+ horizontalScroll,
+ pinLeftColumnIds,
+ columnPinControls,
+}: ResourcePageProps) {
+ if (isLoading) return
+ if (isError) return
+
+ const headerActions = primaryAction ?? actions
+ const resolvedEmpty = emptyState ?? (
+ emptyTitle || emptyDescription || emptyAction
+ ? { title: emptyTitle ?? 'Нет записей', description: emptyDescription, action: emptyAction }
+ : undefined
+ )
+
+ return (
+
+ {toolbarExtra ? (
+
{toolbarExtra}
+ ) : null}
+
+
+ )
+}
+
+function ResourcePageFiltered({
+ title = '',
+ description,
+ tabs,
+ activeTab: controlledTab,
+ onTabChange,
+ tabFilter,
+ filterFields = [],
+ filterQuery: controlledQuery,
+ onFilterQueryChange = noopQueryChange,
+ onClearFilters,
+ getFilterFieldValue = defaultGetFilterFieldValue as (item: T, field: string) => unknown,
+ columns,
+ data,
+ getRowId,
+ isLoading = false,
+ isError = false,
+ error = null,
+ onRetry,
+ primaryAction,
+ actions,
emptyState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
+ pinLastColumn = false,
+ enableColumnVisibility = false,
+ onRowClick,
+ virtualization = false,
+ height = 480,
}: ResourcePageProps) {
+ const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
+ const showFilters = filterFields.length > 0
+
+ const [internalQuery, setInternalQuery] = useState(createEmptyFilterQuery)
+ const isQueryControlled = controlledQuery !== undefined
+ const filterQuery = isQueryControlled ? controlledQuery : internalQuery
+ const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery
const [sorting, setSorting] = useState([])
const [rowSelection, setRowSelection] = useState({})
@@ -144,25 +303,23 @@ export function ResourcePage({
}, [])
const filteredData = useMemo(() => {
- let result = applyFiltersToData(data, filters, getFilterFieldValue)
+ let result = showFilters ? applyFiltersToData(data, filterQuery, getFilterFieldValue) : data
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
- }, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
+ }, [data, filterQuery, getFilterFieldValue, tabs, tabFilter, activeTab, showFilters])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
- const base = applyFiltersToData(data, filters, getFilterFieldValue)
+ const base = showFilters ? applyFiltersToData(data, filterQuery, getFilterFieldValue) : data
const counts: Record = {}
for (const tab of tabs) {
counts[tab.id] =
- tab.id === 'all'
- ? base.length
- : base.filter((item) => tabFilter(item, tab.id)).length
+ tab.id === 'all' ? base.length : base.filter((item) => tabFilter(item, tab.id)).length
}
return counts
- }, [tabs, tabFilter, data, filters, getFilterFieldValue])
+ }, [tabs, tabFilter, data, filterQuery, getFilterFieldValue, showFilters])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
@@ -171,22 +328,33 @@ export function ResourcePage({
const selectedCount = selectedIds.length
+ const lastColId = pinLastColumn ? (columns[columns.length - 1]?.id ?? '') : ''
+ const enablePinning = Boolean(pinLastColumn && lastColId)
+ const columnPinning = {
+ start: [] as string[],
+ end: enablePinning ? [lastColId] : [],
+ }
+
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
- const table = useReactTable({
+ const table = useTable({
+ features: dataGridFeatures,
data: filteredData,
columns,
- getRowId,
- state: { sorting, rowSelection, pagination },
+ getRowId: (row, index) => getRowId(row, index),
+ state: {
+ sorting,
+ rowSelection,
+ pagination,
+ ...(enablePinning ? { columnPinning } : {}),
+ },
+ initialState: enablePinning ? { columnPinning } : undefined,
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
- getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
})
const handleTabChange = useCallback(
@@ -199,17 +367,18 @@ export function ResourcePage({
)
const handleFiltersChange = useCallback(
- (next: Filter[]) => {
- onFiltersChange(next)
+ (next: FilterQuery) => {
+ setFilterQuery(next)
resetPagination()
},
- [onFiltersChange, resetPagination],
+ [setFilterQuery, resetPagination],
)
const handleClear = useCallback(() => {
onClearFilters?.()
+ if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery())
resetPagination()
- }, [onClearFilters, resetPagination])
+ }, [onClearFilters, isQueryControlled, resetPagination])
const countedTabs = useMemo(
() =>
@@ -221,38 +390,24 @@ export function ResourcePage({
[tabs, tabCounts],
)
- if (isLoading) {
- return
- }
-
- if (isError) {
- return (
-
-
- Ошибка загрузки
-
- {error?.message ?? 'Не удалось загрузить данные'}
- {onRetry ? (
-
- ) : null}
-
-
- )
- }
+ if (isLoading) return
+ if (isError) return
if (data.length === 0 && emptyState) {
return (
-
+
+
+
+
+
)
}
- const emptyMessage = 'Нет записей по выбранным фильтрам.'
+ const tableNode =
return (
@@ -266,8 +421,15 @@ export function ResourcePage({
{!hideHeader ? (
@@ -277,28 +439,20 @@ export function ResourcePage({
{description ? (
{description}
-
-
- {filteredData.length} записей
-
+
+ {filteredData.length} записей
{selectedCount > 0 ? (
<>
-
+
{selectedCount} выбрано
>
) : null}
) : null}
- {primaryAction ? (
+ {headerActions ? (
- {primaryAction}
+ {headerActions}
) : null}
@@ -318,20 +472,25 @@ export function ResourcePage({
>
) : null}
-
-
-
- Фильтры
-
- }
- />
+
+ {showFilters ? (
+
+
+ Фильтры
+
+ }
+ />
+ ) : (
+
+ )}
{toolbarExtra}
{selectedCount > 0 ? (
@@ -339,7 +498,7 @@ export function ResourcePage({
{selectedCount} выбрано
) : null}
- {onClearFilters ? (
+ {showFilters ? (
)
}
+
+/**
+ * Ops list page: Frame + DataGrid (+ optional ReUI Filters / tabs).
+ * Without filterFields/tabs → simple CRUD grid (FrameDataGrid).
+ * Preview: https://reui.io/preview/base/data-grid-filtering-2
+ */
+export function ResourcePage(props: ResourcePageProps) {
+ const showFilters = (props.filterFields?.length ?? 0) > 0
+ const hasTabs = (props.tabs?.length ?? 0) > 0
+ if (!showFilters && !hasTabs) {
+ return
+ }
+ return
+}
diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx
index 01719cd..a0eef02 100644
--- a/apps/web/src/components/reui-kit/settings-shell.tsx
+++ b/apps/web/src/components/reui-kit/settings-shell.tsx
@@ -4,8 +4,9 @@ import { PageShell } from '@/components/page-shell'
/**
* Settings layout — page chrome only.
- * Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
- * @see https://reui.io/preview/base/settings-7
+ * Side-tab rail lives in `SettingsPageShell` (settings-3 / settings-16 DNA).
+ * @see https://reui.io/preview/base/settings-3
+ * @see https://reui.io/preview/base/settings-16
* @see https://reui.io/blocks
*/
export function SettingsShell() {
diff --git a/apps/web/src/components/reui/autocomplete.tsx b/apps/web/src/components/reui/autocomplete.tsx
index 8ecedb4..ebcc13d 100644
--- a/apps/web/src/components/reui/autocomplete.tsx
+++ b/apps/web/src/components/reui/autocomplete.tsx
@@ -163,7 +163,10 @@ function AutocompleteItem({
= (
+ node: CascaderNode | null,
+ context: CascaderLoadContext
+) =>
+ | CascaderNode[]
+ | CascaderLoadResult
+ | Promise[] | CascaderLoadResult>
+
+/** Server-side search, replacing the local index scan while the query is set. */
+export type CascaderOnSearch = (
+ query: string,
+ context: CascaderSearchContext
+) =>
+ | CascaderNode[]
+ | CascaderLoadResult
+ | Promise[] | CascaderLoadResult>
+
+/** Resolves a selected value to its ancestor chain, root first, node last. */
+export type CascaderResolveValue = (
+ value: string,
+ context: CascaderLoadContext
+) => CascaderNode[] | Promise[]>
+
+export interface CascaderLoaderStore {
+ /** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
+ pages: Map[]>
+ states: Map
+ /** Keyed by node value: search hits and resolved selections, level-less. */
+ detached: Map>
+}
+
+export interface UseCascaderLoaderOptions {
+ /** The index built from `items`, before any pages are merged in. */
+ base: CascaderIndex
+ getChildren?: CascaderGetChildren
+ onSearch?: CascaderOnSearch
+ resolveValue?: CascaderResolveValue
+ /** Milliseconds of quiet before `onSearch` fires. */
+ searchDebounce?: number
+ /** Changing this drops every cached page, state and detached node. */
+ loadKey?: unknown
+ /** Speculatively fetch a branch's children when it is highlighted. */
+ prefetch?: boolean
+ /** Called when a request fails. Never for an aborted or superseded one. */
+ onLoadError?: (
+ error: unknown,
+ context: { parent: string | null; reason: string }
+ ) => void
+ /** Whether the panel is live: the popup is open, or the cascader is inline. */
+ enabled: boolean
+ query: string
+ /** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
+ levels: string[]
+ /** The navigation path, handed to `onSearch` as its scope. */
+ path: string[]
+ /** Current selection, for `resolveValue`. */
+ values: string[]
+}
+
+export interface CascaderLoader {
+ /** Whether a `getChildren` loader is configured at all. */
+ active: boolean
+ store: CascaderLoaderStore
+ states: ReadonlyMap
+ /**
+ * Async search hits, `null` when no `onSearch` is running. EMPTY while the
+ * first request is in flight, so the level behind is not shown as the answer.
+ */
+ searchResults: CascaderNode[] | null
+ searchState: CascaderLoadState | null
+ /**
+ * Fetches a level's FIRST page. No-ops on a `states` entry (in flight,
+ * loaded or failed) or when `items` fills the level; pages are never
+ * consulted, so a `resolveValue` chain still fetches. The level effect fires
+ * only for on-screen levels, so a branch merely PRESSED is asked for by hand.
+ */
+ ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void
+ /** Fetches the next page of a level. No-ops unless one is available. */
+ loadMore: (parentKey: string) => void
+ /** Refires a failed level. No-ops unless that level is in an error state. */
+ retryLevel: (parentKey: string) => void
+ /** Schedules a speculative fetch. Safe to call on every highlight move. */
+ prefetchNode: (node: CascaderNode | null | undefined) => void
+ /**
+ * Evicts ONE level: aborts its request, drops its `states`/`pages` entries
+ * and paging latch, so the level effect refetches it. `null` = root level.
+ */
+ invalidateLevel: (value: string | null) => void
+}
+
+/* -------------------------------------------------------------------------- */
+/* Constants */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown
+ * does not fire a request per row, short enough to beat the ArrowRight press.
+ */
+const PREFETCH_DELAY = 150
+
+/** Request keys for the two non-level requests. Never collide with a value. */
+const SEARCH_KEY = "\u0000search"
+const RESOLVE_PREFIX = "\u0000resolve:"
+
+const NO_STATE: CascaderLoadState = {
+ loading: false,
+ error: false,
+ hasMore: false,
+}
+
+/** Stable empty result, so an idle search never churns the state context. */
+const NO_RESULTS: CascaderNode[] = []
+
+function createStore(): CascaderLoaderStore {
+ return { pages: new Map(), states: new Map(), detached: new Map() }
+}
+
+function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean {
+ return (
+ a.loading === b.loading &&
+ a.error === b.error &&
+ a.hasMore === b.hasMore &&
+ a.cursor === b.cursor
+ )
+}
+
+/* -------------------------------------------------------------------------- */
+/* Store transitions */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised
+ * on store identity, so a fresh object for an unchanged state would rebuild it.
+ */
+function withLoadState(
+ store: CascaderLoaderStore,
+ key: string,
+ update: (state: CascaderLoadState) => CascaderLoadState
+): CascaderLoaderStore {
+ const current = store.states.get(key) ?? NO_STATE
+ const next = update(current)
+ if (store.states.has(key) && sameLoadState(current, next)) return store
+ const states = new Map(store.states)
+ states.set(key, next)
+ return { pages: store.pages, states, detached: store.detached }
+}
+
+function withPage(
+ store: CascaderLoaderStore,
+ key: string,
+ items: readonly CascaderNode[],
+ options: { append: boolean; hasMore: boolean; cursor?: string }
+): CascaderLoaderStore {
+ // A fresh level REPLACES its page so a `resolveValue` stub can be superseded.
+ const previous = options.append ? (store.pages.get(key) ?? []) : []
+ const seen = new Set(previous.map((node) => node.value))
+ const merged = previous.slice()
+ for (const item of items) {
+ if (seen.has(item.value)) continue
+ seen.add(item.value)
+ merged.push(item)
+ }
+
+ const pages = new Map(store.pages)
+ pages.set(key, merged)
+ const states = new Map(store.states)
+ states.set(key, {
+ loading: false,
+ error: false,
+ hasMore: options.hasMore,
+ cursor: options.cursor,
+ })
+ return { pages, states, detached: store.detached }
+}
+
+function withDetached(
+ store: CascaderLoaderStore,
+ items: readonly CascaderNode[]
+): CascaderLoaderStore {
+ let detached: Map> | null = null
+ for (const item of items) {
+ if (store.detached.get(item.value) === item) continue
+ detached = detached ?? new Map(store.detached)
+ detached.set(item.value, item)
+ }
+ if (!detached) return store
+ return { pages: store.pages, states: store.states, detached }
+}
+
+/**
+ * Places a resolved ancestor chain into `pages`, root first. Writes no
+ * `states`, so those levels still read as unloaded and a drill-in still fetches
+ * for real. Mirrored into `detached` so the trigger keeps its label.
+ */
+function withChain(
+ store: CascaderLoaderStore,
+ chain: readonly CascaderNode[]
+): CascaderLoaderStore {
+ if (chain.length === 0) return store
+ const pages = new Map(store.pages)
+ const detached = new Map(store.detached)
+ let parentKey = CASCADER_ROOT_KEY
+
+ for (const node of chain) {
+ const bucket = pages.get(parentKey)
+ if (!bucket) {
+ pages.set(parentKey, [node])
+ } else if (!bucket.some((entry) => entry.value === node.value)) {
+ pages.set(parentKey, [...bucket, node])
+ }
+ detached.set(node.value, node)
+ parentKey = node.value
+ }
+
+ return { pages, states: store.states, detached }
+}
+
+/* -------------------------------------------------------------------------- */
+/* Hook */
+/* -------------------------------------------------------------------------- */
+
+interface CascaderLoaderLatest {
+ base: CascaderIndex
+ store: CascaderLoaderStore
+ getChildren?: CascaderGetChildren
+ onSearch?: CascaderOnSearch
+ resolveValue?: CascaderResolveValue
+ onLoadError?: (
+ error: unknown,
+ context: { parent: string | null; reason: string }
+ ) => void
+ prefetch: boolean
+ path: string[]
+}
+
+interface CascaderSearchSlice {
+ query: string
+ results: CascaderNode[]
+ loading: boolean
+ error: boolean
+}
+
+/**
+ * The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the
+ * build stays pure in `items`, the merge pure in that build plus this store.
+ */
+export function useCascaderLoader({
+ base,
+ getChildren,
+ onSearch,
+ resolveValue,
+ searchDebounce = 250,
+ loadKey,
+ prefetch = false,
+ onLoadError,
+ enabled,
+ query,
+ levels,
+ path,
+ values,
+}: UseCascaderLoaderOptions): CascaderLoader {
+ const [store, setStore] = React.useState>(createStore)
+ const [search, setSearch] = React.useState | null>(
+ null
+ )
+
+ /**
+ * Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most
+ * consumers, so closing over it would refire every in-flight request per
+ * re-render. The ref is what keeps the request machinery `[]`-dep. Declared
+ * FIRST, since effects run in declaration order, so the level effect below
+ * already sees the current commit.
+ */
+ const latest = React.useRef>({
+ base,
+ store,
+ getChildren,
+ onSearch,
+ resolveValue,
+ onLoadError,
+ prefetch,
+ path,
+ })
+
+ React.useEffect(() => {
+ latest.current = {
+ base,
+ store,
+ getChildren,
+ onSearch,
+ resolveValue,
+ onLoadError,
+ prefetch,
+ path,
+ }
+ })
+
+ /** One AbortController PER KEY: columns mode runs several levels at once. */
+ const controllers = React.useRef(new Map())
+ /** Monotonic per key. The stale guard for out-of-order responses. */
+ const requestIds = React.useRef(new Map())
+ /** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
+ const inflight = React.useRef(new Map())
+ /**
+ * The `(child count, cursor)` signature at the last paging fire, per level.
+ * Guards what `hasMore` cannot: a page of zero new items while the server
+ * still says `hasMore`. Cursor is IN the signature because an all-duplicates
+ * page advances it while the count stands still - real progress, which a
+ * count-only latch would brick forever.
+ */
+ const moreLatch = React.useRef(new Map())
+ /** Values `resolveValue` has already been asked about, so it asks once. */
+ const attempted = React.useRef(new Set())
+ /** Every node the loader has seen, so a level key can name its own node. */
+ const known = React.useRef(new Map