From 60673c7ab6b05014b08f26430945448eaafc7153 Mon Sep 17 00:00:00 2001 From: Santiordon Date: Thu, 26 Mar 2026 17:50:14 -0400 Subject: [PATCH 1/2] #4009 Add rendering conditions and switch Collapse to modern auto-friendly css --- .../pages/GanttPage/GanttChart/GanttChart.tsx | 103 +++++++++++++++--- .../GanttChartCollectionSection.tsx | 22 +++- .../GanttTaskBar/GanttTaskBarView.tsx | 64 +++++++---- 3 files changed, 148 insertions(+), 41 deletions(-) diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx index c52ef32847..0976dd02e1 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx @@ -12,6 +12,8 @@ import { eachDayOfInterval, isMonday, differenceInDays } from 'date-fns'; import { getMonday } from '../../../utils/datetime.utils'; import { toDateString } from 'shared'; import { GANTT_CHART_CELL_SIZE, GANTT_CHART_GAP_SIZE } from '../../../utils/gantt.utils'; +import { useRef, useCallback, useEffect } from 'react'; + export interface GanttEditability { highlightTaskComparator: HighlightTaskComparator; highlightSubtaskComparator: HighlightTaskComparator; @@ -38,38 +40,103 @@ const GanttChart = ({ startDate, endDate, collections, editability }: Gant const today = new Date(new Date().setHours(0, 0, 0, 0)); const currentWeekCol = days.findIndex((day) => toDateString(day) === toDateString(getMonday(today))) + 1; - const daysIntoWeek = differenceInDays(today, getMonday(today)); const dailyOffset = daysIntoWeek * (parseFloat(GANTT_CHART_CELL_SIZE) / 7); + const scrollContainerRef = useRef(null); + + const validCollections = collections.filter((c) => c.tasks); + + const sectionDataRef = useRef< + { sectionEl: HTMLDivElement | null; placeholderEl: HTMLDivElement | null; height: number }[] + >([]); + + if (sectionDataRef.current.length !== validCollections.length) { + sectionDataRef.current = validCollections.map((_, i) => ({ + sectionEl: sectionDataRef.current[i]?.sectionEl ?? null, + placeholderEl: sectionDataRef.current[i]?.placeholderEl ?? null, + height: sectionDataRef.current[i]?.height ?? 0 + })); + } + + const updateVisibility = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const viewportBottom = containerRect.bottom; + + for (const data of sectionDataRef.current) { + const { sectionEl, placeholderEl } = data; + if (!sectionEl || !placeholderEl) continue; + + const el = sectionEl.style.display === 'none' ? placeholderEl : sectionEl; + const elTop = el.getBoundingClientRect().top; + const measuredHeight = sectionEl.offsetHeight || placeholderEl.offsetHeight; + + const isVisible = elTop <= viewportBottom; + + sectionEl.style.display = isVisible ? '' : 'none'; + placeholderEl.style.display = isVisible ? 'none' : ''; + placeholderEl.style.height = `${measuredHeight}px`; + } + }, []); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + container.addEventListener('scroll', updateVisibility, { passive: true }); + // Also re-check on container resize + const ro = new ResizeObserver(updateVisibility); + ro.observe(container); + updateVisibility(); // initial pass + + return () => { + container.removeEventListener('scroll', updateVisibility); + ro.disconnect(); + }; + }, [updateVisibility]); + return ( - {collections.map((collection) => { - return collection.tasks ? ( - ( + + {/* Real */} + { + if (sectionDataRef.current[idx]) sectionDataRef.current[idx].sectionEl = el; + }} + > + + + {/* Placeholder */} + { + if (sectionDataRef.current[idx]) sectionDataRef.current[idx].placeholderEl = el; + }} + style={{ display: 'none', height: 0 }} /> - ) : ( - <> - ); - })} + + ))} {currentWeekCol > 0 && ( { @@ -10,16 +10,19 @@ interface GanttChartCollectionSectionProps { endDate: Date; collection: GanttCollection; editability?: GanttEditability; + onHeightChange?: (height: number) => void; } const GanttChartCollectionSection = ({ startDate, endDate, collection, - editability + editability, + onHeightChange }: GanttChartCollectionSectionProps) => { const theme = useTheme(); const [isEditMode, setIsEditMode] = useState(false); + const sectionRef = useRef(null); const collectionSectionBackgroundStyle = { mt: 1, @@ -61,8 +64,21 @@ const GanttChartCollectionSection = ({ const ignoreBool = () => false; + useEffect(() => { + const el = sectionRef.current; + if (!el || !onHeightChange) return; + + const ro = new ResizeObserver(() => { + onHeightChange(el.getBoundingClientRect().height); + }); + ro.observe(el); + // Fire once immediately so the parent has a height before the first scroll + onHeightChange(el.getBoundingClientRect().height); + return () => ro.disconnect(); + }, [onHeightChange]); + return ( - + {collection.title} diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx index 05cba3506e..c70f9e927a 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx @@ -4,10 +4,10 @@ import { HighlightTaskComparator, OnMouseOverOptions } from '../../../../../utils/gantt.utils'; -import { Collapse } from '@mui/material'; +import { Box } from '@mui/material'; import GanttTaskBar from './GanttTaskBar'; import GanttTaskBarDisplay from './GanttTaskBarDisplay'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; interface GanttTaskBarViewProps { days: Date[]; @@ -37,11 +37,19 @@ const GanttTaskBarView = ({ onToggle }: GanttTaskBarViewProps) => { const [showChildren, setShowChildren] = useState(false); + const animationRef = useRef(null); const handleToggle = () => { setShowChildren((prev) => !prev); }; + // Fire onToggle after the grid animation completes (200ms matches transition below) + useEffect(() => { + if (animationRef.current) cancelAnimationFrame(animationRef.current); + const timeout = setTimeout(() => onToggle?.(), 200); + return () => clearTimeout(timeout); + }, [showChildren, onToggle]); + return ( <> ({ highlightTaskComparator={highlightTaskComparator} /> - - {task.children.map((child) => ( - {}} - handleOnMouseOver={handleOnMouseOver} - handleOnMouseLeave={handleOnMouseLeave} - highlightedChange={highlightedChange} - onAddTaskPressed={onAddTaskPressed} - highlightSubtaskComparator={highlightSubtaskComparator} - highlightTaskComparator={highlightTaskComparator} - onToggle={onToggle} - /> - ))} - + {/* + The grid trick: animate grid-template-rows from 0fr to 1fr. + The inner div needs to be a single grid child — its natural height + determines the expanded size, so no explicit height is ever needed. + This never triggers layout reflow unlike height/max-height transitions. + */} + + {/* This inner div must have no min-height so it can collapse to 0 */} + + {task.children.map((child) => ( + {}} + handleOnMouseOver={handleOnMouseOver} + handleOnMouseLeave={handleOnMouseLeave} + highlightedChange={highlightedChange} + onAddTaskPressed={onAddTaskPressed} + highlightSubtaskComparator={highlightSubtaskComparator} + highlightTaskComparator={highlightTaskComparator} + onToggle={onToggle} + /> + ))} + + ); }; From 3265a1a4a0134bea52acbf73f0ba845b8434c8c6 Mon Sep 17 00:00:00 2001 From: Santiordon Date: Thu, 26 Mar 2026 17:53:43 -0400 Subject: [PATCH 2/2] Prettier --- .../GanttPage/GanttChart/GanttChartCollectionSection.tsx | 1 - .../GanttTaskBar/GanttTaskBarView.tsx | 8 -------- 2 files changed, 9 deletions(-) diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx index 88289233d8..4ce78e7312 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx @@ -72,7 +72,6 @@ const GanttChartCollectionSection = ({ onHeightChange(el.getBoundingClientRect().height); }); ro.observe(el); - // Fire once immediately so the parent has a height before the first scroll onHeightChange(el.getBoundingClientRect().height); return () => ro.disconnect(); }, [onHeightChange]); diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx index c70f9e927a..52cfa5f9ac 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx @@ -65,13 +65,6 @@ const GanttTaskBarView = ({ highlightSubtaskComparator={highlightSubtaskComparator} highlightTaskComparator={highlightTaskComparator} /> - - {/* - The grid trick: animate grid-template-rows from 0fr to 1fr. - The inner div needs to be a single grid child — its natural height - determines the expanded size, so no explicit height is ever needed. - This never triggers layout reflow unlike height/max-height transitions. - */} ({ overflow: 'hidden' }} > - {/* This inner div must have no min-height so it can collapse to 0 */} {task.children.map((child) => (