From e736465be450cdd015467951accc50f7e9c443fa Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Sun, 12 Jul 2026 13:05:36 +0300 Subject: [PATCH 1/6] optimize table rendering for large data sets --- src/components/table/body/index.js | 197 ++++++- src/components/table/body/index.test.js | 300 ++++++++++ src/components/table/body/measureElement.js | 38 ++ .../table/body/measureElement.test.js | 46 ++ src/components/table/body/row.js | 537 +++++++++++++----- src/components/table/body/row.test.js | 32 ++ .../table/body/rowMountController.js | 56 ++ .../table/body/rowMountController.test.js | 57 ++ src/components/table/createLargeDataSource.js | 229 ++++++++ .../table/createLargeDataSource.test.js | 210 +++++++ src/components/table/header/actions/index.js | 22 +- src/components/table/helpers/downloadCsv.js | 36 +- .../table/helpers/downloadCsv.test.js | 30 + src/components/table/index.d.ts | 42 +- src/components/table/index.js | 1 + src/components/table/largeData.js | 47 ++ src/components/table/largeData.test.js | 65 +++ .../table/largeDataBodyIntegration.test.js | 40 ++ src/components/table/largeDataSelection.js | 36 ++ .../table/largeDataSelection.test.js | 50 ++ src/components/table/largeDataTable.test.js | 105 ++++ src/components/table/table.js | 94 ++- src/index.js | 2 +- src/organisms/navigation/sortable/item.js | 13 +- .../navigation/sortable/item.test.js | 36 ++ 25 files changed, 2118 insertions(+), 203 deletions(-) create mode 100644 src/components/table/body/index.test.js create mode 100644 src/components/table/body/measureElement.js create mode 100644 src/components/table/body/measureElement.test.js create mode 100644 src/components/table/body/row.test.js create mode 100644 src/components/table/body/rowMountController.js create mode 100644 src/components/table/body/rowMountController.test.js create mode 100644 src/components/table/createLargeDataSource.js create mode 100644 src/components/table/createLargeDataSource.test.js create mode 100644 src/components/table/helpers/downloadCsv.test.js create mode 100644 src/components/table/largeData.js create mode 100644 src/components/table/largeData.test.js create mode 100644 src/components/table/largeDataBodyIntegration.test.js create mode 100644 src/components/table/largeDataSelection.js create mode 100644 src/components/table/largeDataSelection.test.js create mode 100644 src/components/table/largeDataTable.test.js create mode 100644 src/organisms/navigation/sortable/item.test.js diff --git a/src/components/table/body/index.js b/src/components/table/body/index.js index a5737b5e9..dde3413e2 100644 --- a/src/components/table/body/index.js +++ b/src/components/table/body/index.js @@ -1,4 +1,12 @@ -import React, { memo, useCallback, useRef, useEffect, useMemo } from "react" +import React, { + memo, + useCallback, + useRef, + useEffect, + useMemo, + useLayoutEffect, + useState, +} from "react" import { useVirtualizer, defaultRangeExtractor } from "@tanstack/react-virtual" import identity from "lodash/identity" import Flex from "@/components/templates/flex" @@ -6,9 +14,43 @@ import { useTableState } from "../provider" import Row from "./row" import Header from "./header" import RowPlaceholdersRenderer from "./rowPLaceholdersRenderer" +import { getVirtualWindowRange } from "../largeData" +import { createRowMountController } from "./rowMountController" +import { measureTableElement } from "./measureElement" + +const deferredRowScrollResetDelayMs = 75 const noop = () => {} +const DeferredRow = memo( + ({ children, controller, RowPlaceholder, index, placeholderSize }) => { + const [ready, setReady] = useState(false) + + useEffect(() => controller.schedule(() => setReady(true)), [controller]) + + if (ready) return children + return ( +
+ {RowPlaceholder ? : null} +
+ ) + }, + (previous, next) => + previous.controller === next.controller && + previous.rowKey === next.rowKey && + previous.rowOriginal === next.rowOriginal && + previous.RowPlaceholder === next.RowPlaceholder && + previous.RowWrapper === next.RowWrapper && + previous.GroupRow === next.GroupRow && + previous.onClickRow === next.onClickRow && + previous.directCellContent === next.directCellContent && + previous.placeholderSize === next.placeholderSize && + previous.index === next.index +) + const rerenderSelector = state => ({ sorting: state.sorting, sizing: state.columnSizing, @@ -35,28 +77,58 @@ const Body = memo( virtualRef, initialOffset = 0, onScroll, + onIsScrollingChange = noop, + deferRowMount = false, + DeferredRowPlaceholder, enableColumnReordering, RowPlaceholder, + RowWrapper, placeholdersLength, + largeDataSource, + windowStartIndex = 0, + onWindowChange, ...rest }) => { useTableState(rerenderSelector) const ref = useRef() + const publishedWindowRef = useRef() + const pendingWindowRef = useRef() + const rowMountController = useMemo(() => createRowMountController(), []) + + useEffect(() => () => rowMountController.dispose(), [rowMountController]) const { rows } = table.getRowModel() + const dataRowCount = largeDataSource?.getRowCount() ?? rows.length + const rowHeight = + (!!meta.styles?.height && parseInt(meta.styles.height)) || + (!!meta.cellStyles?.height && parseInt(meta.cellStyles.height)) || + 35 + const estimateSize = useCallback( + index => (index > 0 && largeDataSource?.getEstimatedRowHeight?.(index - 1)) || rowHeight, + [largeDataSource, rowHeight] + ) + const resolveItemKey = useCallback( + index => + index === 0 ? "header" : (largeDataSource?.getRowId(index - 1) ?? getItemKey(index - 1)), + [getItemKey, largeDataSource] + ) + const measureElement = useCallback( + (element, entry, instance) => measureTableElement(element, entry, instance), + [] + ) + const rowVirtualizer = useVirtualizer({ - count: rows.length ? rows.length + 1 : 1, + count: dataRowCount ? dataRowCount + 1 : 1, getScrollElement: () => ref.current, enableSmoothScroll: false, - estimateSize: () => - (!!meta.styles?.height && parseInt(meta.styles.height)) || - (!!meta.cellStyles?.height && parseInt(meta.cellStyles.height)) || - 35, + estimateSize, overscan: overscan || 15, onChange: onVirtualChange, initialOffset, - getItemKey: index => (index === 0 ? "header" : getItemKey(index - 1)), + getItemKey: resolveItemKey, + measureElement, + ...(deferRowMount ? { isScrollingResetDelay: deferredRowScrollResetDelayMs } : {}), rangeExtractor: useCallback(range => { if (range.count && range.startIndex >= 0) { const next = new Set([0, ...defaultRangeExtractor(range)]) @@ -69,7 +141,40 @@ const Body = memo( if (virtualRef) virtualRef.current = rowVirtualizer + useEffect(() => { + rowMountController.setScrolling(rowVirtualizer.isScrolling) + onIsScrollingChange(rowVirtualizer.isScrolling) + }, [onIsScrollingChange, rowMountController, rowVirtualizer.isScrolling]) + const virtualRows = rowVirtualizer.getVirtualItems() + const nextWindowRange = useMemo( + () => (largeDataSource ? getVirtualWindowRange(virtualRows, dataRowCount) : null), + [largeDataSource, virtualRows, dataRowCount] + ) + + useLayoutEffect(() => { + if (!nextWindowRange) return + + if (deferRowMount && rowVirtualizer.isScrolling) { + pendingWindowRef.current = nextWindowRange + return + } + + const windowRange = pendingWindowRef.current || nextWindowRange + pendingWindowRef.current = undefined + + const publishedWindow = publishedWindowRef.current + if ( + publishedWindow?.startIndex === windowRange.startIndex && + publishedWindow?.endIndex === windowRange.endIndex + ) { + return + } + + publishedWindowRef.current = windowRange + onWindowChange(windowRange) + }, [deferRowMount, nextWindowRange, onWindowChange, rowVirtualizer.isScrolling]) + // index 0 is reserved for the sticky header (see count = rows + 1 and rangeExtractor above) const firstVirtualDataIndex = virtualRows[1]?.index ?? 1 const lastVirtualDataIndex = virtualRows[virtualRows.length - 1]?.index ?? 0 @@ -78,7 +183,7 @@ const Body = memo( if (!RowPlaceholder) return { before: [], after: [] } const firstDataIndex = 1 - const lastDataIndex = rows.length + const lastDataIndex = dataRowCount // "before" = rows before the virtual window; capped to placeholdersLength when provided const beforeEnd = firstVirtualDataIndex @@ -98,17 +203,20 @@ const Body = memo( before: Array.from({ length: beforeEnd - beforeStart }, (_, i) => beforeStart + i), after: Array.from({ length: afterEnd - afterStart }, (_, i) => afterStart + i), } - }, [RowPlaceholder, firstVirtualDataIndex, lastVirtualDataIndex, rows.length, placeholdersLength]) + }, [ + RowPlaceholder, + firstVirtualDataIndex, + lastVirtualDataIndex, + dataRowCount, + placeholdersLength, + ]) const getPlaceholderOffset = useCallback( index => { - // measurementsCache is populated for all count items on every render - // (getTotalSize calls getMeasurements which fills the full cache). - // Entries use real sizes for measured rows and estimateSize for the rest. - const cached = rowVirtualizer.measurementsCache[index] - return cached ? cached.start : 0 + if (!largeDataSource) return rowVirtualizer.measurementsCache[index]?.start || 0 + return rowVirtualizer.getOffsetForIndex(index, "start")?.[0] || 0 }, - [rowVirtualizer] + [largeDataSource, rowVirtualizer] ) useEffect(() => { @@ -118,7 +226,7 @@ const Body = memo( if (!lastItem) return - if (lastItem.index >= rows.length && getHasNextPage() && !loading) { + if (lastItem.index >= dataRowCount && getHasNextPage() && !loading) { loadMore("backward") } }, [virtualRows, loading]) @@ -158,6 +266,36 @@ const Body = memo( getPlaceholderOffset={getPlaceholderOffset} /> {virtualRows.map(virtualRow => { + const row = + virtualRow.index === 0 + ? null + : rows[virtualRow.index - 1 - (largeDataSource ? windowStartIndex : 0)] + const logicalIndex = largeDataSource ? virtualRow.index - 1 : undefined + const rowContent = row ? ( + + ) : null + const wrappedRow = + row && RowWrapper ? ( + + {rowContent} + + ) : row ? ( + rowContent + ) : RowPlaceholder ? ( + + ) : null + return (
+ ) : deferRowMount && row ? ( + + {wrappedRow} + ) : ( - + wrappedRow )}
) diff --git a/src/components/table/body/index.test.js b/src/components/table/body/index.test.js new file mode 100644 index 000000000..23aff877e --- /dev/null +++ b/src/components/table/body/index.test.js @@ -0,0 +1,300 @@ +import React from "react" +import { renderWithProviders, screen, waitFor } from "testUtilities" +import TableProvider from "../provider" +import Body from "." +import { useVirtualizer } from "@tanstack/react-virtual" + +jest.mock("@tanstack/react-virtual", () => ({ + defaultRangeExtractor: jest.fn(() => []), + useVirtualizer: jest.fn(() => ({ + getTotalSize: () => 0, + getVirtualItems: () => [], + measureElement: jest.fn(), + measurementsCache: [], + })), +})) + +jest.mock("./row", () => ({ row }) => ( +
+)) +jest.mock("./header", () => () =>
) + +describe("Table Body large-data mode", () => { + it("keeps virtual measurement callbacks stable across equivalent renders", () => { + const largeDataSource = { + getRowCount: () => 50_000, + getRowId: index => `node-${index}`, + } + const props = { + largeDataSource, + meta: {}, + onWindowChange: jest.fn(), + table: { getRowModel: () => ({ rows: [] }) }, + } + const renderBody = dataGa => ( + + + + ) + const { rerender } = renderWithProviders(renderBody("first")) + + const firstOptions = useVirtualizer.mock.calls.at(-1)[0] + rerender(renderBody("second")) + const secondOptions = useVirtualizer.mock.calls.at(-1)[0] + + expect(secondOptions.getItemKey).toBe(firstOptions.getItemKey) + expect(secondOptions.estimateSize).toBe(firstOptions.estimateSize) + }) + + it("halves the scroll reset delay only for deferred row mounting", () => { + const props = { + largeDataSource: { getRowCount: () => 50_000, getRowId: index => `node-${index}` }, + meta: {}, + onWindowChange: jest.fn(), + table: { getRowModel: () => ({ rows: [] }) }, + } + const renderBody = deferRowMount => ( + + + + ) + const { rerender } = renderWithProviders(renderBody(false)) + + expect(useVirtualizer.mock.calls.at(-1)[0].isScrollingResetDelay).toBeUndefined() + + rerender(renderBody(true)) + + expect(useVirtualizer.mock.calls.at(-1)[0].isScrollingResetDelay).toBe(75) + }) + + it("publishes an unchanged virtual window only once", () => { + useVirtualizer.mockImplementation(() => ({ + getTotalSize: () => 1_750_000, + getVirtualItems: () => [ + { index: 1, key: "node-0", start: 35 }, + { index: 2, key: "node-1", start: 70 }, + ], + measureElement: jest.fn(), + measurementsCache: [], + })) + + const onWindowChange = jest.fn() + const largeDataSource = { + getRowCount: () => 50_000, + getRowId: index => `node-${index}`, + } + const props = { + largeDataSource, + meta: {}, + onWindowChange, + table: { getRowModel: () => ({ rows: [] }) }, + } + const { rerender } = renderWithProviders( + + + + ) + + rerender( + + + + ) + + expect(onWindowChange).toHaveBeenCalledTimes(1) + expect(onWindowChange).toHaveBeenCalledWith({ startIndex: 0, endIndex: 2 }) + }) + + it("publishes the final large-data window after deferred scrolling stops", () => { + let isScrolling = true + useVirtualizer.mockImplementation(() => ({ + getTotalSize: () => 1_750_000, + getVirtualItems: () => [ + { index: 10, key: "node-9", start: 350 }, + { index: 11, key: "node-10", start: 385 }, + ], + isScrolling, + measureElement: jest.fn(), + measurementsCache: [], + })) + + const onWindowChange = jest.fn() + const props = { + deferRowMount: true, + largeDataSource: { + getRowCount: () => 50_000, + getRowId: index => `node-${index}`, + }, + meta: {}, + onWindowChange, + table: { getRowModel: () => ({ rows: [] }) }, + } + const renderBody = dataGa => ( + + + + ) + const { rerender } = renderWithProviders(renderBody("scrolling")) + + expect(onWindowChange).not.toHaveBeenCalled() + + isScrolling = false + rerender(renderBody("idle")) + + expect(onWindowChange).toHaveBeenCalledTimes(1) + expect(onWindowChange).toHaveBeenCalledWith({ startIndex: 9, endIndex: 11 }) + }) + + it("measures large-data rows with wrapped content", () => { + const measureElement = jest.fn() + useVirtualizer.mockImplementation(() => ({ + getTotalSize: () => 70, + getVirtualItems: () => [ + { index: 0, key: "header", start: 0 }, + { index: 1, key: "node-0", start: 35 }, + ], + measureElement, + measurementsCache: [], + })) + + const largeDataSource = { + getRowCount: () => 1, + getRowId: () => "node-0", + } + + renderWithProviders( + + ({ rows: [{ id: "node-0" }] }) }} + /> + + ) + + expect(measureElement.mock.calls.some(([element]) => element?.dataset.index === "1")).toBe(true) + }) + + it("sizes a deferred placeholder from the virtual row", () => { + useVirtualizer.mockImplementation(() => ({ + getTotalSize: () => 80.4, + getVirtualItems: () => [ + { index: 0, key: "header", size: 46, start: 0 }, + { index: 1, key: "node-0", size: 34.4, start: 46 }, + ], + isScrolling: true, + measureElement: jest.fn(), + measurementsCache: [], + })) + + const { container } = renderWithProviders( + + ({ + rows: [{ id: "node-0", original: { id: "node-0" } }], + }), + }} + /> + + ) + + expect(container.querySelector('[data-index="1"] > [aria-hidden="true"]')).toHaveStyle({ + height: "34.4px", + overflow: "hidden", + width: "100%", + }) + }) + + it("wraps only data rows with the optional RowWrapper", () => { + useVirtualizer.mockImplementation(() => ({ + getTotalSize: () => 70, + getVirtualItems: () => [ + { index: 0, key: "header", start: 0 }, + { index: 1, key: "node-0", start: 35 }, + ], + isScrolling: true, + measureElement: jest.fn(), + measurementsCache: [], + })) + + const RowWrapper = ({ children, row, virtualIndex, logicalIndex }) => ( +
+ {children} +
+ ) + const onIsScrollingChange = jest.fn() + + renderWithProviders( + + 1, getRowId: () => "node-0" }} + meta={{}} + onWindowChange={jest.fn()} + table={{ getRowModel: () => ({ rows: [{ id: "node-0" }] }) }} + /> + + ) + + expect(document.querySelectorAll('[data-testid="row-wrapper"]')).toHaveLength(1) + expect(document.querySelector('[data-testid="row-wrapper"]')).toHaveAttribute( + "data-row-id", + "node-0" + ) + expect(document.querySelector('[data-testid="row-wrapper"]')).toHaveAttribute( + "data-virtual-index", + "1" + ) + expect(document.querySelector('[data-testid="row-wrapper"]')).toHaveAttribute( + "data-logical-index", + "0" + ) + expect(onIsScrollingChange).toHaveBeenCalledWith(true) + }) + + it("updates a mounted deferred row when its canonical record changes", async () => { + useVirtualizer.mockImplementation(() => ({ + getTotalSize: () => 70, + getVirtualItems: () => [ + { index: 0, key: "header", start: 0 }, + { index: 1, key: "alert-1", start: 35 }, + ], + isScrolling: false, + measureElement: jest.fn(), + measurementsCache: [], + })) + + const renderBody = value => ( + + ({ + rows: [{ id: "alert-1", original: { id: "alert-1", value } }], + }), + }} + /> + + ) + const { rerender } = renderWithProviders(renderBody("warning")) + + await waitFor(() => + expect(screen.getByTestId("row-content")).toHaveAttribute("data-row-value", "warning") + ) + + rerender(renderBody("critical")) + + expect(screen.getByTestId("row-content")).toHaveAttribute("data-row-value", "critical") + }) +}) diff --git a/src/components/table/body/measureElement.js b/src/components/table/body/measureElement.js new file mode 100644 index 000000000..700a59587 --- /dev/null +++ b/src/components/table/body/measureElement.js @@ -0,0 +1,38 @@ +const getCachedSize = (element, instance) => { + const index = instance.indexFromElement(element) + const key = instance.options.getItemKey(index) + + return { + cached: instance.itemSizeCache.get(key), + estimated: instance.options.estimateSize(index), + } +} + +const getObservedSize = (entry, horizontal) => { + const borderBoxSize = entry?.borderBoxSize + const box = Array.isArray(borderBoxSize) ? borderBoxSize[0] : borderBoxSize + + return box?.[horizontal ? "inlineSize" : "blockSize"] +} + +export const measureTableElement = (element, entry, instance) => { + const horizontal = instance.options.horizontal + + if (instance.options.useCachedMeasurements) { + const { cached, estimated } = getCachedSize(element, instance) + return cached ?? estimated + } + + const observedSize = getObservedSize(entry, horizontal) + if (observedSize) return observedSize + + if (!entry) { + const { cached } = getCachedSize(element, instance) + if (cached !== undefined) return cached + } + + const rect = element.getBoundingClientRect() + const measuredSize = horizontal ? rect.width : rect.height + + return measuredSize || instance.options.estimateSize(instance.indexFromElement(element)) +} diff --git a/src/components/table/body/measureElement.test.js b/src/components/table/body/measureElement.test.js new file mode 100644 index 000000000..b3ce1b89f --- /dev/null +++ b/src/components/table/body/measureElement.test.js @@ -0,0 +1,46 @@ +import { measureTableElement } from "./measureElement" + +const createInstance = ({ cached, estimate = 35, horizontal = false } = {}) => ({ + indexFromElement: jest.fn(() => 2), + itemSizeCache: new Map(cached === undefined ? [] : [["row-2", cached]]), + options: { + estimateSize: jest.fn(() => estimate), + getItemKey: jest.fn(index => `row-${index}`), + horizontal, + useCachedMeasurements: false, + }, +}) + +describe("measureTableElement", () => { + it("preserves a fractional ResizeObserver border-box height", () => { + const element = { getBoundingClientRect: jest.fn() } + const instance = createInstance() + const entry = { borderBoxSize: [{ blockSize: 34.4, inlineSize: 200.8 }] } + + expect(measureTableElement(element, entry, instance)).toBe(34.4) + expect(element.getBoundingClientRect).not.toHaveBeenCalled() + }) + + it("preserves a fractional bounding-client-rect fallback", () => { + const element = { getBoundingClientRect: jest.fn(() => ({ height: 52.8, width: 400.8 })) } + const instance = createInstance() + + expect(measureTableElement(element, undefined, instance)).toBe(52.8) + }) + + it("reuses a cached measurement without forcing layout", () => { + const element = { getBoundingClientRect: jest.fn() } + const instance = createInstance({ cached: 44.8 }) + + expect(measureTableElement(element, undefined, instance)).toBe(44.8) + expect(element.getBoundingClientRect).not.toHaveBeenCalled() + }) + + it("measures fractional width for a horizontal virtualizer", () => { + const element = { getBoundingClientRect: jest.fn() } + const instance = createInstance({ horizontal: true }) + const entry = { borderBoxSize: { blockSize: 34.4, inlineSize: 200.8 } } + + expect(measureTableElement(element, entry, instance)).toBe(200.8) + }) +}) diff --git a/src/components/table/body/row.js b/src/components/table/body/row.js index cf63849c3..38844e554 100644 --- a/src/components/table/body/row.js +++ b/src/components/table/body/row.js @@ -1,11 +1,19 @@ import React, { memo, useMemo, useCallback } from "react" -import styled from "styled-components" +import styled, { useTheme } from "styled-components" import Flex from "@/components/templates/flex" -import { getColor } from "@/theme" +import { getColor, getRgbColor } from "@/theme" import { useTableState } from "../provider" import getColumnFlex from "./columnFlex" -const CellGroup = ({ cell, row, table, header, testPrefix, coloredSortedColumn }) => { +const CellGroup = ({ + cell, + row, + table, + header, + testPrefix, + coloredSortedColumn, + directCellContent, +}) => { const { column } = cell const tableMeta = useMemo( @@ -30,6 +38,7 @@ const CellGroup = ({ cell, row, table, header, testPrefix, coloredSortedColumn } ...(tableMeta?.cellStyles || {}), ...(meta?.cellStyles || {}), } + const content = return ( - - - + {directCellContent ? ( + content + ) : ( + + {content} + + )} ) } +const alignment = { + start: "flex-start", + center: "center", + end: "flex-end", + baseline: "baseline", + stretch: "stretch", +} + +const getFlexStyle = value => { + if (value === true) return "1 1 auto" + if (value === false) return "0 0 auto" + if (value === "grow") return "1 0 auto" + if (value === "shrink") return "0 1 auto" + if (typeof value === "number") return `${value} 0 auto` + return value +} + +const getInlineCellStyles = (styles, theme) => { + const result = { ...styles } + + if (styles.alignItems) result.alignItems = alignment[styles.alignItems] || styles.alignItems + if (styles.justifyContent) { + result.justifyContent = alignment[styles.justifyContent] || styles.justifyContent + } + if (styles.flex !== undefined) result.flex = getFlexStyle(styles.flex) + if (typeof styles.width === "number") { + result.width = `${styles.width * theme.constants.SIZE_SUB_UNIT}px` + } + if (typeof styles.height === "number") { + result.height = `${styles.height * theme.constants.SIZE_SUB_UNIT}px` + } + if (Array.isArray(styles.padding)) { + result.padding = styles.padding + .map(value => `${value * theme.constants.SIZE_SUB_UNIT}px`) + .join(" ") + } + if (styles.background) { + result.backgroundColor = styles.backgroundOpacity + ? getRgbColor(styles.background, styles.backgroundOpacity)({ theme }) + : getColor(styles.background)({ theme }) + delete result.background + delete result.backgroundOpacity + } + + return result +} + +const DirectCellGroup = ({ cell, row, table, header, testPrefix, coloredSortedColumn, theme }) => { + const { column } = cell + const tableMeta = + typeof column.columnDef.tableMeta === "function" + ? column.columnDef.tableMeta({}, column, row.index) + : column.columnDef.tableMeta + const meta = + typeof column.columnDef.meta === "function" + ? column.columnDef.meta({}, column, row.index) + : column.columnDef.meta + const cellStyles = getInlineCellStyles( + { + ...(tableMeta?.styles || {}), + ...(meta?.styles || {}), + ...(tableMeta?.cellStyles || {}), + ...(meta?.cellStyles || {}), + }, + theme + ) + const sorted = column.getCanSort() && coloredSortedColumn && column.getIsSorted() + + return ( +
+ +
+ ) +} + const rerenderSelector = state => ({ sizing: state.columnSizing, expanded: state.expanded, @@ -69,156 +176,284 @@ const StyledRow = styled(Flex)` } ` -export default memo( - ({ - disableClickRow, - onClickRow, - row, - table, - testPrefix, - testPrefixCallback, - zIndex, - onHoverCell, - renderSubComponent, - GroupRow, - ...rest - }) => { - useTableState(rerenderSelector) - - const isClickable = useMemo(() => { - if (typeof onClickRow !== "function") return false - - return disableClickRow - ? !disableClickRow({ data: row.original, table: table, fullRow: row }) - : true - }, [row, onClickRow]) - - return ( - { - e.stopPropagation() - - if (row.getCanExpand()) { - row.getToggleExpandedHandler()(e) - } else if (isClickable) { - onClickRow({ data: row.original, table: table, fullRow: row }, e) - } - setTimeout(() => e?.target?.scrollIntoView?.({ behavior: "auto", block: "nearest" })) - }, - [isClickable, row, onClickRow] +const DirectStyledRow = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + background-color: ${getColor("mainBackground")}; + border-bottom: 1px solid ${getColor("border")}; + + &:hover, + &:hover .row-content { + background: ${getColor("mainBackgroundHover")}; + } +` + +const TableRow = ({ + disableClickRow, + onClickRow, + row: tableRow, + logicalIndex, + table, + testPrefix, + testPrefixCallback, + zIndex, + onHoverCell, + renderSubComponent, + GroupRow, + directCellContent, + ...rest +}) => { + useTableState(rerenderSelector) + const theme = useTheme() + const row = useMemo( + () => + logicalIndex == null || logicalIndex === tableRow.index + ? tableRow + : { ...tableRow, index: logicalIndex }, + [logicalIndex, tableRow] + ) + + const isClickable = useMemo(() => { + if (typeof onClickRow !== "function") return false + + return disableClickRow + ? !disableClickRow({ data: row.original, table: table, fullRow: row }) + : true + }, [row, onClickRow]) + + const onClick = useCallback( + e => { + e.stopPropagation() + + if (row.getCanExpand()) { + row.getToggleExpandedHandler()(e) + } else if (isClickable) { + onClickRow({ data: row.original, table: table, fullRow: row }, e) + } + setTimeout(() => e?.target?.scrollIntoView?.({ behavior: "auto", block: "nearest" })) + }, + [isClickable, row, onClickRow] + ) + + const rowContent = + !!GroupRow && !!row.original.isGroup ? ( + + ) : directCellContent ? ( +
+ {!!row.getLeftVisibleCells().length && ( +
+ {row.getLeftVisibleCells().map((cell, index) => ( + + ))} +
)} - cursor={isClickable ? "pointer" : "default"} - onMouseEnter={() => onHoverCell?.({ row: row.index })} - onMouseLeave={() => onHoverCell?.({ row: null })} - flex - column - background="mainBackground" - _hover={{ - background: "mainBackgroundHover", - }} - border={{ side: "bottom" }} - > - {!!GroupRow && !!row.original.isGroup ? ( - - ) : ( - - {!!row.getLeftVisibleCells().length && ( - - {row.getLeftVisibleCells().map((cell, index) => ( - - ))} - - )} - - - {row.getCenterVisibleCells().map((cell, index) => ( - - ))} - - - {!!row.getRightVisibleCells().length && ( - - {row.getRightVisibleCells().map((cell, index) => ( - - ))} - - )} +
+ {row.getCenterVisibleCells().map((cell, index) => ( + + ))} +
+ {!!row.getRightVisibleCells().length && ( +
+ {row.getRightVisibleCells().map((cell, index) => ( + + ))} +
+ )} +
+ ) : ( + + {!!row.getLeftVisibleCells().length && ( + + {row.getLeftVisibleCells().map((cell, index) => ( + + ))} )} - {renderSubComponent && row.getIsExpanded() && !row.getIsGrouped() && ( + + + {row.getCenterVisibleCells().map((cell, index) => ( + + ))} + + + {!!row.getRightVisibleCells().length && ( e.stopPropagation()} + position="sticky" + right={0} + border={{ side: "left" }} + zIndex={zIndex || 10} + basis={`${table.getRightTotalSize()}px`} + flex={false} + background="mainBackground" + _hover={{ + background: "mainBackgroundHover", + }} + rowReverse + className="row-content" > - {renderSubComponent({ data: row.original, table, fullRow: row })} + {row.getRightVisibleCells().map((cell, index) => ( + + ))} )} -
+ ) - } -) + + const RowContainer = directCellContent ? DirectStyledRow : StyledRow + + return ( + onHoverCell?.({ row: row.index })} + onMouseLeave={() => onHoverCell?.({ row: null })} + {...(!directCellContent && { + flex: true, + column: true, + background: "mainBackground", + _hover: { background: "mainBackgroundHover" }, + border: { side: "bottom" }, + })} + > + {rowContent} + {renderSubComponent && row.getIsExpanded() && !row.getIsGrouped() && ( + e.stopPropagation()} + > + {renderSubComponent({ data: row.original, table, fullRow: row })} + + )} + + ) +} + +const sameTableRow = (previous, next) => + previous === next || + (previous?.id === next?.id && + previous?.original === next?.original && + previous?.depth === next?.depth && + previous?.parentId === next?.parentId) + +export const areRowPropsEqual = (previous, next) => { + const previousKeys = Object.keys(previous) + const nextKeys = Object.keys(next) + if (previousKeys.length !== nextKeys.length) return false + + return previousKeys.every(key => + key === "row" ? sameTableRow(previous.row, next.row) : Object.is(previous[key], next[key]) + ) +} + +export default memo(TableRow, areRowPropsEqual) diff --git a/src/components/table/body/row.test.js b/src/components/table/body/row.test.js new file mode 100644 index 000000000..6569b0a67 --- /dev/null +++ b/src/components/table/body/row.test.js @@ -0,0 +1,32 @@ +import { areRowPropsEqual } from "./row" + +describe("Table row memoization", () => { + const original = { id: "node-1" } + const makeRow = overrides => ({ + id: "node-1", + original, + depth: 0, + parentId: undefined, + ...overrides, + }) + + it("keeps an overlapping logical row when only the TanStack wrapper changes", () => { + expect( + areRowPropsEqual( + { row: makeRow(), logicalIndex: 10, table: "table" }, + { row: makeRow(), logicalIndex: 10, table: "table" } + ) + ).toBe(true) + }) + + it("rerenders for changed data, hierarchy, logical position, or other props", () => { + const base = { row: makeRow(), logicalIndex: 10, table: "table" } + + expect(areRowPropsEqual(base, { ...base, row: makeRow({ original: { id: "node-1" } }) })).toBe( + false + ) + expect(areRowPropsEqual(base, { ...base, row: makeRow({ depth: 1 }) })).toBe(false) + expect(areRowPropsEqual(base, { ...base, logicalIndex: 11 })).toBe(false) + expect(areRowPropsEqual(base, { ...base, table: "next-table" })).toBe(false) + }) +}) diff --git a/src/components/table/body/rowMountController.js b/src/components/table/body/rowMountController.js new file mode 100644 index 000000000..71ff2857b --- /dev/null +++ b/src/components/table/body/rowMountController.js @@ -0,0 +1,56 @@ +const rowMountIntervalMs = 24 +const rowMountBatchSize = 3 + +export const createRowMountController = () => { + let scrolling = false + let timer = null + const scheduled = new Set() + + const flush = () => { + timer = null + if (scrolling) return + + const batch = [...scheduled].slice(0, rowMountBatchSize) + if (!batch.length) return + + batch.forEach(entry => { + scheduled.delete(entry) + entry.callback() + }) + start() + } + + const start = () => { + if (scrolling || timer !== null || !scheduled.size) return + timer = setTimeout(flush, rowMountIntervalMs) + } + + const setScrolling = next => { + const value = Boolean(next) + if (scrolling === value) return + + scrolling = value + if (scrolling && timer !== null) { + clearTimeout(timer) + timer = null + } else { + start() + } + } + + const schedule = callback => { + const entry = { callback } + scheduled.add(entry) + start() + + return () => scheduled.delete(entry) + } + + const dispose = () => { + if (timer !== null) clearTimeout(timer) + timer = null + scheduled.clear() + } + + return { dispose, isScrolling: () => scrolling, schedule, setScrolling } +} diff --git a/src/components/table/body/rowMountController.test.js b/src/components/table/body/rowMountController.test.js new file mode 100644 index 000000000..410be0891 --- /dev/null +++ b/src/components/table/body/rowMountController.test.js @@ -0,0 +1,57 @@ +import { createRowMountController } from "./rowMountController" + +describe("row mount controller", () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => jest.useRealTimers()) + + it("mounts three queued rows per interval", () => { + const controller = createRowMountController() + const first = jest.fn() + const second = jest.fn() + const third = jest.fn() + const fourth = jest.fn() + + controller.schedule(first) + controller.schedule(second) + controller.schedule(third) + controller.schedule(fourth) + + jest.advanceTimersByTime(23) + expect(first).not.toHaveBeenCalled() + + jest.advanceTimersByTime(1) + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + expect(third).toHaveBeenCalledTimes(1) + expect(fourth).not.toHaveBeenCalled() + + jest.advanceTimersByTime(24) + expect(fourth).toHaveBeenCalledTimes(1) + }) + + it("pauses queued mounts while scrolling", () => { + const controller = createRowMountController() + const callback = jest.fn() + + controller.schedule(callback) + controller.setScrolling(true) + jest.advanceTimersByTime(1_000) + expect(callback).not.toHaveBeenCalled() + + controller.setScrolling(false) + jest.advanceTimersByTime(24) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it("cancels its timer and queued mounts when disposed", () => { + const controller = createRowMountController() + const callback = jest.fn() + + controller.schedule(callback) + controller.dispose() + jest.advanceTimersByTime(1_000) + + expect(callback).not.toHaveBeenCalled() + expect(jest.getTimerCount()).toBe(0) + }) +}) diff --git a/src/components/table/createLargeDataSource.js b/src/components/table/createLargeDataSource.js new file mode 100644 index 000000000..fc1a6a460 --- /dev/null +++ b/src/components/table/createLargeDataSource.js @@ -0,0 +1,229 @@ +import { + filterFns as defaultFilterFns, + sortingFns as defaultSortingFns, +} from "@tanstack/react-table" + +const getPathValue = (value, path) => + path.split(".").reduce((current, key) => current?.[key], value) + +const getColumnsById = columns => { + const byId = new Map() + + const addColumns = items => { + items.forEach(column => { + if (column.id) byId.set(column.id, column) + if (column.columns) addColumns(column.columns) + }) + } + + addColumns(columns) + return byId +} + +const getColumnValue = (row, index, column) => { + if (column.accessorFn) return column.accessorFn(row, index) + if (column.accessorKey) return getPathValue(row, column.accessorKey) + return row?.[column.id] +} + +const getAutoSortingFn = (rows, column) => { + let isString = false + + for (let index = 10; index < rows.length; index += 1) { + const value = getColumnValue(rows[index], index, column) + if (Object.prototype.toString.call(value) === "[object Date]") { + return defaultSortingFns.datetime + } + if (typeof value !== "string") continue + isString = true + if (/([0-9]+)/.test(value)) return defaultSortingFns.alphanumeric + } + + return isString ? defaultSortingFns.text : defaultSortingFns.basic +} + +const createRowAdapter = columnsById => ({ + index: 0, + original: null, + subRows: [], + getValue(columnId) { + return getColumnValue(this.original, this.index, columnsById.get(columnId)) + }, +}) + +const getAutoFilterFn = (rows, column) => { + const value = getColumnValue(rows[0], 0, column) + + if (typeof value === "string") return defaultFilterFns.includesString + if (typeof value === "number") return defaultFilterFns.inNumberRange + if (typeof value === "boolean") return defaultFilterFns.equals + if (Array.isArray(value)) return defaultFilterFns.arrIncludes + if (value !== null && typeof value === "object") return defaultFilterFns.equals + return defaultFilterFns.weakEquals +} + +const filterRows = ({ rows, columnFilters, columnsById, filterFns }) => { + if (!columnFilters?.length) return rows + + const filters = columnFilters + .map(({ id, value }) => { + const column = columnsById.get(id) + if (!column) return null + + const filterFn = + typeof column.filterFn === "function" + ? column.filterFn + : column.filterFn === "auto" || !column.filterFn + ? getAutoFilterFn(rows, column) + : filterFns[column.filterFn] || defaultFilterFns[column.filterFn] + + return filterFn + ? { + filterFn, + id, + value: filterFn.resolveFilterValue?.(value) ?? value, + } + : null + }) + .filter(Boolean) + + if (!filters.length) return rows + + const rowAdapter = createRowAdapter(columnsById) + return rows.filter((row, index) => { + rowAdapter.index = index + rowAdapter.original = row + return filters.every(({ filterFn, id, value }) => filterFn(rowAdapter, id, value)) + }) +} + +const sortRows = ({ rows, sorting, columnsById, sortingFns }) => { + if (!sorting?.length || rows.length < 2) return rows + + const availableSorting = sorting + .map(entry => ({ entry, column: columnsById.get(entry.id) })) + .filter(({ column }) => column && column.enableSorting !== false) + + if (!availableSorting.length) return rows + + const columnInfo = availableSorting.map(({ entry, column }) => ({ + entry, + column, + sortUndefined: column.sortUndefined ?? 1, + sortingFn: + typeof column.sortingFn === "function" + ? column.sortingFn + : column.sortingFn === "auto" || !column.sortingFn + ? getAutoSortingFn(rows, column) + : sortingFns[column.sortingFn] || defaultSortingFns[column.sortingFn], + })) + const left = createRowAdapter(columnsById) + const right = createRowAdapter(columnsById) + const indexedRows = rows.map((row, index) => ({ index, row })) + + indexedRows.sort((a, b) => { + left.index = a.index + left.original = a.row + right.index = b.index + right.original = b.row + + for (let index = 0; index < columnInfo.length; index += 1) { + const { entry, column, sortUndefined, sortingFn } = columnInfo[index] + const aValue = left.getValue(entry.id) + const bValue = right.getValue(entry.id) + let result = 0 + + if (sortUndefined && (aValue === undefined || bValue === undefined)) { + if (sortUndefined === "first") return aValue === undefined ? -1 : 1 + if (sortUndefined === "last") return aValue === undefined ? 1 : -1 + result = + aValue === undefined && bValue === undefined + ? 0 + : aValue === undefined + ? sortUndefined + : -sortUndefined + } + + if (result === 0) result = sortingFn(left, right, entry.id) + if (result === 0) continue + if (entry.desc) result *= -1 + if (column.invertSorting) result *= -1 + return result + } + + return a.index - b.index + }) + + return indexedRows.map(({ row }) => row) +} + +export default ({ + columns, + columnFilters, + data, + expanded, + filterRow, + filterFns = {}, + getEstimatedRowHeight, + getRowId, + sorting, + sortingFns = {}, +}) => { + const columnsById = getColumnsById(columns) + const displayRows = [] + const displayIds = [] + const exportRows = [] + const exportIds = [] + const lastDisplayIndexById = new Map() + const nearestDisplayIndexById = new Map() + + const forEachRow = (items, callback) => { + items.forEach((row, index) => { + callback(row, getRowId(row, index)) + if (row.children?.length) forEachRow(row.children, callback) + }) + } + + const appendRows = (rows, visible, visibleAncestorIndex = -1) => { + sortRows({ rows, sorting, columnsById, sortingFns }).forEach((row, index) => { + const id = getRowId(row, index) + exportRows.push(row) + exportIds.push(id) + let displayIndex = visibleAncestorIndex + if (visible) { + displayRows.push(row) + displayIds.push(id) + displayIndex = displayRows.length - 1 + } + nearestDisplayIndexById.set(id, displayIndex) + if (row.children?.length) { + appendRows( + row.children, + visible && (expanded === true || Boolean(expanded?.[id])), + displayIndex + ) + } + if (visible) lastDisplayIndexById.set(id, displayRows.length - 1) + }) + } + + const columnFilteredRows = filterRows({ rows: data, columnFilters, columnsById, filterFns }) + appendRows(filterRow ? columnFilteredRows.filter(filterRow) : columnFilteredRows, true) + + const displayIndexById = new Map(displayIds.map((id, index) => [id, index])) + + return { + forEachExportRow: callback => + exportRows.forEach((row, index) => callback(row, exportIds[index])), + forEachRow: callback => forEachRow(data, callback), + getDisplayIndex: (id, { leaf = false } = {}) => + (leaf ? lastDisplayIndexById : displayIndexById).get(id) ?? + nearestDisplayIndexById.get(id) ?? + -1, + getEstimatedRowHeight: index => getEstimatedRowHeight?.(displayRows[index], index), + getFlatRowCount: () => exportRows.length, + getRow: index => displayRows[index], + getRowCount: () => displayRows.length, + getRowId: index => displayIds[index], + } +} diff --git a/src/components/table/createLargeDataSource.test.js b/src/components/table/createLargeDataSource.test.js new file mode 100644 index 000000000..b792541c0 --- /dev/null +++ b/src/components/table/createLargeDataSource.test.js @@ -0,0 +1,210 @@ +import createLargeDataSource from "./createLargeDataSource" +import { + createTable, + getCoreRowModel, + getExpandedRowModel, + getSortedRowModel, +} from "@tanstack/react-table" + +const columns = [ + { id: "name", accessorKey: "name", sortingFn: "basic" }, + { + id: "score", + accessorKey: "score", + sortingFn: (left, right) => left.original.score - right.original.score, + }, +] + +const data = [ + { + id: "group-b", + name: "Group B", + isGroup: true, + children: [ + { id: "node-b", name: "Beta", score: 2 }, + { id: "node-a", name: "Alpha", score: 1 }, + ], + }, + { id: "node-c", name: "Charlie", score: 3 }, +] + +describe("createLargeDataSource", () => { + it("owns complete sorting and expansion while exposing a flat display index", () => { + const source = createLargeDataSource({ + columns, + data, + expanded: { "group-b": true }, + getRowId: row => row.id, + sorting: [{ id: "name", desc: false }], + }) + + expect(source.getRowCount()).toBe(4) + expect(Array.from({ length: 4 }, (_, index) => source.getRowId(index))).toEqual([ + "node-c", + "group-b", + "node-a", + "node-b", + ]) + expect(source.getDisplayIndex("node-a")).toBe(2) + expect(source.getDisplayIndex("group-b", { leaf: true })).toBe(3) + }) + + it("keeps collapsed descendants available to complete export", () => { + const source = createLargeDataSource({ + columns, + data, + expanded: {}, + getRowId: row => row.id, + sorting: [{ id: "score", desc: true }], + }) + const exported = [] + + source.forEachExportRow(row => exported.push(row.id)) + + expect(source.getRowCount()).toBe(2) + expect(source.getDisplayIndex("node-a")).toBe(0) + expect(exported).toEqual(["group-b", "node-b", "node-a", "node-c"]) + }) + + it("matches TanStack sorting, expansion, and flat export order", () => { + const sorting = [ + { id: "name", desc: false }, + { id: "score", desc: true }, + ] + const expanded = { "group-b": true } + const table = createTable({ + columns, + data, + expanded, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowId: row => row.id, + getSortedRowModel: getSortedRowModel(), + getSubRows: row => row.children, + onStateChange: () => {}, + renderFallbackValue: null, + state: { expanded, sorting }, + }) + const source = createLargeDataSource({ + columns, + data, + expanded, + getRowId: row => row.id, + sorting, + }) + const exported = [] + source.forEachExportRow(row => exported.push(row.id)) + + expect( + Array.from({ length: source.getRowCount() }, (_, index) => source.getRowId(index)) + ).toEqual(table.getRowModel().rows.map(row => row.id)) + expect(exported).toEqual(table.getRowModel().flatRows.map(row => row.id)) + }) + + it("matches TanStack default undefined-value ordering", () => { + const sparseData = [ + { id: "missing" }, + { id: "beta", name: "Beta" }, + { id: "alpha", name: "Alpha" }, + ] + const sorting = [{ id: "name", desc: false }] + const sparseColumns = [{ id: "name", accessorKey: "name", sortingFn: "alphanumeric" }] + const source = createLargeDataSource({ + columns: sparseColumns, + data: sparseData, + getRowId: row => row.id, + sorting, + }) + const table = createTable({ + columns: sparseColumns, + data: sparseData, + getCoreRowModel: getCoreRowModel(), + getRowId: row => row.id, + getSortedRowModel: getSortedRowModel(), + onStateChange: () => {}, + renderFallbackValue: null, + state: { sorting }, + }) + + const tableIds = table.getRowModel().rows.map(row => row.id) + const sourceIds = Array.from({ length: source.getRowCount() }, (_, index) => + source.getRowId(index) + ) + + expect(tableIds).toEqual(["alpha", "beta", "missing"]) + expect(sourceIds).toEqual(tableIds) + }) + + it("keeps complete row identity while filtering display and export rows", () => { + const source = createLargeDataSource({ + columns, + data: [ + { id: "hidden", name: "Hidden", score: 1 }, + { id: "visible", name: "Visible", score: 2 }, + ], + filterRow: row => row.id === "visible", + getRowId: row => row.id, + }) + const allIds = [] + const exportIds = [] + + source.forEachRow((_, id) => allIds.push(id)) + source.forEachExportRow((_, id) => exportIds.push(id)) + + expect(allIds).toEqual(["hidden", "visible"]) + expect(exportIds).toEqual(["visible"]) + expect(source.getFlatRowCount()).toBe(1) + expect(source.getRowCount()).toBe(1) + }) + + it("applies existing column filter predicates before sorting and publication", () => { + const source = createLargeDataSource({ + columns: [ + { + id: "state", + accessorKey: "state", + filterFn: (row, id, values) => values.includes(row.getValue(id)), + }, + ], + columnFilters: [{ id: "state", value: ["live"] }], + data: [ + { id: "stale", state: "stale" }, + { id: "live", state: "live" }, + ], + getRowId: row => row.id, + }) + + expect(source.getRowCount()).toBe(1) + expect(source.getRowId(0)).toBe("live") + }) + + it("matches TanStack automatic column filtering", () => { + const source = createLargeDataSource({ + columns: [{ id: "name", accessorKey: "name" }], + columnFilters: [{ id: "name", value: "alp" }], + data: [ + { id: "alpha", name: "Alpha" }, + { id: "beta", name: "Beta" }, + ], + getRowId: row => row.id, + }) + + expect(source.getRowCount()).toBe(1) + expect(source.getRowId(0)).toBe("alpha") + }) + + it("uses array membership filtering for array-valued columns", () => { + const source = createLargeDataSource({ + columns: [{ id: "roles", accessorKey: "roles" }], + columnFilters: [{ id: "roles", value: "admin" }], + data: [ + { id: "admin", roles: ["admin", "viewer"] }, + { id: "viewer", roles: ["viewer"] }, + ], + getRowId: row => row.id, + }) + + expect(source.getRowCount()).toBe(1) + expect(source.getRowId(0)).toBe("admin") + }) +}) diff --git a/src/components/table/header/actions/index.js b/src/components/table/header/actions/index.js index 8051f1349..e133aedb0 100644 --- a/src/components/table/header/actions/index.js +++ b/src/components/table/header/actions/index.js @@ -14,18 +14,18 @@ const useSelectedRowsObserver = (table, { onRowSelected = noop, rowSelection }) const [selectedRows, setActualSelectedRows] = useState([]) useEffect(() => { - const { flatRows } = table.getSelectedRowModel() - if (flatRows) { - const selected = flatRows.reduce((acc, { original }) => { - if (original?.disabled || original?.unselectable) return acc + const selected = table.getSelectedOriginalRows + ? table.getSelectedOriginalRows() + : table.getSelectedRowModel().flatRows.reduce((acc, { original }) => { + if (original?.disabled || original?.unselectable) return acc - acc.push(original) - return acc - }, []) - setActualSelectedRows(selected) - onRowSelected(selected) - } - }, [rowSelection, table]) + acc.push(original) + return acc + }, []) + + setActualSelectedRows(selected) + onRowSelected(selected) + }, [rowSelection, table, table.largeDataSource]) return selectedRows } diff --git a/src/components/table/helpers/downloadCsv.js b/src/components/table/helpers/downloadCsv.js index a063d0726..679ff42c2 100644 --- a/src/components/table/helpers/downloadCsv.js +++ b/src/components/table/helpers/downloadCsv.js @@ -15,6 +15,28 @@ const escapeForCSV = value => { const convertToCSV = data => data.reduce((h, row) => h + row.map(v => escapeForCSV(formatValue(v))).join(",") + "\n", "") +const getPathValue = (value, path) => + path.split(".").reduce((current, key) => current?.[key], value) + +const createExportRow = (original, index, headers, columnsById) => { + const row = { + index, + original, + getValue: columnId => { + const column = columnsById.get(columnId) + const { accessorFn, accessorKey } = column?.columnDef || {} + if (accessorFn) return accessorFn(original, index) + if (accessorKey) return getPathValue(original, accessorKey) + return original?.[columnId] + }, + } + + row.getAllCells = () => + headers.map(header => ({ column: header.column, row, getValue: () => row.getValue(header.id) })) + + return row +} + export default (name = "netdata") => (_, table) => { const headers = table @@ -22,6 +44,7 @@ export default (name = "netdata") => .filter( header => !header.subHeaders?.length && header.id !== "checkbox" && header.id !== "actions" ) + const columnsById = new Map(headers.map(header => [header.id, header.column])) let data = [ headers.map(header => { @@ -39,7 +62,7 @@ export default (name = "netdata") => return parentLabel ? `${parentLabel} ${label}` : label }), ] - table.getRowModel().rows.forEach(row => + const appendRow = row => data.push( headers.map(header => { const value = row.getValue(header.id) @@ -53,7 +76,16 @@ export default (name = "netdata") => return header.column.columnDef.renderString(cell.row) }) ) - ) + + if (table.forEachExportRow) { + let index = 0 + table.forEachExportRow(original => { + appendRow(createExportRow(original, index, headers, columnsById)) + index += 1 + }) + } else { + table.getRowModel().rows.forEach(appendRow) + } const url = window.URL.createObjectURL( new Blob([convertToCSV(data)], { type: "text/csv;charset=utf-8;" }) diff --git a/src/components/table/helpers/downloadCsv.test.js b/src/components/table/helpers/downloadCsv.test.js new file mode 100644 index 000000000..69a4b7680 --- /dev/null +++ b/src/components/table/helpers/downloadCsv.test.js @@ -0,0 +1,30 @@ +import downloadCsvAction from "./downloadCsv" + +describe("downloadCsvAction", () => { + beforeEach(() => { + window.URL.createObjectURL = jest.fn(() => "blob:csv") + HTMLAnchorElement.prototype.click = jest.fn() + }) + + it("exports every logical row without requesting the rendered row model", () => { + const rows = [ + { id: "a", name: "Alpha" }, + { id: "b", name: "Beta" }, + ] + const column = { + id: "name", + parent: null, + columnDef: { accessorKey: "name", headerString: () => "Name" }, + } + const table = { + forEachExportRow: callback => rows.forEach(callback), + getFlatHeaders: () => [{ id: "name", subHeaders: [], column }], + getRowModel: jest.fn(), + } + + downloadCsvAction("nodes")(null, table) + + expect(table.getRowModel).not.toHaveBeenCalled() + expect(window.URL.createObjectURL).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/table/index.d.ts b/src/components/table/index.d.ts index 8fded3d31..729526e70 100644 --- a/src/components/table/index.d.ts +++ b/src/components/table/index.d.ts @@ -1,16 +1,30 @@ import { ColumnDef, FilterFnOption, + ColumnFiltersState, PaginationState, ColumnPinningState, VisibilityTableState, Table, + Row, } from "@tanstack/table-core" +import { ComponentType, ReactNode } from "react" import { supportedBulkActions } from "./header/actions/useActions" import { supportedRowActions } from "./useColumns/useRowActions" type NetdataCoreColumns = Pick, "id" | "header" | "cell" | "filterFn"> +type LargeDataSource = { + forEachExportRow?: (callback: (row: D, id: string) => void) => void + forEachRow?: (callback: (row: D, id: string) => void) => void + getDisplayIndex?: (rowId: string, options?: { leaf?: boolean }) => number + getEstimatedRowHeight?: (index: number) => number | undefined + getFlatRowCount?: () => number + getRow: (index: number) => D + getRowCount: () => number + getRowId: (index: number) => string +} + export type TableProps = { data: Array dataColumns: Array> @@ -57,14 +71,40 @@ export type TableProps = { onClickRow?: (value: any) => void onHoverCell?: (value: any) => void disableClickRow?: (value: any) => void + RowWrapper?: ComponentType<{ + children: ReactNode + row: Row + virtualIndex: number + logicalIndex?: number + }> /**This is an escape hatch test id generator, we use this when we want to have * dynamic generator tesids depending on the row values */ testPrefixCallback?: (rowData: D) => string + largeDataOptions?: { + enabled?: boolean + filterRow?: (row: D, globalFilter: any) => boolean + getEstimatedRowHeight?: (row: D, index: number) => number | undefined + initialRowCount?: number + sortingFns?: Record + source?: LargeDataSource + } } declare const Table: (props: TableProps) => JSX.Element +declare const createLargeDataSource: (options: { + columns: Array + columnFilters?: ColumnFiltersState + data: Array + expanded?: boolean | Record + filterFns?: Record + filterRow?: (row: D, index: number) => boolean + getEstimatedRowHeight?: (row: D, index: number) => number | undefined + getRowId: (row: D, index: number) => string + sorting?: Array<{ id: string; desc?: boolean }> + sortingFns?: Record +}) => LargeDataSource -export { Table } +export { Table, createLargeDataSource } export default Table diff --git a/src/components/table/index.js b/src/components/table/index.js index afab4697c..afdd73a1a 100644 --- a/src/components/table/index.js +++ b/src/components/table/index.js @@ -1,2 +1,3 @@ export { default as Table } from "./table" export { default as downloadCsvAction } from "./helpers/downloadCsv" +export { default as createLargeDataSource } from "./createLargeDataSource" diff --git a/src/components/table/largeData.js b/src/components/table/largeData.js new file mode 100644 index 000000000..b6cea38f6 --- /dev/null +++ b/src/components/table/largeData.js @@ -0,0 +1,47 @@ +const clamp = (value, minimum, maximum) => Math.min(Math.max(value, minimum), maximum) + +const normalizeRange = ({ startIndex = 0, endIndex = startIndex }, count) => { + const start = clamp(startIndex, 0, count) + const end = clamp(Math.max(start, endIndex), start, count) + + return { startIndex: start, endIndex: end } +} + +export const containsWindowRange = (windowRange, range) => + range.startIndex >= windowRange.startIndex && range.endIndex <= windowRange.endIndex + +export const getBufferedWindowRange = (range, count, buffer = 50) => + normalizeRange( + { + startIndex: range.startIndex - buffer, + endIndex: range.endIndex + buffer, + }, + count + ) + +export const getVirtualWindowRange = (virtualItems, count) => { + let startIndex + let endIndex + + virtualItems.forEach(item => { + if (item.index === 0) return + const index = item.index - 1 + startIndex = startIndex === undefined ? index : Math.min(startIndex, index) + endIndex = endIndex === undefined ? index + 1 : Math.max(endIndex, index + 1) + }) + + return normalizeRange({ startIndex, endIndex }, count) +} + +export const getWindowPublication = (source, range) => { + const normalized = normalizeRange(range, source.getRowCount()) + const rows = [] + const rowIds = [] + + for (let index = normalized.startIndex; index < normalized.endIndex; index += 1) { + rows.push(source.getRow(index)) + rowIds.push(source.getRowId(index)) + } + + return { ...normalized, rows, rowIds } +} diff --git a/src/components/table/largeData.test.js b/src/components/table/largeData.test.js new file mode 100644 index 000000000..2f132b1b0 --- /dev/null +++ b/src/components/table/largeData.test.js @@ -0,0 +1,65 @@ +import { + containsWindowRange, + getBufferedWindowRange, + getVirtualWindowRange, + getWindowPublication, +} from "./largeData" + +describe("large-data table window", () => { + it("keeps a virtual range inside its current materialized window", () => { + expect( + containsWindowRange({ startIndex: 100, endIndex: 200 }, { startIndex: 120, endIndex: 180 }) + ).toBe(true) + expect( + containsWindowRange({ startIndex: 100, endIndex: 200 }, { startIndex: 80, endIndex: 180 }) + ).toBe(false) + }) + + it("buffers a new materialized window within the logical row count", () => { + expect(getBufferedWindowRange({ startIndex: 100, endIndex: 130 }, 50_000)).toEqual({ + startIndex: 50, + endIndex: 180, + }) + expect(getBufferedWindowRange({ startIndex: 49_990, endIndex: 50_000 }, 50_000)).toEqual({ + startIndex: 49_940, + endIndex: 50_000, + }) + }) + + it("publishes only the requested rows from a 50k logical source", () => { + const getRow = jest.fn(index => ({ id: `node-${index}` })) + const source = { + getRowCount: () => 50_000, + getRow, + getRowId: index => `node-${index}`, + } + + const publication = getWindowPublication(source, { + startIndex: 12_345, + endIndex: 12_375, + }) + + expect(publication.startIndex).toBe(12_345) + expect(publication.rows).toHaveLength(30) + expect(publication.rowIds).toEqual( + Array.from({ length: 30 }, (_, index) => `node-${12_345 + index}`) + ) + expect(getRow).toHaveBeenCalledTimes(30) + }) + + it("uses the complete virtual range instead of classifying range overlap", () => { + const virtualItems = [{ index: 0 }, { index: 20_001 }, { index: 20_002 }, { index: 20_003 }] + + expect(getVirtualWindowRange(virtualItems, 50_000)).toEqual({ + startIndex: 20_000, + endIndex: 20_003, + }) + }) + + it("clamps stale virtual indexes when the logical source shrinks", () => { + expect(getVirtualWindowRange([{ index: 0 }, { index: 10 }], 5)).toEqual({ + startIndex: 5, + endIndex: 5, + }) + }) +}) diff --git a/src/components/table/largeDataBodyIntegration.test.js b/src/components/table/largeDataBodyIntegration.test.js new file mode 100644 index 000000000..04c0a559e --- /dev/null +++ b/src/components/table/largeDataBodyIntegration.test.js @@ -0,0 +1,40 @@ +import React from "react" +import { renderWithProviders, waitFor } from "testUtilities" +import Table from "./table" + +const columns = [ + { + id: "name", + accessorKey: "name", + header: "Name", + cell: ({ getValue }) => getValue(), + }, +] + +const getRowId = row => row.id +const largeDataOptions = { enabled: true } + +describe("Table large-data body integration", () => { + it("settles after publishing the initial virtual window", async () => { + const data = Array.from({ length: 50_000 }, (_, index) => ({ + id: `node-${index}`, + name: `Node ${index}`, + })) + + const { getByTestId } = renderWithProviders( +
+ + + ) + + await waitFor(() => expect(getByTestId("netdata-table")).toBeInTheDocument()) + }) +}) diff --git a/src/components/table/largeDataSelection.js b/src/components/table/largeDataSelection.js new file mode 100644 index 000000000..22687702a --- /dev/null +++ b/src/components/table/largeDataSelection.js @@ -0,0 +1,36 @@ +export const getSelectedOriginalRows = (source, rowSelection) => { + if (!Object.keys(rowSelection).length) return [] + + const rows = [] + source.forEachRow((row, id) => { + if (rowSelection[id] && !row?.disabled && !row?.unselectable) rows.push(row) + }) + return rows +} + +export const getIsAllRowsSelected = (source, rowSelection) => { + if (!source.getFlatRowCount() || !Object.keys(rowSelection).length) return false + + let allSelected = true + source.forEachExportRow((_, id) => { + if (!rowSelection[id]) allSelected = false + }) + return allSelected +} + +export const getIsSomeRowsSelected = (source, rowSelection) => { + const selectedCount = Object.keys(rowSelection).length + return selectedCount > 0 && selectedCount < source.getFlatRowCount() +} + +export const getNextRowSelection = (source, rowSelection, value) => { + const selected = value ?? !getIsAllRowsSelected(source, rowSelection) + const next = { ...rowSelection } + + source.forEachExportRow((_, id) => { + if (selected) next[id] = true + else delete next[id] + }) + + return next +} diff --git a/src/components/table/largeDataSelection.test.js b/src/components/table/largeDataSelection.test.js new file mode 100644 index 000000000..0ac0f9725 --- /dev/null +++ b/src/components/table/largeDataSelection.test.js @@ -0,0 +1,50 @@ +import createLargeDataSource from "./createLargeDataSource" +import { + getIsAllRowsSelected, + getIsSomeRowsSelected, + getNextRowSelection, + getSelectedOriginalRows, +} from "./largeDataSelection" + +const rows = [ + { id: "outside", name: "Outside" }, + { id: "enabled", name: "Enabled" }, + { id: "disabled", name: "Disabled", disabled: true }, +] + +const source = createLargeDataSource({ + columns: [{ id: "name", accessorKey: "name" }], + data: rows, + filterRow: row => row.id !== "outside", + getRowId: row => row.id, +}) + +describe("large-data row selection", () => { + it("resolves offscreen selections in core-row order and excludes disabled rows", () => { + expect( + getSelectedOriginalRows(source, { disabled: true, outside: true, enabled: true }) + ).toEqual([rows[0], rows[1]]) + }) + + it("evaluates all and some against the complete filtered result", () => { + expect(getIsAllRowsSelected(source, { outside: true })).toBe(false) + expect(getIsSomeRowsSelected(source, { outside: true })).toBe(true) + expect(getIsAllRowsSelected(source, { outside: true, enabled: true, disabled: true })).toBe( + true + ) + expect(getIsSomeRowsSelected(source, { outside: true, enabled: true, disabled: true })).toBe( + false + ) + }) + + it("toggles only the filtered result and preserves selections outside it", () => { + expect(getNextRowSelection(source, { outside: true }, true)).toEqual({ + outside: true, + enabled: true, + disabled: true, + }) + expect( + getNextRowSelection(source, { outside: true, enabled: true, disabled: true }, false) + ).toEqual({ outside: true }) + }) +}) diff --git a/src/components/table/largeDataTable.test.js b/src/components/table/largeDataTable.test.js new file mode 100644 index 000000000..f1764af46 --- /dev/null +++ b/src/components/table/largeDataTable.test.js @@ -0,0 +1,105 @@ +import React from "react" +import { act, renderWithProviders, waitFor } from "testUtilities" +import Table from "./table" + +jest.mock("./body", () => { + const ReactModule = require("react") + const MockBody = props => { + const { onWindowChange, table } = props + const rows = table.getRowModel().rows + + ReactModule.useEffect(() => { + const rowCount = props.largeDataSource?.getRowCount() || rows.length + const startIndex = Math.min(10_000, Math.max(0, rowCount - 30)) + onWindowChange({ startIndex, endIndex: Math.min(rowCount, startIndex + 30) }) + }, [onWindowChange, props.largeDataSource, rows.length]) + + return ( +
+ ) + } + + return { __esModule: true, default: MockBody } +}) + +describe("Table large-data mode", () => { + it("gives TanStack Table only the active logical window", async () => { + const getRow = jest.fn(index => ({ id: `node-${index}`, name: `Node ${index}` })) + const source = { + getRowCount: () => 50_000, + getRow, + getRowId: index => `node-${index}`, + } + + const { getByTestId } = renderWithProviders( +
null }]} + largeDataOptions={{ source }} + /> + ) + + await waitFor(() => { + expect(getByTestId("large-data-body")).toHaveAttribute("data-row-count", "130") + expect(getByTestId("large-data-body")).toHaveAttribute("data-first-row", "node-9950") + }) + + expect(getRow).toHaveBeenCalledTimes(180) + }) + + it("owns filtering and whole-result selection outside the visible window", async () => { + const data = [ + { id: "outside", name: "Outside" }, + { id: "enabled", name: "Inside" }, + { id: "disabled", name: "Inside disabled", disabled: true }, + ] + const tableRef = { current: null } + const onRowSelected = jest.fn() + + renderWithProviders( +
null, + filterFn: (row, id, value) => row.getValue(id).toLowerCase().includes(value), + }, + ]} + enableColumnVisibility + enableSelection + getRowId={row => row.id} + globalFilter="inside" + largeDataOptions={{ + enabled: true, + filterRow: (row, value) => row.name.toLowerCase().includes(value), + }} + onRowSelected={onRowSelected} + rowSelection={{ outside: true }} + tableRef={tableRef} + /> + ) + + await waitFor(() => expect(tableRef.current?.largeDataSource.getRowCount()).toBe(2)) + + act(() => tableRef.current.getColumn("name").setFilterValue("disabled")) + await waitFor(() => expect(tableRef.current.largeDataSource.getRowCount()).toBe(1)) + act(() => tableRef.current.getColumn("name").setFilterValue(undefined)) + await waitFor(() => expect(tableRef.current.largeDataSource.getRowCount()).toBe(2)) + + act(() => tableRef.current.toggleAllRowsSelected(true)) + + await waitFor(() => expect(onRowSelected).toHaveBeenLastCalledWith([data[0], data[1]])) + expect(tableRef.current.getIsAllRowsSelected()).toBe(true) + + act(() => tableRef.current.toggleAllRowsSelected(false)) + + await waitFor(() => expect(onRowSelected).toHaveBeenLastCalledWith([data[0]])) + }) +}) diff --git a/src/components/table/table.js b/src/components/table/table.js index be9b6ed84..992d880fd 100644 --- a/src/components/table/table.js +++ b/src/components/table/table.js @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react" +import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react" import { getCoreRowModel, getFilteredRowModel, @@ -32,6 +32,14 @@ import useSelecting from "./useSelecting" import useSorting from "./useSorting" import useGrouping from "./useGrouping" import useColumnOrder from "./useColumnOrder" +import { containsWindowRange, getBufferedWindowRange, getWindowPublication } from "./largeData" +import createLargeDataSource from "./createLargeDataSource" +import { + getIsAllRowsSelected, + getIsSomeRowsSelected, + getNextRowSelection, + getSelectedOriginalRows, +} from "./largeDataSelection" const noop = () => {} const emptyObj = {} @@ -127,6 +135,7 @@ const Table = memo(props => { meta: tableMeta = tableDefaultProps.meta, title, virtualizeOptions = tableDefaultProps.virtualizeOptions, + largeDataOptions, tableRef, className, width, @@ -136,6 +145,13 @@ const Table = memo(props => { ...rest } = { ...tableDefaultProps, ...props } + const [largeDataRange, setLargeDataRange] = useState(() => ({ + startIndex: 0, + endIndex: largeDataOptions?.initialRowCount || 50, + })) + const largeDataRangeRef = useRef(largeDataRange) + largeDataRangeRef.current = largeDataRange + const [columnVisibility, onColumnVisibilityChange] = useVisibility( defaultColumnVisibility, visibilityChangeCb @@ -161,6 +177,7 @@ const Table = memo(props => { const [globalFilter, onGlobalFilterChange] = useSearching(defaultGlobalFilter, onSearch) const [columnOrder, onColumnOrderChange] = useColumnOrder(defaultColumnOrder, columnOrderChangeCb) + const [largeDataColumnFilters, setLargeDataColumnFilters] = useState([]) const columns = useColumns(dataColumns, { testPrefix, @@ -172,9 +189,54 @@ const Table = memo(props => { tableMeta, }) + const largeDataSource = useMemo(() => { + if (largeDataOptions?.source) return largeDataOptions.source + if (!largeDataOptions?.enabled) return null + if (!getRowId) throw new Error("Large-data Table requires getRowId") + + const filterRow = largeDataOptions.filterRow + + return createLargeDataSource({ + columns: dataColumns, + columnFilters: largeDataColumnFilters, + data, + expanded, + filterRow: globalFilter && filterRow ? row => filterRow(row, globalFilter) : undefined, + filterFns, + getEstimatedRowHeight: largeDataOptions.getEstimatedRowHeight, + getRowId, + sorting, + sortingFns: largeDataOptions.sortingFns, + }) + }, [ + data, + dataColumns, + expanded, + getRowId, + globalFilter, + largeDataColumnFilters, + largeDataOptions, + sorting, + ]) + const largeDataPublication = useMemo( + () => (largeDataSource ? getWindowPublication(largeDataSource, largeDataRange) : null), + [largeDataSource, largeDataRange] + ) + const handleLargeDataRangeChange = useCallback( + nextRange => { + if (containsWindowRange(largeDataRangeRef.current, nextRange)) return + + const bufferedRange = getBufferedWindowRange(nextRange, largeDataSource.getRowCount()) + largeDataRangeRef.current = bufferedRange + setLargeDataRange(bufferedRange) + }, + [largeDataSource] + ) + const tableData = largeDataPublication?.rows || data + const table = useReactTable({ columns, - data, + data: tableData, manualPagination: !enablePagination, columnResizeMode: "onEnd", filterFns, @@ -195,6 +257,7 @@ const Table = memo(props => { [grouping] ), columnOrder, + ...(largeDataSource ? { columnFilters: largeDataColumnFilters } : {}), }, onExpandedChange, ...(!enableCustomSearch && globalFilterFn ? { globalFilterFn } : {}), @@ -203,7 +266,10 @@ const Table = memo(props => { onRowSelectionChange, onGlobalFilterChange: enableCustomSearch ? undefined : onGlobalFilterChange, onSortingChange, - manualSorting, + manualSorting: Boolean(largeDataSource) || manualSorting, + manualFiltering: Boolean(largeDataSource), + manualGrouping: Boolean(largeDataSource), + manualExpanding: Boolean(largeDataSource), enableMultiSorting: true, isMultiSortEvent: e => e.ctrlKey || e.shiftKey || e.metaKey, getSortedRowModel: getSortedRowModel(), @@ -212,17 +278,32 @@ const Table = memo(props => { getRowCanExpand, autoResetExpanded, getGroupedRowModel: getGroupedRowModel(), - getSubRows: useCallback(row => row.children, []), + getSubRows: useCallback(row => (largeDataSource ? undefined : row.children), [largeDataSource]), onPaginationChange, onColumnVisibilityChange, onColumnSizingChange, onColumnPinningChange, onColumnOrderChange, + ...(largeDataSource ? { onColumnFiltersChange: setLargeDataColumnFilters } : {}), enableSubRowSelection, columnGroupingMode: "reorder", - getRowId, + getRowId: largeDataSource ? (_, index) => String(largeDataPublication.rowIds[index]) : getRowId, }) + table.largeDataSource = largeDataSource + table.forEachExportRow = largeDataSource?.forEachExportRow + if ( + largeDataSource?.forEachRow && + largeDataSource?.forEachExportRow && + largeDataSource?.getFlatRowCount + ) { + table.getSelectedOriginalRows = () => getSelectedOriginalRows(largeDataSource, rowSelection) + table.getIsAllRowsSelected = () => getIsAllRowsSelected(largeDataSource, rowSelection) + table.getIsSomeRowsSelected = () => getIsSomeRowsSelected(largeDataSource, rowSelection) + table.toggleAllRowsSelected = value => + onRowSelectionChange(current => getNextRowSelection(largeDataSource, current, value)) + } + const prevStateRef = useRef(table.getState()) table.isEqual = (selector = identity) => { if (!prevStateRef.current) { @@ -304,6 +385,9 @@ const Table = memo(props => { testPrefix={testPrefix} meta={tableMeta} enableColumnReordering={enableColumnReordering} + largeDataSource={largeDataSource} + windowStartIndex={largeDataPublication?.startIndex || 0} + onWindowChange={handleLargeDataRangeChange} {...rest} {...virtualizeOptions} /> diff --git a/src/index.js b/src/index.js index 26178d368..5f24004cc 100644 --- a/src/index.js +++ b/src/index.js @@ -121,7 +121,7 @@ export { default as Modal } from "./components/modal" export { ConfirmationDialog } from "./components/confirmation-dialog" -export { Table, downloadCsvAction } from "./components/table" +export { Table, createLargeDataSource, downloadCsvAction } from "./components/table" export { default as Select } from "./components/select" export { default as SearchInput } from "./components/search" diff --git a/src/organisms/navigation/sortable/item.js b/src/organisms/navigation/sortable/item.js index 93d561433..17d10a5ac 100644 --- a/src/organisms/navigation/sortable/item.js +++ b/src/organisms/navigation/sortable/item.js @@ -1,4 +1,4 @@ -import React from "react" +import React, { useCallback } from "react" import { useSortable } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" @@ -36,10 +36,13 @@ const SortableItem = ({ transition, } - const setRef = el => { - setNodeRef(el) - if (lastTabRef) lastTabRef.current = el - } + const setRef = useCallback( + el => { + setNodeRef(el) + if (lastTabRef) lastTabRef.current = el + }, + [lastTabRef, setNodeRef] + ) return ( ({ + ...jest.requireActual("@dnd-kit/sortable"), + useSortable: jest.fn(), +})) + +const Item = ({ ref }) => + +const useSortableWithStateRef = () => { + const [, setNodeRef] = React.useState(null) + + return { + attributes: {}, + isDragging: false, + isSorting: false, + listeners: {}, + setActivatorNodeRef: jest.fn(), + setNodeRef, + transform: null, + transition: null, + } +} + +describe("SortableItem", () => { + it("keeps its callback ref stable when the sortable ref updates state", () => { + useSortable.mockImplementation(useSortableWithStateRef) + + renderWithProviders() + + expect(screen.getByTestId("sortable-item")).toBeInTheDocument() + }) +}) From 51136a47901fd1280c9003fee70a44671e338dac Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Sun, 12 Jul 2026 15:06:09 +0300 Subject: [PATCH 2/6] complete large-data Table type declarations --- src/components/table/index.d.ts | 60 ++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/src/components/table/index.d.ts b/src/components/table/index.d.ts index 729526e70..994c8fa38 100644 --- a/src/components/table/index.d.ts +++ b/src/components/table/index.d.ts @@ -8,13 +8,14 @@ import { Table, Row, } from "@tanstack/table-core" -import { ComponentType, ReactNode } from "react" +import { ComponentType, Key, MutableRefObject, ReactNode, UIEventHandler } from "react" +import { Virtualizer } from "@tanstack/react-virtual" import { supportedBulkActions } from "./header/actions/useActions" import { supportedRowActions } from "./useColumns/useRowActions" type NetdataCoreColumns = Pick, "id" | "header" | "cell" | "filterFn"> -type LargeDataSource = { +export type LargeDataSource = { forEachExportRow?: (callback: (row: D, id: string) => void) => void forEachRow?: (callback: (row: D, id: string) => void) => void getDisplayIndex?: (rowId: string, options?: { leaf?: boolean }) => number @@ -25,6 +26,44 @@ type LargeDataSource = { getRowId: (index: number) => string } +export type TableVirtualizer = Virtualizer + +export type TableRowWrapperProps = { + children: ReactNode + row: Row + virtualIndex: number + logicalIndex?: number +} + +export type TableVirtualizeOptions = { + DeferredRowPlaceholder?: ComponentType<{ index: number }> + RowPlaceholder?: ComponentType<{ index: number }> + deferRowMount?: boolean + directCellContent?: boolean + getHasNextPage?: () => boolean + getHasPrevPage?: () => boolean + getItemKey?: (index: number) => Key + initialOffset?: number + loading?: boolean + loadMore?: (direction: "forward" | "backward") => void + onIsScrollingChange?: (isScrolling: boolean) => void + onScroll?: UIEventHandler + onVirtualChange?: (instance: TableVirtualizer, sync: boolean) => void + overscan?: number + placeholdersLength?: number + virtualRef?: MutableRefObject + warning?: ReactNode +} + +export type LargeDataOptions = { + enabled?: boolean + filterRow?: (row: D, globalFilter: any) => boolean + getEstimatedRowHeight?: (row: D, index: number) => number | undefined + initialRowCount?: number + sortingFns?: Record + source?: LargeDataSource +} + export type TableProps = { data: Array dataColumns: Array> @@ -71,25 +110,14 @@ export type TableProps = { onClickRow?: (value: any) => void onHoverCell?: (value: any) => void disableClickRow?: (value: any) => void - RowWrapper?: ComponentType<{ - children: ReactNode - row: Row - virtualIndex: number - logicalIndex?: number - }> + RowWrapper?: ComponentType> + virtualizeOptions?: TableVirtualizeOptions /**This is an escape hatch test id generator, we use this when we want to have * dynamic generator tesids depending on the row values */ testPrefixCallback?: (rowData: D) => string - largeDataOptions?: { - enabled?: boolean - filterRow?: (row: D, globalFilter: any) => boolean - getEstimatedRowHeight?: (row: D, index: number) => number | undefined - initialRowCount?: number - sortingFns?: Record - source?: LargeDataSource - } + largeDataOptions?: LargeDataOptions } declare const Table: (props: TableProps) => JSX.Element From 7de2140c862dd0136cedda49c63d6b3b502d5e3e Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Sun, 12 Jul 2026 20:55:04 +0300 Subject: [PATCH 3/6] tune deferred table row hydration --- src/components/table/body/index.js | 2 +- src/components/table/body/index.test.js | 4 ++-- src/components/table/body/rowMountController.js | 2 +- src/components/table/body/rowMountController.test.js | 9 ++++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/table/body/index.js b/src/components/table/body/index.js index dde3413e2..e0dd5bdf2 100644 --- a/src/components/table/body/index.js +++ b/src/components/table/body/index.js @@ -18,7 +18,7 @@ import { getVirtualWindowRange } from "../largeData" import { createRowMountController } from "./rowMountController" import { measureTableElement } from "./measureElement" -const deferredRowScrollResetDelayMs = 75 +const deferredRowScrollResetDelayMs = 50 const noop = () => {} diff --git a/src/components/table/body/index.test.js b/src/components/table/body/index.test.js index 23aff877e..922797509 100644 --- a/src/components/table/body/index.test.js +++ b/src/components/table/body/index.test.js @@ -46,7 +46,7 @@ describe("Table Body large-data mode", () => { expect(secondOptions.estimateSize).toBe(firstOptions.estimateSize) }) - it("halves the scroll reset delay only for deferred row mounting", () => { + it("shortens the scroll reset delay only for deferred row mounting", () => { const props = { largeDataSource: { getRowCount: () => 50_000, getRowId: index => `node-${index}` }, meta: {}, @@ -64,7 +64,7 @@ describe("Table Body large-data mode", () => { rerender(renderBody(true)) - expect(useVirtualizer.mock.calls.at(-1)[0].isScrollingResetDelay).toBe(75) + expect(useVirtualizer.mock.calls.at(-1)[0].isScrollingResetDelay).toBe(50) }) it("publishes an unchanged virtual window only once", () => { diff --git a/src/components/table/body/rowMountController.js b/src/components/table/body/rowMountController.js index 71ff2857b..e8d9d5995 100644 --- a/src/components/table/body/rowMountController.js +++ b/src/components/table/body/rowMountController.js @@ -1,5 +1,5 @@ const rowMountIntervalMs = 24 -const rowMountBatchSize = 3 +const rowMountBatchSize = 4 export const createRowMountController = () => { let scrolling = false diff --git a/src/components/table/body/rowMountController.test.js b/src/components/table/body/rowMountController.test.js index 410be0891..6f3d92683 100644 --- a/src/components/table/body/rowMountController.test.js +++ b/src/components/table/body/rowMountController.test.js @@ -4,17 +4,19 @@ describe("row mount controller", () => { beforeEach(() => jest.useFakeTimers()) afterEach(() => jest.useRealTimers()) - it("mounts three queued rows per interval", () => { + it("mounts four queued rows per interval", () => { const controller = createRowMountController() const first = jest.fn() const second = jest.fn() const third = jest.fn() const fourth = jest.fn() + const fifth = jest.fn() controller.schedule(first) controller.schedule(second) controller.schedule(third) controller.schedule(fourth) + controller.schedule(fifth) jest.advanceTimersByTime(23) expect(first).not.toHaveBeenCalled() @@ -23,10 +25,11 @@ describe("row mount controller", () => { expect(first).toHaveBeenCalledTimes(1) expect(second).toHaveBeenCalledTimes(1) expect(third).toHaveBeenCalledTimes(1) - expect(fourth).not.toHaveBeenCalled() + expect(fourth).toHaveBeenCalledTimes(1) + expect(fifth).not.toHaveBeenCalled() jest.advanceTimersByTime(24) - expect(fourth).toHaveBeenCalledTimes(1) + expect(fifth).toHaveBeenCalledTimes(1) }) it("pauses queued mounts while scrolling", () => { From 7d44d9ea3b8b3da919d1c9b172ed8853d9efa686 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Sun, 12 Jul 2026 21:55:49 +0300 Subject: [PATCH 4/6] add delegated overflow tooltips to Table --- src/components/table/body/index.js | 3 + .../table/components/overflowTooltip.js | 134 ++++++++++++++++++ .../table/components/overflowTooltip.test.js | 121 ++++++++++++++++ src/components/table/index.d.ts | 8 ++ src/components/table/table.js | 2 + 5 files changed, 268 insertions(+) create mode 100644 src/components/table/components/overflowTooltip.js create mode 100644 src/components/table/components/overflowTooltip.test.js diff --git a/src/components/table/body/index.js b/src/components/table/body/index.js index e0dd5bdf2..c7241601b 100644 --- a/src/components/table/body/index.js +++ b/src/components/table/body/index.js @@ -17,6 +17,7 @@ import RowPlaceholdersRenderer from "./rowPLaceholdersRenderer" import { getVirtualWindowRange } from "../largeData" import { createRowMountController } from "./rowMountController" import { measureTableElement } from "./measureElement" +import OverflowTooltip from "../components/overflowTooltip" const deferredRowScrollResetDelayMs = 50 @@ -87,6 +88,7 @@ const Body = memo( largeDataSource, windowStartIndex = 0, onWindowChange, + overflowTooltip, ...rest }) => { useTableState(rerenderSelector) @@ -350,6 +352,7 @@ const Body = memo( getPlaceholderOffset={getPlaceholderOffset} /> + {overflowTooltip ? : null} ) } diff --git a/src/components/table/components/overflowTooltip.js b/src/components/table/components/overflowTooltip.js new file mode 100644 index 000000000..8ed5e72c4 --- /dev/null +++ b/src/components/table/components/overflowTooltip.js @@ -0,0 +1,134 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import Drop from "@/components/drops/drop" +import Container from "@/components/drops/container" +import dropAlignMap from "@/components/drops/mixins/dropAlignMap" + +const selector = "[data-overflow-tooltip]" + +const getTarget = (container, origin, includeDescendant = false) => { + if (!(origin instanceof Element)) return null + + const closest = origin.closest(selector) + if (closest && container.contains(closest)) return closest + + if (!includeDescendant) return null + const nested = origin.querySelector(selector) + return nested && container.contains(nested) ? nested : null +} + +const isOverflowing = target => + target.scrollWidth > target.clientWidth || target.scrollHeight > target.clientHeight + +const OverflowTooltip = ({ containerRef, options }) => { + const { align = "bottom", delay = 0, renderContent, zIndex = 80 } = options + const [active, setActive] = useState(null) + const activeRef = useRef(null) + const pendingTargetRef = useRef(null) + const timeoutRef = useRef() + + const clearPending = useCallback(() => { + if (timeoutRef.current === undefined) return + clearTimeout(timeoutRef.current) + timeoutRef.current = undefined + pendingTargetRef.current = null + }, []) + + const close = useCallback(() => { + clearPending() + if (!activeRef.current) return + activeRef.current = null + setActive(null) + }, [clearPending]) + + const open = useCallback( + (target, immediate = false) => { + if (activeRef.current?.target === target) return + if (!immediate && pendingTargetRef.current === target) return + clearPending() + if (!target || !isOverflowing(target)) { + close() + return + } + + const content = target.dataset.overflowTooltip + if (!content) { + close() + return + } + + const activate = () => { + timeoutRef.current = undefined + pendingTargetRef.current = null + if (!target.isConnected || !isOverflowing(target)) return + if (activeRef.current?.target === target && activeRef.current.content === content) return + + const next = { content, target } + activeRef.current = next + setActive(next) + } + + if (delay && !immediate) { + if (activeRef.current?.target !== target) close() + pendingTargetRef.current = target + timeoutRef.current = setTimeout(activate, delay) + } else { + activate() + } + }, + [clearPending, close, delay] + ) + + useEffect(() => { + const container = containerRef.current + if (!container) return undefined + + const onMouseOver = event => open(getTarget(container, event.target)) + const onMouseOut = event => { + const target = getTarget(container, event.target) + if (target?.contains(event.relatedTarget)) return + close() + } + const onFocusIn = event => open(getTarget(container, event.target, true), true) + const onFocusOut = event => { + const target = getTarget(container, event.target, true) + if (target?.contains(event.relatedTarget)) return + close() + } + + container.addEventListener("mouseover", onMouseOver) + container.addEventListener("mouseout", onMouseOut) + container.addEventListener("focusin", onFocusIn) + container.addEventListener("focusout", onFocusOut) + container.addEventListener("scroll", close, { passive: true }) + + return () => { + clearPending() + container.removeEventListener("mouseover", onMouseOver) + container.removeEventListener("mouseout", onMouseOut) + container.removeEventListener("focusin", onFocusIn) + container.removeEventListener("focusout", onFocusOut) + container.removeEventListener("scroll", close) + } + }, [clearPending, close, containerRef, open]) + + if (!active?.target.isConnected) return null + + return ( + + {renderContent ? ( + renderContent(active.content) + ) : ( + {active.content} + )} + + ) +} + +export default OverflowTooltip diff --git a/src/components/table/components/overflowTooltip.test.js b/src/components/table/components/overflowTooltip.test.js new file mode 100644 index 000000000..1166f7f48 --- /dev/null +++ b/src/components/table/components/overflowTooltip.test.js @@ -0,0 +1,121 @@ +import React, { useRef } from "react" +import { act, fireEvent, renderWithProviders } from "testUtilities" +import OverflowTooltip from "./overflowTooltip" + +const setDimensions = ( + target, + { clientHeight = 16, clientWidth = 100, scrollHeight = 16, scrollWidth = 200 } = {} +) => { + Object.defineProperties(target, { + clientHeight: { configurable: true, value: clientHeight }, + clientWidth: { configurable: true, value: clientWidth }, + scrollHeight: { configurable: true, value: scrollHeight }, + scrollWidth: { configurable: true, value: scrollWidth }, + }) +} + +const renderContent = content => {content} + +const Fixture = ({ delay = 0 }) => { + const ref = useRef() + + return ( + <> +
+ + Cropped value + +
+ + + ) +} + +it("does not measure the target while rendering", () => { + const scrollWidth = jest.spyOn(HTMLElement.prototype, "scrollWidth", "get") + + renderWithProviders() + + expect(scrollWidth).not.toHaveBeenCalled() + scrollWidth.mockRestore() +}) + +it("shows the tooltip only for overflowing content", () => { + const { getByTestId, queryByText } = renderWithProviders() + const target = getByTestId("target") + + setDimensions(target) + fireEvent.mouseOver(target) + expect(queryByText("Complete value")).toBeInTheDocument() + + fireEvent.mouseOut(target) + expect(queryByText("Complete value")).not.toBeInTheDocument() + + setDimensions(target, { scrollWidth: 100 }) + fireEvent.mouseOver(target) + expect(queryByText("Complete value")).not.toBeInTheDocument() +}) + +it("waits for the configured hover delay", () => { + jest.useFakeTimers() + const { getByTestId, queryByText } = renderWithProviders() + const target = getByTestId("target") + + setDimensions(target) + fireEvent.mouseOver(target) + expect(queryByText("Complete value")).not.toBeInTheDocument() + + act(() => jest.advanceTimersByTime(600)) + expect(queryByText("Complete value")).toBeInTheDocument() + jest.useRealTimers() +}) + +it("cancels pending and visible tooltips when the table scrolls", () => { + jest.useFakeTimers() + const { getByTestId, queryByText } = renderWithProviders() + const container = getByTestId("container") + const target = getByTestId("target") + + setDimensions(target) + fireEvent.mouseOver(target) + fireEvent.scroll(container) + act(() => jest.advanceTimersByTime(600)) + expect(queryByText("Complete value")).not.toBeInTheDocument() + + fireEvent.focus(target) + expect(queryByText("Complete value")).toBeInTheDocument() + fireEvent.scroll(container) + expect(queryByText("Complete value")).not.toBeInTheDocument() + jest.useRealTimers() +}) + +it("does not resolve an unrelated descendant while hovering the table container", () => { + const { getByTestId, queryByText } = renderWithProviders() + + setDimensions(getByTestId("target")) + fireEvent.mouseOver(getByTestId("container")) + expect(queryByText("Complete value")).not.toBeInTheDocument() +}) + +it("finds an overflowing descendant when its link receives focus", () => { + const FocusFixture = () => { + const ref = useRef() + return ( + <> + + + + ) + } + const { getByTestId, queryByText } = renderWithProviders() + + setDimensions(getByTestId("target")) + fireEvent.focus(getByTestId("link")) + expect(queryByText("Complete value")).toBeInTheDocument() +}) diff --git a/src/components/table/index.d.ts b/src/components/table/index.d.ts index 994c8fa38..26cc074c1 100644 --- a/src/components/table/index.d.ts +++ b/src/components/table/index.d.ts @@ -64,6 +64,13 @@ export type LargeDataOptions = { source?: LargeDataSource } +export type TableOverflowTooltipOptions = { + align?: "top" | "right" | "bottom" | "left" + delay?: number + renderContent?: (content: string) => ReactNode + zIndex?: number +} + export type TableProps = { data: Array dataColumns: Array> @@ -112,6 +119,7 @@ export type TableProps = { disableClickRow?: (value: any) => void RowWrapper?: ComponentType> virtualizeOptions?: TableVirtualizeOptions + overflowTooltip?: TableOverflowTooltipOptions /**This is an escape hatch test id generator, we use this when we want to have * dynamic generator tesids depending on the row values diff --git a/src/components/table/table.js b/src/components/table/table.js index 992d880fd..79619482b 100644 --- a/src/components/table/table.js +++ b/src/components/table/table.js @@ -139,6 +139,7 @@ const Table = memo(props => { tableRef, className, width, + overflowTooltip, getRowCanExpand, getRowId, ref, @@ -388,6 +389,7 @@ const Table = memo(props => { largeDataSource={largeDataSource} windowStartIndex={largeDataPublication?.startIndex || 0} onWindowChange={handleLargeDataRangeChange} + overflowTooltip={overflowTooltip} {...rest} {...virtualizeOptions} /> From e454d14b34ec8ced0d8cc4b941a41e2526ed2208 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Mon, 13 Jul 2026 00:14:05 +0300 Subject: [PATCH 5/6] show tooltips for truncated table headers --- src/components/table/body/header/cell.js | 13 +++ src/components/table/body/header/cell.test.js | 20 +++++ .../table/components/overflowTooltip.js | 63 +++++++++++---- .../table/components/overflowTooltip.test.js | 81 ++++++++++++++++++- .../table/headerOverflowTooltip.test.js | 36 +++++++++ src/components/table/index.d.ts | 18 ++++- src/components/table/index.js | 1 + src/index.js | 7 +- 8 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 src/components/table/body/header/cell.test.js create mode 100644 src/components/table/headerOverflowTooltip.test.js diff --git a/src/components/table/body/header/cell.js b/src/components/table/body/header/cell.js index cd5062232..4db524c26 100644 --- a/src/components/table/body/header/cell.js +++ b/src/components/table/body/header/cell.js @@ -34,6 +34,16 @@ const rerenderSelector = state => ({ selecting: state.selectedRows, }) +export const getHeaderTooltipContent = columnDef => { + const headerString = + typeof columnDef.headerString === "function" ? columnDef.headerString() : columnDef.headerString + + if (headerString != null) return String(headerString) + if (typeof columnDef.header === "string") return columnDef.header + if (columnDef.header != null) return "" + return undefined +} + const BodyHeaderCell = ({ header, table, @@ -63,6 +73,8 @@ const BodyHeaderCell = ({ ? column.columnDef.meta({}, column, index) : column.columnDef.meta + const headerTooltipContent = getHeaderTooltipContent(column.columnDef) + const headStyles = { ...(tableMeta?.styles || {}), ...(meta?.styles || {}), @@ -110,6 +122,7 @@ const BodyHeaderCell = ({
- + ) } @@ -89,6 +89,32 @@ it("cancels pending and visible tooltips when the table scrolls", () => { jest.useRealTimers() }) +it("closes a visible tooltip when the viewport resizes", () => { + const { getByTestId, queryByText } = renderWithProviders() + const target = getByTestId("target") + + setDimensions(target) + fireEvent.focus(target) + expect(queryByText("Complete value")).toBeInTheDocument() + + fireEvent(window, new Event("resize")) + expect(queryByText("Complete value")).not.toBeInTheDocument() +}) + +it("closes a visible tooltip on window scroll when configured", () => { + const { getByTestId, queryByText } = renderWithProviders( + + ) + const target = getByTestId("target") + + setDimensions(target) + fireEvent.focus(target) + expect(queryByText("Complete value")).toBeInTheDocument() + + fireEvent.scroll(window) + expect(queryByText("Complete value")).not.toBeInTheDocument() +}) + it("does not resolve an unrelated descendant while hovering the table container", () => { const { getByTestId, queryByText } = renderWithProviders() @@ -119,3 +145,54 @@ it("finds an overflowing descendant when its link receives focus", () => { fireEvent.focus(getByTestId("link")) expect(queryByText("Complete value")).toBeInTheDocument() }) + +it("supports custom targets, content resolution, and overflow detection", () => { + const HeaderFixture = () => { + const ref = useRef() + return ( + <> +
+ + Cropped header + +
+ target.dataset.tableHeaderTooltip, + isOverflowing: target => + [target, ...target.querySelectorAll("*")].some( + element => + element.scrollWidth > element.clientWidth || + element.scrollHeight > element.clientHeight + ), + renderContent, + selector: "[data-table-header-tooltip]", + }} + /> + + ) + } + const { getByTestId, queryByText } = renderWithProviders() + + setDimensions(getByTestId("header"), { scrollWidth: 100 }) + setDimensions(getByTestId("headerText")) + fireEvent.mouseOver(getByTestId("headerText")) + + expect(queryByText("Canonical header")).toBeInTheDocument() +}) + +it("removes a visible tooltip when its target disconnects without a controller render", () => { + jest.useFakeTimers() + const { getByTestId, queryByText } = renderWithProviders() + const target = getByTestId("target") + + setDimensions(target) + fireEvent.focus(target) + expect(queryByText("Complete value")).toBeInTheDocument() + + target.remove() + act(() => jest.advanceTimersByTime(100)) + expect(queryByText("Complete value")).not.toBeInTheDocument() + jest.useRealTimers() +}) diff --git a/src/components/table/headerOverflowTooltip.test.js b/src/components/table/headerOverflowTooltip.test.js new file mode 100644 index 000000000..5bd122ed6 --- /dev/null +++ b/src/components/table/headerOverflowTooltip.test.js @@ -0,0 +1,36 @@ +import React from "react" +import { renderWithProviders, waitFor } from "testUtilities" +import Table from "./table" + +it("publishes canonical overflow content without replacing column drag handles", async () => { + const { container } = renderWithProviders( +
XYZ…, + headerString: () => "XYZ complete dynamic column name", + }, + { + id: "literal", + accessorKey: "literal", + header: "Complete literal column name", + }, + ]} + enableColumnReordering + /> + ) + + await waitFor(() => { + expect( + container.querySelector('[data-table-header-tooltip="XYZ complete dynamic column name"]') + ).toBeInTheDocument() + expect( + container.querySelector('[data-table-header-tooltip="Complete literal column name"]') + ).toBeInTheDocument() + expect(container.querySelectorAll(".drag-handle[role=button]")).toHaveLength(2) + expect(container.querySelectorAll("[data-table-header-tooltip] .drag-handle")).toHaveLength(0) + }) +}) diff --git a/src/components/table/index.d.ts b/src/components/table/index.d.ts index 26cc074c1..dc35ca7d0 100644 --- a/src/components/table/index.d.ts +++ b/src/components/table/index.d.ts @@ -8,7 +8,7 @@ import { Table, Row, } from "@tanstack/table-core" -import { ComponentType, Key, MutableRefObject, ReactNode, UIEventHandler } from "react" +import { ComponentType, Key, MutableRefObject, ReactNode, RefObject, UIEventHandler } from "react" import { Virtualizer } from "@tanstack/react-virtual" import { supportedBulkActions } from "./header/actions/useActions" import { supportedRowActions } from "./useColumns/useRowActions" @@ -64,13 +64,24 @@ export type LargeDataOptions = { source?: LargeDataSource } -export type TableOverflowTooltipOptions = { +export type OverflowTooltipOptions = { align?: "top" | "right" | "bottom" | "left" + closeOnWindowScroll?: boolean delay?: number + getContent?: (target: HTMLElement) => string | null | undefined + isOverflowing?: (target: HTMLElement) => boolean renderContent?: (content: string) => ReactNode + selector?: string zIndex?: number } +export type TableOverflowTooltipOptions = OverflowTooltipOptions + +export type OverflowTooltipProps = { + containerRef: RefObject + options?: OverflowTooltipOptions +} + export type TableProps = { data: Array dataColumns: Array> @@ -129,6 +140,7 @@ export type TableProps = { } declare const Table: (props: TableProps) => JSX.Element +declare const OverflowTooltip: (props: OverflowTooltipProps) => JSX.Element declare const createLargeDataSource: (options: { columns: Array columnFilters?: ColumnFiltersState @@ -142,5 +154,5 @@ declare const createLargeDataSource: (options: { sortingFns?: Record }) => LargeDataSource -export { Table, createLargeDataSource } +export { Table, createLargeDataSource, OverflowTooltip } export default Table diff --git a/src/components/table/index.js b/src/components/table/index.js index afdd73a1a..1b7ee6a48 100644 --- a/src/components/table/index.js +++ b/src/components/table/index.js @@ -1,3 +1,4 @@ export { default as Table } from "./table" export { default as downloadCsvAction } from "./helpers/downloadCsv" export { default as createLargeDataSource } from "./createLargeDataSource" +export { default as OverflowTooltip } from "./components/overflowTooltip" diff --git a/src/index.js b/src/index.js index 5f24004cc..ff7fcfece 100644 --- a/src/index.js +++ b/src/index.js @@ -121,7 +121,12 @@ export { default as Modal } from "./components/modal" export { ConfirmationDialog } from "./components/confirmation-dialog" -export { Table, createLargeDataSource, downloadCsvAction } from "./components/table" +export { + Table, + createLargeDataSource, + downloadCsvAction, + OverflowTooltip, +} from "./components/table" export { default as Select } from "./components/select" export { default as SearchInput } from "./components/search" From cf524c49e70e91b3494cfc99cd729e3859f27173 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 09:56:00 +0300 Subject: [PATCH 6/6] Fix snapshot. --- .../tabs/__snapshots__/tabs.test.js.snap | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/components/tabs/__snapshots__/tabs.test.js.snap b/src/components/tabs/__snapshots__/tabs.test.js.snap index aede8b190..839d8ea0e 100644 --- a/src/components/tabs/__snapshots__/tabs.test.js.snap +++ b/src/components/tabs/__snapshots__/tabs.test.js.snap @@ -18,9 +18,11 @@ exports[`Tabs states * should render uncontrolled 1`] = ` padding: 0 2px; color: #526161; border-bottom: 1px solid #E4E8E8; + overflow-y: hidden; + overflow-x: auto; } -.c2 { +.c3 { display: flex; flex-direction: row; align-items: center; @@ -29,7 +31,16 @@ exports[`Tabs states * should render uncontrolled 1`] = ` color: #526161; } -.c3 { +.c2 { + -ms-overflow-style: none; + overflow: -moz-scrollbars-none; +} + +.c2::-webkit-scrollbar { + height: 0px; +} + +.c4 { white-space: nowrap; border-bottom-width: 1px; border-bottom-style: solid; @@ -45,15 +56,15 @@ exports[`Tabs states * should render uncontrolled 1`] = ` background: #F1FFF7; } -.c3>span { +.c4>span { color: #00AB44; } -.c3:hover { +.c4:hover { border-bottom-color: #00AB44; } -.c4 { +.c5 { white-space: nowrap; border-bottom-width: 1px; border-bottom-style: solid; @@ -69,11 +80,11 @@ exports[`Tabs states * should render uncontrolled 1`] = ` background: #F6F7F7; } -.c4>span { +.c5>span { color: #708585; } -.c4:hover { +.c5:hover { border-bottom-color: #00AB44; } @@ -83,13 +94,15 @@ exports[`Tabs states * should render uncontrolled 1`] = `