From df01a76b4e0a7148cd45fcffe449f008d2dddb9b Mon Sep 17 00:00:00 2001 From: Sandeep Das <65163571+Dasyure@users.noreply.github.com> Date: Fri, 21 Jul 2023 11:36:49 +1000 Subject: [PATCH] fix: CF-421: adding or removing courses now reflect in the graphical selector (#1037) * feat: dark mode functionality added for editMarkModal's input and button elements * feat: dark mode improvement for editMarkModal's cancel button * feat: dark mode improvements for OptionHeader icons underneath the 'Term Planner' tab * feat: dark mode added for the select menu in the settingsMenu tooltip under the TermPlanner tab * feat: dark mode added to SettingMenu's DatePicker element * feat: dark mode for export button done + editModalMark bug fixed * fix: making sure the css for the select element in settingsMenu does not affect other select elements * feat: dark mode improvement for popconfirm for unplan wanring, import tooltip, also moved the button for import and export tooltip to common styles * feat: dark mode scrollbar added * feat: dark mode scrollbar on courseSelector menu had ugly white padding, fixed it to be #333 color instead * fix: href in courseSelector not very readable in dark mode, made the text a bit brighter and made a styled component for it * feat: dark mode added for search bar * feat: dark mode added for remove planner button * fix: forgot to add the new styles.ts file * feat: progressBar's text color and trailing color fixed * feat: dividing line in courseDescription changed from white to a dark grey * feat: bug icon turned into dark mode * feat: dark mode added for quick add and remove buttons in course menu * feat: courseProgression progress bar trailing color changed to dark grey * feat: progress on dark mode for graph, need to save this commit before I merge in the latest changes since the graph was changed * feat: dark mode for graph complete (nodes, arrows, hover states) + label now changes on hover (non-dark mode feature) * feat: buttons on graphical selector are dark mode * feat: saving progress on converting courseDescription panel to dark mode * feat: dark mode added to the sidebar * feat: sidebardrawer color changed, box shadow added to tabs so it looks more visible in dark mode * feat: new images added in help menu in course selector, dark mode versions added too * feat: TermPlanner's help menu tooltips now have dark mode pics and gifs * feat: highlight adjacent nodes and edges on hover * feat: highlight adjacent nodes opacity updated * refactor: graph.ts, changing function names and object names to be more readable: * feat: implemented a function that checks if a course is a prereq based on GraphData without calling the backend * fix: two graphs get rendered if you switch tabs fast enough * feat: created a function to store a hashmap of prereqs for later use for node styling * fix: updated the function that checks for coursePrerequisite Other options are: 1. Rely on GraphData, however it gets laggy when you hover over too many nodes 2. API call - would get slow with multiple requests 3. CourseEdge info gets stored into a hashmap at initialisation, so checking for prereqs is fast afterwards * refactor: graph.ts function and object names made more readable * refactor: rewriting the returns and using spread operator to reduce repetition in graph.ts * feat: highlight prerequisite nodes on hover * refactor: splitting functions up as they were getting too long * fix: if the dark mode button is toggled on and off, it repaints the canvas more than one time * feat: highlighted incoming edge if it's a prerequisite as well * feat: forgot to add pics into the HelpMenu for the new graphical selector hover node feature * feat: unlocked course nodes are now distinct from planned and locked courses feat: HelpMenu pictures were also updated * fix: adding and removing courses in graphical selector, updates the node in the graph without re-render refactor: graph.ts duplicate function removed * fix: endArrow opacity of graph was not changing on hover * fix: instead of using courseSlicer, used axios request to update unlocked nodes after adding to planner --------- Co-authored-by: Daysure --- .../CourseDescriptionPanel.tsx | 6 +- .../PlannerButton/PlannerButton.tsx | 5 +- .../pages/CourseSelector/CourseMenu/styles.ts | 1 - .../pages/CourseSelector/CourseSelector.tsx | 2 + .../CourseGraph/CourseGraph.tsx | 110 ++++++++++++----- .../GraphicalSelector/CourseGraph/graph.ts | 115 +++++++++--------- .../GraphicalSelector/GraphicalSelector.tsx | 3 + 7 files changed, 152 insertions(+), 90 deletions(-) diff --git a/frontend/src/components/CourseDescriptionPanel/CourseDescriptionPanel.tsx b/frontend/src/components/CourseDescriptionPanel/CourseDescriptionPanel.tsx index 1a39df01f..5a5ac6cc1 100644 --- a/frontend/src/components/CourseDescriptionPanel/CourseDescriptionPanel.tsx +++ b/frontend/src/components/CourseDescriptionPanel/CourseDescriptionPanel.tsx @@ -27,13 +27,15 @@ type CourseDescriptionPanelProps = { courseCode: string; onCourseClick?: (code: string) => void; courseDescInfoCache: React.MutableRefObject; + hasPlannerUpdated: React.MutableRefObject; }; const CourseDescriptionPanel = ({ className, courseCode, onCourseClick, - courseDescInfoCache + courseDescInfoCache, + hasPlannerUpdated }: CourseDescriptionPanelProps) => { const { degree, planner } = useSelector((state: RootState) => state); @@ -127,7 +129,7 @@ const CourseDescriptionPanel = ({ {courseCode} - {course.title} - + {/* TODO: Style this better? */} {course.is_legacy && ( diff --git a/frontend/src/components/PlannerButton/PlannerButton.tsx b/frontend/src/components/PlannerButton/PlannerButton.tsx index 04bf8b55e..422232eea 100644 --- a/frontend/src/components/PlannerButton/PlannerButton.tsx +++ b/frontend/src/components/PlannerButton/PlannerButton.tsx @@ -12,9 +12,10 @@ import S from './styles'; interface PlannerButtonProps { course: Course; + hasPlannerUpdated: React.MutableRefObject; } -const PlannerButton = ({ course }: PlannerButtonProps) => { +const PlannerButton = ({ course, hasPlannerUpdated }: PlannerButtonProps) => { const coursesInPlanner = useSelector((state: RootState) => state.planner.courses); const { degree, planner } = useSelector((state: RootState) => state); @@ -56,6 +57,7 @@ const PlannerButton = ({ course }: PlannerButtonProps) => { }; dispatch(addToUnplanned({ courseCode: course.code, courseData })); addCourseToPlannerTimeout(true); + hasPlannerUpdated.current = true; } }; @@ -67,6 +69,7 @@ const PlannerButton = ({ course }: PlannerButtonProps) => { ); addCourseToPlannerTimeout(false); dispatch(removeCourses(res.data.courses)); + hasPlannerUpdated.current = true; } catch (e) { // eslint-disable-next-line no-console console.error('Error at removeFromPlanner', e); diff --git a/frontend/src/pages/CourseSelector/CourseMenu/styles.ts b/frontend/src/pages/CourseSelector/CourseMenu/styles.ts index b47556104..c216ce9b6 100644 --- a/frontend/src/pages/CourseSelector/CourseMenu/styles.ts +++ b/frontend/src/pages/CourseSelector/CourseMenu/styles.ts @@ -5,7 +5,6 @@ const SidebarWrapper = styled.div` overflow: auto; overflow-x: hidden; height: 100%; - width: 100%; border-right: 1px solid ${({ theme }) => theme.courseMenu?.borderColor}; `; diff --git a/frontend/src/pages/CourseSelector/CourseSelector.tsx b/frontend/src/pages/CourseSelector/CourseSelector.tsx index a247e2691..e38a74dfb 100644 --- a/frontend/src/pages/CourseSelector/CourseSelector.tsx +++ b/frontend/src/pages/CourseSelector/CourseSelector.tsx @@ -21,6 +21,7 @@ const CourseSelector = () => { const { programCode, specs } = useSelector((state: RootState) => state.degree); const { courses } = useSelector((state: RootState) => state.planner); const { active, tabs } = useSelector((state: RootState) => state.courseTabs); + const hasPlannerUpdated = useRef(false); const dispatch = useDispatch(); @@ -100,6 +101,7 @@ const CourseSelector = () => { courseCode={courseCode} onCourseClick={onCourseClick} courseDescInfoCache={courseDescInfoCache} + hasPlannerUpdated={hasPlannerUpdated} /> ) : ( diff --git a/frontend/src/pages/GraphicalSelector/CourseGraph/CourseGraph.tsx b/frontend/src/pages/GraphicalSelector/CourseGraph/CourseGraph.tsx index e0e9a2d7b..2d9ab4cef 100644 --- a/frontend/src/pages/GraphicalSelector/CourseGraph/CourseGraph.tsx +++ b/frontend/src/pages/GraphicalSelector/CourseGraph/CourseGraph.tsx @@ -10,6 +10,7 @@ import type { Graph, GraphOptions, IG6GraphEvent, INode, Item } from '@antv/g6'; import { Switch } from 'antd'; import axios from 'axios'; import { CourseEdge, CoursesAllUnlocked, GraphPayload } from 'types/api'; +import { CourseValidation } from 'types/courses'; import { useDebouncedCallback } from 'use-debounce'; import prepareUserPayload from 'utils/prepareUserPayload'; import Spinner from 'components/Spinner'; @@ -19,12 +20,11 @@ import { ZOOM_IN_RATIO, ZOOM_OUT_RATIO } from '../constants'; import { defaultEdge, edgeInHoverStyle, - edgeOpacity, edgeOutHoverStyle, edgeUnhoverStyle, + mapEdgeOpacity, mapNodeOpacity, mapNodePrereq, - mapNodeRestore, mapNodeStyle, nodeLabelHoverStyle, nodeLabelUnhoverStyle, @@ -38,18 +38,26 @@ type Props = { handleToggleFullscreen: () => void; fullscreen: boolean; focused?: string; + hasPlannerUpdated: React.MutableRefObject; }; interface CoursePrerequisite { [key: string]: string[]; } -const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused }: Props) => { +const CourseGraph = ({ + onNodeClick, + handleToggleFullscreen, + fullscreen, + focused, + hasPlannerUpdated +}: Props) => { const { theme } = useSelector((state: RootState) => state.settings); const previousTheme = useRef(theme); const { programCode, specs } = useSelector((state: RootState) => state.degree); const { courses: plannedCourses } = useSelector((state: RootState) => state.planner); - const { degree, planner, courses } = useSelector((state: RootState) => state); + const { degree, planner } = useSelector((state: RootState) => state); + const allUnlocked = useRef | undefined>({}); const windowSize = useAppWindowSize(); const graphRef = useRef(null); @@ -60,13 +68,22 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused const containerRef = useRef(null); + function unwrap(res: PromiseSettledResult): T | undefined { + if (res.status === 'rejected') { + // eslint-disable-next-line no-console + console.error('Rejected request at unwrap', res.reason); + return undefined; + } + return res.value; + } + useEffect(() => { const isCoursePrerequisite = (target: string, neighbour: string) => { const prereqs = prerequisites[target] || []; return prereqs.includes(neighbour); }; - const addAdjacentStyles = async (nodeItem: Item) => { + const addNeighbourStyles = async (nodeItem: Item) => { const node = nodeItem as INode; const neighbours = node.getNeighbors(); const opacity = theme === 'light' ? 0.3 : 0.4; @@ -76,19 +93,17 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused graphRef.current?.getNodes().forEach((n) => { graphRef.current?.updateItem(n as Item, mapNodeOpacity(n.getID(), opacity)); n.getEdges().forEach((e) => { - graphRef.current?.updateItem(e, edgeOpacity(e.getID(), opacity)); + graphRef.current?.updateItem(e, mapEdgeOpacity(Arrow, theme, e.getID(), opacity)); }); n.toBack(); }); // Highlight node's edges node.getOutEdges().forEach((e) => { graphRef.current?.updateItem(e, edgeOutHoverStyle(Arrow, theme, e.getID())); - graphRef.current?.updateItem(e, edgeOpacity(e.getID(), 1)); e.toFront(); }); node.getInEdges().forEach((e) => { graphRef.current?.updateItem(e, edgeInHoverStyle(Arrow, theme, e.getID())); - graphRef.current?.updateItem(e, edgeOpacity(e.getID(), 1)); e.toFront(); }); // Target node and neighbouring nodes remain visible @@ -104,7 +119,7 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused }); }; - const removeAdjacentStyles = async (nodeItem: Item) => { + const removeNeighbourStyles = async (nodeItem: Item) => { const node = nodeItem as INode; const edges = node.getEdges(); const { Arrow } = await import('@antv/g6'); @@ -116,13 +131,13 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused const courseId = n.getID(); graphRef.current?.updateItem( n as Item, - mapNodeRestore(courseId, plannedCourses, courses.courses, theme) + mapNodeStyle(courseId, plannedCourses, allUnlocked.current, theme) ); graphRef.current?.updateItem(n as Item, mapNodeOpacity(courseId, 1)); n.toFront(); }); graphRef.current?.getEdges().forEach((e) => { - graphRef.current?.updateItem(e, edgeOpacity(e.getID(), 1)); + graphRef.current?.updateItem(e, mapEdgeOpacity(Arrow, theme, e.getID(), 1)); }); }; @@ -131,7 +146,7 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused const node = ev.item as Item; graphRef.current?.setItemState(node, 'hover', true); graphRef.current?.updateItem(node, nodeLabelHoverStyle(node.getID())); - addAdjacentStyles(node); + addNeighbourStyles(node); graphRef.current?.paint(); }; @@ -143,11 +158,14 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused node, nodeLabelUnhoverStyle(node.getID(), plannedCourses, theme) ); - removeAdjacentStyles(node); + removeNeighbourStyles(node); graphRef.current?.paint(); }; - const initialiseGraph = async (courseCodes: string[], courseEdges: CourseEdge[]) => { + const initialiseGraph = async ( + courseCodes: string[] | undefined, + courseEdges: CourseEdge[] | undefined + ) => { const container = containerRef.current; if (!container) return; @@ -183,7 +201,7 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused graphRef.current = new Graph(graphArgs); const data = { - nodes: courseCodes.map((c) => mapNodeStyle(c, plannedCourses, courses.courses, theme)), + nodes: courseCodes?.map((c) => mapNodeStyle(c, plannedCourses, allUnlocked.current, theme)), edges: courseEdges }; @@ -206,9 +224,9 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused }; // Store a hashmap for performance reasons when highlighting nodes - const makePrerequisitesMap = (edges: CourseEdge[]) => { + const makePrerequisitesMap = (edges: CourseEdge[] | undefined) => { const prereqs: CoursePrerequisite = prerequisites; - edges.forEach((e) => { + edges?.forEach((e) => { if (!prereqs[e.target]) { prereqs[e.target] = [e.source]; } else { @@ -218,13 +236,13 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused setPrerequisites(prereqs); }; - // Update styling for: each node, hovering state and edges + // Without re-render, update styling for: each node, hovering state and edges const repaintCanvas = async () => { const nodes = graphRef.current?.getNodes(); nodes?.map((n) => graphRef.current?.updateItem( n, - mapNodeStyle(n.getID(), plannedCourses, courses.courses, theme) + mapNodeStyle(n.getID(), plannedCourses, allUnlocked.current, theme) ) ); @@ -243,16 +261,38 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused graphRef.current?.paint(); }; + const getUnlocked = async () => { + try { + setLoading(true); + const res = await axios.post( + '/courses/getAllUnlocked/', + JSON.stringify(prepareUserPayload(degree, planner)) + ); + allUnlocked.current = res.data.courses_state; + repaintCanvas(); + setLoading(false); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error at updating allUnlocked', err); + } + }; + const setupGraph = async () => { try { initialising.current = true; - const res = await axios.get( - `/programs/graph/${programCode}/${specs.join('+')}` - ); - const { edges } = res.data; - makePrerequisitesMap(edges); - if (res.data.courses.length !== 0 && edges.length !== 0) { - initialiseGraph(res.data.courses, edges); + const res = await Promise.allSettled([ + axios.get(`/programs/graph/${programCode}/${specs.join('+')}`), + axios.post( + '/courses/getAllUnlocked/', + JSON.stringify(prepareUserPayload(degree, planner)) + ) + ]); + const [programsRes, coursesRes] = res; + const programs = unwrap(programsRes)?.data; + allUnlocked.current = unwrap(coursesRes)?.data.courses_state; + makePrerequisitesMap(programs?.edges); + if (programs?.courses.length !== 0 && programs?.edges.length !== 0) { + initialiseGraph(programs?.courses, programs?.edges); } } catch (e) { // eslint-disable-next-line no-console @@ -261,12 +301,26 @@ const CourseGraph = ({ onNodeClick, handleToggleFullscreen, fullscreen, focused }; if (!initialising.current) setupGraph(); - // Repaint canvas when theme is changed without re-render + if (hasPlannerUpdated.current) { + hasPlannerUpdated.current = false; + getUnlocked(); + } + // Change theme without re-render if (previousTheme.current !== theme) { previousTheme.current = theme; repaintCanvas(); } - }, [onNodeClick, plannedCourses, programCode, specs, theme, prerequisites, courses]); + }, [ + onNodeClick, + plannedCourses, + programCode, + specs, + theme, + prerequisites, + degree, + planner, + hasPlannerUpdated + ]); const showAllCourses = () => { if (!graphRef.current) return; diff --git a/frontend/src/pages/GraphicalSelector/CourseGraph/graph.ts b/frontend/src/pages/GraphicalSelector/CourseGraph/graph.ts index 43ee79256..b007d3ba6 100644 --- a/frontend/src/pages/GraphicalSelector/CourseGraph/graph.ts +++ b/frontend/src/pages/GraphicalSelector/CourseGraph/graph.ts @@ -57,6 +57,17 @@ const sameNode = (courseCode: string) => ({ label: courseCode }); +const nodeStateStyles = { + hover: { + fill: '#b37feb', + stroke: '#b37feb' + }, + click: { + fill: '#b37feb', + stroke: '#b37feb' + } +}; + const plannedLabel = { labelCfg: { style: { @@ -73,25 +84,42 @@ const lockedLabel = (theme: string) => ({ } }); -const nodeStateStyles = { - hover: { - fill: '#b37feb', - stroke: '#b37feb' - }, - click: { - fill: '#b37feb', - stroke: '#b37feb' +const nodeLabelHoverStyle = (courseCode: string) => ({ + ...sameNode(courseCode), + ...plannedLabel +}); + +const nodeLabelUnhoverStyle = ( + courseCode: string, + plannedCourses: Record, + theme: string +) => { + if (plannedCourses[courseCode]) { + return { + ...sameNode(courseCode), + ...plannedLabel + }; } + return { + ...sameNode(courseCode), + ...lockedLabel(theme) + }; }; +const arrowColor = (theme: string) => ({ + default: theme === 'light' ? '#e0e0e0' : '#4a4a4a', + outHover: theme === 'light' ? '#999' : '#aaa', + inHover: theme === 'light' ? '#000' : '#fff' +}); + const defaultEdge = (arrow: typeof Arrow, theme: string) => ({ style: { endArrow: { path: arrow.triangle(5, 5, 30), - fill: theme === 'light' ? '#e0e0e0' : '#4a4a4a', + fill: arrowColor(theme).default, d: 25 }, - stroke: theme === 'light' ? '#e0e0e0' : '#4a4a4a' + stroke: arrowColor(theme).default } }); @@ -100,10 +128,11 @@ const edgeOutHoverStyle = (arrow: typeof Arrow, theme: string, id: string) => ({ style: { endArrow: { path: arrow.triangle(5, 5, 30), - fill: theme === 'light' ? '#999' : '#aaa', + fill: arrowColor(theme).outHover, d: 25 }, - stroke: theme === 'light' ? '#999' : '#aaa' + stroke: arrowColor(theme).outHover, + opacity: 1 } }); @@ -112,10 +141,11 @@ const edgeInHoverStyle = (arrow: typeof Arrow, theme: string, id: string) => ({ style: { endArrow: { path: arrow.triangle(5, 5, 30), - fill: theme === 'light' ? '#000' : '#fff', + fill: arrowColor(theme).inHover, d: 25 }, - stroke: theme === 'light' ? '#000' : '#fff' + stroke: arrowColor(theme).inHover, + opacity: 1 } }); @@ -129,13 +159,13 @@ const edgeUnhoverStyle = (arrow: typeof Arrow, theme: string, id: string) => { const mapNodeStyle = ( courseCode: string, plannedCourses: Record, - courses: Record, + courses: Record | undefined, theme: string ) => { const isPlanned = plannedCourses[courseCode]; - const isUnlocked = courses[courseCode]?.unlocked; + const isUnlocked = courses && courses[courseCode] ? courses[courseCode].unlocked : false; - if (isPlanned) return sameNode(courseCode); + if (isPlanned) return { ...sameNode(courseCode), ...plannedNode }; if (isUnlocked) return { ...sameNode(courseCode), ...unlockedNode(theme) }; return { ...sameNode(courseCode), ...lockedNode(theme) }; }; @@ -147,20 +177,6 @@ const mapNodePrereq = (courseCode: string, theme: string) => { }; }; -const mapNodeRestore = ( - courseCode: string, - plannedCourses: Record, - courses: Record, - theme: string -) => { - const isPlanned = plannedCourses[courseCode]; - const isUnlocked = courses[courseCode]?.unlocked; - - if (isPlanned) return { ...sameNode(courseCode), ...plannedNode }; - if (isUnlocked) return { ...sameNode(courseCode), ...unlockedNode(theme) }; - return { ...sameNode(courseCode), ...lockedNode(theme) }; -}; - const mapNodeOpacity = (courseCode: string, opacity: number) => { return { ...sameNode(courseCode), @@ -175,45 +191,28 @@ const mapNodeOpacity = (courseCode: string, opacity: number) => { }; }; -const edgeOpacity = (id: string, opacity: number) => ({ +// Changes opacity but also changes edge to default style due to endArrow styling +const mapEdgeOpacity = (arrow: typeof Arrow, theme: string, id: string, opacity: number) => ({ id, style: { - opacity + opacity, + endArrow: { + path: arrow.triangle(5, 5, 30), + fill: arrowColor(theme).default, + opacity, + d: 25 + } } }); -const nodeLabelHoverStyle = (courseCode: string) => ({ - ...sameNode(courseCode), - ...plannedLabel -}); - -const nodeLabelUnhoverStyle = ( - courseCode: string, - plannedCourses: Record, - theme: string -) => { - if (plannedCourses[courseCode]) { - // uses default node style with label color changed - return { - ...sameNode(courseCode), - ...plannedLabel - }; - } - return { - ...sameNode(courseCode), - ...lockedLabel(theme) - }; -}; - export { defaultEdge, edgeInHoverStyle, - edgeOpacity, edgeOutHoverStyle, edgeUnhoverStyle, + mapEdgeOpacity, mapNodeOpacity, mapNodePrereq, - mapNodeRestore, mapNodeStyle, nodeLabelHoverStyle, nodeLabelUnhoverStyle, diff --git a/frontend/src/pages/GraphicalSelector/GraphicalSelector.tsx b/frontend/src/pages/GraphicalSelector/GraphicalSelector.tsx index d9baf621c..838a75f0e 100644 --- a/frontend/src/pages/GraphicalSelector/GraphicalSelector.tsx +++ b/frontend/src/pages/GraphicalSelector/GraphicalSelector.tsx @@ -15,6 +15,7 @@ const GraphicalSelector = () => { const [courseCode, setCourseCode] = useState(null); const [activeTab, setActiveTab] = useState(HELP_TAB); const courseDescInfoCache = useRef({} as CourseDescInfoResCache); + const hasPlannerUpdated = useRef(false); const items = [ { @@ -26,6 +27,7 @@ const GraphicalSelector = () => { key={courseCode} onCourseClick={setCourseCode} courseDescInfoCache={courseDescInfoCache} + hasPlannerUpdated={hasPlannerUpdated} /> ) : ( No course selected @@ -51,6 +53,7 @@ const GraphicalSelector = () => { fullscreen={fullscreen} handleToggleFullscreen={() => setFullscreen((prevState) => !prevState)} focused={courseCode ?? undefined} + hasPlannerUpdated={hasPlannerUpdated} />