From 50567531c4592722f86aadb4697554f8a7bec485 Mon Sep 17 00:00:00 2001 From: Bircck <55695195+Bircck@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:00:07 +0200 Subject: [PATCH 1/2] fix(website): follow lookup links outside search results --- .../datamodelview/DatamodelView.tsx | 19 +++++++-- Website/components/datamodelview/List.tsx | 39 +++++++++++++++++-- .../datamodelview/Relationships.tsx | 2 +- .../attributes/LookupAttribute.tsx | 2 +- Website/contexts/DatamodelViewContext.tsx | 4 +- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index a8b8e18..dd29fcc 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -40,6 +40,7 @@ function DatamodelViewContent() { const { filters: entityFilters, selectedSecurityRoles } = useEntityFilters(); const workerRef = useRef(null); const [currentSearchIndex, setCurrentSearchIndex] = useState(0); + const [searchResetKey, setSearchResetKey] = useState(0); const accumulatedResultsRef = useRef([]); // Track all results during search const searchRequestIdRef = useRef(0); // Track search requests to ignore stale results const [searchScope, setSearchScope] = useState({ @@ -99,10 +100,10 @@ function DatamodelViewContent() { // Isolated search handlers - these don't depend on component state const handleSearch = useCallback((searchValue: string) => { + // Clearing a search must also invalidate results still in flight. + searchRequestIdRef.current += 1; if (workerRef.current && groups) { if (searchValue.length >= 3) { - // Increment request ID to invalidate previous searches - searchRequestIdRef.current += 1; const currentRequestId = searchRequestIdRef.current; // Convert Map to plain object for worker @@ -134,6 +135,12 @@ function DatamodelViewContent() { setCurrentSearchIndex(searchValue.length >= 3 ? 1 : 0); // Reset to first result when searching, 0 when cleared }, [groups, datamodelDataDispatch, restoreSection, entityFilters, searchScope, selectedSecurityRoles]); + const handleExitSearch = useCallback(() => { + handleSearch(""); + datamodelDispatch({ type: "SET_LOADING", payload: false }); + setSearchResetKey(value => value + 1); + }, [handleSearch, datamodelDispatch]); + const handleLoadingChange = useCallback((isLoading: boolean) => { datamodelDispatch({ type: "SET_LOADING", payload: isLoading }); }, [datamodelDispatch]); @@ -349,6 +356,7 @@ function DatamodelViewContent() { const handleMessage = (e: MessageEvent) => { const message = e.data; + const requestId = searchRequestIdRef.current; // Ignore stale search results if (message.requestId && message.requestId < searchRequestIdRef.current) { @@ -393,6 +401,7 @@ function DatamodelViewContent() { // Small delay to ensure virtual list is ready setTimeout(() => { + if (requestId !== searchRequestIdRef.current) return; if (firstResult.type === 'attribute') { scrollToAttribute(firstResult.entity.SchemaName, firstResult.attribute.SchemaName); } else { @@ -411,6 +420,7 @@ function DatamodelViewContent() { datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstResult.entity.SchemaName }); datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstResult.group.Name }); setTimeout(() => { + if (requestId !== searchRequestIdRef.current) return; scrollToSection(firstResult.entity.SchemaName); }, 100); } @@ -502,16 +512,17 @@ function DatamodelViewContent() { )} */} - + ); diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 37a77d5..76b31c1 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -14,6 +14,7 @@ import { Box, CircularProgress, debounce, Tooltip } from '@mui/material'; interface IListProps { setCurrentIndex: (index: number) => void; entityActiveTabs: Map; + onExitSearch: () => void; } // Helper to highlight search matches @@ -24,7 +25,7 @@ export function highlightMatch(text: string, search: string) { return <>{text.slice(0, idx)}{text.slice(idx, idx + search.length)}{text.slice(idx + search.length)}; } -export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { +export const List = ({ setCurrentIndex, entityActiveTabs, onExitSearch }: IListProps) => { const dispatch = useDatamodelViewDispatch(); const { currentSection, loadingSection } = useDatamodelView(); const { groups, filtered, search } = useDatamodelData(); @@ -34,6 +35,8 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { // used to relocate section after search/filter const [sectionVirtualItem, setSectionVirtualItem] = useState(null); + const [pendingSection, setPendingSection] = useState(null); + // Helper function to check if entity has access from selected security roles const hasSecurityRoleAccess = useCallback((entity: EntityType): boolean => { if (selectedSecurityRoles.length === 0) return false; @@ -179,19 +182,28 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; }, [rowVirtualizer]); - const scrollToSection = useCallback((sectionId: string) => { + const scrollToSection = useCallback((sectionId: string, revealIfFiltered = false) => { const sectionIndex = flatItems.findIndex(item => item.type === 'entity' && item.entity.SchemaName === sectionId ); if (sectionIndex === -1) { - console.warn(`Section ${sectionId} not found in virtualized list`); + if (!revealIfFiltered) return; + const target = groups.flatMap(group => group.Entities).find(entity => entity.SchemaName === sectionId); + if (search && target && (selectedSecurityRoles.length === 0 || hasSecurityRoleAccess(target))) { + // Retry after clearing search has restored the destination to the list. + setPendingSection(sectionId); + onExitSearch(); + } else { + dispatch({ type: 'SET_LOADING_SECTION', payload: null }); + showSnackbar('This table is not available with the current filters.', 'info'); + } return; } smartScrollToIndex(sectionIndex); - }, [flatItems]); + }, [flatItems, groups, search, selectedSecurityRoles, hasSecurityRoleAccess, onExitSearch, dispatch, showSnackbar]); const scrollToAttribute = useCallback((sectionId: string, attrSchema: string) => { const attrId = `attr-${sectionId}-${attrSchema}`; @@ -279,6 +291,25 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { requestAnimationFrame(tryFix); }, [rowVirtualizer]); + useEffect(() => { + if (!pendingSection || search) return; + const sectionIndex = flatItems.findIndex(item => item.type === 'entity' && item.entity.SchemaName === pendingSection); + if (sectionIndex === -1) { + dispatch({ type: 'SET_LOADING_SECTION', payload: null }); + setPendingSection(null); + return; + } + const frame = requestAnimationFrame(() => { + smartScrollToIndex(sectionIndex); + const target = flatItems[sectionIndex]; + updateURL({ query: { group: target.group.Name, section: pendingSection } }); + dispatch({ type: 'SET_CURRENT_GROUP', payload: target.group.Name }); + dispatch({ type: 'SET_CURRENT_SECTION', payload: pendingSection }); + dispatch({ type: 'SET_LOADING_SECTION', payload: null }); + setPendingSection(null); + }); + return () => cancelAnimationFrame(frame); + }, [pendingSection, search, flatItems, smartScrollToIndex, dispatch]); return ( <> diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 5e89094..728a4e3 100644 --- a/Website/components/datamodelview/Relationships.tsx +++ b/Website/components/datamodelview/Relationships.tsx @@ -421,7 +421,7 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe onClick={() => { dispatch({ type: 'SET_LOADING_SECTION', payload: relationship.TableSchema }); dispatch({ type: "SET_CURRENT_SECTION", payload: relationship.TableSchema }); - scrollToSection(relationship.TableSchema); + scrollToSection(relationship.TableSchema, true); }} sx={{ fontSize: { xs: '0.625rem', md: '0.875rem' }, diff --git a/Website/components/datamodelview/attributes/LookupAttribute.tsx b/Website/components/datamodelview/attributes/LookupAttribute.tsx index 0534271..2cbb59d 100644 --- a/Website/components/datamodelview/attributes/LookupAttribute.tsx +++ b/Website/components/datamodelview/attributes/LookupAttribute.tsx @@ -22,7 +22,7 @@ export default function LookupAttribute({ attribute }: { attribute: LookupAttrib onClick={() => { dispatch({ type: 'SET_LOADING_SECTION', payload: target.Name }); dispatch({ type: "SET_CURRENT_SECTION", payload: target.Name }); - scrollToSection(target.Name); + scrollToSection(target.Name, true); }} sx={{ fontSize: { xs: '0.625rem', md: '0.875rem' }, diff --git a/Website/contexts/DatamodelViewContext.tsx b/Website/contexts/DatamodelViewContext.tsx index 1a7cda2..abaf54e 100644 --- a/Website/contexts/DatamodelViewContext.tsx +++ b/Website/contexts/DatamodelViewContext.tsx @@ -7,7 +7,7 @@ import { createContext, ReactNode, useContext, useEffect, useReducer, useRef } f export interface DatamodelViewState { currentGroup: string | null; currentSection: string | null; - scrollToSection: (sectionId: string) => void; + scrollToSection: (sectionId: string, revealIfFiltered?: boolean) => void; scrollToGroup: (groupName: string) => void; scrollToAttribute: (sectionId: string, attrSchema: string) => void; scrollToRelationship: (sectionId: string, relSchema: string) => void; @@ -31,7 +31,7 @@ const initialState: DatamodelViewState = { 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_SECTION', payload: (sectionId: string, revealIfFiltered?: boolean) => void } | { type: 'SET_SCROLL_TO_GROUP', payload: (groupName: string) => void } | { type: 'SET_LOADING', payload: boolean } | { type: 'SET_LOADING_SECTION', payload: string | null } From 970673a3d04d2475b2999ff0c1eb35583c542ff3 Mon Sep 17 00:00:00 2001 From: Bircck <55695195+Bircck@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:45:01 +0200 Subject: [PATCH 2/2] Keep metadata navigation stable during scrolling --- Website/components/datamodelview/List.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 76b31c1..e20505f 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -32,6 +32,12 @@ export const List = ({ setCurrentIndex, entityActiveTabs, onExitSearch }: IListP const { selectedSecurityRoles } = useEntityFilters(); const { showSnackbar } = useSnackbar(); const parentRef = useRef(null); + // Search handlers change when scrolling updates the restore position. Keep + // navigation stable so callback registration cannot replay URL navigation. + const onExitSearchRef = useRef(onExitSearch); + useEffect(() => { + onExitSearchRef.current = onExitSearch; + }, [onExitSearch]); // used to relocate section after search/filter const [sectionVirtualItem, setSectionVirtualItem] = useState(null); @@ -193,7 +199,7 @@ export const List = ({ setCurrentIndex, entityActiveTabs, onExitSearch }: IListP if (search && target && (selectedSecurityRoles.length === 0 || hasSecurityRoleAccess(target))) { // Retry after clearing search has restored the destination to the list. setPendingSection(sectionId); - onExitSearch(); + onExitSearchRef.current(); } else { dispatch({ type: 'SET_LOADING_SECTION', payload: null }); showSnackbar('This table is not available with the current filters.', 'info'); @@ -203,7 +209,7 @@ export const List = ({ setCurrentIndex, entityActiveTabs, onExitSearch }: IListP smartScrollToIndex(sectionIndex); - }, [flatItems, groups, search, selectedSecurityRoles, hasSecurityRoleAccess, onExitSearch, dispatch, showSnackbar]); + }, [flatItems, groups, search, selectedSecurityRoles, hasSecurityRoleAccess, dispatch, showSnackbar]); const scrollToAttribute = useCallback((sectionId: string, attrSchema: string) => { const attrId = `attr-${sectionId}-${attrSchema}`;