fix(ui): завершить основу таблиц и фильтров серверов

This commit is contained in:
Denozordec committed 2026-09-13 14:46:17 +07:00
1 parent f9a4a07996
commit 3d3c45e4d2
24 files changed
+385 -263

No files matched your search

+8 -8
View File
@@ -9,9 +9,10 @@ import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
import { createFilterQuery } from "@/components/reui/filters/filters-query"
import { ServerFilters } from "@/components/data-grids/server-filters"
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields"
import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
@@ -225,7 +226,7 @@ export default function ServersPage() {
const [_backendOk, setBackendOk] = useState(false)
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery())
const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null)
@@ -556,7 +557,7 @@ export default function ServersPage() {
const q = search.toLowerCase()
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
})
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters])
const counts = useMemo(() => ({
@@ -654,9 +655,7 @@ export default function ServersPage() {
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
@@ -664,6 +663,7 @@ export default function ServersPage() {
/>
<ServersDataGrid
servers={filtered}
hasUnfilteredRows={serverList.length > 0}
isLive={isLive}
pollingIds={pollingIds}
busy={controlsBusy}
+14
View File
@@ -0,0 +1,14 @@
"use client"
import { Filters } from "@/components/reui/filters/filters"
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
import { FILTER_LABELS_RU, FILTER_OPERATOR_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
export function ServerFilters({ query, onQueryChange }: {
query: FilterQuery
onQueryChange: (query: FilterQuery) => void
}) {
return <Filters fields={SERVER_FILTER_FIELDS} query={query} onQueryChange={onQueryChange}
size="sm" showClear labels={FILTER_LABELS_RU} operatorLabels={FILTER_OPERATOR_LABELS_RU} />
}
+11 -4
View File
@@ -163,6 +163,7 @@ function ServerRowActions({
}
interface ServersDataGridProps {
hasUnfilteredRows?: boolean
busy?: boolean
servers: Server[]
isLive: boolean
@@ -174,6 +175,7 @@ interface ServersDataGridProps {
}
function ServersDataGrid({
hasUnfilteredRows = false,
busy = false,
servers,
isLive,
@@ -196,9 +198,14 @@ function ServersDataGrid({
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
<Button type="button" variant="ghost" size="icon-xs"
aria-label={`${expanded ? "Свернуть" : "Раскрыть"}: ${s.name}`}
aria-expanded={expanded}
onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
{expanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
? <ChevronDownIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground/40" />}
</Button>
<div className="min-w-0">
<p className="font-medium truncate">{s.name}</p>
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
@@ -374,8 +381,8 @@ function ServersDataGrid({
return (
<EmptyState
icon={<ServerIcon className="size-4" />}
title="Нет серверов"
description="Добавьте первый MikroTik-сервер для мониторинга"
title={hasUnfilteredRows ? "Нет совпадений" : "Нет серверов"}
description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : "Добавьте первый MikroTik-сервер для мониторинга"}
className="border-0 py-12"
/>
)
@@ -32,18 +32,18 @@ function DataGridSortHeader<TData extends object>({
type="button"
className={cn(
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2",
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === "asc" ? (
<ArrowUpIcon className="size-3 text-foreground" />
<ArrowUpIcon aria-hidden="true" className="size-3 text-foreground" />
) : sorted === "desc" ? (
<ArrowDownIcon className="size-3 text-foreground" />
<ArrowDownIcon aria-hidden="true" className="size-3 text-foreground" />
) : (
<ArrowUpDownIcon className="size-3 opacity-35" />
<ArrowUpDownIcon aria-hidden="true" className="size-3 opacity-35" />
)}
</button>
)
+4 -2
View File
@@ -44,6 +44,7 @@ interface DataPageToolbarProps<T extends string = string> {
filters?: Filter[]
onFiltersChange?: (filters: Filter[]) => void
filterFields?: FilterFieldConfig[]
filterControl?: ReactNode
countLabel?: string
actions?: ReactNode
className?: string
@@ -58,6 +59,7 @@ function DataPageToolbar<T extends string = string>({
filters,
onFiltersChange,
filterFields,
filterControl,
countLabel,
actions,
className,
@@ -72,7 +74,7 @@ function DataPageToolbar<T extends string = string>({
options={segmented.options}
/>
)}
{filterFields && filters && onFiltersChange && (
{filterControl ?? (filterFields && filters && onFiltersChange && (
<Filters
filters={filters}
fields={filterFields}
@@ -85,7 +87,7 @@ function DataPageToolbar<T extends string = string>({
</Button>
}
/>
)}
))}
{onSearchChange != null && (
<InputGroup className="min-w-[220px] max-w-sm">
<InputGroupAddon>
+2 -2
View File
@@ -765,6 +765,7 @@ export function useCascaderLoader<T = unknown>({
const hasSearch = typeof onSearch === "function"
const hasResolve = typeof resolveValue === "function"
const trimmed = query.trim()
if ((!enabled || !trimmed) && search !== null) setSearch(null)
// Serialised so the effects key on CONTENT, not on the array identity.
const levelsKey = JSON.stringify(levels)
@@ -783,7 +784,6 @@ export function useCascaderLoader<T = unknown>({
if (!hasSearch) return undefined
if (!enabled || !trimmed) {
abortKey(SEARCH_KEY)
setSearch((prev) => (prev === null ? prev : null))
return undefined
}
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
@@ -863,4 +863,4 @@ export function useCascaderLoadState(
): CascaderLoadState | null {
const { loadStates } = useCascaderState()
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
}
}
@@ -125,6 +125,7 @@ function useCascaderVirtualGutter(
React.useLayoutEffect(() => {
if (!contentElement) return
const measure = () => {
const styles = getComputedStyle(contentElement)
const block = Number.parseFloat(styles.paddingTop) || 0
const inline =
@@ -134,6 +135,10 @@ function useCascaderVirtualGutter(
? previous
: { block, inline }
)
}
const observer = new ResizeObserver(measure)
observer.observe(contentElement)
return () => observer.disconnect()
}, [contentElement])
return gutter
@@ -455,4 +460,4 @@ function CascaderVirtualColumnRows({
)
}
export { CascaderVirtualColumn, CascaderVirtualItems }
export { CascaderVirtualColumn, CascaderVirtualItems }
+32 -23
View File
@@ -925,23 +925,30 @@ function Cascader<T>({
// Remembers every selected node, so the trigger keeps a label after it
// leaves the loaded set (or was never in it).
const labelCacheRef = React.useRef(new Map<string, CascaderNode<T>>())
const [labelCache, setLabelCache] = React.useState(() => new Map<string, CascaderNode<T>>())
// Populated during render, since the trigger reads it while rendering.
// Idempotent and prop-derived, so a discarded render is harmless.
for (const selected of selectedValues) {
if (selectedValues.some(selected => {
const node = index.byValue.get(selected)
if (node) labelCacheRef.current.set(selected, node)
return node && labelCache.get(selected) !== node
})) {
const nextCache = new Map(labelCache)
for (const selected of selectedValues) {
const node = index.byValue.get(selected)
if (node) nextCache.set(selected, node)
}
setLabelCache(nextCache)
}
const resolveNode = React.useCallback(
(nodeValue: string): CascaderNode<T> =>
index.byValue.get(nodeValue) ??
labelCacheRef.current.get(nodeValue) ?? {
labelCache.get(nodeValue) ?? {
value: nodeValue,
label: nodeValue,
},
[index]
[index, labelCache]
)
/* ------------------------------ derived view ----------------------------- */
@@ -1086,7 +1093,9 @@ function Cascader<T>({
const swapping = swap.active && swap.key === levelKey
React.useLayoutEffect(() => {
if (swap.active) setSwap((prev) => ({ ...prev, active: false }))
if (!swap.active) return
const frame = requestAnimationFrame(() => setSwap(prev => ({ ...prev, active: false })))
return () => cancelAnimationFrame(frame)
}, [swap.active])
/* ----------------------------- virtualization ---------------------------- */
@@ -1139,7 +1148,8 @@ function Cascader<T>({
React.useEffect(() => {
if (!virtualized) return
setVirtualSyncs((count) => count + 1)
const frame = requestAnimationFrame(() => setVirtualSyncs(count => count + 1))
return () => cancelAnimationFrame(frame)
}, [virtualized])
React.useEffect(() => {
@@ -1669,24 +1679,23 @@ function Cascader<T>({
const state = loadStates.get(pendingNavigation.value)
if (!state || state.loading || state.error) return
setPendingNavigation(null)
if (pendingNavigation.kind === "expand") {
toggleExpanded(pendingNavigation.value)
return
}
if (pendingNavigation.kind === "at") {
goToLevelAt(pendingNavigation.value, pendingNavigation.depth)
return
}
pushLevel(pendingNavigation.value)
}, [pendingNavigation, loadStates, toggleExpanded, goToLevelAt, pushLevel])
if (!open && !inline) return
// Navigation notifies the controlled parent; dispatch only after this
// commit and discard the intent if the popup/context changes first.
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setPendingNavigation(null)
if (pendingNavigation.kind === "expand") toggleExpanded(pendingNavigation.value)
else if (pendingNavigation.kind === "at") goToLevelAt(pendingNavigation.value, pendingNavigation.depth)
else pushLevel(pendingNavigation.value)
})
return () => { cancelled = true }
}, [pendingNavigation, loadStates, toggleExpanded, goToLevelAt, pushLevel, open, inline])
// A popup that closes mid-flight must not drill in when the answer arrives;
// the loader aborts the request, this drops the intent.
React.useEffect(() => {
if (!open && !inline) setPendingNavigation(null)
}, [open, inline])
if (!open && !inline && pendingNavigation !== null) setPendingNavigation(null)
/* ------------------------------- selection ------------------------------ */
@@ -2931,4 +2940,4 @@ export type {
CascaderItemState,
CascaderRenderContextValue,
CascaderStateContextValue,
}
}
@@ -231,8 +231,8 @@ function DataGridScrollArea({
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
const frame = requestAnimationFrame(resetMetrics)
return () => cancelAnimationFrame(frame)
}
let frame = 0
@@ -480,4 +480,4 @@ function DataGridScrollArea({
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -59,12 +59,11 @@ import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type SortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table"
import type { Cell, HeaderGroup, Row, Table } from "@tanstack/react-table"
import type { Cell, HeaderGroup, Row, } from "@tanstack/react-table"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
@@ -445,14 +444,10 @@ function DataGridTableDndRows<TData extends object>({
// lands offset by that ancestor's own top/left, and the container clamp
// below mis-clamps too, because its rects are measured in viewport space.
//
// Resolved in an effect rather than read at render so the server and the
// first client render agree. A drag cannot start before hydration, so the
// overlay being absent for one frame costs nothing.
// Resolve the portal target on pickup, without a document read during
// hydration or an extra render before the first drag.
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
useEffect(() => {
setPortalTarget(document.body)
}, [])
// The row being carried, plus the column widths measured off the header the
// moment the drag starts. The clone lives outside the table, so it has no
// columns of its own and has to be told what they are.
@@ -464,6 +459,7 @@ function DataGridTableDndRows<TData extends object>({
} | null>(null)
const pickUpRow = useCallback((id: UniqueIdentifier) => {
setPortalTarget(document.body)
const container = tableContainerRef.current
const head = container?.querySelector("thead tr")
if (!container || !head) {
@@ -737,4 +733,4 @@ function DataGridTableDndRows<TData extends object>({
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
@@ -58,8 +58,7 @@ import type {
Header,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { GripVerticalIcon } from "lucide-react"
@@ -29,7 +29,7 @@ import {
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { flexRender } from "@tanstack/react-table"
import type { Column, Row, Table } from "@tanstack/react-table"
import type { Column, Row, } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import type {
VirtualItem,
@@ -52,6 +52,8 @@ type DataGridTableVirtualizerInstance = Virtualizer<
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
const EMPTY_VIRTUAL_ITEMS: never[] = []
interface DataGridTableColumnVirtualizerOptions {
/** Off unless explicitly true; a column window is opt-in. */
enabled?: boolean
@@ -804,17 +806,17 @@ function DataGridTableVirtual<TData extends object>({
// virtualizer's cached sizes by hand.
useEffect(() => {
if (columnVirtualizationActive) columnVirtualizer.measure()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
columnVirtualizationActive,
columnVirtualizer,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnVisibility,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnOrder,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnPinning,
])
@@ -834,7 +836,7 @@ function DataGridTableVirtual<TData extends object>({
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
: EMPTY_VIRTUAL_ITEMS
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
@@ -1049,7 +1051,7 @@ function DataGridTableVirtual<TData extends object>({
// wrapper (any table state change recreates it), the layout props, and
// the column window. A scroll frame changes none of them, so the whole
// sortable-header subtree is reused instead of rebuilt per frame.
// eslint-disable-next-line react-hooks/exhaustive-deps
const headerNode = useMemo(
() =>
renderHeader && (
+10 -10
View File
@@ -28,7 +28,7 @@ import type {
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import { flexRender, Subscribe } from "@tanstack/react-table"
import type { Cell, Column, Header, Row, Table } from "@tanstack/react-table"
import type { Cell, Column, Header, Row, } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -741,19 +741,19 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
props.tableLayout?.columnsResizable,
// Visibility/order/pinning change the flat header set, so a column shown
// after mount must get its size variable even though sizing is untouched.
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnVisibility,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnOrder,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnPinning,
// A def swap can change a column's `size` without touching sizing
// STATE; without this dep the CSS variables keep the old widths. For a
// consumer defining columns inline the memo degrades to per-render
// recompute, which is the safe direction.
// eslint-disable-next-line react-hooks/exhaustive-deps
table.options.columns,
])
@@ -956,7 +956,7 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
function DataGridTableHeadRow({
children,
rowId,
rowId: _rowId,
}: {
children: ReactNode
rowId: string
@@ -1460,7 +1460,7 @@ function DataGridTableFootRowCell({
}
function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) {
const { table, props } = useDataGrid()
const { props } = useDataGrid()
return (
<tr
@@ -2340,7 +2340,7 @@ const MemoizedDataGridTableBodyRows = memo(
) as typeof DataGridTableBodyRows
function DataGridTableHeader<TData extends object>() {
const { table, props } = useDataGrid()
const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
@@ -2415,7 +2415,7 @@ function DataGridTable<TData extends object>({
footerContent?: ReactNode
renderHeader?: boolean
}) {
const { table, props } = useDataGrid()
const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
+17 -81
View File
@@ -5,8 +5,9 @@ import {
useContext,
useEffect,
useId,
useMemo,
useLayoutEffect,
useRef,
useState,
} from "react"
import type { ReactNode } from "react"
import {
@@ -878,25 +879,17 @@ function DataGridProvider<TData extends object>({
table: DataGridTableInstance<TData>
children?: ReactNode
}) {
// Latest-props ref: context reads always resolve fresh props through the
// getter below without the memoized context value depending on unstable
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
// Rendering reads this snapshot; imperative sizing reads committed values.
const gridId = useId()
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
// otherwise publish a new context value on every consumer render - at
// mousemove rate during a resize drag, piercing the body-rows memo).
const propsRef = useRef(props)
propsRef.current = props
const i18n = mergeDataGridI18n(props.i18n)
const i18nRef = useRef(i18n)
i18nRef.current = i18n
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
// every state change. Depending on it directly would republish the context
// on each resize tick, which is exactly what the memo below exists to
// prevent; the getter still hands every consumer the current instance.
const tableRef = useRef(table)
tableRef.current = table
const [sizing] = useState(() => {
let committedTable = table
return {
controller: createDataGridAutoSizeController(() => committedTable),
update(next: DataGridTableInstance<TData>) { committedTable = next },
}
})
useLayoutEffect(() => { sizing.update(table) }, [sizing, table])
// Re-assert an explicit tableLayout resize mode so consumer-level useTable
// options cannot flip it back between drags. v9 makes `table.options`
@@ -936,70 +929,13 @@ function DataGridProvider<TData extends object>({
if (previousPageKeyRef.current === pageKey) return
previousPageKeyRef.current = pageKey
if (!cellSelectionOn || table.atoms.cellSelection == null) return
tableRef.current.resetCellSelection(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageKey, cellSelectionOn])
table.resetCellSelection(true)
}, [pageKey, cellSelectionOn, table])
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
// stable for the life of the table, rather than on `table` itself: the
// wrapper is re-created on every state change, and re-creating the
// controller with it would reset its applied-once bookkeeping mid-drag.
const autoSize = useMemo(
() => createDataGridAutoSizeController(() => tableRef.current),
[table.store]
)
const tableState = table.state
const tableLayoutKey = JSON.stringify(props.tableLayout)
const tableClassNamesKey = JSON.stringify(props.tableClassNames)
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
// ReactNode/function props (messages, onRowClick) are also excluded: they
// are served fresh through the props getter, so unstable inline identities
// cannot invalidate the context value.
// `cellSelection` state is excluded on purpose too: a drag writes it once
// per cell crossed, and nothing reads it through context - cells and the
// selection bar subscribe to `table.atoms.cellSelection` directly.
const value = useMemo(
() => ({
get props() {
return propsRef.current
},
get table() {
return tableRef.current
},
get i18n() {
return i18nRef.current
},
recordCount: props.recordCount,
isLoading: props.isLoading || false,
gridId,
autoSize,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
autoSize,
props.recordCount,
props.isLoading,
props.loadingMode,
props.className,
tableLayoutKey,
tableClassNamesKey,
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.rowPinning,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
const value = {
props, table, i18n, recordCount: props.recordCount,
isLoading: props.isLoading || false, gridId, autoSize: sizing.controller,
}
return (
// One React context serves every TData, but v9 declares both TFeatures and
+15 -35
View File
@@ -752,19 +752,22 @@ function FilterAdvancedRowImpl<V, O>({
// user's own opens itself. CONSUMED as it is honoured: `pickerOpen` is a
// dependency, so a flag left standing reopened the picker in the frame it
// closed, a keyboard trap on the one row a user creates most.
if (fieldAutoOpen && !pickerOpen && !locked) setPickerOpen(true)
React.useEffect(() => {
if (!fieldAutoOpen || pickerOpen) return
if (!locked) setPickerOpen(true)
if (!fieldAutoOpen) return
focusStore.set({ id: rule.id, segment: "field", autoOpen: false })
}, [fieldAutoOpen, pickerOpen, locked, focusStore, rule.id])
// Re-seed on each open, so a browse that was abandoned last time does not
// decide where this one starts.
React.useEffect(() => {
if (!pickerOpen) return
setPickerPath(rule.path.slice(0, -1))
setPickerQuery("")
}, [pickerOpen, rule.path])
const [previousPicker, setPreviousPicker] = React.useState({ open: pickerOpen, path: rule.path })
if (previousPicker.open !== pickerOpen || previousPicker.path !== rule.path) {
setPreviousPicker({ open: pickerOpen, path: rule.path })
if (pickerOpen) {
setPickerPath(rule.path.slice(0, -1))
setPickerQuery("")
}
}
/* ------------------------------- tab stop ------------------------------- */
@@ -1474,7 +1477,7 @@ export function FiltersAdvancedPanel<V, O>({
const focusStore = useFilterFocusStore()
const rowStateStore = useFilterRowStateStore()
// A version counter, because the store is mutable and stable by identity.
const rowStateVersion = React.useSyncExternalStore(
React.useSyncExternalStore(
rowStateStore.subscribe,
() => rowStateStore.version(),
() => 0
@@ -1525,14 +1528,8 @@ export function FiltersAdvancedPanel<V, O>({
[query, actions]
)
/** Every issue, keyed by node: what the tree IS, drawn or not. */
const issueMap = React.useMemo(
() => new Map(issues.map((issue) => [issue.nodeId, issue])),
[issues]
)
/** The issues a row may DRAW: the ones on a value committed once. */
const visibleIssueMap = React.useMemo(() => {
const visibleIssueMap = (() => {
const visible = new Map<string, FilterIssue>()
for (const issue of issues) {
// CUSTOM ONLY. The built-in reasons describe a row as HALF BUILT,
@@ -1541,7 +1538,7 @@ export function FiltersAdvancedPanel<V, O>({
if (rowStateStore.has(issue.nodeId)) visible.set(issue.nodeId, issue)
}
return visible
}, [issues, rowStateStore, rowStateVersion])
})()
/** Says out loud that a VISIBLE error has appeared, which would
* otherwise happen in silence. The DRAWN issues, on a RISE only. */
@@ -1560,24 +1557,7 @@ export function FiltersAdvancedPanel<V, O>({
actions.announce(actions.labels.issueSummary(visibleIssueMap.size))
}, [visibleIssueMap, announcementSeq, actions])
/** Sends focus to the first thing that needs it. Walked and not selected
* by id: a node id is consumer-supplied, and `CSS.escape` is not
* optional in a hand-built selector. */
const focusFirstIssue = React.useCallback(() => {
const first = issues[0]
const body = bodyRef.current
if (!first || !body) return
const row = Array.from(
body.querySelectorAll<HTMLElement>(ROW_SELECTOR)
).find((candidate) => candidate.dataset.nodeId === first.nodeId)
if (!row) return
// A group's issue is drawn on the add button that resolves it.
const column = first.column === "group" ? "add" : first.column
const target = ownCells(row).find(
(cell) => cell.getAttribute(CELL_ATTRIBUTE) === column
)
target?.focus()
}, [issues])
/* ---------------------------- focus recovery ---------------------------- */
@@ -2078,4 +2058,4 @@ export function FiltersAdvanced<V, O>({
</PopoverContent>
</Popover>
)
}
}
+9 -8
View File
@@ -373,9 +373,11 @@ function FilterValueEditor<V, O>({
const actions = useFilterActions<V, O>()
const [draft, setDraft] = React.useState<V | undefined>(rule.value)
React.useEffect(() => {
const [previousInput, setPreviousInput] = React.useState({ open, value: rule.value })
if (previousInput.open !== open || !Object.is(previousInput.value, rule.value)) {
setPreviousInput({ open, value: rule.value })
if (open) setDraft(rule.value)
}, [open, rule.value])
}
const options = useFilterOptions<V, O>(field, open)
// A LOOKUP, not a definition: every result is a module-level built-in or the
@@ -461,9 +463,9 @@ export function FilterValuePopover<V, O>({
// spends sits armed until some later chip spends it, and a chip that opened
// because a DIFFERENT chip was arrowed onto is worse than one that opened
// nothing.
if (autoOpen && !open && !locked) setOpen(true)
React.useEffect(() => {
if (!autoOpen || open) return
if (!locked) setOpen(true)
if (!autoOpen) return
focusStore.set({ id: rule.id, segment: "value", autoOpen: false })
}, [autoOpen, open, focusStore, rule.id, locked])
@@ -547,7 +549,6 @@ export function FilterOperatorPopover<V, O>({
* unmount. Clearing it in `release` would break that twin only. */
const handoff = React.useRef(false)
const operators = actions.resolveOperators(field)
const operator = getFilterOperator(operators, rule.operator)
// Memoized because the array IS the menu's identity: the cascader rebuilds
// its index per `items` identity, so a fresh array rebuilds it per keystroke.
@@ -563,10 +564,10 @@ export function FilterOperatorPopover<V, O>({
// Opened by the root the moment the chip is created. CONSUMED here, or the
// handoff re-opens the menu on the frame Escape closes it. Consumed even
// while locked, for the reason on the value popover's copy.
if (autoOpen && !open && !locked) setOpen(true)
React.useEffect(() => {
if (!autoOpen || open) return
if (!autoOpen) return
handoff.current = false
if (!locked) setOpen(true)
focusStore.set({ id: rule.id, segment: "operator", autoOpen: false })
}, [autoOpen, open, focusStore, rule.id, locked])
@@ -1068,4 +1069,4 @@ function FilterChipImpl<V, O>({ rule, index }: FilterChipProps<V>) {
)
}
export const FilterChip = React.memo(FilterChipImpl) as typeof FilterChipImpl
export const FilterChip = React.memo(FilterChipImpl) as typeof FilterChipImpl
+7 -52
View File
@@ -1,54 +1,9 @@
# ReUI: оставшиеся замечания реестра
# ReUI: итог lint этапа 2
2026-09-13. `npx eslint components/reui/data-grid components/reui/filters components/reui/cascader --format json`. Осталось 21 ошибка и 27 предупреждений (было 23 и 27). Две ошибки сложных выражений в зависимостях useMemo исправлены без изменения семантики сравнения. Правила не отключены.
2026-09-13. Проверены изменённые файлы серверов/Filters и вся новая группа Data Grid, Filters, Cascader. **0 ошибок, 1 предупреждение** (исходно 23 ошибки и 27 предупреждений). Правила не отключены.
| Файл | Позиция | Уровень | Правило | Замечание |
| --- | --- | --- | --- | --- |
| `components/reui/cascader/cascader-async.tsx` | 786:7 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader-virtual.tsx` | 132:5 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx` | 934:15 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/cascader/cascader.tsx` | 934:15 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/cascader/cascader.tsx` | 1089:22 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx` | 1142:5 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx` | 1672:5 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx` | 1688:27 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx` | 2111:45 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/cascader/cascader.tsx` | 2112:36 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid-scroll-area.tsx` | 234:7 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/data-grid/data-grid-table-dnd-rows.tsx` | 62:3 | warning | `@typescript-eslint/no-unused-vars` | 'verticalListSortingStrategy' is defined but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid-table-dnd-rows.tsx` | 67:39 | warning | `@typescript-eslint/no-unused-vars` | 'Table' is defined but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid-table-dnd-rows.tsx` | 454:5 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/data-grid/data-grid-table-dnd.tsx` | 61:3 | warning | `@typescript-eslint/no-unused-vars` | 'Table' is defined but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 32:28 | warning | `@typescript-eslint/no-unused-vars` | 'Table' is defined but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 762:23 | warning | `react-hooks/incompatible-library` | Compilation Skipped: Use of incompatible library |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 807:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 811:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 813:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 815:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 817:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 835:9 | warning | `react-hooks/exhaustive-deps` | The 'virtualItems' conditional could make the dependencies of useEffect Hook (at line 1037) change on every render. To fix this, wrap the initialization of 'virtualItems' in its own useMemo() Hook. |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 1052:3 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table.tsx` | 31:42 | warning | `@typescript-eslint/no-unused-vars` | 'Table' is defined but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid-table.tsx` | 744:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table.tsx` | 746:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table.tsx` | 748:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table.tsx` | 750:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table.tsx` | 756:5 | warning | `directive` | Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps'). |
| `components/reui/data-grid/data-grid-table.tsx` | 959:3 | warning | `@typescript-eslint/no-unused-vars` | 'rowId' is defined but never used. Allowed unused args must match /^_/u. |
| `components/reui/data-grid/data-grid-table.tsx` | 1463:11 | warning | `@typescript-eslint/no-unused-vars` | 'table' is assigned a value but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid-table.tsx` | 2342:30 | warning | `@typescript-eslint/no-unused-vars` | 'TData' is defined but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid-table.tsx` | 2411:24 | warning | `@typescript-eslint/no-unused-vars` | 'TData' is defined but never used. Allowed unused vars must match /^_/u. |
| `components/reui/data-grid/data-grid.tsx` | 889:3 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx` | 892:3 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx` | 899:3 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx` | 949:44 | error | `react-hooks/refs` | Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx` | 950:5 | warning | `react-hooks/exhaustive-deps` | React Hook useMemo has an unnecessary dependency: 'table.store'. Either exclude it or remove the dependency array. |
| `components/reui/filters/filters-advanced.tsx` | 757:18 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-advanced.tsx` | 765:5 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-advanced.tsx` | 1529:9 | warning | `@typescript-eslint/no-unused-vars` | 'issueMap' is assigned a value but never used. Allowed unused vars must match /^_/u. |
| `components/reui/filters/filters-advanced.tsx` | 1544:6 | warning | `react-hooks/exhaustive-deps` | React Hook React.useMemo has an unnecessary dependency: 'rowStateVersion'. Either exclude it or remove the dependency array. |
| `components/reui/filters/filters-advanced.tsx` | 1566:9 | warning | `@typescript-eslint/no-unused-vars` | 'focusFirstIssue' is assigned a value but never used. Allowed unused vars must match /^_/u. |
| `components/reui/filters/filters-chip.tsx` | 377:15 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-chip.tsx` | 466:18 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-chip.tsx` | 550:9 | warning | `@typescript-eslint/no-unused-vars` | 'operator' is assigned a value but never used. Allowed unused vars must match /^_/u. |
| `components/reui/filters/filters-chip.tsx` | 569:18 | error | `react-hooks/set-state-in-effect` | Error: Calling setState synchronously within an effect can trigger cascading renders |
Оставшийся виртуализатор исключается React Compiler из автоматической мемоизации. Его браузерную регрессию и производительность проверять при переносе использующих его экранов. Исходные общепроектные ошибки вне этого этапа перечислены в основном журнале.
| Файл | Позиция | Правило | Замечание |
| --- | --- | --- | --- |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 764:23 | `react-hooks/incompatible-library` | Compilation Skipped: Use of incompatible library |
+36 -2
View File
@@ -16,8 +16,8 @@
| --- | --- | --- |
| 0 | Исходная база, карта миграции, проверки и обзор экранов | Завершён 2026-09-13 |
| 1 | Достоверность сохранения, удаления, включения и опроса серверов | Завершён 2026-09-13 |
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | В работе: основа и серверы перенесены, приёмка не завершена |
| 3 | Основные таблицы и справочники | Не начат |
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | Завершён 2026-09-13, с перечисленными ограничениями приёмки |
| 3 | Основные таблицы и справочники | Следующий, отдельное окно |
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Не начат |
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Не начат |
| 6 | Навигация и дашборд | Не начат |
@@ -147,6 +147,40 @@ flowchart LR
Проверки: TypeScript всего проекта прошёл; браузерный тест v9 (12 строк, сортировка, раскрытие) прошёл. Повторный lint группы: **21 ошибка, 27 предупреждений**, актуальный компактный отчёт — `docs/reui-migration-lint.md`. Production build не повторялся после этой локальной правки; предыдущая сборка этапа прошла. Этап 2 остаётся в работе; следующий объём прежний.
## Этап 2 — завершение
Продолжение от `f9a4a07` в `codex/reui-v9-migration`. Записи о промежуточных остановках выше — история; текущий следующий шаг — **этап 3**.
### Результат
- Серверы используют ReUI Data Grid/TanStack 9.2.4 и новый `FilterQuery`; таблица сохраняет все строки, сортировку, детали и подтверждённые операции этапа 1. Для раскрытия есть отдельная кнопка с `aria-expanded`, работающая с клавиатуры. При пустом результате фильтрации показывается «Нет совпадений», а не предложение добавить первый сервер.
- `ServerFilters` повторяет контракт официального `c-filters-1`. Поля `country/status/type` имеют `type: select`, потому что данные скалярные. Варианты множественного выбора задаются операторами. Добавлены русские подписи Filters, вычислитель дерева `applyFilterQuery` и временный `migrateFilterQuery` для AND-массива старых условий. Неизвестное условие не расширяет выборку, незавершённые условия не превращают OR в истинное выражение.
- `DataPageToolbar.filterControl` позволяет переносить потребителей по одному; старые props сохранены только на период этапов 3–4. Новые импорты Filters обязательно указывают `/filters/filters`, пока существует старый одноимённый файл.
- Выполнены локальные исправления совместимости исходников ReUI с React Compiler. Контекст Data Grid передаёт актуальный снимок данных; императивный координатор ширины получает только закоммиченные React значения. Кэш подписей Cascader обновляется без мутации ref во время рендера; измерения запускаются через ResizeObserver/кадр браузера, отложенная навигация отменяется при смене контекста. Draft и автооткрытие Filters синхронизируются до рендера дочерних редакторов. Убраны неиспользуемые импорты и лишние вычисления. Правила lint не отключались, публичные API и композиция сохранены. При будущем обновлении реестра эти исправления сравнить с upstream, не перезаписывать автоматически.
### Приёмка
| Проверка | Результат |
| --- | --- |
| `npm run test:servers` | 17 браузерных тестов прошли, включая операции, сортировку 12 строк, фильтрацию AND, изменение/сброс, Escape и клавиатурное раскрытие |
| `npx tsx --test lib/data-filters/apply-filter-query.test.ts` | 6 тестов прошли: AND/OR, отрицание, черновики, пустые/неизвестные условия, эквивалентность поддерживаемых старых операторов |
| TypeScript и `npm run build` | Успешно, 34 статические страницы |
| Lint изменённых участков и новой группы ReUI | 0 ошибок, 1 предупреждение `react-hooks/incompatible-library` для виртуализатора. React Compiler пропускает его оптимизацию; [отчёт](reui-migration-lint.md) |
| Визуальная проверка | Просмотрены экраны 1440×1000 и 390×844. Page overflow отсутствует; широкая таблица сохраняет горизонтальный скролл внутри своего контейнера |
| ReUI audit | Checklist получен; проверены композиция Frame/base-nova, API, типизированные HTTP-данные, подписи, состояния и фокус эталона. Правки internals ограничены описанной совместимостью, без смены дизайна |
Границы приёмки: моки HTTP не подтверждают реальные права или применение RouterOS. Общая мобильная шапка обрезает часть действий, KPI занимают много высоты — исходные задачи этапов 6–7. Некоторые служебные объявления вложенного Cascader ещё английские — завершить локализацию на этапе 7. Все режимы DnD/виртуализации/автоподбора ширины не были проверены браузером на этом этапе; их проверять при переносе использующих их экранов. Изменённый способ публикации контекста Data Grid требует профилирования больших таблиц на этапе 12. Это не полная приёмка доступности/производительности всего приложения.
### Образец следующего переноса
1. В выбранном доменном grid заменить `react-table-v8` на `react-table`, legacy-оболочку на `DataGridShell` и заголовки на новый `DataGridSortHeader`.
2. Тип колонок — `ColumnDef<DataGridFeatures, T>`, таблицы — `Table<DataGridFeatures, T>`. Использовать `useTable({ features: dataGridFeatures, ... })`; удалить старые `get*RowModel`. Сверить переименованные sorting/pinning/resize props с установленным API. Сохранить стабильный `getRowId`.
3. Явно определить пагинацию: для полного списка без панели — `manualPagination: true`; для серверной — передавать реальный `rowCount`; для клиентской — подключить панель и контролируемую страницу.
4. Для Filters перенести поля `key → id`, выбрать тип по данным, сохранить AND через `migrateFilterQuery`. Вычислитель сейчас поддерживает только операторы скалярных фильтров серверов и прежние текстовые contains; перед переносом дат, чисел или массивов расширить вычисление и тесты. Нельзя просто сплющить OR-дерево.
5. Проверить фильтрацию, сортировку, сброс/страницу, раскрытие и действия. Старую основу удалять только после последнего потребителя в этапе 4.
Следующий шаг: проверить квоту и состояние файлов; выполнить только этап 3 (обзор, мониторинг, трафик, статистика и справочники) в этой ветке, перечислить оставшихся legacy-потребителей и сделать локальный коммит без push. Перед миграцией виртуализированных таблиц добавить профильные проверки указанного выше риска контекста. Этапы 2–4 не выпускать до общей регрессии этапа 4.
## Карта миграции
Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile.
@@ -0,0 +1,39 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { applyFilterQuery } from "./apply-filter-query"
import { migrateFilterQuery } from "./migrate-filter-query"
import { applyReuiFilters } from "./apply-reui-filters"
import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query"
const rows = [{ country: "RU", status: "online" }, { country: "DE", status: "offline" }, { country: "NL", status: "offline" }]
const accessors = { country: (r: typeof rows[number]) => r.country, status: (r: typeof rows[number]) => r.status }
const rule = (field: string, operator: string, value?: unknown) => createFilterRule({ id: field, path: [field], operator, value })
test("scalar select supports multiple allowed values and AND with status", () => {
const query = createFilterQuery([rule("country", "is_any_of", ["RU", "DE"]), rule("status", "is", "offline")])
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
})
test("nested OR preserves grouping and negation", () => {
const query = createFilterQuery([createFilterQuery([rule("country", "is", "RU"), rule("country", "is", "DE")], "or", "countries"), { ...rule("status", "is", "online"), negated: true }])
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
})
test("drafts do not make OR true, an entirely empty query keeps the list", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is", "RU"), rule("status", "is")], "or"), accessors), [rows[0]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery(), accessors), rows)
})
test("unknown fields and operators never broaden the selection", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "unsupported", "RU")]), accessors), [])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("unknown", "is", "RU")]), accessors), [])
})
test("negative selection and empty values stay distinct", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is_none_of", ["RU", "NL"])]), accessors), [rows[1]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "empty")]), accessors), [])
})
test("migration preserves the existing operators and implicit AND", () => {
for (const operator of ["is", "isNot", "isAnyOf", "isNotAnyOf", "contains", "doesNotContain"]) {
const filters = [{ id: "country", field: "country", operator, values: ["RU", "DE"] },
{ id: "status", field: "status", operator: "isNot", values: ["online"] }]
assert.deepEqual(applyFilterQuery(rows, migrateFilterQuery(filters), accessors), applyReuiFilters(rows, filters, accessors))
}
})
+42
View File
@@ -0,0 +1,42 @@
import type { FilterNode, FilterQuery, FilterRule } from "@/components/reui/filters/filters-types"
export type FilterAccessors<T> = Record<string, (row: T) => unknown>
/** Undefined is a draft, not a matching branch (especially inside OR). */
function matchRule<T>(row: T, rule: FilterRule, accessors: FilterAccessors<T>): boolean | undefined {
if (!rule.operator) return undefined
const accessor = accessors[rule.path.join(".")]
if (!accessor) return false
const actual = accessor(row)
const value = rule.value
const values = Array.isArray(value) ? value : value === undefined ? [] : [value]
const empty = actual == null || actual === "" || (Array.isArray(actual) && actual.length === 0)
if (!["empty", "not_empty"].includes(rule.operator) && (value === undefined || values.length === 0)) return undefined
const text = (v: unknown) => String(v ?? "").toLocaleLowerCase()
let result: boolean
switch (rule.operator) {
case "is": result = String(actual) === String(values[0]); break
case "is_not": result = String(actual) !== String(values[0]); break
case "is_any_of": result = values.some(v => String(actual) === String(v)); break
case "is_none_of": result = !values.some(v => String(actual) === String(v)); break
case "contains": result = values.some(v => text(actual).includes(text(v))); break
case "not_contains": result = !values.some(v => text(actual).includes(text(v))); break
case "empty": result = empty; break
case "not_empty": result = !empty; break
// Unsupported conditions must never silently widen the result set.
default: return false
}
return rule.negated ? !result : result
}
function matchNode<T>(row: T, node: FilterNode, accessors: FilterAccessors<T>): boolean | undefined {
if (node.type === "rule") return matchRule(row, node, accessors)
const results = node.rules.map(child => matchNode(row, child, accessors)).filter(value => value !== undefined)
if (results.length === 0) return undefined
return node.combinator === "and" ? results.every(Boolean) : results.some(Boolean)
}
/** Evaluate the tree recursively: flattening loses group combinators. */
export function applyFilterQuery<T>(rows: T[], query: FilterQuery, accessors: FilterAccessors<T>): T[] {
return rows.filter(row => matchNode(row, query, accessors) !== false)
}
+43
View File
@@ -0,0 +1,43 @@
import type { FilterLabels } from "@/components/reui/filters/filters-types"
export const FILTER_LABELS_RU: FilterLabels = {
addFilter: "Фильтры", advancedFilter: "Расширенные фильтры", showRecords: "Показать записи",
builderEmpty: "Фильтров пока нет", builderEmptyHint: "Добавьте условие, чтобы уточнить список.",
addCondition: "Добавить условие", addConditionGroup: "Добавить группу", addToGroup: "Добавить условие в группу",
removeGroup: "Удалить группу", wrapInGroup: "Объединить в группу", ungroup: "Разгруппировать",
moveToTopLevel: "На верхний уровень", moveToGroup: n => `В группу ${n}`,
reorder: "Изменить порядок", reorderHint: "Alt + стрелка вверх или вниз — изменить порядок",
groupAll: "Выполнены все условия", groupAny: "Выполнено любое условие", groupPlaceholder: "Перетащите условия сюда",
rowLabel: (condition, depth) => `${condition}, уровень ${depth}`,
groupLabel: (description, depth) => `${description}, уровень ${depth}`,
groupAnnouncement: added => added ? "Группа добавлена" : "Группа удалена",
reorderAnnouncement: (label, position, total) => `${label}: позиция ${position} из ${total}`,
moveAnnouncement: (label, destination, position, total) => `${label}: ${destination}, позиция ${position} из ${total}`,
clearAll: "Сбросить фильтры", groupMenu: "Действия с группой",
searchFields: "Поиск поля…", searchOperators: "Поиск условия…", searchOptions: "Поиск значения…",
back: "Назад", clear: "Очистить", apply: "Применить", discard: "Отменить изменения",
empty: "Ничего не найдено", loading: "Загрузка…", loadingMore: "Загрузка…", loadMore: "Загрузить ещё",
error: "Не удалось загрузить", retry: "Повторить", where: "Где", and: "И", or: "ИЛИ",
combinator: "Изменить связь условий", combinatorLabel: word => `${word}, изменить связь условий`,
duplicate: "Дублировать", negate: "Инвертировать", convertToAdvanced: "Расширенный редактор", remove: "Удалить",
chipMenu: field => `Действия с фильтром «${field}»`, filtersLabel: "Фильтры", filterLabel: condition => condition,
readOnly: "Фильтры доступны только для чтения.", pathSeparator: " > ", valuePlaceholder: "Введите значение…",
selectPlaceholder: "Выберите…", noValue: "нет значения", selectCondition: "Выберите условие",
incomplete: "незавершённый фильтр", branchAffordance: "открывает список",
exclusiveHint: "нельзя сочетать с другими значениями",
exclusiveAnnouncement: (label, cleared) => `Выбрано ${label}. Снято других значений: ${cleared}.`,
itemCount: count => `Элементов: ${count}`, fieldsLabel: "Поля",
resultsAnnouncement: count => `Найдено: ${count}`, actionsLabel: "Действия",
stepAnnouncement: (step, label) => `${step === "field" ? "Выберите поле" : step === "operator" ? "Выберите условие" : "Введите значение"}. ${label}`,
countAnnouncement: count => `Применено фильтров: ${count}`, valueCount: count => `Выбрано: ${count}`,
valueDetail: (summary, values) => `${summary}: ${values.join(", ")}`, valueRange: (from, to) => `${from} — ${to}`,
rangeFrom: field => `${field}: от`, rangeTo: field => `${field}: до`, rangeSeparator: "до",
negated: operator => `не (${operator})`, issueOperator: "Выберите условие", issueValue: "Введите значение",
issueRange: "Укажите обе границы", issueRangeOrder: "Конец диапазона меньше начала",
issueEmptyGroup: "В группе пока нет условий", issueSummary: count => `Требуют внимания: ${count}`,
}
export const FILTER_OPERATOR_LABELS_RU = {
is: "равно", is_not: "не равно", is_any_of: "любой из", is_none_of: "ни один из",
contains: "содержит", not_contains: "не содержит", empty: "не задано", not_empty: "задано",
}
+15
View File
@@ -0,0 +1,15 @@
import { createFilterQuery, createFilterRule } from "@/components/reui/filters/filters-query"
const OPERATORS: Record<string, string> = {
is: "is", isNot: "is_not", isAnyOf: "is_any_of", isNotAnyOf: "is_none_of",
contains: "contains", doesNotContain: "not_contains",
}
/** Temporary input contract, independent of the old UI component. */
export function migrateFilterQuery(filters: { id: string; field: string; operator: string; values: unknown[] }[]) {
return createFilterQuery(filters.map(filter => {
const operator = OPERATORS[filter.operator]
if (!operator) throw new Error(`Неподдерживаемое условие: ${filter.operator}`)
return createFilterRule({ id: filter.id, path: [filter.field], operator, value: filter.values })
}))
}
+8 -8
View File
@@ -1,11 +1,11 @@
import type { FilterFieldConfig } from "@/components/reui/filters"
import type { FilterField } from "@/components/reui/filters/filters-types"
import type { Server, ServerType } from "@/lib/data"
export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
export const SERVER_FILTER_FIELDS: FilterField[] = [
{
key: "country",
id: "country",
label: "Страна",
type: "multiselect",
type: "select",
searchable: true,
options: [
{ value: "RU", label: "RU" },
@@ -17,9 +17,9 @@ export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
],
},
{
key: "status",
id: "status",
label: "Статус",
type: "multiselect",
type: "select",
options: [
{ value: "online", label: "Online" },
{ value: "degraded", label: "Degraded" },
@@ -27,9 +27,9 @@ export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
],
},
{
key: "type",
id: "type",
label: "Тип",
type: "multiselect",
type: "select",
options: [
{ value: "jump-host", label: "JumpHost" },
{ value: "exit-node", label: "Exit Node" },
+44 -1
View File
@@ -5,7 +5,7 @@ function server(id: number): ServerRead {
return serverReadSchema.parse({
id, name: `Проверка ${id}`, host: `192.0.2.${id}`, port: 443,
username: "test-user", password: "fixture-only", useSsl: true, verifySsl: true,
type: "exit-node", site: "TEST", country: "RU", asn: "", comment: "",
type: "exit-node", site: "TEST", country: id === 2 ? "DE" : "RU", asn: "", comment: "",
enabled: true, lanSubnet: "", wanUplinks: [],
createdAt: "2026-09-13T00:00:00Z", updatedAt: "2026-09-13T00:00:00Z",
status: "offline", latency: null, os: "7.20", model: "Test router",
@@ -63,6 +63,49 @@ async function finalStep(page: Page) {
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Далее", exact: true }).click()
}
test("фильтры сервера: применение, AND, изменение и сброс", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await setup(page)
await page.getByRole("button", { name: "Фильтры", exact: true }).click()
await page.getByRole("option", { name: "Статус", exact: true }).click()
await page.getByRole("option", { name: "равно", exact: true }).click()
await page.getByRole("option", { name: "Online", exact: true }).click()
await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Online", exact: true }).click()
await page.getByRole("option", { name: "Offline", exact: true }).click()
await expect(page.locator('tr[data-row-id]')).toHaveCount(2)
await page.getByRole("button", { name: "Фильтры", exact: true }).click()
await page.getByRole("option", { name: "Страна", exact: true }).click()
await page.getByRole("option", { name: "равно", exact: true }).click()
await page.getByRole("option", { name: "RU", exact: true }).click()
await expect(page.locator('tr[data-row-id]')).toHaveCount(1)
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
await page.screenshot({ path: "test-results/servers-desktop.png", fullPage: true, animations: "disabled" })
await page.getByRole("button", { name: "Очистить", exact: true }).click()
await expect(page.locator('tr[data-row-id]')).toHaveCount(2)
expect(errors).toEqual([])
})
test("мобильный эталон: клавиатура, Escape и раскрытие", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await setup(page)
const filters = page.getByRole("button", { name: "Фильтры", exact: true })
await filters.focus()
await page.keyboard.press("Enter")
await expect(page.getByRole("combobox", { name: "Поиск поля…" })).toBeFocused()
await page.keyboard.press("Escape")
await expect(filters).toBeFocused()
const expand = page.getByRole("button", { name: "Раскрыть: Проверка 1", exact: true })
await expand.focus()
await page.keyboard.press("Enter")
await expect(page.getByText("Модель: Test router", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Свернуть: Проверка 1", exact: true }).press("Enter")
await expect(page.getByText("Модель: Test router", { exact: true })).toBeHidden()
await page.screenshot({ path: "test-results/servers-mobile.png", fullPage: true, animations: "disabled" })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
})
for (const status of [400, 403, 500]) {
test(`ошибка сохранения ${status} сохраняет форму; повтор завершается успешно`, async ({ page }) => {
let fail = true