Skip to content

Commit

Permalink
Render decision tree based on url (#125)
Browse files Browse the repository at this point in the history
* rename OffCanvas prop from 'handleClose' to 'onClose'

* add path URL parameter as parameter to to useDecisionTree

* build path (and store in global state) from URL query parameter

* auto build tree when URL query parameter is present

* remove duplicate function

* add error handling for showHelp function returned from useHelp hook
  • Loading branch information
dpgraham4401 authored May 9, 2024
1 parent ebb2d9b commit 662f06e
Show file tree
Hide file tree
Showing 12 changed files with 141 additions and 109 deletions.
10 changes: 4 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Tree } from 'components/Tree/Tree';
import { useDecisionTree } from 'hooks';
import { useFetchConfig } from 'hooks/useFetchConfig/useFetchConfig';
import { useHelp } from 'hooks/useHelp/useHelp';
import { useUrl } from 'hooks/useUrl/useUrl';

/**
* App - responsible for rendering the decision tree
Expand All @@ -16,13 +17,10 @@ import { useHelp } from 'hooks/useHelp/useHelp';
export default function App() {
const title = import.meta.env.VITE_APP_TITLE ?? 'The Manifest Game';
const { config, isLoading: configIsLoading, error: configError } = useFetchConfig(defaultTree);
const { nodes, edges } = useDecisionTree(config);
const { pathParam } = useUrl();
const { nodes, edges } = useDecisionTree(config, pathParam);
const { helpIsOpen, hideHelp } = useHelp();

const handleHelpClose = () => {
hideHelp();
};

return (
<>
<BackgroundImage />
Expand All @@ -36,7 +34,7 @@ export default function App() {
<Tree nodes={nodes} edges={edges} />
</>
)}
<OffCanvas isOpen={helpIsOpen} handleClose={handleHelpClose} />
<OffCanvas isOpen={helpIsOpen} onClose={hideHelp} />
</>
);
}
14 changes: 7 additions & 7 deletions src/components/OffCanvas/OffCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,29 @@ import { FaX } from 'react-icons/fa6';

interface OffCanvasProps {
isOpen: boolean;
handleClose: () => void;
onClose: () => void;
}

/**
* Sidebar for displaying content and help
* @constructor
*/
export const OffCanvas = ({ isOpen, handleClose }: OffCanvasProps) => {
export const OffCanvas = ({ isOpen, onClose }: OffCanvasProps) => {
/** handle when user clicks outside the off canvas component*/
const onClickOutside = useCallback(() => {
if (isOpen) {
if (handleClose) handleClose();
if (onClose) onClose();
}
}, [isOpen, handleClose]);
}, [isOpen, onClose]);

/** handle when user presses the escape key */
const onEscKey = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
if (handleClose) handleClose();
if (onClose) onClose();
}
},
[isOpen, handleClose]
[isOpen, onClose]
);

