diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 390f734..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(() => { @@ -432,9 +437,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 +447,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..a8b8e18 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -4,19 +4,20 @@ 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"; 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,22 +33,67 @@ export function DatamodelView() { } function DatamodelViewContent() { - const { scrollToSection, scrollToAttribute, restoreSection } = useDatamodelView(); + const { scrollToSection, scrollToAttribute, scrollToRelationship, restoreSection } = useDatamodelView(); const datamodelDispatch = useDatamodelViewDispatch(); - const { groups, filtered } = useDatamodelData(); + 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 + const searchRequestIdRef = useRef(0); // Track search requests to ignore stale results + const [searchScope, setSearchScope] = useState({ + columnNames: true, + columnDescriptions: true, + columnDataTypes: false, + tableDescriptions: false, + relationships: false, + }); + // Track which tab should be active for each entity during search navigation + const [entityActiveTabs, setEntityActiveTabs] = useState>(new Map()); + + // 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 (prioritize attributes, fallback to entities) + // 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; - return 0; + // 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 (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; + return entityCount; }, [filtered]); const initialLocalValue = useSearchParams().get('globalsearch') || ""; @@ -55,6 +101,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) => { @@ -64,7 +114,10 @@ function DatamodelViewContent() { workerRef.current.postMessage({ type: 'search', data: searchValue, - entityFilters: filtersObject + entityFilters: filtersObject, + searchScope: searchScope, + selectedSecurityRoles: selectedSecurityRoles, + requestId: currentRequestId // Send request ID to worker }); } else { // Clear search - reset to show all groups @@ -79,60 +132,122 @@ 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, selectedSecurityRoles]); const handleLoadingChange = useCallback((isLoading: boolean) => { datamodelDispatch({ type: "SET_LOADING", payload: isLoading }); }, [datamodelDispatch]); - // 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; - } + const handleSearchScopeChange = useCallback((newScope: SearchScope) => { + setSearchScope(newScope); + datamodelDataDispatch({ type: "SET_SEARCH_SCOPE", payload: newScope }); + }, [datamodelDataDispatch]); + + // 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, 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 + 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}`; - // Fallback: if elements can't be found, maintain original order - return 0; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; }); - }, []); - // 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]); + // Group results by entity to keep them together + const resultsByEntity = new Map>(); - // Cached sorted results - only re-sort when attribute results change - const [cachedSortedResults, setCachedSortedResults] = useState>([]); + for (const result of deduplicatedResults) { + const entityKey = result.entity.SchemaName; + if (!resultsByEntity.has(entityKey)) { + resultsByEntity.set(entityKey, []); + } + resultsByEntity.get(entityKey)!.push(result); + } - // 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([]); + // 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 [entitySchemaA] = a; + const [entitySchemaB] = b; + + const orderA = entityOrder.get(entitySchemaA) ?? Number.MAX_SAFE_INTEGER; + const orderB = entityOrder.get(entitySchemaB) ?? Number.MAX_SAFE_INTEGER; + + 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; + }); + + // 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}`); + + if (elementA && elementB) { + const rectA = elementA.getBoundingClientRect(); + const rectB = elementB.getBoundingClientRect(); + return rectA.top - rectB.top; + } + return 0; + }); + + // Add all attributes first, then all relationships + result.push(...attributes, ...relationships); } - }, [attributeResults, sortResultsByYPosition]); - // Helper function to get sorted attribute results - const getSortedAttributeResults = useCallback(() => { - return cachedSortedResults; - }, [cachedSortedResults]); + return result; + }, [filtered, groups]); // Navigation handlers const handleNavigateNext = useCallback(() => { @@ -140,21 +255,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' ); @@ -167,27 +290,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' ); @@ -200,7 +332,7 @@ function DatamodelViewContent() { } } } - }, [currentSearchIndex, getSortedAttributeResults, filtered, datamodelDispatch, scrollToAttribute, scrollToSection]); + }, [currentSearchIndex, getSortedCombinedResults, filtered, datamodelDispatch, scrollToAttribute, scrollToRelationship, scrollToSection, getTabIndexForType]); useEffect(() => { if (!workerRef.current) { @@ -218,6 +350,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); @@ -239,24 +376,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' ); @@ -278,23 +422,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' ); @@ -314,7 +467,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 ( @@ -356,8 +509,9 @@ function DatamodelViewContent() { initialLocalValue={initialLocalValue} currentIndex={currentSearchIndex} totalResults={totalResults} + onSearchScopeChange={handleSearchScopeChange} /> - + ); diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 7301919..37a77d5 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -8,10 +8,12 @@ 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 { setCurrentIndex: (index: number) => void; + entityActiveTabs: Map; } // Helper to highlight search matches @@ -22,15 +24,26 @@ 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(); + 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); @@ -43,7 +56,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< @@ -54,6 +67,13 @@ export const List = ({ setCurrentIndex }: 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) || @@ -73,7 +93,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { } } return items; - }, [filtered, search, groups]); + }, [filtered, search, groups, selectedSecurityRoles, hasSecurityRoleAccess]); const debouncedOnChange = debounce((instance, sync) => { if (!sync) { @@ -192,6 +212,29 @@ export const List = ({ setCurrentIndex }: IListProps) => { } }, [scrollToSection]); + const scrollToRelationship = useCallback((sectionId: string, relSchema: string) => { + const relId = `rel-${sectionId}-${relSchema}`; + + // 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) => { const groupIndex = flatItems.findIndex(item => item.type === 'group' && item.group.Name === groupName @@ -214,9 +257,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 +345,8 @@ export const List = ({ setCurrentIndex }: IListProps) => { entity={item.entity} group={item.group} search={search} + activeTab={entityActiveTabs.get(item.entity.SchemaName)} + highlightSecurityRole={selectedSecurityRoles.length > 0 && hasSecurityRoleAccess(item.entity)} /> )} diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 94e1088..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(() => { @@ -393,6 +396,7 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe {sortedRelationships.map((relationship, index) => - {highlightMatch(relationship.Name, highlightTerm)} + {relationship.Name} {isEntityInSolution(relationship.TableSchema) ? ( @@ -430,13 +434,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 +456,7 @@ export const Relationships = ({ entity, search = "", onVisibleCountChange }: IRe )} - {relationship.LookupDisplayName} + {highlightMatch(relationship.LookupDisplayName, highlightTerm)} {relationship.RelationshipType} @@ -461,7 +465,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 0ae723e..529604d 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" @@ -15,11 +16,20 @@ interface ISectionProps { entity: EntityType; 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 }: ISectionProps) => { + ({ entity, group, search, activeTab, highlightSecurityRole }: 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); @@ -37,12 +47,18 @@ export const Section = React.memo( return ( - + {entity.SecurityRoles.length > 0 && (
- +
)}
@@ -114,10 +130,12 @@ 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, activeTab, or highlightSecurityRole 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 && + 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 fffbda8..78f25b4 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -1,12 +1,33 @@ '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 { useSearchParams, usePathname, useRouter } from 'next/navigation'; 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 { 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, AlignHorizontalLeftRounded, ClearRounded, ExpandMoreRounded, FormatListBulletedRounded, InfoRounded, KeyboardArrowDownRounded, KeyboardArrowUpRounded, NavigateBeforeRounded, NavigateNextRounded, NotesRounded, RestartAltRounded, SearchRounded } from '@mui/icons-material'; + +export const SEARCH_SCOPE_KEYS = { + COLUMN_NAMES: 'columnNames', + COLUMN_DESCRIPTIONS: 'columnDescriptions', + COLUMN_DATA_TYPES: 'columnDataTypes', + TABLE_DESCRIPTIONS: 'tableDescriptions', + 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; + relationships: boolean; +} interface TimeSlicedSearchProps { onSearch: (value: string) => void; @@ -17,8 +38,17 @@ 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, + relationships: false, +}; + // Time-sliced input that maintains 60fps regardless of background work export const TimeSlicedSearch = ({ onSearch, @@ -29,22 +59,176 @@ export const TimeSlicedSearch = ({ currentIndex, totalResults, 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(() => { + // 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(); 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; + // 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); + }, [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.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), + 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); + }, []); + + 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; @@ -237,55 +421,229 @@ 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 && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + {/* Security Role Impersonation */} + {showAdvanced && availableRoles.length > 0 && ( + <> + + + + Security Role Impersonation + + + {selectedSecurityRoles.length > 0 && ( + + + + + + )} + + + )}
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..a92ba28 100644 --- a/Website/components/datamodelview/entity/SecurityRoles.tsx +++ b/Website/components/datamodelview/entity/SecurityRoles.tsx @@ -3,18 +3,28 @@ 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 }: { roles: SecurityRole[] }) { return ( {roles.map(role => ( - + ))} ); } -function SecurityRoleRow({ role }: { role: SecurityRole }) { +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 ( @@ -27,10 +37,12 @@ function SecurityRoleRow({ role }: { role: SecurityRole }) { 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%', }} > @@ -43,7 +55,7 @@ function SecurityRoleRow({ role }: { role: SecurityRole }) { color: 'text.primary' }} > - {role.Name} + {highlightMatch && highlightTerm ? highlightMatch(role.Name, highlightTerm) : role.Name} ; + searchScope?: SearchScope; + selectedSecurityRoles?: string[]; + requestId?: number; } type WorkerMessage = InitMessage | SearchMessage | string; @@ -25,13 +37,16 @@ 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; + requestId?: number; } interface StartedMessage { type: 'started'; + requestId?: number; } type WorkerResponse = ResultsMessage | StartedMessage; @@ -55,29 +70,62 @@ 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 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, + 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 }; + 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< | { 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 } > = []; + // 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' }; @@ -103,26 +151,111 @@ 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; + } + + // 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 basicMatch || optionsMatch; + 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 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; + + 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 }); } + + // Add matching relationships + for (const rel of matchingRelationships) { + allItems.push({ type: 'relationship', group, entity, relationship: rel }); + } } } } @@ -136,7 +269,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); 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', diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index 12e1469..a495391 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -1,8 +1,9 @@ '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"; +import { SearchScope } from "@/components/datamodelview/TimeSlicedSearch"; interface DataModelAction { getEntityDataBySchemaName: (schemaName: string) => EntityType | undefined; @@ -14,10 +15,12 @@ interface DatamodelDataState extends DataModelAction { warnings: SolutionWarningType[]; solutionCount: number; search: string; + searchScope: SearchScope; filtered: Array< | { 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 } >; } @@ -26,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.") }, @@ -46,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": 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; } 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; }