Skip to content

Commit f601417

Browse files
authored
Merge pull request #62 from delegateas/patches/v2-navigation
Patches to navigation 120203
2 parents 8c4bead + 6a7f944 commit f601417

7 files changed

Lines changed: 371 additions & 67 deletions

File tree

Website/app/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SettingsProvider } from "@/contexts/SettingsContext";
55
import { AuthProvider } from "@/contexts/AuthContext";
66
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
77
import { DatamodelViewProvider } from "@/contexts/DatamodelViewContext";
8+
import { SnackbarProvider } from "@/contexts/SnackbarContext";
89

910
export const metadata: Metadata = {
1011
title: "Data Model Viewer",
@@ -30,7 +31,9 @@ export default function RootLayout({
3031
<SettingsProvider>
3132
<DatamodelViewProvider>
3233
<SidebarProvider>
33-
{children}
34+
<SnackbarProvider>
35+
{children}
36+
</SnackbarProvider>
3437
</SidebarProvider>
3538
</DatamodelViewProvider>
3639
</SettingsProvider>

Website/components/datamodelview/List.tsx

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { Section } from "./Section";
66
import { useDatamodelData } from "@/contexts/DatamodelDataContext";
77
import { AttributeType, EntityType, GroupType } from "@/lib/Types";
88
import { updateURL } from "@/lib/url-utils";
9+
import { copyToClipboard, generateGroupLink } from "@/lib/clipboard-utils";
10+
import { useSnackbar } from "@/contexts/SnackbarContext";
11+
import { Tooltip } from '@mui/material';
912

1013
interface IListProps {
1114
}
@@ -23,6 +26,7 @@ export const List = ({ }: IListProps) => {
2326
const datamodelView = useDatamodelView();
2427
const [isScrollingToSection, setIsScrollingToSection] = useState(false);
2528
const { groups, filtered, search } = useDatamodelData();
29+
const { showSnackbar } = useSnackbar();
2630
const parentRef = useRef<HTMLDivElement | null>(null);
2731
const lastScrollHandleTime = useRef<number>(0);
2832
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
@@ -42,6 +46,16 @@ export const List = ({ }: IListProps) => {
4246
if (el) rowVirtualizer.measureElement(el);
4347
};
4448

49+
const handleCopyGroupLink = useCallback(async (groupName: string) => {
50+
const link = generateGroupLink(groupName);
51+
const success = await copyToClipboard(link);
52+
if (success) {
53+
showSnackbar('Group link copied to clipboard!', 'success');
54+
} else {
55+
showSnackbar('Failed to copy group link', 'error');
56+
}
57+
}, [showSnackbar]);
58+
4559
// Only recalculate items when filtered or search changes
4660
const flatItems = useMemo(() => {
4761
if (filtered && filtered.length > 0) return filtered;
@@ -140,6 +154,7 @@ export const List = ({ }: IListProps) => {
140154

141155
setTimeout(() => {
142156
setIsScrollingToSection(false);
157+
dispatch({ type: 'SET_LOADING_SECTION', payload: null });
143158
// Reset intentional scroll flag after scroll is complete
144159
setTimeout(() => {
145160
isIntentionalScroll.current = false;
@@ -160,6 +175,65 @@ export const List = ({ }: IListProps) => {
160175
setIsScrollingToSection(false);
161176
}
162177
}, 20);
178+
179+
}, [flatItems, rowVirtualizer]);
180+
181+
const scrollToGroup = useCallback((groupName: string) => {
182+
if (scrollTimeoutRef.current) {
183+
clearTimeout(scrollTimeoutRef.current);
184+
}
185+
186+
const groupIndex = flatItems.findIndex(item =>
187+
item.type === 'group' && item.group.Name === groupName
188+
);
189+
190+
if (groupIndex === -1) {
191+
console.warn(`Group ${groupName} not found in virtualized list`);
192+
return;
193+
}
194+
195+
const currentIndex = rowVirtualizer.getVirtualItems()[0]?.index || 0;
196+
const isLargeJump = Math.abs(groupIndex - currentIndex) > 10;
197+
198+
if (isLargeJump) {
199+
setIsScrollingToSection(true);
200+
}
201+
202+
scrollTimeoutRef.current = setTimeout(() => {
203+
if (!rowVirtualizer || groupIndex >= flatItems.length) {
204+
console.warn(`Invalid index ${groupIndex} for group ${groupName}`);
205+
setIsScrollingToSection(false);
206+
return;
207+
}
208+
209+
try {
210+
isIntentionalScroll.current = true; // Mark this as intentional scroll
211+
rowVirtualizer.scrollToIndex(groupIndex, {
212+
align: 'start'
213+
});
214+
215+
setTimeout(() => {
216+
setIsScrollingToSection(false);
217+
// Reset intentional scroll flag after scroll is complete
218+
setTimeout(() => {
219+
isIntentionalScroll.current = false;
220+
}, 100);
221+
}, 500);
222+
} catch (error) {
223+
console.warn(`Failed to scroll to group ${groupName}:`, error);
224+
225+
const estimatedOffset = groupIndex * 300;
226+
if (parentRef.current) {
227+
isIntentionalScroll.current = true;
228+
parentRef.current.scrollTop = estimatedOffset;
229+
// Reset flags for fallback scroll
230+
setTimeout(() => {
231+
isIntentionalScroll.current = false;
232+
}, 600);
233+
}
234+
setIsScrollingToSection(false);
235+
}
236+
}, 20);
163237
}, [flatItems, rowVirtualizer]);
164238

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

276350
useEffect(() => {
277351
dispatch({ type: 'SET_SCROLL_TO_SECTION', payload: scrollToSection });
352+
dispatch({ type: 'SET_SCROLL_TO_GROUP', payload: scrollToGroup });
278353

279354
return () => {
280355
if (scrollTimeoutRef.current) {
281356
clearTimeout(scrollTimeoutRef.current);
282357
}
283358
};
284-
}, [dispatch, scrollToSection]);
359+
}, [dispatch, scrollToSection, scrollToGroup]);
285360

286361
useEffect(() => {
287362
// When the current section is in view, set loading to false
@@ -363,9 +438,14 @@ export const List = ({ }: IListProps) => {
363438
{item.type === 'group' ? (
364439
<div className="flex items-center py-6 my-4">
365440
<div className="flex-1 h-0.5 bg-gray-200" />
366-
<div className="px-4 text-md font-semibold text-gray-700 uppercase tracking-wide whitespace-nowrap">
367-
{item.group.Name}
368-
</div>
441+
<Tooltip title="Copy link to this group">
442+
<div
443+
className="px-4 text-md font-semibold text-gray-700 uppercase tracking-wide whitespace-nowrap cursor-pointer hover:text-blue-600 transition-colors"
444+
onClick={() => handleCopyGroupLink(item.group.Name)}
445+
>
446+
{item.group.Name}
447+
</div>
448+
</Tooltip>
369449
<div className="flex-1 h-0.5 bg-gray-200" />
370450
</div>
371451
) : (

Website/components/datamodelview/SidebarDatamodelView.tsx

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface INavItemProps {
2020

2121

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

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

3536
// Memoize search results to prevent recalculation on every render
3637
const filteredGroups = useMemo(() => {
@@ -67,6 +68,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
6768
newExpandedGroups.add(group.Name);
6869
}
6970
});
71+
setExpandedGroups(newExpandedGroups);
7072
}
7173
}, [groups]);
7274

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

9597
const handleGroupClick = useCallback((groupName: string) => {
96-
dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: groupName });
97-
}, [dataModelDispatch]);
98+
setExpandedGroups(prev => {
99+
const newExpanded = new Set(prev);
100+
if (newExpanded.has(groupName)) {
101+
newExpanded.delete(groupName);
102+
} else {
103+
if (currentGroup?.toLowerCase() === groupName.toLowerCase()) return newExpanded;
104+
newExpanded.add(groupName);
105+
}
106+
return newExpanded;
107+
});
108+
}, [dataModelDispatch, currentGroup]);
109+
110+
const handleScrollToGroup = useCallback((group: GroupType) => {
111+
112+
// Set current group and scroll to group header
113+
dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: group.Name });
114+
if (group.Entities.length > 0)
115+
dataModelDispatch({ type: "SET_CURRENT_SECTION", payload: group.Entities[0].SchemaName });
116+
117+
setExpandedGroups(prev => {
118+
const newExpanded = new Set(prev);
119+
if (newExpanded.has(group.Name)) {
120+
newExpanded.delete(group.Name);
121+
}
122+
return newExpanded;
123+
});
124+
125+
if (scrollToGroup) {
126+
scrollToGroup(group.Name);
127+
}
128+
129+
// On phone - close sidebar
130+
if (!!isMobile) {
131+
closeSidebar();
132+
}
133+
}, [dataModelDispatch, scrollToGroup, isMobile, closeSidebar]);
98134

99135
const handleSectionClick = useCallback((sectionId: string, groupName: string) => {
100136
// Use requestAnimationFrame to defer heavy operations
@@ -115,27 +151,31 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
115151
scrollToSection(sectionId);
116152
}
117153
clearSearch();
118-
119-
// Clear loading section after a short delay to show the loading state
120-
setTimeout(() => {
121-
dataModelDispatch({ type: 'SET_LOADING_SECTION', payload: null });
122-
}, 500);
123154
});
124155
});
125156
}, [dataModelDispatch, scrollToSection, clearSearch]);
126157

