Skip to content
Open
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
19 changes: 15 additions & 4 deletions Website/components/datamodelview/DatamodelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function DatamodelViewContent() {
const { filters: entityFilters, selectedSecurityRoles } = useEntityFilters();
const workerRef = useRef<Worker | null>(null);
const [currentSearchIndex, setCurrentSearchIndex] = useState(0);
const [searchResetKey, setSearchResetKey] = useState(0);
const accumulatedResultsRef = useRef<SearchResultItem[]>([]); // Track all results during search
const searchRequestIdRef = useRef(0); // Track search requests to ignore stale results
const [searchScope, setSearchScope] = useState<SearchScope>({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down Expand Up @@ -502,16 +512,17 @@ function DatamodelViewContent() {
</div>
)} */}
<TimeSlicedSearch
key={searchResetKey}
onSearch={handleSearch}
onLoadingChange={handleLoadingChange}
onNavigateNext={handleNavigateNext}
onNavigatePrevious={handleNavigatePrevious}
initialLocalValue={initialLocalValue}
initialLocalValue={searchResetKey === 0 ? initialLocalValue : ""}
currentIndex={currentSearchIndex}
totalResults={totalResults}
onSearchScopeChange={handleSearchScopeChange}
/>
<List setCurrentIndex={setCurrentSearchIndex} entityActiveTabs={entityActiveTabs} />
<List setCurrentIndex={setCurrentSearchIndex} entityActiveTabs={entityActiveTabs} onExitSearch={handleExitSearch} />
</div>
</div>
);
Expand Down
45 changes: 41 additions & 4 deletions Website/components/datamodelview/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Box, CircularProgress, debounce, Tooltip } from '@mui/material';
interface IListProps {
setCurrentIndex: (index: number) => void;
entityActiveTabs: Map<string, number>;
onExitSearch: () => void;
}

// Helper to highlight search matches
Expand All @@ -24,16 +25,24 @@ export function highlightMatch(text: string, search: string) {
return <>{text.slice(0, idx)}<mark className="bg-yellow-200 text-black px-0.5 rounded">{text.slice(idx, idx + search.length)}</mark>{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();
const { selectedSecurityRoles } = useEntityFilters();
const { showSnackbar } = useSnackbar();
const parentRef = useRef<HTMLDivElement | null>(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<string | null>(null);

const [pendingSection, setPendingSection] = useState<string | null>(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;
Expand Down Expand Up @@ -179,19 +188,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);
onExitSearchRef.current();
} 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, dispatch, showSnackbar]);

const scrollToAttribute = useCallback((sectionId: string, attrSchema: string) => {
const attrId = `attr-${sectionId}-${attrSchema}`;
Expand Down Expand Up @@ -279,6 +297,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 (
<>
<Box className={`absolute w-full h-full flex items-center justify-center z-[100] transition-opacity duration-300 ${loadingSection ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
Expand Down
2 changes: 1 addition & 1 deletion Website/components/datamodelview/Relationships.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
4 changes: 2 additions & 2 deletions Website/contexts/DatamodelViewContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 }
Expand Down
Loading