diff --git a/.eslintrc.json b/.eslintrc.json index 8c3135079c..71021adf2b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +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"] }], + "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/demo-project/conf/base/catalog_01_raw.yml b/demo-project/conf/base/catalog_01_raw.yml index b2d71bf82c..e2f3f2b324 100644 --- a/demo-project/conf/base/catalog_01_raw.yml +++ b/demo-project/conf/base/catalog_01_raw.yml @@ -5,7 +5,7 @@ companies: kedro-viz: layer: raw preview_args: - nrows: 5 + nrows: 5 reviews: type: pandas.CSVDataset diff --git a/src/actions/filters.js b/src/actions/filters.js new file mode 100644 index 0000000000..3143ae19d3 --- /dev/null +++ b/src/actions/filters.js @@ -0,0 +1,12 @@ +export const FILTER_NODES = 'FILTER_NODES'; + +export const filterNodes = (from, to) => ({ + type: FILTER_NODES, + filters: { from, to }, +}); + +export const RESET_NODES_FILTER = 'RESET_NODES_FILTER'; + +export const resetNodesFilter = () => ({ + type: RESET_NODES_FILTER, +}); diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js index 5c25245c80..de85981672 100644 --- a/src/components/node-list/index.js +++ b/src/components/node-list/index.js @@ -12,6 +12,8 @@ import { import { getNodeTypes, isModularPipelineType, + getTaskNodes, + getDatasets, } from '../../selectors/node-types'; import { getTagData, getTagNodeCounts } from '../../selectors/tags'; import { @@ -37,6 +39,7 @@ import { toggleNodeHovered, toggleNodesDisabled, } from '../../actions/nodes'; +import { filterNodes, resetNodesFilter } from '../../actions/filters'; import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; import './styles/node-list.scss'; import { params, NODE_TYPES } from '../../config'; @@ -46,12 +49,15 @@ import { params, NODE_TYPES } from '../../config'; * Also handles user interaction and dispatches updates back to the store. */ const NodeListProvider = ({ + flags, faded, nodes, nodeSelected, tags, tagNodeCounts, nodeTypes, + taskNodes, + datasets, onToggleNodesDisabled, onToggleNodeSelected, onToggleNodeActive, @@ -63,6 +69,8 @@ const NodeListProvider = ({ onToggleModularPipelineExpanded, onToggleTypeDisabled, onToggleFocusMode, + onFilterNodes, + onResetNodesFilter, modularPipelinesTree, focusMode, disabledModularPipeline, @@ -295,11 +303,14 @@ const NodeListProvider = ({ return ( ({ + flags: state.flags, tags: getTagData(state), tagNodeCounts: getTagNodeCounts(state), nodes: getGroupedNodes(state), nodeSelected: getNodeSelected(state), nodeTypes: getNodeTypes(state), + taskNodes: getTaskNodes(state), + datasets: getDatasets(state), focusMode: getFocusedModularPipeline(state), disabledModularPipeline: state.modularPipeline.disabled, inputOutputDataNodes: getInputOutputNodesForFocusedModularPipeline(state), @@ -363,6 +379,12 @@ export const mapDispatchToProps = (dispatch) => ({ onToggleFocusMode: (modularPipeline) => { dispatch(toggleFocusMode(modularPipeline)); }, + onFilterNodes: (from, to) => { + dispatch(filterNodes(from, to)); + }, + onResetNodesFilter: () => { + dispatch(resetNodesFilter()); + }, }); export default connect(mapStateToProps, mapDispatchToProps)(NodeListProvider); diff --git a/src/components/node-list/node-list-groups.js b/src/components/node-list/node-list-groups.js index a91dd31e94..f4bf0681b3 100644 --- a/src/components/node-list/node-list-groups.js +++ b/src/components/node-list/node-list-groups.js @@ -2,11 +2,16 @@ import React, { useState } from 'react'; import { loadLocalStorage, saveLocalStorage } from '../../store/helpers'; import NodeListGroup from './node-list-group'; import { localStorageName } from '../../config'; - +import Dropdown from '../ui/dropdown'; +import MenuOption from '../ui/menu-option'; +import Button from '../ui/button'; const storedState = loadLocalStorage(localStorageName); const NodeListGroups = ({ + flags, groups, + datasets, + taskNodes, items, onGroupToggleChanged, onItemChange, @@ -14,8 +19,13 @@ const NodeListGroups = ({ onItemMouseEnter, onItemMouseLeave, searchValue, + onFilterNodes, + onResetNodesFilters, }) => { const [collapsed, setCollapsed] = useState(storedState.groupsCollapsed || {}); + const [toNode, selectedToNode] = useState({}); + const [fromNode, selectedFromNode] = useState({}); + const isSlicingEnabled = flags.slicePipeline; // Collapse/expand node group const onToggleGroupCollapsed = (groupID) => { @@ -29,31 +39,91 @@ const NodeListGroups = ({ }; return ( - + <> + {!isSlicingEnabled ? ( + + ) : ( + + )} + ); }; diff --git a/src/components/node-list/node-list.js b/src/components/node-list/node-list.js index 0106c0594c..9c9895f692 100644 --- a/src/components/node-list/node-list.js +++ b/src/components/node-list/node-list.js @@ -12,7 +12,10 @@ import './styles/node-list.scss'; * Scrollable list of toggleable items, with search & filter functionality */ const NodeList = ({ + flags, faded, + datasets, + taskNodes, items, modularPipelinesTree, modularPipelinesSearchResult, @@ -20,6 +23,8 @@ const NodeList = ({ searchValue, getGroupState, onUpdateSearchValue, + onFilterNodes, + onResetNodesFilter, onGroupToggleChanged, onItemClick, onItemMouseEnter, @@ -94,9 +99,14 @@ const NodeList = ({ { + const updateState = (newState) => Object.assign({}, filterState, newState); + + switch (action.type) { + case FILTER_NODES: + return updateState({ + from: action.filters.from, + to: action.filters.to, + }); + case RESET_NODES_FILTER: + return {}; + default: + return filterState; + } +}; + +export default filterNodesReducer; diff --git a/src/reducers/index.js b/src/reducers/index.js index af858fdc50..1c572cb69f 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -7,6 +7,7 @@ import loading from './loading'; import node from './nodes'; import nodeType from './node-type'; import pipeline from './pipeline'; +import filters from './filters'; import tag from './tags'; import modularPipeline from './modular-pipelines'; import visible from './visible'; @@ -61,6 +62,7 @@ const combinedReducer = combineReducers({ node, nodeType, pipeline, + filters, tag, modularPipeline, visible, diff --git a/src/selectors/disabled.js b/src/selectors/disabled.js index aa0e8bba79..44cae1b15a 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 { getFilteredPipeline } from './filtered-pipeline'; const getNodeIDs = (state) => state.node.ids; const getNodeDisabledNode = (state) => state.node.disabled; @@ -78,6 +79,7 @@ export const getNodeDisabled = createSelector( getVisibleSidebarNodes, getVisibleModularPipelineInputsOutputs, getDisabledModularPipeline, + getFilteredPipeline, ], ( nodeIDs, @@ -91,12 +93,18 @@ export const getNodeDisabled = createSelector( focusedModularPipeline, visibleSidebarNodes, visibleModularPipelineInputsOutputs, - disabledModularPipeline + disabledModularPipeline, + filteredPipeline ) => arrayToObject(nodeIDs, (id) => { let isDisabledViaModularPipeline = disabledModularPipeline[nodeModularPipelines[id]]; + let isDisabledViaFilters = false; + if (filteredPipeline.length > 0) { + isDisabledViaFilters = !filteredPipeline.includes(id); + } + const isDisabledViaSidebar = !visibleSidebarNodes[id] && !visibleModularPipelineInputsOutputs.has(id); @@ -126,6 +134,7 @@ export const getNodeDisabled = createSelector( isDisabledViaSidebar, isDisabledViaModularPipeline, isDisabledViaFocusedModularPipeline, + isDisabledViaFilters, ].some(Boolean); }) ); diff --git a/src/selectors/filtered-pipeline.js b/src/selectors/filtered-pipeline.js new file mode 100644 index 0000000000..78c7ceea58 --- /dev/null +++ b/src/selectors/filtered-pipeline.js @@ -0,0 +1,111 @@ +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.filters.from; +const getToNodes = (state) => state.filters.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) => { + if (!visited[nodeID]) { + visited[nodeID] = true; + + if (edgesByNode[nodeID]) { + edgesByNode[nodeID].forEach((nodeID) => + findLinkedNodes(nodeID, edgesByNode, visited) + ); + } + } + + return visited; +}; + +/** + * 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 getFilteredPipeline = createSelector( + [getEdgesByNode, getFromNodes, getToNodes], + ({ sourceEdges, targetEdges }, startID, endID) => { + let filteredNodeIDs = []; + + if ((!startID || !startID.length) && (!endID || !endID.length)) { + return filteredNodeIDs; + } else { + const linkedNodesBetween = []; + const linkedNodesBeforeEnd = {}; + findLinkedNodes(endID, sourceEdges, linkedNodesBeforeEnd); + const linkedNodeBeforeStart = {}; + findLinkedNodes(startID, sourceEdges, linkedNodeBeforeStart); + + // keep any nodes before the endID + filteredNodeIDs = linkedNodesBetween.concat( + Object.keys(linkedNodesBeforeEnd) + ); + + // remove any nodes before startID + Object.keys(linkedNodeBeforeStart).map((node) => { + if (node !== startID) { + const index = filteredNodeIDs.indexOf(node); + filteredNodeIDs.splice(index, 1); + } + }); + + return filteredNodeIDs; + } + } +); diff --git a/src/selectors/node-types.js b/src/selectors/node-types.js index e9437129a8..9a29060317 100644 --- a/src/selectors/node-types.js +++ b/src/selectors/node-types.js @@ -2,13 +2,35 @@ import { createSelector } from 'reselect'; import { getNodeDisabled } from './disabled'; import { arrayToObject } from '../utils'; + + const getNodeIDs = (state) => state.node.ids; const getNodeType = (state) => state.node.type; +const getNodeName = (state) => state.node.name; export const getNodeTypeIDs = (state) => state.nodeType.ids; const getNodeTypeName = (state) => state.nodeType.name; const getNodeTypeDisabled = (state) => state.nodeType.disabled; export const isModularPipelineType = (type) => type === 'modularPipeline'; + + +export const getTaskNodes = createSelector( + [getNodeType, getNodeName], + (nodeType, nodeName) => { + const taskNodeIds = Object.keys(nodeName).filter(id => nodeType[id] === 'task'); + return arrayToObject(taskNodeIds, id => nodeName[id]); + } +); + +export const getDatasets = createSelector( + [getNodeType, getNodeName], + (nodeType, nodeName) => { + const dataNodeIds = Object.keys(nodeName).filter(id => nodeType[id] === 'data'); + return arrayToObject(dataNodeIds, id => nodeName[id]); + } +); + + /** * Calculate the total number of nodes (and the number of visible nodes) * for each node-type diff --git a/src/store/normalize-data.js b/src/store/normalize-data.js index 6ad17baf44..edcd5020d8 100644 --- a/src/store/normalize-data.js +++ b/src/store/normalize-data.js @@ -79,6 +79,12 @@ export const createInitialPipelineState = () => ({ active: {}, enabled: {}, }, + filters: { + from: [], + to: [], + active: {}, + disabled: {}, + }, hoveredParameters: false, hoveredFocusMode: false, });