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 { 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}
|
||||||
|
|||||||
@@ -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 {
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -863,4 +863,4 @@ export function useCascaderLoadState(
|
|||||||
): CascaderLoadState | null {
|
): CascaderLoadState | null {
|
||||||
const { loadStates } = useCascaderState()
|
const { loadStates } = useCascaderState()
|
||||||
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
|
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
// 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 ------------------------------ */
|
||||||
|
|
||||||
@@ -2931,4 +2940,4 @@ export type {
|
|||||||
CascaderItemState,
|
CascaderItemState,
|
||||||
CascaderRenderContextValue,
|
CascaderRenderContextValue,
|
||||||
CascaderStateContextValue,
|
CascaderStateContextValue,
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -480,4 +480,4 @@ function DataGridScrollArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { DataGridScrollArea }
|
export { DataGridScrollArea }
|
||||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||||
@@ -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) {
|
||||||
@@ -737,4 +733,4 @@ function DataGridTableDndRows<TData extends object>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||||
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ---------------------------- */
|
||||||
|
|
||||||
@@ -2078,4 +2058,4 @@ export function FiltersAdvanced<V, O>({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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])
|
||||||
|
|
||||||
@@ -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 предупреждений). Правила не отключены.
|
||||||
|
|
||||||
| Файл | Позиция | Уровень | Правило | Замечание |
|
Оставшийся виртуализатор исключается 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
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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"
|
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" },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user