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
+378 -256

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 { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid" import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters" import { createFilterQuery } from "@/components/reui/filters/filters-query"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" import { ServerFilters } from "@/components/data-grids/server-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields" 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 { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data" import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers" import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
@@ -225,7 +226,7 @@ export default function ServersPage() {
const [_backendOk, setBackendOk] = useState(false) const [_backendOk, setBackendOk] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all") const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([]) const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery())
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add") const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
@@ -556,7 +557,7 @@ export default function ServersPage() {
const q = search.toLowerCase() const q = search.toLowerCase()
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q) 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]) }, [serverList, search, typeFilter, advancedFilters])
const counts = useMemo(() => ({ const counts = useMemo(() => ({
@@ -654,9 +655,7 @@ export default function ServersPage() {
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType], count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})), })),
}} }}
filters={advancedFilters} filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…" searchPlaceholder="Поиск по имени, хосту…"
@@ -664,6 +663,7 @@ export default function ServersPage() {
/> />
<ServersDataGrid <ServersDataGrid
servers={filtered} servers={filtered}
hasUnfilteredRows={serverList.length > 0}
isLive={isLive} isLive={isLive}
pollingIds={pollingIds} pollingIds={pollingIds}
busy={controlsBusy} 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 { interface ServersDataGridProps {
hasUnfilteredRows?: boolean
busy?: boolean busy?: boolean
servers: Server[] servers: Server[]
isLive: boolean isLive: boolean
@@ -174,6 +175,7 @@ interface ServersDataGridProps {
} }
function ServersDataGrid({ function ServersDataGrid({
hasUnfilteredRows = false,
busy = false, busy = false,
servers, servers,
isLive, isLive,
@@ -196,9 +198,14 @@ function ServersDataGrid({
const expanded = row.getIsExpanded() const expanded = row.getIsExpanded()
return ( return (
<div className="flex items-start gap-2 min-w-0"> <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 {expanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" /> ? <ChevronDownIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />} : <ChevronRightIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground/40" />}
</Button>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium truncate">{s.name}</p> <p className="font-medium truncate">{s.name}</p>
<p className="text-xs font-mono text-muted-foreground">{s.host}</p> <p className="text-xs font-mono text-muted-foreground">{s.host}</p>
@@ -374,8 +381,8 @@ function ServersDataGrid({
return ( return (
<EmptyState <EmptyState
icon={<ServerIcon className="size-4" />} icon={<ServerIcon className="size-4" />}
title="Нет серверов" title={hasUnfilteredRows ? "Нет совпадений" : "Нет серверов"}
description="Добавьте первый MikroTik-сервер для мониторинга" description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : "Добавьте первый MikroTik-сервер для мониторинга"}
className="border-0 py-12" className="border-0 py-12"
/> />
) )
@@ -32,18 +32,18 @@ function DataGridSortHeader<TData extends object>({
type="button" type="button"
className={cn( className={cn(
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground", "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, className,
)} )}
onClick={column.getToggleSortingHandler()} onClick={column.getToggleSortingHandler()}
> >
{title} {title}
{sorted === "asc" ? ( {sorted === "asc" ? (
<ArrowUpIcon className="size-3 text-foreground" /> <ArrowUpIcon aria-hidden="true" className="size-3 text-foreground" />
) : sorted === "desc" ? ( ) : 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> </button>
) )
+4 -2
View File
@@ -44,6 +44,7 @@ interface DataPageToolbarProps<T extends string = string> {
filters?: Filter[] filters?: Filter[]
onFiltersChange?: (filters: Filter[]) => void onFiltersChange?: (filters: Filter[]) => void
filterFields?: FilterFieldConfig[] filterFields?: FilterFieldConfig[]
filterControl?: ReactNode
countLabel?: string countLabel?: string
actions?: ReactNode actions?: ReactNode
className?: string className?: string
@@ -58,6 +59,7 @@ function DataPageToolbar<T extends string = string>({
filters, filters,
onFiltersChange, onFiltersChange,
filterFields, filterFields,
filterControl,
countLabel, countLabel,
actions, actions,
className, className,
@@ -72,7 +74,7 @@ function DataPageToolbar<T extends string = string>({
options={segmented.options} options={segmented.options}
/> />
)} )}
{filterFields && filters && onFiltersChange && ( {filterControl ?? (filterFields && filters && onFiltersChange && (
<Filters <Filters
filters={filters} filters={filters}
fields={filterFields} fields={filterFields}
@@ -85,7 +87,7 @@ function DataPageToolbar<T extends string = string>({
</Button> </Button>
} }
/> />
)} ))}
{onSearchChange != null && ( {onSearchChange != null && (
<InputGroup className="min-w-[220px] max-w-sm"> <InputGroup className="min-w-[220px] max-w-sm">
<InputGroupAddon> <InputGroupAddon>
+1 -1
View File
@@ -765,6 +765,7 @@ export function useCascaderLoader<T = unknown>({
const hasSearch = typeof onSearch === "function" const hasSearch = typeof onSearch === "function"
const hasResolve = typeof resolveValue === "function" const hasResolve = typeof resolveValue === "function"
const trimmed = query.trim() const trimmed = query.trim()
if ((!enabled || !trimmed) && search !== null) setSearch(null)
// Serialised so the effects key on CONTENT, not on the array identity. // Serialised so the effects key on CONTENT, not on the array identity.
const levelsKey = JSON.stringify(levels) const levelsKey = JSON.stringify(levels)
@@ -783,7 +784,6 @@ export function useCascaderLoader<T = unknown>({
if (!hasSearch) return undefined if (!hasSearch) return undefined
if (!enabled || !trimmed) { if (!enabled || !trimmed) {
abortKey(SEARCH_KEY) abortKey(SEARCH_KEY)
setSearch((prev) => (prev === null ? prev : null))
return undefined return undefined
} }
const timer = setTimeout(() => runSearch(trimmed), searchDebounce) const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
@@ -125,6 +125,7 @@ function useCascaderVirtualGutter(
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!contentElement) return if (!contentElement) return
const measure = () => {
const styles = getComputedStyle(contentElement) const styles = getComputedStyle(contentElement)
const block = Number.parseFloat(styles.paddingTop) || 0 const block = Number.parseFloat(styles.paddingTop) || 0
const inline = const inline =
@@ -134,6 +135,10 @@ function useCascaderVirtualGutter(
? previous ? previous
: { block, inline } : { block, inline }
) )
}
const observer = new ResizeObserver(measure)
observer.observe(contentElement)
return () => observer.disconnect()
}, [contentElement]) }, [contentElement])
return gutter return gutter
+31 -22
View File
@@ -925,23 +925,30 @@ function Cascader<T>({
// Remembers every selected node, so the trigger keeps a label after it // Remembers every selected node, so the trigger keeps a label after it
// leaves the loaded set (or was never in 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. // Populated during render, since the trigger reads it while rendering.
// Idempotent and prop-derived, so a discarded render is harmless. // 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) 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( const resolveNode = React.useCallback(
(nodeValue: string): CascaderNode<T> => (nodeValue: string): CascaderNode<T> =>
index.byValue.get(nodeValue) ?? index.byValue.get(nodeValue) ??
labelCacheRef.current.get(nodeValue) ?? { labelCache.get(nodeValue) ?? {
value: nodeValue, value: nodeValue,
label: nodeValue, label: nodeValue,
}, },
[index] [index, labelCache]
) )
/* ------------------------------ derived view ----------------------------- */ /* ------------------------------ derived view ----------------------------- */
@@ -1086,7 +1093,9 @@ function Cascader<T>({
const swapping = swap.active && swap.key === levelKey const swapping = swap.active && swap.key === levelKey
React.useLayoutEffect(() => { 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]) }, [swap.active])
/* ----------------------------- virtualization ---------------------------- */ /* ----------------------------- virtualization ---------------------------- */
@@ -1139,7 +1148,8 @@ function Cascader<T>({
React.useEffect(() => { React.useEffect(() => {
if (!virtualized) return if (!virtualized) return
setVirtualSyncs((count) => count + 1) const frame = requestAnimationFrame(() => setVirtualSyncs(count => count + 1))
return () => cancelAnimationFrame(frame)
}, [virtualized]) }, [virtualized])
React.useEffect(() => { React.useEffect(() => {
@@ -1669,24 +1679,23 @@ function Cascader<T>({
const state = loadStates.get(pendingNavigation.value) const state = loadStates.get(pendingNavigation.value)
if (!state || state.loading || state.error) return if (!state || state.loading || state.error) return
setPendingNavigation(null) if (!open && !inline) return
// Navigation notifies the controlled parent; dispatch only after this
if (pendingNavigation.kind === "expand") { // commit and discard the intent if the popup/context changes first.
toggleExpanded(pendingNavigation.value) let cancelled = false
return queueMicrotask(() => {
} if (cancelled) return
if (pendingNavigation.kind === "at") { setPendingNavigation(null)
goToLevelAt(pendingNavigation.value, pendingNavigation.depth) if (pendingNavigation.kind === "expand") toggleExpanded(pendingNavigation.value)
return else if (pendingNavigation.kind === "at") goToLevelAt(pendingNavigation.value, pendingNavigation.depth)
} else pushLevel(pendingNavigation.value)
pushLevel(pendingNavigation.value) })
}, [pendingNavigation, loadStates, toggleExpanded, goToLevelAt, pushLevel]) return () => { cancelled = true }
}, [pendingNavigation, loadStates, toggleExpanded, goToLevelAt, pushLevel, open, inline])
// A popup that closes mid-flight must not drill in when the answer arrives; // A popup that closes mid-flight must not drill in when the answer arrives;
// the loader aborts the request, this drops the intent. // the loader aborts the request, this drops the intent.
React.useEffect(() => { if (!open && !inline && pendingNavigation !== null) setPendingNavigation(null)
if (!open && !inline) setPendingNavigation(null)
}, [open, inline])
/* ------------------------------- selection ------------------------------ */ /* ------------------------------- selection ------------------------------ */
@@ -231,8 +231,8 @@ function DataGridScrollArea({
if (!container || !viewport) return if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) { if (!usesCustomVerticalScrollbar) {
resetMetrics() const frame = requestAnimationFrame(resetMetrics)
return return () => cancelAnimationFrame(frame)
} }
let frame = 0 let frame = 0
@@ -59,12 +59,11 @@ import {
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
useSortable, useSortable,
verticalListSortingStrategy,
type SortingStrategy, type SortingStrategy,
} from "@dnd-kit/sortable" } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities" import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table" 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 { createPortal } from "react-dom"
import { cn } from "@/lib/utils" 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 // 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. // 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 // Resolve the portal target on pickup, without a document read during
// first client render agree. A drag cannot start before hydration, so the // hydration or an extra render before the first drag.
// overlay being absent for one frame costs nothing.
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null) const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
useEffect(() => {
setPortalTarget(document.body)
}, [])
// The row being carried, plus the column widths measured off the header the // 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 // 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. // columns of its own and has to be told what they are.
@@ -464,6 +459,7 @@ function DataGridTableDndRows<TData extends object>({
} | null>(null) } | null>(null)
const pickUpRow = useCallback((id: UniqueIdentifier) => { const pickUpRow = useCallback((id: UniqueIdentifier) => {
setPortalTarget(document.body)
const container = tableContainerRef.current const container = tableContainerRef.current
const head = container?.querySelector("thead tr") const head = container?.querySelector("thead tr")
if (!container || !head) { if (!container || !head) {
@@ -58,8 +58,7 @@ import type {
Header, Header,
HeaderGroup, HeaderGroup,
Row, Row,
Table, } from "@tanstack/react-table"
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { GripVerticalIcon } from "lucide-react" import { GripVerticalIcon } from "lucide-react"
@@ -29,7 +29,7 @@ import {
hasDataGridTableRightPinnedColumns, hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table" } from "@/components/reui/data-grid/data-grid-table"
import { flexRender } from "@tanstack/react-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 { useVirtualizer } from "@tanstack/react-virtual"
import type { import type {
VirtualItem, VirtualItem,
@@ -52,6 +52,8 @@ type DataGridTableVirtualizerInstance = Virtualizer<
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end" type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
const EMPTY_VIRTUAL_ITEMS: never[] = []
interface DataGridTableColumnVirtualizerOptions { interface DataGridTableColumnVirtualizerOptions {
/** Off unless explicitly true; a column window is opt-in. */ /** Off unless explicitly true; a column window is opt-in. */
enabled?: boolean enabled?: boolean
@@ -804,17 +806,17 @@ function DataGridTableVirtual<TData extends object>({
// virtualizer's cached sizes by hand. // virtualizer's cached sizes by hand.
useEffect(() => { useEffect(() => {
if (columnVirtualizationActive) columnVirtualizer.measure() if (columnVirtualizationActive) columnVirtualizer.measure()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
columnVirtualizationActive, columnVirtualizationActive,
columnVirtualizer, columnVirtualizer,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnSizing, table.state.columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnVisibility, table.state.columnVisibility,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnOrder, table.state.columnOrder,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnPinning, table.state.columnPinning,
]) ])
@@ -834,7 +836,7 @@ function DataGridTableVirtual<TData extends object>({
const virtualItems = isVirtualizationEnabled const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems() ? virtualizer.getVirtualItems()
: [] : EMPTY_VIRTUAL_ITEMS
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0 const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef = const measureRowRef =
isVirtualizationEnabled && customMeasureElement isVirtualizationEnabled && customMeasureElement
@@ -1049,7 +1051,7 @@ function DataGridTableVirtual<TData extends object>({
// wrapper (any table state change recreates it), the layout props, and // wrapper (any table state change recreates it), the layout props, and
// the column window. A scroll frame changes none of them, so the whole // the column window. A scroll frame changes none of them, so the whole
// sortable-header subtree is reused instead of rebuilt per frame. // sortable-header subtree is reused instead of rebuilt per frame.
// eslint-disable-next-line react-hooks/exhaustive-deps
const headerNode = useMemo( const headerNode = useMemo(
() => () =>
renderHeader && ( renderHeader && (
+10 -10
View File
@@ -28,7 +28,7 @@ import type {
DataGridTableInstance, DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid" } from "@/components/reui/data-grid/data-grid"
import { flexRender, Subscribe } from "@tanstack/react-table" 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 { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -741,19 +741,19 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
props.tableLayout?.columnsResizable, props.tableLayout?.columnsResizable,
// Visibility/order/pinning change the flat header set, so a column shown // Visibility/order/pinning change the flat header set, so a column shown
// after mount must get its size variable even though sizing is untouched. // after mount must get its size variable even though sizing is untouched.
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnSizing, table.state.columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnVisibility, table.state.columnVisibility,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnOrder, table.state.columnOrder,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnPinning, table.state.columnPinning,
// A def swap can change a column's `size` without touching sizing // 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 // STATE; without this dep the CSS variables keep the old widths. For a
// consumer defining columns inline the memo degrades to per-render // consumer defining columns inline the memo degrades to per-render
// recompute, which is the safe direction. // recompute, which is the safe direction.
// eslint-disable-next-line react-hooks/exhaustive-deps
table.options.columns, table.options.columns,
]) ])
@@ -956,7 +956,7 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
function DataGridTableHeadRow({ function DataGridTableHeadRow({
children, children,
rowId, rowId: _rowId,
}: { }: {
children: ReactNode children: ReactNode
rowId: string rowId: string
@@ -1460,7 +1460,7 @@ function DataGridTableFootRowCell({
} }
function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) { function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) {
const { table, props } = useDataGrid() const { props } = useDataGrid()
return ( return (
<tr <tr
@@ -2340,7 +2340,7 @@ const MemoizedDataGridTableBodyRows = memo(
) as typeof DataGridTableBodyRows ) as typeof DataGridTableBodyRows
function DataGridTableHeader<TData extends object>() { function DataGridTableHeader<TData extends object>() {
const { table, props } = useDataGrid() const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table) const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table) const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
@@ -2415,7 +2415,7 @@ function DataGridTable<TData extends object>({
footerContent?: ReactNode footerContent?: ReactNode
renderHeader?: boolean renderHeader?: boolean
}) { }) {
const { table, props } = useDataGrid() const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table) const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table) const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
+17 -81
View File
@@ -5,8 +5,9 @@ import {
useContext, useContext,
useEffect, useEffect,
useId, useId,
useMemo, useLayoutEffect,
useRef, useRef,
useState,
} from "react" } from "react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { import {
@@ -878,25 +879,17 @@ function DataGridProvider<TData extends object>({
table: DataGridTableInstance<TData> table: DataGridTableInstance<TData>
children?: ReactNode children?: ReactNode
}) { }) {
// Latest-props ref: context reads always resolve fresh props through the // Rendering reads this snapshot; imperative sizing reads committed values.
// getter below without the memoized context value depending on unstable
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
const gridId = useId() 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 i18n = mergeDataGridI18n(props.i18n)
const i18nRef = useRef(i18n) const [sizing] = useState(() => {
i18nRef.current = i18n let committedTable = table
return {
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on controller: createDataGridAutoSizeController(() => committedTable),
// every state change. Depending on it directly would republish the context update(next: DataGridTableInstance<TData>) { committedTable = next },
// 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) useLayoutEffect(() => { sizing.update(table) }, [sizing, table])
tableRef.current = table
// Re-assert an explicit tableLayout resize mode so consumer-level useTable // Re-assert an explicit tableLayout resize mode so consumer-level useTable
// options cannot flip it back between drags. v9 makes `table.options` // 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 if (previousPageKeyRef.current === pageKey) return
previousPageKeyRef.current = pageKey previousPageKeyRef.current = pageKey
if (!cellSelectionOn || table.atoms.cellSelection == null) return if (!cellSelectionOn || table.atoms.cellSelection == null) return
tableRef.current.resetCellSelection(true) table.resetCellSelection(true)
// eslint-disable-next-line react-hooks/exhaustive-deps }, [pageKey, cellSelectionOn, table])
}, [pageKey, cellSelectionOn])
// One autoSize coordinator per table instance so split header/body viewports const value = {
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps props, table, i18n, recordCount: props.recordCount,
// stable for the life of the table, rather than on `table` itself: the isLoading: props.isLoading || false, gridId, autoSize: sizing.controller,
// 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,
]
)
return ( return (
// One React context serves every TData, but v9 declares both TFeatures and // One React context serves every TData, but v9 declares both TFeatures and
+14 -34
View File
@@ -752,19 +752,22 @@ function FilterAdvancedRowImpl<V, O>({
// user's own opens itself. CONSUMED as it is honoured: `pickerOpen` is a // 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 // 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. // closed, a keyboard trap on the one row a user creates most.
if (fieldAutoOpen && !pickerOpen && !locked) setPickerOpen(true)
React.useEffect(() => { React.useEffect(() => {
if (!fieldAutoOpen || pickerOpen) return if (!fieldAutoOpen) return
if (!locked) setPickerOpen(true)
focusStore.set({ id: rule.id, segment: "field", autoOpen: false }) focusStore.set({ id: rule.id, segment: "field", autoOpen: false })
}, [fieldAutoOpen, pickerOpen, locked, focusStore, rule.id]) }, [fieldAutoOpen, pickerOpen, locked, focusStore, rule.id])
// Re-seed on each open, so a browse that was abandoned last time does not // Re-seed on each open, so a browse that was abandoned last time does not
// decide where this one starts. // decide where this one starts.
React.useEffect(() => { const [previousPicker, setPreviousPicker] = React.useState({ open: pickerOpen, path: rule.path })
if (!pickerOpen) return if (previousPicker.open !== pickerOpen || previousPicker.path !== rule.path) {
setPickerPath(rule.path.slice(0, -1)) setPreviousPicker({ open: pickerOpen, path: rule.path })
setPickerQuery("") if (pickerOpen) {
}, [pickerOpen, rule.path]) setPickerPath(rule.path.slice(0, -1))
setPickerQuery("")
}
}
/* ------------------------------- tab stop ------------------------------- */ /* ------------------------------- tab stop ------------------------------- */
@@ -1474,7 +1477,7 @@ export function FiltersAdvancedPanel<V, O>({
const focusStore = useFilterFocusStore() const focusStore = useFilterFocusStore()
const rowStateStore = useFilterRowStateStore() const rowStateStore = useFilterRowStateStore()
// A version counter, because the store is mutable and stable by identity. // A version counter, because the store is mutable and stable by identity.
const rowStateVersion = React.useSyncExternalStore( React.useSyncExternalStore(
rowStateStore.subscribe, rowStateStore.subscribe,
() => rowStateStore.version(), () => rowStateStore.version(),
() => 0 () => 0
@@ -1525,14 +1528,8 @@ export function FiltersAdvancedPanel<V, O>({
[query, actions] [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. */ /** 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>() const visible = new Map<string, FilterIssue>()
for (const issue of issues) { for (const issue of issues) {
// CUSTOM ONLY. The built-in reasons describe a row as HALF BUILT, // 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) if (rowStateStore.has(issue.nodeId)) visible.set(issue.nodeId, issue)
} }
return visible return visible
}, [issues, rowStateStore, rowStateVersion]) })()
/** Says out loud that a VISIBLE error has appeared, which would /** Says out loud that a VISIBLE error has appeared, which would
* otherwise happen in silence. The DRAWN issues, on a RISE only. */ * 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)) actions.announce(actions.labels.issueSummary(visibleIssueMap.size))
}, [visibleIssueMap, announcementSeq, actions]) }, [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 ---------------------------- */ /* ---------------------------- focus recovery ---------------------------- */
+8 -7
View File
@@ -373,9 +373,11 @@ function FilterValueEditor<V, O>({
const actions = useFilterActions<V, O>() const actions = useFilterActions<V, O>()
const [draft, setDraft] = React.useState<V | undefined>(rule.value) 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) if (open) setDraft(rule.value)
}, [open, rule.value]) }
const options = useFilterOptions<V, O>(field, open) const options = useFilterOptions<V, O>(field, open)
// A LOOKUP, not a definition: every result is a module-level built-in or the // 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 // 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 // because a DIFFERENT chip was arrowed onto is worse than one that opened
// nothing. // nothing.
if (autoOpen && !open && !locked) setOpen(true)
React.useEffect(() => { React.useEffect(() => {
if (!autoOpen || open) return if (!autoOpen) return
if (!locked) setOpen(true)
focusStore.set({ id: rule.id, segment: "value", autoOpen: false }) focusStore.set({ id: rule.id, segment: "value", autoOpen: false })
}, [autoOpen, open, focusStore, rule.id, locked]) }, [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. */ * unmount. Clearing it in `release` would break that twin only. */
const handoff = React.useRef(false) const handoff = React.useRef(false)
const operators = actions.resolveOperators(field) const operators = actions.resolveOperators(field)
const operator = getFilterOperator(operators, rule.operator)
// Memoized because the array IS the menu's identity: the cascader rebuilds // 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. // 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 // 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 // handoff re-opens the menu on the frame Escape closes it. Consumed even
// while locked, for the reason on the value popover's copy. // while locked, for the reason on the value popover's copy.
if (autoOpen && !open && !locked) setOpen(true)
React.useEffect(() => { React.useEffect(() => {
if (!autoOpen || open) return if (!autoOpen) return
handoff.current = false handoff.current = false
if (!locked) setOpen(true)
focusStore.set({ id: rule.id, segment: "operator", autoOpen: false }) focusStore.set({ id: rule.id, segment: "operator", autoOpen: false })
}, [autoOpen, open, focusStore, rule.id, locked]) }, [autoOpen, open, focusStore, rule.id, locked])
+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 предупреждений). Правила не отключены.
| Файл | Позиция | Уровень | Правило | Замечание | Оставшийся виртуализатор исключается React Compiler из автоматической мемоизации. Его браузерную регрессию и производительность проверять при переносе использующих его экранов. Исходные общепроектные ошибки вне этого этапа перечислены в основном журнале.
| --- | --- | --- | --- | --- |
| `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/data-grid/data-grid-table-virtual.tsx` | 764:23 | `react-hooks/incompatible-library` | Compilation Skipped: Use of incompatible library |
| `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 |
+36 -2
View File
@@ -16,8 +16,8 @@
| --- | --- | --- | | --- | --- | --- |
| 0 | Исходная база, карта миграции, проверки и обзор экранов | Завершён 2026-09-13 | | 0 | Исходная база, карта миграции, проверки и обзор экранов | Завершён 2026-09-13 |
| 1 | Достоверность сохранения, удаления, включения и опроса серверов | Завершён 2026-09-13 | | 1 | Достоверность сохранения, удаления, включения и опроса серверов | Завершён 2026-09-13 |
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | В работе: основа и серверы перенесены, приёмка не завершена | | 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | Завершён 2026-09-13, с перечисленными ограничениями приёмки |
| 3 | Основные таблицы и справочники | Не начат | | 3 | Основные таблицы и справочники | Следующий, отдельное окно |
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Не начат | | 4 | Остальные таблицы, удаление старых API, общая регрессия | Не начат |
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Не начат | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Не начат |
| 6 | Навигация и дашборд | Не начат | | 6 | Навигация и дашборд | Не начат |
@@ -147,6 +147,40 @@ flowchart LR
Проверки: TypeScript всего проекта прошёл; браузерный тест v9 (12 строк, сортировка, раскрытие) прошёл. Повторный lint группы: **21 ошибка, 27 предупреждений**, актуальный компактный отчёт — `docs/reui-migration-lint.md`. Production build не повторялся после этой локальной правки; предыдущая сборка этапа прошла. Этап 2 остаётся в работе; следующий объём прежний. Проверки: 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. Текущая связка: @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" import type { Server, ServerType } from "@/lib/data"
export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [ export const SERVER_FILTER_FIELDS: FilterField[] = [
{ {
key: "country", id: "country",
label: "Страна", label: "Страна",
type: "multiselect", type: "select",
searchable: true, searchable: true,
options: [ options: [
{ value: "RU", label: "RU" }, { value: "RU", label: "RU" },
@@ -17,9 +17,9 @@ export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
], ],
}, },
{ {
key: "status", id: "status",
label: "Статус", label: "Статус",
type: "multiselect", type: "select",
options: [ options: [
{ value: "online", label: "Online" }, { value: "online", label: "Online" },
{ value: "degraded", label: "Degraded" }, { value: "degraded", label: "Degraded" },
@@ -27,9 +27,9 @@ export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
], ],
}, },
{ {
key: "type", id: "type",
label: "Тип", label: "Тип",
type: "multiselect", type: "select",
options: [ options: [
{ value: "jump-host", label: "JumpHost" }, { value: "jump-host", label: "JumpHost" },
{ value: "exit-node", label: "Exit Node" }, { value: "exit-node", label: "Exit Node" },
+44 -1
View File
@@ -5,7 +5,7 @@ function server(id: number): ServerRead {
return serverReadSchema.parse({ return serverReadSchema.parse({
id, name: `Проверка ${id}`, host: `192.0.2.${id}`, port: 443, id, name: `Проверка ${id}`, host: `192.0.2.${id}`, port: 443,
username: "test-user", password: "fixture-only", useSsl: true, verifySsl: true, 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: [], enabled: true, lanSubnet: "", wanUplinks: [],
createdAt: "2026-09-13T00:00:00Z", updatedAt: "2026-09-13T00:00:00Z", createdAt: "2026-09-13T00:00:00Z", updatedAt: "2026-09-13T00:00:00Z",
status: "offline", latency: null, os: "7.20", model: "Test router", 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() 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]) { for (const status of [400, 403, 500]) {
test(`ошибка сохранения ${status} сохраняет форму; повтор завершается успешно`, async ({ page }) => { test(`ошибка сохранения ${status} сохраняет форму; повтор завершается успешно`, async ({ page }) => {
let fail = true let fail = true