diff --git a/.eslintrc.json b/.eslintrc.json index 8c3135079c..ffe6df2b65 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,8 +4,7 @@ "curly": ["error"], "valid-typeof": ["error"], "camelcase": "error", - "id-length": ["error", { "min": 3, "exceptions": ["_","a","b","d","e","i","j","k","x","y","id","el","pi","PI","up"] }], - "no-var": ["error"], +"id-length": ["error", { "min": 3, "exceptions": ["_","a","b","d","e","i","j","k","x","y","id","el","pi","PI","up","to"] }], "no-var": ["error"], "lines-between-class-members": ["error", "always"] } } diff --git a/RELEASE.md b/RELEASE.md index 4957e89bd6..2b6e64f130 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -10,6 +10,7 @@ Please follow the established format: ## Major features and improvements - Introduce `onActionCallback` prop in Kedro-Viz react component. (#2022) +- Slice a pipeline functionality. (#2036) ## Bug fixes and other changes diff --git a/cypress/tests/ui/flowchart/flowchart.cy.js b/cypress/tests/ui/flowchart/flowchart.cy.js index 39b4a37128..a2eee6fbde 100644 --- a/cypress/tests/ui/flowchart/flowchart.cy.js +++ b/cypress/tests/ui/flowchart/flowchart.cy.js @@ -6,13 +6,14 @@ describe('Flowchart DAG', () => { beforeEach(() => { cy.enablePrettyNames(); // Enable pretty names using the custom command + cy.wait(500); + cy.get('.feature-hints__close').click(); // Close the feature hints so can click on a node + cy.wait(500); }); it('verifies that users can expand a collapsed modular pipeline in the flowchart. #TC-23', () => { const modularPipelineText = 'feature_engineering'; - const taskNodeText = 'Create Derived Features'; - - cy.enablePrettyNames(); + const taskNodeText = 'create_derived_features'; // Assert before action cy.get('.pipeline-node > .pipeline-node__text').should( diff --git a/cypress/tests/ui/flowchart/menu.cy.js b/cypress/tests/ui/flowchart/menu.cy.js index 833c081efe..deb6d38f81 100644 --- a/cypress/tests/ui/flowchart/menu.cy.js +++ b/cypress/tests/ui/flowchart/menu.cy.js @@ -1,10 +1,11 @@ // All E2E Tests Related to Flowchart Menu goes here. -import { prettifyName } from '../../../../src/utils'; - describe('Flowchart Menu', () => { beforeEach(() => { cy.enablePrettyNames(); // Enable pretty names using the custom command + cy.wait(500); + cy.get('.feature-hints__close').click(); // Close the feature hints so can click on a node + cy.wait(500); }); it('verifies that users can select a section of the flowchart through the drop down. #TC-16', () => { @@ -144,7 +145,7 @@ describe('Flowchart Menu', () => { .invoke('text') .then((focusedNodesText) => expect(focusedNodesText.toLowerCase()).to.contains( - prettifyName(nodeToFocusText).toLowerCase() + nodeToFocusText ) ); cy.get('.pipeline-node--active > .pipeline-node__text').should( diff --git a/cypress/tests/ui/flowchart/panel.cy.js b/cypress/tests/ui/flowchart/panel.cy.js index 9f525d99a2..db246d382d 100644 --- a/cypress/tests/ui/flowchart/panel.cy.js +++ b/cypress/tests/ui/flowchart/panel.cy.js @@ -124,7 +124,7 @@ describe('Pipeline Minimap Toolbar', () => { cy.__waitForPageLoad__(() => { let initialZoomValue; let zoomInValue; - + cy.get('@zoomScale') .invoke('text') .then((text) => { @@ -155,6 +155,7 @@ describe('Pipeline Minimap Toolbar', () => { cy.get('@zoomScale') .invoke('text') .should((text) => { + initialZoomValue = parseFloat(text.replace('%', '')); expect(initialZoomValue).to.be.eq(parseFloat(text.replace('%', ''))); }); }); diff --git a/docs/source/images/slice_pipeline_multiple_click.gif b/docs/source/images/slice_pipeline_multiple_click.gif new file mode 100644 index 0000000000..fa6a6448fa Binary files /dev/null and b/docs/source/images/slice_pipeline_multiple_click.gif differ diff --git a/docs/source/images/slice_pipeline_slice_reset.gif b/docs/source/images/slice_pipeline_slice_reset.gif new file mode 100644 index 0000000000..8185ad2a5d Binary files /dev/null and b/docs/source/images/slice_pipeline_slice_reset.gif differ diff --git a/docs/source/index.md b/docs/source/index.md index 9dd3a7aa87..ea10570cfb 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -29,6 +29,7 @@ Take a look at the { + return async function (dispatch) { + dispatch({ + type: APPLY_SLICE_PIPELINE, + apply, + }); + }; +}; + +export const SET_SLICE_PIPELINE = 'SET_SLICE_PIPELINE'; + +export const setSlicePipeline = (from, to) => { + return async function (dispatch) { + dispatch({ + type: SET_SLICE_PIPELINE, + slice: { from, to }, + }); + }; +}; + +export const RESET_SLICE_PIPELINE = 'RESET_SLICE_PIPELINE'; + +export const resetSlicePipeline = () => ({ + type: RESET_SLICE_PIPELINE, + slice: { from: null, to: null }, +}); diff --git a/src/components/app/app.scss b/src/components/app/app.scss index bb1a3ea9a1..bd53527605 100644 --- a/src/components/app/app.scss +++ b/src/components/app/app.scss @@ -10,7 +10,7 @@ --color-bg-1: #{colors.$white-800}; --color-bg-2: #{colors.$grey-0}; --color-bg-3: #{colors.$white-200}; - --color-bg-4: #{colors.$white-0}; + --color-bg-4: #{colors.$white-200}; --color-bg-5: #{colors.$white-600}; --color-bg-alt: #{colors.$black-700}; --color-bg-list: #{colors.$white-100}; diff --git a/src/components/app/app.test.js b/src/components/app/app.test.js index 33d0e33c79..b56b429814 100644 --- a/src/components/app/app.test.js +++ b/src/components/app/app.test.js @@ -12,7 +12,6 @@ import { localStorageName } from '../../config'; import { prepareNonPipelineState } from '../../store/initial-state'; import reducer from '../../reducers/index'; import { TOGGLE_GRAPH_LOADING } from '../../actions/graph'; -import { prettifyName } from '../../utils/index'; describe('App', () => { const getState = (wrapper) => wrapper.instance().store.getState(); diff --git a/src/components/flowchart/draw.js b/src/components/flowchart/draw.js index d221bf4d29..f2f300bf43 100644 --- a/src/components/flowchart/draw.js +++ b/src/components/flowchart/draw.js @@ -24,6 +24,20 @@ const toSinglePoint = (value) => parseFloat(value).toFixed(1); */ const limitPrecision = (path) => path.replace(matchFloats, toSinglePoint); +/** + * Creates a mapping of node IDs to a boolean indicating if the node ID is included in the given values. + * @param {Array} nodes - Array of nodes to process. + * @param {Array} values - Array of values to check against node IDs. + * @returns {Object} An object mapping node IDs to booleans. + */ +function createNodeStateMap(nodes, values) { + const valueSet = new Set(values); // Convert to Set for efficient lookup + return nodes.reduce((acc, { id }) => { + acc[id] = valueSet.has(id); + return acc; + }, {}); +} + /** * Render layer bands */ @@ -135,7 +149,20 @@ export const drawNodes = function (changed) { nodes, focusMode, hoveredFocusMode, + isSlicingPipelineApplied, } = this.props; + const { + from: slicedPipelineFromId, + to: slicedPipelineToId, + range, + } = this.state.slicedPipelineState; + + const slicedPipelineFromTo = + slicedPipelineFromId && + slicedPipelineToId && + createNodeStateMap(nodes, [slicedPipelineFromId, slicedPipelineToId]); + + const slicedPipelineRange = createNodeStateMap(nodes, range); const isInputOutputNode = (nodeID) => focusMode !== null && inputOutputDataNodes[nodeID]; @@ -241,6 +268,17 @@ export const drawNodes = function (changed) { allNodes .classed('pipeline-node--active', (node) => nodeActive[node.id]) .classed('pipeline-node--selected', (node) => nodeSelected[node.id]) + .classed( + 'pipeline-node--sliced-pipeline', + (node) => !isSlicingPipelineApplied && slicedPipelineRange[node.id] + ) + .classed( + 'pipeline-node--from-to-sliced-pipeline', + (node) => + !isSlicingPipelineApplied && + slicedPipelineFromTo && + slicedPipelineFromTo[node.id] + ) .classed( 'pipeline-node--collapsed-hint', (node) => diff --git a/src/components/flowchart/flowchart.js b/src/components/flowchart/flowchart.js index 07e47d39f2..f8abcdf26b 100644 --- a/src/components/flowchart/flowchart.js +++ b/src/components/flowchart/flowchart.js @@ -12,6 +12,11 @@ import { toggleNodeHovered, toggleNodeClicked, } from '../../actions/nodes'; +import { + applySlicePipeline, + setSlicePipeline, + resetSlicePipeline, +} from '../../actions/slice'; import { getNodeActive, getNodeSelected, @@ -20,9 +25,11 @@ import { } 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, @@ -34,7 +41,11 @@ import { 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 './styles/flowchart.scss'; /** @@ -47,6 +58,12 @@ export class FlowChart extends Component { this.state = { tooltip: { visible: false }, activeLayer: undefined, + slicedPipelineState: { + from: null, + to: null, + range: [], + }, + showSlicingNotification: false, }; this.onViewChange = this.onViewChange.bind(this); this.onViewChangeEnd = this.onViewChangeEnd.bind(this); @@ -58,6 +75,7 @@ export class FlowChart extends Component { this.nodesRef = React.createRef(); this.layersRef = React.createRef(); this.layerNamesRef = React.createRef(); + this.slicedPipelineActionBarRef = React.createRef(); this.DURATION = 700; this.MARGIN = 500; @@ -87,12 +105,51 @@ export class FlowChart extends Component { } } + /** + * Updates the state of the sliced pipeline with new values for 'from', 'to', and 'range'. + */ + updateSlicedPipelineState(from, to, range) { + this.setState({ + slicedPipelineState: { + ...this.state.slicedPipelineState, + from, + to, + range, + }, + }); + } + componentWillUnmount() { this.removeGlobalEventListeners(); } componentDidUpdate(prevProps) { this.update(prevProps); + + 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); + } + } + + // Hide slicing notification if metadata panel is closed using button + if ( + this.props.clickedNode !== prevProps.clickedNode && + !this.props.clickedNode + ) { + this.setState({ showSlicingNotification: false }); + } } /** @@ -454,32 +511,143 @@ export class FlowChart extends Component { */ 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(); + }; + + handleSingleNodeClick = (node) => { + const { id } = node; const { - onClickToExpandModularPipeline, displayMetadataPanel, onLoadNodeData, - toSelectedNode, onToggleNodeClicked, + toSelectedNode, } = this.props; - if (type === 'modularPipeline') { - onClickToExpandModularPipeline(id); + // 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 }); + } + + // 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 + } + + const fromNodeRect = fromNodeElement.getBoundingClientRect(); + const toNodeRect = toNodeElement.getBoundingClientRect(); + + // Reorder based on their Y-coordinate + return fromNodeRect.y < toNodeRect.y + ? { updatedFromNodeId: fromNodeId, updatedToNodeId: toNodeId } + : { updatedFromNodeId: toNodeId, updatedToNodeId: fromNodeId }; + }; + + 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 { - displayMetadataPanel ? onLoadNodeData(id) : onToggleNodeClicked(id); - toSelectedNode(node); + this.props.onSlicePipeline(fromNodeId, toNodeId); } - event.stopPropagation(); + + this.props.onApplySlice(false); + this.setState({ showSlicingNotification: false }); // Hide notification after selecting the second node + + getHeap().track(getDataTestAttribute('flowchart', 'multiple-nodes-click'), { + fromNodeId, + toNodeId, + }); }; /** * Remove a node's focus state and dim linked nodes */ - handleChartClick = () => { + 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 }); + } }; /** @@ -580,7 +748,7 @@ export class FlowChart extends Component { this.handleNodeClick(event, node); } if (event.keyCode === ESCAPE) { - this.handleChartClick(); + this.handleChartClick(event); this.handleNodeMouseOut(); } }; @@ -622,13 +790,26 @@ export class FlowChart extends Component { render() { const { chartSize, - layers, - visibleGraph, displayGlobalNavigation, displaySidebar, + isSlicingPipelineApplied, + layers, + onApplySlice, + runCommand, + visibleGraph, + slicedPipeline, + visibleSidebar, + clickedNode, + modularPipelineIds, + visibleSlicing, } = this.props; const { outerWidth = 0, outerHeight = 0 } = chartSize; + const { showSlicingNotification } = this.state; + // Counts the nodes in the slicedPipeline array, excludes any modularPipeline Id + const numberOfNodesInSlicedPipeline = slicedPipeline.filter( + (id) => !modularPipelineIds.includes(id) + ).length; return (
+ {showSlicingNotification && visibleSlicing && ( + + )} + {numberOfNodesInSlicedPipeline > 0 && runCommand.length > 0 && ( +
+ onApplySlice(true)} + onResetSlicingPipeline={this.resetSlicedPipeline} + ref={this.slicedPipelineActionBarRef} + runCommand={runCommand} + slicedPipelineLength={numberOfNodesInSlicedPipeline} + visibleSidebar={visibleSidebar} + /> +
+ )} ({ 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, + runCommand: getRunCommand(state), ...ownProps, }); @@ -765,6 +974,15 @@ export const mapDispatchToProps = (dispatch, ownProps) => ({ onUpdateZoom: (transform) => { dispatch(updateZoom(transform)); }, + onApplySlice: (apply) => { + dispatch(applySlicePipeline(apply)); + }, + onSlicePipeline: (fromID, toID) => { + dispatch(setSlicePipeline(fromID, toID)); + }, + onResetSlicePipeline: () => { + dispatch(resetSlicePipeline()); + }, ...ownProps, }); diff --git a/src/components/flowchart/flowchart.test.js b/src/components/flowchart/flowchart.test.js index 0580c01b1b..fa9812df13 100644 --- a/src/components/flowchart/flowchart.test.js +++ b/src/components/flowchart/flowchart.test.js @@ -487,6 +487,11 @@ describe('FlowChart', () => { displayGlobalNavigation: expect.any(Boolean), displaySidebar: expect.any(Boolean), displayMetadataPanel: expect.any(Boolean), + slicedPipeline: expect.any(Object), + isSlicingPipelineApplied: expect.any(Boolean), + runCommand: expect.any(Object), + modularPipelineIds: expect.any(Object), + visibleSlicing: expect.any(Boolean), }; expect(mapStateToProps(mockState.spaceflights)).toEqual(expectedResult); }); diff --git a/src/components/flowchart/styles/_node.scss b/src/components/flowchart/styles/_node.scss index 913593bb11..a2dc7b8d7a 100644 --- a/src/components/flowchart/styles/_node.scss +++ b/src/components/flowchart/styles/_node.scss @@ -34,7 +34,7 @@ text, .pipeline-node__icon { - fill: #{colors.$white-600}; + fill: var(--node-icon-fill); } } @@ -113,7 +113,33 @@ .pipeline-node__icon, .pipeline-node__text { - fill: #{colors.$white-600}; + fill: var(--node-icon-fill); + } +} + +.pipeline-node--sliced-pipeline { + .pipeline-node__bg { + stroke-width: 2px; + fill: var(--node-fill-sliced); + stroke: var(--node-stroke-sliced); + } + + .pipeline-node__icon, + .pipeline-node__text { + fill: var(--node-icon-fill); + } +} + +.pipeline-node--from-to-sliced-pipeline { + .pipeline-node__bg { + stroke-width: 2px; + fill: var(--node-fill-selected); + stroke: var(--node-stroke-selected); + } + + .pipeline-node__icon, + .pipeline-node__text { + fill: var(--node-icon-fill); } } diff --git a/src/components/flowchart/styles/_variables.scss b/src/components/flowchart/styles/_variables.scss index 3ac08df7c7..bdd5fdbf2f 100644 --- a/src/components/flowchart/styles/_variables.scss +++ b/src/components/flowchart/styles/_variables.scss @@ -8,15 +8,17 @@ --node-icon-fill: #{colors.$black-800}; --node-input-fill-active: #{colors.$white-600}; --node-input-icon-selected: #{colors.$black-900}; - --node-fill-default: #{colors.$grey-100}; - --node-stroke-default: #{colors.$grey-300}; - --node-fill-active: #{colors.$blue-900}; + --node-fill-default: #{colors.$white-0}; + --node-stroke-default: #{colors.$white-600}; + --node-fill-active: #{colors.$ocean-200}; --node-stroke-active: #{colors.$blue-600}; - --node-fill-selected: #{colors.$blue-900}; - --node-stroke-selected: #{colors.$blue-300}; + --node-fill-selected: #{colors.$ocean-200}; + --node-stroke-selected: #{colors.$blue-900}; + --node-fill-sliced: #{colors.$ocean-0-50}; + --node-stroke-sliced: #{colors.$blue-900}; --node-labeltext-fill: #{colors.$black-800}; - --edge-stroke: #{colors.$black-100}; - --edge-arrowhead-fill: #{colors.$slate-0}; + --edge-stroke: #{colors.$grey-400}; + --edge-arrowhead-fill: #{colors.$grey-400}; --layer-fill: #{colors.$white-300}; --layer-text: #{colors.$black-900}; --node-input-fill: #{colors.$black-200}; @@ -48,6 +50,8 @@ --node-stroke-active: #{colors.$blue-600}; --node-fill-selected: #{colors.$blue-900}; --node-stroke-selected: #{colors.$blue-300}; + --node-fill-sliced: #{colors.$ocean-600}; + --node-stroke-sliced: #{colors.$blue-300}; --node-labeltext-fill: #{colors.$white-600}; --edge-stroke: #{colors.$black-300}; --edge-arrowhead-fill: #{colors.$white-900}; diff --git a/src/components/global-toolbar/global-toolbar.test.js b/src/components/global-toolbar/global-toolbar.test.js index a4eb6b843e..29332748e6 100644 --- a/src/components/global-toolbar/global-toolbar.test.js +++ b/src/components/global-toolbar/global-toolbar.test.js @@ -55,6 +55,7 @@ describe('GlobalToolbar', () => { settingsModal: false, shareableUrlModal: false, sidebar: true, + slicing: true, }, }; expect(mapStateToProps(mockState.spaceflights)).toEqual(expectedResult); diff --git a/src/components/icons/cut.js b/src/components/icons/cut.js new file mode 100644 index 0000000000..a447602799 --- /dev/null +++ b/src/components/icons/cut.js @@ -0,0 +1,12 @@ +import React from 'react'; + +const CutIcon = ({ className }) => ( + + + +); + +export default CutIcon; diff --git a/src/components/metadata/styles/metadata-code.scss b/src/components/metadata/styles/metadata-code.scss index 2034a50b5c..29e4ceb19b 100644 --- a/src/components/metadata/styles/metadata-code.scss +++ b/src/components/metadata/styles/metadata-code.scss @@ -3,7 +3,7 @@ @use './metadata-code-themes.scss'; .kui-theme--light { - --color-metadata-code-bg: #{variables.$grey-200}; + --color-metadata-code-bg: #{variables.$grey-0}; } .kui-theme--dark { diff --git a/src/components/metadata/styles/metadata.scss b/src/components/metadata/styles/metadata.scss index 78a85c4003..b35c612d49 100644 --- a/src/components/metadata/styles/metadata.scss +++ b/src/components/metadata/styles/metadata.scss @@ -2,7 +2,7 @@ @use '../../../styles/variables' as variables; .kui-theme--light { - --color-metadata-bg: #{variables.$grey-100}; + --color-metadata-bg: #{variables.$grey-0}; --color-metadata-preview-bg: #{variables.$grey-200}; --color-metadata-kind-token-bg: #{variables.$grey-300}; --color-plot-bg: #{variables.$white-400}; diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js index 5c25245c80..74353d8944 100644 --- a/src/components/node-list/index.js +++ b/src/components/node-list/index.js @@ -32,6 +32,7 @@ import { toggleModularPipelineDisabled, toggleModularPipelinesExpanded, } from '../../actions/modular-pipelines'; +import { resetSlicePipeline } from '../../actions/slice'; import { loadNodeData, toggleNodeHovered, @@ -67,6 +68,8 @@ const NodeListProvider = ({ focusMode, disabledModularPipeline, inputOutputDataNodes, + onResetSlicePipeline, + isSlicingPipelineApplied, }) => { const [searchValue, updateSearchValue] = useState(''); const [isResetFilterActive, setIsResetFilterActive] = useState(false); @@ -109,6 +112,10 @@ const NodeListProvider = ({ } else { onToggleNodeSelected(item.id); toSelectedNode(item); + // Reset the pipeline slicing filters if no slicing is currently applied + if (!isSlicingPipelineApplied) { + onResetSlicePipeline(); + } } } }; @@ -327,6 +334,7 @@ export const mapStateToProps = (state) => ({ disabledModularPipeline: state.modularPipeline.disabled, inputOutputDataNodes: getInputOutputNodesForFocusedModularPipeline(state), modularPipelinesTree: getModularPipelinesTree(state), + isSlicingPipelineApplied: state.slice.apply, }); export const mapDispatchToProps = (dispatch) => ({ @@ -363,6 +371,9 @@ export const mapDispatchToProps = (dispatch) => ({ onToggleFocusMode: (modularPipeline) => { dispatch(toggleFocusMode(modularPipeline)); }, + onResetSlicePipeline: () => { + dispatch(resetSlicePipeline()); + }, }); export default connect(mapStateToProps, mapDispatchToProps)(NodeListProvider); diff --git a/src/components/node-list/node-list-row-list.js b/src/components/node-list/node-list-row-list.js index 47025c3fac..4566fbaafc 100644 --- a/src/components/node-list/node-list-row-list.js +++ b/src/components/node-list/node-list-row-list.js @@ -67,6 +67,7 @@ const NodeRowList = ({ faded={item.faded} visible={item.visible} selected={item.selected} + highlight={item.highlight} allUnchecked={group.allUnchecked} visibleIcon={item.visibleIcon} invisibleIcon={item.invisibleIcon} diff --git a/src/components/node-list/node-list-row.js b/src/components/node-list/node-list-row.js index f071eb1e9d..fdabf9d584 100644 --- a/src/components/node-list/node-list-row.js +++ b/src/components/node-list/node-list-row.js @@ -27,6 +27,7 @@ const shouldMemo = (prevProps, nextProps) => 'focused', 'visible', 'selected', + 'highlight', 'label', 'children', 'count', @@ -56,6 +57,8 @@ const NodeListRow = memo( onChange, onClick, selected, + highlight, + isSlicingPipelineApplied, type, icon, visibleIcon = VisibleIcon, @@ -79,7 +82,8 @@ const NodeListRow = memo( { 'pipeline-nodelist__row--visible': visible, 'pipeline-nodelist__row--active': active, - 'pipeline-nodelist__row--selected': selected, + 'pipeline-nodelist__row--selected': + selected || (!isSlicingPipelineApplied && highlight), 'pipeline-nodelist__row--disabled': disabled, 'pipeline-nodelist__row--unchecked': !isChecked, 'pipeline-nodelist__row--overwrite': !(active || selected), diff --git a/src/components/node-list/node-list-tree-item.js b/src/components/node-list/node-list-tree-item.js index 5d7ed7319a..5a08c0ca25 100644 --- a/src/components/node-list/node-list-tree-item.js +++ b/src/components/node-list/node-list-tree-item.js @@ -13,6 +13,7 @@ const NodeListTreeItem = ({ onItemMouseLeave, onItemChange, children, + isSlicingPipelineApplied, }) => ( { const checked = !data.disabledModularPipeline; return { @@ -81,6 +84,7 @@ const getModularPipelineRowData = ({ disabled: disabled, focused: focused, checked, + highlight, }; }; @@ -90,7 +94,7 @@ const getModularPipelineRowData = ({ * @param {Boolean} selected Whether the node is currently disabled * @param {Boolean} selected Whether the node is currently selected */ -const getNodeRowData = (node, disabled, selected) => { +const getNodeRowData = (node, disabled, selected, highlight) => { const checked = !node.disabledNode; return { ...node, @@ -98,6 +102,7 @@ const getNodeRowData = (node, disabled, selected) => { invisibleIcon: InvisibleIcon, active: node.active, selected, + highlight, faded: disabled || node.disabledNode, visible: !disabled && checked, checked, @@ -118,9 +123,17 @@ const TreeListProvider = ({ disabledModularPipeline, expanded, onToggleNodeSelected, + slicedPipeline, + isSlicingPipelineApplied, }) => { // render a leaf node in the modular pipelines tree const renderLeafNode = (node) => { + // As part of the slicing pipeline logic, child nodes not included in the sliced pipeline are assigned an empty data object. + // Therefore, if a child node has an empty data object, it indicates it's not part of the slicing pipeline and should not be rendered. + if (Object.keys(node).length === 0) { + return null; + } + const disabled = node.disabledTag || node.disabledType || @@ -138,14 +151,19 @@ const TreeListProvider = ({ .some(Boolean)); const selected = nodeSelected[node.id]; + + const highlight = slicedPipeline.includes(node.id); + const data = getNodeRowData(node, disabled, selected, highlight); + return ( ); }; @@ -158,11 +176,20 @@ const TreeListProvider = ({ return; } + // If all children's data are empty, the subtree rooted at this node will not be rendered. + // in scenarios where the pipeline is being sliced, and some modular pipelines trees do not have any children + const allChildrenDataEmpty = node.children.every( + (child) => Object.keys(child.data).length === 0 + ); + if (allChildrenDataEmpty) { + return; + } + // render each child of the tree node first const children = sortBy( node.children, (child) => GROUPED_NODES_DISPLAY_ORDER[child.type], - (child) => child.data.name + (child) => child?.data?.name ).map((child) => isModularPipelineType(child.type) ? renderTree(tree, child.id) @@ -184,19 +211,29 @@ const TreeListProvider = ({ focusModeIcon = isFocusedModularPipeline ? FocusModeIcon : null; } + const isModularPipelineCollapsed = !expanded.includes(node.id); + // Highlight modular pipeline if any child node of the current modular pipeline is part of the slicedPipeline and the modular pipeline is collapsed + const highlight = + node.children.some((child) => slicedPipeline.includes(child.id)) && + isModularPipelineCollapsed; + + const data = getModularPipelineRowData({ + ...node, + focusModeIcon, + disabled: focusMode && !isOnFocusedModePath(focusMode.id, node.id), + focused: isFocusedModularPipeline, + highlight, + }); + return ( {children} @@ -236,6 +273,8 @@ const TreeListProvider = ({ export const mapStateToProps = (state) => ({ nodeSelected: getNodeSelected(state), expanded: state.modularPipeline.expanded, + slicedPipeline: getSlicedPipeline(state), + isSlicingPipelineApplied: state.slice.apply, }); export const mapDispatchToProps = (dispatch) => ({ diff --git a/src/components/node-list/styles/_panels.scss b/src/components/node-list/styles/_panels.scss index a7cd8ef2a0..20f4ef8aed 100644 --- a/src/components/node-list/styles/_panels.scss +++ b/src/components/node-list/styles/_panels.scss @@ -1,4 +1,5 @@ @use '../../../styles/variables' as colors; +@use './variables'; .pipeline-nodelist__filter-panel { z-index: 1; @@ -11,10 +12,6 @@ } } -.pipeline-nodelist-section > ul.MuiTreeView-root { - padding: 0 0 0 20px; -} - .pipeline-nodelist__elements-panel { .pipeline-nodelist-section:last-child { padding-bottom: 28px; diff --git a/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.js b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.js new file mode 100755 index 0000000000..40c4d206a4 --- /dev/null +++ b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.js @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from 'react'; +import classnames from 'classnames'; +import Button from '../ui/button'; +import CommandCopier from '../ui/command-copier/command-copier'; +import CutIcon from '../icons/cut'; +import IconButton from '../ui/icon-button'; +import { sidebarWidth, metaSidebarWidth } from '../../config'; +import { getDataTestAttribute } from '../../utils/get-data-test-attribute'; + +import './sliced-pipeline-action-bar.scss'; + +/** + * Calculate the transformX value ensuring it does not go below the minimum required for the sidebar opened and closed + */ +const calculateTransformX = ({ + screenWidth, + slicePipelineActionBarWidth, + metaDataPanelWidth, + nodeListWidth, + minimumTransformX, +}) => { + const actionBarWidthAdjustment = + screenWidth > 2200 + ? slicePipelineActionBarWidth + : slicePipelineActionBarWidth / 2; + return Math.max( + screenWidth - nodeListWidth - metaDataPanelWidth - actionBarWidthAdjustment, + minimumTransformX + ); +}; + +export const SlicedPipelineActionBar = React.forwardRef((props, ref) => { + const { + chartSize, + displayMetadataPanel, + isSlicingPipelineApplied, + onApplySlicingPipeline, + onResetSlicingPipeline, + runCommand, + slicedPipelineLength, + visibleSidebar, + } = props; + const [isFirstRender, setIsFirstRender] = useState(true); + + useEffect(() => { + // Set a timer to change `isFirstRender` state after the component has been rendered for 500ms + const timer = setTimeout(() => { + setIsFirstRender(false); // Update the state after 500ms + }, 500); + + // Cleanup function to clear the timeout if the component unmounts + return () => clearTimeout(timer); + }, []); + + const { outerWidth: screenWidth } = chartSize; + const transitionMargin = 200; + const slicePipelineActionBarWidth = + ref.current && ref.current.firstChild.getBoundingClientRect().width; + const metaDataPanelWidth = displayMetadataPanel + ? metaSidebarWidth.open + transitionMargin / 1.5 + : metaSidebarWidth.open; + const nodeListWidth = visibleSidebar + ? sidebarWidth.open - transitionMargin + : sidebarWidth.open - transitionMargin / 1.5; + const minimumTransformX = visibleSidebar + ? sidebarWidth.open + : sidebarWidth.closed; + + const transformX = calculateTransformX({ + screenWidth, + slicePipelineActionBarWidth, + metaDataPanelWidth, + nodeListWidth, + minimumTransformX, + }); + + return ( +
+
+ {`${slicedPipelineLength} selected`} +
+
90, + })} + > + +
+ {isSlicingPipelineApplied ? ( +
+ +
+ ) : ( +
+ + Slice +
+ )} +
+ ); +}); diff --git a/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.scss b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.scss new file mode 100755 index 0000000000..cdf5b8536c --- /dev/null +++ b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.scss @@ -0,0 +1,230 @@ +@use '../../styles/variables' as colors; + +.kui-theme--dark { + --sliced-pipeline-action-bar--background: #{colors.$slate-200}; + --sliced-pipeline-action-bar--color: #{colors.$white-0}; + --sliced-pipeline-action-bar--color-faded: #{colors.$white-800}; + --sliced-pipeline-action-bar--background--hover: #{colors.$white-0}; + --sliced-pipeline-action-bar--color--hover: #{colors.$black-900}; + --sliced-pipeline-action-bar--code-background: #{colors.$black-900}; + --sliced-pipeline-action-bar-shadow-start: #{colors.$black-500}; + --sliced-pipeline-action-bar-shadow-end: #{colors.$black-900}; +} + +.kui-theme--light { + --sliced-pipeline-action-bar--background: #{colors.$white-0}; + --sliced-pipeline-action-bar--color: #{colors.$black-900}; + --sliced-pipeline-action-bar--color-faded: #{colors.$black-200}; + --sliced-pipeline-action-bar--background--hover: #{colors.$black-900}; + --sliced-pipeline-action-bar--color--hover: #{colors.$white-0}; + --sliced-pipeline-action-bar--code-background: #{colors.$white-200}; + --sliced-pipeline-action-bar-shadow-start: #{colors.$white-900}; + --sliced-pipeline-action-bar-shadow-end: #{colors.$white-200}; +} + +@keyframes slideUpTranslate { + from { + transform: translateY(30px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +$padding-top-bottom: 8px; +$padding-right-left: 10px; +$inner-gap: 12px; +$transitionTiming: 0.3s; + +.sliced-pipeline-action-bar { + background-color: var(--sliced-pipeline-action-bar--background); + border-radius: 4px; + bottom: 30px; + display: flex; + margin: 0; + position: absolute; + transition: transform $transitionTiming ease-out, + opacity $transitionTiming ease-out; + padding: $padding-top-bottom $padding-right-left; + + .pipeline-icon-toolbar__button { + height: 24px; + width: 24px; + margin: 0; + opacity: 1; + + svg { + fill: var(--sliced-pipeline-action-bar--color); + opacity: 1; + } + } +} + +// First render it will be sliding up animation +.sliced-pipeline-action-bar--first-render { + animation: slideUpTranslate $transitionTiming ease-out forwards; +} + +.sliced-pipeline-action-bar--info { + align-items: center; + color: var(--sliced-pipeline-action-bar--color-faded); + display: flex; + font-size: 14px; + font-weight: 600; + line-height: 20px; + margin-left: 6px; + margin-right: 16px; +} + +.sliced-pipeline-action-bar--run-command { + display: flex; + min-width: 296px; + max-width: 780px; + + .container { + justify-content: space-between; + align-items: center; + width: 100%; + } + + .command-value { + background-color: var(--sliced-pipeline-action-bar--code-background); + color: var(--sliced-pipeline-action-bar--color); + overflow-x: scroll; + padding: 10px $inner-gap; + text-overflow: ellipsis; + white-space: nowrap; + } + + .toolbox { + align-items: center; + display: flex; + justify-content: center; + margin: 0 10px; + width: auto; + } + + .pipeline-icon--container { + padding: $padding-top-bottom; + + &:hover { + background: var(--sliced-pipeline-action-bar--code-background); + } + } + + .pipeline-icon { + width: 24px; + height: 24px; + } + + .pipeline-tooltip { + bottom: calc(100% + 20px); + } +} + +.sliced-pipeline-action-bar--run-command-long { + .command-value { + background-image: + + /* Shadows */ linear-gradient( + to right, + var(--sliced-pipeline-action-bar--code-background), + var(--sliced-pipeline-action-bar--code-background) + ), + linear-gradient( + to right, + var(--sliced-pipeline-action-bar--code-background), + var(--sliced-pipeline-action-bar--code-background) + ), + /* Shadow covers*/ + linear-gradient( + to right, + var(--sliced-pipeline-action-bar-shadow-start), + var(--sliced-pipeline-action-bar-shadow-end) + ), + linear-gradient( + to left, + var(--sliced-pipeline-action-bar-shadow-start), + var(--sliced-pipeline-action-bar-shadow-end) + ); + background-position: left center, right center, left center, right center; + background-repeat: no-repeat; + background-color: var(--sliced-pipeline-action-bar--code-background); + background-size: 30px 100%, 30px 100%, 30px 100%, 30px 100%; + + /* Opera doesn't support this in the shorthand */ + background-attachment: local, local, scroll, scroll; + } +} + +.sliced-pipeline-action-bar--cta { + align-items: center; + border-radius: 4px; + border: 1px solid var(--sliced-pipeline-action-bar--color); + display: flex; + justify-content: center; + padding: $padding-top-bottom $inner-gap; + height: 40px; +} + +.sliced-pipeline-action-bar--reset { + background: #{colors.$red-0}; + border: none; + + &:hover { + background: #{colors.$red-100}; + } + + .button__btn { + border: none; + font-size: 14px; + padding: 0; + color: #{colors.$white-0}; + + &:hover { + background: #{colors.$red-100}; + color: #{colors.$white-0}; + } + } +} + +.sliced-pipeline-action-bar--slice { + cursor: pointer; + + &:hover { + background: var(--sliced-pipeline-action-bar--background--hover); + + .pipeline-icon { + path { + fill: var(--sliced-pipeline-action-bar--color--hover); + } + } + + .sliced-pipeline-action-bar--slice-text { + color: var(--sliced-pipeline-action-bar--color--hover); + } + } + + .pipeline-icon--container { + list-style-type: none; + } + + .pipeline-icon { + height: 24px; + width: 24px; + + path { + fill: var(--sliced-pipeline-action-bar--color); + } + } + + .sliced-pipeline-action-bar--slice-text { + color: var(--sliced-pipeline-action-bar--color); + font-size: 14px; + font-weight: 600; + margin-left: 4px; + } +} diff --git a/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.test.js b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.test.js new file mode 100644 index 0000000000..95e8bfecb9 --- /dev/null +++ b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.test.js @@ -0,0 +1,34 @@ +// sliced-pipeline-action-bar.test.js +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SlicedPipelineActionBar } from './sliced-pipeline-action-bar'; + +describe('SlicedPipelineActionBar', () => { + it('displays "Slice" button when slicedPipeline is not empty', () => { + const ref = { + current: { + firstChild: { getBoundingClientRect: () => ({ width: 100 }) }, + }, + }; + const { container } = render( + {}} + onResetSlicingPipeline={() => {}} + runCommand={'mock run command'} + ref={ref} + slicedPipelineLength={3} + visibleSidebar={true} + /> + ); + const ctaElement = container.querySelector( + '.sliced-pipeline-action-bar--cta' + ); + + expect(ctaElement).toBeInTheDocument(); + const sliceButton = ctaElement.querySelector('button'); + expect(sliceButton).toBeInTheDocument(); + }); +}); diff --git a/src/components/sliced-pipeline-notification/sliced-pipeline-notification.js b/src/components/sliced-pipeline-notification/sliced-pipeline-notification.js new file mode 100644 index 0000000000..390b1142a0 --- /dev/null +++ b/src/components/sliced-pipeline-notification/sliced-pipeline-notification.js @@ -0,0 +1,19 @@ +import React from 'react'; +import classnames from 'classnames'; + +import './sliced-pipeline-notification.scss'; + +export const SlicedPipelineNotification = ({ + notification, + visibleSidebar, +}) => { + return ( +
+ {notification} +
+ ); +}; diff --git a/src/components/sliced-pipeline-notification/sliced-pipeline-notification.scss b/src/components/sliced-pipeline-notification/sliced-pipeline-notification.scss new file mode 100644 index 0000000000..6b4e3e1eb3 --- /dev/null +++ b/src/components/sliced-pipeline-notification/sliced-pipeline-notification.scss @@ -0,0 +1,43 @@ +@use '../../styles/variables' as colors; + +.kui-theme--dark { + --sliced-pipeline-notification--background: #{colors.$slate-200}; + --sliced-pipeline-notification--color: #{colors.$white-0}; +} + +.kui-theme--light { + --sliced-pipeline-notification--background: #{colors.$white-0}; + --sliced-pipeline-notification--color: #{colors.$black-900}; +} + +// Define a fade-in animation +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.sliced-pipeline-notification { + background-color: var(--sliced-pipeline-notification--background); + color: var(--sliced-pipeline-notification--color); + padding: 8px 16px; + border-radius: 4px; + bottom: 30px; + left: 40%; + display: flex; + margin: 0; + position: absolute; + font-size: 14px; + font-style: normal; + font-weight: 600; + transition: left 0.5s ease-out; + animation: fadeIn 0.5s ease-out forwards; // Apply the fade-in animation +} + +.sliced-pipeline-notification--no-sidebar { + left: 30%; +} diff --git a/src/components/sliced-pipeline-notification/sliced-pipeline-notification.test.js b/src/components/sliced-pipeline-notification/sliced-pipeline-notification.test.js new file mode 100644 index 0000000000..cd0ac5f58c --- /dev/null +++ b/src/components/sliced-pipeline-notification/sliced-pipeline-notification.test.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SlicedPipelineNotification } from './sliced-pipeline-notification'; + +describe('SlicedPipelineNotification', () => { + test('renders with a notification message', () => { + const notification = 'Test Notification'; + render( + + ); + expect(screen.getByText(notification)).toBeInTheDocument(); + }); + + test('applies correct class when visibleSidebar is true', () => { + render( + + ); + const notificationElement = screen.getByText('Test'); + expect(notificationElement).toHaveClass('sliced-pipeline-notification'); + expect(notificationElement).not.toHaveClass( + 'sliced-pipeline-notification--no-sidebar' + ); + }); + + test('applies correct class when visibleSidebar is false', () => { + render( + + ); + const notificationElement = screen.getByText('Test'); + expect(notificationElement).toHaveClass( + 'sliced-pipeline-notification', + 'sliced-pipeline-notification--no-sidebar' + ); + }); +}); diff --git a/src/reducers/index.js b/src/reducers/index.js index 795ad4d2e0..914e45c65b 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -11,6 +11,7 @@ import tag from './tags'; import merge from 'lodash/merge'; import modularPipeline from './modular-pipelines'; import visible from './visible'; +import slice from './slice'; import { RESET_DATA, TOGGLE_SHOW_FEATURE_HINTS, @@ -78,6 +79,7 @@ const combinedReducer = combineReducers({ node, nodeType, pipeline, + slice, tag, modularPipeline, visible, diff --git a/src/reducers/reducers.test.js b/src/reducers/reducers.test.js index ab2e5e7a9e..36ea28b7bb 100644 --- a/src/reducers/reducers.test.js +++ b/src/reducers/reducers.test.js @@ -22,6 +22,7 @@ import { TOGGLE_EXPAND_ALL_PIPELINES, UPDATE_STATE_FROM_OPTIONS, } from '../actions'; +import { SET_SLICE_PIPELINE, RESET_SLICE_PIPELINE } from '../actions/slice'; import { TOGGLE_NODE_CLICKED, TOGGLE_NODES_DISABLED, @@ -91,6 +92,30 @@ describe('Reducer', () => { }); }); + describe('SET_SLICE_PIPELINE', () => { + it('should add nodes to filters list, with from and to', () => { + const fromNode = 'abc123'; + const toNode = 'def456'; + const newState = reducer(mockState.spaceflights, { + type: SET_SLICE_PIPELINE, + slice: { from: fromNode, to: toNode }, + }); + expect(newState.slice.from).toEqual(fromNode); + expect(newState.slice.to).toEqual(toNode); + }); + }); + + describe('RESET_SLICE_PIPELINE', () => { + it('should reset the filters', () => { + const newState = reducer(mockState.spaceflights, { + type: RESET_SLICE_PIPELINE, + }); + expect(newState.slice.from).toEqual(null); + expect(newState.slice.to).toEqual(null); + expect(newState.slice.apply).toEqual(false); + }); + }); + describe('TOGGLE_NODES_DISABLED', () => { it('should toggle the given nodes disabled', () => { const newState = reducer(mockState.spaceflights, { diff --git a/src/reducers/slice.js b/src/reducers/slice.js new file mode 100644 index 0000000000..955308eff4 --- /dev/null +++ b/src/reducers/slice.js @@ -0,0 +1,32 @@ +import { + SET_SLICE_PIPELINE, + RESET_SLICE_PIPELINE, + APPLY_SLICE_PIPELINE, +} from '../actions/slice'; + +// Reducer for filtering nodes +const slicePipelineReducer = (sliceState = {}, action) => { + const updateState = (newState) => Object.assign({}, sliceState, newState); + + switch (action.type) { + case APPLY_SLICE_PIPELINE: + return updateState({ + apply: action.apply, + }); + case SET_SLICE_PIPELINE: + return updateState({ + from: action.slice.from, + to: action.slice.to, + }); + case RESET_SLICE_PIPELINE: + return { + from: null, + to: null, + apply: false, + }; + default: + return sliceState; + } +}; + +export default slicePipelineReducer; diff --git a/src/selectors/disabled.js b/src/selectors/disabled.js index aa0e8bba79..2822be9711 100644 --- a/src/selectors/disabled.js +++ b/src/selectors/disabled.js @@ -6,6 +6,7 @@ import { getModularPipelinesTree, } from './modular-pipelines'; import { getTagCount } from './tags'; +import { getSlicedPipeline } from './sliced-pipeline'; const getNodeIDs = (state) => state.node.ids; const getNodeDisabledNode = (state) => state.node.disabled; @@ -22,6 +23,7 @@ const getLayersVisible = (state) => state.layer.visible; const getNodeLayer = (state) => state.node.layer; const getNodeModularPipelines = (state) => state.node.modularPipelines; const getVisibleSidebarNodes = (state) => state.modularPipeline.visible; +const getSliceApply = (state) => state.slice.apply; /** * Return all inputs and outputs of currently visible modular pipelines @@ -78,6 +80,8 @@ export const getNodeDisabled = createSelector( getVisibleSidebarNodes, getVisibleModularPipelineInputsOutputs, getDisabledModularPipeline, + getSlicedPipeline, + getSliceApply, ], ( nodeIDs, @@ -91,12 +95,19 @@ export const getNodeDisabled = createSelector( focusedModularPipeline, visibleSidebarNodes, visibleModularPipelineInputsOutputs, - disabledModularPipeline + disabledModularPipeline, + slicedPipeline, + isSliceApplied ) => arrayToObject(nodeIDs, (id) => { let isDisabledViaModularPipeline = disabledModularPipeline[nodeModularPipelines[id]]; + let isDisabledViaSlicedPipeline = false; + if (isSliceApplied && slicedPipeline.length > 0) { + isDisabledViaSlicedPipeline = !slicedPipeline.includes(id); + } + const isDisabledViaSidebar = !visibleSidebarNodes[id] && !visibleModularPipelineInputsOutputs.has(id); @@ -126,6 +137,7 @@ export const getNodeDisabled = createSelector( isDisabledViaSidebar, isDisabledViaModularPipeline, isDisabledViaFocusedModularPipeline, + isDisabledViaSlicedPipeline, ].some(Boolean); }) ); diff --git a/src/selectors/nodes.js b/src/selectors/nodes.js index 994c134c6c..adaaaedf73 100644 --- a/src/selectors/nodes.js +++ b/src/selectors/nodes.js @@ -2,6 +2,7 @@ import { createSelector } from 'reselect'; import { select } from 'd3-selection'; import { arrayToObject } from '../utils'; import { getPipelineNodeIDs } from './pipeline'; +import { getSlicedPipeline } from './sliced-pipeline'; import { getNodeDisabled, getNodeDisabledTag, @@ -42,6 +43,16 @@ export const getGraphNodes = createSelector( }, {}) ); +/** + * Filters the `nodes` object to include only the nodes whose IDs are present in the `slicedPipeline` array. + * @param {Object} nodes - An object where keys are node IDs and values are node data objects. + * @param {Array} slicedPipeline - An array of node IDs to include in the sliced pipeline . + */ +export const filterNodesFromSlicingPipeline = (nodes, slicedPipeline) => + Object.fromEntries( + Object.entries(nodes).filter(([nodeId]) => slicedPipeline.includes(nodeId)) + ); + /** * Retrieves tags associated with both nodes and their corresponding modular pipelines. */ @@ -255,23 +266,32 @@ export const getNodeDataObject = createSelector( }, {}) ); -/** - * Return the modular pipelines tree with full data for each tree node for display. - */ export const getModularPipelinesTree = createSelector( - [(state) => state.modularPipeline.tree, getNodeDataObject], - (modularPipelinesTree, nodes) => { + [ + (state) => state.modularPipeline.tree, + (state) => state.slice.apply, + getNodeDataObject, + getSlicedPipeline, + ], + (modularPipelinesTree, isSlicingPipelineApplied, nodes, slicedPipeline) => { if (!modularPipelinesTree) { return {}; } + + // Determine the relevant nodes based on whether slicing is applied + const relevantNodes = isSlicingPipelineApplied + ? filterNodesFromSlicingPipeline(nodes, slicedPipeline) + : nodes; + for (const modularPipelineID in modularPipelinesTree) { modularPipelinesTree[modularPipelineID].data = { ...nodes[modularPipelineID], }; for (const child of modularPipelinesTree[modularPipelineID].children) { - child.data = { ...nodes[child.id] }; + child.data = { ...relevantNodes[child.id] }; } } + return modularPipelinesTree; } ); diff --git a/src/selectors/nodes.test.js b/src/selectors/nodes.test.js index 347bc2dd2f..c41aef871f 100644 --- a/src/selectors/nodes.test.js +++ b/src/selectors/nodes.test.js @@ -14,6 +14,7 @@ import { getInputOutputNodesForFocusedModularPipeline, getNodeLabel, getOppositeForPrettyName, + filterNodesFromSlicingPipeline, } from './nodes'; import { toggleTextLabels, @@ -31,8 +32,6 @@ import reducer from '../reducers'; import { getVisibleNodeIDs } from './disabled'; const getNodeIDs = (state) => state.node.ids; -const getNodeName = (state) => state.node.name; -const getNodeFullName = (state) => state.node.fullName; const getNodeType = (state) => state.node.type; const getNodePipelines = (state) => state.node.pipelines; @@ -458,3 +457,21 @@ describe('getInputOutputDataNodes', () => { ).toHaveProperty('23c94afb'); }); }); + +describe('filterNodesFromSlicingPipeline helper function', () => { + it('should return an object with nodes filtered by includedNodeIds', () => { + const nodes = { + 1: { name: 'Node 1', value: 100 }, + 2: { name: 'Node 2', value: 200 }, + 3: { name: 'Node 3', value: 300 }, + }; + const expected = { + 1: { name: 'Node 1', value: 100 }, + 3: { name: 'Node 3', value: 300 }, + }; + const filteredPipeline = ['1', '3']; + const res = filterNodesFromSlicingPipeline(nodes, filteredPipeline); + + expect(res).toEqual(expected); + }); +}); diff --git a/src/selectors/run-command.js b/src/selectors/run-command.js new file mode 100755 index 0000000000..1914be71e6 --- /dev/null +++ b/src/selectors/run-command.js @@ -0,0 +1,19 @@ +import { createSelector } from 'reselect'; + +const getSlicedPipelineState = (state) => state.slice; +const getNodesRunCommand = (state) => state.node.runCommand; + +export const getRunCommand = createSelector( + [getSlicedPipelineState, getNodesRunCommand], + (slicedPipelineState, nodeRunCommand) => { + const { from, to } = slicedPipelineState; + + if (!from || !to) { + return null; + } + + const slicingPipelineCommand = + nodeRunCommand[to] || 'please define a run command for this node'; + return slicingPipelineCommand; + } +); diff --git a/src/selectors/run-command.test.js b/src/selectors/run-command.test.js new file mode 100644 index 0000000000..498dd99109 --- /dev/null +++ b/src/selectors/run-command.test.js @@ -0,0 +1,38 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { getRunCommand } from './run-command'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('Kedro command generation', () => { + it('should generate a kedro run command with specified from and to nodes', () => { + // Define node IDs for from and to nodes + const fromNodeId = '47b81aa6'; + const toNodeId = '65d0d789'; + + const expectedToNodeName = 'data_processing.companies'; + const expectedCommand = `kedro run --to-outputs=${expectedToNodeName}`; + + // Initial state with run commands for nodes + const initialState = { + node: { + runCommand: { + '65d0d789': 'kedro run --to-outputs=data_processing.companies', + '47b81aa6': 'kedro run --to-nodes=create_model_input_table_node', + }, + }, + slice: { + from: fromNodeId, + to: toNodeId, + }, + }; + + // Create a mock store with the initial state + const store = mockStore(initialState); + const newState = store.getState(); + const generatedCommand = getRunCommand(newState); + + expect(generatedCommand).toEqual(expectedCommand); + }); +}); diff --git a/src/selectors/sliced-pipeline.js b/src/selectors/sliced-pipeline.js new file mode 100644 index 0000000000..5a341ac8e0 --- /dev/null +++ b/src/selectors/sliced-pipeline.js @@ -0,0 +1,112 @@ +import { createSelector } from 'reselect'; + +const getEdgeIDs = (state) => state.edge.ids; +const getEdgeSources = (state) => state.edge.sources; +const getEdgeTargets = (state) => state.edge.targets; +const getFromNodes = (state) => state.slice.from; +const getToNodes = (state) => state.slice.to; + +/** + * Selector to get all edges formatted as an array of objects with id, source, and target properties. + * @param {Object} state - The global state object. + * @returns {Array} An array of edge objects. + */ +const getEdges = createSelector( + [getEdgeIDs, getEdgeSources, getEdgeTargets], + (edgeIDs, edgeSources, edgeTargets) => + edgeIDs.map((id) => ({ + id, + source: edgeSources[id], + target: edgeTargets[id], + })) +); + +/** + * Selector to organize edges by their source and target nodes. + * @param {Array} edges - Array of edge objects. + * @returns {Object} An object containing edges mapped by source and target nodes. + */ + +export const getEdgesByNode = createSelector([getEdges], (edges) => { + const sourceEdges = {}; + const targetEdges = {}; + + for (const edge of edges) { + if (!sourceEdges[edge.target]) { + sourceEdges[edge.target] = []; + } + + sourceEdges[edge.target].push(edge.source); + + if (!targetEdges[edge.source]) { + targetEdges[edge.source] = []; + } + + targetEdges[edge.source].push(edge.target); + } + + return { sourceEdges, targetEdges }; +}); + +/** + * Recursive function to find all linked nodes starting from a given node ID. + * @param {string} nodeID - The starting node ID. + * @param {Object} edgesByNode - A map of node IDs to their connected node IDs. + * @param {Object} visited - A map to keep track of visited nodes. + * @returns {Object} A map of visited nodes. + */ +const findLinkedNodes = (nodeID, edgesByNode, visited) => { + // Check if the current node has not been visited + if (!visited[nodeID]) { + // Mark the current node as visited + visited[nodeID] = true; + // If the current node has outgoing edges + if (edgesByNode[nodeID]) { + // Recursively visit all connected nodes + edgesByNode[nodeID].forEach((nodeID) => + findLinkedNodes(nodeID, edgesByNode, visited) + ); + } + } + + // Return the map of visited nodes + return visited; +}; + +const findNodesInBetween = (sourceEdges, startID, endID) => { + if (!startID || !endID) { + return [startID, endID].filter(Boolean); + } + + const linkedNodesBeforeEnd = {}; + findLinkedNodes(endID, sourceEdges, linkedNodesBeforeEnd); + + const linkedNodeBeforeStart = {}; + findLinkedNodes(startID, sourceEdges, linkedNodeBeforeStart); + + let filteredNodeIDs = Object.keys(linkedNodesBeforeEnd); + + if (filteredNodeIDs.includes(startID) && filteredNodeIDs.includes(endID)) { + return filteredNodeIDs; + } else { + // If startID and endID are not connected, return empty array so it won't render the flowchart + filteredNodeIDs = []; + } + + return filteredNodeIDs; +}; + +/** + * Selector to filter nodes that are connected between two specified node IDs. + * @param {Object} edgesByNode - Edges organized by node IDs. + * @param {string} startID - Starting node ID. + * @param {string} endID - Ending node ID. + * @returns {Array} Array of node IDs that are connected from startID to endID. + */ + +export const getSlicedPipeline = createSelector( + [getEdgesByNode, getFromNodes, getToNodes], + ({ sourceEdges, targetEdges }, startID, endID) => { + return findNodesInBetween(sourceEdges, startID, endID); + } +); diff --git a/src/selectors/sliced-pipeline.test.js b/src/selectors/sliced-pipeline.test.js new file mode 100644 index 0000000000..2a76049b2b --- /dev/null +++ b/src/selectors/sliced-pipeline.test.js @@ -0,0 +1,45 @@ +import { mockState } from '../utils/state.mock'; +import reducer from '../reducers'; + +import { SET_SLICE_PIPELINE } from '../actions/slice'; +import { getSlicedPipeline } from './sliced-pipeline'; + +describe('Selectors', () => { + it('return a filteredPipeline array with any node in between', () => { + const fromNode = '47b81aa6'; + const toNode = '23c94afb'; + + const expected = [ + '23c94afb', + '47b81aa6', + 'daf35ba0', + 'c09084f2', + '0abef172', + 'e5a9ec27', + 'b7bb7198', + 'f192326a', + '90ebe5f3', + 'data_processing', + ]; + const newState = reducer(mockState.spaceflights, { + type: SET_SLICE_PIPELINE, + slice: { from: fromNode, to: toNode }, + }); + + const res = getSlicedPipeline(newState); + expect(res).toEqual(expected); + }); + + it('return empty if they are not connected', () => { + const fromNode = '47b81aa6'; + const toNode = 'f1f1425b'; + + const newState = reducer(mockState.spaceflights, { + type: SET_SLICE_PIPELINE, + slice: { from: fromNode, to: toNode }, + }); + + const res = getSlicedPipeline(newState); + expect(res).toEqual([]); + }); +}); diff --git a/src/store/initial-state.js b/src/store/initial-state.js index 9dca179a98..2e13d2838a 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -41,6 +41,7 @@ export const createInitialState = () => ({ settingsModal: false, shareableUrlModal: false, sidebar: window.innerWidth > sidebarWidth.breakpoint, + slicing: true, }, display: { globalNavigation: true, diff --git a/src/store/normalize-data.js b/src/store/normalize-data.js index 3fc3715855..15aaacaa94 100644 --- a/src/store/normalize-data.js +++ b/src/store/normalize-data.js @@ -79,6 +79,11 @@ export const createInitialPipelineState = () => ({ active: {}, enabled: {}, }, + slice: { + from: null, + to: null, + apply: false, + }, hoveredParameters: false, hoveredFocusMode: false, }); diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index e60117aecf..c34c6cbb8e 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -90,6 +90,7 @@ $grey-700: #717d84; $grey-800: #5e6c74; $grey-900: #4c5b64; $ocean-0: #acccda; +$ocean-0-50: #cfdfe7; $ocean-100: #97bac9; $ocean-200: #82a8b8; $ocean-300: #598395; @@ -117,3 +118,5 @@ $yellow-0: #ffe300; $yellow-300: #ffbc00; $yellow-600: #b28400; $yellow-900: #7d5c00; +$red-0: #f03b3a; +$red-100: #dc3938; diff --git a/src/tracking/index.js b/src/tracking/index.js new file mode 100644 index 0000000000..369f43da97 --- /dev/null +++ b/src/tracking/index.js @@ -0,0 +1,11 @@ +import { noop } from 'lodash'; + +export const getHeap = () => { + if (!window.heap) { + window.heap = { + track: noop, + }; + } + + return window.heap; +}; diff --git a/src/utils/get-data-test-attribute.js b/src/utils/get-data-test-attribute.js new file mode 100644 index 0000000000..63a0a1fd48 --- /dev/null +++ b/src/utils/get-data-test-attribute.js @@ -0,0 +1,12 @@ +/** + * to get a string that can be used as a value for data-test attributes in HTML elements, + * to make it easier to target of elements for testing purposes. + * @param {String} component A string representing the name of the main component. + * @param {String} uiElement A string representing the type of UI element (e.g., button, input, dropdown, event name). + * @param {String} state (optional) A string representing the state of the UI element (e.g., disabled, active, error). + * @returns + */ +export const getDataTestAttribute = (component, uiElement, state = '') => { + const stateSuffix = state ? `-${state}` : ''; + return `${component}--${uiElement}${stateSuffix}`; +}; diff --git a/tools/test-lib/react-app/app.test.js b/tools/test-lib/react-app/app.test.js index 4ca8ec2eac..07354db84b 100644 --- a/tools/test-lib/react-app/app.test.js +++ b/tools/test-lib/react-app/app.test.js @@ -42,7 +42,7 @@ describe('lib-test', () => { test.each(keys)( `updates to %s dataset when radio button triggers change`, (key) => { - const { container } = render(); + const { container } = render(); const radioInput = container.querySelector(`[value="${key}"]`); fireEvent.click(radioInput, { target: { value: key } });