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 */}
+
+ {/* Upper placeholder */}
+
+ {/* Lower placeholder */}
+
+ {/* List items in visible range */}
+ {items.slice(start, end).map(i => (
+ -
+ Item {i}
+
+ ))}
+
+
+ >
+ );
+
+ // 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') {