fix(ui): завершить основу таблиц и фильтров серверов
This commit is contained in:
1 parent
f9a4a07996
commit
3d3c45e4d2
24 files changed
+385
-263
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: "задано",
|
||||
}
|
||||
@@ -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 })
|
||||
}))
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user