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..5093092 100644 --- a/src/components/PeopleFlow.tsx +++ b/src/components/PeopleFlow.tsx @@ -12,6 +12,9 @@ import StaticNode from "./StaticNode"; 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 = { @@ -27,6 +30,10 @@ const PeopleFlow = ({ campusFilter }: PeopleFlowProps) => { const { data, isLoading, error } = usePeopleFlowData(campusFilter); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { + state: { selectedTeamNode }, + dispatch, + } = usePathway(); // Transform data into React Flow nodes and edges const flowData = useMemo(() => { @@ -43,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) { @@ -62,7 +78,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 [excludeCG, setExcludeCG] = useState(false); + const [excludeServing, setExcludeServing] = useState(false); + + const filteredPeople = useMemo(() => { + return people + .filter((person) => { + // Text search filter + const matchesSearch = person.fullName + .toLowerCase() + .includes(searchTerm.toLowerCase()); + + // CG group filter + let matchesCG = true; + if (showOnlyCG) { + matchesCG = !!person.cgGroup; + } else if (excludeCG) { + matchesCG = !person.cgGroup; + } + + // Serving filter + 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, + excludeCG, + excludeServing, + ]); + + // 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 || + excludeCG || + excludeServing) && ( + + )} +
+ + {/* 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..515e11b 100644 --- a/src/components/PeopleList/PeopleList.tsx +++ b/src/components/PeopleList/PeopleList.tsx @@ -4,23 +4,32 @@ import PeopleListItem from "./PeopleListItem"; interface PeopleListProps { people: Person[]; surveys: Survey[]; - label: string; + 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"}`} +
); } - // 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(); - }} - > +
+ {/* Person link - avatar and name */} + + {personName} +
{personName} - - {/* Only show chips for Attending or Growing statuses */} - {(label === "Attending" || label === "Growing") && ( - <> - {/* Connect Group Chip */} +
+ + + {/* 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..a63eccb 100644 --- a/src/components/TeamNode.tsx +++ b/src/components/TeamNode.tsx @@ -1,47 +1,40 @@ -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"; - -interface TeamNodeData { - label?: string; - description?: string; - people: Person[]; - surveys?: Survey[]; -} +import { usePathway } from "../hooks/usePathway"; +import { selectTeamNode, deselectTeamNode } from "../contexts/pathwayActions"; +import type { TeamNodeData } from "../contexts/PathwayContext"; const TeamNode = memo(({ data, id }: NodeProps) => { - const { label, description, people, surveys = [] } = data; - const nodeRef = useRef(null); - const [isExpanded, setIsExpanded] = useState(false); - - // 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); + const { label, people } = data; + const { + state: { selectedTeamNode }, + dispatch, + } = usePathway(); - 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, + }) + ); + } }; 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.label ?? "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..df8714c --- /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; + label?: 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; +}