Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Website/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SettingsProvider } from "@/contexts/SettingsContext";
import { AuthProvider } from "@/contexts/AuthContext";
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import { DatamodelViewProvider } from "@/contexts/DatamodelViewContext";
import { SnackbarProvider } from "@/contexts/SnackbarContext";

export const metadata: Metadata = {
title: "Data Model Viewer",
Expand All @@ -30,7 +31,9 @@ export default function RootLayout({
<SettingsProvider>
<DatamodelViewProvider>
<SidebarProvider>
{children}
<SnackbarProvider>
{children}
</SnackbarProvider>
</SidebarProvider>
</DatamodelViewProvider>
</SettingsProvider>
Expand Down
88 changes: 84 additions & 4 deletions Website/components/datamodelview/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { Section } from "./Section";
import { useDatamodelData } from "@/contexts/DatamodelDataContext";
import { AttributeType, EntityType, GroupType } from "@/lib/Types";
import { updateURL } from "@/lib/url-utils";
import { copyToClipboard, generateGroupLink } from "@/lib/clipboard-utils";
import { useSnackbar } from "@/contexts/SnackbarContext";
import { Tooltip } from '@mui/material';

interface IListProps {
}
Expand All @@ -23,6 +26,7 @@ export const List = ({ }: IListProps) => {
const datamodelView = useDatamodelView();
const [isScrollingToSection, setIsScrollingToSection] = useState(false);
const { groups, filtered, search } = useDatamodelData();
const { showSnackbar } = useSnackbar();
const parentRef = useRef<HTMLDivElement | null>(null);
const lastScrollHandleTime = useRef<number>(0);
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
Expand All @@ -42,6 +46,16 @@ export const List = ({ }: IListProps) => {
if (el) rowVirtualizer.measureElement(el);
};

const handleCopyGroupLink = useCallback(async (groupName: string) => {
const link = generateGroupLink(groupName);
const success = await copyToClipboard(link);
if (success) {
showSnackbar('Group link copied to clipboard!', 'success');
} else {
showSnackbar('Failed to copy group link', 'error');
}
}, [showSnackbar]);

// Only recalculate items when filtered or search changes
const flatItems = useMemo(() => {
if (filtered && filtered.length > 0) return filtered;
Expand Down Expand Up @@ -140,6 +154,7 @@ export const List = ({ }: IListProps) => {

setTimeout(() => {
setIsScrollingToSection(false);
dispatch({ type: 'SET_LOADING_SECTION', payload: null });
// Reset intentional scroll flag after scroll is complete
setTimeout(() => {
isIntentionalScroll.current = false;
Expand All @@ -160,6 +175,65 @@ export const List = ({ }: IListProps) => {
setIsScrollingToSection(false);
}
}, 20);

}, [flatItems, rowVirtualizer]);

const scrollToGroup = useCallback((groupName: string) => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}

const groupIndex = flatItems.findIndex(item =>
item.type === 'group' && item.group.Name === groupName
);

if (groupIndex === -1) {
console.warn(`Group ${groupName} not found in virtualized list`);
return;
}

const currentIndex = rowVirtualizer.getVirtualItems()[0]?.index || 0;
const isLargeJump = Math.abs(groupIndex - currentIndex) > 10;

if (isLargeJump) {
setIsScrollingToSection(true);
}

scrollTimeoutRef.current = setTimeout(() => {
if (!rowVirtualizer || groupIndex >= flatItems.length) {
console.warn(`Invalid index ${groupIndex} for group ${groupName}`);
setIsScrollingToSection(false);
return;
}

try {
isIntentionalScroll.current = true; // Mark this as intentional scroll
rowVirtualizer.scrollToIndex(groupIndex, {
align: 'start'
});

setTimeout(() => {
setIsScrollingToSection(false);
// Reset intentional scroll flag after scroll is complete
setTimeout(() => {
isIntentionalScroll.current = false;
}, 100);
}, 500);
} catch (error) {
console.warn(`Failed to scroll to group ${groupName}:`, error);

const estimatedOffset = groupIndex * 300;
if (parentRef.current) {
isIntentionalScroll.current = true;
parentRef.current.scrollTop = estimatedOffset;
// Reset flags for fallback scroll
setTimeout(() => {
isIntentionalScroll.current = false;
}, 600);
}
setIsScrollingToSection(false);
}
}, 20);
}, [flatItems, rowVirtualizer]);

useEffect(() => {
Expand Down Expand Up @@ -275,13 +349,14 @@ export const List = ({ }: IListProps) => {

useEffect(() => {
dispatch({ type: 'SET_SCROLL_TO_SECTION', payload: scrollToSection });
dispatch({ type: 'SET_SCROLL_TO_GROUP', payload: scrollToGroup });

return () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [dispatch, scrollToSection]);
}, [dispatch, scrollToSection, scrollToGroup]);

useEffect(() => {
// When the current section is in view, set loading to false
Expand Down Expand Up @@ -363,9 +438,14 @@ export const List = ({ }: IListProps) => {
{item.type === 'group' ? (
<div className="flex items-center py-6 my-4">
<div className="flex-1 h-0.5 bg-gray-200" />
<div className="px-4 text-md font-semibold text-gray-700 uppercase tracking-wide whitespace-nowrap">
{item.group.Name}
</div>
<Tooltip title="Copy link to this group">
<div
className="px-4 text-md font-semibold text-gray-700 uppercase tracking-wide whitespace-nowrap cursor-pointer hover:text-blue-600 transition-colors"
onClick={() => handleCopyGroupLink(item.group.Name)}
>
{item.group.Name}
</div>
</Tooltip>
<div className="flex-1 h-0.5 bg-gray-200" />
</div>
) : (
Expand Down
78 changes: 59 additions & 19 deletions Website/components/datamodelview/SidebarDatamodelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface INavItemProps {


export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
const { currentSection, currentGroup, scrollToSection, loadingSection } = useDatamodelView();
const { currentSection, currentGroup, scrollToSection, scrollToGroup, loadingSection } = useDatamodelView();
const { close: closeSidebar } = useSidebar();
const theme = useTheme();
const isMobile = useIsMobile();
Expand All @@ -31,6 +31,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {

const [searchTerm, setSearchTerm] = useState("");
const [displaySearchTerm, setDisplaySearchTerm] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());

// Memoize search results to prevent recalculation on every render
const filteredGroups = useMemo(() => {
Expand Down Expand Up @@ -67,6 +68,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
newExpandedGroups.add(group.Name);
}
});
setExpandedGroups(newExpandedGroups);
}
}, [groups]);

Expand All @@ -93,8 +95,42 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
}, []);

const handleGroupClick = useCallback((groupName: string) => {
dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: groupName });
}, [dataModelDispatch]);
setExpandedGroups(prev => {
const newExpanded = new Set(prev);
if (newExpanded.has(groupName)) {
newExpanded.delete(groupName);
} else {
if (currentGroup?.toLowerCase() === groupName.toLowerCase()) return newExpanded;
newExpanded.add(groupName);
}
return newExpanded;
});
}, [dataModelDispatch, currentGroup]);

const handleScrollToGroup = useCallback((group: GroupType) => {

// Set current group and scroll to group header
dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: group.Name });
if (group.Entities.length > 0)
dataModelDispatch({ type: "SET_CURRENT_SECTION", payload: group.Entities[0].SchemaName });

setExpandedGroups(prev => {
const newExpanded = new Set(prev);
if (newExpanded.has(group.Name)) {
newExpanded.delete(group.Name);
}
return newExpanded;
});

if (scrollToGroup) {
scrollToGroup(group.Name);
}

// On phone - close sidebar
if (!!isMobile) {
closeSidebar();
}
}, [dataModelDispatch, scrollToGroup, isMobile, closeSidebar]);

const handleSectionClick = useCallback((sectionId: string, groupName: string) => {
// Use requestAnimationFrame to defer heavy operations
Expand All @@ -115,27 +151,31 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
scrollToSection(sectionId);
}
clearSearch();

// Clear loading section after a short delay to show the loading state
setTimeout(() => {
dataModelDispatch({ type: 'SET_LOADING_SECTION', payload: null });
}, 500);
});
});
}, [dataModelDispatch, scrollToSection, clearSearch]);