/** add event listeners for escape key keydown*/
Expand Down Expand Up @@ -62,7 +62,7 @@ export const OffCanvas = ({ isOpen, handleClose }: OffCanvasProps) => {
className="text-gray800 rounded-full p-1 transition-colors duration-200 ease-in-out
hover:text-gray-900 focus:outline-none focus:ring
focus:ring-gray-800 active:text-gray-900"
onClick={handleClose}
onClick={onClose}
type="button"
tabIndex={0}
aria-label="Close"
Expand Down
8 changes: 4 additions & 4 deletions src/components/OffCanvas/Offcanvas.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ afterEach(() => cleanup());

describe('OffCanvas', () => {
test('renders', () => {
render(<OffCanvas isOpen={true} handleClose={() => undefined} />);
render(<OffCanvas isOpen={true} onClose={() => undefined} />);
expect(screen.getByTestId(/offcanvas/i)).toBeInTheDocument();
});

test('handles close event', () => {
const handleClose = vi.fn();
render(<OffCanvas isOpen={true} handleClose={handleClose} />);
render(<OffCanvas isOpen={true} onClose={handleClose} />);
screen.getByRole('button', { name: /close/i }).click();
expect(handleClose).toHaveBeenCalled();
});
test('closes modal when user clicks close', () => {
const handleClose = vi.fn();
const { rerender } = render(<OffCanvas isOpen={true} handleClose={handleClose} />);
const { rerender } = render(<OffCanvas isOpen={true} onClose={handleClose} />);
expect(screen.getByTestId(/offcanvas/i)).toBeVisible();
rerender(<OffCanvas isOpen={false} handleClose={handleClose} />);
rerender(<OffCanvas isOpen={false} onClose={handleClose} />);
expect(screen.getByTestId(/offcanvas/i)).not.toBeVisible();
});
});
8 changes: 6 additions & 2 deletions src/components/Tree/Nodes/BoolNode/BoolNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ export const BoolNode = ({
const { decisionIsInPath, decision, isCurrentDecision } = useDecisions(id);

const handleHelpClick: MouseEventHandler = (event) => {
showHelp(help);
event.stopPropagation();
try {
showHelp(help);
event.stopPropagation();
} catch (error) {
console.error(error);
}
};

const handleAnswer = (answer: boolean) => (event: React.MouseEvent<HTMLButtonElement>) => {
Expand Down
8 changes: 6 additions & 2 deletions src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ export const DefaultNode = ({ data, ...props }: NodeProps<VertexData>) => {
const { isCurrentDecision } = useDecisions(props.id);

const handleHelpClick: MouseEventHandler = (event) => {
showHelp(props.id);
event.stopPropagation();
try {
showHelp(props.id);
event.stopPropagation();
} catch (error) {
console.error(error);
}
};

const nodeBackgroundColor = isCurrentDecision
Expand Down
26 changes: 21 additions & 5 deletions src/hooks/useDecisionTree/useDecisionTree.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useTreeViewport } from 'hooks/useTreeViewport/useTreeViewport';
import { useUrl } from 'hooks/useUrl/useUrl';
import { useEffect } from 'react';
import useDecTreeStore, { PositionUnawareDecisionTree } from 'store';

/**
* custom hook that wraps around the tree store to provide a simplified interface for common tasks
* @param initialTree
* @param pathParam
*/
export const useDecisionTree = (initialTree?: PositionUnawareDecisionTree) => {
export const useDecisionTree = (initialTree?: PositionUnawareDecisionTree, pathParam?: string) => {
const { setCenter, getZoom } = useTreeViewport();
const {
tree,
Expand All @@ -22,8 +22,9 @@ export const useDecisionTree = (initialTree?: PositionUnawareDecisionTree) => {
onEdgesChange,
addDecisionToPath,
removeDecisionFromPath,
getParentVertexId,
getPath,
} = useDecTreeStore((state) => state);
const { setPathParam } = useUrl();

const focusNode = (nodeId: string) => {
setCenter(tree[nodeId].position.x + 50, tree[nodeId].position.y + 50, {
Expand All @@ -39,16 +40,31 @@ export const useDecisionTree = (initialTree?: PositionUnawareDecisionTree) => {
Object.values(initialTree).forEach((node) => {
if (!node.hidden) showNode(node.id);
});
if (pathParam) {
const parent = getParentVertexId(pathParam);
if (parent) addDecisionToPath(parent, pathParam);
getPath().forEach((decision) => {
showChildren(decision.nodeId);
});
}
}
}, [initialTree, setDecisionTree, showNode]);
}, [
getPath,
showChildren,
addDecisionToPath,
initialTree,
pathParam,
setDecisionTree,
showNode,
getParentVertexId,
]);

const makeDecision = (source: string, target: string) => {
showNode(target, { parentId: source });
focusNode(target);
showChildren(target);
hideNiblings(source);
addDecisionToPath(source, target);
setPathParam(target);
};

const retractDecision = (target: string) => {
Expand Down
8 changes: 6 additions & 2 deletions src/hooks/useHelp/useHelp.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import '@testing-library/jest-dom';
import { renderHook } from '@testing-library/react';
import { useHelp } from 'hooks/useHelp/useHelp';
import { expect, suite, test } from 'vitest';
import { describe, expect, test } from 'vitest';

suite('useHelp hook', () => {
describe('useHelp hook', () => {
test('helpIsOpen is initially false', () => {
const { result } = renderHook(() => useHelp());
expect(result.current.helpIsOpen).toBe(false);
});
test('showHelp returns if arg is undefined', () => {
const { result } = renderHook(() => useHelp());
expect(() => result.current.showHelp(undefined)).toThrowError('contentId is required');
});
});
2 changes: 1 addition & 1 deletion src/hooks/useHelp/useHelp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const useHelp = () => {
} = useTreeStore((state) => state);

const showHelp = (contentId: string | undefined) => {
if (!contentId) return;
if (!contentId) throw new Error('contentId is required');
storeShowHelp(contentId);
};

Expand Down
4 changes: 4 additions & 0 deletions src/store/DecisionTreeStore/decisionTreeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface DecisionTreeStore {
hideNiblings: (nodeId: string) => void;
addDecisionToPath: (source: string, target: string) => void;
removeDecisionFromPath: (nodeId: string) => void;
getParentId: (nodeId: string) => string | undefined;
}

/** The state of the decision tree, implemented as a shared slice that builds on concrete slices
Expand Down Expand Up @@ -80,4 +81,7 @@ export const createDecisionTreeStore: StateCreator<
.filter((decision) => !decisionIdsToRemove.includes(decision.nodeId));
get().setPath(pathWithoutDescendants);
},
getParentId: (nodeId: string) => {
return get().getParentVertexId(nodeId);
},
});
13 changes: 12 additions & 1 deletion src/store/TreeSlice/treeSlice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Node } from 'reactflow';
import { layoutTree } from 'store/TreeSlice/layout';
import { getAncestorIds, setNodesHidden, setNodeVisible } from 'store/TreeSlice/treeSliceUtils';
import {
getAncestorIds,
getParentId,
setNodesHidden,
setNodeVisible,
} from 'store/TreeSlice/treeSliceUtils';
import { StateCreator } from 'zustand';

/** Data needed by all nodes in our tree*/
Expand Down Expand Up @@ -62,6 +67,8 @@ interface TreeSliceActions {
removePathDecision: (nodeId: string) => void;
/** get ancestor IDs */
getAncestorDecisions: (nodeId: string) => string[];
/** get a node's parent ID */
getParentVertexId: (nodeId: string) => string | undefined;
}

export interface TreeSlice extends TreeSliceActions, TreeSliceState {}
Expand Down Expand Up @@ -141,4 +148,8 @@ export const createTreeSlice: StateCreator<
const tree = get().tree;
return getAncestorIds(tree, nodeId);
},
getParentVertexId: (nodeId: string) => {
const tree = get().tree;
return getParentId(tree, nodeId);
},
});
Loading

0 comments on commit 662f06e

Please sign in to comment.