From 6320c52c797351c1efaba168837098e64eaea447 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Sat, 23 Aug 2025 12:17:14 +1200 Subject: [PATCH 1/4] Integrate PathwayProvider into App component for improved state management, add TeamNodeDrawer for enhanced UI functionality, and update PeopleFlow to reflect selected team node state. Refactor TeamNode to manage selection state with context and simplify rendering logic in PeopleListItem for better user experience. --- src/App.tsx | 31 ++-- src/components/PeopleFlow.tsx | 10 +- src/components/PeopleList/PeopleFilter.tsx | 139 ++++++++++++++++++ src/components/PeopleList/PeopleList.tsx | 3 +- .../PeopleListItem/PeopleListItem.tsx | 116 +++++++-------- src/components/PeopleList/index.ts | 1 + src/components/TeamNode.tsx | 81 ++++------ src/components/TeamNodeDrawer.tsx | 102 +++++++++++++ src/contexts/PathwayContext.ts | 33 +++++ src/contexts/PathwayProvider.tsx | 43 ++++++ src/contexts/pathwayActions.ts | 11 ++ src/hooks/usePathway.ts | 10 ++ 12 files changed, 452 insertions(+), 128 deletions(-) create mode 100644 src/components/PeopleList/PeopleFilter.tsx create mode 100644 src/components/TeamNodeDrawer.tsx create mode 100644 src/contexts/PathwayContext.ts create mode 100644 src/contexts/PathwayProvider.tsx create mode 100644 src/contexts/pathwayActions.ts create mode 100644 src/hooks/usePathway.ts diff --git a/src/App.tsx b/src/App.tsx index a742787..7b86451 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,25 +3,32 @@ import { useState } from "react"; import "./App.css"; import PeopleFlow from "./components/PeopleFlow.tsx"; import CampusFilterButtons from "./components/CampusFilterButtons.tsx"; +import TeamNodeDrawer from "./components/TeamNodeDrawer.tsx"; +import { PathwayProvider } from "./contexts/PathwayProvider.tsx"; function App() { const [selectedCampusId, setSelectedCampusId] = useState("3"); return ( - -
- {/* Campus Filter Buttons */} - + + +
+ {/* Campus Filter Buttons */} + - {/* React Flow Container */} -
- + {/* React Flow Container */} +
+ +
+ + {/* Team Node Drawer */} +
-
-
+ +
); } diff --git a/src/components/PeopleFlow.tsx b/src/components/PeopleFlow.tsx index b65e2f5..6c754fc 100644 --- a/src/components/PeopleFlow.tsx +++ b/src/components/PeopleFlow.tsx @@ -12,6 +12,7 @@ import StaticNode from "./StaticNode"; import { usePeopleFlowData } from "../hooks/usePeopleFlowData"; import { getInitialLayout } from "../utils/layoutUtils"; import { createNodesFromStatuses } from "../utils/nodeUtils"; +import { usePathway } from "../hooks/usePathway"; // Define custom node types outside component to prevent re-renders const nodeTypes = { @@ -27,6 +28,9 @@ const PeopleFlow = ({ campusFilter }: PeopleFlowProps) => { const { data, isLoading, error } = usePeopleFlowData(campusFilter); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { + state: { selectedTeamNode }, + } = usePathway(); // Transform data into React Flow nodes and edges const flowData = useMemo(() => { @@ -62,7 +66,11 @@ const PeopleFlow = ({ campusFilter }: PeopleFlowProps) => { } return ( -
+
void; +} + +function PeopleFilter({ people, onFilterChange }: PeopleFilterProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [showOnlyCG, setShowOnlyCG] = useState(false); + const [showOnlyServing, setShowOnlyServing] = useState(false); + + const filteredPeople = useMemo(() => { + return people + .filter((person) => { + // Text search filter + const matchesSearch = person.fullName + .toLowerCase() + .includes(searchTerm.toLowerCase()); + + // CG group filter + const matchesCG = !showOnlyCG || person.cgGroup; + + // Serving filter + const matchesServing = !showOnlyServing || person.isServing; + + return matchesSearch && matchesCG && matchesServing; + }) + .sort((a, b) => a.fullName.localeCompare(b.fullName)); + }, [people, searchTerm, showOnlyCG, showOnlyServing]); + + // Update parent component when filters change + useMemo(() => { + onFilterChange(filteredPeople); + }, [filteredPeople, onFilterChange]); + + const cgCount = people.filter((p) => p.cgGroup).length; + const servingCount = people.filter((p) => p.isServing).length; + + return ( +
+ {/* Search Box */} +
+ { + setSearchTerm(e.target.value); + }} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {searchTerm && ( + + )} +
+ + {/* Filter Chips */} +
+ {/* CG Filter Chip - only show if there are people with CG groups */} + {cgCount > 0 && ( + + )} + + {/* Serving Filter Chip - only show if there are people serving */} + {servingCount > 0 && ( + + )} + + {/* Clear Filters Button */} + {(searchTerm || showOnlyCG || showOnlyServing) && ( + + )} +
+ + {/* Results Count */} +
+ Showing {filteredPeople.length} of {people.length} people +
+
+ ); +} + +export default PeopleFilter; diff --git a/src/components/PeopleList/PeopleList.tsx b/src/components/PeopleList/PeopleList.tsx index 3ecad9c..c30d12a 100644 --- a/src/components/PeopleList/PeopleList.tsx +++ b/src/components/PeopleList/PeopleList.tsx @@ -16,11 +16,10 @@ function PeopleList({ people, surveys, label }: PeopleListProps) { ); } - // Create a map of person ID to survey for quick lookup const surveyMap = new Map(surveys.map((survey) => [survey.personId, survey])); return ( -
+
{people.map((person) => ( -
- { - e.stopPropagation(); - }} - > - {personName} - - {/* Only show chips for Attending or Growing statuses */} - {(label === "Attending" || label === "Growing") && ( - <> - {/* Connect Group Chip */} + + {personName} +
+ {personName} +
+ {/* Only show chips for Attending or Growing statuses */} + {(label === "Attending" || label === "Growing") && ( + <> + {/* Connect Group Chip */} +
+ CG +
+ {/* Serving Chip */} + {hasDoneSurvey ? ( +
+ S + + ) : (
{ - e.stopPropagation(); - }} - title={person.cgGroup ?? undefined} > - CG + S
- {/* Serving Chip */} - {hasDoneSurvey ? ( - { - e.stopPropagation(); - }} - title="View Survey" - > - S - - ) : ( -
{ - e.stopPropagation(); - }} - > - S -
- )} - - )} -
-
+ )} + + )} + ); } diff --git a/src/components/PeopleList/index.ts b/src/components/PeopleList/index.ts index e5db3c6..122e449 100644 --- a/src/components/PeopleList/index.ts +++ b/src/components/PeopleList/index.ts @@ -1 +1,2 @@ export { default } from "./PeopleList"; +export { default as PeopleFilter } from "./PeopleFilter"; diff --git a/src/components/TeamNode.tsx b/src/components/TeamNode.tsx index c7496f6..4f2387b 100644 --- a/src/components/TeamNode.tsx +++ b/src/components/TeamNode.tsx @@ -1,8 +1,8 @@ -import { memo, useRef, useEffect, useState } from "react"; +import { memo } from "react"; import { Handle, Position, type NodeProps } from "reactflow"; import type { Person, Survey } from "../hooks/usePeopleFlowData"; -import { updateNodeDimensions } from "../utils/layoutUtils"; -import PeopleList from "./PeopleList"; +import { usePathway } from "../hooks/usePathway"; +import { selectTeamNode, deselectTeamNode } from "../contexts/pathwayActions"; interface TeamNodeData { label?: string; @@ -12,36 +12,41 @@ interface TeamNodeData { } const TeamNode = memo(({ data, id }: NodeProps) => { - const { label, description, people, surveys = [] } = data; - const nodeRef = useRef(null); - const [isExpanded, setIsExpanded] = useState(false); + const { label, description, people } = data; + const { + state: { selectedTeamNode }, + dispatch, + } = usePathway(); - // Measure and update node dimensions when content changes - useEffect(() => { - if (nodeRef.current) { - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - updateNodeDimensions(id, width, height); - } - }); - - resizeObserver.observe(nodeRef.current); - - return () => { - resizeObserver.disconnect(); - }; - } - }, [id, isExpanded]); + const isSelected = selectedTeamNode?.id === id; const handleNodeClick = () => { - setIsExpanded(!isExpanded); + if (isSelected) { + // If already selected, deselect it + dispatch(deselectTeamNode()); + } else { + // Select this team node + dispatch( + selectTeamNode({ + data: { + id, + name: label ?? "", + description: description ?? "", + people, + }, + id, + }) + ); + } }; return (
) => { position={Position.Top} className="w-0 h-0 opacity-0" /> -
+

{label} ({people.length})

- {description && ( -
{description}
- )} -
-
- -
-
([]); + + const handleClose = () => { + dispatch(deselectTeamNode()); + }; + + // Initialize filtered people when selectedTeamNode changes + useMemo(() => { + if (selectedTeamNode) { + setFilteredPeople(selectedTeamNode.data.people); + } + }, [selectedTeamNode]); + + const handleFilterChange = (newFilteredPeople: Person[]) => { + setFilteredPeople(newFilteredPeople); + }; + + return ( +
+ {/* Fixed Header */} +
+

+ {selectedTeamNode?.data.name ?? "Team Details"} +

+ +
+ + {/* Scrollable Content */} +
+
+ {selectedTeamNode ? ( + <> + {/* Description - scrolls normally */} + {selectedTeamNode.data.description && ( +

+ {selectedTeamNode.data.description} +

+ )} + + {/* Sticky Filter - sticks to top when scrolled */} +
+ +
+ + {/* People List */} +
+ +
+ + ) : ( +
+

Select a team node to view details

+
+ )} +
+
+
+ ); +} + +export default TeamNodeDrawer; diff --git a/src/contexts/PathwayContext.ts b/src/contexts/PathwayContext.ts new file mode 100644 index 0000000..56a1a12 --- /dev/null +++ b/src/contexts/PathwayContext.ts @@ -0,0 +1,33 @@ +import { createContext } from "react"; +import type { Person } from "../hooks/usePeopleFlowData"; + +// Types +export interface TeamNodeData { + id: string; + name: string; + description: string; + people: Person[]; +} + +export interface SelectedTeamNode { + data: TeamNodeData; + id: string; +} + +export interface PathwayState { + selectedTeamNode: SelectedTeamNode | null; +} + +export type PathwayAction = + | { type: "SELECT_TEAM_NODE"; payload: SelectedTeamNode } + | { type: "DESELECT_TEAM_NODE" }; + +// Context +interface PathwayContextType { + state: PathwayState; + dispatch: React.Dispatch; +} + +export const PathwayContext = createContext( + undefined +); diff --git a/src/contexts/PathwayProvider.tsx b/src/contexts/PathwayProvider.tsx new file mode 100644 index 0000000..0766f60 --- /dev/null +++ b/src/contexts/PathwayProvider.tsx @@ -0,0 +1,43 @@ +import { useReducer, useMemo } from "react"; +import type { ReactNode } from "react"; +import { PathwayContext } from "./PathwayContext"; +import type { PathwayState, PathwayAction } from "./PathwayContext"; + +// Initial state +const initialState: PathwayState = { + selectedTeamNode: null, +}; + +// Reducer +function pathwayReducer( + state: PathwayState, + action: PathwayAction +): PathwayState { + switch (action.type) { + case "SELECT_TEAM_NODE": + return { + ...state, + selectedTeamNode: action.payload, + }; + case "DESELECT_TEAM_NODE": + return { + ...state, + selectedTeamNode: null, + }; + default: + return state; + } +} + +// Provider component +interface PathwayProviderProps { + children: ReactNode; +} + +export function PathwayProvider({ children }: PathwayProviderProps) { + const [state, dispatch] = useReducer(pathwayReducer, initialState); + + const contextValue = useMemo(() => ({ state, dispatch }), [state, dispatch]); + + return {children}; +} diff --git a/src/contexts/pathwayActions.ts b/src/contexts/pathwayActions.ts new file mode 100644 index 0000000..2417c21 --- /dev/null +++ b/src/contexts/pathwayActions.ts @@ -0,0 +1,11 @@ +import type { SelectedTeamNode, PathwayAction } from "./PathwayContext"; + +// Action creators +export const selectTeamNode = (teamNode: SelectedTeamNode): PathwayAction => ({ + type: "SELECT_TEAM_NODE", + payload: teamNode, +}); + +export const deselectTeamNode = (): PathwayAction => ({ + type: "DESELECT_TEAM_NODE", +}); diff --git a/src/hooks/usePathway.ts b/src/hooks/usePathway.ts new file mode 100644 index 0000000..ae44ea8 --- /dev/null +++ b/src/hooks/usePathway.ts @@ -0,0 +1,10 @@ +import { use } from "react"; +import { PathwayContext } from "../contexts/PathwayContext"; + +export function usePathway() { + const context = use(PathwayContext); + if (context === undefined) { + throw new Error("usePathway must be used within a PathwayProvider"); + } + return context; +} From bcd687244e2a15e5a7528e4f865ef44df7b962f1 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Sat, 23 Aug 2025 12:34:19 +1200 Subject: [PATCH 2/4] Enhance PeopleFlow and TeamNode components by integrating selected team node handling, updating label references, and refining PeopleList props for improved flexibility. Adjust TeamNodeDrawer to display correct team details and ensure consistent data structure across components. --- src/components/PeopleFlow.tsx | 12 ++++++++++++ src/components/PeopleList/PeopleList.tsx | 2 +- .../PeopleListItem/PeopleListItem.tsx | 2 +- src/components/TeamNode.tsx | 18 +++--------------- src/components/TeamNodeDrawer.tsx | 4 ++-- src/contexts/PathwayContext.ts | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/components/PeopleFlow.tsx b/src/components/PeopleFlow.tsx index 6c754fc..5093092 100644 --- a/src/components/PeopleFlow.tsx +++ b/src/components/PeopleFlow.tsx @@ -13,6 +13,8 @@ import { usePeopleFlowData } from "../hooks/usePeopleFlowData"; import { getInitialLayout } from "../utils/layoutUtils"; import { createNodesFromStatuses } from "../utils/nodeUtils"; import { usePathway } from "../hooks/usePathway"; +import { selectTeamNode } from "../contexts/pathwayActions"; +import type { SelectedTeamNode } from "../contexts/PathwayContext"; // Define custom node types outside component to prevent re-renders const nodeTypes = { @@ -30,6 +32,7 @@ const PeopleFlow = ({ campusFilter }: PeopleFlowProps) => { const [edges, setEdges, onEdgesChange] = useEdgesState([]); const { state: { selectedTeamNode }, + dispatch, } = usePathway(); // Transform data into React Flow nodes and edges @@ -47,8 +50,17 @@ const PeopleFlow = ({ campusFilter }: PeopleFlowProps) => { // Set nodes and edges when data changes useEffect(() => { + if (selectedTeamNode) { + const teamNode = flowData.nodes.find( + (node) => node.id == selectedTeamNode.id + ); + if (teamNode) { + dispatch(selectTeamNode(teamNode as unknown as SelectedTeamNode)); + } + } setNodes(flowData.nodes); setEdges(flowData.edges); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [flowData.nodes, flowData.edges, setNodes, setEdges]); if (isLoading) { diff --git a/src/components/PeopleList/PeopleList.tsx b/src/components/PeopleList/PeopleList.tsx index c30d12a..4918a71 100644 --- a/src/components/PeopleList/PeopleList.tsx +++ b/src/components/PeopleList/PeopleList.tsx @@ -4,7 +4,7 @@ import PeopleListItem from "./PeopleListItem"; interface PeopleListProps { people: Person[]; surveys: Survey[]; - label: string; + label?: string; } function PeopleList({ people, surveys, label }: PeopleListProps) { diff --git a/src/components/PeopleList/PeopleListItem/PeopleListItem.tsx b/src/components/PeopleList/PeopleListItem/PeopleListItem.tsx index 3d45c15..1f4f4b7 100644 --- a/src/components/PeopleList/PeopleListItem/PeopleListItem.tsx +++ b/src/components/PeopleList/PeopleListItem/PeopleListItem.tsx @@ -3,7 +3,7 @@ import type { Person, Survey } from "../../../hooks/usePeopleFlowData"; interface PeopleListItemProps { person: Person; survey: Survey | undefined; - label: string; + label?: string; } function PeopleListItem({ person, survey, label }: PeopleListItemProps) { diff --git a/src/components/TeamNode.tsx b/src/components/TeamNode.tsx index 4f2387b..a63eccb 100644 --- a/src/components/TeamNode.tsx +++ b/src/components/TeamNode.tsx @@ -1,18 +1,11 @@ import { memo } from "react"; import { Handle, Position, type NodeProps } from "reactflow"; -import type { Person, Survey } from "../hooks/usePeopleFlowData"; import { usePathway } from "../hooks/usePathway"; import { selectTeamNode, deselectTeamNode } from "../contexts/pathwayActions"; - -interface TeamNodeData { - label?: string; - description?: string; - people: Person[]; - surveys?: Survey[]; -} +import type { TeamNodeData } from "../contexts/PathwayContext"; const TeamNode = memo(({ data, id }: NodeProps) => { - const { label, description, people } = data; + const { label, people } = data; const { state: { selectedTeamNode }, dispatch, @@ -28,12 +21,7 @@ const TeamNode = memo(({ data, id }: NodeProps) => { // Select this team node dispatch( selectTeamNode({ - data: { - id, - name: label ?? "", - description: description ?? "", - people, - }, + data, id, }) ); diff --git a/src/components/TeamNodeDrawer.tsx b/src/components/TeamNodeDrawer.tsx index a37b198..eb3a293 100644 --- a/src/components/TeamNodeDrawer.tsx +++ b/src/components/TeamNodeDrawer.tsx @@ -34,7 +34,7 @@ function TeamNodeDrawer() { {/* Fixed Header */}

- {selectedTeamNode?.data.name ?? "Team Details"} + {selectedTeamNode?.data.label ?? "Team Details"}

diff --git a/src/contexts/PathwayContext.ts b/src/contexts/PathwayContext.ts index 56a1a12..df8714c 100644 --- a/src/contexts/PathwayContext.ts +++ b/src/contexts/PathwayContext.ts @@ -4,8 +4,8 @@ import type { Person } from "../hooks/usePeopleFlowData"; // Types export interface TeamNodeData { id: string; - name: string; - description: string; + label?: string; + description?: string; people: Person[]; } From e3536c6cfaee724eb68f5f9b2a37c62b632e2d62 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Sat, 23 Aug 2025 14:55:17 +1200 Subject: [PATCH 3/4] Enhance PeopleList and TeamNodeDrawer components by adding hasActiveFilters prop to manage filter states. Update PeopleList to display appropriate messages based on filter status, improving user feedback when no matching people are found. --- src/components/PeopleList/PeopleList.tsx | 16 +++++++++++++--- src/components/TeamNodeDrawer.tsx | 4 ++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/PeopleList/PeopleList.tsx b/src/components/PeopleList/PeopleList.tsx index 4918a71..515e11b 100644 --- a/src/components/PeopleList/PeopleList.tsx +++ b/src/components/PeopleList/PeopleList.tsx @@ -5,13 +5,23 @@ interface PeopleListProps { people: Person[]; surveys: Survey[]; label?: string; + hasActiveFilters?: boolean; } -function PeopleList({ people, surveys, label }: PeopleListProps) { +function PeopleList({ + people, + surveys, + label, + hasActiveFilters, +}: PeopleListProps) { if (people.length === 0) { return ( -
-
No people
+
+
+ {hasActiveFilters + ? "No people match your filters." + : `No people in this ${label ?? "category"}`} +
); } diff --git a/src/components/TeamNodeDrawer.tsx b/src/components/TeamNodeDrawer.tsx index eb3a293..2387467 100644 --- a/src/components/TeamNodeDrawer.tsx +++ b/src/components/TeamNodeDrawer.tsx @@ -85,6 +85,10 @@ function TeamNodeDrawer() { people={filteredPeople} surveys={[]} label={selectedTeamNode.data.label} + hasActiveFilters={ + filteredPeople.length !== + selectedTeamNode.data.people.length + } />
From c23c42042ecbf060c0e6324414ac527602b528f3 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Sat, 23 Aug 2025 21:48:55 +1200 Subject: [PATCH 4/4] Enhance PeopleFilter component by adding exclude filters for CG group and serving status, allowing users to filter out specific groups. Update filtering logic and UI to reflect new options, improving user experience and flexibility in managing displayed people. --- src/components/PeopleList/PeopleFilter.tsx | 57 +++++++++++++++++-- .../PeopleListItem/PeopleListItem.tsx | 35 ++++++------ 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/components/PeopleList/PeopleFilter.tsx b/src/components/PeopleList/PeopleFilter.tsx index b440552..fdf62df 100644 --- a/src/components/PeopleList/PeopleFilter.tsx +++ b/src/components/PeopleList/PeopleFilter.tsx @@ -10,6 +10,8 @@ function PeopleFilter({ people, onFilterChange }: PeopleFilterProps) { const [searchTerm, setSearchTerm] = useState(""); const [showOnlyCG, setShowOnlyCG] = useState(false); const [showOnlyServing, setShowOnlyServing] = useState(false); + const [excludeCG, setExcludeCG] = useState(false); + const [excludeServing, setExcludeServing] = useState(false); const filteredPeople = useMemo(() => { return people @@ -20,15 +22,32 @@ function PeopleFilter({ people, onFilterChange }: PeopleFilterProps) { .includes(searchTerm.toLowerCase()); // CG group filter - const matchesCG = !showOnlyCG || person.cgGroup; + let matchesCG = true; + if (showOnlyCG) { + matchesCG = !!person.cgGroup; + } else if (excludeCG) { + matchesCG = !person.cgGroup; + } // Serving filter - const matchesServing = !showOnlyServing || person.isServing; + let matchesServing = true; + if (showOnlyServing) { + matchesServing = !!person.isServing; + } else if (excludeServing) { + matchesServing = !person.isServing; + } return matchesSearch && matchesCG && matchesServing; }) .sort((a, b) => a.fullName.localeCompare(b.fullName)); - }, [people, searchTerm, showOnlyCG, showOnlyServing]); + }, [ + people, + searchTerm, + showOnlyCG, + showOnlyServing, + excludeCG, + excludeServing, + ]); // Update parent component when filters change useMemo(() => { @@ -83,11 +102,21 @@ function PeopleFilter({ people, onFilterChange }: PeopleFilterProps) {