diff --git a/public/help/guide.html b/public/help/guide.html new file mode 100644 index 0000000..05fdfb5 --- /dev/null +++ b/public/help/guide.html @@ -0,0 +1,42 @@ +

Getting Started

+
+

Welcome to The + Manifest Game, an interactive decision tree to help you + use the U.S. EPA's hazardous waste tracking system known as + e-Manifest. +

+

+ Start by answering the "Yes" or "No" questions in the boxes. As you answer + questions, new questions will appear based on your previous answers. +

+

+ If you are unsure about a question, click the question mark in the question + box to learn more. +

+

Navigate

+

+ You can move around the page by clicking and dragging anywhere on the page. + You can also zoom in and out by using the scroll wheel on your mouse or + pinching on a touch screen. +

+

Mini Map

+

+ The mini map in the bottom right corner shows you where you are on the + decision tree. You can click the mini map to quickly navigate to + different parts of the decision tree, and scroll to zoom in and out. +

+

Change the Layout

+

+ You can change the layout of the decision tree from horizontal to vertical + by clicking the "Layout" button to help you visualize the decision tree in + different ways. +

+

Share Your Decisions

+

+ Once you have completed the decision tree, you can share your decisions with + others by clicking the "Share" button. This will generate a unique URL + you can share with others. +

