diff --git a/README.md b/README.md index a92d8b1448..7e55feacc5 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ As a JavaScript React component, the project is designed to be used in two diffe The following flags are available to toggle experimental features: - `newgraph` - From release v3.4.0. Improved graphing algorithm. (default `false`) +- `lazy` - From release v3.8.0. Improved sidebar performance. (default `false`) ### Setting flags diff --git a/src/components/lazy-list/index.js b/src/components/lazy-list/index.js new file mode 100644 index 0000000000..5519d837c5 --- /dev/null +++ b/src/components/lazy-list/index.js @@ -0,0 +1,328 @@ +import { useState, useLayoutEffect, useRef, useMemo, useCallback } from 'react'; + +/** + * A component that renders only the children currently visible on screen. + * Renders all children if not supported by browser or is disabled via the `lazy` prop. + * @param {function} height A `function(start, end)` returning the pixel height for any given range of items + * @param {number} total The total count of all items in the list + * @param {function} children A `function(props)` rendering the list and items (see `childProps`) + * @param {?number} [buffer=0.5] A number [0...1] as a % of the visible region to render additionally + * @param {?boolean} [lazy=true] Toggles the lazy functionality + * @param {?boolean} [dispose=false] Toggles disposing items when they lose visibility + * @param {?function} onChange Optional change callback + * @param {?function} container Optional, default scroll container is `element.offsetParent` + * @return {object} The rendered children + **/ +export default ({ + height, + total, + children, + lazy = true, + dispose = false, + buffer = 0.5, + onChange, + container = element => element?.offsetParent +}) => { + // Required browser feature checks + const supported = typeof window.IntersectionObserver !== 'undefined'; + + // Active only if supported and enabled else renders all children + const active = lazy && supported; + + // The range of items currently rendered + const [range, setRange] = useState([0, 0]); + const rangeRef = useRef([0, 0]); + + // List container element + const listRef = useRef(); + + // Upper placeholder element + const upperRef = useRef(); + + // Lower placeholder element + const lowerRef = useRef(); + + // Height of a single item + const itemHeight = useMemo(() => height(0, 1), [height]); + + // Height of all items + const totalHeight = useMemo(() => height(0, total), [height, total]); + + // Height of items above the rendered range + const upperHeight = useMemo(() => height(0, range[0]), [height, range]); + + // Height of items below the rendered range + const lowerHeight = useMemo(() => height(range[1], total), [ + height, + range, + total + ]); + + // Skipped if not enabled or supported + if (active) { + // Allows an update only once per frame + const requestUpdate = useRequestFrameOnce( + // Memoise the frame callback + useCallback(() => { + // Get the range of items visible in this frame + const visibleRange = visibleRangeOf( + // The list container + listRef.current, + // The list's scrolling parent container + container(listRef.current), + buffer, + total, + itemHeight + ); + + // Merge ranges + const effectiveRange = + // If dispose, render visible range only + dispose + ? visibleRange + : // If not dispose, expand current range with visible range + rangeUnion(rangeRef.current, visibleRange); + + // Avoid duplicate render calls as state is not set immediate + if (!rangeEqual(rangeRef.current, effectiveRange)) { + // Store the update in a ref immediately + rangeRef.current = effectiveRange; + + // Apply the update in the next render + setRange(effectiveRange); + } + }, [buffer, total, itemHeight, dispose, container]) + ); + + // Memoised observer options + const observerOptions = useMemo( + () => ({ + // Create a threshold point for every item + threshold: thresholds(total) + }), + [total] + ); + + // Updates on changes in visibility at the given thresholds (intersection ratios) + useIntersection(listRef, observerOptions, requestUpdate); + useIntersection(upperRef, observerOptions, requestUpdate); + useIntersection(lowerRef, observerOptions, requestUpdate); + + // Updates on changes in item dimensions + useLayoutEffect(() => requestUpdate(), [ + total, + itemHeight, + totalHeight, + requestUpdate + ]); + } + + // Memoised child props for user to apply as needed + const childProps = useMemo( + () => ({ + listRef, + upperRef, + lowerRef, + total, + start: active ? range[0] : 0, + end: active ? range[1] : total, + listStyle: { + // Relative for placeholder positioning + position: 'relative', + // List must always have the correct height + height: active ? totalHeight : undefined, + // List must always pad missing items (upper at least) + paddingTop: active ? upperHeight : undefined + }, + upperStyle: { + position: 'absolute', + display: !active ? 'none' : undefined, + height: upperHeight, + width: '100%', + // Upper placeholder must always snap to top edge + top: '0' + }, + lowerStyle: { + position: 'absolute', + display: !active ? 'none' : undefined, + height: lowerHeight, + width: '100%', + // Lower placeholder must always snap to bottom edge + bottom: '0' + } + }), + [ + active, + range, + total, + listRef, + upperRef, + lowerRef, + totalHeight, + upperHeight, + lowerHeight + ] + ); + + // Optional change callback + onChange && onChange(childProps); + + // Render the children + return children(childProps); +}; + +/** + * Returns a range in the form `[start, end]` clamped inside `[min, max]` + * @param {number} start The start of the range + * @param {number} end The end of the range + * @param {number} min The range minimum + * @param {number} max The range maximum + * @returns {array} The clamped range + */ +export const range = (start, end, min, max) => [ + Math.max(Math.min(start, max), min), + Math.max(Math.min(end, max), min) +]; + +/** + * Returns the union of both ranges + * @param {array} rangeA The first range `[start, end]` + * @param {array} rangeB The second range `[start, end]` + * @returns {array} The range union + */ +export const rangeUnion = (rangeA, rangeB) => [ + Math.min(rangeA[0], rangeB[0]), + Math.max(rangeA[1], rangeB[1]) +]; + +/** + * Returns true if the ranges have the same `start` and `end` values + * @param {array} rangeA The first range `[start, end]` + * @param {array} rangeB The second range `[start, end]` + * @returns {boolean} True if ranges are equal else false + */ +export const rangeEqual = (rangeA, rangeB) => + rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1]; + +/** + * Gets the range of items inside the container's screen bounds. + * Assumes a single fixed height for all child items. + * Only considers visibility along the vertical y-axis (i.e. only top, bottom bounds). + * @param {HTMLElement} element The target element (e.g. list container) + * @param {?HTMLElement} container The clipping container of the target (e.g. scroll container) + * @param {number} buffer A number [0...1] as a % of the container to render additionally + * @param {number} childTotal The total count of all children in the target (e.g. list row count) + * @param {number} childHeight Height of a single child element (e.g. height of one list row) + * @returns {array} The calculated range of visible items as `[start, end]` + */ +const visibleRangeOf = ( + element, + container, + buffer, + childTotal, + childHeight +) => { + // Check element exists + if (!element) { + return [0, 0]; + } + + // If no container use the element itself + if (!container) { + container = element; + } + + // Find the clipping container bounds (e.g. scroll container) + const clip = container.getBoundingClientRect(); + + // Find element bounds (e.g. list container inside scroll container) + const list = element.getBoundingClientRect(); + + // Find the number of items to buffer + const bufferCount = Math.ceil((buffer * clip.height) / childHeight); + + // When clip is fully above viewport or element is fully above clip + if (clip.bottom < 0 || list.bottom < clip.top) { + // Only bottom part of the buffer in range + return range(childTotal - bufferCount, childTotal, 0, childTotal); + } + + // Get the viewport bounds + const viewport = { + top: 0, + bottom: window.innerHeight || document.documentElement.clientHeight + }; + + // When clip is fully below viewport or element is fully below clip + if (clip.top > viewport.bottom || list.top > clip.bottom) { + // Only top part of the buffer in range + return range(0, bufferCount, 0, childTotal); + } + + // Find intersection of clip and viewport now overlap guaranteed + const top = Math.max(clip.top, viewport.top); + const bottom = Math.min(clip.bottom, viewport.bottom); + + // Find unbounded item range within the intersection + const start = Math.floor((top - list.top) / childHeight); + const end = Math.ceil((bottom - list.top) / childHeight); + + // Apply buffer and clamp unbounded range to list bounds + return range(start - bufferCount, end + bufferCount, 0, childTotal); +}; + +/** + * A hook to create a callback that runs once, at the end of the frame + * @param {function} callback The callback + * @returns {function} The wrapped callback + */ +const useRequestFrameOnce = callback => { + const request = useRef(); + + // Allow only a single callback per-frame + return useCallback(() => { + cancelAnimationFrame(request.current); + request.current = requestAnimationFrame(callback); + }, [request, callback]); +}; + +/** + * Generates an array of the form [0, ...n / total] + * except where total is `0` where it returns `[0]`. + * @param {number} total The total number of thresholds to create + * @returns {array} The threshold array + */ +export const thresholds = total => + total === 0 + ? [0] + : [...Array.from({ length: total }, (_, i) => i / total), 1]; + +/** + * A hook that creates and manages an IntersectionObserver for the given element + * @param {object} element A React.Ref from the target element + * @param {object} options An IntersectionObserver options object + * @param {function} callback A function to call with IntersectionObserver changes + */ +const useIntersection = (element, options, callback) => { + const observer = useRef(); + + // After rendering and layout + return useLayoutEffect(() => { + // Check the element is ready + if (!element.current) { + return; + } + + // Dispose any previous observer + if (observer.current) { + observer.current.disconnect(); + } + + // Create a new observer + observer.current = new window.IntersectionObserver(callback, options); + observer.current.observe(element.current); + + // Manually callback as element may already be visible + callback(); + }, [callback, element, options]); +}; diff --git a/src/components/lazy-list/lazy-list.test.js b/src/components/lazy-list/lazy-list.test.js new file mode 100644 index 0000000000..f1b20bbfc7 --- /dev/null +++ b/src/components/lazy-list/lazy-list.test.js @@ -0,0 +1,207 @@ +import React from 'react'; +import LazyList, { range, rangeUnion, rangeEqual, thresholds } from './index'; +import { setup } from '../../utils/state.mock'; + +describe('LazyList', () => { + it('renders expected visible child items with padding for non-visible items', () => { + // Settings for all test items + const itemCount = 500; + const itemHeight = 30; + + // The specific range of items to make visible in this test + const visibleStart = 10; + const visibleEnd = 40; + const visibleCount = visibleEnd - visibleStart; + + // Configure test to include some clipping and scroll conditions + const test = setupTest({ + itemCount, + itemHeight, + visibleCount, + // Viewport to fit exactly desired number of visible items + viewportHeight: itemHeight * visibleCount, + // Container larger than viewport to test clipping + containerHeight: itemHeight * visibleCount * 2, + // Container scrolled half way to desired start item to test + containerScrollY: itemHeight * visibleStart * 0.5, + // Viewport scrolled remaining half way to desired start item + viewportScrollY: itemHeight * visibleStart * 0.5 + }); + + const wrapper = setup.mount( + element?.parentElement}> + {test.listRender} + + ); + + // Generate expected items text for visible range + const expectedItemsText = Array.from( + { length: visibleCount }, + (_, i) => `Item ${visibleStart + i}` + ); + + // Get actual rendered items text + const actualItemsText = wrapper + .find('.test-item') + .map(element => element.text()); + + // Test the items are exactly as expected + expect(actualItemsText).toEqual(expectedItemsText); + + // Sanity check that not all items were rendered + expect(actualItemsText.length).toBe(visibleCount); + expect(actualItemsText.length).toBeLessThan(itemCount); + + // Test element pads the remaining non-visible items + const listElementStyle = wrapper.find('.test-list').get(0).props.style; + expect(listElementStyle.paddingTop).toBe(visibleStart * itemHeight); + expect(listElementStyle.height).toBe(itemCount * itemHeight); + }); + + it('range(from, to, min, max) returns [max(from, min), min(to, max)]', () => { + expect(range(0, 1, 0, 1)).toEqual([0, 1]); + expect(range(-1, 1, 0, 1)).toEqual([0, 1]); + expect(range(-1, 2, 0, 1)).toEqual([0, 1]); + }); + + it('rangeUnion(a, b) returns [min(a[0], b[0]), max(a[1], b[1])]', () => { + expect(rangeUnion([3, 7], [2, 10])).toEqual([2, 10]); + expect(rangeUnion([2, 10], [3, 7])).toEqual([2, 10]); + expect(rangeUnion([1, 7], [2, 10])).toEqual([1, 10]); + expect(rangeUnion([3, 11], [2, 10])).toEqual([2, 11]); + expect(rangeUnion([1, 11], [2, 10])).toEqual([1, 11]); + }); + + it('rangeEqual(a, b) returns true if a[0] = b[0] && a[1] = b[1]', () => { + expect(rangeEqual([1, 2], [1, 2])).toBe(true); + expect(rangeEqual([1, 2], [1, 3])).toBe(false); + expect(rangeEqual([1, 2], [3, 1])).toBe(false); + }); + + it('thresholds(t) returns [0, ...n / t] except t = `0` returns `[0]`', () => { + expect(thresholds(0)).toEqual([0]); + expect(thresholds(1)).toEqual([0, 1]); + expect(thresholds(2)).toEqual([0, 1 / 2, 1]); + expect(thresholds(3)).toEqual([0, 1 / 3, 2 / 3, 1]); + expect(thresholds(4)).toEqual([0, 1 / 4, 2 / 4, 3 / 4, 1]); + }); +}); + +// Sets up the test data and environment +const setupTest = ({ + itemCount, + itemHeight, + visibleCount, + viewportHeight, + viewportScrollY, + containerHeight, + containerScrollY +}) => { + // Generate test data and settings + const items = Array.from({ length: itemCount }, (_, i) => i); + const itemHeights = (start, end) => (end - start) * itemHeight; + const itemWidth = itemHeight * 5; + const containerWidth = itemWidth; + const listWidth = itemWidth; + + // List render function + const listRender = ({ + start, + end, + listRef, + upperRef, + lowerRef, + listStyle, + upperStyle, + lowerStyle + }) => ( + <> + {/* Scroll container */} +
+ {/* List container */} + +
+ + ); + + // Emulate the browser window viewport height + window.innerHeight = viewportHeight; + + // Emulate RAF with immediate callback + window.requestAnimationFrame = cb => cb(0); + + // Emulate IntersectionObserver with immediate callback + window.IntersectionObserver = function(cb) { + return { + observe: () => cb(), + disconnect: () => null + }; + }; + + // Gets the React instance for a React node + const getInstance = node => { + const key = Object.keys(node).find(key => + key.startsWith('__reactInternalInstance') + ); + return node[key]; + }; + + // Emulate element bounds as if rendered + window.Element.prototype.getBoundingClientRect = function() { + const instance = getInstance(this); + + // Check which element this is (list or container) + const isList = instance?.type === 'ul'; + + // Set by `style` in `listRender` + const width = Number.parseInt(this.style.width) || 0; + const height = Number.parseInt(this.style.height) || 0; + + // Find offset for viewport scroll and container scroll + const offsetY = -viewportScrollY - (isList ? containerScrollY : 0); + + // Return bounds in screen space as expected + return { + x: 0, + y: offsetY, + top: offsetY, + bottom: offsetY + height, + left: 0, + right: width, + width: width, + height: height + }; + }; + + // Return the test setup + return { + items, + visibleCount, + itemHeights, + listRender + }; +}; diff --git a/src/components/node-list/node-list-group.js b/src/components/node-list/node-list-group.js index 65f4355226..8badccd942 100644 --- a/src/components/node-list/node-list-group.js +++ b/src/components/node-list/node-list-group.js @@ -1,12 +1,11 @@ import React from 'react'; import classnames from 'classnames'; import NodeListRow from './node-list-row'; +import NodeRowList from './node-list-row-list'; export const NodeListGroup = ({ - container: Container = 'div', - childrenContainer: ChildrenContainer = 'div', - childrenClassName, - children, + items, + group, collapsed, id, name, @@ -18,9 +17,13 @@ export const NodeListGroup = ({ visibleIcon, invisibleIcon, onToggleChecked, - onToggleCollapsed + onToggleCollapsed, + onItemClick, + onItemChange, + onItemMouseEnter, + onItemMouseLeave }) => ( - - - - {children} - - + + ); export default NodeListGroup; diff --git a/src/components/node-list/node-list-group.test.js b/src/components/node-list/node-list-group.test.js index adb9f1550c..7cb0b34480 100644 --- a/src/components/node-list/node-list-group.test.js +++ b/src/components/node-list/node-list-group.test.js @@ -11,16 +11,6 @@ describe('NodeListGroup', () => { ).not.toThrow(); }); - it('renders children', () => { - const type = getNodeTypes(mockState.animals)[0]; - const wrapper = setup.mount( - -
- - ); - expect(wrapper.find('.test-child').length).toBe(1); - }); - it('handles checkbox change events', () => { const type = getNodeTypes(mockState.animals)[0]; const onToggleChecked = jest.fn(); @@ -50,11 +40,23 @@ describe('NodeListGroup', () => { expect(onToggleCollapsed.mock.calls.length).toEqual(1); }); - it('hides children when collapsed class is used', () => { + it('adds class when collapsed prop true', () => { const type = getNodeTypes(mockState.animals)[0]; const wrapper = setup.mount( ); - expect(wrapper.find('.pipeline-nodelist__list--nested').length).toEqual(0); + const children = wrapper.find('.pipeline-nodelist__children'); + expect(children.hasClass('pipeline-nodelist__children--closed')).toBe(true); + }); + + it('removes class when collapsed prop false', () => { + const type = getNodeTypes(mockState.animals)[0]; + const wrapper = setup.mount( + + ); + const children = wrapper.find('.pipeline-nodelist__children'); + expect(children.hasClass('pipeline-nodelist__children--closed')).toBe( + false + ); }); }); diff --git a/src/components/node-list/node-list-groups.js b/src/components/node-list/node-list-groups.js index 40ba831c9e..31706557ee 100644 --- a/src/components/node-list/node-list-groups.js +++ b/src/components/node-list/node-list-groups.js @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { loadState, saveState } from '../../store/helpers'; import NodeListGroup from './node-list-group'; -import NodeListRow from './node-list-row'; const storedState = loadState(); @@ -35,9 +34,8 @@ const NodeListGroups = ({ const group = groups[typeId]; return ( - {(items[group.id] || []).map(item => ( - onItemClick(item)} - onMouseEnter={() => onItemMouseEnter(item)} - onMouseLeave={() => onItemMouseLeave(item)} - onChange={e => onItemChange(item, !e.target.checked)} - /> - ))} - + onToggleChecked={onToggleGroupChecked} + onItemClick={onItemClick} + onItemChange={onItemChange} + onItemMouseEnter={onItemMouseEnter} + onItemMouseLeave={onItemMouseLeave} + /> ); })} diff --git a/src/components/node-list/node-list-row-list.js b/src/components/node-list/node-list-row-list.js new file mode 100644 index 0000000000..59781ca233 --- /dev/null +++ b/src/components/node-list/node-list-row-list.js @@ -0,0 +1,130 @@ +import React from 'react'; +import modifiers from '../../utils/modifiers'; +import { connect } from 'react-redux'; +import NodeListRow, { nodeListRowHeight } from './node-list-row'; +import LazyList from '../lazy-list'; + +const NodeRowList = ({ + items = [], + group, + collapsed, + onItemClick, + onItemChange, + onItemMouseEnter, + onItemMouseLeave +}) => ( +
    + {items.map(item => ( + onItemClick(item)} + onMouseEnter={() => onItemMouseEnter(item)} + onMouseLeave={() => onItemMouseLeave(item)} + onChange={e => onItemChange(item, !e.target.checked)} + /> + ))} +
