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 (
-