diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 18c53d0..bffc513 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -2,7 +2,7 @@ import { EntityType, GroupType } from "@/lib/Types"; import { useSidebar } from '@/contexts/SidebarContext'; import { Box, InputAdornment, Paper } from '@mui/material'; import { SearchRounded } from '@mui/icons-material'; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TextField } from "@mui/material"; import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; import { useDatamodelData } from "@/contexts/DatamodelDataContext"; @@ -25,7 +25,45 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const [searchTerm, setSearchTerm] = useState(""); const [displaySearchTerm, setDisplaySearchTerm] = useState(""); const [expandedGroups, setExpandedGroups] = useState>(new Set()); - + const [manuallyCollapsed, setManuallyCollapsed] = useState>(new Set()); + const prevGroupRef = useRef(null); + + // Auto-expand the current group when it changes or when section changes (e.g., from scrolling) + useEffect(() => { + if (currentGroup) { + const groupChanged = prevGroupRef.current !== currentGroup; + const oldGroup = prevGroupRef.current; + + // If the group changed, close the old group and open the new one + if (groupChanged) { + setManuallyCollapsed(prev => { + const newCollapsed = new Set(prev); + newCollapsed.delete(currentGroup); + return newCollapsed; + }); + setExpandedGroups(prev => { + const newExpanded = new Set(prev); + // Close the old group + if (oldGroup) { + newExpanded.delete(oldGroup); + } + // Open the new group + newExpanded.add(currentGroup); + return newExpanded; + }); + prevGroupRef.current = currentGroup; + } + // If the group didn't change but section did, only expand if not manually collapsed + else if (!manuallyCollapsed.has(currentGroup)) { + setExpandedGroups(prev => { + const newExpanded = new Set(prev); + newExpanded.add(currentGroup); + return newExpanded; + }); + } + } + }, [currentGroup, currentSection, manuallyCollapsed]); + // Memoize search results to prevent recalculation on every render const filteredGroups = useMemo(() => { if (!searchTerm.trim() && !search) return groups; @@ -85,39 +123,67 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const handleGroupClick = useCallback((groupName: string) => { setExpandedGroups(prev => { const newExpanded = new Set(prev); - if (newExpanded.has(groupName)) { + const isCurrentlyExpanded = newExpanded.has(groupName); + + if (isCurrentlyExpanded) { + // User is manually collapsing this group newExpanded.delete(groupName); + setManuallyCollapsed(prevCollapsed => { + const newCollapsed = new Set(prevCollapsed); + newCollapsed.add(groupName); + return newCollapsed; + }); } else { - if (currentGroup?.toLowerCase() === groupName.toLowerCase()) return newExpanded; + // User is manually expanding this group newExpanded.add(groupName); + setManuallyCollapsed(prevCollapsed => { + const newCollapsed = new Set(prevCollapsed); + newCollapsed.delete(groupName); + return newCollapsed; + }); } return newExpanded; }); - }, [currentGroup]); + }, []); + + const clearCurrentGroup = useCallback(() => { + dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: null }); + dataModelDispatch({ type: "SET_CURRENT_SECTION", payload: null }); + }, [dataModelDispatch]); const handleScrollToGroup = useCallback((group: GroupType) => { + // If clicking on the current group, clear the selection + if (currentGroup?.toLowerCase() === group.Name.toLowerCase()) { + clearCurrentGroup(); + return; + } + // Set current group and scroll to group header dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: group.Name }); - if (group.Entities.length > 0) + if (group.Entities.length > 0) dataModelDispatch({ type: "SET_CURRENT_SECTION", payload: group.Entities[0].SchemaName }); + // Clear manually collapsed state and ensure the group is expanded when selected + setManuallyCollapsed(prev => { + const newCollapsed = new Set(prev); + newCollapsed.delete(group.Name); + return newCollapsed; + }); setExpandedGroups(prev => { const newExpanded = new Set(prev); - if (newExpanded.has(group.Name)) { - newExpanded.delete(group.Name); - } + newExpanded.add(group.Name); return newExpanded; }); if (scrollToGroup) { scrollToGroup(group.Name); } - + // On phone - close sidebar if (!!isMobile) { closeSidebar(); } - }, [dataModelDispatch, scrollToGroup, isMobile, closeSidebar]); + }, [currentGroup, clearCurrentGroup, dataModelDispatch, scrollToGroup, isMobile, closeSidebar]); const handleEntityClick = useCallback((entity: EntityType, groupName: string) => { // Use requestAnimationFrame to defer heavy operations @@ -127,11 +193,23 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: groupName }); dataModelDispatch({ type: 'SET_CURRENT_SECTION', payload: entity.SchemaName }); + // Clear manually collapsed state and ensure the group is expanded when an entity is clicked + setManuallyCollapsed(prev => { + const newCollapsed = new Set(prev); + newCollapsed.delete(groupName); + return newCollapsed; + }); + setExpandedGroups(prev => { + const newExpanded = new Set(prev); + newExpanded.add(groupName); + return newExpanded; + }); + // On phone - close if (!!isMobile) { closeSidebar(); } - + // Defer scroll operation to next frame to prevent blocking requestAnimationFrame(() => { if (scrollToSection) { @@ -171,7 +249,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { (); const typingTimeoutRef = useRef(); const frameRef = useRef(); @@ -106,7 +106,7 @@ export const TimeSlicedSearch = ({ channel.port2.onmessage = () => { onSearch(value); setLastValidSearch(value); - + // Reset typing state in next frame frameRef.current = requestAnimationFrame(() => { setIsTyping(false); @@ -118,10 +118,10 @@ export const TimeSlicedSearch = ({ const handleChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; - + // Immediate visual update (highest priority) setLocalValue(value); - + // Only manage typing state and loading for searches >= 3 characters if (value.length >= 3) { // Manage typing state @@ -134,7 +134,7 @@ export const TimeSlicedSearch = ({ if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } - + // Auto-reset typing state if user stops typing typingTimeoutRef.current = window.setTimeout(() => { setIsTyping(false); @@ -143,13 +143,13 @@ export const TimeSlicedSearch = ({ // Clear typing state for short searches setIsTyping(false); onLoadingChange(false); - + // Clear any pending timeouts if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } } - + // Schedule search (will handle short searches internally) scheduleSearch(value); @@ -158,11 +158,12 @@ export const TimeSlicedSearch = ({ // Handle clear button const handleClear = useCallback(() => { if (localValue.length === 0) return; // No-op if already empty + handleClose(); setLocalValue(''); onSearch(''); // Clear search immediately setIsTyping(false); onLoadingChange(false); - + // Clear any pending timeouts if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); @@ -202,7 +203,7 @@ export const TimeSlicedSearch = ({ container.style.zIndex = '9999'; document.body.appendChild(container); } - + const searchContainer = document.createElement('div'); searchContainer.style.pointerEvents = 'auto'; container.appendChild(searchContainer); @@ -236,8 +237,8 @@ export const TimeSlicedSearch = ({ - - - + {isTyping && localValue.length >= 3 ? ( @@ -260,9 +261,9 @@ export const TimeSlicedSearch = ({ ) : null} - + - + @@ -275,28 +276,28 @@ export const TimeSlicedSearch = ({ className='mt-2' > - + Next Enter - + Previous Shift + Enter - + Next Ctrl + - + @@ -304,7 +305,7 @@ export const TimeSlicedSearch = ({ Ctrl + - + diff --git a/Website/components/datamodelview/entity/AttributeDetails.tsx b/Website/components/datamodelview/entity/AttributeDetails.tsx index 1bbde84..0720010 100644 --- a/Website/components/datamodelview/entity/AttributeDetails.tsx +++ b/Website/components/datamodelview/entity/AttributeDetails.tsx @@ -2,7 +2,7 @@ import { AttributeType, CalculationMethods, RequiredLevel } from "@/lib/Types"; import { AddCircleOutlineRounded, CalculateRounded, ElectricBoltRounded, ErrorRounded, FunctionsRounded, LockRounded, VisibilityRounded } from "@mui/icons-material"; -import { Link, Tooltip } from "@mui/material"; +import { Box, Link, Tooltip, Typography } from "@mui/material"; export function AttributeDetails({ entityName, attribute }: { entityName: string, attribute: AttributeType }) { const details = []; @@ -35,14 +35,20 @@ export function AttributeDetails({ entityName, attribute }: { entityName: string } if (attribute.AttributeUsages.length > 0) { - const tooltip = Processes ${attribute.AttributeUsages.map(au => au.Name).join(", ")}.
Click to see more details.
; - details.push({ icon: ( - - - - ), tooltip }); + const tooltip = + Usages ({attribute.AttributeUsages.length}): + {attribute.AttributeUsages.map(au => au.Name).join(", ")} + Click to see more details. + ; + details.push({ + icon: ( + + + + ), tooltip + }); } - + return (
{details.map((detail, index) => ( diff --git a/Website/components/shared/Sidebar.tsx b/Website/components/shared/Sidebar.tsx index 04298cf..ee2026f 100644 --- a/Website/components/shared/Sidebar.tsx +++ b/Website/components/shared/Sidebar.tsx @@ -24,175 +24,178 @@ interface NavItem { } const Sidebar = ({ }: SidebarProps) => { - const { isOpen, element, toggleExpansion, close } = useSidebar(); - const isMobile = useIsMobile(); - - const pathname = usePathname(); + const { isOpen, element, toggleExpansion, close } = useSidebar(); + const isMobile = useIsMobile(); - const navItems: NavItem[] = [ - { - label: 'Home', - href: '/', - icon: HomeIcon, - active: pathname === '/', - }, - { - label: 'Insights', - href: '/insights', - icon: InsightsIcon, - active: pathname === '/insights', - }, - { - label: 'Metadata', - href: '/metadata', - icon: MetadataIcon, - active: pathname === '/metadata', - }, - { - label: 'Diagram', - href: '/diagram', - icon: DiagramIcon, - active: pathname === '/diagram', - new: true, - }, - { - label: 'Processes', - href: '/processes', - icon: ProcessesIcon, - active: pathname === '/processes', - } - ]; + const pathname = usePathname(); - const handleNavClick = () => { - if (isMobile) { - close(); - } - }; + const navItems: NavItem[] = [ + { + label: 'Home', + href: '/', + icon: HomeIcon, + active: pathname === '/', + }, + { + label: 'Insights', + href: '/insights', + icon: InsightsIcon, + active: pathname === '/insights', + }, + { + label: 'Metadata', + href: '/metadata', + icon: MetadataIcon, + active: pathname === '/metadata', + }, + { + label: 'Diagram', + href: '/diagram', + icon: DiagramIcon, + active: pathname === '/diagram', + disabled: isMobile, + new: true, + }, + { + label: 'Processes', + href: '/processes', + icon: ProcessesIcon, + active: pathname === '/processes', + } + ]; + + const handleNavClick = () => { + if (isMobile) { + close(); + } + }; return ( - - - {element !== null && !isMobile && ( - + {element !== null && !isMobile && ( + - {isOpen ? : } - - )} - - {/* Mobile close button */} - {isMobile && ( - - - - - - )} - - - - - + {isOpen ? : } + + )} + + {/* Mobile close button */} + {isMobile && isOpen && ( + - {navItems.map((item, itemIndex) => ( - - - - { - if (item.action) { - item.action(); - } - if (!item.disabled) { - handleNavClick(); - } + + + + + )} + + + + + + {navItems.map((item, itemIndex) => ( + + + + { + if (item.action) { + item.action(); + } + if (!item.disabled) { + handleNavClick(); + } + }} + > + alpha(theme.palette.primary.main, 0.12) + : 'transparent', + transition: 'all 0.2s ease-in-out', + '&:hover': { + backgroundColor: item.disabled + ? 'transparent' + : item.active + ? (theme) => alpha(theme.palette.primary.main, 0.16) + : 'action.hover', + color: item.disabled ? 'text.disabled' : 'text.primary', + } }} > - alpha(theme.palette.primary.main, 0.12) - : 'transparent', - transition: 'all 0.2s ease-in-out', - '&:hover': { - backgroundColor: item.disabled - ? 'transparent' - : item.active - ? (theme) => alpha(theme.palette.primary.main, 0.16) - : 'action.hover', - color: item.disabled ? 'text.disabled' : 'text.primary', - } - }} - > - - {item.icon} - - - {item.label} - - - - - - - ))} + + {item.icon} + + + {item.label} + + + + + + + ))} {isOpen && element != null && ( diff --git a/Website/components/shared/elements/EntityGroupAccordion.tsx b/Website/components/shared/elements/EntityGroupAccordion.tsx index 5c4c01c..442e30c 100644 --- a/Website/components/shared/elements/EntityGroupAccordion.tsx +++ b/Website/components/shared/elements/EntityGroupAccordion.tsx @@ -95,7 +95,7 @@ export const EntityGroupAccordion = ({ isCurrentGroup ? "font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-primary" )} sx={{ - backgroundColor: isExpanded ? alpha(theme.palette.primary.main, 0.1) : 'transparent', + backgroundColor: isCurrentGroup ? alpha(theme.palette.primary.main, 0.1) : 'transparent', padding: '4px', minHeight: '32px !important', '& .MuiAccordionSummary-content': { @@ -106,28 +106,28 @@ export const EntityGroupAccordion = ({ } }} > - {group.Name} - {group.Entities.length} - + {showGroupClickIcon && ( - )} diff --git a/Website/theme.ts b/Website/theme.ts index bb71482..17b8cac 100644 --- a/Website/theme.ts +++ b/Website/theme.ts @@ -23,7 +23,21 @@ declare module '@mui/material/styles' { export const createAppTheme = (mode: PaletteMode) => createTheme({ components: { + MuiTooltip: { + styleOverrides: { + tooltip: ({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + opacity: 1, + color: theme.palette.text.primary, + boxShadow: theme.shadows[1], + }), + arrow: ({ theme }) => ({ + color: theme.palette.background.paper, + }), + }, + }, MuiIconButton: { + styleOverrides: { root: { // Base styles for all IconButton variants @@ -76,9 +90,10 @@ export const createAppTheme = (mode: PaletteMode) => createTheme({ disabled: mode === 'dark' ? '#6b7280' : '#9ca3af', // Disabled text colors }, background: { - default: mode === 'dark' ? '#111827' : '#fafafa', + default: mode === 'dark' ? '#111827' : '#fafafa', paper: mode === 'dark' ? '#1f2937' : '#ffffff', }, + divider: mode === 'dark' ? '#374151' : '#e5e7eb', // Same as border.main for consistency grey: { 100: mode === 'dark' ? '#374151' : '#f3f4f6',