From e0397feccd4f077e0ee1af10a120825aa4c61119 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 5 Jan 2026 18:53:41 +0100 Subject: [PATCH 1/8] feat: search scopes for global search --- .../components/datamodelview/Attributes.tsx | 10 +- .../datamodelview/DatamodelView.tsx | 35 ++- Website/components/datamodelview/Section.tsx | 3 +- .../datamodelview/TimeSlicedSearch.tsx | 249 ++++++++++++++---- .../attributes/BooleanAttribute.tsx | 13 +- .../attributes/DateTimeAttribute.tsx | 7 +- .../attributes/GenericAttribute.tsx | 5 +- .../attributes/StatusAttribute.tsx | 9 +- .../attributes/StringAttribute.tsx | 7 +- .../datamodelview/entity/SecurityRoles.tsx | 9 +- .../components/datamodelview/searchWorker.ts | 109 +++++++- 11 files changed, 369 insertions(+), 87 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 390f734..d009cdf 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -432,9 +432,9 @@ function getAttributeComponent(entity: EntityType, attribute: AttributeType, hig case 'ChoiceAttribute': return ; case 'DateTimeAttribute': - return ; + return ; case 'GenericAttribute': - return ; + return ; case 'IntegerAttribute': return ; case 'LookupAttribute': @@ -442,11 +442,11 @@ function getAttributeComponent(entity: EntityType, attribute: AttributeType, hig case 'DecimalAttribute': return ; case 'StatusAttribute': - return ; + return ; case 'StringAttribute': - return ; + return ; case 'BooleanAttribute': - return ; + return ; case 'FileAttribute': return ; default: diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index cd26e2a..d92d97d 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -4,7 +4,7 @@ import { useSidebar } from "@/contexts/SidebarContext"; import { SidebarDatamodelView } from "./SidebarDatamodelView"; import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; import { List } from "./List"; -import { TimeSlicedSearch } from "./TimeSlicedSearch"; +import { TimeSlicedSearch, SearchScope } from "./TimeSlicedSearch"; import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useDatamodelData, useDatamodelDataDispatch } from "@/contexts/DatamodelDataContext"; import { updateURL } from "@/lib/url-utils"; @@ -34,12 +34,20 @@ export function DatamodelView() { function DatamodelViewContent() { const { scrollToSection, scrollToAttribute, restoreSection } = useDatamodelView(); const datamodelDispatch = useDatamodelViewDispatch(); - const { groups, filtered } = useDatamodelData(); + const { groups, filtered, search } = useDatamodelData(); const datamodelDataDispatch = useDatamodelDataDispatch(); const { filters: entityFilters } = useEntityFilters(); const workerRef = useRef(null); const [currentSearchIndex, setCurrentSearchIndex] = useState(0); const accumulatedResultsRef = useRef([]); // Track all results during search + const [searchScope, setSearchScope] = useState({ + columnNames: true, + columnDescriptions: true, + columnDataTypes: false, + tableDescriptions: false, + securityRoles: false, + relationships: false, + }); // Calculate total search results (prioritize attributes, fallback to entities) const totalResults = useMemo(() => { @@ -47,7 +55,10 @@ function DatamodelViewContent() { const attributeCount = filtered.filter(item => item.type === 'attribute').length; if (attributeCount > 0) return attributeCount; - return 0; + + // If no attributes, count entity-level matches (for security roles, relationships, table descriptions) + const entityCount = filtered.filter(item => item.type === 'entity').length; + return entityCount; }, [filtered]); const initialLocalValue = useSearchParams().get('globalsearch') || ""; @@ -64,7 +75,8 @@ function DatamodelViewContent() { workerRef.current.postMessage({ type: 'search', data: searchValue, - entityFilters: filtersObject + entityFilters: filtersObject, + searchScope: searchScope }); } else { // Clear search - reset to show all groups @@ -79,12 +91,24 @@ function DatamodelViewContent() { updateURL({ query: { globalsearch: searchValue.length >= 3 ? searchValue : "" } }) datamodelDataDispatch({ type: "SET_SEARCH", payload: searchValue.length >= 3 ? searchValue : "" }); setCurrentSearchIndex(searchValue.length >= 3 ? 1 : 0); // Reset to first result when searching, 0 when cleared - }, [groups, datamodelDataDispatch, restoreSection, entityFilters]); + }, [groups, datamodelDataDispatch, restoreSection, entityFilters, searchScope]); const handleLoadingChange = useCallback((isLoading: boolean) => { datamodelDispatch({ type: "SET_LOADING", payload: isLoading }); }, [datamodelDispatch]); + const handleSearchScopeChange = useCallback((newScope: SearchScope) => { + setSearchScope(newScope); + }, []); + + // Re-trigger search when scope changes + useEffect(() => { + if (search && search.length >= 3) { + handleSearch(search); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchScope]); // Only trigger on searchScope change, not handleSearch to avoid infinite loop + // Helper function to sort results by their Y position on the page const sortResultsByYPosition = useCallback((results: Array<{ type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType }>) => { return results.sort((a, b) => { @@ -356,6 +380,7 @@ function DatamodelViewContent() { initialLocalValue={initialLocalValue} currentIndex={currentSearchIndex} totalResults={totalResults} + onSearchScopeChange={handleSearchScopeChange} /> diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index 0ae723e..08adfbe 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -6,6 +6,7 @@ import { SecurityRoles } from "./entity/SecurityRoles" import Keys from "./Keys" import { Attributes } from "./Attributes" import { Relationships } from "./Relationships" +import { highlightMatch } from "./List" import React from "react" import { Box, Paper, Tab, Tabs } from "@mui/material" import CustomTabPanel from "../shared/elements/TabPanel" @@ -42,7 +43,7 @@ export const Section = React.memo( {entity.SecurityRoles.length > 0 && (
- +
)} diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index fffbda8..2cde350 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -5,8 +5,28 @@ import { createPortal } from 'react-dom'; import { useSidebar } from '@/contexts/SidebarContext'; import { useSettings } from '@/contexts/SettingsContext'; import { useIsMobile } from '@/hooks/use-mobile'; -import { Box, CircularProgress, Divider, IconButton, InputAdornment, InputBase, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Paper, Typography } from '@mui/material'; -import { ClearRounded, InfoRounded, KeyboardArrowDownRounded, KeyboardArrowUpRounded, NavigateBeforeRounded, NavigateNextRounded, SearchRounded } from '@mui/icons-material'; +import { Box, CircularProgress, Divider, IconButton, InputAdornment, InputBase, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Paper, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; +import { AbcRounded, AccountTreeRounded, ClearRounded, DataObjectRounded, DescriptionRounded, ExpandMoreRounded, InfoRounded, KeyboardArrowDownRounded, KeyboardArrowUpRounded, LockPersonRounded, NavigateBeforeRounded, NavigateNextRounded, RestartAltRounded, SearchRounded, TableChartRounded } from '@mui/icons-material'; + +export const SEARCH_SCOPE_KEYS = { + COLUMN_NAMES: 'columnNames', + COLUMN_DESCRIPTIONS: 'columnDescriptions', + COLUMN_DATA_TYPES: 'columnDataTypes', + TABLE_DESCRIPTIONS: 'tableDescriptions', + SECURITY_ROLES: 'securityRoles', + RELATIONSHIPS: 'relationships', +} as const; + +export type SearchScopeKey = typeof SEARCH_SCOPE_KEYS[keyof typeof SEARCH_SCOPE_KEYS]; + +export interface SearchScope { + columnNames: boolean; + columnDescriptions: boolean; + columnDataTypes: boolean; + tableDescriptions: boolean; + securityRoles: boolean; + relationships: boolean; +} interface TimeSlicedSearchProps { onSearch: (value: string) => void; @@ -17,8 +37,18 @@ interface TimeSlicedSearchProps { currentIndex?: number; totalResults?: number; placeholder?: string; + onSearchScopeChange?: (scope: SearchScope) => void; } +const DEFAULT_SEARCH_SCOPE: SearchScope = { + columnNames: true, + columnDescriptions: true, + columnDataTypes: false, + tableDescriptions: false, + securityRoles: false, + relationships: false, +}; + // Time-sliced input that maintains 60fps regardless of background work export const TimeSlicedSearch = ({ onSearch, @@ -29,11 +59,14 @@ export const TimeSlicedSearch = ({ currentIndex, totalResults, placeholder = "Search attributes...", + onSearchScopeChange, }: TimeSlicedSearchProps) => { const [localValue, setLocalValue] = useState(initialLocalValue); const [isTyping, setIsTyping] = useState(false); const [portalRoot, setPortalRoot] = useState(null); const [lastValidSearch, setLastValidSearch] = useState(''); + const [searchScope, setSearchScope] = useState(DEFAULT_SEARCH_SCOPE); + const [showAdvanced, setShowAdvanced] = useState(false); const { isOpen } = useSidebar(); const { isSettingsOpen } = useSettings(); const isMobile = useIsMobile(); @@ -45,6 +78,53 @@ export const TimeSlicedSearch = ({ // Hide search on mobile when sidebar is open, or when settings are open const shouldHideSearch = (isMobile && isOpen) || isSettingsOpen; + // Notify parent when search scope changes + useEffect(() => { + onSearchScopeChange?.(searchScope); + }, [searchScope, onSearchScopeChange]); + + // Convert searchScope to array format for ToggleButtonGroup + const scopeToArray = useCallback((scope: SearchScope): SearchScopeKey[] => { + const result: SearchScopeKey[] = []; + if (scope.columnNames) result.push(SEARCH_SCOPE_KEYS.COLUMN_NAMES); + if (scope.columnDescriptions) result.push(SEARCH_SCOPE_KEYS.COLUMN_DESCRIPTIONS); + if (scope.columnDataTypes) result.push(SEARCH_SCOPE_KEYS.COLUMN_DATA_TYPES); + if (scope.tableDescriptions) result.push(SEARCH_SCOPE_KEYS.TABLE_DESCRIPTIONS); + if (scope.securityRoles) result.push(SEARCH_SCOPE_KEYS.SECURITY_ROLES); + if (scope.relationships) result.push(SEARCH_SCOPE_KEYS.RELATIONSHIPS); + return result; + }, []); + + // Convert array format back to searchScope + const arrayToScope = useCallback((arr: SearchScopeKey[]): SearchScope => { + return { + columnNames: arr.includes(SEARCH_SCOPE_KEYS.COLUMN_NAMES), + columnDescriptions: arr.includes(SEARCH_SCOPE_KEYS.COLUMN_DESCRIPTIONS), + columnDataTypes: arr.includes(SEARCH_SCOPE_KEYS.COLUMN_DATA_TYPES), + tableDescriptions: arr.includes(SEARCH_SCOPE_KEYS.TABLE_DESCRIPTIONS), + securityRoles: arr.includes(SEARCH_SCOPE_KEYS.SECURITY_ROLES), + relationships: arr.includes(SEARCH_SCOPE_KEYS.RELATIONSHIPS), + }; + }, []); + + // Handle toggle button group changes + const handleScopeChange = useCallback(( + _event: React.MouseEvent, + newScopes: SearchScopeKey[], + ) => { + if (newScopes.length > 0) { // Ensure at least one scope is selected + setSearchScope(arrayToScope(newScopes)); + } + }, [arrayToScope]); + + const resetScope = useCallback(() => { + setSearchScope(DEFAULT_SEARCH_SCOPE); + }, []); + + const toggleAdvanced = useCallback(() => { + setShowAdvanced(prev => !prev); + }, []); + useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (localValue.length === 0) return; @@ -237,55 +317,136 @@ export const TimeSlicedSearch = ({ }; const searchInput = ( - - - - - - - - - - - - {isTyping && localValue.length >= 3 ? ( - - ) : localValue && totalResults !== undefined && totalResults > 0 ? ( - + + {/* Main Search Bar */} + + + + + + + + + + + {isTyping && localValue.length >= 3 ? ( + + ) : localValue && totalResults !== undefined && totalResults > 0 ? ( + + {currentIndex}/{totalResults} + + ) : null} + + + + + + - {currentIndex}/{totalResults} - - ) : null} - - - - - - - + + + + + + + + + + {/* Advanced Search Section */} + {showAdvanced && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} diff --git a/Website/components/datamodelview/attributes/BooleanAttribute.tsx b/Website/components/datamodelview/attributes/BooleanAttribute.tsx index 2fd68bb..82e44dd 100644 --- a/Website/components/datamodelview/attributes/BooleanAttribute.tsx +++ b/Website/components/datamodelview/attributes/BooleanAttribute.tsx @@ -2,17 +2,18 @@ import { useIsMobile } from "@/hooks/use-mobile"; import { BooleanAttributeType } from "@/lib/Types" import { Box, Typography, Chip } from "@mui/material" import { CheckRounded, RadioButtonCheckedRounded, RadioButtonUncheckedRounded } from "@mui/icons-material"; +import React from "react"; -export default function BooleanAttribute({ attribute }: { attribute: BooleanAttributeType }) { +export default function BooleanAttribute({ attribute, highlightMatch, highlightTerm }: { attribute: BooleanAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { const isMobile = useIsMobile(); - + return ( - Boolean + {highlightMatch && highlightTerm ? highlightMatch("Boolean", highlightTerm) : "Boolean"} {attribute.DefaultValue !== null && !isMobile && ( - } label={`Default: ${attribute.DefaultValue === true ? attribute.TrueLabel : attribute.FalseLabel}`} size="small" @@ -29,7 +30,7 @@ export default function BooleanAttribute({ attribute }: { attribute: BooleanAttr ) : ( )} - {attribute.TrueLabel} + {highlightMatch && highlightTerm ? highlightMatch(attribute.TrueLabel, highlightTerm) : attribute.TrueLabel} )} - {attribute.FalseLabel} + {highlightMatch && highlightTerm ? highlightMatch(attribute.FalseLabel, highlightTerm) : attribute.FalseLabel} string | React.JSX.Element, highlightTerm?: string }) { return ( <> - {attribute.Format} + {highlightMatch && highlightTerm ? highlightMatch(attribute.Format, highlightTerm) : attribute.Format} {" - "} - {attribute.Behavior} + {highlightMatch && highlightTerm ? highlightMatch(attribute.Behavior, highlightTerm) : attribute.Behavior} ) } \ No newline at end of file diff --git a/Website/components/datamodelview/attributes/GenericAttribute.tsx b/Website/components/datamodelview/attributes/GenericAttribute.tsx index 88a74da..de2604a 100644 --- a/Website/components/datamodelview/attributes/GenericAttribute.tsx +++ b/Website/components/datamodelview/attributes/GenericAttribute.tsx @@ -1,6 +1,7 @@ import { GenericAttributeType } from "@/lib/Types"; import { Typography } from "@mui/material"; +import React from "react"; -export default function GenericAttribute({ attribute } : { attribute: GenericAttributeType }) { - return {attribute.Type} +export default function GenericAttribute({ attribute, highlightMatch, highlightTerm } : { attribute: GenericAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { + return {highlightMatch && highlightTerm ? highlightMatch(attribute.Type, highlightTerm) : attribute.Type} } \ No newline at end of file diff --git a/Website/components/datamodelview/attributes/StatusAttribute.tsx b/Website/components/datamodelview/attributes/StatusAttribute.tsx index 425fa41..45aadca 100644 --- a/Website/components/datamodelview/attributes/StatusAttribute.tsx +++ b/Website/components/datamodelview/attributes/StatusAttribute.tsx @@ -2,8 +2,9 @@ import { StatusAttributeType, StatusOption } from "@/lib/Types"; import { formatNumberSeperator } from "@/lib/utils"; import { CircleRounded } from "@mui/icons-material"; import { Box, Typography, Chip } from "@mui/material"; +import React from "react"; -export default function StatusAttribute({ attribute }: { attribute: StatusAttributeType }) { +export default function StatusAttribute({ attribute, highlightMatch, highlightTerm }: { attribute: StatusAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { const groupedOptions = attribute.Options.reduce((acc, option) => { if (!acc[option.State]) { acc[option.State] = []; @@ -15,12 +16,12 @@ export default function StatusAttribute({ attribute }: { attribute: StatusAttrib return ( - State/Status + {highlightMatch && highlightTerm ? highlightMatch("State/Status", highlightTerm) : "State/Status"} {/* No DefaultValue for StatusAttributeType, so no default badge */} {Object.entries(groupedOptions).map(([state, options]) => ( - {state} + {highlightMatch && highlightTerm ? highlightMatch(state, highlightTerm) : state} {options.map(option => ( @@ -29,7 +30,7 @@ export default function StatusAttribute({ attribute }: { attribute: StatusAttrib {/* No DefaultValue, so always show Circle icon */} - {option.Name} + {highlightMatch && highlightTerm ? highlightMatch(option.Name, highlightTerm) : option.Name} diff --git a/Website/components/datamodelview/attributes/StringAttribute.tsx b/Website/components/datamodelview/attributes/StringAttribute.tsx index ce533c7..6d26e78 100644 --- a/Website/components/datamodelview/attributes/StringAttribute.tsx +++ b/Website/components/datamodelview/attributes/StringAttribute.tsx @@ -3,14 +3,15 @@ import { StringAttributeType } from "@/lib/Types"; import { formatNumberSeperator } from "@/lib/utils"; import { Typography } from "@mui/material"; +import React from "react"; -export default function StringAttribute({ attribute } : { attribute: StringAttributeType }) { +export default function StringAttribute({ attribute, highlightMatch, highlightTerm } : { attribute: StringAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { return ( <> - Text + {highlightMatch && highlightTerm ? highlightMatch("Text", highlightTerm) : "Text"} {" "} - ({formatNumberSeperator(attribute.MaxLength)}){attribute.Format !== "Text" ? ` - ${attribute.Format}` : ""} + ({formatNumberSeperator(attribute.MaxLength)}){attribute.Format !== "Text" ? ` - ${highlightMatch && highlightTerm ? highlightMatch(attribute.Format, highlightTerm) : attribute.Format}` : ""} ); diff --git a/Website/components/datamodelview/entity/SecurityRoles.tsx b/Website/components/datamodelview/entity/SecurityRoles.tsx index d704d0a..3ddb40e 100644 --- a/Website/components/datamodelview/entity/SecurityRoles.tsx +++ b/Website/components/datamodelview/entity/SecurityRoles.tsx @@ -3,18 +3,19 @@ import { SecurityRole, PrivilegeDepth } from "@/lib/Types"; import { AccountTreeRounded, BlockRounded, BusinessRounded, PeopleRounded, PersonRounded, RemoveRounded } from "@mui/icons-material"; import { Tooltip, Box, Typography, Paper, useTheme } from "@mui/material"; +import React from "react"; -export function SecurityRoles({ roles }: { roles: SecurityRole[] }) { +export function SecurityRoles({ roles, highlightMatch, highlightTerm }: { roles: SecurityRole[], highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { return ( {roles.map(role => ( - + ))} ); } -function SecurityRoleRow({ role }: { role: SecurityRole }) { +function SecurityRoleRow({ role, highlightMatch, highlightTerm }: { role: SecurityRole, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { const theme = useTheme(); return ( @@ -43,7 +44,7 @@ function SecurityRoleRow({ role }: { role: SecurityRole }) { color: 'text.primary' }} > - {role.Name} + {highlightMatch && highlightTerm ? highlightMatch(role.Name, highlightTerm) : role.Name} ; + searchScope?: SearchScope; } type WorkerMessage = InitMessage | SearchMessage | string; @@ -55,6 +65,21 @@ self.onmessage = async function (e: MessageEvent) { // Handle search const search = (typeof e.data === 'string' ? e.data : e.data?.data || '').trim().toLowerCase(); const entityFilters: Record = (typeof e.data === 'object' && 'entityFilters' in e.data) ? e.data.entityFilters || {} : {}; + const searchScope: SearchScope = (typeof e.data === 'object' && 'searchScope' in e.data) ? e.data.searchScope || { + columnNames: true, + columnDescriptions: true, + columnDataTypes: false, + tableDescriptions: false, + securityRoles: false, + relationships: false, + } : { + columnNames: true, + columnDescriptions: true, + columnDataTypes: false, + tableDescriptions: false, + securityRoles: false, + relationships: false, + }; if (!search) { const response: WorkerResponse = { type: 'results', data: [], complete: true }; @@ -103,23 +128,87 @@ self.onmessage = async function (e: MessageEvent) { } } - // Apply search matching - const basicMatch = attr.SchemaName.toLowerCase().includes(search) || - (attr.DisplayName && attr.DisplayName.toLowerCase().includes(search)) || - (attr.Description && attr.Description.toLowerCase().includes(search)); - let optionsMatch = false; - if (attr.AttributeType === 'ChoiceAttribute' || attr.AttributeType === 'StatusAttribute') { - optionsMatch = attr.Options.some(option => option.Name.toLowerCase().includes(search)); + // Apply search matching based on scope + let matches = false; + + // Column names (SchemaName and DisplayName) + if (searchScope.columnNames) { + if (attr.SchemaName.toLowerCase().includes(search)) matches = true; + if (attr.DisplayName && attr.DisplayName.toLowerCase().includes(search)) matches = true; + } + + // Column descriptions + if (searchScope.columnDescriptions) { + if (attr.Description && attr.Description.toLowerCase().includes(search)) matches = true; } - return basicMatch || optionsMatch; + // Column data types + if (searchScope.columnDataTypes) { + if (attr.AttributeType.toLowerCase().includes(search)) matches = true; + + // Also search in specific type properties + if (attr.AttributeType === 'ChoiceAttribute' || attr.AttributeType === 'StatusAttribute') { + if (attr.Options.some(option => option.Name.toLowerCase().includes(search))) matches = true; + } else if (attr.AttributeType === 'DateTimeAttribute') { + if (attr.Format.toLowerCase().includes(search) || attr.Behavior.toLowerCase().includes(search)) matches = true; + } else if (attr.AttributeType === 'IntegerAttribute') { + if (attr.Format.toLowerCase().includes(search)) matches = true; + } else if (attr.AttributeType === 'StringAttribute') { + if (attr.Format.toLowerCase().includes(search)) matches = true; + } else if (attr.AttributeType === 'DecimalAttribute') { + if (attr.Type.toLowerCase().includes(search)) matches = true; + } else if (attr.AttributeType === 'LookupAttribute') { + if (attr.Targets.some(target => target.Name.toLowerCase().includes(search))) matches = true; + } else if (attr.AttributeType === 'BooleanAttribute') { + if (attr.TrueLabel.toLowerCase().includes(search) || attr.FalseLabel.toLowerCase().includes(search)) matches = true; + } + } + + return matches; }); - // If we have matching attributes, add the entity first (for sidebar) then the attributes - if (matchingAttributes.length > 0) { + // Check for table description matches + let tableDescriptionMatches = false; + if (searchScope.tableDescriptions) { + if (entity.Description && entity.Description.toLowerCase().includes(search)) { + tableDescriptionMatches = true; + } + if (entity.DisplayName && entity.DisplayName.toLowerCase().includes(search)) { + tableDescriptionMatches = true; + } + if (entity.SchemaName.toLowerCase().includes(search)) { + tableDescriptionMatches = true; + } + } + + // Check for security role matches + let securityRoleMatches = false; + if (searchScope.securityRoles && entity.SecurityRoles) { + securityRoleMatches = entity.SecurityRoles.some(role => + role.Name.toLowerCase().includes(search) || role.LogicalName.toLowerCase().includes(search) + ); + } + + // Check for relationship matches + let relationshipMatches = false; + if (searchScope.relationships && entity.Relationships) { + relationshipMatches = entity.Relationships.some(rel => + rel.Name.toLowerCase().includes(search) || + rel.TableSchema.toLowerCase().includes(search) || + rel.RelationshipSchema.toLowerCase().includes(search) || + (rel.LookupDisplayName && rel.LookupDisplayName.toLowerCase().includes(search)) + ); + } + + // If we have any matches, add the entity + const hasMatches = matchingAttributes.length > 0 || tableDescriptionMatches || securityRoleMatches || relationshipMatches; + + if (hasMatches) { if (!groupUsed) allItems.push({ type: 'group', group }); groupUsed = true; allItems.push({ type: 'entity', group, entity }); + + // Add matching attributes for (const attr of matchingAttributes) { allItems.push({ type: 'attribute', group, entity, attribute: attr }); } From 46d088e203d7b57fb3813d43fb4f2241bc28f457 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 5 Jan 2026 19:26:06 +0100 Subject: [PATCH 2/8] fix: stop some of the unnessecary stale search request --- .../datamodelview/DatamodelView.tsx | 40 +++++++++---------- .../components/datamodelview/searchWorker.ts | 11 +++-- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index d92d97d..0d12242 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -40,6 +40,7 @@ function DatamodelViewContent() { const workerRef = useRef(null); const [currentSearchIndex, setCurrentSearchIndex] = 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({ columnNames: true, columnDescriptions: true, @@ -66,6 +67,10 @@ function DatamodelViewContent() { const handleSearch = useCallback((searchValue: string) => { 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 const filtersObject: Record = {}; entityFilters.forEach((filter, entitySchemaName) => { @@ -76,7 +81,8 @@ function DatamodelViewContent() { type: 'search', data: searchValue, entityFilters: filtersObject, - searchScope: searchScope + searchScope: searchScope, + requestId: currentRequestId // Send request ID to worker }); } else { // Clear search - reset to show all groups @@ -135,28 +141,13 @@ function DatamodelViewContent() { ); }, [filtered]); - // Cached sorted results - only re-sort when attribute results change - const [cachedSortedResults, setCachedSortedResults] = useState>([]); - - // Update cached sorted results when attribute results change - useEffect(() => { - if (attributeResults.length > 0) { - // Wait a bit for DOM to settle, then sort and cache - const timeoutId = setTimeout(() => { - const sorted = sortResultsByYPosition([...attributeResults]); - setCachedSortedResults(sorted); - }, 200); - - return () => clearTimeout(timeoutId); - } else { - setCachedSortedResults([]); - } - }, [attributeResults, sortResultsByYPosition]); - - // Helper function to get sorted attribute results + // Helper function to get sorted attribute results on-demand (lazy sorting) + // This prevents blocking the main thread during typing - sorting only happens during navigation const getSortedAttributeResults = useCallback(() => { - return cachedSortedResults; - }, [cachedSortedResults]); + if (attributeResults.length === 0) return []; + // Sort on-demand when needed for navigation + return sortResultsByYPosition([...attributeResults]); + }, [attributeResults, sortResultsByYPosition]); // Navigation handlers const handleNavigateNext = useCallback(() => { @@ -242,6 +233,11 @@ function DatamodelViewContent() { const handleMessage = (e: MessageEvent) => { const message = e.data; + // Ignore stale search results + if (message.requestId && message.requestId < searchRequestIdRef.current) { + return; // Discard results from outdated searches + } + if (message.type === 'started') { datamodelDispatch({ type: "SET_LOADING", payload: true }); // setSearchProgress(0); diff --git a/Website/components/datamodelview/searchWorker.ts b/Website/components/datamodelview/searchWorker.ts index ca9ee87..550832c 100644 --- a/Website/components/datamodelview/searchWorker.ts +++ b/Website/components/datamodelview/searchWorker.ts @@ -25,6 +25,7 @@ interface SearchMessage { data: string; entityFilters?: Record; searchScope?: SearchScope; + requestId?: number; } type WorkerMessage = InitMessage | SearchMessage | string; @@ -38,10 +39,12 @@ interface ResultsMessage { >; complete: boolean; progress?: number; + requestId?: number; } interface StartedMessage { type: 'started'; + requestId?: number; } type WorkerResponse = ResultsMessage | StartedMessage; @@ -65,6 +68,7 @@ self.onmessage = async function (e: MessageEvent) { // Handle search const search = (typeof e.data === 'string' ? e.data : e.data?.data || '').trim().toLowerCase(); const entityFilters: Record = (typeof e.data === 'object' && 'entityFilters' in e.data) ? e.data.entityFilters || {} : {}; + const requestId = (typeof e.data === 'object' && 'requestId' in e.data) ? e.data.requestId : undefined; const searchScope: SearchScope = (typeof e.data === 'object' && 'searchScope' in e.data) ? e.data.searchScope || { columnNames: true, columnDescriptions: true, @@ -82,13 +86,13 @@ self.onmessage = async function (e: MessageEvent) { }; if (!search) { - const response: WorkerResponse = { type: 'results', data: [], complete: true }; + const response: WorkerResponse = { type: 'results', data: [], complete: true, requestId }; self.postMessage(response); return; } // First quickly send back a "started" message - const startedMessage: WorkerResponse = { type: 'started' }; + const startedMessage: WorkerResponse = { type: 'started', requestId }; self.postMessage(startedMessage); const allItems: Array< @@ -225,7 +229,8 @@ self.onmessage = async function (e: MessageEvent) { type: 'results', data: chunk, complete: isLastChunk, - progress: Math.min(100, Math.round((i + CHUNK_SIZE) / allItems.length * 100)) + progress: Math.min(100, Math.round((i + CHUNK_SIZE) / allItems.length * 100)), + requestId }; self.postMessage(response); From 32058e282f0d124a3b2d9559577b01e046f58206 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 5 Jan 2026 22:11:25 +0100 Subject: [PATCH 3/8] feat: auto open tab and scroll to new relationship search result type --- .../datamodelview/DatamodelView.tsx | 229 ++++++++++++------ Website/components/datamodelview/List.tsx | 28 ++- .../datamodelview/Relationships.tsx | 11 +- Website/components/datamodelview/Section.tsx | 15 +- .../components/datamodelview/searchWorker.ts | 43 +++- Website/contexts/DatamodelDataContext.tsx | 3 +- Website/contexts/DatamodelViewContext.tsx | 7 +- 7 files changed, 243 insertions(+), 93 deletions(-) diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index 0d12242..b0c0061 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -9,14 +9,15 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react" import { useDatamodelData, useDatamodelDataDispatch } from "@/contexts/DatamodelDataContext"; import { updateURL } from "@/lib/url-utils"; import { useSearchParams } from "next/navigation"; -import { AttributeType, EntityType, GroupType } from "@/lib/Types"; +import { AttributeType, EntityType, GroupType, RelationshipType } from "@/lib/Types"; import { useEntityFilters } from "@/contexts/EntityFiltersContext"; // Type for search results type SearchResultItem = | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } - | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType }; + | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } + | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType }; export function DatamodelView() { const { setElement, expand } = useSidebar(); @@ -32,7 +33,7 @@ export function DatamodelView() { } function DatamodelViewContent() { - const { scrollToSection, scrollToAttribute, restoreSection } = useDatamodelView(); + const { scrollToSection, scrollToAttribute, scrollToRelationship, restoreSection } = useDatamodelView(); const datamodelDispatch = useDatamodelViewDispatch(); const { groups, filtered, search } = useDatamodelData(); const datamodelDataDispatch = useDatamodelDataDispatch(); @@ -49,15 +50,31 @@ function DatamodelViewContent() { securityRoles: false, relationships: false, }); + // Track which tab should be active for each entity during search navigation + const [entityActiveTabs, setEntityActiveTabs] = useState>(new Map()); - // Calculate total search results (prioritize attributes, fallback to entities) + // Helper function to get the tab index for a given type + const getTabIndexForType = useCallback((entity: EntityType, type: 'attribute' | 'relationship') => { + // Tab 0 is always Attributes + if (type === 'attribute') return 0; + + // Tab 1 is Relationships if they exist, otherwise it would be Keys + if (type === 'relationship' && entity.Relationships.length > 0) return 1; + + return 0; // fallback to attributes + }, []); + + // Calculate total search results (count attributes and relationships) const totalResults = useMemo(() => { if (filtered.length === 0) return 0; const attributeCount = filtered.filter(item => item.type === 'attribute').length; - if (attributeCount > 0) return attributeCount; + const relationshipCount = filtered.filter(item => item.type === 'relationship').length; + const itemCount = attributeCount + relationshipCount; + + if (itemCount > 0) return itemCount; - // If no attributes, count entity-level matches (for security roles, relationships, table descriptions) + // If no attributes or relationships, count entity-level matches (for security roles, table descriptions) const entityCount = filtered.filter(item => item.type === 'entity').length; return entityCount; }, [filtered]); @@ -115,39 +132,78 @@ function DatamodelViewContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchScope]); // Only trigger on searchScope change, not handleSearch to avoid infinite loop - // Helper function to sort results by their Y position on the page - const sortResultsByYPosition = useCallback((results: Array<{ type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType }>) => { - return results.sort((a, b) => { - // Get the actual DOM elements for attributes - const elementA = document.getElementById(`attr-${a.entity.SchemaName}-${a.attribute.SchemaName}`); - const elementB = document.getElementById(`attr-${b.entity.SchemaName}-${b.attribute.SchemaName}`); - - // If both elements are found, compare their Y positions - if (elementA && elementB) { - const rectA = elementA.getBoundingClientRect(); - const rectB = elementB.getBoundingClientRect(); - return rectA.top - rectB.top; + // Helper function to get sorted combined results (attributes + relationships) on-demand + // This prevents blocking the main thread during typing - sorting only happens during navigation + const getSortedCombinedResults = useCallback(() => { + const combinedResults = filtered.filter((item): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } => + item.type === 'attribute' || item.type === 'relationship' + ); + + if (combinedResults.length === 0) return []; + + // Deduplicate results - use a Set with unique keys + const seen = new Set(); + const deduplicatedResults = combinedResults.filter(item => { + const key = item.type === 'attribute' + ? `attr-${item.entity.SchemaName}-${item.attribute.SchemaName}` + : `rel-${item.entity.SchemaName}-${item.relationship.RelationshipSchema}`; + + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + // Group results by entity to keep them together + const resultsByEntity = new Map>(); + + for (const result of deduplicatedResults) { + const entityKey = result.entity.SchemaName; + if (!resultsByEntity.has(entityKey)) { + resultsByEntity.set(entityKey, []); + } + resultsByEntity.get(entityKey)!.push(result); + } + + // Sort entities by the Y position of their first attribute (or use first result if no attributes) + const sortedEntities = Array.from(resultsByEntity.entries()).sort((a, b) => { + const [, resultsA] = a; + const [, resultsB] = b; + + // Find first attribute for each entity + const firstAttrA = resultsA.find(r => r.type === 'attribute') as { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | undefined; + const firstAttrB = resultsB.find(r => r.type === 'attribute') as { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | undefined; + + // If both have attributes, compare by Y position + if (firstAttrA && firstAttrB) { + const elementA = document.getElementById(`attr-${firstAttrA.entity.SchemaName}-${firstAttrA.attribute.SchemaName}`); + const elementB = document.getElementById(`attr-${firstAttrB.entity.SchemaName}-${firstAttrB.attribute.SchemaName}`); + + if (elementA && elementB) { + const rectA = elementA.getBoundingClientRect(); + const rectB = elementB.getBoundingClientRect(); + return rectA.top - rectB.top; + } } - // Fallback: if elements can't be found, maintain original order + // Fallback: maintain original order return 0; }); - }, []); - // Get attribute results (not sorted initially) - const attributeResults = useMemo(() => { - return filtered.filter((item): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => - item.type === 'attribute' - ); - }, [filtered]); + // Flatten back to array, keeping attributes before relationships within each entity + const result: Array<{ type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType }> = []; + for (const [, entityResults] of sortedEntities) { + // Separate attributes and relationships for this entity + const attributes = entityResults.filter((r): r is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => r.type === 'attribute'); + const relationships = entityResults.filter((r): r is { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } => r.type === 'relationship'); - // Helper function to get sorted attribute results on-demand (lazy sorting) - // This prevents blocking the main thread during typing - sorting only happens during navigation - const getSortedAttributeResults = useCallback(() => { - if (attributeResults.length === 0) return []; - // Sort on-demand when needed for navigation - return sortResultsByYPosition([...attributeResults]); - }, [attributeResults, sortResultsByYPosition]); + // Add all attributes first (in their original order), then relationships + result.push(...attributes, ...relationships); + } + + return result; + }, [filtered]); // Navigation handlers const handleNavigateNext = useCallback(() => { @@ -155,21 +211,29 @@ function DatamodelViewContent() { const nextIndex = currentSearchIndex + 1; setCurrentSearchIndex(nextIndex); - // Get sorted attribute results - const sortedAttributeResults = getSortedAttributeResults(); + // Get sorted combined results (attributes sorted by Y position, relationships in original order) + const combinedResults = getSortedCombinedResults(); - // If we have attribute results, use them - if (sortedAttributeResults.length > 0) { - const nextResult = sortedAttributeResults[nextIndex - 1]; + if (combinedResults.length > 0) { + const nextResult = combinedResults[nextIndex - 1]; if (nextResult) { datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: nextResult.entity.SchemaName }); datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: nextResult.group.Name }); - // Always scroll to the attribute since we only have attribute results - scrollToAttribute(nextResult.entity.SchemaName, nextResult.attribute.SchemaName); + // Set the active tab based on result type + const tabIndex = getTabIndexForType(nextResult.entity, nextResult.type === 'attribute' ? 'attribute' : 'relationship'); + setEntityActiveTabs(prev => new Map(prev).set(nextResult.entity.SchemaName, tabIndex)); + + // Scroll to the appropriate element + if (nextResult.type === 'attribute') { + scrollToAttribute(nextResult.entity.SchemaName, nextResult.attribute.SchemaName); + } else { + // For relationships, scroll to the specific relationship + scrollToRelationship(nextResult.entity.SchemaName, nextResult.relationship.RelationshipSchema); + } } } else { - // Fallback to entity results if no attributes found (e.g., searching by entity name) + // Fallback to entity results if no attributes/relationships found (e.g., searching by entity name) const entityResults = filtered.filter((item): item is { type: 'entity'; group: GroupType; entity: EntityType } => item.type === 'entity' ); @@ -182,27 +246,36 @@ function DatamodelViewContent() { } } } - }, [currentSearchIndex, totalResults, getSortedAttributeResults, filtered, datamodelDispatch, scrollToAttribute, scrollToSection]); + }, [currentSearchIndex, totalResults, getSortedCombinedResults, filtered, datamodelDispatch, scrollToAttribute, scrollToRelationship, scrollToSection, getTabIndexForType]); const handleNavigatePrevious = useCallback(() => { if (currentSearchIndex > 1) { const prevIndex = currentSearchIndex - 1; setCurrentSearchIndex(prevIndex); - // Get sorted attribute results - const sortedAttributeResults = getSortedAttributeResults(); + // Get sorted combined results (attributes sorted by Y position, relationships in original order) + const combinedResults = getSortedCombinedResults(); - // If we have attribute results, use them - if (sortedAttributeResults.length > 0) { - const prevResult = sortedAttributeResults[prevIndex - 1]; + if (combinedResults.length > 0) { + const prevResult = combinedResults[prevIndex - 1]; if (prevResult) { datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: prevResult.entity.SchemaName }); datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: prevResult.group.Name }); - // Always scroll to the attribute since we only have attribute results - scrollToAttribute(prevResult.entity.SchemaName, prevResult.attribute.SchemaName); + + // Set the active tab based on result type + const tabIndex = getTabIndexForType(prevResult.entity, prevResult.type === 'attribute' ? 'attribute' : 'relationship'); + setEntityActiveTabs(prev => new Map(prev).set(prevResult.entity.SchemaName, tabIndex)); + + // Scroll to the appropriate element + if (prevResult.type === 'attribute') { + scrollToAttribute(prevResult.entity.SchemaName, prevResult.attribute.SchemaName); + } else { + // For relationships, scroll to the specific relationship + scrollToRelationship(prevResult.entity.SchemaName, prevResult.relationship.RelationshipSchema); + } } } else { - // Fallback to entity results if no attributes found (e.g., searching by entity name) + // Fallback to entity results if no attributes/relationships found (e.g., searching by entity name) const entityResults = filtered.filter((item): item is { type: 'entity'; group: GroupType; entity: EntityType } => item.type === 'entity' ); @@ -215,7 +288,7 @@ function DatamodelViewContent() { } } } - }, [currentSearchIndex, getSortedAttributeResults, filtered, datamodelDispatch, scrollToAttribute, scrollToSection]); + }, [currentSearchIndex, getSortedCombinedResults, filtered, datamodelDispatch, scrollToAttribute, scrollToRelationship, scrollToSection, getTabIndexForType]); useEffect(() => { if (!workerRef.current) { @@ -259,24 +332,31 @@ function DatamodelViewContent() { if (message.complete) { datamodelDispatch({ type: "SET_LOADING", payload: false }); // Set to first result if we have any and auto-navigate to it - // Prioritize attributes, fallback to entities - const attributeResults = accumulatedResultsRef.current.filter((item): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => - item.type === 'attribute' + // Get combined attribute and relationship results + const combinedResults = accumulatedResultsRef.current.filter((item): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } => + item.type === 'attribute' || item.type === 'relationship' ); - if (attributeResults.length > 0) { + if (combinedResults.length > 0) { setCurrentSearchIndex(1); - // Use the first result from the array (will be sorted when user navigates) - const firstResult = attributeResults[0]; + const firstResult = combinedResults[0]; datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstResult.entity.SchemaName }); datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstResult.group.Name }); + + // Set the active tab based on result type + const tabIndex = getTabIndexForType(firstResult.entity, firstResult.type === 'attribute' ? 'attribute' : 'relationship'); + setEntityActiveTabs(prev => new Map(prev).set(firstResult.entity.SchemaName, tabIndex)); + // Small delay to ensure virtual list is ready setTimeout(() => { - // Always scroll to attribute since we have attribute results - scrollToAttribute(firstResult.entity.SchemaName, firstResult.attribute.SchemaName); + if (firstResult.type === 'attribute') { + scrollToAttribute(firstResult.entity.SchemaName, firstResult.attribute.SchemaName); + } else { + scrollToRelationship(firstResult.entity.SchemaName, firstResult.relationship.RelationshipSchema); + } }, 100); } else { - // Fallback to entity results + // Fallback to entity results if no attributes/relationships found const entityResults = accumulatedResultsRef.current.filter((item): item is { type: 'entity'; group: GroupType; entity: EntityType } => item.type === 'entity' ); @@ -298,23 +378,32 @@ function DatamodelViewContent() { const messageData = message as SearchResultItem[]; datamodelDataDispatch({ type: "SET_FILTERED", payload: messageData }); datamodelDispatch({ type: "SET_LOADING", payload: false }); - // Set to first result if we have any and auto-navigate to it - prioritize attributes - const attributeResults = messageData.filter((item): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => - item.type === 'attribute' + // Set to first result if we have any and auto-navigate to it + // Get combined attribute and relationship results + const combinedResults = messageData.filter((item): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } => + item.type === 'attribute' || item.type === 'relationship' ); - if (attributeResults.length > 0) { + if (combinedResults.length > 0) { setCurrentSearchIndex(1); - const firstResult = attributeResults[0]; + const firstResult = combinedResults[0]; datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstResult.entity.SchemaName }); datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstResult.group.Name }); + + // Set the active tab based on result type + const tabIndex = getTabIndexForType(firstResult.entity, firstResult.type === 'attribute' ? 'attribute' : 'relationship'); + setEntityActiveTabs(prev => new Map(prev).set(firstResult.entity.SchemaName, tabIndex)); + // Small delay to ensure virtual list is ready setTimeout(() => { - // Always scroll to attribute since we have attribute results - scrollToAttribute(firstResult.entity.SchemaName, firstResult.attribute.SchemaName); + if (firstResult.type === 'attribute') { + scrollToAttribute(firstResult.entity.SchemaName, firstResult.attribute.SchemaName); + } else { + scrollToRelationship(firstResult.entity.SchemaName, firstResult.relationship.RelationshipSchema); + } }, 100); } else { - // Fallback to entity results + // Fallback to entity results if no attributes/relationships found const entityResults = messageData.filter((item): item is { type: 'entity'; group: GroupType; entity: EntityType } => item.type === 'entity' ); @@ -334,7 +423,7 @@ function DatamodelViewContent() { worker.addEventListener("message", handleMessage); return () => worker.removeEventListener("message", handleMessage); - }, [datamodelDispatch, datamodelDataDispatch, groups, scrollToSection, scrollToAttribute]); + }, [datamodelDispatch, datamodelDataDispatch, groups, scrollToSection, scrollToAttribute, scrollToRelationship, getTabIndexForType]); if (!groups) { return ( @@ -378,7 +467,7 @@ function DatamodelViewContent() { totalResults={totalResults} onSearchScopeChange={handleSearchScopeChange} /> - + ); diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 7301919..92c77b8 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -12,6 +12,7 @@ import { Box, CircularProgress, debounce, Tooltip } from '@mui/material'; interface IListProps { setCurrentIndex: (index: number) => void; + entityActiveTabs: Map; } // Helper to highlight search matches @@ -22,7 +23,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 }: IListProps) => { +export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { const dispatch = useDatamodelViewDispatch(); const { currentSection, loadingSection } = useDatamodelView(); const { groups, filtered, search } = useDatamodelData(); @@ -43,7 +44,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { // Only recalculate items when filtered or search changes const flatItems = useMemo(() => { - if (filtered && filtered.length > 0) return filtered.filter(item => item.type !== 'attribute'); + if (filtered && filtered.length > 0) return filtered.filter(item => item.type !== 'attribute' && item.type !== 'relationship'); const lowerSearch = search.trim().toLowerCase(); const items: Array< @@ -192,6 +193,25 @@ export const List = ({ setCurrentIndex }: IListProps) => { } }, [scrollToSection]); + const scrollToRelationship = useCallback((sectionId: string, relSchema: string) => { + const relId = `rel-${sectionId}-${relSchema}`; + const relationshipLocation = document.getElementById(relId); + + if (relationshipLocation) { + // Relationship is already rendered, scroll directly to it + relationshipLocation.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + // Relationship not found, need to scroll to section first + scrollToSection(sectionId); + setTimeout(() => { + const relationshipLocationAfterScroll = document.getElementById(relId); + if (relationshipLocationAfterScroll) { + relationshipLocationAfterScroll.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); + } + }, [scrollToSection]); + const scrollToGroup = useCallback((groupName: string) => { const groupIndex = flatItems.findIndex(item => item.type === 'group' && item.group.Name === groupName @@ -214,9 +234,10 @@ export const List = ({ setCurrentIndex }: IListProps) => { useEffect(() => { dispatch({ type: 'SET_SCROLL_TO_SECTION', payload: scrollToSection }); dispatch({ type: 'SET_SCROLL_TO_ATTRIBUTE', payload: scrollToAttribute }); + dispatch({ type: 'SET_SCROLL_TO_RELATIONSHIP', payload: scrollToRelationship }); dispatch({ type: 'SET_SCROLL_TO_GROUP', payload: scrollToGroup }); dispatch({ type: 'SET_RESTORE_SECTION', payload: restoreSection }); - }, [dispatch, scrollToSection, scrollToAttribute, scrollToGroup]); + }, [dispatch, scrollToSection, scrollToAttribute, scrollToRelationship, scrollToGroup, restoreSection]); const smartScrollToIndex = useCallback((index: number) => { rowVirtualizer.scrollToIndex(index, { align: 'start' }); @@ -301,6 +322,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { entity={item.entity} group={item.group} search={search} + activeTab={entityActiveTabs.get(item.entity.SchemaName)} /> )} diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 94e1088..3e1694a 100644 --- a/Website/components/datamodelview/Relationships.tsx +++ b/Website/components/datamodelview/Relationships.tsx @@ -393,6 +393,7 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe {sortedRelationships.map((relationship, index) => - {highlightMatch(relationship.Name, highlightTerm)} + {relationship.Name} {isEntityInSolution(relationship.TableSchema) ? ( @@ -430,13 +431,13 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe } }} > - {highlightMatch(relationship.TableSchema, highlightTerm)} + {relationship.TableSchema} ) : ( } - label={highlightMatch(relationship.TableSchema, highlightTerm)} + label={relationship.TableSchema} size="small" disabled sx={{ @@ -452,7 +453,7 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe )} - {relationship.LookupDisplayName} + {highlightMatch(relationship.LookupDisplayName, highlightTerm)} {relationship.RelationshipType} @@ -461,7 +462,7 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe - {relationship.RelationshipSchema} + {highlightMatch(relationship.RelationshipSchema, highlightTerm)} {relationship.IntersectEntitySchemaName && (Intersecting table: {relationship.IntersectEntitySchemaName})} diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index 08adfbe..600691a 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -16,11 +16,19 @@ interface ISectionProps { entity: EntityType; group: GroupType; search?: string; + activeTab?: number; // External tab control for search navigation } export const Section = React.memo( - ({ entity, group, search }: ISectionProps) => { + ({ entity, group, search, activeTab }: ISectionProps) => { const [tab, setTab] = React.useState(0); + + // Update local tab state when external activeTab prop changes + React.useEffect(() => { + if (activeTab !== undefined) { + setTab(activeTab); + } + }, [activeTab]); const [visibleAttributeCount, setVisibleAttributeCount] = React.useState(entity.Attributes.length); const [visibleRelationshipCount, setVisibleRelationshipCount] = React.useState(entity.Relationships.length); @@ -115,10 +123,11 @@ export const Section = React.memo( }, // Custom comparison function to prevent unnecessary re-renders (prevProps, nextProps) => { - // Only re-render if entity, search or group changes + // Only re-render if entity, search, group, or activeTab changes return prevProps.entity.SchemaName === nextProps.entity.SchemaName && prevProps.search === nextProps.search && - prevProps.group.Name === nextProps.group.Name; + prevProps.group.Name === nextProps.group.Name && + prevProps.activeTab === nextProps.activeTab; } ); diff --git a/Website/components/datamodelview/searchWorker.ts b/Website/components/datamodelview/searchWorker.ts index 550832c..b9051ad 100644 --- a/Website/components/datamodelview/searchWorker.ts +++ b/Website/components/datamodelview/searchWorker.ts @@ -1,4 +1,4 @@ -import { GroupType, EntityType, AttributeType } from "@/lib/Types"; +import { GroupType, EntityType, AttributeType, RelationshipType } from "@/lib/Types"; // Worker message types interface InitMessage { @@ -36,6 +36,7 @@ interface ResultsMessage { | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } + | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } >; complete: boolean; progress?: number; @@ -99,6 +100,7 @@ self.onmessage = async function (e: MessageEvent) { | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } + | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } > = []; //////////////////////////////////////////////// @@ -193,16 +195,32 @@ self.onmessage = async function (e: MessageEvent) { ); } - // Check for relationship matches - let relationshipMatches = false; - if (searchScope.relationships && entity.Relationships) { - relationshipMatches = entity.Relationships.some(rel => - rel.Name.toLowerCase().includes(search) || - rel.TableSchema.toLowerCase().includes(search) || - rel.RelationshipSchema.toLowerCase().includes(search) || - (rel.LookupDisplayName && rel.LookupDisplayName.toLowerCase().includes(search)) - ); + // Check for relationship matches and collect matching relationships + const matchingRelationships = []; + if (searchScope.relationships && entity.Relationships && groups) { + // Helper function to check if an entity is in the solution (exists in groups) + const isEntityInSolution = (entitySchemaName: string): boolean => { + return groups!.some(group => + group.Entities.some(e => e.SchemaName === entitySchemaName) + ); + }; + + for (const rel of entity.Relationships) { + // Apply same default filters as the Relationships component: + // 1. Hide implicit relationships by default (only show IsExplicit === true) + // 2. Hide relationships to tables not in solution + if (!rel.IsExplicit) continue; + if (!isEntityInSolution(rel.TableSchema)) continue; + + if ( + rel.RelationshipSchema.toLowerCase().includes(search) || + (rel.LookupDisplayName && rel.LookupDisplayName.toLowerCase().includes(search)) + ) { + matchingRelationships.push(rel); + } + } } + const relationshipMatches = matchingRelationships.length > 0; // If we have any matches, add the entity const hasMatches = matchingAttributes.length > 0 || tableDescriptionMatches || securityRoleMatches || relationshipMatches; @@ -216,6 +234,11 @@ self.onmessage = async function (e: MessageEvent) { for (const attr of matchingAttributes) { allItems.push({ type: 'attribute', group, entity, attribute: attr }); } + + // Add matching relationships + for (const rel of matchingRelationships) { + allItems.push({ type: 'relationship', group, entity, relationship: rel }); + } } } } diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index 12e1469..0b78cc9 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -1,7 +1,7 @@ 'use client' import React, { createContext, useContext, useReducer, ReactNode } from "react"; -import { AttributeType, EntityType, GroupType, SolutionWarningType } from "@/lib/Types"; +import { AttributeType, EntityType, GroupType, RelationshipType, SolutionWarningType } from "@/lib/Types"; import { useSearchParams } from "next/navigation"; interface DataModelAction { @@ -18,6 +18,7 @@ interface DatamodelDataState extends DataModelAction { | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } + | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } >; } diff --git a/Website/contexts/DatamodelViewContext.tsx b/Website/contexts/DatamodelViewContext.tsx index 744b5c4..1a7cda2 100644 --- a/Website/contexts/DatamodelViewContext.tsx +++ b/Website/contexts/DatamodelViewContext.tsx @@ -10,6 +10,7 @@ export interface DatamodelViewState { scrollToSection: (sectionId: string) => void; scrollToGroup: (groupName: string) => void; scrollToAttribute: (sectionId: string, attrSchema: string) => void; + scrollToRelationship: (sectionId: string, relSchema: string) => void; loading: boolean; loadingSection: string | null; restoreSection: () => void; @@ -21,6 +22,7 @@ const initialState: DatamodelViewState = { scrollToSection: () => { throw new Error("scrollToSection not initialized yet!"); }, scrollToGroup: () => { throw new Error("scrollToGroup not initialized yet!"); }, scrollToAttribute: () => { throw new Error("scrollToAttribute not initialized yet!"); }, + scrollToRelationship: () => { throw new Error("scrollToRelationship not initialized yet!"); }, loading: true, loadingSection: null, restoreSection: () => { throw new Error("restoreSection not initialized yet!"); }, @@ -34,7 +36,8 @@ type DatamodelViewAction = | { type: 'SET_LOADING', payload: boolean } | { type: 'SET_LOADING_SECTION', payload: string | null } | { type: 'SET_RESTORE_SECTION', payload: () => void } - | { type: 'SET_SCROLL_TO_ATTRIBUTE', payload: (sectionId: string, attrSchema: string) => void }; + | { type: 'SET_SCROLL_TO_ATTRIBUTE', payload: (sectionId: string, attrSchema: string) => void } + | { type: 'SET_SCROLL_TO_RELATIONSHIP', payload: (sectionId: string, relSchema: string) => void }; const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAction): DatamodelViewState => { @@ -55,6 +58,8 @@ const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAc return { ...state, restoreSection: action.payload } case 'SET_SCROLL_TO_ATTRIBUTE': return { ...state, scrollToAttribute: action.payload } + case 'SET_SCROLL_TO_RELATIONSHIP': + return { ...state, scrollToRelationship: action.payload } default: return state; } From cb5ab17515c5a8010bf50081014c7da27c41b24e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 12 Jan 2026 20:40:48 +0100 Subject: [PATCH 4/8] feat: moved security scope search to a multi-select dropdown. --- .../datamodelview/DatamodelView.tsx | 1 - Website/components/datamodelview/List.tsx | 22 ++- Website/components/datamodelview/Section.tsx | 18 +- .../datamodelview/SidebarDatamodelView.tsx | 36 +++- .../datamodelview/TimeSlicedSearch.tsx | 170 +++++++++++++++--- .../datamodelview/entity/SecurityRoles.tsx | 25 ++- Website/contexts/EntityFiltersContext.tsx | 8 +- 7 files changed, 235 insertions(+), 45 deletions(-) diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index b0c0061..deeb3ba 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -47,7 +47,6 @@ function DatamodelViewContent() { columnDescriptions: true, columnDataTypes: false, tableDescriptions: false, - securityRoles: false, relationships: false, }); // Track which tab should be active for each entity during search navigation diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 92c77b8..6dc3766 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -8,6 +8,7 @@ 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 { useEntityFilters } from "@/contexts/EntityFiltersContext"; import { Box, CircularProgress, debounce, Tooltip } from '@mui/material'; interface IListProps { @@ -27,11 +28,22 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { const dispatch = useDatamodelViewDispatch(); const { currentSection, loadingSection } = useDatamodelView(); const { groups, filtered, search } = useDatamodelData(); + const { selectedSecurityRoles } = useEntityFilters(); const { showSnackbar } = useSnackbar(); const parentRef = useRef(null); // used to relocate section after search/filter const [sectionVirtualItem, setSectionVirtualItem] = 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; + + return entity.SecurityRoles.some(role => + selectedSecurityRoles.includes(role.Name) && + (role.Read !== null && role.Read >= 0) // Has any read access + ); + }, [selectedSecurityRoles]); + const handleCopyGroupLink = useCallback(async (groupName: string) => { const link = generateGroupLink(groupName); const success = await copyToClipboard(link); @@ -55,6 +67,13 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { // Filter entities in this group const filteredEntities = group.Entities.filter((entity: EntityType) => { const typedEntity = entity; + + // If security roles are selected, only show entities with access + if (selectedSecurityRoles.length > 0) { + const hasAccess = hasSecurityRoleAccess(typedEntity); + if (!hasAccess) return false; + } + if (!lowerSearch) return true; // Match entity schema or display name const entityMatch = typedEntity.SchemaName.toLowerCase().includes(lowerSearch) || @@ -74,7 +93,7 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { } } return items; - }, [filtered, search, groups]); + }, [filtered, search, groups, selectedSecurityRoles, hasSecurityRoleAccess]); const debouncedOnChange = debounce((instance, sync) => { if (!sync) { @@ -323,6 +342,7 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { group={item.group} search={search} activeTab={entityActiveTabs.get(item.entity.SchemaName)} + highlightSecurityRole={selectedSecurityRoles.length > 0 && hasSecurityRoleAccess(item.entity)} /> )} diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index 600691a..529604d 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -17,10 +17,11 @@ interface ISectionProps { group: GroupType; search?: string; activeTab?: number; // External tab control for search navigation + highlightSecurityRole?: boolean; // Highlight when entity matches selected security roles } export const Section = React.memo( - ({ entity, group, search, activeTab }: ISectionProps) => { + ({ entity, group, search, activeTab, highlightSecurityRole }: ISectionProps) => { const [tab, setTab] = React.useState(0); // Update local tab state when external activeTab prop changes @@ -46,12 +47,18 @@ export const Section = React.memo( return ( - + {entity.SecurityRoles.length > 0 && (
- +
)}
@@ -123,11 +130,12 @@ export const Section = React.memo( }, // Custom comparison function to prevent unnecessary re-renders (prevProps, nextProps) => { - // Only re-render if entity, search, group, or activeTab changes + // Only re-render if entity, search, group, activeTab, or highlightSecurityRole changes return prevProps.entity.SchemaName === nextProps.entity.SchemaName && prevProps.search === nextProps.search && prevProps.group.Name === nextProps.group.Name && - prevProps.activeTab === nextProps.activeTab; + prevProps.activeTab === nextProps.activeTab && + prevProps.highlightSecurityRole === nextProps.highlightSecurityRole; } ); diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index bffc513..5dd6305 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -6,6 +6,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TextField } from "@mui/material"; import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; import { useDatamodelData } from "@/contexts/DatamodelDataContext"; +import { useEntityFilters } from "@/contexts/EntityFiltersContext"; import { useIsMobile } from "@/hooks/use-mobile"; import { EntityGroupAccordion } from "@/components/shared/elements/EntityGroupAccordion"; @@ -21,6 +22,17 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const dataModelDispatch = useDatamodelViewDispatch(); const { groups, filtered, search } = useDatamodelData(); + const { selectedSecurityRoles } = useEntityFilters(); + + // Helper function to check if entity has access from selected security roles + const hasSecurityRoleAccess = useCallback((entity: EntityType): boolean => { + if (selectedSecurityRoles.length === 0) return true; // Show all if no roles selected + + return entity.SecurityRoles.some(role => + selectedSecurityRoles.includes(role.Name) && + (role.Read !== null && role.Read >= 0) // Has any read access + ); + }, [selectedSecurityRoles]); const [searchTerm, setSearchTerm] = useState(""); const [displaySearchTerm, setDisplaySearchTerm] = useState(""); @@ -66,17 +78,25 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { // Memoize search results to prevent recalculation on every render const filteredGroups = useMemo(() => { - if (!searchTerm.trim() && !search) return groups; - return groups.map(group => ({ ...group, - Entities: group.Entities.filter(entity => - (entity.SchemaName.toLowerCase().includes(searchTerm.toLowerCase()) || - entity.DisplayName.toLowerCase().includes(searchTerm.toLowerCase())) && - (!search || filtered.some(f => f.type === 'entity' && f.entity.SchemaName === entity.SchemaName)) - ) + Entities: group.Entities.filter(entity => { + // Filter by security roles if any are selected + if (!hasSecurityRoleAccess(entity)) return false; + + // Filter by local search term + const matchesLocalSearch = !searchTerm.trim() || + entity.SchemaName.toLowerCase().includes(searchTerm.toLowerCase()) || + entity.DisplayName.toLowerCase().includes(searchTerm.toLowerCase()); + + // Filter by global search results + const matchesGlobalSearch = !search || + filtered.some(f => f.type === 'entity' && f.entity.SchemaName === entity.SchemaName); + + return matchesLocalSearch && matchesGlobalSearch; + }) })).filter(group => group.Entities.length > 0); - }, [groups, searchTerm, filtered]); + }, [groups, searchTerm, filtered, search, hasSecurityRoleAccess]); // Debounced search to reduce performance impact useEffect(() => { diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 2cde350..e274131 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -1,19 +1,20 @@ 'use client' -import React, { useState, useRef, useCallback, useEffect } from 'react'; +import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useSidebar } from '@/contexts/SidebarContext'; import { useSettings } from '@/contexts/SettingsContext'; import { useIsMobile } from '@/hooks/use-mobile'; -import { Box, CircularProgress, Divider, IconButton, InputAdornment, InputBase, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Paper, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; -import { AbcRounded, AccountTreeRounded, ClearRounded, DataObjectRounded, DescriptionRounded, ExpandMoreRounded, InfoRounded, KeyboardArrowDownRounded, KeyboardArrowUpRounded, LockPersonRounded, NavigateBeforeRounded, NavigateNextRounded, RestartAltRounded, SearchRounded, TableChartRounded } from '@mui/icons-material'; +import { useDatamodelData } from '@/contexts/DatamodelDataContext'; +import { useEntityFilters, useEntityFiltersDispatch } from '@/contexts/EntityFiltersContext'; +import { Box, Chip, CircularProgress, Divider, FormControl, IconButton, InputAdornment, InputBase, InputLabel, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, OutlinedInput, Paper, Select, SelectChangeEvent, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; +import { AbcRounded, AccountTreeRounded, ClearRounded, DataObjectRounded, DescriptionRounded, ExpandMoreRounded, InfoRounded, KeyboardArrowDownRounded, KeyboardArrowUpRounded, NavigateBeforeRounded, NavigateNextRounded, RestartAltRounded, SearchRounded, TableChartRounded } from '@mui/icons-material'; export const SEARCH_SCOPE_KEYS = { COLUMN_NAMES: 'columnNames', COLUMN_DESCRIPTIONS: 'columnDescriptions', COLUMN_DATA_TYPES: 'columnDataTypes', TABLE_DESCRIPTIONS: 'tableDescriptions', - SECURITY_ROLES: 'securityRoles', RELATIONSHIPS: 'relationships', } as const; @@ -24,7 +25,6 @@ export interface SearchScope { columnDescriptions: boolean; columnDataTypes: boolean; tableDescriptions: boolean; - securityRoles: boolean; relationships: boolean; } @@ -45,7 +45,6 @@ const DEFAULT_SEARCH_SCOPE: SearchScope = { columnDescriptions: true, columnDataTypes: false, tableDescriptions: false, - securityRoles: false, relationships: false, }; @@ -70,10 +69,33 @@ export const TimeSlicedSearch = ({ const { isOpen } = useSidebar(); const { isSettingsOpen } = useSettings(); const isMobile = useIsMobile(); + const { groups } = useDatamodelData(); + const { selectedSecurityRoles } = useEntityFilters(); + const entityFiltersDispatch = useEntityFiltersDispatch(); + + // Collect all unique security roles across all entities + const availableRoles = useMemo(() => { + if (!groups) return []; + + const roleSet = new Set(); + + for (const group of groups) { + for (const entity of group.Entities) { + if (entity.SecurityRoles) { + for (const role of entity.SecurityRoles) { + roleSet.add(role.Name); + } + } + } + } + + return Array.from(roleSet).sort((a, b) => a.localeCompare(b)); + }, [groups]); const searchTimeoutRef = useRef(); const typingTimeoutRef = useRef(); const frameRef = useRef(); + const paperRef = useRef(null); // Hide search on mobile when sidebar is open, or when settings are open const shouldHideSearch = (isMobile && isOpen) || isSettingsOpen; @@ -90,7 +112,6 @@ export const TimeSlicedSearch = ({ if (scope.columnDescriptions) result.push(SEARCH_SCOPE_KEYS.COLUMN_DESCRIPTIONS); if (scope.columnDataTypes) result.push(SEARCH_SCOPE_KEYS.COLUMN_DATA_TYPES); if (scope.tableDescriptions) result.push(SEARCH_SCOPE_KEYS.TABLE_DESCRIPTIONS); - if (scope.securityRoles) result.push(SEARCH_SCOPE_KEYS.SECURITY_ROLES); if (scope.relationships) result.push(SEARCH_SCOPE_KEYS.RELATIONSHIPS); return result; }, []); @@ -102,7 +123,6 @@ export const TimeSlicedSearch = ({ columnDescriptions: arr.includes(SEARCH_SCOPE_KEYS.COLUMN_DESCRIPTIONS), columnDataTypes: arr.includes(SEARCH_SCOPE_KEYS.COLUMN_DATA_TYPES), tableDescriptions: arr.includes(SEARCH_SCOPE_KEYS.TABLE_DESCRIPTIONS), - securityRoles: arr.includes(SEARCH_SCOPE_KEYS.SECURITY_ROLES), relationships: arr.includes(SEARCH_SCOPE_KEYS.RELATIONSHIPS), }; }, []); @@ -125,6 +145,19 @@ export const TimeSlicedSearch = ({ setShowAdvanced(prev => !prev); }, []); + const handleSecurityRoleChange = useCallback((event: SelectChangeEvent) => { + const value = event.target.value; + entityFiltersDispatch({ type: 'SET_SECURITY_ROLES', roles: typeof value === 'string' ? value.split(',') : value }); + }, [entityFiltersDispatch]); + + const handleDeleteSecurityRole = useCallback((roleToDelete: string) => () => { + entityFiltersDispatch({ type: 'SET_SECURITY_ROLES', roles: selectedSecurityRoles.filter(role => role !== roleToDelete) }); + }, [selectedSecurityRoles, entityFiltersDispatch]); + + const handleClearSecurityRoles = useCallback(() => { + entityFiltersDispatch({ type: 'SET_SECURITY_ROLES', roles: [] }); + }, [entityFiltersDispatch]); + useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (localValue.length === 0) return; @@ -319,6 +352,7 @@ export const TimeSlicedSearch = ({ const searchInput = ( - + - + - + - + - + - - - - - - + - + @@ -440,13 +470,109 @@ export const TimeSlicedSearch = ({ )} + + {/* Security Role Impersonation */} + {showAdvanced && availableRoles.length > 0 && ( + <> + + + + Security Role Impersonation + + + {selectedSecurityRoles.length > 0 && ( + + + + + + )} + + + )}
diff --git a/Website/components/datamodelview/entity/SecurityRoles.tsx b/Website/components/datamodelview/entity/SecurityRoles.tsx index 3ddb40e..a92ba28 100644 --- a/Website/components/datamodelview/entity/SecurityRoles.tsx +++ b/Website/components/datamodelview/entity/SecurityRoles.tsx @@ -4,18 +4,27 @@ import { SecurityRole, PrivilegeDepth } from "@/lib/Types"; import { AccountTreeRounded, BlockRounded, BusinessRounded, PeopleRounded, PersonRounded, RemoveRounded } from "@mui/icons-material"; import { Tooltip, Box, Typography, Paper, useTheme } from "@mui/material"; import React from "react"; +import { useEntityFilters } from "@/contexts/EntityFiltersContext"; + +export function SecurityRoles({ roles, highlightMatch, highlightTerm, highlightSecurityRole }: { roles: SecurityRole[], highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string, highlightSecurityRole?: boolean }) { + const { selectedSecurityRoles } = useEntityFilters(); -export function SecurityRoles({ roles, highlightMatch, highlightTerm }: { roles: SecurityRole[], highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { return ( {roles.map(role => ( - + ))} ); } -function SecurityRoleRow({ role, highlightMatch, highlightTerm }: { role: SecurityRole, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { +function SecurityRoleRow({ role, highlightMatch, highlightTerm, shouldHighlight }: { role: SecurityRole, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string, shouldHighlight?: boolean }) { const theme = useTheme(); return ( @@ -28,10 +37,12 @@ function SecurityRoleRow({ role, highlightMatch, highlightTerm }: { role: Securi justifyContent: 'space-between', gap: 1, p: 2, - backgroundColor: theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.02)' - : 'rgba(0, 0, 0, 0.02)', - borderColor: 'border.main', + backgroundColor: shouldHighlight + ? (theme.palette.mode === 'dark' ? 'rgba(25, 118, 210, 0.15)' : 'rgba(25, 118, 210, 0.08)') + : (theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : 'rgba(0, 0, 0, 0.02)'), + borderColor: shouldHighlight ? 'primary.main' : 'border.main', + borderWidth: shouldHighlight ? 2 : 1, + boxShadow: shouldHighlight ? '0 0 0 3px rgba(25, 118, 210, 0.1)' : 'none', width: '100%', }} > diff --git a/Website/contexts/EntityFiltersContext.tsx b/Website/contexts/EntityFiltersContext.tsx index fc03374..e4d9260 100644 --- a/Website/contexts/EntityFiltersContext.tsx +++ b/Website/contexts/EntityFiltersContext.tsx @@ -9,14 +9,17 @@ export interface EntityFilterState { interface EntityFiltersState { filters: Map; // Map of entitySchemaName -> filter state + selectedSecurityRoles: string[]; // Global security role filter } type EntityFiltersAction = | { type: "SET_ENTITY_FILTERS"; entitySchemaName: string; filters: EntityFilterState } - | { type: "CLEAR_ENTITY_FILTERS"; entitySchemaName: string }; + | { type: "CLEAR_ENTITY_FILTERS"; entitySchemaName: string } + | { type: "SET_SECURITY_ROLES"; roles: string[] }; const initialState: EntityFiltersState = { filters: new Map(), + selectedSecurityRoles: [], }; const EntityFiltersContext = createContext(initialState); @@ -34,6 +37,9 @@ const entityFiltersReducer = (state: EntityFiltersState, action: EntityFiltersAc newFilters.delete(action.entitySchemaName); return { ...state, filters: newFilters }; } + case "SET_SECURITY_ROLES": { + return { ...state, selectedSecurityRoles: action.roles }; + } default: return state; } From ac62371588015a81cebad72805cabb5a6d1fdfa5 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 12 Jan 2026 20:49:36 +0100 Subject: [PATCH 5/8] feat: query params --- .../datamodelview/TimeSlicedSearch.tsx | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index e274131..f24d185 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; +import { useSearchParams, usePathname, useRouter } from 'next/navigation'; import { useSidebar } from '@/contexts/SidebarContext'; import { useSettings } from '@/contexts/SettingsContext'; import { useIsMobile } from '@/hooks/use-mobile'; @@ -60,11 +61,33 @@ export const TimeSlicedSearch = ({ placeholder = "Search attributes...", onSearchScopeChange, }: TimeSlicedSearchProps) => { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const [localValue, setLocalValue] = useState(initialLocalValue); const [isTyping, setIsTyping] = useState(false); const [portalRoot, setPortalRoot] = useState(null); const [lastValidSearch, setLastValidSearch] = useState(''); - const [searchScope, setSearchScope] = useState(DEFAULT_SEARCH_SCOPE); + const [searchScope, setSearchScope] = useState(() => { + // Initialize from URL params if available + const scopeParam = searchParams.get('scope'); + if (scopeParam) { + try { + const scopes = scopeParam.split(','); + return { + columnNames: scopes.includes('cn'), + columnDescriptions: scopes.includes('cd'), + columnDataTypes: scopes.includes('cdt'), + tableDescriptions: scopes.includes('td'), + relationships: scopes.includes('rel'), + }; + } catch { + return DEFAULT_SEARCH_SCOPE; + } + } + return DEFAULT_SEARCH_SCOPE; + }); const [showAdvanced, setShowAdvanced] = useState(false); const { isOpen } = useSidebar(); const { isSettingsOpen } = useSettings(); @@ -100,6 +123,54 @@ export const TimeSlicedSearch = ({ // Hide search on mobile when sidebar is open, or when settings are open const shouldHideSearch = (isMobile && isOpen) || isSettingsOpen; + // Initialize security roles from URL params on mount + useEffect(() => { + const rolesParam = searchParams.get('roles'); + if (rolesParam) { + try { + const roles = rolesParam.split(',').filter(Boolean); + entityFiltersDispatch({ type: 'SET_SECURITY_ROLES', roles }); + } catch { + // Ignore invalid param + } + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Update URL params when search scope or security roles change + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()); + + // Update scope param + const scopeKeys: string[] = []; + if (searchScope.columnNames) scopeKeys.push('cn'); + if (searchScope.columnDescriptions) scopeKeys.push('cd'); + if (searchScope.columnDataTypes) scopeKeys.push('cdt'); + if (searchScope.tableDescriptions) scopeKeys.push('td'); + if (searchScope.relationships) scopeKeys.push('rel'); + + if (scopeKeys.length > 0 && JSON.stringify(searchScope) !== JSON.stringify(DEFAULT_SEARCH_SCOPE)) { + params.set('scope', scopeKeys.join(',')); + } else { + params.delete('scope'); + } + + // Update roles param + if (selectedSecurityRoles.length > 0) { + params.set('roles', selectedSecurityRoles.join(',')); + } else { + params.delete('roles'); + } + + // Build URL, preserving existing params like 'globalsearch' + const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; + + // Only update if the URL actually changed to avoid infinite loops + const currentUrl = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + if (newUrl !== currentUrl) { + router.replace(newUrl, { scroll: false }); + } + }, [searchScope, selectedSecurityRoles, pathname, router, searchParams]); + // Notify parent when search scope changes useEffect(() => { onSearchScopeChange?.(searchScope); From 1b4031551fae45a2f6c946a5fc9d308ade518117 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 12 Jan 2026 20:56:16 +0100 Subject: [PATCH 6/8] chore: homepage news --- Website/components/homeview/HomeView.tsx | 24 ++++++++---------------- Website/components/shared/Sidebar.tsx | 1 + 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/Website/components/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx index d844f40..5201136 100644 --- a/Website/components/homeview/HomeView.tsx +++ b/Website/components/homeview/HomeView.tsx @@ -23,6 +23,14 @@ export const HomeView = ({ }: IHomeViewProps) => { // Carousel data const carouselItems: CarouselItem[] = [ + { + image: '/insights.jpg', + title: 'New Search features!', + text: 'Enhanced global search with customizable scope filters—search across attributes, descriptions, data types, relationships, and more. New security role impersonation lets you filter entities by access permissions, making it easy to understand what data different roles can see. All filters are shareable via URL for seamless collaboration.', + type: '(v2.3.1) Feature Update', + actionlabel: 'Try It Now', + action: () => router.push('/metadata') + }, { image: '/MSAuthentication.jpg', title: 'Microsoft Entra ID Authentication!', @@ -45,22 +53,6 @@ export const HomeView = ({ }: IHomeViewProps) => { actionlabel: 'Go to Diagrams', action: () => router.push('/diagram') }, - { - image: '/insights.jpg', - title: 'Insights are here!', - text: "Get insights into your solutions, entities and attributes with the new Insights feature. Analyze your solutions' relationships and shared components to optimize your environment. See bad practices and get recommendations to improve your data model.", - type: '(v2.1.0) Feature Release', - actionlabel: 'Go to Insights', - action: () => router.push('/insights') - }, - { - image: '/processes.jpg', - title: 'Webresource support!', - text: "View your attributes used inside your JS webresources in the Processes Explorer. Now supports the getAttribute method with more to come soon.", - type: '(v2.0.1) Feature update', - actionlabel: 'Try it out', - action: () => router.push('/processes') - }, ]; const goToPrevious = () => { diff --git a/Website/components/shared/Sidebar.tsx b/Website/components/shared/Sidebar.tsx index 56bfc6c..4864fb2 100644 --- a/Website/components/shared/Sidebar.tsx +++ b/Website/components/shared/Sidebar.tsx @@ -47,6 +47,7 @@ const Sidebar = ({ }: SidebarProps) => { href: '/metadata', icon: MetadataIcon, active: pathname === '/metadata', + new: true, }, { label: 'Diagram', From 12574cea28b09f65259883cf53cd6ac716684868 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 12 Jan 2026 21:07:48 +0100 Subject: [PATCH 7/8] chore: better icons for toggle buttons --- Website/components/datamodelview/TimeSlicedSearch.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index f24d185..78f25b4 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -9,7 +9,7 @@ import { useIsMobile } from '@/hooks/use-mobile'; import { useDatamodelData } from '@/contexts/DatamodelDataContext'; import { useEntityFilters, useEntityFiltersDispatch } from '@/contexts/EntityFiltersContext'; import { Box, Chip, CircularProgress, Divider, FormControl, IconButton, InputAdornment, InputBase, InputLabel, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, OutlinedInput, Paper, Select, SelectChangeEvent, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; -import { AbcRounded, AccountTreeRounded, ClearRounded, DataObjectRounded, DescriptionRounded, ExpandMoreRounded, InfoRounded, KeyboardArrowDownRounded, KeyboardArrowUpRounded, NavigateBeforeRounded, NavigateNextRounded, RestartAltRounded, SearchRounded, TableChartRounded } from '@mui/icons-material'; +import { AbcRounded, AccountTreeRounded, AlignHorizontalLeftRounded, ClearRounded, ExpandMoreRounded, FormatListBulletedRounded, InfoRounded, KeyboardArrowDownRounded, KeyboardArrowUpRounded, NavigateBeforeRounded, NavigateNextRounded, NotesRounded, RestartAltRounded, SearchRounded } from '@mui/icons-material'; export const SEARCH_SCOPE_KEYS = { COLUMN_NAMES: 'columnNames', @@ -514,17 +514,17 @@ export const TimeSlicedSearch = ({ - + - + - + From e08b03bdb7bc31b4295f72daa794d5354ada0dc9 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 13 Jan 2026 18:45:57 +0100 Subject: [PATCH 8/8] fix: minor adjustment and fixes to search scroll and resultorder --- .../components/datamodelview/Attributes.tsx | 7 +- .../datamodelview/DatamodelView.tsx | 107 +++++++++++++----- Website/components/datamodelview/List.tsx | 32 +++--- .../datamodelview/Relationships.tsx | 7 +- .../components/datamodelview/searchWorker.ts | 17 +++ Website/contexts/DatamodelDataContext.tsx | 11 ++ 6 files changed, 133 insertions(+), 48 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index d009cdf..3aa8a86 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -18,6 +18,7 @@ import { highlightMatch } from "../datamodelview/List"; import { Box, Button, FormControl, InputAdornment, InputLabel, MenuItem, Select, Table, TableBody, TableCell, TableHead, TableRow, TextField, Tooltip, Typography, useTheme } from "@mui/material" import { ClearRounded, SearchRounded, Visibility, VisibilityOff, ArrowUpwardRounded, ArrowDownwardRounded } from "@mui/icons-material" import { useEntityFiltersDispatch } from "@/contexts/EntityFiltersContext" +import { useDatamodelData } from "@/contexts/DatamodelDataContext" type SortDirection = 'asc' | 'desc' | null type SortColumn = 'displayName' | 'schemaName' | 'type' | 'description' | null @@ -37,6 +38,7 @@ export const Attributes = ({ entity, search = "", onVisibleCountChange }: IAttri const theme = useTheme(); const entityFiltersDispatch = useEntityFiltersDispatch(); + const { searchScope } = useDatamodelData(); // Report filter state changes to context useEffect(() => { @@ -144,7 +146,10 @@ export const Attributes = ({ entity, search = "", onVisibleCountChange }: IAttri } const sortedAttributes = getSortedAttributes(); - const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting + // Only highlight if search scope includes columns + // Use internal search query first, or parent search if column scopes are enabled + const highlightTerm = searchQuery || + (search && (searchScope.columnNames || searchScope.columnDescriptions || searchScope.columnDataTypes) ? search : ""); // Notify parent of visible count changes useEffect(() => { diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index deeb3ba..a8b8e18 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -37,7 +37,7 @@ function DatamodelViewContent() { const datamodelDispatch = useDatamodelViewDispatch(); const { groups, filtered, search } = useDatamodelData(); const datamodelDataDispatch = useDatamodelDataDispatch(); - const { filters: entityFilters } = useEntityFilters(); + const { filters: entityFilters, selectedSecurityRoles } = useEntityFilters(); const workerRef = useRef(null); const [currentSearchIndex, setCurrentSearchIndex] = useState(0); const accumulatedResultsRef = useRef([]); // Track all results during search @@ -67,11 +67,29 @@ function DatamodelViewContent() { const totalResults = useMemo(() => { if (filtered.length === 0) return 0; - const attributeCount = filtered.filter(item => item.type === 'attribute').length; - const relationshipCount = filtered.filter(item => item.type === 'relationship').length; - const itemCount = attributeCount + relationshipCount; + // Get combined results and deduplicate to match navigation behavior + const combinedResults = filtered.filter((item): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } => + item.type === 'attribute' || item.type === 'relationship' + ); - if (itemCount > 0) return itemCount; + if (combinedResults.length > 0) { + // Deduplicate to match getSortedCombinedResults behavior + // Note: We don't check DOM element existence here because relationships on inactive tabs + // won't have DOM elements yet, but they should still be counted for navigation + const seen = new Set(); + let count = 0; + for (const item of combinedResults) { + const key = item.type === 'attribute' + ? `attr-${item.entity.SchemaName}-${item.attribute.SchemaName}` + : `rel-${item.entity.SchemaName}-${item.relationship.RelationshipSchema}`; + + if (!seen.has(key)) { + seen.add(key); + count++; + } + } + return count; + } // If no attributes or relationships, count entity-level matches (for security roles, table descriptions) const entityCount = filtered.filter(item => item.type === 'entity').length; @@ -98,6 +116,7 @@ function DatamodelViewContent() { data: searchValue, entityFilters: filtersObject, searchScope: searchScope, + selectedSecurityRoles: selectedSecurityRoles, requestId: currentRequestId // Send request ID to worker }); } else { @@ -113,7 +132,7 @@ function DatamodelViewContent() { updateURL({ query: { globalsearch: searchValue.length >= 3 ? searchValue : "" } }) datamodelDataDispatch({ type: "SET_SEARCH", payload: searchValue.length >= 3 ? searchValue : "" }); setCurrentSearchIndex(searchValue.length >= 3 ? 1 : 0); // Reset to first result when searching, 0 when cleared - }, [groups, datamodelDataDispatch, restoreSection, entityFilters, searchScope]); + }, [groups, datamodelDataDispatch, restoreSection, entityFilters, searchScope, selectedSecurityRoles]); const handleLoadingChange = useCallback((isLoading: boolean) => { datamodelDispatch({ type: "SET_LOADING", payload: isLoading }); @@ -121,15 +140,16 @@ function DatamodelViewContent() { const handleSearchScopeChange = useCallback((newScope: SearchScope) => { setSearchScope(newScope); - }, []); + datamodelDataDispatch({ type: "SET_SEARCH_SCOPE", payload: newScope }); + }, [datamodelDataDispatch]); - // Re-trigger search when scope changes + // Re-trigger search when scope or security roles change useEffect(() => { if (search && search.length >= 3) { handleSearch(search); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchScope]); // Only trigger on searchScope change, not handleSearch to avoid infinite loop + }, [searchScope, selectedSecurityRoles]); // Only trigger on searchScope or selectedSecurityRoles change, not handleSearch to avoid infinite loop // Helper function to get sorted combined results (attributes + relationships) on-demand // This prevents blocking the main thread during typing - sorting only happens during navigation @@ -165,44 +185,69 @@ function DatamodelViewContent() { resultsByEntity.get(entityKey)!.push(result); } - // Sort entities by the Y position of their first attribute (or use first result if no attributes) + // Create a stable sort order based on the groups data structure + // This ensures consistent navigation order regardless of scroll position or tab state + const entityOrder = new Map(); + let orderIndex = 0; + for (const group of groups) { + for (const entity of group.Entities) { + entityOrder.set(entity.SchemaName, orderIndex++); + } + } + + // Sort entities by their position in the groups data structure const sortedEntities = Array.from(resultsByEntity.entries()).sort((a, b) => { - const [, resultsA] = a; - const [, resultsB] = b; + const [entitySchemaA] = a; + const [entitySchemaB] = b; - // Find first attribute for each entity - const firstAttrA = resultsA.find(r => r.type === 'attribute') as { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | undefined; - const firstAttrB = resultsB.find(r => r.type === 'attribute') as { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | undefined; + const orderA = entityOrder.get(entitySchemaA) ?? Number.MAX_SAFE_INTEGER; + const orderB = entityOrder.get(entitySchemaB) ?? Number.MAX_SAFE_INTEGER; - // If both have attributes, compare by Y position - if (firstAttrA && firstAttrB) { - const elementA = document.getElementById(`attr-${firstAttrA.entity.SchemaName}-${firstAttrA.attribute.SchemaName}`); - const elementB = document.getElementById(`attr-${firstAttrB.entity.SchemaName}-${firstAttrB.attribute.SchemaName}`); + return orderA - orderB; + }); + + // Flatten back to array, keeping attributes BEFORE relationships within each entity + const result: Array<{ type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType }> = []; + for (const [, entityResults] of sortedEntities) { + // Separate attributes and relationships for this entity + const attributes = entityResults.filter((r): r is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => r.type === 'attribute'); + const relationships = entityResults.filter((r): r is { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } => r.type === 'relationship'); + + // Sort attributes by Y position within the entity (if they exist in DOM) + // Note: We don't filter out attributes that don't exist in DOM because the search worker + // already applied all component-level filters + attributes.sort((a, b) => { + const elementA = document.getElementById(`attr-${a.entity.SchemaName}-${a.attribute.SchemaName}`); + const elementB = document.getElementById(`attr-${b.entity.SchemaName}-${b.attribute.SchemaName}`); if (elementA && elementB) { const rectA = elementA.getBoundingClientRect(); const rectB = elementB.getBoundingClientRect(); return rectA.top - rectB.top; } - } + return 0; + }); - // Fallback: maintain original order - return 0; - }); + // Sort relationships by Y position within the entity (if they exist in DOM) + // Note: Relationships on inactive tabs won't have DOM elements, so we keep them in original order + relationships.sort((a, b) => { + const elementA = document.getElementById(`rel-${a.entity.SchemaName}-${a.relationship.RelationshipSchema}`); + const elementB = document.getElementById(`rel-${b.entity.SchemaName}-${b.relationship.RelationshipSchema}`); - // Flatten back to array, keeping attributes before relationships within each entity - const result: Array<{ type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType }> = []; - for (const [, entityResults] of sortedEntities) { - // Separate attributes and relationships for this entity - const attributes = entityResults.filter((r): r is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => r.type === 'attribute'); - const relationships = entityResults.filter((r): r is { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } => r.type === 'relationship'); + if (elementA && elementB) { + const rectA = elementA.getBoundingClientRect(); + const rectB = elementB.getBoundingClientRect(); + return rectA.top - rectB.top; + } + return 0; + }); - // Add all attributes first (in their original order), then relationships + // Add all attributes first, then all relationships result.push(...attributes, ...relationships); } return result; - }, [filtered]); + }, [filtered, groups]); // Navigation handlers const handleNavigateNext = useCallback(() => { diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 6dc3766..37a77d5 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -214,21 +214,25 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { const scrollToRelationship = useCallback((sectionId: string, relSchema: string) => { const relId = `rel-${sectionId}-${relSchema}`; - const relationshipLocation = document.getElementById(relId); - if (relationshipLocation) { - // Relationship is already rendered, scroll directly to it - relationshipLocation.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } else { - // Relationship not found, need to scroll to section first - scrollToSection(sectionId); - setTimeout(() => { - const relationshipLocationAfterScroll = document.getElementById(relId); - if (relationshipLocationAfterScroll) { - relationshipLocationAfterScroll.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, 100); - } + // Helper function to attempt scrolling to relationship with retries + const attemptScroll = (attemptsLeft: number) => { + const relationshipLocation = document.getElementById(relId); + + if (relationshipLocation) { + // Relationship found, scroll to it + relationshipLocation.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else if (attemptsLeft > 0) { + // Relationship not rendered yet, retry after delay + setTimeout(() => attemptScroll(attemptsLeft - 1), 100); + } else { + // Give up after all retries, just scroll to section + scrollToSection(sectionId); + } + }; + + // Start attempting to scroll with 5 retries (total 500ms wait time) + attemptScroll(5); }, [scrollToSection]); const scrollToGroup = useCallback((groupName: string) => { diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 3e1694a..5e89094 100644 --- a/Website/components/datamodelview/Relationships.tsx +++ b/Website/components/datamodelview/Relationships.tsx @@ -31,7 +31,7 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe const dispatch = useDatamodelViewDispatch(); const { scrollToSection } = useDatamodelView(); - const { groups } = useDatamodelData(); + const { groups, searchScope } = useDatamodelData(); // Helper function to check if an entity is in the solution const isEntityInSolution = (entitySchemaName: string): boolean => { @@ -147,7 +147,10 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe ] const sortedRelationships = getSortedRelationships(); - const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting + // Only highlight if search scope includes relationships + // Use internal search query first, or parent search if relationships scope is enabled + const highlightTerm = searchQuery || + (search && searchScope.relationships ? search : ""); // Notify parent of visible count changes useEffect(() => { diff --git a/Website/components/datamodelview/searchWorker.ts b/Website/components/datamodelview/searchWorker.ts index b9051ad..6266b3f 100644 --- a/Website/components/datamodelview/searchWorker.ts +++ b/Website/components/datamodelview/searchWorker.ts @@ -25,6 +25,7 @@ interface SearchMessage { data: string; entityFilters?: Record; searchScope?: SearchScope; + selectedSecurityRoles?: string[]; requestId?: number; } @@ -70,6 +71,7 @@ self.onmessage = async function (e: MessageEvent) { const search = (typeof e.data === 'string' ? e.data : e.data?.data || '').trim().toLowerCase(); const entityFilters: Record = (typeof e.data === 'object' && 'entityFilters' in e.data) ? e.data.entityFilters || {} : {}; const requestId = (typeof e.data === 'object' && 'requestId' in e.data) ? e.data.requestId : undefined; + const selectedSecurityRoles: string[] = (typeof e.data === 'object' && 'selectedSecurityRoles' in e.data) ? e.data.selectedSecurityRoles || [] : []; const searchScope: SearchScope = (typeof e.data === 'object' && 'searchScope' in e.data) ? e.data.searchScope || { columnNames: true, columnDescriptions: true, @@ -103,12 +105,27 @@ self.onmessage = async function (e: MessageEvent) { | { type: 'relationship'; group: GroupType; entity: EntityType; relationship: RelationshipType } > = []; + // Helper function to check if entity has access from selected security roles + const hasSecurityRoleAccess = (entity: EntityType): boolean => { + if (selectedSecurityRoles.length === 0) return true; // No filter means show all + + return entity.SecurityRoles.some(role => + selectedSecurityRoles.includes(role.Name) && + (role.Read !== null && role.Read >= 0) // Has any read access + ); + }; + //////////////////////////////////////////////// // Finding matches part //////////////////////////////////////////////// for (const group of groups) { let groupUsed = false; for (const entity of group.Entities) { + // Filter by security roles first - skip entity if no access + if (!hasSecurityRoleAccess(entity)) { + continue; + } + // Get entity-specific filters (default to showing all if not set) const entityFilter = entityFilters[entity.SchemaName] || { hideStandardFields: true, typeFilter: 'all' }; diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index 0b78cc9..a495391 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -3,6 +3,7 @@ import React, { createContext, useContext, useReducer, ReactNode } from "react"; import { AttributeType, EntityType, GroupType, RelationshipType, SolutionWarningType } from "@/lib/Types"; import { useSearchParams } from "next/navigation"; +import { SearchScope } from "@/components/datamodelview/TimeSlicedSearch"; interface DataModelAction { getEntityDataBySchemaName: (schemaName: string) => EntityType | undefined; @@ -14,6 +15,7 @@ interface DatamodelDataState extends DataModelAction { warnings: SolutionWarningType[]; solutionCount: number; search: string; + searchScope: SearchScope; filtered: Array< | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } @@ -27,6 +29,13 @@ const initialState: DatamodelDataState = { warnings: [], solutionCount: 0, search: "", + searchScope: { + columnNames: true, + columnDescriptions: true, + columnDataTypes: false, + tableDescriptions: false, + relationships: false, + }, filtered: [], getEntityDataBySchemaName: () => { throw new Error("getEntityDataBySchemaName not implemented.") }, @@ -47,6 +56,8 @@ const datamodelDataReducer = (state: DatamodelDataState, action: any): Datamodel return { ...state, solutionCount: action.payload }; case "SET_SEARCH": return { ...state, search: action.payload }; + case "SET_SEARCH_SCOPE": + return { ...state, searchScope: action.payload }; case "SET_FILTERED": return { ...state, filtered: action.payload }; case "APPEND_FILTERED":