+
diff --git a/src/App.spec.tsx b/src/App.spec.tsx index ef7a5e2..963e4c4 100644 --- a/src/App.spec.tsx +++ b/src/App.spec.tsx @@ -7,7 +7,7 @@ import { setupServer } from 'msw/node'; import React from 'react'; import { ReactFlowProvider } from 'reactflow'; import useTreeStore from 'store'; -import { renderWithProviders } from 'test-utils'; +import { notFirstTimeMock, renderWithProviders } from 'test-utils'; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; const TestComponent = () => { @@ -48,6 +48,7 @@ afterAll(() => { }); describe('App', () => { + notFirstTimeMock(); test('shows a spinner while waiting for config', () => { server.use( http.get('/default.json', async () => { diff --git a/src/App.tsx b/src/App.tsx index bf30cff..cb448b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,9 +30,7 @@ export default function App() { ) : configError ? ( ) : ( - <> - - + )} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index dfc4f6a..1f2c44f 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,3 +1,6 @@ +import { HelpIcon } from 'components/Help/HelpIcon/HelpIcon'; +import { useHelp } from 'hooks'; +import React, { MouseEventHandler } from 'react'; import { Panel } from 'reactflow'; interface HeaderProps { @@ -6,12 +9,25 @@ interface HeaderProps { export const Header = ({ treeTitle }: HeaderProps) => { const issueUrl = import.meta.env.VITE_ISSUE_URL; + const { showHelp } = useHelp(); + + const showInstructions: MouseEventHandler = (event) => { + try { + showHelp('guide.html'); + event.stopPropagation(); + } catch (error) { + console.error(error); + } + }; return ( - +
-
+

{treeTitle}

+
+ +
{issueUrl && (
diff --git a/src/components/Help/Help.spec.tsx b/src/components/Help/Help.spec.tsx index 343e00d..44b28de 100644 --- a/src/components/Help/Help.spec.tsx +++ b/src/components/Help/Help.spec.tsx @@ -4,6 +4,7 @@ import { Help } from 'components/Help/Help'; import { delay, http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import useTreeStore from 'store'; +import { notFirstTimeMock } from 'test-utils'; import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; const handlers = [ @@ -37,6 +38,7 @@ beforeAll(() => server.listen()); afterAll(() => server.close()); describe('Help', () => { + notFirstTimeMock(); test('renders error message when help content ID is undefined', () => { render(); expect(screen.getByText(/problem/i)).toBeInTheDocument(); diff --git a/src/components/Help/Help.tsx b/src/components/Help/Help.tsx index 8f8fa7b..5a1bed7 100644 --- a/src/components/Help/Help.tsx +++ b/src/components/Help/Help.tsx @@ -35,7 +35,6 @@ export const Help = () => { return ( <> -

More Information

{help?.type === 'text' && } {help?.type === 'html' && } diff --git a/src/components/Help/HelpIcon/HelpIcon.tsx b/src/components/Help/HelpIcon/HelpIcon.tsx index 4fd4bfd..f69718d 100644 --- a/src/components/Help/HelpIcon/HelpIcon.tsx +++ b/src/components/Help/HelpIcon/HelpIcon.tsx @@ -3,13 +3,14 @@ import { FaQuestionCircle } from 'react-icons/fa'; interface HelpIconProps { onClick?: React.MouseEventHandler; + size?: number; } /** * icon to help users make decisions or direct them to more information * @constructor */ -export const HelpIcon = ({ onClick }: HelpIconProps) => { +export const HelpIcon = ({ onClick, size = 30 }: HelpIconProps) => { return (
diff --git a/src/components/OffCanvas/OffCanvas.tsx b/src/components/OffCanvas/OffCanvas.tsx index 1daf0a9..42e9c46 100644 --- a/src/components/OffCanvas/OffCanvas.tsx +++ b/src/components/OffCanvas/OffCanvas.tsx @@ -1,5 +1,5 @@ import { Help } from 'components/Help/Help'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { FaX } from 'react-icons/fa6'; interface OffCanvasProps { @@ -13,6 +13,8 @@ interface OffCanvasProps { */ export const OffCanvas = ({ isOpen, onClose }: OffCanvasProps) => { /** handle when user clicks outside the off canvas component*/ + const contentRef = useRef(null); + const onClickOutside = useCallback(() => { if (isOpen) { if (onClose) onClose(); @@ -41,6 +43,12 @@ export const OffCanvas = ({ isOpen, onClose }: OffCanvasProps) => { return () => document.removeEventListener('click', onClickOutside); }, [onClickOutside]); + useEffect(() => { + if (isOpen) { + contentRef.current?.focus(); + } + }, [contentRef, isOpen]); + return ( <>
{
-
+
{/* backdrop while open*/}
); diff --git a/src/components/Tree/ControlCenter/Controls/LayoutBtn/LayoutBtn.tsx b/src/components/Tree/ControlCenter/Controls/LayoutBtn/LayoutBtn.tsx index 9076de9..8800427 100644 --- a/src/components/Tree/ControlCenter/Controls/LayoutBtn/LayoutBtn.tsx +++ b/src/components/Tree/ControlCenter/Controls/LayoutBtn/LayoutBtn.tsx @@ -12,6 +12,7 @@ export const LayoutBtn = ({ isHorizontal, toggleDirection }: LayoutBtnProps) => {isHorizontal ? : } diff --git a/src/components/Tree/ControlCenter/Controls/MiniMapBtn/MiniMapBtn.tsx b/src/components/Tree/ControlCenter/Controls/MiniMapBtn/MiniMapBtn.tsx index 971f67d..90e6461 100644 --- a/src/components/Tree/ControlCenter/Controls/MiniMapBtn/MiniMapBtn.tsx +++ b/src/components/Tree/ControlCenter/Controls/MiniMapBtn/MiniMapBtn.tsx @@ -9,7 +9,11 @@ interface MiniMapBtnProps { /** LayoutBtn toggles the layout direction of the tree.*/ export const MiniMapBtn = ({ visible, onClick }: MiniMapBtnProps) => { return ( - + {visible ? : } ); diff --git a/src/components/Tree/ControlCenter/Controls/ShareBtn/ShareBtn.tsx b/src/components/Tree/ControlCenter/Controls/ShareBtn/ShareBtn.tsx index 3158263..765093e 100644 --- a/src/components/Tree/ControlCenter/Controls/ShareBtn/ShareBtn.tsx +++ b/src/components/Tree/ControlCenter/Controls/ShareBtn/ShareBtn.tsx @@ -7,7 +7,11 @@ export const ShareBtn = () => { const { copyTreeUrlToClipboard } = useUrl(); return ( - + ); diff --git a/src/components/Tree/Nodes/BoolNode/BoolNode.tsx b/src/components/Tree/Nodes/BoolNode/BoolNode.tsx index 3452e18..f2ed193 100644 --- a/src/components/Tree/Nodes/BoolNode/BoolNode.tsx +++ b/src/components/Tree/Nodes/BoolNode/BoolNode.tsx @@ -21,7 +21,7 @@ export const BoolNode = ({ }: NodeProps) => { const { showHelp } = useHelp(); const { retractDecision, makeDecision } = useDecisionTree(); - const { decisionIsInPath, decision, isCurrentDecision } = useDecisions(id); + const { decisionIsInPath, decision } = useDecisions(id); const handleHelpClick: MouseEventHandler = (event) => { try { @@ -44,8 +44,7 @@ export const BoolNode = ({ data-testid={`bool-node-${id}-content`} className={`flex min-w-80 flex-col items-center justify-center rounded-xl p-6 text-xl text-white - ${decisionIsInPath(id) ? 'bg-gradient-to-b from-teal-700 to-teal-800' : 'bg-gradient-to-b from-sky-700 to-sky-900'} - ${isCurrentDecision ? 'animate-pulse' : ''}`} + ${decisionIsInPath(id) ? 'bg-gradient-to-b from-teal-700 to-teal-800' : 'bg-gradient-to-b from-sky-700 to-sky-900'}`} > {help && (
diff --git a/src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx b/src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx index d46e064..bed398b 100644 --- a/src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx +++ b/src/components/Tree/Nodes/DefaultNode/DefaultNode.tsx @@ -23,13 +23,11 @@ export const DefaultNode = ({ data, ...props }: NodeProps) => { ? 'bg-teal-700' : 'bg-gradient-to-b from-sky-700 to-sky-900'; - const nodeFocusedClasses = isCurrentDecision ? 'animate-pulse' : ''; - return (
{data.help && (
diff --git a/src/components/Tree/Tree.tsx b/src/components/Tree/Tree.tsx index 6931d74..1ad8eb6 100644 --- a/src/components/Tree/Tree.tsx +++ b/src/components/Tree/Tree.tsx @@ -39,17 +39,18 @@ export const Tree = ({ nodes, edges }: TreeProps) => { onNodesChange={onNodesChange} fitView edgesFocusable={false} - fitViewOptions={{ padding: 5, minZoom: 0, maxZoom: 5 }} + fitViewOptions={{ padding: 5, minZoom: 0, maxZoom: 2 }} proOptions={{ hideAttribution: true }} > {mapVisible && ( - setCenter(position.x, position.y, { zoom: zoom, duration: 1.5 }) + setCenter(position.x, position.y, { zoom: zoom }) } /> )} diff --git a/src/hooks/useHelp/useHelp.spec.tsx b/src/hooks/useHelp/useHelp.spec.tsx index 0c82cdb..bef95bb 100644 --- a/src/hooks/useHelp/useHelp.spec.tsx +++ b/src/hooks/useHelp/useHelp.spec.tsx @@ -1,12 +1,26 @@ import '@testing-library/jest-dom'; import { renderHook } from '@testing-library/react'; import { useHelp } from 'hooks/useHelp/useHelp'; -import { describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; describe('useHelp hook', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + afterEach(() => { + localStorage.clear(); + getItemSpy.mockClear(); + setItemSpy.mockClear(); + }); + + test('helpIsOpen is true on a user first visit', () => { + const { result } = renderHook(() => useHelp()); + expect(result.current.helpIsOpen).toBeTruthy(); + }); test('helpIsOpen is initially false', () => { + localStorage.setItem('tmg-first-time', 'true'); const { result } = renderHook(() => useHelp()); - expect(result.current.helpIsOpen).toBe(false); + expect(result.current.helpIsOpen).toBeFalsy(); }); test('showHelp returns if arg is undefined', () => { const { result } = renderHook(() => useHelp()); diff --git a/src/hooks/useHelp/useHelp.tsx b/src/hooks/useHelp/useHelp.tsx index 74cd451..90bc3a3 100644 --- a/src/hooks/useHelp/useHelp.tsx +++ b/src/hooks/useHelp/useHelp.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useState } from 'react'; import useTreeStore from 'store'; export interface UseHelpReturn { @@ -5,6 +6,7 @@ export interface UseHelpReturn { contentFilename: string | undefined; showHelp: (contentId: string | undefined) => void; hideHelp: () => void; + showInstructions: () => void; } /** @@ -17,16 +19,33 @@ export const useHelp = () => { hideHelp, showHelp: storeShowHelp, } = useTreeStore((state) => state); + const [firstTime, setFirstTime] = useState(window.localStorage.getItem('tmg-first-time')); - const showHelp = (contentId: string | undefined) => { - if (!contentId) throw new Error('contentId is required'); - storeShowHelp(contentId); - }; + const showHelp = useCallback( + (contentId: string | undefined) => { + if (!contentId) throw new Error('contentId is required'); + storeShowHelp(contentId); + }, + [storeShowHelp] + ); + + const showInstructions = useCallback(() => { + showHelp('guide.html'); + }, [showHelp]); + + useEffect(() => { + if (!firstTime) { + showInstructions(); + } + setFirstTime('false'); + window.localStorage.setItem('tmg-first-time', 'false'); + }, [firstTime, showInstructions]); return { helpIsOpen: helpIsOpen, contentFilename, showHelp, hideHelp, + showInstructions, } as UseHelpReturn; }; diff --git a/src/index.css b/src/index.css index bf0851a..b8b98ab 100644 --- a/src/index.css +++ b/src/index.css @@ -45,25 +45,20 @@ body, .react-flow__controls button { border-radius: 10px; border: 2px solid #fff; - margin-top: 5px; + margin-top: 2px; background: #fefefe !important; } .react-flow__node { border-radius: 15px; - border: 2px solid #333333; + border: 3px solid #333333; } .react-flow__node:focus { - border: white 2px solid; + border: white 3px solid; border-radius: 15px; } -.offcanvas-scrollbar { - scrollbar-color: #333333 transparent; -} - - @layer components { .z-top { z-index: 1050; diff --git a/src/test-utils.tsx b/src/test-utils.tsx index 45dd747..c44ddbd 100644 --- a/src/test-utils.tsx +++ b/src/test-utils.tsx @@ -2,6 +2,7 @@ import { render, RenderOptions } from '@testing-library/react'; import React, { PropsWithChildren, ReactElement } from 'react'; import { MemoryRouter, MemoryRouterProps } from 'react-router-dom'; import { ReactFlowProvider } from 'reactflow'; +import { afterEach, beforeEach, vi } from 'vitest'; interface DecisionTreeRenderOptions extends RenderOptions { memoryRouterProps?: MemoryRouterProps; @@ -30,3 +31,18 @@ export function renderWithProviders( return { ...render(ui, { wrapper: Wrapper, ...renderOptions }) }; } + +export const notFirstTimeMock = () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + afterEach(() => { + localStorage.clear(); + getItemSpy.mockClear(); + setItemSpy.mockClear(); + }); + + beforeEach(() => { + localStorage.setItem('tmg-first-time', 'false'); + }); +}; diff --git a/tsconfig.json b/tsconfig.json index 9633154..8e30641 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,11 @@ "compilerOptions": { "baseUrl": "src", "target": "ES6", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -18,6 +22,11 @@ "jsx": "react-jsx", "incremental": true }, - "include": ["src"], - "exclude": ["node_modules", "build"] + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "build" + ] }