const NavItem = useCallback(({ group }: INavItemProps) => {
const isCurrentGroup = currentGroup?.toLowerCase() === group.Name.toLowerCase();

const isExpanded = expandedGroups.has(group.Name) || isCurrentGroup;

return (
<Accordion
disableGutters
expanded={isCurrentGroup}
onClick={() => handleGroupClick(group.Name)}
className={`group/accordion transition-all duration-300 w-full first:rounded-t-lg last:rounded-b-lg shadow-none p-1`}
expanded={isExpanded}
onChange={() => handleGroupClick(group.Name)}
className={`group/accordion w-full first:rounded-t-lg last:rounded-b-lg shadow-none p-1`}
slotProps={{
transition: {
timeout: 300,
}
}}
sx={{
backgroundColor: "background.paper",
borderColor: 'border.main',
'& .MuiCollapse-root': {
transition: 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}
}}
>
<AccordionSummary
Expand All @@ -145,7 +185,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
isCurrentGroup ? "font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-primary"
)}
sx={{
backgroundColor: isCurrentGroup ? alpha(theme.palette.primary.main, 0.1) : 'transparent',
backgroundColor: isExpanded ? alpha(theme.palette.primary.main, 0.1) : 'transparent',
padding: '4px',
minHeight: '32px !important',
'& .MuiAccordionSummary-content': {
Expand All @@ -157,24 +197,24 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
}}
>
<Typography
className={`flex-1 text-sm text-left truncate min-w-0 ${isCurrentGroup ? 'font-semibold' : ''}`}
className={`flex-1 text-sm text-left truncate min-w-0 ${isExpanded ? 'font-semibold' : ''}`}
sx={{
color: isCurrentGroup ? 'primary.main' : 'text.primary'
color: isExpanded ? 'primary.main' : 'text.primary'
}}
>
{group.Name}
</Typography>
<Typography className={`flex-shrink-0 text-xs mr-2 ${isCurrentGroup ? 'font-semibold' : ''}`} sx={{ opacity: 0.7, color: isCurrentGroup ? 'primary.main' : 'text.primary' }}>{group.Entities.length}</Typography>
<Typography className={`flex-shrink-0 text-xs mr-2 ${isExpanded ? 'font-semibold' : ''}`} sx={{ opacity: 0.7, color: isExpanded ? 'primary.main' : 'text.primary' }}>{group.Entities.length}</Typography>

<OpenInNewRounded
onClick={(e) => {
e.stopPropagation();
if (group.Entities.length > 0) handleSectionClick(group.Entities[0].SchemaName, group.Name);
handleScrollToGroup(group);
}}
aria-label={`Link to first entity in ${group.Name}`}
className="w-4 h-4 flex-shrink-0"
sx={{
color: isCurrentGroup ? "primary.main" : "default"
color: isExpanded ? "primary.main" : "default"
}}
/>
</AccordionSummary>
Expand Down Expand Up @@ -257,7 +297,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
</AccordionDetails>
</Accordion>
)
}, [currentGroup, currentSection, theme, handleGroupClick, handleSectionClick, isEntityMatch, searchTerm, highlightText]);
}, [currentGroup, currentSection, theme, handleGroupClick, handleSectionClick, isEntityMatch, searchTerm, highlightText, expandedGroups, loadingSection]);

return (
<Box className="flex flex-col w-full p-2">
Expand Down
Loading
Loading