From 783478bab2721db28d4efa538a3da537eaf52671 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sun, 2 Nov 2025 18:39:01 +0100 Subject: [PATCH 01/11] feat: scroll to search result attribute instead of entity section --- .../components/datamodelview/Attributes.tsx | 197 +++++++-------- .../datamodelview/DatamodelView.tsx | 224 ++++++++++++++---- Website/components/datamodelview/List.tsx | 59 +++-- Website/components/datamodelview/Section.tsx | 40 ++-- .../components/datamodelview/searchWorker.ts | 63 ++--- Website/contexts/DatamodelDataContext.tsx | 3 +- Website/contexts/DatamodelViewContext.tsx | 11 +- 7 files changed, 378 insertions(+), 219 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 0136830..3254e30 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -56,13 +56,13 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri const basicMatch = attr.DisplayName.toLowerCase().includes(query) || attr.SchemaName.toLowerCase().includes(query) || (attr.Description && attr.Description.toLowerCase().includes(query)); - + // Check options for ChoiceAttribute and StatusAttribute let optionsMatch = false; if (attr.AttributeType === 'ChoiceAttribute' || attr.AttributeType === 'StatusAttribute') { optionsMatch = attr.Options.some(option => option.Name.toLowerCase().includes(query)); } - + return basicMatch || optionsMatch; }; @@ -153,9 +153,9 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri ] return <> - ) } - }} + }} /> Filter by type - @@ -220,7 +220,7 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri onClick={() => setHideStandardFields(!hideStandardFields)} title="Control customfields" className="min-w-0 p-0 h-8 w-8 md:h-10 md:w-10" - sx={{ + sx={{ borderColor: 'border.main' }} > @@ -244,9 +244,9 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri )} {search && search.length >= 3 && searchQuery && ( - - {searchQuery && typeFilter !== "all" + {searchQuery && typeFilter !== "all" ? `No ${typeFilter === "all" ? "" : typeFilter.replace("Attribute", "")} attributes found matching "${searchQuery}"` - : searchQuery + : searchQuery ? `No attributes found matching "${searchQuery}"` : `No ${typeFilter === "all" ? "" : typeFilter.replace("Attribute", "")} attributes available` } @@ -288,10 +288,10 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri )} ) : ( - - - - - handleSort('displayName')} - sx={{ color: 'text.primary' }} - > - - Display Name - - - - handleSort('schemaName')} - sx={{ color: 'text.primary' }} - > - - Schema Name - - - - handleSort('type')} - sx={{ color: 'text.primary' }} - > - - Type - - - - - Details - - handleSort('description')} - sx={{ color: 'text.primary' }} - > - - Description - - - - - - - {sortedAttributes.map((attribute, index) => ( - - - {highlightMatch(attribute.DisplayName, highlightTerm)} + + + handleSort('displayName')} + sx={{ color: 'text.primary' }} + > + + Display Name + + - - {highlightMatch(attribute.SchemaName, highlightTerm)} + handleSort('schemaName')} + sx={{ color: 'text.primary' }} + > + + Schema Name + + - {getAttributeComponent(entity, attribute, highlightMatch, highlightTerm)} - - - {highlightMatch(attribute.Description ?? "", highlightTerm)} + handleSort('type')} + sx={{ color: 'text.primary' }} + > + + Type + + + + + Details + + handleSort('description')} + sx={{ color: 'text.primary' }} + > + + Description + + - ))} - -
+ + + {sortedAttributes.map((attribute, index) => ( + + + {highlightMatch(attribute.DisplayName, highlightTerm)} + + + {highlightMatch(attribute.SchemaName, highlightTerm)} + + {getAttributeComponent(entity, attribute, highlightMatch, highlightTerm)} + + + {highlightMatch(attribute.Description ?? "", highlightTerm)} + + + ))} + +
)} diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index 00caa2a..48f83c2 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -6,10 +6,11 @@ import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/Datamodel import { SearchPerformanceProvider } from "@/contexts/SearchPerformanceContext"; import { List } from "./List"; import { TimeSlicedSearch } from "./TimeSlicedSearch"; -import React, { useState, useEffect, useRef, useCallback } from "react"; +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"; export function DatamodelView() { const { setElement, expand } = useSidebar(); @@ -27,16 +28,22 @@ export function DatamodelView() { } function DatamodelViewContent() { - const { scrollToSection, restoreSection } = useDatamodelView(); + const { scrollToSection, scrollToAttribute, restoreSection } = useDatamodelView(); const datamodelDispatch = useDatamodelViewDispatch(); const { groups, filtered } = useDatamodelData(); const datamodelDataDispatch = useDatamodelDataDispatch(); const workerRef = useRef(null); const [currentSearchIndex, setCurrentSearchIndex] = useState(0); - const accumulatedResultsRef = useRef>([]); // Track all results during search + const accumulatedResultsRef = useRef>([]); // Track all results during search - // Calculate total search results - const totalResults = filtered.length > 0 ? filtered.filter(item => item.type === 'entity').length : 0; + // Calculate total search results (prioritize attributes, fallback to entities) + 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; + }, [filtered]); const initialLocalValue = useSearchParams().get('globalsearch') || ""; // Isolated search handlers - these don't depend on component state @@ -63,42 +70,122 @@ function DatamodelViewContent() { 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; + } + + // Fallback: if elements can't be found, 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]); + + // 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 + const getSortedAttributeResults = useCallback(() => { + return cachedSortedResults; + }, [cachedSortedResults]); + // Navigation handlers const handleNavigateNext = useCallback(() => { if (currentSearchIndex < totalResults) { const nextIndex = currentSearchIndex + 1; setCurrentSearchIndex(nextIndex); - - // Find the next entity in filtered results - const entityResults = filtered.filter(item => item.type === 'entity'); - const nextEntity = entityResults[nextIndex - 1]; - if (nextEntity) { - datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: nextEntity.entity.SchemaName }); - datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: nextEntity.group.Name }); - - // Scroll to the section - scrollToSection(nextEntity.entity.SchemaName); + + // Get sorted attribute results + const sortedAttributeResults = getSortedAttributeResults(); + + // If we have attribute results, use them + if (sortedAttributeResults.length > 0) { + const nextResult = sortedAttributeResults[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); + } + } else { + // Fallback to entity results if no attributes found (e.g., searching by entity name) + const entityResults = filtered.filter((item): item is { type: 'entity'; group: GroupType; entity: EntityType } => + item.type === 'entity' + ); + + const nextResult = entityResults[nextIndex - 1]; + if (nextResult) { + datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: nextResult.entity.SchemaName }); + datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: nextResult.group.Name }); + scrollToSection(nextResult.entity.SchemaName); + } } } - }, [currentSearchIndex, totalResults, filtered, datamodelDispatch, scrollToSection]); + }, [currentSearchIndex, totalResults, getSortedAttributeResults, filtered, datamodelDispatch, scrollToAttribute, scrollToSection]); const handleNavigatePrevious = useCallback(() => { if (currentSearchIndex > 1) { const prevIndex = currentSearchIndex - 1; setCurrentSearchIndex(prevIndex); - - // Find the previous entity in filtered results - const entityResults = filtered.filter(item => item.type === 'entity'); - const prevEntity = entityResults[prevIndex - 1]; - if (prevEntity) { - datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: prevEntity.entity.SchemaName }); - datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: prevEntity.group.Name }); - - // Scroll to the section - scrollToSection(prevEntity.entity.SchemaName); + + // Get sorted attribute results + const sortedAttributeResults = getSortedAttributeResults(); + + // If we have attribute results, use them + if (sortedAttributeResults.length > 0) { + const prevResult = sortedAttributeResults[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); + } + } else { + // Fallback to entity results if no attributes found (e.g., searching by entity name) + const entityResults = filtered.filter((item): item is { type: 'entity'; group: GroupType; entity: EntityType } => + item.type === 'entity' + ); + + const prevResult = entityResults[prevIndex - 1]; + if (prevResult) { + datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: prevResult.entity.SchemaName }); + datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: prevResult.group.Name }); + scrollToSection(prevResult.entity.SchemaName); + } } } - }, [currentSearchIndex, filtered, datamodelDispatch, scrollToSection]); + }, [currentSearchIndex, getSortedAttributeResults, filtered, datamodelDispatch, scrollToAttribute, scrollToSection]); useEffect(() => { if (!workerRef.current) { @@ -115,7 +202,7 @@ function DatamodelViewContent() { const handleMessage = (e: MessageEvent) => { const message = e.data; - + if (message.type === 'started') { datamodelDispatch({ type: "SET_LOADING", payload: true }); // setSearchProgress(0); @@ -123,31 +210,51 @@ function DatamodelViewContent() { // Start with empty results to show loading state accumulatedResultsRef.current = []; // Reset accumulated results datamodelDataDispatch({ type: "SET_FILTERED", payload: [] }); - } + } else if (message.type === 'results') { // setSearchProgress(message.progress || 0); - + // Accumulate results in ref for immediate access accumulatedResultsRef.current = [...accumulatedResultsRef.current, ...message.data]; - + // For chunked results, always append to existing datamodelDataDispatch({ type: "APPEND_FILTERED", payload: message.data }); - + // Only handle completion logic when all chunks are received if (message.complete) { datamodelDispatch({ type: "SET_LOADING", payload: false }); // Set to first result if we have any and auto-navigate to it - // Use accumulated results from ref for immediate access - const allFilteredResults = accumulatedResultsRef.current.filter((item: { type: string }) => item.type === 'entity'); - if (allFilteredResults.length > 0) { + // Prioritize attributes, fallback to entities + const attributeResults = accumulatedResultsRef.current.filter((item: any): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => + item.type === 'attribute' + ); + + if (attributeResults.length > 0) { setCurrentSearchIndex(1); - const firstEntity = allFilteredResults[0]; - datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstEntity.entity.SchemaName }); - datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstEntity.group.Name }); + // Use the first result from the array (will be sorted when user navigates) + const firstResult = attributeResults[0]; + datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstResult.entity.SchemaName }); + datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstResult.group.Name }); // Small delay to ensure virtual list is ready setTimeout(() => { - scrollToSection(firstEntity.entity.SchemaName); + // Always scroll to attribute since we have attribute results + scrollToAttribute(firstResult.entity.SchemaName, firstResult.attribute.SchemaName); }, 100); + } else { + // Fallback to entity results + const entityResults = accumulatedResultsRef.current.filter((item: any): item is { type: 'entity'; group: GroupType; entity: EntityType } => + item.type === 'entity' + ); + + if (entityResults.length > 0) { + setCurrentSearchIndex(1); + const firstResult = entityResults[0]; + datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstResult.entity.SchemaName }); + datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstResult.group.Name }); + setTimeout(() => { + scrollToSection(firstResult.entity.SchemaName); + }, 100); + } } } } @@ -155,24 +262,43 @@ function DatamodelViewContent() { // Handle legacy format for backward compatibility datamodelDataDispatch({ type: "SET_FILTERED", payload: message }); datamodelDispatch({ type: "SET_LOADING", payload: false }); - // Set to first result if we have any and auto-navigate to it - const entityResults = message.filter((item: { type: string }) => item.type === 'entity'); - if (entityResults.length > 0) { + // Set to first result if we have any and auto-navigate to it - prioritize attributes + const attributeResults = message.filter((item: any): item is { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } => + item.type === 'attribute' + ); + + if (attributeResults.length > 0) { setCurrentSearchIndex(1); - const firstEntity = entityResults[0]; - datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstEntity.entity.SchemaName }); - datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstEntity.group.Name }); + const firstResult = attributeResults[0]; + datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstResult.entity.SchemaName }); + datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstResult.group.Name }); // Small delay to ensure virtual list is ready setTimeout(() => { - scrollToSection(firstEntity.entity.SchemaName); + // Always scroll to attribute since we have attribute results + scrollToAttribute(firstResult.entity.SchemaName, firstResult.attribute.SchemaName); }, 100); + } else { + // Fallback to entity results + const entityResults = message.filter((item: any): item is { type: 'entity'; group: GroupType; entity: EntityType } => + item.type === 'entity' + ); + + if (entityResults.length > 0) { + setCurrentSearchIndex(1); + const firstResult = entityResults[0]; + datamodelDispatch({ type: "SET_CURRENT_SECTION", payload: firstResult.entity.SchemaName }); + datamodelDispatch({ type: "SET_CURRENT_GROUP", payload: firstResult.group.Name }); + setTimeout(() => { + scrollToSection(firstResult.entity.SchemaName); + }, 100); + } } } }; worker.addEventListener("message", handleMessage); return () => worker.removeEventListener("message", handleMessage); - }, [datamodelDispatch, datamodelDataDispatch, groups]); + }, [datamodelDispatch, datamodelDataDispatch, groups, scrollToSection, scrollToAttribute]); if (!groups) { return ( @@ -206,8 +332,8 @@ function DatamodelViewContent() { /> )} */} - { const parentRef = useRef(null); // used to relocate section after search/filter const [sectionVirtualItem, setSectionVirtualItem] = useState(null); - + const handleCopyGroupLink = useCallback(async (groupName: string) => { const link = generateGroupLink(groupName); const success = await copyToClipboard(link); @@ -43,7 +43,8 @@ export const List = ({ setCurrentIndex }: IListProps) => { // Only recalculate items when filtered or search changes const flatItems = useMemo(() => { - if (filtered && filtered.length > 0) return filtered; + if (filtered && filtered.length > 0) return filtered.filter(item => item.type !== 'attribute'); + const lowerSearch = search.trim().toLowerCase(); const items: Array< | { type: 'group'; group: GroupType } @@ -78,7 +79,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { if (!sync) { dispatch({ type: 'SET_LOADING_SECTION', payload: null }); } - + const virtualItems = instance.getVirtualItems(); if (virtualItems.length === 0) return; @@ -101,19 +102,19 @@ export const List = ({ setCurrentIndex }: IListProps) => { const item = flatItems[vi.index]; if (!item || item.type !== 'entity') continue; actualIndex++; - + const itemTop = vi.start; const itemBottom = vi.end; - + // Calculate intersection const intersectionTop = Math.max(itemTop, viewportTop); const intersectionBottom = Math.min(itemBottom, viewportBottom); - + // Skip if no intersection if (intersectionTop >= intersectionBottom) continue; - + const visibleArea = intersectionBottom - intersectionTop; - + // Update most visible entity without array operations if (!mostVisibleEntity || visibleArea > mostVisibleEntity.visibleArea) { mostVisibleEntity = { @@ -144,16 +145,16 @@ export const List = ({ setCurrentIndex }: IListProps) => { estimateSize: (index) => { const item = flatItems[index]; if (!item) return 200; - return item.type === 'group' ? 100 : 500; + return item.type === 'group' ? 100 : 500; }, onChange: debouncedOnChange, }); - + const scrollToSection = useCallback((sectionId: string) => { - const sectionIndex = flatItems.findIndex(item => + const sectionIndex = flatItems.findIndex(item => item.type === 'entity' && item.entity.SchemaName === sectionId ); - + if (sectionIndex === -1) { console.warn(`Section ${sectionId} not found in virtualized list`); return; @@ -163,11 +164,30 @@ export const List = ({ setCurrentIndex }: IListProps) => { }, [flatItems]); + const scrollToAttribute = useCallback((sectionId: string, attrSchema: string) => { + const attrId = `attr-${sectionId}-${attrSchema}`; + const attributeLocation = document.getElementById(attrId); + + if (attributeLocation) { + // Attribute is already rendered, scroll directly to it + attributeLocation.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + // Attribute not found, need to scroll to section first + scrollToSection(sectionId); + setTimeout(() => { + const attributeLocationAfterScroll = document.getElementById(attrId); + if (attributeLocationAfterScroll) { + attributeLocationAfterScroll.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); + } + }, [scrollToSection]); + const scrollToGroup = useCallback((groupName: string) => { - const groupIndex = flatItems.findIndex(item => + const groupIndex = flatItems.findIndex(item => item.type === 'group' && item.group.Name === groupName ); - + if (groupIndex === -1) { console.warn(`Group ${groupName} not found in virtualized list`); return; @@ -184,9 +204,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_GROUP', payload: scrollToGroup }); dispatch({ type: 'SET_RESTORE_SECTION', payload: restoreSection }); - }, [dispatch, scrollToSection, scrollToGroup]); + }, [dispatch, scrollToSection, scrollToAttribute, scrollToGroup]); // Callback to handle section content changes (for tab switches, expansions, etc.) const handleSectionResize = useCallback((index: number) => { @@ -213,7 +234,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { }); }; requestAnimationFrame(tryFix); - }, [rowVirtualizer]); + }, [rowVirtualizer]); return ( <> @@ -231,7 +252,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { )} - + {/* Virtualized list */}
{
-
handleCopyGroupLink(item.group.Name)} > @@ -284,7 +305,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { entity={item.entity} group={item.group} onTabChange={() => { - + }} search={search} /> diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index 5f58692..dfb61a3 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -23,9 +23,9 @@ export const Section = React.memo( ({ entity, group, onContentChange, onTabChange, search }: ISectionProps) => { // Use useRef to track previous props for comparison const prevSearch = React.useRef(search); - + const [tab, setTab] = React.useState(0); - + // Handle tab changes to notify parent component const handleTabChange = React.useCallback((event: React.SyntheticEvent, newValue: number) => { if (onTabChange) { @@ -33,7 +33,7 @@ export const Section = React.memo( } setTab(newValue); }, [onTabChange]); - + // Only compute these counts when needed const visibleAttributeCount = React.useMemo(() => entity.Attributes.length, [entity.Attributes]); const visibleRelationshipCount = React.useMemo(() => entity.Relationships.length, [entity.Relationships]); @@ -41,16 +41,16 @@ export const Section = React.memo( // Only call onContentChange when something actually changes React.useEffect(() => { - if (onContentChange && - (prevSearch.current !== search || - tab !== 0)) { + if (onContentChange && + (prevSearch.current !== search || + tab !== 0)) { prevSearch.current = search; onContentChange(); } }, [tab, search, onContentChange]); return ( -
+ @@ -63,8 +63,8 @@ export const Section = React.memo( - - @@ -82,7 +82,7 @@ export const Section = React.memo( } /> {entity.Relationships.length > 0 && ( - @@ -92,7 +92,7 @@ export const Section = React.memo( /> )} {entity.Keys.length > 0 && ( - @@ -102,7 +102,7 @@ export const Section = React.memo( /> )} - + @@ -112,9 +112,9 @@ export const Section = React.memo( )} {entity.Keys.length > 0 && ( - 0 ? 2 : 1} + 0 ? 2 : 1} className="m-0 p-0" > @@ -123,15 +123,15 @@ export const Section = React.memo( -
+ ) }, // Custom comparison function to prevent unnecessary re-renders (prevProps, nextProps) => { // Only re-render if entity, search or group changes - return prevProps.entity.SchemaName === nextProps.entity.SchemaName && - prevProps.search === nextProps.search && - prevProps.group.Name === nextProps.group.Name; + return prevProps.entity.SchemaName === nextProps.entity.SchemaName && + prevProps.search === nextProps.search && + prevProps.group.Name === nextProps.group.Name; } ); diff --git a/Website/components/datamodelview/searchWorker.ts b/Website/components/datamodelview/searchWorker.ts index 433eef4..dfa0063 100644 --- a/Website/components/datamodelview/searchWorker.ts +++ b/Website/components/datamodelview/searchWorker.ts @@ -18,6 +18,7 @@ interface ResultsMessage { data: Array< | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } + | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } >; complete: boolean; progress?: number; @@ -32,17 +33,22 @@ type WorkerResponse = ResultsMessage | StartedMessage; let groups: GroupType[] | null = null; const CHUNK_SIZE = 20; // Process results in chunks -self.onmessage = async function(e: MessageEvent) { +self.onmessage = async function (e: MessageEvent) { // Handle initialization if (e.data && typeof e.data === 'object' && 'type' in e.data && e.data.type === 'init') { groups = e.data.groups; return; } - + + if (!groups) { + const response: WorkerResponse = { type: 'results', data: [], complete: true }; + self.postMessage(response); + return; + } + // Handle search const search = (typeof e.data === 'string' ? e.data : e.data?.data || '').trim().toLowerCase(); - - if (!groups) { + if (!search) { const response: WorkerResponse = { type: 'results', data: [], complete: true }; self.postMessage(response); return; @@ -51,61 +57,58 @@ self.onmessage = async function(e: MessageEvent) { // First quickly send back a "started" message const startedMessage: WorkerResponse = { type: 'started' }; self.postMessage(startedMessage); - + const allItems: Array< | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } + | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } > = []; - - // Find all matches + + //////////////////////////////////////////////// + // Finding matches part + //////////////////////////////////////////////// for (const group of groups) { - const filteredEntities = group.Entities.filter((entity: EntityType) => { - if (!search) return true; - - // Match entity schema or display name - const entityMatch = entity.SchemaName.toLowerCase().includes(search) || - (entity.DisplayName && entity.DisplayName.toLowerCase().includes(search)); - - // Match any attribute schema, display name, description, or option names - const attrMatch = entity.Attributes.some((attr: AttributeType) => { + let groupUsed = false; + for (const entity of group.Entities) { + // Find all matching attributes + const matchingAttributes = entity.Attributes.filter((attr: AttributeType) => { const basicMatch = attr.SchemaName.toLowerCase().includes(search) || (attr.DisplayName && attr.DisplayName.toLowerCase().includes(search)) || (attr.Description && attr.Description.toLowerCase().includes(search)); - - // Check options for ChoiceAttribute and StatusAttribute let optionsMatch = false; if (attr.AttributeType === 'ChoiceAttribute' || attr.AttributeType === 'StatusAttribute') { optionsMatch = attr.Options.some(option => option.Name.toLowerCase().includes(search)); } - + return basicMatch || optionsMatch; }); - - return entityMatch || attrMatch; - }); - - if (filteredEntities.length > 0) { - allItems.push({ type: 'group', group }); - for (const entity of filteredEntities) { + + // If we have matching attributes, add the entity first (for sidebar) then the attributes + if (matchingAttributes.length > 0) { + if (!groupUsed) allItems.push({ type: 'group', group }); + groupUsed = true; allItems.push({ type: 'entity', group, entity }); + for (const attr of matchingAttributes) { + allItems.push({ type: 'attribute', group, entity, attribute: attr }); + } } } } - + // Send results in chunks to prevent UI blocking for (let i = 0; i < allItems.length; i += CHUNK_SIZE) { const chunk = allItems.slice(i, i + CHUNK_SIZE); const isLastChunk = i + CHUNK_SIZE >= allItems.length; - + const response: WorkerResponse = { type: 'results', data: chunk, complete: isLastChunk, progress: Math.min(100, Math.round((i + CHUNK_SIZE) / allItems.length * 100)) }; - + self.postMessage(response); - + // Small delay between chunks to let the UI breathe if (!isLastChunk) { // Use a proper yielding mechanism to let the UI breathe diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index 5fa747e..1ea1ed8 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 { EntityType, GroupType, RelationshipType, SolutionType, SolutionWarningType } from "@/lib/Types"; +import { AttributeType, EntityType, GroupType, SolutionType, SolutionWarningType } from "@/lib/Types"; import { useSearchParams } from "next/navigation"; interface DataModelAction { @@ -17,6 +17,7 @@ interface DatamodelDataState extends DataModelAction { filtered: Array< | { type: 'group'; group: GroupType } | { type: 'entity'; group: GroupType; entity: EntityType } + | { type: 'attribute'; group: GroupType; entity: EntityType; attribute: AttributeType } >; } diff --git a/Website/contexts/DatamodelViewContext.tsx b/Website/contexts/DatamodelViewContext.tsx index 97c540f..744b5c4 100644 --- a/Website/contexts/DatamodelViewContext.tsx +++ b/Website/contexts/DatamodelViewContext.tsx @@ -9,6 +9,7 @@ export interface DatamodelViewState { currentSection: string | null; scrollToSection: (sectionId: string) => void; scrollToGroup: (groupName: string) => void; + scrollToAttribute: (sectionId: string, attrSchema: string) => void; loading: boolean; loadingSection: string | null; restoreSection: () => void; @@ -19,19 +20,21 @@ const initialState: DatamodelViewState = { currentSection: null, scrollToSection: () => { throw new Error("scrollToSection not initialized yet!"); }, scrollToGroup: () => { throw new Error("scrollToGroup not initialized yet!"); }, + scrollToAttribute: () => { throw new Error("scrollToAttribute not initialized yet!"); }, loading: true, loadingSection: null, restoreSection: () => { throw new Error("restoreSection not initialized yet!"); }, } -type DatamodelViewAction = +type DatamodelViewAction = | { type: 'SET_CURRENT_GROUP', payload: string | null } | { type: 'SET_CURRENT_SECTION', payload: string | null } | { type: 'SET_SCROLL_TO_SECTION', payload: (sectionId: string) => void } | { type: 'SET_SCROLL_TO_GROUP', payload: (groupName: string) => void } | { type: 'SET_LOADING', payload: boolean } | { type: 'SET_LOADING_SECTION', payload: string | null } - | { type: 'SET_RESTORE_SECTION', payload: () => void }; + | { type: 'SET_RESTORE_SECTION', payload: () => void } + | { type: 'SET_SCROLL_TO_ATTRIBUTE', payload: (sectionId: string, attrSchema: string) => void }; const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAction): DatamodelViewState => { @@ -50,6 +53,8 @@ const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAc return { ...state, loadingSection: action.payload } case 'SET_RESTORE_SECTION': return { ...state, restoreSection: action.payload } + case 'SET_SCROLL_TO_ATTRIBUTE': + return { ...state, scrollToAttribute: action.payload } default: return state; } @@ -70,7 +75,7 @@ export const DatamodelViewProvider = ({ children }: { children: ReactNode }) => try { datamodelViewState.scrollToSection(""); } catch { return; } dispatch({ type: "SET_CURRENT_GROUP", payload: groupParam }); dispatch({ type: "SET_CURRENT_SECTION", payload: sectionParam }); - datamodelViewState.scrollToSection(sectionParam); + datamodelViewState.scrollToSection(sectionParam); }, [datamodelViewState.scrollToSection]) return ( From 6cedd7869ab561285890d7c09756f0e3bcb14227 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 4 Nov 2025 19:58:35 +0100 Subject: [PATCH 02/11] fix: index changing when searching --- Website/components/datamodelview/List.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index ebd1cd5..323a73f 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -134,7 +134,10 @@ export const List = ({ setCurrentIndex }: IListProps) => { updateURL({ query: { group: mostVisibleEntity.group.Name, section: mostVisibleEntity.entity.SchemaName } }); dispatch({ type: "SET_CURRENT_GROUP", payload: mostVisibleEntity.group.Name }); dispatch({ type: "SET_CURRENT_SECTION", payload: mostVisibleEntity.entity.SchemaName }); - setCurrentIndex(mostVisibleEntity.index); + // Only update the index when not searching - during search, index should only change via next/previous buttons + if (!search) { + setCurrentIndex(mostVisibleEntity.index); + } } }, 100); From 8e56a59077898fbb87c54017e2d7857fc05f5d85 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 4 Nov 2025 20:00:54 +0100 Subject: [PATCH 03/11] chore: fraction in search results --- .../components/datamodelview/TimeSlicedSearch.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 633ebc0..7f596d2 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -254,11 +254,15 @@ export const TimeSlicedSearch = ({ {isTyping && localValue.length >= 3 ? ( - ) : localValue ? ( - - {currentIndex} - {totalResults} - + ) : localValue && totalResults !== undefined && totalResults > 0 ? ( + + {currentIndex}/{totalResults} + ) : null} From 45d89b6fd9bf3d12031045181602845135a8fd79 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 4 Nov 2025 20:03:11 +0100 Subject: [PATCH 04/11] fix: remove navigation option menu when focusing searchfield --- Website/components/datamodelview/TimeSlicedSearch.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 7f596d2..f3e421c 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -229,6 +229,13 @@ export const TimeSlicedSearch = ({ setAnchorEl(null); }; + // Close menu when focus moves back to search input or elsewhere + const handleSearchFocus = () => { + if (open) { + setAnchorEl(null); + } + }; + const searchInput = ( @@ -245,6 +252,7 @@ export const TimeSlicedSearch = ({ aria-label="Search attributes in tables" value={localValue} onChange={handleChange} + onFocus={handleSearchFocus} spellCheck={false} autoComplete="off" autoCapitalize="off" From bbfa6fef1c52f2ffb9a7fad6185519a9fc7ad926 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 4 Nov 2025 21:10:05 +0100 Subject: [PATCH 05/11] fix: cleanup and fix to the scrolladjustments tanstack performs on dynamic height changes --- Website/.claude/settings.local.json | 12 +++++++ .../components/datamodelview/Attributes.tsx | 11 ++---- Website/components/datamodelview/Keys.tsx | 10 ++---- Website/components/datamodelview/List.tsx | 24 ++++--------- .../datamodelview/Relationships.tsx | 10 ++---- Website/components/datamodelview/Section.tsx | 34 +++---------------- 6 files changed, 30 insertions(+), 71 deletions(-) create mode 100644 Website/.claude/settings.local.json diff --git a/Website/.claude/settings.local.json b/Website/.claude/settings.local.json new file mode 100644 index 0000000..47115b5 --- /dev/null +++ b/Website/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run lint:*)", + "Bash(npm install:*)", + "Bash(npm run build:*)", + "Bash(npx tsc:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 3254e30..f51c049 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -23,10 +23,10 @@ type SortColumn = 'displayName' | 'schemaName' | 'type' | 'description' | null interface IAttributeProps { entity: EntityType - onVisibleCountChange?: (count: number) => void + search?: string } -export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttributeProps & { search?: string }) => { +export const Attributes = ({ entity, search = "" }: IAttributeProps) => { const [sortColumn, setSortColumn] = useState("displayName") const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") @@ -124,13 +124,6 @@ export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttri const sortedAttributes = getSortedAttributes(); const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting - // Notify parent of visible count - React.useEffect(() => { - if (onVisibleCountChange) { - onVisibleCountChange(sortedAttributes.length); - } - }, [onVisibleCountChange, sortedAttributes.length]); - const SortIcon = ({ column }: { column: SortColumn }) => { if (sortColumn !== column) return if (sortDirection === 'asc') return diff --git a/Website/components/datamodelview/Keys.tsx b/Website/components/datamodelview/Keys.tsx index 999546d..c6ef9eb 100644 --- a/Website/components/datamodelview/Keys.tsx +++ b/Website/components/datamodelview/Keys.tsx @@ -26,10 +26,10 @@ type SortDirection = 'asc' | 'desc' | null interface IKeysProps { entity: EntityType; - onVisibleCountChange?: (count: number) => void; + search?: string; } -function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { search?: string }) { +function Keys({ entity, search = "" }: IKeysProps) { const [sortColumn, setSortColumn] = useState("name") const [sortDirection, setSortDirection] = useState("asc") const [searchQuery, setSearchQuery] = useState("") @@ -107,12 +107,6 @@ function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { sear const sortedKeys = getSortedKeys(); const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting - React.useEffect(() => { - if (onVisibleCountChange) { - onVisibleCountChange(sortedKeys.length); - } - }, [onVisibleCountChange, sortedKeys.length]); - const SortIcon = ({ column }: { column: SortColumn }) => { if (sortColumn !== column) return if (sortDirection === 'asc') return diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 323a73f..7301919 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -153,6 +153,12 @@ export const List = ({ setCurrentIndex }: IListProps) => { onChange: debouncedOnChange, }); + // Set shouldAdjustScrollPositionOnItemSizeChange to prevent scroll position adjustments + // when item sizes change due to filtering/searching + useEffect(() => { + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; + }, [rowVirtualizer]); + const scrollToSection = useCallback((sectionId: string) => { const sectionIndex = flatItems.findIndex(item => item.type === 'entity' && item.entity.SchemaName === sectionId @@ -212,16 +218,6 @@ export const List = ({ setCurrentIndex }: IListProps) => { dispatch({ type: 'SET_RESTORE_SECTION', payload: restoreSection }); }, [dispatch, scrollToSection, scrollToAttribute, scrollToGroup]); - // Callback to handle section content changes (for tab switches, expansions, etc.) - const handleSectionResize = useCallback((index: number) => { - if (index !== -1) { - const containerElement = document.querySelector(`[data-index="${index}"]`) as HTMLElement; - if (containerElement) { - rowVirtualizer.measureElement(containerElement); - } - } - }, [rowVirtualizer]); - const smartScrollToIndex = useCallback((index: number) => { rowVirtualizer.scrollToIndex(index, { align: 'start' }); @@ -282,10 +278,7 @@ export const List = ({ setCurrentIndex }: IListProps) => { }} ref={(el) => { if (el) { - // trigger remeasurement when content changes and load - requestAnimationFrame(() => { - handleSectionResize(virtualItem.index); - }); + rowVirtualizer.measureElement(el); } }} > @@ -307,9 +300,6 @@ export const List = ({ setCurrentIndex }: IListProps) => {
{ - - }} search={search} />
diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index daa4c6b..72291d5 100644 --- a/Website/components/datamodelview/Relationships.tsx +++ b/Website/components/datamodelview/Relationships.tsx @@ -14,10 +14,10 @@ type SortColumn = 'name' | 'tableSchema' | 'lookupField' | 'type' | 'behavior' | interface IRelationshipsProps { entity: EntityType; - onVisibleCountChange?: (count: number) => void; + search?: string; } -export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRelationshipsProps & { search?: string }) => { +export const Relationships = ({ entity, search = "" }: IRelationshipsProps) => { const [sortColumn, setSortColumn] = useState("name") const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") @@ -130,12 +130,6 @@ export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRe const sortedRelationships = getSortedRelationships(); const highlightTerm = searchQuery || search; // Use internal search or parent search for highlighting - React.useEffect(() => { - if (onVisibleCountChange) { - onVisibleCountChange(sortedRelationships.length); - } - }, [onVisibleCountChange, sortedRelationships.length]); - return <> void; - onTabChange?: (isChanging: boolean) => void; search?: string; } export const Section = React.memo( - ({ entity, group, onContentChange, onTabChange, search }: ISectionProps) => { - // Use useRef to track previous props for comparison - const prevSearch = React.useRef(search); - + ({ entity, group, search }: ISectionProps) => { const [tab, setTab] = React.useState(0); - // Handle tab changes to notify parent component const handleTabChange = React.useCallback((event: React.SyntheticEvent, newValue: number) => { - if (onTabChange) { - onTabChange(true); - } setTab(newValue); - }, [onTabChange]); - - // Only compute these counts when needed - const visibleAttributeCount = React.useMemo(() => entity.Attributes.length, [entity.Attributes]); - const visibleRelationshipCount = React.useMemo(() => entity.Relationships.length, [entity.Relationships]); - const visibleKeyCount = React.useMemo(() => entity.Keys.length, [entity.Keys]); - - // Only call onContentChange when something actually changes - React.useEffect(() => { - if (onContentChange && - (prevSearch.current !== search || - tab !== 0)) { - prevSearch.current = search; - onContentChange(); - } - }, [tab, search, onContentChange]); + }, []); return ( @@ -77,7 +53,7 @@ export const Section = React.memo( label={
- Attributes [{visibleAttributeCount}] + Attributes [{entity.Attributes.length}]
} /> @@ -86,7 +62,7 @@ export const Section = React.memo( label={
- Relationships [{visibleRelationshipCount}] + Relationships [{entity.Relationships.length}]
} /> @@ -96,7 +72,7 @@ export const Section = React.memo( label={
- Keys [{visibleKeyCount}] + Keys [{entity.Keys.length}]
} /> From 1a923af697af081bb1ebb8e9d795a2448048ba1c Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 5 Nov 2025 19:22:50 +0100 Subject: [PATCH 06/11] fix: NODE upgrade --- Infrastructure/main.bicep | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index c3fa9ac..7e32d13 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -34,7 +34,7 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { serverFarmId: appServicePlan.id httpsOnly: true siteConfig: { - linuxFxVersion: 'NODE|20-lts' + linuxFxVersion: 'NODE|24-lts' appSettings: [ { From c34e912beab1b5e674c5268c7cd623da00b3d3f6 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 5 Nov 2025 19:33:33 +0100 Subject: [PATCH 07/11] chore: show Status inside Choice type filter --- Website/components/datamodelview/Attributes.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index f51c049..cedd525 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -70,7 +70,14 @@ export const Attributes = ({ entity, search = "" }: IAttributeProps) => { let filteredAttributes = entity.Attributes if (typeFilter !== "all") { - filteredAttributes = filteredAttributes.filter(attr => attr.AttributeType === typeFilter) + if (typeFilter === "ChoiceAttribute") { + // When Choice is selected, show both ChoiceAttribute and StatusAttribute + filteredAttributes = filteredAttributes.filter(attr => + attr.AttributeType === "ChoiceAttribute" || attr.AttributeType === "StatusAttribute" + ) + } else { + filteredAttributes = filteredAttributes.filter(attr => attr.AttributeType === typeFilter) + } } if (searchQuery) { @@ -139,7 +146,6 @@ export const Attributes = ({ entity, search = "" }: IAttributeProps) => { "IntegerAttribute", "LookupAttribute", "DecimalAttribute", - "StatusAttribute", "StringAttribute", "BooleanAttribute", "FileAttribute" From de60c968cb0a55859fce61baf20f95a8f70489c1 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 5 Nov 2025 19:36:43 +0100 Subject: [PATCH 08/11] chore: tooltip for show/hide standard uses MUI and is dynamic --- .../components/datamodelview/Attributes.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index cedd525..63c513e 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -15,7 +15,7 @@ import StatusAttribute from "./attributes/StatusAttribute" import StringAttribute from "./attributes/StringAttribute" import React from "react" import { highlightMatch } from "../datamodelview/List"; -import { Box, Button, FormControl, InputAdornment, InputLabel, MenuItem, Select, Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography, useTheme } from "@mui/material" +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" type SortDirection = 'asc' | 'desc' | null @@ -213,20 +213,21 @@ export const Attributes = ({ entity, search = "" }: IAttributeProps) => { ))} - + + + {(searchQuery || typeFilter !== "all") && (