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) }) })