+); + +const NodeRowLazyList = ({ + items = [], + group, + collapsed, + onItemClick, + onItemChange, + onItemMouseEnter, + onItemMouseLeave +}) => ( + (end - start) * nodeListRowHeight} + total={items.length}> + {({ + start, + end, + total, + listRef, + upperRef, + lowerRef, + listStyle, + upperStyle, + lowerStyle + }) => ( +
    +
  • 0 + })} + ref={upperRef} + style={upperStyle} + /> +
  • + {items.slice(start, end).map(item => ( + onItemClick(item)} + onMouseEnter={() => onItemMouseEnter(item)} + onMouseLeave={() => onItemMouseLeave(item)} + onChange={e => onItemChange(item, !e.target.checked)} + /> + ))} +
+ )} +
+); + +export const mapStateToProps = (state, ownProps) => ({ + lazy: state.flags.lazy, + ...ownProps +}); + +export default connect(mapStateToProps)(props => + props.lazy ? : +); diff --git a/src/components/node-list/node-list-row.js b/src/components/node-list/node-list-row.js index 149f91a06f..a7e05cd0cb 100644 --- a/src/components/node-list/node-list-row.js +++ b/src/components/node-list/node-list-row.js @@ -1,122 +1,150 @@ -import React from 'react'; +import React, { memo } from 'react'; import { connect } from 'react-redux'; import classnames from 'classnames'; +import { changed } from '../../utils'; import NodeIcon from '../icons/node-icon'; import VisibleIcon from '../icons/visible'; import InvisibleIcon from '../icons/invisible'; import { getNodeActive } from '../../selectors/nodes'; -const NodeListRow = ({ - container: Container = 'div', - active, - checked, - unset, - children, - disabled, - faded, - visible, - id, - label, - name, - kind, - onMouseEnter, - onMouseLeave, - onChange, - onClick, - selected, - type, - visibleIcon = VisibleIcon, - invisibleIcon = InvisibleIcon -}) => { - const VisibilityIcon = checked ? visibleIcon : invisibleIcon; +// The exact fixed height of a row as measured by getBoundingClientRect() +export const nodeListRowHeight = 36.59375; - return ( - - + {children} + - ); -}; + + + ); + }, + shouldMemo +); export const mapStateToProps = (state, ownProps) => ({ ...ownProps, diff --git a/src/components/node-list/styles/_group.scss b/src/components/node-list/styles/_group.scss index 436010289a..73ce184b5e 100644 --- a/src/components/node-list/styles/_group.scss +++ b/src/components/node-list/styles/_group.scss @@ -1,3 +1,5 @@ +@import '../../../styles/variables'; + %nolist { margin: 0; padding: 0; @@ -16,12 +18,57 @@ } .pipeline-nodelist__children { + // Avoid placeholder fade leaking out for small lists + overflow: hidden; + &--closed { display: none; } } } +$placeholder-fade: 120px; + +.pipeline-nodelist__placeholder-upper, +.pipeline-nodelist__placeholder-lower { + z-index: 2; + pointer-events: none; +} + +.pipeline-nodelist__placeholder-upper:after, +.pipeline-nodelist__placeholder-lower:after { + position: absolute; + width: 100%; + height: $placeholder-fade; + opacity: 0; + transition: opacity ease 0.3s; + content: ' '; + pointer-events: none; +} + +.pipeline-nodelist__placeholder-upper:after { + bottom: -$placeholder-fade; + background: linear-gradient( + 0deg, + var(--nodelist-bg-transparent) 0%, + var(--color-bg-3) 100% + ); +} + +.pipeline-nodelist__placeholder-lower:after { + top: -$placeholder-fade; + background: linear-gradient( + 0deg, + var(--color-bg-3) 0%, + var(--nodelist-bg-transparent) 100% + ); +} + +.pipeline-nodelist__placeholder-upper--fade:after, +.pipeline-nodelist__placeholder-lower--fade:after { + opacity: 1; +} + .pipeline-nodelist__heading { margin: 0; diff --git a/src/components/node-list/styles/_row.scss b/src/components/node-list/styles/_row.scss index ed33c33a79..a185c88c8c 100644 --- a/src/components/node-list/styles/_row.scss +++ b/src/components/node-list/styles/_row.scss @@ -1,3 +1,11 @@ +/* + + Notes: + - any change to row height must be reflected here: + `nodeListRowHeight` in node-list-row.js + +*/ + .pipeline-nodelist__row { position: relative; display: flex; diff --git a/src/config.js b/src/config.js index fc46c0dcf1..7dc7f0ade8 100644 --- a/src/config.js +++ b/src/config.js @@ -25,6 +25,11 @@ export const flags = { default: false, private: false, icon: '📈' + }, + lazy: { + description: 'Improved sidebar performance', + default: false, + icon: '😴' } }; diff --git a/src/selectors/linked-nodes.js b/src/selectors/linked-nodes.js index 802b316d2b..03e8740d7a 100644 --- a/src/selectors/linked-nodes.js +++ b/src/selectors/linked-nodes.js @@ -3,6 +3,7 @@ import { getVisibleEdges } from './edges'; const getClickedNode = state => state.node.clicked; const getHoveredNode = state => state.node.hovered; +const getLazyFlag = state => state.flags.lazy; /** * Get the node that should be used as the center of the set of linked nodes @@ -10,8 +11,9 @@ const getHoveredNode = state => state.node.hovered; * @param {string} nodeID */ export const getCentralNode = createSelector( - [getClickedNode, getHoveredNode], - (clickedNode, hoveredNode) => clickedNode || hoveredNode + [getClickedNode, getHoveredNode, getLazyFlag], + (clickedNode, hoveredNode, lazy) => + lazy ? clickedNode : clickedNode || hoveredNode ); /** diff --git a/src/utils/index.js b/src/utils/index.js index 4819377ef4..d428c05092 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -42,3 +42,17 @@ export const getUrl = (type, id) => { * @param {Array} arr The array to remove duplicate values from */ export const unique = (d, i, arr) => arr.indexOf(d) === i; + +/** + * Returns true if any of the given props are different between given objects. + * Only shallow changes are detected. + * @param {Array} props The prop names to check + * @param {object} objectA The first object + * @param {object} objectB The second object + * @returns {boolean} True if any prop changed else false + */ +export const changed = (props, objectA, objectB) => { + return ( + objectA && objectB && props.some(prop => objectA[prop] !== objectB[prop]) + ); +}; diff --git a/tools/test-lib/react-app/app.test.js b/tools/test-lib/react-app/app.test.js index 027b52ce6d..6947807e2b 100644 --- a/tools/test-lib/react-app/app.test.js +++ b/tools/test-lib/react-app/app.test.js @@ -23,7 +23,7 @@ describe('lib-test', () => { const firstNodeName = wrapper .find('.pipeline-nodelist__group--type-task') .find('.pipeline-nodelist__list--nested') - .find('NodeListRow') + .find('.pipeline-nodelist__row__label') .first() .text(); if (key === 'random') {