127158
const NavItem = useCallback(({ group }: INavItemProps) => {
128159
const isCurrentGroup = currentGroup?.toLowerCase() === group.Name.toLowerCase();
129-
160+
const isExpanded = expandedGroups.has(group.Name) || isCurrentGroup;
161+
130162
return (
131163
<Accordion
132164
disableGutters
133-
expanded={isCurrentGroup}
134-
onClick={() => handleGroupClick(group.Name)}
135-
className={`group/accordion transition-all duration-300 w-full first:rounded-t-lg last:rounded-b-lg shadow-none p-1`}
165+
expanded={isExpanded}
166+
onChange={() => handleGroupClick(group.Name)}
167+
className={`group/accordion w-full first:rounded-t-lg last:rounded-b-lg shadow-none p-1`}
168+
slotProps={{
169+
transition: {
170+
timeout: 300,
171+
}
172+
}}
136173
sx={{
137174
backgroundColor: "background.paper",
138175
borderColor: 'border.main',
176+
'& .MuiCollapse-root': {
177+
transition: 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
178+
}
139179
}}
140180
>
141181
<AccordionSummary
@@ -145,7 +185,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
145185
isCurrentGroup ? "font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-primary"
146186
)}
147187
sx={{
148-
backgroundColor: isCurrentGroup ? alpha(theme.palette.primary.main, 0.1) : 'transparent',
188+
backgroundColor: isExpanded ? alpha(theme.palette.primary.main, 0.1) : 'transparent',
149189
padding: '4px',
150190
minHeight: '32px !important',
151191
'& .MuiAccordionSummary-content': {
@@ -157,24 +197,24 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
157197
}}
158198
>
159199
<Typography
160-
className={`flex-1 text-sm text-left truncate min-w-0 ${isCurrentGroup ? 'font-semibold' : ''}`}
200+
className={`flex-1 text-sm text-left truncate min-w-0 ${isExpanded ? 'font-semibold' : ''}`}
161201
sx={{
162-
color: isCurrentGroup ? 'primary.main' : 'text.primary'
202+
color: isExpanded ? 'primary.main' : 'text.primary'
163203
}}
164204
>
165205
{group.Name}
166206
</Typography>
167-
<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>
207+
<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>
168208

169209
<OpenInNewRounded
170210
onClick={(e) => {
171211
e.stopPropagation();
172-
if (group.Entities.length > 0) handleSectionClick(group.Entities[0].SchemaName, group.Name);
212+
handleScrollToGroup(group);
173213
}}
174214
aria-label={`Link to first entity in ${group.Name}`}
175215
className="w-4 h-4 flex-shrink-0"
176216
sx={{
177-
color: isCurrentGroup ? "primary.main" : "default"
217+
color: isExpanded ? "primary.main" : "default"
178218
}}
179219
/>
180220
</AccordionSummary>
@@ -257,7 +297,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
257297
</AccordionDetails>
258298
</Accordion>
259299
)
260-
}, [currentGroup, currentSection, theme, handleGroupClick, handleSectionClick, isEntityMatch, searchTerm, highlightText]);
300+
}, [currentGroup, currentSection, theme, handleGroupClick, handleSectionClick, isEntityMatch, searchTerm, highlightText, expandedGroups, loadingSection]);
261301

262302
return (
263303
<Box className="flex flex-col w-full p-2">

0 commit comments

Comments
 (0)