diff --git a/package.json b/package.json
index 532b3840..c84b2b20 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@netdata/netdata-ui",
- "version": "5.5.0",
+ "version": "5.5.2",
"description": "netdata UI kit",
"main": "dist/index.js",
"module": "dist/es6/index.js",
diff --git a/src/components/table/body/index.js b/src/components/table/body/index.js
index c7241601..02308306 100644
--- a/src/components/table/body/index.js
+++ b/src/components/table/body/index.js
@@ -14,7 +14,7 @@ import { useTableState } from "../provider"
import Row from "./row"
import Header from "./header"
import RowPlaceholdersRenderer from "./rowPLaceholdersRenderer"
-import { getVirtualWindowRange } from "../largeData"
+import { normalizeWindowRange } from "../largeData"
import { createRowMountController } from "./rowMountController"
import { measureTableElement } from "./measureElement"
import OverflowTooltip from "../components/overflowTooltip"
@@ -23,34 +23,32 @@ const deferredRowScrollResetDelayMs = 50
const noop = () => {}
-const DeferredRow = memo(
- ({ children, controller, RowPlaceholder, index, placeholderSize }) => {
- const [ready, setReady] = useState(false)
+const getVirtualWindowRange = (virtualItems, count) => {
+ let startIndex
+ let endIndex
- useEffect(() => controller.schedule(() => setReady(true)), [controller])
+ 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)
+ })
- 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
-)
+ return normalizeWindowRange({ startIndex, endIndex }, count)
+}
+
+const DeferredRow = ({ children, controller, RowPlaceholder, index, placeholderSize }) => {
+ const [ready, setReady] = useState(false)
+
+ useEffect(() => controller.schedule(() => setReady(true)), [controller])
+
+ if (ready) return children
+ return (
+
+ {RowPlaceholder ? : null}
+
+ )
+}
const rerenderSelector = state => ({
sorting: state.sorting,
@@ -175,9 +173,14 @@ const Body = memo(
publishedWindowRef.current = windowRange
onWindowChange(windowRange)
- }, [deferRowMount, nextWindowRange, onWindowChange, rowVirtualizer.isScrolling])
+ }, [
+ deferRowMount,
+ nextWindowRange?.startIndex,
+ nextWindowRange?.endIndex,
+ 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
@@ -187,14 +190,12 @@ const Body = memo(
const firstDataIndex = 1
const lastDataIndex = dataRowCount
- // "before" = rows before the virtual window; capped to placeholdersLength when provided
const beforeEnd = firstVirtualDataIndex
const beforeStart =
placeholdersLength != null
? Math.max(firstDataIndex, beforeEnd - placeholdersLength)
: firstDataIndex
- // "after" = rows after the virtual window; capped to placeholdersLength when provided
const afterStart = lastVirtualDataIndex + 1
const afterEnd =
placeholdersLength != null
@@ -328,13 +329,7 @@ const Body = memo(
) : deferRowMount && row ? (
diff --git a/src/components/table/body/index.test.js b/src/components/table/body/index.test.js
index 92279750..24dbdd33 100644
--- a/src/components/table/body/index.test.js
+++ b/src/components/table/body/index.test.js
@@ -6,12 +6,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"
jest.mock("@tanstack/react-virtual", () => ({
defaultRangeExtractor: jest.fn(() => []),
- useVirtualizer: jest.fn(() => ({
- getTotalSize: () => 0,
- getVirtualItems: () => [],
- measureElement: jest.fn(),
- measurementsCache: [],
- })),
+ useVirtualizer: jest.fn(),
}))
jest.mock("./row", () => ({ row }) => (
@@ -19,7 +14,18 @@ jest.mock("./row", () => ({ row }) => (
))
jest.mock("./header", () => () => )
+const createDefaultVirtualizer = () => ({
+ getTotalSize: () => 0,
+ getVirtualItems: () => [],
+ measureElement: jest.fn(),
+ measurementsCache: [],
+})
+
describe("Table Body large-data mode", () => {
+ beforeEach(() => {
+ useVirtualizer.mockImplementation(createDefaultVirtualizer)
+ })
+
it("keeps virtual measurement callbacks stable across equivalent renders", () => {
const largeDataSource = {
getRowCount: () => 50_000,
diff --git a/src/components/table/body/measureElement.js b/src/components/table/body/measureElement.js
index 700a5958..5c1cc0a8 100644
--- a/src/components/table/body/measureElement.js
+++ b/src/components/table/body/measureElement.js
@@ -1,38 +1,19 @@
+import { measureElement } from "@tanstack/react-virtual"
+
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"]
+ return instance.itemSizeCache.get(key)
}
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)
+ const cached = getCachedSize(element, instance)
if (cached !== undefined) return cached
}
- const rect = element.getBoundingClientRect()
- const measuredSize = horizontal ? rect.width : rect.height
+ const measuredSize = measureElement(element, entry, instance)
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
index b3ce1b89..4a1dca62 100644
--- a/src/components/table/body/measureElement.test.js
+++ b/src/components/table/body/measureElement.test.js
@@ -7,40 +7,37 @@ const createInstance = ({ cached, estimate = 35, horizontal = false } = {}) => (
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() }
+ it("uses TanStack's rounded ResizeObserver border-box height", () => {
+ const element = { offsetHeight: 0 }
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()
+ expect(measureTableElement(element, entry, instance)).toBe(34)
})
- it("preserves a fractional bounding-client-rect fallback", () => {
- const element = { getBoundingClientRect: jest.fn(() => ({ height: 52.8, width: 400.8 })) }
+ it("uses TanStack's offset-size fallback", () => {
+ const element = { offsetHeight: 53 }
const instance = createInstance()
- expect(measureTableElement(element, undefined, instance)).toBe(52.8)
+ expect(measureTableElement(element, undefined, instance)).toBe(53)
})
it("reuses a cached measurement without forcing layout", () => {
- const element = { getBoundingClientRect: jest.fn() }
+ const element = { offsetHeight: 53 }
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() }
+ it("uses TanStack's rounded width for a horizontal virtualizer", () => {
+ const element = { offsetWidth: 0 }
const instance = createInstance({ horizontal: true })
- const entry = { borderBoxSize: { blockSize: 34.4, inlineSize: 200.8 } }
+ const entry = { borderBoxSize: [{ blockSize: 34.4, inlineSize: 200.8 }] }
- expect(measureTableElement(element, entry, instance)).toBe(200.8)
+ expect(measureTableElement(element, entry, instance)).toBe(201)
})
})
diff --git a/src/components/table/body/row.js b/src/components/table/body/row.js
index 38844e55..ec5bac58 100644
--- a/src/components/table/body/row.js
+++ b/src/components/table/body/row.js
@@ -1,10 +1,32 @@
import React, { memo, useMemo, useCallback } from "react"
import styled, { useTheme } from "styled-components"
import Flex from "@/components/templates/flex"
+import { alignItemValuesMap } from "@/components/templates/mixins/alignItems"
+import { justifyContentMap } from "@/components/templates/mixins/justifyContent"
+import { getFlex } from "@/components/templates/mixins/flex"
+import { getDimension, getDimensions } from "@/mixins/utils"
import { getColor, getRgbColor } from "@/theme"
import { useTableState } from "../provider"
import getColumnFlex from "./columnFlex"
+const resolveCellStyles = (column, rowIndex) => {
+ const tableMeta =
+ typeof column.columnDef.tableMeta === "function"
+ ? column.columnDef.tableMeta({}, column, rowIndex)
+ : column.columnDef.tableMeta
+ const meta =
+ typeof column.columnDef.meta === "function"
+ ? column.columnDef.meta({}, column, rowIndex)
+ : column.columnDef.meta
+
+ return {
+ ...(tableMeta?.styles || {}),
+ ...(meta?.styles || {}),
+ ...(tableMeta?.cellStyles || {}),
+ ...(meta?.cellStyles || {}),
+ }
+}
+
const CellGroup = ({
cell,
row,
@@ -16,28 +38,11 @@ const CellGroup = ({
}) => {
const { column } = cell
- const tableMeta = useMemo(
- () =>
- typeof column.columnDef.tableMeta === "function"
- ? column.columnDef.tableMeta({}, column, row.index)
- : column.columnDef.tableMeta,
- [column.columnDef.tableMeta, column, row.index]
- )
-
- const meta = useMemo(
- () =>
- typeof column.columnDef.meta === "function"
- ? column.columnDef.meta({}, column, row.index)
- : column.columnDef.meta,
- [column.columnDef.meta, column, row.index]
+ const cellStyles = useMemo(
+ () => resolveCellStyles(column, row.index),
+ [column.columnDef.tableMeta, column.columnDef.meta, column, row.index]
)
- const cellStyles = {
- ...(tableMeta?.styles || {}),
- ...(meta?.styles || {}),
- ...(tableMeta?.cellStyles || {}),
- ...(meta?.cellStyles || {}),
- }
const content =
return (
@@ -68,42 +73,19 @@ const CellGroup = ({
)
}
-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 (styles.alignItems) {
+ result.alignItems = alignItemValuesMap[styles.alignItems] || styles.alignItems
}
- if (Array.isArray(styles.padding)) {
- result.padding = styles.padding
- .map(value => `${value * theme.constants.SIZE_SUB_UNIT}px`)
- .join(" ")
+ if (styles.justifyContent) {
+ result.justifyContent = justifyContentMap[styles.justifyContent] || styles.justifyContent
}
+ if (styles.flex !== undefined) result.flex = getFlex(styles.flex)
+ if (typeof styles.width === "number") result.width = getDimension(theme, styles.width)
+ if (typeof styles.height === "number") result.height = getDimension(theme, styles.height)
+ if (Array.isArray(styles.padding)) result.padding = getDimensions(theme, styles.padding)
if (styles.background) {
result.backgroundColor = styles.backgroundOpacity
? getRgbColor(styles.background, styles.backgroundOpacity)({ theme })
@@ -117,23 +99,7 @@ const getInlineCellStyles = (styles, theme) => {
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 cellStyles = getInlineCellStyles(resolveCellStyles(column, row.index), theme)
const sorted = column.getCanSort() && coloredSortedColumn && column.getIsSorted()
return (
@@ -142,14 +108,14 @@ const DirectCellGroup = ({ cell, row, table, header, testPrefix, coloredSortedCo
style={{
display: "flex",
boxSizing: "border-box",
- flex: getFlexStyle(
+ flex: getFlex(
getColumnFlex(column, header, table.getState().columnSizing?.[column.id] != null)
),
width: `${column.getSize()}px`,
position: "relative",
overflow: "hidden",
- padding: `${theme.constants.SIZE_SUB_UNIT * 1.5}px ${theme.constants.SIZE_SUB_UNIT * 2}px`,
- alignItems: alignment[column.columnDef.align] || "flex-start",
+ padding: getDimensions(theme, [1.5, 2]),
+ alignItems: alignItemValuesMap[column.columnDef.align] || "flex-start",
backgroundColor: sorted
? getRgbColor("columnHighlight", row.index % 2 === 0 ? 0.2 : 0.4)({ theme })
: undefined,
@@ -176,13 +142,12 @@ const StyledRow = styled(Flex)`
}
`
-const DirectStyledRow = styled.div`
- display: flex;
- flex: 1 1 auto;
- flex-direction: column;
- background-color: ${getColor("mainBackground")};
- border-bottom: 1px solid ${getColor("border")};
-
+const DirectStyledRow = styled(Flex).attrs({
+ flex: true,
+ column: true,
+ background: "mainBackground",
+ border: { side: "bottom" },
+})`
&:hover,
&:hover .row-content {
background: ${getColor("mainBackgroundHover")};
@@ -411,8 +376,7 @@ const TableRow = ({
}`}
data-id={row.original?.id || row.id}
onClick={onClick}
- cursor={directCellContent ? undefined : isClickable ? "pointer" : "default"}
- style={directCellContent ? { cursor: isClickable ? "pointer" : "default" } : undefined}
+ cursor={isClickable ? "pointer" : "default"}
onMouseEnter={() => onHoverCell?.({ row: row.index })}
onMouseLeave={() => onHoverCell?.({ row: null })}
{...(!directCellContent && {
diff --git a/src/components/table/body/rowMountController.js b/src/components/table/body/rowMountController.js
index e8d9d599..7459ee5e 100644
--- a/src/components/table/body/rowMountController.js
+++ b/src/components/table/body/rowMountController.js
@@ -10,14 +10,15 @@ export const createRowMountController = () => {
timer = null
if (scrolling) return
- const batch = [...scheduled].slice(0, rowMountBatchSize)
- if (!batch.length) return
-
- batch.forEach(entry => {
+ let executed = 0
+ for (const entry of scheduled) {
+ if (executed === rowMountBatchSize) break
scheduled.delete(entry)
entry.callback()
- })
- start()
+ executed += 1
+ }
+
+ if (executed) start()
}
const start = () => {
diff --git a/src/components/table/components/overflowTooltip.js b/src/components/table/components/overflowTooltip.js
index 3322dadd..6ffc8e93 100644
--- a/src/components/table/components/overflowTooltip.js
+++ b/src/components/table/components/overflowTooltip.js
@@ -3,8 +3,10 @@ import Drop from "@/components/drops/drop"
import Container from "@/components/drops/container"
import dropAlignMap from "@/components/drops/mixins/dropAlignMap"
-const defaultSelector = "[data-overflow-tooltip]"
-const defaultGetContent = target => target.dataset.overflowTooltip
+export const overflowTooltipAttribute = "data-overflow-tooltip"
+
+const defaultSelector = `[${overflowTooltipAttribute}]`
+const defaultGetContent = target => target.getAttribute(overflowTooltipAttribute)
const targetConnectivityCheckIntervalMs = 100
const getTarget = (container, origin, selector, includeDescendant = false) => {
@@ -33,6 +35,7 @@ const OverflowTooltip = ({ containerRef, options = {} }) => {
zIndex = 80,
} = options
const [active, setActive] = useState(null)
+ const [pending, setPending] = useState(false)
const activeRef = useRef(null)
const pendingTargetRef = useRef(null)
const timeoutRef = useRef()
@@ -42,6 +45,7 @@ const OverflowTooltip = ({ containerRef, options = {} }) => {
clearTimeout(timeoutRef.current)
timeoutRef.current = undefined
pendingTargetRef.current = null
+ setPending(false)
}, [])
const close = useCallback(() => {
@@ -70,6 +74,7 @@ const OverflowTooltip = ({ containerRef, options = {} }) => {
const activate = () => {
timeoutRef.current = undefined
pendingTargetRef.current = null
+ setPending(false)
const currentContent = getContent(target)
if (!target.isConnected || !currentContent || !isOverflowing(target)) return
if (activeRef.current?.target === target && activeRef.current.content === currentContent)
@@ -84,6 +89,7 @@ const OverflowTooltip = ({ containerRef, options = {} }) => {
if (activeRef.current?.target !== target) close()
pendingTargetRef.current = target
timeoutRef.current = setTimeout(activate, delay)
+ setPending(true)
} else {
activate()
}
@@ -91,17 +97,21 @@ const OverflowTooltip = ({ containerRef, options = {} }) => {
[clearPending, close, delay, getContent, isOverflowing]
)
+ const openRef = useRef()
+ openRef.current = open
+
useEffect(() => {
const container = containerRef.current
if (!container) return undefined
- const onMouseOver = event => open(getTarget(container, event.target, selector))
+ const onMouseOver = event => openRef.current(getTarget(container, event.target, selector))
const onMouseOut = event => {
const target = getTarget(container, event.target, selector)
if (target?.contains(event.relatedTarget)) return
close()
}
- const onFocusIn = event => open(getTarget(container, event.target, selector, true), true)
+ const onFocusIn = event =>
+ openRef.current(getTarget(container, event.target, selector, true), true)
const onFocusOut = event => {
const target = getTarget(container, event.target, selector, true)
if (target?.contains(event.relatedTarget)) return
@@ -113,10 +123,6 @@ const OverflowTooltip = ({ containerRef, options = {} }) => {
container.addEventListener("focusin", onFocusIn)
container.addEventListener("focusout", onFocusOut)
container.addEventListener("scroll", close, { capture: true, passive: true })
- if (closeOnWindowScroll) {
- window.addEventListener("scroll", close, { capture: true, passive: true })
- }
- window.addEventListener("resize", close, { passive: true })
return () => {
clearPending()
@@ -125,20 +131,29 @@ const OverflowTooltip = ({ containerRef, options = {} }) => {
container.removeEventListener("focusin", onFocusIn)
container.removeEventListener("focusout", onFocusOut)
container.removeEventListener("scroll", close, true)
- if (closeOnWindowScroll) window.removeEventListener("scroll", close, true)
- window.removeEventListener("resize", close)
}
- }, [clearPending, close, closeOnWindowScroll, containerRef, open, selector])
+ }, [clearPending, close, containerRef, selector])
useEffect(() => {
- if (!active) return undefined
+ if (!active && !pending) return undefined
- const interval = setInterval(() => {
- if (!active.target.isConnected) close()
- }, targetConnectivityCheckIntervalMs)
+ const interval = active
+ ? setInterval(() => {
+ if (!active.target.isConnected) close()
+ }, targetConnectivityCheckIntervalMs)
+ : undefined
- return () => clearInterval(interval)
- }, [active, close])
+ window.addEventListener("resize", close, { passive: true })
+ if (closeOnWindowScroll) {
+ window.addEventListener("scroll", close, { capture: true, passive: true })
+ }
+
+ return () => {
+ if (interval !== undefined) clearInterval(interval)
+ window.removeEventListener("resize", close)
+ if (closeOnWindowScroll) window.removeEventListener("scroll", close, true)
+ }
+ }, [active, close, closeOnWindowScroll, pending])
if (!active?.target.isConnected) return null
diff --git a/src/components/table/components/overflowTooltip.test.js b/src/components/table/components/overflowTooltip.test.js
index d297c89a..2e55051d 100644
--- a/src/components/table/components/overflowTooltip.test.js
+++ b/src/components/table/components/overflowTooltip.test.js
@@ -16,6 +16,8 @@ const setDimensions = (
const renderContent = content => {content}
+afterEach(() => jest.useRealTimers())
+
const Fixture = ({ delay = 0, options = {} }) => {
const ref = useRef()
@@ -67,7 +69,6 @@ it("waits for the configured hover delay", () => {
act(() => jest.advanceTimersByTime(600))
expect(queryByText("Complete value")).toBeInTheDocument()
- jest.useRealTimers()
})
it("cancels pending and visible tooltips when the table scrolls", () => {
@@ -86,7 +87,34 @@ it("cancels pending and visible tooltips when the table scrolls", () => {
expect(queryByText("Complete value")).toBeInTheDocument()
fireEvent.scroll(container)
expect(queryByText("Complete value")).not.toBeInTheDocument()
- jest.useRealTimers()
+})
+
+it("cancels a pending tooltip when the viewport resizes", () => {
+ jest.useFakeTimers()
+ const { getByTestId, queryByText } = renderWithProviders()
+ const target = getByTestId("target")
+
+ setDimensions(target)
+ fireEvent.mouseOver(target)
+ fireEvent(window, new Event("resize"))
+ act(() => jest.advanceTimersByTime(600))
+
+ expect(queryByText("Complete value")).not.toBeInTheDocument()
+})
+
+it("cancels a pending tooltip on window scroll when configured", () => {
+ jest.useFakeTimers()
+ const { getByTestId, queryByText } = renderWithProviders(
+
+ )
+ const target = getByTestId("target")
+
+ setDimensions(target)
+ fireEvent.mouseOver(target)
+ fireEvent.scroll(window)
+ act(() => jest.advanceTimersByTime(600))
+
+ expect(queryByText("Complete value")).not.toBeInTheDocument()
})
it("closes a visible tooltip when the viewport resizes", () => {
@@ -194,5 +222,4 @@ it("removes a visible tooltip when its target disconnects without a controller r
target.remove()
act(() => jest.advanceTimersByTime(100))
expect(queryByText("Complete value")).not.toBeInTheDocument()
- jest.useRealTimers()
})
diff --git a/src/components/table/createLargeDataSource.js b/src/components/table/createLargeDataSource.js
index fc1a6a46..a303db0f 100644
--- a/src/components/table/createLargeDataSource.js
+++ b/src/components/table/createLargeDataSource.js
@@ -2,9 +2,9 @@ import {
filterFns as defaultFilterFns,
sortingFns as defaultSortingFns,
} from "@tanstack/react-table"
+import { getColumnValue } from "./helpers/columnValue"
-const getPathValue = (value, path) =>
- path.split(".").reduce((current, key) => current?.[key], value)
+const reSplitAlphaNumeric = /([0-9]+)/gm
const getColumnsById = columns => {
const byId = new Map()
@@ -20,12 +20,6 @@ const getColumnsById = 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
@@ -36,7 +30,7 @@ const getAutoSortingFn = (rows, column) => {
}
if (typeof value !== "string") continue
isString = true
- if (/([0-9]+)/.test(value)) return defaultSortingFns.alphanumeric
+ if (value.split(reSplitAlphaNumeric).length > 1) return defaultSortingFns.alphanumeric
}
return isString ? defaultSortingFns.text : defaultSortingFns.basic
@@ -46,8 +40,18 @@ const createRowAdapter = columnsById => ({
index: 0,
original: null,
subRows: [],
+ values: null,
+ setRow(original, index) {
+ this.original = original
+ this.index = index
+ this.values = null
+ },
getValue(columnId) {
- return getColumnValue(this.original, this.index, columnsById.get(columnId))
+ if (this.values?.has(columnId)) return this.values.get(columnId)
+ const value = getColumnValue(this.original, this.index, columnsById.get(columnId))
+ if (!this.values) this.values = new Map()
+ this.values.set(columnId, value)
+ return value
},
})
@@ -57,7 +61,6 @@ const getAutoFilterFn = (rows, 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
}
@@ -91,8 +94,7 @@ const filterRows = ({ rows, columnFilters, columnsById, filterFns }) => {
const rowAdapter = createRowAdapter(columnsById)
return rows.filter((row, index) => {
- rowAdapter.index = index
- rowAdapter.original = row
+ rowAdapter.setRow(row, index)
return filters.every(({ filterFn, id, value }) => filterFn(rowAdapter, id, value))
})
}
@@ -117,20 +119,17 @@ const sortRows = ({ rows, sorting, columnsById, sortingFns }) => {
? 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 }))
+ const indexedRows = rows.map((row, index) => {
+ const adapter = createRowAdapter(columnsById)
+ adapter.setRow(row, index)
+ return adapter
+ })
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)
+ const aValue = a.getValue(entry.id)
+ const bValue = b.getValue(entry.id)
let result = 0
if (sortUndefined && (aValue === undefined || bValue === undefined)) {
@@ -144,7 +143,7 @@ const sortRows = ({ rows, sorting, columnsById, sortingFns }) => {
: -sortUndefined
}
- if (result === 0) result = sortingFn(left, right, entry.id)
+ if (result === 0) result = sortingFn(a, b, entry.id)
if (result === 0) continue
if (entry.desc) result *= -1
if (column.invertSorting) result *= -1
@@ -154,7 +153,7 @@ const sortRows = ({ rows, sorting, columnsById, sortingFns }) => {
return a.index - b.index
})
- return indexedRows.map(({ row }) => row)
+ return indexedRows.map(({ original }) => original)
}
export default ({
@@ -213,8 +212,11 @@ export default ({
const displayIndexById = new Map(displayIds.map((id, index) => [id, index]))
return {
- forEachExportRow: callback =>
- exportRows.forEach((row, index) => callback(row, exportIds[index])),
+ forEachExportRow: callback => {
+ for (let index = 0; index < exportRows.length; index += 1) {
+ if (callback(exportRows[index], exportIds[index]) === false) return
+ }
+ },
forEachRow: callback => forEachRow(data, callback),
getDisplayIndex: (id, { leaf = false } = {}) =>
(leaf ? lastDisplayIndexById : displayIndexById).get(id) ??
diff --git a/src/components/table/createLargeDataSource.test.js b/src/components/table/createLargeDataSource.test.js
index b792541c..eee73aa7 100644
--- a/src/components/table/createLargeDataSource.test.js
+++ b/src/components/table/createLargeDataSource.test.js
@@ -3,6 +3,7 @@ import {
createTable,
getCoreRowModel,
getExpandedRowModel,
+ getFilteredRowModel,
getSortedRowModel,
} from "@tanstack/react-table"
@@ -193,9 +194,9 @@ describe("createLargeDataSource", () => {
expect(source.getRowId(0)).toBe("alpha")
})
- it("uses array membership filtering for array-valued columns", () => {
+ it("supports explicit array membership filtering for array-valued columns", () => {
const source = createLargeDataSource({
- columns: [{ id: "roles", accessorKey: "roles" }],
+ columns: [{ id: "roles", accessorKey: "roles", filterFn: "arrIncludes" }],
columnFilters: [{ id: "roles", value: "admin" }],
data: [
{ id: "admin", roles: ["admin", "viewer"] },
@@ -207,4 +208,33 @@ describe("createLargeDataSource", () => {
expect(source.getRowCount()).toBe(1)
expect(source.getRowId(0)).toBe("admin")
})
+
+ it("matches TanStack automatic filtering for array-valued columns", () => {
+ const arrayColumns = [{ id: "roles", accessorKey: "roles" }]
+ const arrayData = [
+ { id: "admin", roles: ["admin", "viewer"] },
+ { id: "viewer", roles: ["viewer"] },
+ ]
+ const columnFilters = [{ id: "roles", value: "admin" }]
+ const source = createLargeDataSource({
+ columns: arrayColumns,
+ columnFilters,
+ data: arrayData,
+ getRowId: row => row.id,
+ })
+ const table = createTable({
+ columns: arrayColumns,
+ data: arrayData,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getRowId: row => row.id,
+ onStateChange: () => {},
+ renderFallbackValue: null,
+ state: { columnFilters },
+ })
+
+ expect(
+ Array.from({ length: source.getRowCount() }, (_, index) => source.getRowId(index))
+ ).toEqual(table.getRowModel().rows.map(row => row.id))
+ })
})
diff --git a/src/components/table/header/actions/index.js b/src/components/table/header/actions/index.js
index e133aedb..ec252180 100644
--- a/src/components/table/header/actions/index.js
+++ b/src/components/table/header/actions/index.js
@@ -1,6 +1,7 @@
-import React, { memo, useState, useEffect } from "react"
+import React, { memo, useState, useEffect, useRef } from "react"
import Flex from "@/components/templates/flex"
import { useTableState } from "../../provider"
+import { isRowSelectable } from "../../largeDataSelection"
import useActions from "./useActions"
import Action from "./action"
import ColumnVisibility from "./columnVisibility"
@@ -12,19 +13,21 @@ const rerenderSelector = state => state.selectedRows
const useSelectedRowsObserver = (table, { onRowSelected = noop, rowSelection }) => {
useTableState(rerenderSelector)
const [selectedRows, setActualSelectedRows] = useState([])
+ const onRowSelectedRef = useRef(onRowSelected)
+ onRowSelectedRef.current = onRowSelected
useEffect(() => {
const selected = table.getSelectedOriginalRows
? table.getSelectedOriginalRows()
: table.getSelectedRowModel().flatRows.reduce((acc, { original }) => {
- if (original?.disabled || original?.unselectable) return acc
+ if (!isRowSelectable(original)) return acc
acc.push(original)
return acc
}, [])
setActualSelectedRows(selected)
- onRowSelected(selected)
+ onRowSelectedRef.current(selected)
}, [rowSelection, table, table.largeDataSource])
return selectedRows
diff --git a/src/components/table/headerOverflowTooltip.test.js b/src/components/table/headerOverflowTooltip.test.js
index 5bd122ed..1440c79f 100644
--- a/src/components/table/headerOverflowTooltip.test.js
+++ b/src/components/table/headerOverflowTooltip.test.js
@@ -2,25 +2,23 @@ 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 dataColumns = [
+ {
+ id: "dynamic",
+ accessorKey: "dynamic",
+ header: XYZ…,
+ headerString: () => "XYZ complete dynamic column name",
+ },
+ {
+ id: "literal",
+ accessorKey: "literal",
+ header: "Complete literal column name",
+ },
+]
+
+it("publishes dedicated header 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(() => {
@@ -32,5 +30,6 @@ it("publishes canonical overflow content without replacing column drag handles",
).toBeInTheDocument()
expect(container.querySelectorAll(".drag-handle[role=button]")).toHaveLength(2)
expect(container.querySelectorAll("[data-table-header-tooltip] .drag-handle")).toHaveLength(0)
+ expect(container.querySelectorAll("[data-overflow-tooltip]")).toHaveLength(0)
})
})
diff --git a/src/components/table/helpers/columnValue.js b/src/components/table/helpers/columnValue.js
new file mode 100644
index 00000000..8a113228
--- /dev/null
+++ b/src/components/table/helpers/columnValue.js
@@ -0,0 +1,8 @@
+export const getPathValue = (value, path) =>
+ path.split(".").reduce((current, key) => current?.[key], value)
+
+export 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]
+}
diff --git a/src/components/table/helpers/downloadCsv.js b/src/components/table/helpers/downloadCsv.js
index 679ff42c..8f29f4da 100644
--- a/src/components/table/helpers/downloadCsv.js
+++ b/src/components/table/helpers/downloadCsv.js
@@ -1,3 +1,5 @@
+import { getColumnValue } from "./columnValue"
+
const formatValue = value => {
if (value === null || value === undefined) return "-"
if (typeof value === "object") return JSON.stringify(value)
@@ -15,19 +17,13 @@ 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]
+ return getColumnValue(original, index, { ...(column?.columnDef || {}), id: columnId })
},
}
diff --git a/src/components/table/largeData.js b/src/components/table/largeData.js
index b6cea38f..e219cdfd 100644
--- a/src/components/table/largeData.js
+++ b/src/components/table/largeData.js
@@ -1,6 +1,6 @@
-const clamp = (value, minimum, maximum) => Math.min(Math.max(value, minimum), maximum)
+import clamp from "lodash/clamp"
-const normalizeRange = ({ startIndex = 0, endIndex = startIndex }, count) => {
+export const normalizeWindowRange = ({ startIndex = 0, endIndex = startIndex }, count) => {
const start = clamp(startIndex, 0, count)
const end = clamp(Math.max(start, endIndex), start, count)
@@ -11,7 +11,7 @@ export const containsWindowRange = (windowRange, range) =>
range.startIndex >= windowRange.startIndex && range.endIndex <= windowRange.endIndex
export const getBufferedWindowRange = (range, count, buffer = 50) =>
- normalizeRange(
+ normalizeWindowRange(
{
startIndex: range.startIndex - buffer,
endIndex: range.endIndex + buffer,
@@ -19,22 +19,8 @@ export const getBufferedWindowRange = (range, count, buffer = 50) =>
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 normalized = normalizeWindowRange(range, source.getRowCount())
const rows = []
const rowIds = []
diff --git a/src/components/table/largeData.test.js b/src/components/table/largeData.test.js
index 2f132b1b..0045a9f2 100644
--- a/src/components/table/largeData.test.js
+++ b/src/components/table/largeData.test.js
@@ -1,8 +1,8 @@
import {
containsWindowRange,
getBufferedWindowRange,
- getVirtualWindowRange,
getWindowPublication,
+ normalizeWindowRange,
} from "./largeData"
describe("large-data table window", () => {
@@ -47,17 +47,8 @@ describe("large-data table window", () => {
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({
+ it("clamps a stale window range when the logical source shrinks", () => {
+ expect(normalizeWindowRange({ startIndex: 9, endIndex: 10 }, 5)).toEqual({
startIndex: 5,
endIndex: 5,
})
diff --git a/src/components/table/largeDataSelection.js b/src/components/table/largeDataSelection.js
index 22687702..ca2617e3 100644
--- a/src/components/table/largeDataSelection.js
+++ b/src/components/table/largeDataSelection.js
@@ -1,9 +1,11 @@
+export const isRowSelectable = row => !row?.disabled && !row?.unselectable
+
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)
+ if (rowSelection[id] && isRowSelectable(row)) rows.push(row)
})
return rows
}
@@ -11,25 +13,47 @@ export const getSelectedOriginalRows = (source, rowSelection) => {
export const getIsAllRowsSelected = (source, rowSelection) => {
if (!source.getFlatRowCount() || !Object.keys(rowSelection).length) return false
+ let hasSelectable = false
let allSelected = true
- source.forEachExportRow((_, id) => {
- if (!rowSelection[id]) allSelected = false
+ source.forEachExportRow((row, id) => {
+ if (!isRowSelectable(row)) return undefined
+ hasSelectable = true
+ if (rowSelection[id]) return undefined
+ allSelected = false
+ return false
})
- return allSelected
+ return hasSelectable && allSelected
}
export const getIsSomeRowsSelected = (source, rowSelection) => {
- const selectedCount = Object.keys(rowSelection).length
- return selectedCount > 0 && selectedCount < source.getFlatRowCount()
+ let selectedCount = Object.keys(rowSelection).length
+ if (!selectedCount) return false
+
+ let selectableCount = 0
+ let selectedSelectableCount = 0
+ source.forEachExportRow((row, id) => {
+ if (!isRowSelectable(row)) {
+ if (rowSelection[id]) selectedCount -= 1
+ return
+ }
+
+ selectableCount += 1
+ if (rowSelection[id]) selectedSelectableCount += 1
+ })
+
+ return selectedCount > 0 && selectedSelectableCount < selectableCount
}
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]
+ source.forEachExportRow((row, id) => {
+ if (selected) {
+ if (isRowSelectable(row)) 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
index 0ac0f972..8fdec639 100644
--- a/src/components/table/largeDataSelection.test.js
+++ b/src/components/table/largeDataSelection.test.js
@@ -32,16 +32,19 @@ describe("large-data row selection", () => {
expect(getIsAllRowsSelected(source, { outside: true, enabled: true, disabled: true })).toBe(
true
)
+ expect(getIsAllRowsSelected(source, { outside: true, enabled: true })).toBe(true)
+ expect(getIsSomeRowsSelected(source, { enabled: true })).toBe(false)
+ expect(getIsSomeRowsSelected(source, { outside: true, enabled: true })).toBe(false)
expect(getIsSomeRowsSelected(source, { outside: true, enabled: true, disabled: true })).toBe(
false
)
+ expect(getIsSomeRowsSelected(source, { disabled: true })).toBe(false)
})
- it("toggles only the filtered result and preserves selections outside it", () => {
+ it("toggles only the selectable 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)
diff --git a/src/components/table/table.js b/src/components/table/table.js
index 79619482..ee82d2e6 100644
--- a/src/components/table/table.js
+++ b/src/components/table/table.js
@@ -1,4 +1,4 @@
-import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"
+import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react"
import {
getCoreRowModel,
getFilteredRowModel,
@@ -32,14 +32,7 @@ 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"
+import useLargeData, { augmentTableWithLargeData } from "./useLargeData"
const noop = () => {}
const emptyObj = {}
@@ -146,13 +139,6 @@ 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
@@ -178,7 +164,6 @@ 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,
@@ -190,49 +175,22 @@ 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,
- })
- }, [
+ const {
+ largeDataColumnFilters,
+ largeDataPublication,
+ largeDataSource,
+ onLargeDataColumnFiltersChange,
+ onLargeDataRangeChange,
+ } = useLargeData({
data,
dataColumns,
expanded,
+ filterFns,
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({
@@ -285,25 +243,17 @@ const Table = memo(props => {
onColumnSizingChange,
onColumnPinningChange,
onColumnOrderChange,
- ...(largeDataSource ? { onColumnFiltersChange: setLargeDataColumnFilters } : {}),
+ ...(largeDataSource ? { onColumnFiltersChange: onLargeDataColumnFiltersChange } : {}),
enableSubRowSelection,
columnGroupingMode: "reorder",
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))
- }
+ augmentTableWithLargeData(table, {
+ source: largeDataSource,
+ rowSelection,
+ onRowSelectionChange,
+ })
const prevStateRef = useRef(table.getState())
table.isEqual = (selector = identity) => {
@@ -388,7 +338,7 @@ const Table = memo(props => {
enableColumnReordering={enableColumnReordering}
largeDataSource={largeDataSource}
windowStartIndex={largeDataPublication?.startIndex || 0}
- onWindowChange={handleLargeDataRangeChange}
+ onWindowChange={onLargeDataRangeChange}
overflowTooltip={overflowTooltip}
{...rest}
{...virtualizeOptions}
diff --git a/src/components/table/useLargeData.js b/src/components/table/useLargeData.js
new file mode 100644
index 00000000..e7587e9d
--- /dev/null
+++ b/src/components/table/useLargeData.js
@@ -0,0 +1,111 @@
+import { useCallback, useMemo, useRef, useState } from "react"
+import { containsWindowRange, getBufferedWindowRange, getWindowPublication } from "./largeData"
+import createLargeDataSource from "./createLargeDataSource"
+import {
+ getIsAllRowsSelected,
+ getIsSomeRowsSelected,
+ getNextRowSelection,
+ getSelectedOriginalRows,
+} from "./largeDataSelection"
+
+export const augmentTableWithLargeData = (
+ table,
+ { source, rowSelection, onRowSelectionChange }
+) => {
+ table.largeDataSource = source
+ table.forEachExportRow = source?.forEachExportRow
+
+ if (!source?.forEachRow || !source?.forEachExportRow || !source?.getFlatRowCount) return
+
+ table.getSelectedOriginalRows = () => getSelectedOriginalRows(source, rowSelection)
+ table.getIsAllRowsSelected = () => getIsAllRowsSelected(source, rowSelection)
+ table.getIsSomeRowsSelected = () => getIsSomeRowsSelected(source, rowSelection)
+ table.toggleAllRowsSelected = value =>
+ onRowSelectionChange(current => getNextRowSelection(source, current, value))
+}
+
+export default ({
+ data,
+ dataColumns,
+ expanded,
+ filterFns,
+ getRowId,
+ globalFilter,
+ largeDataOptions,
+ sorting,
+}) => {
+ const {
+ enabled,
+ filterRow,
+ getEstimatedRowHeight,
+ initialRowCount,
+ sortingFns,
+ source: providedSource,
+ } = largeDataOptions || {}
+
+ const [largeDataRange, setLargeDataRange] = useState(() => ({
+ startIndex: 0,
+ endIndex: initialRowCount || 50,
+ }))
+ const largeDataRangeRef = useRef(largeDataRange)
+ largeDataRangeRef.current = largeDataRange
+
+ const [largeDataColumnFilters, setLargeDataColumnFilters] = useState([])
+
+ const largeDataSource = useMemo(() => {
+ if (providedSource) return providedSource
+ if (!enabled) return null
+ if (!getRowId) throw new Error("Large-data Table requires getRowId")
+
+ return createLargeDataSource({
+ columns: dataColumns,
+ columnFilters: largeDataColumnFilters,
+ data,
+ expanded,
+ filterRow: globalFilter && filterRow ? row => filterRow(row, globalFilter) : undefined,
+ filterFns,
+ getEstimatedRowHeight,
+ getRowId,
+ sorting,
+ sortingFns,
+ })
+ }, [
+ data,
+ dataColumns,
+ enabled,
+ expanded,
+ filterFns,
+ filterRow,
+ getEstimatedRowHeight,
+ getRowId,
+ globalFilter,
+ largeDataColumnFilters,
+ providedSource,
+ sorting,
+ sortingFns,
+ ])
+
+ const largeDataPublication = useMemo(
+ () => (largeDataSource ? getWindowPublication(largeDataSource, largeDataRange) : null),
+ [largeDataSource, largeDataRange]
+ )
+
+ const onLargeDataRangeChange = useCallback(
+ nextRange => {
+ if (containsWindowRange(largeDataRangeRef.current, nextRange)) return
+
+ const bufferedRange = getBufferedWindowRange(nextRange, largeDataSource.getRowCount())
+ largeDataRangeRef.current = bufferedRange
+ setLargeDataRange(bufferedRange)
+ },
+ [largeDataSource]
+ )
+
+ return {
+ largeDataColumnFilters,
+ largeDataPublication,
+ largeDataSource,
+ onLargeDataColumnFiltersChange: setLargeDataColumnFilters,
+ onLargeDataRangeChange,
+ }
+}
diff --git a/src/components/templates/mixins/alignItems.js b/src/components/templates/mixins/alignItems.js
index c2a79c79..7ca13a77 100644
--- a/src/components/templates/mixins/alignItems.js
+++ b/src/components/templates/mixins/alignItems.js
@@ -1,4 +1,4 @@
-const alignItemValuesMap = {
+export const alignItemValuesMap = {
start: "flex-start",
center: "center",
end: "flex-end",
diff --git a/src/components/templates/mixins/flex.js b/src/components/templates/mixins/flex.js
index 329d47f5..75cabae0 100644
--- a/src/components/templates/mixins/flex.js
+++ b/src/components/templates/mixins/flex.js
@@ -1,4 +1,4 @@
-const getFlex = (flex, basis = "auto") => {
+export const getFlex = (flex, basis = "auto") => {
if (flex === true) {
return `1 1 ${basis}`
}
diff --git a/src/components/templates/mixins/justifyContent.js b/src/components/templates/mixins/justifyContent.js
index 4ca37dfb..bdbad573 100644
--- a/src/components/templates/mixins/justifyContent.js
+++ b/src/components/templates/mixins/justifyContent.js
@@ -1,4 +1,4 @@
-const justifyContentMap = {
+export const justifyContentMap = {
start: "start",
center: "center",
end: "end",
diff --git a/src/organisms/navigation/sortable/item.test.js b/src/organisms/navigation/sortable/item.test.js
index 69bd1023..63e6850d 100644
--- a/src/organisms/navigation/sortable/item.test.js
+++ b/src/organisms/navigation/sortable/item.test.js
@@ -8,7 +8,11 @@ jest.mock("@dnd-kit/sortable", () => ({
useSortable: jest.fn(),
}))
-const Item = ({ ref }) =>
+const renderedRefs = []
+const Item = ({ ref }) => {
+ renderedRefs.push(ref)
+ return
+}
const useSortableWithStateRef = () => {
const [, setNodeRef] = React.useState(null)
@@ -26,11 +30,17 @@ const useSortableWithStateRef = () => {
}
describe("SortableItem", () => {
+ beforeEach(() => {
+ renderedRefs.length = 0
+ })
+
it("keeps its callback ref stable when the sortable ref updates state", () => {
useSortable.mockImplementation(useSortableWithStateRef)
renderWithProviders()
expect(screen.getByTestId("sortable-item")).toBeInTheDocument()
+ expect(renderedRefs.length).toBeGreaterThan(1)
+ expect(new Set(renderedRefs).size).toBe(1)
})
})