From 78607ee7b850957e9a227f8989e158ddbda00b75 Mon Sep 17 00:00:00 2001 From: David Paul Graham <43794491+dpgraham4401@users.noreply.github.com> Date: Thu, 14 Mar 2024 22:18:03 -0400 Subject: [PATCH] Highlight path edges (#73) * move Nodes directory to subcomponent of Tree component * initial custom edge DecisionEdge * set new DecisionEdge as teh deafult edge * optional styling for edge when decision made prop passed * add edge to chosen path implementation * remove old edges from path on selection --- .../Edges/DecisionEdge/DecisionEdge.spec.tsx | 39 +++++++++++++++++++ .../Tree/Edges/DecisionEdge/DecisionEdge.tsx | 32 +++++++++++++++ .../Nodes/BaseNode/BaseNode.spec.tsx | 2 +- .../{ => Tree}/Nodes/BaseNode/BaseNode.tsx | 5 +-- .../Nodes/BaseNode/DragHandle/DragHandle.tsx | 2 +- .../BaseNode/DragHandle/dragHandle.module.css | 0 .../Nodes/BaseNode/baseNode.module.css | 0 .../Nodes/BoolNode/BoolNode.spec.tsx | 2 +- .../{ => Tree}/Nodes/BoolNode/BoolNode.tsx | 9 +++-- .../{ => Tree}/Nodes/BoolNode/bool.module.css | 0 .../Nodes/DefaultNode/DefaultNode.spec.tsx | 2 +- .../Nodes/DefaultNode/DefaultNode.tsx | 6 +-- .../Nodes/DefaultNode/default.module.css | 0 src/components/Tree/Tree.tsx | 10 ++++- src/components/Tree/index.ts | 4 ++ src/hooks/useDecisionTree/useDecisionTree.tsx | 6 +++ src/hooks/useFetchConfig/useFetchConfig.tsx | 2 +- src/store/DagEdgeSlice/dagEdgeSlice.ts | 38 +++++++++++++++++- src/store/DagEdgeSlice/dagEdgeUtils.ts | 2 +- src/store/TreeSlice/treeSlice.ts | 6 ++- 20 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 src/components/Tree/Edges/DecisionEdge/DecisionEdge.spec.tsx create mode 100644 src/components/Tree/Edges/DecisionEdge/DecisionEdge.tsx rename src/components/{ => Tree}/Nodes/BaseNode/BaseNode.spec.tsx (95%) rename src/components/{ => Tree}/Nodes/BaseNode/BaseNode.tsx (89%) rename src/components/{ => Tree}/Nodes/BaseNode/DragHandle/DragHandle.tsx (62%) rename src/components/{ => Tree}/Nodes/BaseNode/DragHandle/dragHandle.module.css (100%) rename src/components/{ => Tree}/Nodes/BaseNode/baseNode.module.css (100%) rename src/components/{ => Tree}/Nodes/BoolNode/BoolNode.spec.tsx (97%) rename src/components/{ => Tree}/Nodes/BoolNode/BoolNode.tsx (89%) rename src/components/{ => Tree}/Nodes/BoolNode/bool.module.css (100%) rename src/components/{ => Tree}/Nodes/DefaultNode/DefaultNode.spec.tsx (91%) rename src/components/{ => Tree}/Nodes/DefaultNode/DefaultNode.tsx (71%) rename src/components/{ => Tree}/Nodes/DefaultNode/default.module.css (100%) create mode 100644 src/components/Tree/index.ts diff --git a/src/components/Tree/Edges/DecisionEdge/DecisionEdge.spec.tsx b/src/components/Tree/Edges/DecisionEdge/DecisionEdge.spec.tsx new file mode 100644 index 0000000..d9591f4 --- /dev/null +++ b/src/components/Tree/Edges/DecisionEdge/DecisionEdge.spec.tsx @@ -0,0 +1,39 @@ +import '@testing-library/jest-dom'; +import { cleanup, render } from '@testing-library/react'; +import { Position, ReactFlowProvider } from 'reactflow'; +import { afterEach, describe, test } from 'vitest'; +import { DecisionEdge } from './DecisionEdge'; + +afterEach(() => cleanup()); + +interface TestComponentProps { + source?: string; + target?: string; +} + +const TestComponent = (props: TestComponentProps) => ( + + + + + +); + +// ToDo: We need to find a better way to test this component. +// The challenge is our component is only a slim wrapper for the BaseEdge component from reactflow. +// There's not much to test here, or even grab as a reference to test the component. +describe('Decision Edge', () => { + test('renders', () => { + render(); + }); +}); diff --git a/src/components/Tree/Edges/DecisionEdge/DecisionEdge.tsx b/src/components/Tree/Edges/DecisionEdge/DecisionEdge.tsx new file mode 100644 index 0000000..ca64e02 --- /dev/null +++ b/src/components/Tree/Edges/DecisionEdge/DecisionEdge.tsx @@ -0,0 +1,32 @@ +import { BaseEdge, EdgeProps, getSmoothStepPath } from 'reactflow'; + +export interface DecisionEdgeData { + decisionMade?: boolean; +} + +export interface DecisionEdgeProps extends EdgeProps {} + +export const DecisionEdge = (props: DecisionEdgeProps) => { + const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = props; + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + return ( + <> + + + ); +}; diff --git a/src/components/Nodes/BaseNode/BaseNode.spec.tsx b/src/components/Tree/Nodes/BaseNode/BaseNode.spec.tsx similarity index 95% rename from src/components/Nodes/BaseNode/BaseNode.spec.tsx rename to src/components/Tree/Nodes/BaseNode/BaseNode.spec.tsx index 04fc1f4..c2c5d19 100644 --- a/src/components/Nodes/BaseNode/BaseNode.spec.tsx +++ b/src/components/Tree/Nodes/BaseNode/BaseNode.spec.tsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom'; import { act, cleanup, render, screen } from '@testing-library/react'; -import { BaseNode } from 'components/Nodes/BaseNode/BaseNode'; +import { BaseNode } from 'components/Tree/Nodes/BaseNode/BaseNode'; import { ReactFlowProvider } from 'reactflow'; import useTreeStore from 'store'; import { afterEach, describe, expect, test } from 'vitest'; diff --git a/src/components/Nodes/BaseNode/BaseNode.tsx b/src/components/Tree/Nodes/BaseNode/BaseNode.tsx similarity index 89% rename from src/components/Nodes/BaseNode/BaseNode.tsx rename to src/components/Tree/Nodes/BaseNode/BaseNode.tsx index bf68120..6c08cc2 100644 --- a/src/components/Nodes/BaseNode/BaseNode.tsx +++ b/src/components/Tree/Nodes/BaseNode/BaseNode.tsx @@ -1,11 +1,10 @@ +import styles from 'components/Tree/Nodes/BaseNode/baseNode.module.css'; +import { DragHandle } from 'components/Tree/Nodes/BaseNode/DragHandle/DragHandle'; import { useTreeDirection } from 'hooks'; import { ReactNode, useEffect } from 'react'; import { Handle, NodeProps, Position, useUpdateNodeInternals } from 'reactflow'; import { DecisionStatus } from 'store/DecisionSlice/decisionSlice'; -import styles from './baseNode.module.css'; -import { DragHandle } from './DragHandle/DragHandle'; - interface BaseNodeProps extends Omit { children: ReactNode; status?: DecisionStatus; diff --git a/src/components/Nodes/BaseNode/DragHandle/DragHandle.tsx b/src/components/Tree/Nodes/BaseNode/DragHandle/DragHandle.tsx similarity index 62% rename from src/components/Nodes/BaseNode/DragHandle/DragHandle.tsx rename to src/components/Tree/Nodes/BaseNode/DragHandle/DragHandle.tsx index a4f8032..bbf79cc 100644 --- a/src/components/Nodes/BaseNode/DragHandle/DragHandle.tsx +++ b/src/components/Tree/Nodes/BaseNode/DragHandle/DragHandle.tsx @@ -1,4 +1,4 @@ -import styles from 'components/Nodes/BaseNode/DragHandle/dragHandle.module.css'; +import styles from 'components/Tree/Nodes/BaseNode/DragHandle/dragHandle.module.css'; export const DragHandle = () => { return ( diff --git a/src/components/Nodes/BaseNode/DragHandle/dragHandle.module.css b/src/components/Tree/Nodes/BaseNode/DragHandle/dragHandle.module.css similarity index 100% rename from src/components/Nodes/BaseNode/DragHandle/dragHandle.module.css rename to src/components/Tree/Nodes/BaseNode/DragHandle/dragHandle.module.css diff --git a/src/components/Nodes/BaseNode/baseNode.module.css b/src/components/Tree/Nodes/BaseNode/baseNode.module.css similarity index 100% rename from src/components/Nodes/BaseNode/baseNode.module.css rename to src/components/Tree/Nodes/BaseNode/baseNode.module.css diff --git a/src/components/Nodes/BoolNode/BoolNode.spec.tsx b/src/components/Tree/Nodes/BoolNode/BoolNode.spec.tsx similarity index 97% rename from src/components/Nodes/BoolNode/BoolNode.spec.tsx rename to src/components/Tree/Nodes/BoolNode/BoolNode.spec.tsx index 540d6d6..d03488e 100644 --- a/src/components/Nodes/BoolNode/BoolNode.spec.tsx +++ b/src/components/Tree/Nodes/BoolNode/BoolNode.spec.tsx @@ -1,7 +1,7 @@ import '@testing-library/jest-dom'; import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { BoolNode, BoolNodeData } from 'components/Nodes/BoolNode/BoolNode'; +import { BoolNode, BoolNodeData } from 'components/Tree/Nodes/BoolNode/BoolNode'; import { NodeProps, ReactFlowProvider } from 'reactflow'; import useTreeStore from 'store'; import { afterEach, describe, expect, test } from 'vitest'; diff --git a/src/components/Nodes/BoolNode/BoolNode.tsx b/src/components/Tree/Nodes/BoolNode/BoolNode.tsx similarity index 89% rename from src/components/Nodes/BoolNode/BoolNode.tsx rename to src/components/Tree/Nodes/BoolNode/BoolNode.tsx index 7740d65..51f923d 100644 --- a/src/components/Nodes/BoolNode/BoolNode.tsx +++ b/src/components/Tree/Nodes/BoolNode/BoolNode.tsx @@ -1,11 +1,11 @@ -import { BaseNode } from 'components/Nodes/BaseNode/BaseNode'; +import { BaseNode } from 'components/Tree/Nodes/BaseNode/BaseNode'; + +import styles from 'components/Tree/Nodes/BoolNode/bool.module.css'; import { useDecisionTree } from 'hooks'; import { useState } from 'react'; import { NodeProps } from 'reactflow'; import { NodeData } from 'store/DecisionSlice/decisionSlice'; -import styles from './bool.module.css'; - export interface BoolNodeData extends NodeData { label: string; yesId: string; @@ -25,6 +25,7 @@ export const BoolNode = ({ hideDescendants, markDecisionMade, markDecisionFocused, + addToPath, } = useDecisionTree(); const [selected, setSelected] = useState<'yes' | 'no' | undefined>(undefined); @@ -35,6 +36,7 @@ export const BoolNode = ({ hideDescendants(noId); markDecisionMade(id); markDecisionFocused(yesId); + addToPath(id, yesId); setSelected('yes'); }; @@ -45,6 +47,7 @@ export const BoolNode = ({ hideDescendants(yesId); markDecisionMade(id); markDecisionFocused(noId); + addToPath(id, noId); setSelected('no'); }; diff --git a/src/components/Nodes/BoolNode/bool.module.css b/src/components/Tree/Nodes/BoolNode/bool.module.css similarity index 100% rename from src/components/Nodes/BoolNode/bool.module.css rename to src/components/Tree/Nodes/BoolNode/bool.module.css diff --git a/src/components/Nodes/DefaultNode/DefaultNode.spec.tsx b/src/components/Tree/Nodes/DefaultNode/DefaultNode.spec.tsx similarity index 91% rename from src/components/Nodes/DefaultNode/DefaultNode.spec.tsx rename to src/components/Tree/Nodes/DefaultNode/DefaultNode.spec.tsx index 05a4aef..7d547d7 100644 --- a/src/components/Nodes/DefaultNode/DefaultNode.spec.tsx +++ b/src/components/Tree/Nodes/DefaultNode/DefaultNode.spec.tsx @@ -1,8 +1,8 @@ import '@testing-library/jest-dom'; import { cleanup, render, screen } from '@testing-library/react'; +import { DefaultNode } from 'components/Tree/Nodes/DefaultNode/DefaultNode'; import { ReactFlowProvider } from 'reactflow'; import { afterEach, describe, expect, test } from 'vitest'; -import { DefaultNode } from './DefaultNode'; afterEach(() => cleanup()); diff --git a/src/components/Nodes/DefaultNode/DefaultNode.tsx b/src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx similarity index 71% rename from src/components/Nodes/DefaultNode/DefaultNode.tsx rename to src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx index 53bab85..ddf4d64 100644 --- a/src/components/Nodes/DefaultNode/DefaultNode.tsx +++ b/src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx @@ -1,9 +1,9 @@ -import { BaseNode } from 'components/Nodes/BaseNode/BaseNode'; +import { BaseNode } from 'components/Tree/Nodes/BaseNode/BaseNode'; + +import styles from 'components/Tree/Nodes/DefaultNode/default.module.css'; import { NodeProps } from 'reactflow'; import { NodeData } from 'store/DecisionSlice/decisionSlice'; -import styles from './default.module.css'; - export const DefaultNode = ({ data, ...props }: NodeProps) => { return ( diff --git a/src/components/Nodes/DefaultNode/default.module.css b/src/components/Tree/Nodes/DefaultNode/default.module.css similarity index 100% rename from src/components/Nodes/DefaultNode/default.module.css rename to src/components/Tree/Nodes/DefaultNode/default.module.css diff --git a/src/components/Tree/Tree.tsx b/src/components/Tree/Tree.tsx index 12e757a..a9a5e51 100644 --- a/src/components/Tree/Tree.tsx +++ b/src/components/Tree/Tree.tsx @@ -1,6 +1,7 @@ -import { BoolNode } from 'components/Nodes/BoolNode/BoolNode'; -import { DefaultNode } from 'components/Nodes/DefaultNode/DefaultNode'; import { ControlCenter } from 'components/Tree/ControlCenter'; +import { DecisionEdge } from 'components/Tree/Edges/DecisionEdge/DecisionEdge'; +import { BoolNode } from 'components/Tree/Nodes/BoolNode/BoolNode'; +import { DefaultNode } from 'components/Tree/Nodes/DefaultNode/DefaultNode'; import { useDecisionTree, useTreeDirection } from 'hooks'; import React, { useMemo, useState } from 'react'; import ReactFlow, { Edge, MiniMap, Node, useReactFlow, useViewport, XYPosition } from 'reactflow'; @@ -11,6 +12,10 @@ export interface TreeProps { mapVisible?: boolean; } +const edgeTypes = { + decision: DecisionEdge, +}; + /** * Tree - responsible for rendering the decision tree */ @@ -27,6 +32,7 @@ export const Tree = ({ nodes, edges }: TreeProps) => {
{ onEdgesChange, markDecisionMade, markDecisionFocused, + updatePath, } = useDecTreeStore((state) => state); /** show a node's direct children and the edges leading to them */ @@ -64,6 +65,10 @@ export const useDecisionTree = (initialTree?: PositionUnawareDecisionTree) => { } }, [initialTree, setStoreTree, showStoreNode]); + const addToPath = (source: string, target: string) => { + updatePath(source, target); + }; + return { tree, showNode, @@ -77,5 +82,6 @@ export const useDecisionTree = (initialTree?: PositionUnawareDecisionTree) => { onNodesChange, markDecisionMade, markDecisionFocused, + addToPath, } as const; }; diff --git a/src/hooks/useFetchConfig/useFetchConfig.tsx b/src/hooks/useFetchConfig/useFetchConfig.tsx index 0fb508a..f80154d 100644 --- a/src/hooks/useFetchConfig/useFetchConfig.tsx +++ b/src/hooks/useFetchConfig/useFetchConfig.tsx @@ -1,4 +1,4 @@ -import { BoolNodeData } from 'components/Nodes/BoolNode/BoolNode'; +import { BoolNodeData } from 'components/Tree'; import { useEffect, useState } from 'react'; import { PositionUnawareDecisionTree, TreeNode } from 'store'; import { BooleanNodeData, NodeData } from 'store/DecisionSlice/decisionSlice'; diff --git a/src/store/DagEdgeSlice/dagEdgeSlice.ts b/src/store/DagEdgeSlice/dagEdgeSlice.ts index 4dfbee8..726c363 100644 --- a/src/store/DagEdgeSlice/dagEdgeSlice.ts +++ b/src/store/DagEdgeSlice/dagEdgeSlice.ts @@ -1,9 +1,10 @@ +import { DecisionEdgeData } from 'components/Tree/Edges/DecisionEdge/DecisionEdge'; import { applyEdgeChanges, Edge, EdgeChange, OnEdgesChange } from 'reactflow'; import { addDagEdge } from 'store/DagEdgeSlice/dagEdgeUtils'; import { StateCreator } from 'zustand'; interface DagEdgeSliceState { - dagEdges: Edge[]; + dagEdges: Edge[]; } interface DagEdgeSliceActions { @@ -13,6 +14,10 @@ interface DagEdgeSliceActions { removeEdgesByTarget: (nodeIds: string[]) => void; /** Create an edge */ createEdge: (sourceId?: string, targetId?: string) => void; + /** Mark an edge as decision made by source */ + addEdgeToPath: (source: string, target: string) => void; + /** Remove an edge from the path by source */ + removeEdgeFromPathBySource: (source: string) => void; } export interface DagEdgeSlice extends DagEdgeSliceState, DagEdgeSliceActions {} @@ -54,4 +59,35 @@ export const createDagEdgeSlice: StateCreator< 'createEdge' ); }, + removeEdgeFromPathBySource: (source: string) => { + const newEdges = get().dagEdges.map((edge) => { + if (edge.source === source) { + edge.data = { decisionMade: false }; + } + return edge; + }); + set( + { + dagEdges: newEdges, + }, + false, + 'removeEdgeFromPathBySource' + ); + }, + addEdgeToPath: (source: string, target: string) => { + const newEdges = get().dagEdges.map((edge) => { + if (edge.source === source && edge.target === target) { + edge.style = { stroke: '#05b485', strokeWidth: '3px' }; + edge.data = { decisionMade: true }; + } + return edge; + }); + set( + { + dagEdges: newEdges, + }, + false, + 'markEdgeAsDecisionMade' + ); + }, }); diff --git a/src/store/DagEdgeSlice/dagEdgeUtils.ts b/src/store/DagEdgeSlice/dagEdgeUtils.ts index f3b440e..610e4ee 100644 --- a/src/store/DagEdgeSlice/dagEdgeUtils.ts +++ b/src/store/DagEdgeSlice/dagEdgeUtils.ts @@ -20,7 +20,7 @@ export const createDagEdge = (source: string, target: string): Edge => { hidden: false, source, target, - type: 'smoothstep', + type: 'decision', markerEnd: { type: MarkerType.ArrowClosed }, }; }; diff --git a/src/store/TreeSlice/treeSlice.ts b/src/store/TreeSlice/treeSlice.ts index a7f5870..72df625 100644 --- a/src/store/TreeSlice/treeSlice.ts +++ b/src/store/TreeSlice/treeSlice.ts @@ -19,6 +19,7 @@ export interface TreeSlice { removeNiblings: (nodeId: string) => void; markDecisionMade: (nodeId: string) => void; markDecisionFocused: (nodeId: string) => void; + updatePath: (source: string, target: string) => void; } /** The state of the tree, implemented as a shared slice that builds on concrete slices @@ -75,7 +76,6 @@ export const createTreeSlice: StateCreator< const siblingDescendantIds = siblings.flatMap((id) => getDescendantIds(get().tree, id)); get().setStatus([nodeId], 'chosen'); get().setStatus([...siblingDescendantIds, ...siblings], undefined); - get().updateDagNodes(get().tree); }, markDecisionFocused: (nodeId: string) => { const siblings = getSiblingIds(get().tree, nodeId); @@ -84,4 +84,8 @@ export const createTreeSlice: StateCreator< get().setStatus([...siblingDescendantIds, ...siblings], undefined); get().updateDagNodes(get().tree); }, + updatePath: (source: string, target: string) => { + get().removeEdgeFromPathBySource(source); + get().addEdgeToPath(source, target); + }, });