diff --git a/Website/app/layout.tsx b/Website/app/layout.tsx index 599e2b6..dccc646 100644 --- a/Website/app/layout.tsx +++ b/Website/app/layout.tsx @@ -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", @@ -30,7 +31,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 0555fb9..05e7565 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -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 { } @@ -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(null); const lastScrollHandleTime = useRef(0); const scrollTimeoutRef = useRef(); @@ -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; @@ -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; @@ -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(() => { @@ -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 @@ -363,9 +438,14 @@ export const List = ({ }: IListProps) => { {item.type === 'group' ? (
-
- {item.group.Name} -
+ +
handleCopyGroupLink(item.group.Name)} + > + {item.group.Name} +
+
) : ( diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 2cc21c6..b849ebd 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -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(); @@ -31,6 +31,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const [searchTerm, setSearchTerm] = useState(""); const [displaySearchTerm, setDisplaySearchTerm] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); // Memoize search results to prevent recalculation on every render const filteredGroups = useMemo(() => { @@ -67,6 +68,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { newExpandedGroups.add(group.Name); } }); + setExpandedGroups(newExpandedGroups); } }, [groups]); @@ -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 @@ -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 ( 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)', + } }} > { 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': { @@ -157,24 +197,24 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { }} > {group.Name} - {group.Entities.length} + {group.Entities.length} { 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" }} /> @@ -257,7 +297,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { ) - }, [currentGroup, currentSection, theme, handleGroupClick, handleSectionClick, isEntityMatch, searchTerm, highlightText]); + }, [currentGroup, currentSection, theme, handleGroupClick, handleSectionClick, isEntityMatch, searchTerm, highlightText, expandedGroups, loadingSection]); return ( diff --git a/Website/components/datamodelview/entity/EntityHeader.tsx b/Website/components/datamodelview/entity/EntityHeader.tsx index 9b4fc63..289d9e3 100644 --- a/Website/components/datamodelview/entity/EntityHeader.tsx +++ b/Website/components/datamodelview/entity/EntityHeader.tsx @@ -2,11 +2,25 @@ import { EntityType } from "@/lib/Types"; import { EntityDetails } from "./EntityDetails"; -import { Box, Typography, Paper, useTheme } from '@mui/material'; +import { Box, Typography, Paper, useTheme, Tooltip } from '@mui/material'; import { LinkRounded } from "@mui/icons-material"; +import { useCallback } from 'react'; +import { copyToClipboard, generateSectionLink } from "@/lib/clipboard-utils"; +import { useSnackbar } from "@/contexts/SnackbarContext"; export function EntityHeader({ entity }: { entity: EntityType }) { const theme = useTheme(); + const { showSnackbar } = useSnackbar(); + + const handleCopyLink = useCallback(async () => { + const link = generateSectionLink(entity.SchemaName); + const success = await copyToClipboard(link); + if (success) { + showSnackbar('Link copied to clipboard!', 'success'); + } else { + showSnackbar('Failed to copy link', 'error'); + } + }, [entity.SchemaName, showSnackbar]); return ( - - {entity.IconBase64 == null ? - : -
- } - - - - {entity.DisplayName} - - + - {entity.SchemaName} - + {entity.IconBase64 == null ? + : +
+ } + + + + + + {entity.DisplayName} + + + + + {entity.SchemaName} + + diff --git a/Website/contexts/DatamodelViewContext.tsx b/Website/contexts/DatamodelViewContext.tsx index 4f7fe30..3ab7765 100644 --- a/Website/contexts/DatamodelViewContext.tsx +++ b/Website/contexts/DatamodelViewContext.tsx @@ -8,6 +8,7 @@ export interface DatamodelViewState { currentGroup: string | null; currentSection: string | null; scrollToSection: (sectionId: string) => void; + scrollToGroup: (groupName: string) => void; loading: boolean; loadingSection: string | null; } @@ -16,6 +17,7 @@ const initialState: DatamodelViewState = { currentGroup: null, currentSection: null, scrollToSection: () => { throw new Error("scrollToSection not initialized yet!"); }, + scrollToGroup: () => { throw new Error("scrollToGroup not initialized yet!"); }, loading: true, loadingSection: null, } @@ -24,6 +26,7 @@ type DatamodelViewAction = | { type: 'SET_CURRENT_GROUP', payload: string | null } | { type: 'SET_CURRENT_SECTION', payload: string | null } | { type: 'SET_SCROLL_TO_SECTION', payload: (sectionId: string) => void } + | { type: 'SET_SCROLL_TO_GROUP', payload: (groupName: string) => void } | { type: 'SET_LOADING', payload: boolean } | { type: 'SET_LOADING_SECTION', payload: string | null } @@ -36,6 +39,8 @@ const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAc return { ...state, currentSection: action.payload } case 'SET_SCROLL_TO_SECTION': return { ...state, scrollToSection: action.payload } + case 'SET_SCROLL_TO_GROUP': + return { ...state, scrollToGroup: action.payload } case 'SET_LOADING': return { ...state, loading: action.payload } case 'SET_LOADING_SECTION': diff --git a/Website/contexts/SnackbarContext.tsx b/Website/contexts/SnackbarContext.tsx new file mode 100644 index 0000000..8fbd3fe --- /dev/null +++ b/Website/contexts/SnackbarContext.tsx @@ -0,0 +1,71 @@ +'use client' + +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { Snackbar, Alert, AlertColor } from '@mui/material'; + +interface SnackbarMessage { + message: string; + severity?: AlertColor; + duration?: number; +} + +interface SnackbarContextType { + showSnackbar: (message: string, severity?: AlertColor, duration?: number) => void; +} + +const SnackbarContext = createContext(undefined); + +export const useSnackbar = (): SnackbarContextType => { + const context = useContext(SnackbarContext); + if (!context) { + throw new Error('useSnackbar must be used within a SnackbarProvider'); + } + return context; +}; + +interface SnackbarProviderProps { + children: ReactNode; +} + +export const SnackbarProvider: React.FC = ({ children }) => { + const [snackbarData, setSnackbarData] = useState(null); + const [open, setOpen] = useState(false); + + const showSnackbar = useCallback((message: string, severity: AlertColor = 'success', duration: number = 3000) => { + setSnackbarData({ message, severity, duration }); + setOpen(true); + }, []); + + const handleClose = useCallback((event?: React.SyntheticEvent | Event, reason?: string) => { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }, []); + + const handleExited = useCallback(() => { + setSnackbarData(null); + }, []); + + return ( + + {children} + + + {snackbarData?.message} + + + + ); +}; \ No newline at end of file diff --git a/Website/lib/clipboard-utils.ts b/Website/lib/clipboard-utils.ts new file mode 100644 index 0000000..62f4d46 --- /dev/null +++ b/Website/lib/clipboard-utils.ts @@ -0,0 +1,66 @@ +/** + * Utility functions for clipboard operations + */ + +/** + * Copy text to clipboard using the modern clipboard API + * @param text The text to copy to clipboard + * @returns Promise that resolves to true if successful, false otherwise + */ +export async function copyToClipboard(text: string): Promise { + try { + // Modern clipboard API - preferred method + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + + // Fallback for older browsers - create temporary input element + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'absolute'; + textArea.style.left = '-9999px'; + textArea.style.opacity = '0'; + + document.body.appendChild(textArea); + textArea.select(); + textArea.setSelectionRange(0, 99999); + + // Use the legacy method as fallback (suppress deprecation warning) + const success = document.execCommand('copy'); + document.body.removeChild(textArea); + + return success; + } catch (err) { + console.error('Failed to copy text to clipboard:', err); + return false; + } +} + +/** + * Generate a shareable URL for a specific section + * @param sectionId The schema name of the section + * @param groupName Optional group name for context + * @returns The full URL that can be shared + */ +export function generateSectionLink(sectionId: string, groupName?: string): string { + const url = new URL(window.location.href); + url.pathname = '/metadata'; + url.searchParams.set('section', sectionId); + if (groupName) { + url.searchParams.set('group', groupName); + } + return url.toString(); +} + +/** + * Generate a shareable URL for a specific group + * @param groupName The name of the group + * @returns The full URL that can be shared + */ +export function generateGroupLink(groupName: string): string { + const url = new URL(window.location.href); + url.pathname = '/metadata'; + url.searchParams.set('group', groupName); + return url.toString(); +} \ No newline at end of file