From 00349de3b00b608ff59ff9257a4c4c94c55e6f18 Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Thu, 7 Nov 2024 13:41:28 +0000 Subject: [PATCH] Update flowchart-wrapper.js --- .../flowchart-wrapper/flowchart-wrapper.js | 1294 ++++++++++++----- 1 file changed, 958 insertions(+), 336 deletions(-) diff --git a/src/components/flowchart-wrapper/flowchart-wrapper.js b/src/components/flowchart-wrapper/flowchart-wrapper.js index 73fec5327..58d300c30 100644 --- a/src/components/flowchart-wrapper/flowchart-wrapper.js +++ b/src/components/flowchart-wrapper/flowchart-wrapper.js @@ -1,426 +1,1048 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; +import React, { Component } from 'react'; import { connect } from 'react-redux'; import classnames from 'classnames'; -import { isLoading } from '../../selectors/loading'; -import { - getModularPipelinesTree, - getNodeFullName, -} from '../../selectors/nodes'; -import { getVisibleMetaSidebar } from '../../selectors/metadata'; +import { select } from 'd3-selection'; +import { updateChartSize, updateZoom } from '../../actions'; import { + toggleSingleModularPipelineExpanded, toggleModularPipelineActive, - toggleModularPipelinesExpanded, } from '../../actions/modular-pipelines'; -import { toggleFocusMode } from '../../actions'; -import { loadNodeData } from '../../actions/nodes'; -import { loadPipelineData } from '../../actions/pipelines'; -import ExportModal from '../export-modal'; -import FlowChart from '../flowchart'; -import PipelineWarning from '../pipeline-warning'; -import LoadingIcon from '../icons/loading'; -import AlertIcon from '../icons/alert'; -import MetaData from '../metadata'; -import MetadataModal from '../metadata-modal'; -import ShareableUrlMetadata from '../shareable-url-modal/shareable-url-metadata'; -import Sidebar from '../sidebar'; -import Button from '../ui/button'; -import CircleProgressBar from '../ui/circle-progress-bar'; -import { loadLocalStorage, saveLocalStorage } from '../../store/helpers'; import { - errorMessages, - linkToFlowchartInitialVal, - localStorageFlowchartLink, - localStorageName, - localStorageBannerStatus, - params, - BANNER_METADATA, - BANNER_KEYS, -} from '../../config'; -import { findMatchedPath } from '../../utils/match-path'; -import { getKeyByValue, getKeysByValue } from '../../utils/object-utils'; -import { isRunningLocally, mapNodeTypes } from '../../utils'; -import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; -import './flowchart-wrapper.scss'; -import Banner from '../ui/banner'; + loadNodeData, + toggleNodeHovered, + toggleNodeClicked, +} from '../../actions/nodes'; +import { + applySlicePipeline, + setSlicePipeline, + resetSlicePipeline, +} from '../../actions/slice'; +import { + getNodeActive, + getNodeSelected, + getNodesWithInputParams, + getInputOutputNodesForFocusedModularPipeline, +} from '../../selectors/nodes'; +import { getInputOutputDataEdges } from '../../selectors/edges'; +import { getChartSize, getChartZoom } from '../../selectors/layout'; +import { getSlicedPipeline } from '../../selectors/sliced-pipeline'; +import { getLayers } from '../../selectors/layers'; +import { getLinkedNodes } from '../../selectors/linked-nodes'; +import { getVisibleMetaSidebar } from '../../selectors/metadata'; +import { getRunCommand } from '../../selectors/run-command'; +import { drawNodes, drawEdges, drawLayers, drawLayerNames } from './draw'; +import { + viewing, + isOrigin, + viewTransformToFit, + setViewTransform, + getViewTransform, + setViewTransformExact, + setViewExtents, + getViewExtents, +} from '../../utils/view'; +import { getHeap } from '../../tracking/index'; import { getDataTestAttribute } from '../../utils/get-data-test-attribute'; +import Tooltip from '../ui/tooltip'; +import { SlicedPipelineActionBar } from '../sliced-pipeline-action-bar/sliced-pipeline-action-bar'; +import { SlicedPipelineNotification } from '../sliced-pipeline-notification/sliced-pipeline-notification'; +import { FeedbackButton } from '../feedback-button/feedback-button'; +import { FeedbackForm } from '../feedback-form/feedback-form'; +import { loadLocalStorage } from '../../store/helpers'; +import { localStorageFeedbackSeen } from '../../config'; + +import './styles/flowchart.scss'; + +export const feedbacks = { + slicingPipeline: { + formTitle: [ + 'How satisfied are you with', +
, + 'pipeline slicing?', + ], + buttonTittle: 'Feedback for pipeline slicing', + usageContext: 'slicing-pipeline', + }, +}; /** - * Main flowchart container. Handles showing/hiding the sidebar nav for flowchart view, - * the rendering of the flowchart, as well as the display of all related modals. + * Display a pipeline flowchart, mostly rendered with D3 */ -export const FlowChartWrapper = ({ - fullNodeNames, - displaySidebar, - graph, - loading, - metadataVisible, - modularPipelinesTree, - nodes, - onToggleFocusMode, - onToggleModularPipelineActive, - onToggleModularPipelineExpanded, - onToggleNodeSelected, - onUpdateActivePipeline, - pipelines, - sidebarVisible, - activePipeline, - tag, - nodeType, - expandAllPipelines, - displayMetadataPanel, - displayExportBtn, - displayBanner, -}) => { - const history = useHistory(); - const { pathname, search } = useLocation(); - const searchParams = new URLSearchParams(search); - const { toSetQueryParam } = useGeneratePathname(); - - const [errorMessage, setErrorMessage] = useState({}); - const [isInvalidUrl, setIsInvalidUrl] = useState(false); - const [usedNavigationBtn, setUsedNavigationBtn] = useState(false); - - const [counter, setCounter] = useState(60); - const [goBackToExperimentTracking, setGoBackToExperimentTracking] = - useState(false); - - const graphRef = useRef(null); - - const { - matchedFlowchartMainPage, - matchedSelectedPipeline, - matchedSelectedNodeId, - matchedSelectedNodeName, - matchedFocusedNode, - } = findMatchedPath(pathname, search); +export class FlowChart extends Component { + constructor(props) { + super(props); + + this.state = { + tooltip: { visible: false }, + activeLayer: undefined, + slicedPipelineState: { + from: null, + to: null, + range: [], + }, + showSlicingNotification: false, + resetSlicingPipelineBtnClicked: false, + showFeedbackForm: false, + }; + this.onViewChange = this.onViewChange.bind(this); + this.onViewChangeEnd = this.onViewChangeEnd.bind(this); + + this.containerRef = React.createRef(); + this.svgRef = React.createRef(); + this.wrapperRef = React.createRef(); + this.edgesRef = React.createRef(); + this.nodesRef = React.createRef(); + this.layersRef = React.createRef(); + this.layerNamesRef = React.createRef(); + this.slicedPipelineActionBarRef = React.createRef(); + + this.DURATION = 700; + this.MARGIN = 500; + this.MIN_SCALE = 0.8; + this.MAX_SCALE = 2; + } + + componentDidMount() { + this.selectD3Elements(); + this.updateChartSize(); + + this.view = viewing({ + container: this.svgRef, + wrapper: this.wrapperRef, + onViewChanged: this.onViewChange, + onViewEnd: this.onViewChangeEnd, + }); + + this.updateViewExtents(); + this.addGlobalEventListeners(); + this.update(); + + if (this.props.tooltip) { + this.showTooltip(null, null, this.props.tooltip); + } else { + this.hideTooltip(); + } + } /** - * On initial load & when user switch active pipeline, - * sets the query params from local storage based on NodeType, tag, expandAllPipelines and active pipeline. - * @param {string} activePipeline - The active pipeline. + * Updates the state of the sliced pipeline with new values for 'from', 'to', and 'range'. */ - const setParamsFromLocalStorage = (activePipeline) => { - const localStorageParams = loadLocalStorage(localStorageName); - if (localStorageParams) { - const paramActions = { - pipeline: (value) => { - if (activePipeline) { - toSetQueryParam(params.pipeline, value.active || activePipeline); - } - }, - tag: (value) => { - const enabledKeys = getKeysByValue(value.enabled, true); - enabledKeys && toSetQueryParam(params.tags, enabledKeys); - }, - nodeType: (value) => { - const disabledKeys = getKeysByValue(value.disabled, false); - // Replace task with node to keep UI label & the URL consistent - const mappedDisabledNodes = mapNodeTypes(disabledKeys); - disabledKeys && toSetQueryParam(params.types, mappedDisabledNodes); - }, - expandAllPipelines: (value) => toSetQueryParam(params.expandAll, value), - }; + updateSlicedPipelineState(from, to, range) { + this.setState({ + slicedPipelineState: { + ...this.state.slicedPipelineState, + from, + to, + range, + }, + }); + } + + componentWillUnmount() { + this.removeGlobalEventListeners(); + } + + componentDidUpdate(prevProps) { + this.update(prevProps); - for (const [key, value] of Object.entries(localStorageParams)) { - if (paramActions[key]) { - paramActions[key](value); - } + const { from, to } = this.state.slicedPipelineState; + + const isSlicedPipelineChanged = + this.props.slicedPipeline !== prevProps.slicedPipeline; + const isSlicedPipelineEmpty = this.props.slicedPipeline.length === 0; + const isSlicedPipelineStateDefined = from !== null && to !== null; + + if (isSlicedPipelineChanged) { + // Reset local state to null if the redux state's SlicedPipeline is empty, + // but the local state still has 'from' and 'to' values defined. + if (isSlicedPipelineEmpty && isSlicedPipelineStateDefined) { + this.updateSlicedPipelineState(null, null, []); + } else { + this.updateSlicedPipelineState(from, to, this.props.slicedPipeline); } } - }; - useEffect(() => { - setParamsFromLocalStorage(activePipeline); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activePipeline, tag, nodeType, expandAllPipelines]); + // Hide slicing notification if metadata panel is closed using button + if ( + this.props.clickedNode !== prevProps.clickedNode && + !this.props.clickedNode + ) { + this.setState({ showSlicingNotification: false }); + } + } - const resetErrorMessage = () => { - setErrorMessage({}); - setIsInvalidUrl(false); - }; + /** + * Updates drawing and zoom if props have changed + */ + update(prevProps = {}) { + const { chartZoom } = this.props; + const changed = (...names) => this.changed(names, prevProps, this.props); + const preventZoom = this.props.visibleMetaSidebar; - const checkIfPipelineExists = () => { - const pipelineId = searchParams.get(params.pipeline); - const foundPipeline = pipelines.find((id) => id === pipelineId); + if (changed('visibleSidebar', 'visibleCode', 'visibleMetaSidebar')) { + this.updateChartSize(); + } - if (!foundPipeline) { - setErrorMessage(errorMessages.pipeline); - setIsInvalidUrl(true); + if (changed('layers', 'chartSize')) { + drawLayers.call(this); + drawLayerNames.call(this); } - }; - const redirectSelectedPipeline = () => { - const pipelineId = searchParams.get(params.pipeline); - const foundPipeline = pipelines.find((id) => id === pipelineId); + if ( + changed( + 'edges', + 'clickedNode', + 'linkedNodes', + 'focusMode', + 'inputOutputDataEdges' + ) + ) { + drawEdges.call(this, changed); + } - if (foundPipeline) { - onUpdateActivePipeline(foundPipeline); - onToggleNodeSelected(null); - onToggleFocusMode(null); - } else { - setErrorMessage(errorMessages.pipeline); - setIsInvalidUrl(true); + if ( + changed( + 'nodes', + 'clickedNode', + 'linkedNodes', + 'nodeTypeDisabled', + 'nodeActive', + 'nodeSelected', + 'hoveredParameters', + 'nodesWithInputParams', + 'focusMode', + 'inputOutputDataNodes', + 'hoveredFocusMode' + ) + ) { + drawNodes.call(this, changed); } - }; - const redirectToSelectedNode = () => { - const node = - searchParams.get(params.selected) || - searchParams.get(params.selectedName); - - const nodeId = - getKeyByValue(fullNodeNames, node) || - Object.keys(nodes).find((nodeId) => nodeId === node); - - if (nodeId) { - const modularPipeline = nodes[nodeId]; - const modularPipelineTree = modularPipelinesTree[modularPipeline]; - const isModularPipelineChild = - modularPipelineTree?.children?.includes(nodeId); - - const isParameterType = - graph.nodes && - graph.nodes.find( - (node) => node.id === nodeId && node.type === 'parameters' - ); - - if (isModularPipelineChild && !isParameterType) { - onToggleModularPipelineExpanded(modularPipeline); - } - onToggleNodeSelected(nodeId); + if (changed('edges', 'nodes', 'layers', 'chartSize', 'clickedNode')) { + // Don't zoom out when the metadata or code panels are opened or closed + const metaSidebarViewChanged = + prevProps.visibleMetaSidebar !== this.props.visibleMetaSidebar; - if (isInvalidUrl) { - resetErrorMessage(); + const codeViewChangedWithoutMetaSidebar = + prevProps.visibleCode !== this.props.visibleCode && + !this.props.visibleMetaSidebar; + + // Don't zoom out when the clicked node changes and the nodeReFocus is disabled + const clickedNodeChangedWithoutReFocus = + prevProps.clickedNode !== this.props.clickedNode && + !this.props.nodeReFocus; + + if ( + metaSidebarViewChanged || + codeViewChangedWithoutMetaSidebar || + clickedNodeChangedWithoutReFocus + ) { + drawNodes.call(this, changed); + drawEdges.call(this, changed); + return; } + + this.resetView(preventZoom); } else { - setErrorMessage(errorMessages.node); - setIsInvalidUrl(true); + this.onChartZoomChanged(chartZoom); } + } - checkIfPipelineExists(); - }; + /** + * Returns true if any of the given props are different between given objects. + * Only shallow changes are detected. + */ + changed(props, objectA, objectB) { + return ( + objectA && + objectB && + props.some((prop) => objectA[prop] !== objectB[prop]) + ); + } - const redirectToFocusedNode = () => { - const focusedId = searchParams.get(params.focused); - const foundModularPipeline = modularPipelinesTree[focusedId]; + /** + * Create D3 element selectors + */ + selectD3Elements() { + this.el = { + svg: select(this.svgRef.current), + wrapper: select(this.wrapperRef.current), + edgeGroup: select(this.edgesRef.current), + nodeGroup: select(this.nodesRef.current), + layerGroup: select(this.layersRef.current), + layerNameGroup: select(this.layerNamesRef.current), + }; + } - if (foundModularPipeline) { - onToggleModularPipelineActive(focusedId, true); - onToggleFocusMode(foundModularPipeline.data); + /** + * Update the chart size in state from chart container bounds. + * This is emulated in tests with a constant fixed size. + */ + updateChartSize() { + if (typeof jest !== 'undefined') { + // Emulate chart size for tests + this.props.onUpdateChartSize(chartSizeTestFallback); + } else { + // Use container bounds + this.props.onUpdateChartSize( + this.containerRef.current.getBoundingClientRect() + ); + } + } - if (isInvalidUrl) { - resetErrorMessage(); - } + /** + * Add window event listeners on mount + */ + addGlobalEventListeners() { + // Add ResizeObserver to listen for any changes in the container's width/height + // (with event listener fallback) + if (window.ResizeObserver) { + this.resizeObserver = + this.resizeObserver || + new window.ResizeObserver(this.handleWindowResize); + this.resizeObserver.observe(this.containerRef.current); } else { - setErrorMessage(errorMessages.modularPipeline); - setIsInvalidUrl(true); + window.addEventListener('resize', this.handleWindowResize); } + // Print event listeners + window.addEventListener('beforeprint', this.handleBeforePrint); + window.addEventListener('afterprint', this.handleAfterPrint); + } - checkIfPipelineExists(); + /** + * Remove window event listeners on unmount + */ + removeGlobalEventListeners() { + // ResizeObserver + if (window.ResizeObserver) { + this.resizeObserver.unobserve(this.containerRef.current); + } else { + window.removeEventListener('resize', this.handleWindowResize); + } + // Print event listeners + window.removeEventListener('beforeprint', this.handleBeforePrint); + window.removeEventListener('afterprint', this.handleAfterPrint); + } + + /** + * Handle window resize + */ + handleWindowResize = () => { + this.updateChartSize(); }; - const handlePopState = useCallback(() => { - setUsedNavigationBtn((usedNavigationBtn) => !usedNavigationBtn); - }, []); + /** + * Add viewBox on window print so that the SVG can be scaled to fit + */ + handleBeforePrint = () => { + const graphSize = this.props.graphSize; + const width = graphSize.width + graphSize.marginx * 2; + const height = graphSize.height + graphSize.marginy * 2; + this.el.svg.attr('viewBox', `0 0 ${width} ${height}`); + }; - useEffect(() => { - window.addEventListener('popstate', handlePopState); + /** + * Remove viewBox once printing is done + */ + handleAfterPrint = () => { + this.el.svg.attr('viewBox', null); + }; - return () => { - window.removeEventListener('popstate', handlePopState); - }; - }, [handlePopState]); + /** + * On every frame of every view transform change (from reset, pan, zoom etc.) + * @param {Object} transform The current view transform + */ + onViewChange(transform) { + const { k: scale, x, y } = transform; + + // Apply animating class to zoom wrapper + this.el.wrapper.classed( + 'pipeline-flowchart__zoom-wrapper--animating', + true + ); - useEffect(() => { - setGoBackToExperimentTracking(loadLocalStorage(localStorageFlowchartLink)); - }, []); + // Update layer label y positions + if (this.el.layerNames) { + this.el.layerNames.style('transform', (d) => { + const updateY = y + (d.y + d.height / 2) * scale; + return `translateY(${updateY}px)`; + }); + } + + // Hide the tooltip so it doesn't get misaligned to its node + this.hideTooltip(); + + // Update extents + this.updateViewExtents(transform); + const extents = getViewExtents(this.view); + + // Share the applied zoom state with other components + this.props.onUpdateZoom({ + scale, + x, + y, + applied: true, + transition: false, + relative: false, + minScale: extents.scale.minK, + maxScale: extents.scale.maxK, + }); + } /** - * To handle redirecting to a different location via the URL (e.g. selectedNode, - * focusNode, etc.) we only need to call the matchPath actions when: - * 1. graphRef.current is null, meaning the page has just loaded - * 2. or when the user navigates using the back and forward buttons - * 3. or when invalidUrl is true, meaning the user entered something wrong in - * the URL and we should allow them to reset by clicking on a different node. + * Called when the view changes have ended (i.e. after transition ends) */ - useEffect(() => { - const isGraphEmpty = Object.keys(graph).length === 0; - if ( - (graphRef.current === null || usedNavigationBtn || isInvalidUrl) && - !isGraphEmpty - ) { - if (matchedFlowchartMainPage) { - onToggleNodeSelected(null); - onToggleFocusMode(null); + onViewChangeEnd() { + this.el.wrapper.classed( + 'pipeline-flowchart__zoom-wrapper--animating', + false + ); + } - resetErrorMessage(); - } + /** + * Updates view extents based on the current view transform. + * Offsets the extents considering any open sidebars. + * Allows additional margin for user panning within limits. + * Zoom scale is limited to a practical range for usability. + * @param {?Object} transform Current transform override + */ + updateViewExtents(transform) { + const { k: scale } = transform || getViewTransform(this.view); - if (matchedSelectedPipeline()) { - // Redirecting to a different pipeline is also handled at `preparePipelineState` - // to ensure the data is ready before being passed to here - redirectSelectedPipeline(); - } + const { + sidebarWidth = 0, + metaSidebarWidth = 0, + codeSidebarWidth = 0, + width: chartWidth = 0, + height: chartHeight = 0, + } = this.props.chartSize; - if (matchedSelectedNodeName() || matchedSelectedNodeId()) { - redirectToSelectedNode(); - } + const { width: graphWidth = 0, height: graphHeight = 0 } = + this.props.graphSize; - if (matchedFocusedNode()) { - redirectToFocusedNode(); - } + const leftSidebarOffset = sidebarWidth / scale; + const rightSidebarOffset = (metaSidebarWidth + codeSidebarWidth) / scale; + const margin = this.MARGIN; + + // Find the relative minimum scale to fit whole graph + const minScale = Math.min( + chartWidth / (graphWidth || 1), + chartHeight / (graphHeight || 1) + ); + + setViewExtents(this.view, { + translate: { + minX: -leftSidebarOffset - margin, + maxX: graphWidth + margin + rightSidebarOffset, + minY: -margin, + maxY: graphHeight + margin, + }, + scale: { + minK: this.MIN_SCALE * minScale, + maxK: this.MAX_SCALE, + }, + }); + } + + /** + * Applies the given zoom state as necessary + * @param {Object} chartZoom The new zoom state + */ + onChartZoomChanged(chartZoom) { + // No change if already applied (e.g. was an internal update) + if (chartZoom.applied) { + return; + } + + // Apply reset if it was requested + if (chartZoom.reset === true) { + this.resetView(true); + return; + } + + // Set the view while respecting extents + setViewTransform( + this.view, + { x: chartZoom.x, y: chartZoom.y, k: chartZoom.scale }, + chartZoom.transition ? this.DURATION * 0.3 : 0, + chartZoom.relative + ); + } + + /** + * Zoom and scale to fit graph and any selected node in view + */ + resetView(preventZoom) { + const { chartSize, graphSize, clickedNode, nodes } = this.props; + const { width: chartWidth, height: chartHeight } = chartSize; + const { width: graphWidth, height: graphHeight } = graphSize; - // Once all the matchPath checks are finished - // ensure the local states are reset - graphRef.current = graph; - setUsedNavigationBtn(false); + // Skip if chart or graph is not ready yet + if (!chartWidth || !graphWidth) { + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [graph, usedNavigationBtn, isInvalidUrl]); + // Sidebar offset + const offset = { x: chartSize.sidebarWidth, y: 0 }; - const resetLinkingToFlowchartLocalStorage = useCallback(() => { - saveLocalStorage(localStorageFlowchartLink, linkToFlowchartInitialVal); + // Use the selected node as focus point + const focus = clickedNode + ? nodes.find((node) => node.id === clickedNode) + : null; - setGoBackToExperimentTracking(linkToFlowchartInitialVal); - }, []); + // Find a transform that fits everything in view + const transform = viewTransformToFit({ + offset, + focus, + viewWidth: chartWidth, + viewHeight: chartHeight, + objectWidth: graphWidth, + objectHeight: graphHeight, + minScaleX: 0.2, + minScaleFocus: this.props.visibleMetaSidebar + ? this.props.chartZoom.scale + : 0.1, + focusOffset: 0, + preventZoom, + }); - useEffect(() => { - if (goBackToExperimentTracking?.showGoBackBtn) { - const timer = - counter > 0 && setInterval(() => setCounter(counter - 1), 1000); + // Detect first transform + const isFirstTransform = isOrigin(getViewTransform(this.view)); - if (counter === 0) { - resetLinkingToFlowchartLocalStorage(); + // Apply transform ignoring extents + setViewTransformExact( + this.view, + transform, + isFirstTransform ? 0 : this.DURATION, + false + ); + } + + /** + * Returns parameter count when there are more + * than one parameters and parameter name if there's a single parameter + * @param {Array} parameterNames + * @returns {String} + */ + getHoveredParameterLabel = (parameterNames) => + parameterNames.length > 1 + ? `Parameters:${parameterNames.length}` + : parameterNames[0]; + + /** + * Enable a node's focus state and highlight linked nodes + * @param {Object} event Event object + * @param {Object} node Datum for a single node + */ + handleNodeClick = (event, node) => { + const { type, id } = node; + const { onClickToExpandModularPipeline } = this.props; + + if (type === 'modularPipeline') { + onClickToExpandModularPipeline(id); + } else { + this.handleSingleNodeClick(node); + + // the hold shift only happens on clicking a node first + // but only if no filters are currently applied. + if (event.shiftKey && !this.props.isSlicingPipelineApplied) { + this.handleMultipleNodesClick(node); } + } + + event.stopPropagation(); + }; + + resetSlicedPipeline = () => { + this.props.onResetSlicePipeline(); + this.updateSlicedPipelineState(null, null, []); + this.props.toSelectedPipeline(); + }; - return () => clearInterval(timer); + handleSingleNodeClick = (node) => { + const { id } = node; + const { + displayMetadataPanel, + onLoadNodeData, + onToggleNodeClicked, + toSelectedNode, + } = this.props; + + // Handle metadata panel display or node click toggle + displayMetadataPanel ? onLoadNodeData(id) : onToggleNodeClicked(id); + toSelectedNode(node); + + const { from, to, range } = this.state.slicedPipelineState; + + this.updateSlicedPipelineState(id, to, range); + + if (!this.props.isSlicingPipelineApplied) { + // Show notification only when slicing is not applied + this.setState({ showSlicingNotification: true }); } - }, [ - counter, - goBackToExperimentTracking?.showGoBackBtn, - resetLinkingToFlowchartLocalStorage, - ]); - const onGoBackToExperimentTrackingHandler = () => { - const url = goBackToExperimentTracking.fromURL; + // Clicking on a single node should reset the sliced pipeline + // if both "from" and "to" are defined and slicing is not yet applied + if (from && to && !this.props.isSlicingPipelineApplied) { + this.props.onResetSlicePipeline(); + // Also, prepare the "from" node for the next slicing action + this.updateSlicedPipelineState(id, null, []); + // Hide notification + this.setState({ showSlicingNotification: true }); + } + }; + + /** + * Determines the correct order of nodes based on their positions. + * @param {string} fromNodeId - 'From' node ID. + * @param {string} toNodeId - 'To' node ID. + * @returns {Object} - Object containing updatedFromNodeId and updatedToNodeId. + */ + determineNodesOrder = (fromNodeId, toNodeId) => { + // Get bounding client rects of nodes + const fromNodeElement = document.querySelector(`[data-id="${fromNodeId}"]`); + const toNodeElement = document.querySelector(`[data-id="${toNodeId}"]`); + + if (!fromNodeElement || !toNodeElement) { + return { + updatedFromNodeId: null, + updatedToNodeId: null, + }; // If any element is missing, return nulls + } - history.push(url); + const fromNodeRect = fromNodeElement.getBoundingClientRect(); + const toNodeRect = toNodeElement.getBoundingClientRect(); - resetLinkingToFlowchartLocalStorage(); + // Reorder based on their Y-coordinate + return fromNodeRect.y < toNodeRect.y + ? { updatedFromNodeId: fromNodeId, updatedToNodeId: toNodeId } + : { updatedFromNodeId: toNodeId, updatedToNodeId: fromNodeId }; }; - const handleBannerClose = (bannerKey) => { - saveLocalStorage(localStorageBannerStatus, { [bannerKey]: false }); + handleMultipleNodesClick = (node) => { + // Close meta data panel + this.props.onLoadNodeData(null); + + const { from: fromNodeIdState, range } = this.state.slicedPipelineState; + + const fromNodeId = fromNodeIdState || node.id; + const toNodeId = node.id; + + this.updateSlicedPipelineState(fromNodeId, toNodeId, range); + + const { updatedFromNodeId, updatedToNodeId } = this.determineNodesOrder( + fromNodeId, + toNodeId + ); + + // Slice the pipeline based on the determined node order + // If the order could not be determined, use the original selection + if (updatedFromNodeId && updatedToNodeId) { + this.props.onSlicePipeline(updatedFromNodeId, updatedToNodeId); + } else { + this.props.onSlicePipeline(fromNodeId, toNodeId); + } + + this.props.onApplySlice(false); + this.setState({ showSlicingNotification: false }); // Hide notification after selecting the second node + + getHeap().track(getDataTestAttribute('flowchart', 'multiple-nodes-click'), { + fromNodeId, + toNodeId, + }); }; - const showBanner = (bannerKey) => { - const bannerStatus = loadLocalStorage(localStorageBannerStatus); - const shouldShowBanner = - displayBanner[bannerKey] && - (bannerStatus[bannerKey] || bannerStatus[bannerKey] === undefined); - return shouldShowBanner; + /** + * Remove a node's focus state and dim linked nodes + */ + handleChartClick = (event) => { + // If a node was previously clicked, clear the selected node data and reset the URL. + if (this.props.clickedNode) { + this.props.onLoadNodeData(null); + // To reset URL to current active pipeline when click outside of a node on flowchart + this.props.toSelectedPipeline(); + } + + // Determine if the click event occurred on the slice button. + const isSliceButtonClicked = + this.slicedPipelineActionBarRef.current && + this.slicedPipelineActionBarRef.current.contains(event.target); + + // Check if the pipeline is sliced, no slice button is clicked, and no filters are applied + if (!isSliceButtonClicked && !this.props.isSlicingPipelineApplied) { + this.resetSlicedPipeline(); + this.setState({ showSlicingNotification: false }); + } }; - if (isInvalidUrl) { - return ( -
- {displaySidebar && } - {displayMetadataPanel && } - setIsInvalidUrl(false)} - /> -
+ /** + * Enable a node's active state, show tooltip, and highlight linked nodes + * @param {Object} event Event object + * @param {Object} node Datum for a single node + */ + handleNodeMouseOver = (event, node) => { + if (node.type === 'modularPipeline') { + this.props.onToggleModularPipelineActive(node.id, true); + } else { + this.props.onToggleNodeHovered(node.id); + } + node && this.showTooltip(event, node.fullName); + }; + + /** + * Enable a layer's active state when hovering it, update labelName's active className accordingly + * @param {Object} event Event object + * @param {Object} node Datum for a single node + */ + handleLayerMouseOver = (event, node) => { + if (node) { + this.setState({ + activeLayer: node.name, + }); + } + + const { activeLayer } = this.state; + const layerName = document.querySelector( + `[data-id="layer-label--${node.name}"]` + ); + + if (activeLayer && layerName) { + layerName.classList.add('pipeline-layer-name--active'); + } + }; + + /** + * Remove the current labelName's active className when not hovering, and update layer's active state accordingly + * @param {Object} event Event object + * @param {Object} node Datum for a single node + */ + handleLayerMouseOut = (event, node) => { + const { activeLayer } = this.state; + const layerName = document.querySelector( + `[data-id="layer-label--${node.name}"]` ); - } else { + + if (activeLayer && layerName) { + layerName.classList.remove('pipeline-layer-name--active'); + } + + if (node) { + this.setState({ + activeLayer: undefined, + }); + } + }; + + /** + * Shows tooltip when the parameter indicator is hovered on + * @param {Object} event Event object + * @param {Object} node Datum for a single node + */ + handleParamsIndicatorMouseOver = (event, node) => { + const parameterNames = this.props.nodesWithInputParams[node.id]; + if (parameterNames) { + const label = this.getHoveredParameterLabel(parameterNames); + + this.showTooltip(event, label); + } + event.stopPropagation(); + }; + + /** + * Remove a node's active state, hide tooltip, and dim linked nodes + * @param {Object} node Datum for a single node + */ + handleNodeMouseOut = (event, node) => { + if (node.type === 'modularPipeline') { + this.props.onToggleModularPipelineActive(node.id, false); + } else { + this.props.onToggleNodeHovered(null); + } + this.hideTooltip(); + }; + + /** + * Handle keydown event when a node is focused + * @param {Object} event Event object + * @param {Object} node Datum for a single node + */ + handleNodeKeyDown = (event, node) => { + const ENTER = 13; + const ESCAPE = 27; + if (event.keyCode === ENTER) { + this.handleNodeClick(event, node); + } + if (event.keyCode === ESCAPE) { + this.handleChartClick(event); + this.handleNodeMouseOut(); + } + }; + + /** + * Show, fill and and position the tooltip + * @param {Object} event Event object + * @param {Object} text Text to show on the tooltip + * @param {?Object} options Options for the tooltip if required + */ + showTooltip(event, text, options = {}) { + this.setState({ + tooltip: { + targetRect: event && event.target.getBoundingClientRect(), + text: text, + visible: true, + ...options, + }, + }); + } + + /** + * Hide the tooltip + */ + hideTooltip() { + if (this.state.tooltip.visible) { + this.setState({ + tooltip: { + ...this.state.tooltip, + visible: false, + }, + }); + } + } + + /** + * Render React elements + */ + render() { + const { + chartSize, + displayGlobalNavigation, + displaySidebar, + isSlicingPipelineApplied, + layers, + onApplySlice, + runCommand, + visibleGraph, + slicedPipeline, + visibleSidebar, + clickedNode, + modularPipelineIds, + visibleSlicing, + } = this.props; + const { outerWidth = 0, outerHeight = 0 } = chartSize; + const { + showSlicingNotification, + resetSlicingPipelineBtnClicked, + showFeedbackForm, + } = this.state; + + // Counts the nodes in the slicedPipeline array, excludes any modularPipeline Id + const numberOfNodesInSlicedPipeline = slicedPipeline.filter( + (id) => !modularPipelineIds.includes(id) + ).length; + + const isFirstTimeFeedbackAfterResetSlicing = + resetSlicingPipelineBtnClicked && + loadLocalStorage(localStorageFeedbackSeen)['slicing-pipeline'] === + undefined; + + const seenSlicingFeedbackBefore = + loadLocalStorage(localStorageFeedbackSeen)['slicing-pipeline'] === false; + return ( -
- {displaySidebar && } - {displayMetadataPanel && } - {showBanner(BANNER_KEYS.LITE) && ( - } - message={{ - title: BANNER_METADATA.liteModeWarning.title, - body: BANNER_METADATA.liteModeWarning.body, - }} - btnUrl={BANNER_METADATA.liteModeWarning.docsLink} - onClose={() => handleBannerClose(BANNER_KEYS.LITE)} - dataTest={getDataTestAttribute('flowchart-wrapper', 'lite-banner')} - /> - )} -
- - -
- -
-
+ + - + + {[ + 'arrowhead', + 'arrowhead--input', + 'arrowhead--accent--input', + 'arrowhead--accent', + ].map((id) => ( + + + + ))} + + + + + + +
    + this.setState({ showFeedbackForm: true })} + title={feedbacks.slicingPipeline.buttonTittle} + visible={ + isSlicingPipelineApplied && + seenSlicingFeedbackBefore && + !showFeedbackForm + } + /> + {(isFirstTimeFeedbackAfterResetSlicing || showFeedbackForm) && ( + this.setState({ showFeedbackForm: false })} + title={feedbacks.slicingPipeline.formTitle} + usageContext={feedbacks.slicingPipeline.usageContext} + /> + )} + {showSlicingNotification && visibleSlicing && ( + + )} + + {numberOfNodesInSlicedPipeline > 0 && runCommand.length > 0 && ( +
    + onApplySlice(true)} + onResetSlicingPipeline={() => { + this.resetSlicedPipeline(); + this.setState({ resetSlicingPipelineBtnClicked: true }); + }} + ref={this.slicedPipelineActionBarRef} + runCommand={runCommand} + slicedPipelineLength={numberOfNodesInSlicedPipeline} + visibleSidebar={visibleSidebar} + />
    - {isRunningLocally() ? null : } -
- {displayExportBtn && } - + )} +
); } +} + +// Fixed chart size used in tests +export const chartSizeTestFallback = { + left: 0, + top: 0, + right: 1280, + bottom: 1024, + width: 1280, + height: 1024, }; -export const mapStateToProps = (state) => ({ - fullNodeNames: getNodeFullName(state), +// Maintain a single reference to support change detection +const emptyEdges = []; +const emptyNodes = []; +const emptyGraphSize = {}; + +export const mapStateToProps = (state, ownProps) => ({ + clickedNode: state.node.clicked, + chartSize: getChartSize(state), + chartZoom: getChartZoom(state), + displayGlobalNavigation: state.display.globalNavigation, displaySidebar: state.display.sidebar, - graph: state.graph, - loading: isLoading(state), - metadataVisible: getVisibleMetaSidebar(state), - modularPipelinesTree: getModularPipelinesTree(state), - nodes: state.node.modularPipelines, - pipelines: state.pipeline.ids, - activePipeline: state.pipeline.active, - sidebarVisible: state.visible.sidebar, - tag: state.tag.enabled, - nodeType: state.nodeType.disabled, - expandAllPipelines: state.expandAllPipelines, displayMetadataPanel: state.display.metadataPanel, - displayExportBtn: state.display.exportBtn, - displayBanner: state.showBanner, + edges: state.graph.edges || emptyEdges, + focusMode: state.visible.modularPipelineFocusMode, + graphSize: state.graph.size || emptyGraphSize, + hoveredParameters: state.hoveredParameters, + hoveredFocusMode: state.hoveredFocusMode, + layers: getLayers(state), + linkedNodes: getLinkedNodes(state), + nodes: state.graph.nodes || emptyNodes, + nodeTypeDisabled: state.nodeType.disabled, + nodeActive: getNodeActive(state), + nodeSelected: getNodeSelected(state), + nodesWithInputParams: getNodesWithInputParams(state), + modularPipelineIds: state.modularPipeline.ids, + inputOutputDataNodes: getInputOutputNodesForFocusedModularPipeline(state), + inputOutputDataEdges: getInputOutputDataEdges(state), + visibleGraph: state.visible.graph, + visibleSidebar: state.visible.sidebar, + visibleCode: state.visible.code, + visibleMetaSidebar: getVisibleMetaSidebar(state), + slicedPipeline: getSlicedPipeline(state), + isSlicingPipelineApplied: state.slice.apply, + visibleSlicing: state.visible.slicing, + nodeReFocus: state.behaviour.reFocus, + runCommand: getRunCommand(state), + ...ownProps, }); -export const mapDispatchToProps = (dispatch) => ({ - onToggleFocusMode: (modularPipeline) => { - dispatch(toggleFocusMode(modularPipeline)); +export const mapDispatchToProps = (dispatch, ownProps) => ({ + onClickToExpandModularPipeline: (modularPipelineId) => { + dispatch(toggleSingleModularPipelineExpanded(modularPipelineId)); }, - onToggleNodeSelected: (nodeID) => { - dispatch(loadNodeData(nodeID)); + onLoadNodeData: (nodeClicked) => { + dispatch(loadNodeData(nodeClicked)); + }, + onToggleNodeClicked: (id) => { + dispatch(toggleNodeClicked(id)); }, onToggleModularPipelineActive: (modularPipelineIDs, active) => { dispatch(toggleModularPipelineActive(modularPipelineIDs, active)); }, - onToggleModularPipelineExpanded: (expanded) => { - dispatch(toggleModularPipelinesExpanded(expanded)); + onToggleNodeHovered: (nodeHovered) => { + dispatch(toggleNodeHovered(nodeHovered)); + }, + onUpdateChartSize: (chartSize) => { + dispatch(updateChartSize(chartSize)); + }, + onUpdateZoom: (transform) => { + dispatch(updateZoom(transform)); + }, + onApplySlice: (apply) => { + dispatch(applySlicePipeline(apply)); + }, + onSlicePipeline: (fromID, toID) => { + dispatch(setSlicePipeline(fromID, toID)); }, - onUpdateActivePipeline: (pipelineId) => { - dispatch(loadPipelineData(pipelineId)); + onResetSlicePipeline: () => { + dispatch(resetSlicePipeline()); }, + ...ownProps, }); -export default connect(mapStateToProps, mapDispatchToProps)(FlowChartWrapper); +export default connect(mapStateToProps, mapDispatchToProps)(FlowChart);