From eb6f434541f833756fbfffd9cd2d70ade467e569 Mon Sep 17 00:00:00 2001 From: boer Date: Fri, 18 Jul 2025 18:50:16 +0200 Subject: [PATCH 01/19] chore: pipelinechanges --- .github/workflows/release.yml | 51 +++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15c4605..35cd367 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,3 @@ -# .github/workflows/release.yml name: Release on: @@ -17,7 +16,6 @@ on: jobs: release-please: runs-on: ubuntu-latest - # Only run on merged PRs or manual dispatch if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true) outputs: releases_created: ${{ steps.manual_release.outputs.releases_created }} @@ -38,58 +36,65 @@ jobs: run: | npm install -g release-please npm install semver - - # Configure git + git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - - # Get current version from manifest + CURRENT_VERSION=$(cat .release-please-manifest.json | jq -r '.Website') echo "Current version: $CURRENT_VERSION" - - # Calculate next version based on release type using Node.js + NEXT_VERSION=$(node -e " const semver = require('semver'); const current = '$CURRENT_VERSION'; const type = '${{ github.event.inputs.release_type }}'; console.log(semver.inc(current, type)); ") - + echo "Next version will be: $NEXT_VERSION" - - # Update version in package.json + cd Website npm version $NEXT_VERSION --no-git-tag-version cd .. - - # Update manifest file + jq --arg version "$NEXT_VERSION" '.Website = $version' .release-please-manifest.json > temp.json && mv temp.json .release-please-manifest.json - - # Generate changelog entry + echo "## [$NEXT_VERSION] - $(date +'%Y-%m-%d')" > temp_changelog.md echo "" >> temp_changelog.md echo "### Changed" >> temp_changelog.md - echo "- Manual ${{ github.event.inputs.release_type }} release" >> temp_changelog.md + + LAST_TAG="website-v$CURRENT_VERSION" + git fetch --tags + if git rev-parse "$LAST_TAG" >/dev/null 2>&1; then + echo "- Changes since $CURRENT_VERSION:" >> temp_changelog.md + COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:"- %s") + if [ -n "$COMMITS" ]; then + echo "$COMMITS" >> temp_changelog.md + else + echo "- No new commits since last version" >> temp_changelog.md + fi + else + echo "- Manual ${{ github.event.inputs.release_type }} release" >> temp_changelog.md + echo "- (No previous tag $LAST_TAG found to compare commits)" >> temp_changelog.md + fi echo "" >> temp_changelog.md - - # Prepend to existing changelog if it exists + if [ -f "Website/CHANGELOG.md" ]; then cat temp_changelog.md Website/CHANGELOG.md > temp_full_changelog.md mv temp_full_changelog.md Website/CHANGELOG.md else mv temp_changelog.md Website/CHANGELOG.md fi - - # Commit and push changes + git add . git commit -m "chore(release): release $NEXT_VERSION Release type: ${{ github.event.inputs.release_type }} Previous version: $CURRENT_VERSION New version: $NEXT_VERSION" - - git push origin HEAD - + + git tag -a "website-v$NEXT_VERSION" -m "Release $NEXT_VERSION" + git push origin HEAD --tags + echo "releases_created=true" >> $GITHUB_OUTPUT echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT From 543d4539d9bf371cba806e0b2e00e4fcf8ff863f Mon Sep 17 00:00:00 2001 From: boer Date: Fri, 18 Jul 2025 18:58:22 +0200 Subject: [PATCH 02/19] chore: statusattribute styling --- .../components/attributes/StatusAttribute.tsx | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/Website/components/attributes/StatusAttribute.tsx b/Website/components/attributes/StatusAttribute.tsx index 41974bb..c2f0b38 100644 --- a/Website/components/attributes/StatusAttribute.tsx +++ b/Website/components/attributes/StatusAttribute.tsx @@ -1,31 +1,50 @@ -import { StatusAttributeType, StatusOption } from "@/lib/Types" -import { formatNumberSeperator } from "@/lib/utils" +import { useIsMobile } from "@/hooks/use-mobile"; +import { StatusAttributeType, StatusOption } from "@/lib/Types"; +import { formatNumberSeperator } from "@/lib/utils"; +import { CheckCircle, Circle } from "lucide-react"; export default function StatusAttribute({ attribute }: { attribute: StatusAttributeType }) { + const isMobile = useIsMobile(); const groupedOptions = attribute.Options.reduce((acc, option) => { if (!acc[option.State]) { - acc[option.State] = [] + acc[option.State] = []; } - acc[option.State].push(option) - return acc - }, {} as Record) + acc[option.State].push(option); + return acc; + }, {} as Record); return ( -
- State/Status +
+
+ State/Status + {/* No DefaultValue for StatusAttributeType, so no default badge */} +
{Object.entries(groupedOptions).map(([state, options]) => (
{state} -
+
{options.map(option => ( -
- {option.Name} - {formatNumberSeperator(option.Value)} +
+
+
+
+ {/* No DefaultValue, so always show Circle icon */} + + {option.Name} +
+
+
+ + {formatNumberSeperator(option.Value)} + +
+
+ {/* No Description property */}
))}
))}
- ) + ); } \ No newline at end of file From f5c87196d974695bf10eb8234f030d721ca02929 Mon Sep 17 00:00:00 2001 From: boer Date: Fri, 18 Jul 2025 19:04:22 +0200 Subject: [PATCH 03/19] chore: PBI 119532 - replace entity with table --- Website/components/datamodelview/Attributes.tsx | 2 +- Website/components/datamodelview/Keys.tsx | 2 +- Website/components/datamodelview/Relationships.tsx | 2 +- Website/components/datamodelview/SidebarDatamodelView.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 6e18c35..2280c9a 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -198,7 +198,7 @@ export const Attributes = ({ entity }: IAttributeProps) => {
) : ( -

No attributes available for this entity

+

No attributes available for this table

)}
) : ( diff --git a/Website/components/datamodelview/Keys.tsx b/Website/components/datamodelview/Keys.tsx index f8dcc2c..dfab1f2 100644 --- a/Website/components/datamodelview/Keys.tsx +++ b/Website/components/datamodelview/Keys.tsx @@ -120,7 +120,7 @@ function Keys({ entity }: { entity: EntityType }) {
) : ( -

No keys available for this entity

+

No keys available for this table

)} ) : ( diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 8b48d73..68a8162 100644 --- a/Website/components/datamodelview/Relationships.tsx +++ b/Website/components/datamodelview/Relationships.tsx @@ -177,7 +177,7 @@ export const Relationships = ({ entity }: IRelationshipsProps) => { ) : ( -

No relationships available for this entity

+

No relationships available for this table

)} ) : ( diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 67936c2..5616f91 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -170,8 +170,8 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { handleSearch(e.target.value)} className="pl-8 pr-8 h-8 text-xs" From 0d0f57f6ed9f31097558a436d63f1ea2874e908b Mon Sep 17 00:00:00 2001 From: boer Date: Fri, 18 Jul 2025 19:21:34 +0200 Subject: [PATCH 04/19] chore: PBI 119531 - ability to minimize sidebare content area --- Website/components/AppSidebar.tsx | 44 ++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/Website/components/AppSidebar.tsx b/Website/components/AppSidebar.tsx index 91b0dc2..3105390 100644 --- a/Website/components/AppSidebar.tsx +++ b/Website/components/AppSidebar.tsx @@ -5,6 +5,7 @@ import { SidebarClose, SidebarOpen } from 'lucide-react' import { useIsMobile } from '@/hooks/use-mobile' import SidebarNavRail from './SidebarNavRail' import clsx from 'clsx' +import { useState } from 'react'; interface IAppSidebarProps {} @@ -12,11 +13,16 @@ export const AppSidebar = ({}: IAppSidebarProps) => { const { element, isOpen } = useSidebar() const dispatch = useSidebarDispatch() const isMobile = useIsMobile() + const [showElement, setShowElement] = useState(true) const toggleSidebar = () => { dispatch({ type: 'SET_OPEN', payload: !isOpen }) } + const toggleElement = () => { + setShowElement(v => !v) + } + return ( <> {/* Toggle Button (mobile only) */} @@ -34,29 +40,55 @@ export const AppSidebar = ({}: IAppSidebarProps) => { {/* Sidebar */}
{/* Header */} -
+
{isMobile ? ( Logo ) : ( - Logo + showElement ? ( + Logo + ) : ( + Logo + ) )}
+ {/* Vertically centered sidebar toggle button (desktop only) */} + {!isMobile && ( + + )} + {/* Content */}
- {element} + {(isMobile || showElement) && element}
From fa450b52ef3f5354a07bb52c668961a5f037fab9 Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 20 Jul 2025 17:47:19 +0200 Subject: [PATCH 05/19] chore: PBI 119588 - counts are change to be only of visible items --- .../components/datamodelview/Attributes.tsx | 17 +++++++++++++--- Website/components/datamodelview/Keys.tsx | 20 ++++++++++++++++--- .../datamodelview/Relationships.tsx | 16 ++++++++++++--- Website/components/datamodelview/Section.tsx | 15 ++++++++------ 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 2280c9a..b24a8bb 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -18,15 +18,17 @@ import IntegerAttribute from "./../attributes/IntegerAttribute" import LookupAttribute from "./../attributes/LookupAttribute" import StatusAttribute from "./../attributes/StatusAttribute" import StringAttribute from "./../attributes/StringAttribute" +import React from "react" type SortDirection = 'asc' | 'desc' | null type SortColumn = 'displayName' | 'schemaName' | 'type' | 'description' | null interface IAttributeProps { entity: EntityType + onVisibleCountChange?: (count: number) => void } -export const Attributes = ({ entity }: IAttributeProps) => { +export const Attributes = ({ entity, onVisibleCountChange }: IAttributeProps) => { const [sortColumn, setSortColumn] = useState("displayName") const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") @@ -103,6 +105,15 @@ export const Attributes = ({ entity }: IAttributeProps) => { }) } + const sortedAttributes = getSortedAttributes(); + + // 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 @@ -174,7 +185,7 @@ export const Attributes = ({ entity }: IAttributeProps) => { )}
- {getSortedAttributes().length === 0 ? ( + {sortedAttributes.length === 0 ? (
{searchQuery || typeFilter !== "all" ? (
@@ -245,7 +256,7 @@ export const Attributes = ({ entity }: IAttributeProps) => { - {getSortedAttributes().map((attribute, index) => ( + {sortedAttributes.map((attribute, index) => ( void; +} + +function Keys({ entity, onVisibleCountChange }: IKeysProps) { const [sortColumn, setSortColumn] = useState("name") const [sortDirection, setSortDirection] = useState("asc") const [searchQuery, setSearchQuery] = useState("") @@ -74,6 +80,14 @@ function Keys({ entity }: { entity: EntityType }) { }) } + const sortedKeys = getSortedKeys(); + + React.useEffect(() => { + if (onVisibleCountChange) { + onVisibleCountChange(sortedKeys.length); + } + }, [onVisibleCountChange, sortedKeys.length]); + const SortIcon = ({ column }: { column: SortColumn }) => { if (sortColumn !== column) return if (sortDirection === 'asc') return @@ -106,7 +120,7 @@ function Keys({ entity }: { entity: EntityType }) { )}
- {getSortedKeys().length === 0 ? ( + {sortedKeys.length === 0 ? (
{searchQuery ? (
@@ -157,7 +171,7 @@ function Keys({ entity }: { entity: EntityType }) { - {getSortedKeys().map((key, index) => ( + {sortedKeys.map((key, index) => ( void; } -export const Relationships = ({ entity }: IRelationshipsProps) => { +export const Relationships = ({ entity, onVisibleCountChange }: IRelationshipsProps) => { const [sortColumn, setSortColumn] = useState("name") const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") @@ -114,6 +116,14 @@ export const Relationships = ({ entity }: IRelationshipsProps) => { { value: "one-to-many", label: "One-to-Many" } ] + const sortedRelationships = getSortedRelationships(); + + React.useEffect(() => { + if (onVisibleCountChange) { + onVisibleCountChange(sortedRelationships.length); + } + }, [onVisibleCountChange, sortedRelationships.length]); + return <>
@@ -233,7 +243,7 @@ export const Relationships = ({ entity }: IRelationshipsProps) => { - {getSortedRelationships().map((relationship, index) => + {sortedRelationships.map((relationship, index) => { + const [visibleAttributeCount, setVisibleAttributeCount] = React.useState(entity.Attributes.length); + const [visibleRelationshipCount, setVisibleRelationshipCount] = React.useState(entity.Relationships.length); + const [visibleKeyCount, setVisibleKeyCount] = React.useState(entity.Keys.length); return (
@@ -34,28 +37,28 @@ export const Section = React.memo(({ entity, group }: ISectionProps) => - Attributes [{entity.Attributes.length}] + Attributes [{visibleAttributeCount}] {entity.Relationships.length ? - Relationships [{entity.Relationships.length}] + Relationships [{visibleRelationshipCount}] : <> } - Keys [{entity.Keys.length}] + Keys [{visibleKeyCount}] - + - + - +
From ac13fcbbef4f712d17eb920fddead64b1cbc0d80 Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 20 Jul 2025 17:54:45 +0200 Subject: [PATCH 06/19] chore: PBI 119537 - link on groups to navigate to first section in that group. Chev. button removed --- .../datamodelview/SidebarDatamodelView.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 5616f91..12cd049 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@radix-ui/react-collapsible"; import { Slot } from "@radix-ui/react-slot"; import { ChevronDown, Puzzle, Search, X } from "lucide-react"; +import { Link as LinkIcon } from "lucide-react"; import { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; @@ -123,7 +124,22 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { > {group.Name}

{group.Entities.length}

- handleGroupClick(group.Name)}/> + From 39726df2a9fea5a0cc75cabf0e88611ec27b6797 Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 20 Jul 2025 17:56:50 +0200 Subject: [PATCH 07/19] chore: PBI 119536 - auto collapse on off-content click (mobile) --- Website/components/AppSidebar.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Website/components/AppSidebar.tsx b/Website/components/AppSidebar.tsx index 3105390..cafca4c 100644 --- a/Website/components/AppSidebar.tsx +++ b/Website/components/AppSidebar.tsx @@ -38,6 +38,15 @@ export const AppSidebar = ({}: IAppSidebarProps) => { )} + {/* Overlay for mobile sidebar */} + {isMobile && isOpen && ( +
+ )} + {/* Sidebar */}
Date: Mon, 21 Jul 2025 17:08:14 +0200 Subject: [PATCH 08/19] chore: PBI 119535 - changed sidebar selection to be the closes to the scroll ignoreing containerheight --- Website/components/datamodelview/List.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 76abb85..c6fe5fa 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -112,8 +112,6 @@ export const List = ({ }: IListProps) => { const virtualItems = rowVirtualizer.getVirtualItems(); const scrollOffset = scrollElement.scrollTop; - const containerHeight = scrollElement.clientHeight; - const target = containerHeight / 3; let best: { id: string; group: string; distance: number } | null = null; @@ -122,7 +120,7 @@ export const List = ({ }: IListProps) => { if (item.type !== "entity") continue; const itemTop = v.start; - const distance = Math.abs(itemTop - scrollOffset - target); + const distance = Math.abs(itemTop - scrollOffset); if (!best || distance < best.distance) { best = { @@ -131,6 +129,8 @@ export const List = ({ }: IListProps) => { distance, }; } + + if (distance > best.distance) break; } if (best && best.id !== lastSectionRef.current) { From 5a2e8a50d4a8310047f984eecf1667b8a4e8ac1c Mon Sep 17 00:00:00 2001 From: boer Date: Mon, 21 Jul 2025 17:38:09 +0200 Subject: [PATCH 09/19] chore: PBI 119533 - stop screen from jumping on tab changes (has added a performance bottleneck, due to virtualization recalculations - later fix) --- Website/components/datamodelview/List.tsx | 25 +++++++++++++++++-- Website/components/datamodelview/Section.tsx | 11 ++++++-- .../datamodelview/SidebarDatamodelView.tsx | 8 +++--- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index c6fe5fa..0c94d53 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -16,6 +16,16 @@ export const List = ({ }: IListProps) => { const lastSectionRef = useRef(null); const scrollTimeoutRef = useRef(); + // Map of SchemaName to element + const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + const getSectionRefCallback = (schemaName: string) => (el: HTMLDivElement | null) => { + sectionRefs.current[schemaName] = el; + }; + const remeasureSection = (schemaName: string) => { + const el = sectionRefs.current[schemaName]; + if (el) rowVirtualizer.measureElement(el); + }; + const flatItems = useMemo(() => { const items: Array< | { type: 'group'; group: (typeof Groups)[number] } @@ -181,12 +191,19 @@ export const List = ({ }: IListProps) => { > {rowVirtualizer.getVirtualItems().map((virtualItem) => { const item = flatItems[virtualItem.index]; + const sectionRef = item.type === 'entity' ? getSectionRefCallback(item.entity.SchemaName) : undefined; return (
{ + sectionRef && sectionRef(el); + if (el) rowVirtualizer.measureElement(el); + } + : rowVirtualizer.measureElement + } style={{ position: 'absolute', top: 0, @@ -205,7 +222,11 @@ export const List = ({ }: IListProps) => {
) : (
-
+
remeasureSection(item.entity.SchemaName)} + />
)}
diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index 38fd2c3..d2b7e10 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -13,13 +13,20 @@ import React from "react" interface ISectionProps { entity: EntityType; group: GroupType; + onContentChange?: () => void; } -export const Section = React.memo(({ entity, group }: ISectionProps) => +export const Section = React.memo(({ entity, group, onContentChange }: ISectionProps) => { + const [tab, setTab] = React.useState("attributes"); const [visibleAttributeCount, setVisibleAttributeCount] = React.useState(entity.Attributes.length); const [visibleRelationshipCount, setVisibleRelationshipCount] = React.useState(entity.Relationships.length); const [visibleKeyCount, setVisibleKeyCount] = React.useState(entity.Keys.length); + + React.useEffect(() => { + onContentChange && onContentChange(); + }, [tab, visibleAttributeCount, visibleRelationshipCount, visibleKeyCount, onContentChange]); + return (
@@ -32,7 +39,7 @@ export const Section = React.memo(({ entity, group }: ISectionProps) => )}
- +
diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 12cd049..01d0b72 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -5,7 +5,7 @@ import { useSidebarDispatch } from '@/contexts/SidebarContext'; import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@radix-ui/react-collapsible"; import { Slot } from "@radix-ui/react-slot"; -import { ChevronDown, Puzzle, Search, X } from "lucide-react"; +import { ChevronDown, ExternalLink, Puzzle, Search, X } from "lucide-react"; import { Link as LinkIcon } from "lucide-react"; import { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; @@ -124,7 +124,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { > {group.Name}

{group.Entities.length}

- + + From 3800fdaf78c1b6a7f0990ec83e9fde0a22949911 Mon Sep 17 00:00:00 2001 From: boer Date: Mon, 21 Jul 2025 18:56:18 +0200 Subject: [PATCH 10/19] feat: initial draft of loading bar indication, global search on attributes --- Website/app/page.tsx | 5 +- .../components/datamodelview/Attributes.tsx | 196 +++++++----------- .../datamodelview/DatamodelView.tsx | 137 ++++++++++-- Website/components/datamodelview/Keys.tsx | 13 +- Website/components/datamodelview/List.tsx | 90 ++++++-- .../datamodelview/Relationships.tsx | 9 +- Website/components/datamodelview/Section.tsx | 9 +- .../datamodelview/SidebarDatamodelView.tsx | 10 +- .../datamodelview/dataLoaderWorker.js | 5 + .../components/datamodelview/searchWorker.js | 34 +++ Website/contexts/DatamodelDataContext.tsx | 28 +++ Website/contexts/DatamodelViewContext.tsx | 5 + 12 files changed, 370 insertions(+), 171 deletions(-) create mode 100644 Website/components/datamodelview/dataLoaderWorker.js create mode 100644 Website/components/datamodelview/searchWorker.js create mode 100644 Website/contexts/DatamodelDataContext.tsx diff --git a/Website/app/page.tsx b/Website/app/page.tsx index d83dcd8..dc292db 100644 --- a/Website/app/page.tsx +++ b/Website/app/page.tsx @@ -1,12 +1,15 @@ import { DatamodelView } from "@/components/datamodelview/DatamodelView"; import { TouchProvider } from "@/components/ui/hybridtooltop"; import { Loading } from "@/components/ui/loading"; +import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; import { Suspense } from "react"; export default function Home() { return }> - + + + } diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index b24a8bb..d8cd7ab 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -19,6 +19,7 @@ import LookupAttribute from "./../attributes/LookupAttribute" import StatusAttribute from "./../attributes/StatusAttribute" import StringAttribute from "./../attributes/StringAttribute" import React from "react" +import { highlightMatch } from "../datamodelview/List"; type SortDirection = 'asc' | 'desc' | null type SortColumn = 'displayName' | 'schemaName' | 'type' | 'description' | null @@ -28,12 +29,11 @@ interface IAttributeProps { onVisibleCountChange?: (count: number) => void } -export const Attributes = ({ entity, onVisibleCountChange }: IAttributeProps) => { +export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttributeProps & { search?: string }) => { const [sortColumn, setSortColumn] = useState("displayName") const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") const [hideStandardFields, setHideStandardFields] = useState(true) - const [searchQuery, setSearchQuery] = useState("") const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -58,14 +58,13 @@ export const Attributes = ({ entity, onVisibleCountChange }: IAttributeProps) => filteredAttributes = filteredAttributes.filter(attr => attr.AttributeType === typeFilter) } - if (searchQuery) { - const query = searchQuery.toLowerCase() + // Filter by search prop (from parent) + if (search && search.length >= 3) { + const query = search.toLowerCase(); filteredAttributes = filteredAttributes.filter(attr => attr.DisplayName.toLowerCase().includes(query) || - attr.SchemaName.toLowerCase().includes(query) || - attr.AttributeType.toLowerCase().includes(query) || - (attr.Description?.toLowerCase().includes(query) ?? false) - ) + attr.SchemaName.toLowerCase().includes(query) + ); } if (hideStandardFields) filteredAttributes = filteredAttributes.filter(attr => attr.IsCustomAttribute || attr.IsStandardFieldModified); @@ -137,15 +136,7 @@ export const Attributes = ({ entity, onVisibleCountChange }: IAttributeProps) => return <>
-
- - setSearchQuery(e.target.value)} - className="pl-6 h-8 text-xs md:pl-8 md:h-10 md:text-sm" - /> -
+ {/* Removed internal search input, now using parent search */} { + setSearch(e.target.value); + datamodelDispatch({ type: 'SET_LOADING', payload: true }); + }} + placeholder="Search attributes or entities..." + className="w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" + />
+ + +
- - ) +
+ ); } \ No newline at end of file diff --git a/Website/components/datamodelview/Keys.tsx b/Website/components/datamodelview/Keys.tsx index 43a38d0..9795319 100644 --- a/Website/components/datamodelview/Keys.tsx +++ b/Website/components/datamodelview/Keys.tsx @@ -7,6 +7,7 @@ import { ArrowUpDown, ArrowUp, ArrowDown, Search, X } from "lucide-react" import { Input } from "../ui/input" import { Button } from "../ui/button" import React from "react" +import { highlightMatch } from "../datamodelview/List"; type SortColumn = 'name' | 'logicalName' | 'attributes' | null type SortDirection = 'asc' | 'desc' | null @@ -16,7 +17,7 @@ interface IKeysProps { onVisibleCountChange?: (count: number) => void; } -function Keys({ entity, onVisibleCountChange }: IKeysProps) { +function Keys({ entity, onVisibleCountChange, search = "" }: IKeysProps & { search?: string }) { const [sortColumn, setSortColumn] = useState("name") const [sortDirection, setSortDirection] = useState("asc") const [searchQuery, setSearchQuery] = useState("") @@ -178,8 +179,12 @@ function Keys({ entity, onVisibleCountChange }: IKeysProps) { index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50' }`} > - {key.Name} - {key.LogicalName} + + {highlightMatch(key.Name, search)} + + + {highlightMatch(key.LogicalName, search)} +
{key.KeyAttributes.map((attr, i) => ( @@ -187,7 +192,7 @@ function Keys({ entity, onVisibleCountChange }: IKeysProps) { key={i} className="inline-flex items-center px-1.5 py-0.5 text-xs rounded-md font-medium bg-blue-50 text-blue-700 md:px-2 md:py-1 md:text-sm" > - {attr} + {highlightMatch(attr, search)} ))}
diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 0c94d53..98ca4a7 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -1,17 +1,33 @@ import { useEffect, useMemo, useRef, useCallback, useState } from "react"; -import { Groups } from "../../generated/Data"; -import { useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; +import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; import React from "react"; import { useVirtualizer } from '@tanstack/react-virtual'; import { Section } from "./Section"; -import { Loader2 } from "lucide-react"; +import { useDatamodelData } from "@/contexts/DatamodelDataContext"; -interface IListProps { } +interface IListProps { + filteredItems?: Array< + | { type: 'group'; group: any } + | { type: 'entity'; group: any; entity: any } + >; + search?: string; +} -export const List = ({ }: IListProps) => { +// Helper to highlight search matches +export function highlightMatch(text: string, search: string) { + if (!search || search.length < 3) return text; + const idx = text.toLowerCase().indexOf(search.toLowerCase()); + if (idx === -1) return text; + return <>{text.slice(0, idx)}{text.slice(idx, idx + search.length)}{text.slice(idx + search.length)}; +} + +export const List = ({ filteredItems, search = "" }: IListProps) => { const dispatch = useDatamodelViewDispatch(); + const datamodelView = useDatamodelView(); const [isScrollingToSection, setIsScrollingToSection] = useState(false); + const groups = useDatamodelData(); + const parentRef = useRef(null); const lastSectionRef = useRef(null); const scrollTimeoutRef = useRef(); @@ -26,25 +42,47 @@ export const List = ({ }: IListProps) => { if (el) rowVirtualizer.measureElement(el); }; + // Compute filtered items based on search const flatItems = useMemo(() => { + if (filteredItems && filteredItems.length > 0) return filteredItems; + const lowerSearch = search.trim().toLowerCase(); const items: Array< - | { type: 'group'; group: (typeof Groups)[number] } - | { type: 'entity'; group: (typeof Groups)[number]; entity: (typeof Groups)[number]['Entities'][number] } + | { type: 'group'; group: any } + | { type: 'entity'; group: any; entity: any } > = []; - for (const group of Groups) { - items.push({ type: 'group', group }); - for (const entity of group.Entities) { - items.push({ type: 'entity', group, entity }); - } + for (const group of groups) { + // Filter entities in this group + const filteredEntities = group.Entities.filter((entity: any) => { + if (!lowerSearch) return true; + // Match entity schema or display name + const entityMatch = entity.SchemaName.toLowerCase().includes(lowerSearch) || + (entity.DisplayName && entity.DisplayName.toLowerCase().includes(lowerSearch)); + // Match any attribute schema or display name + const attrMatch = entity.Attributes.some((attr: any) => + attr.SchemaName.toLowerCase().includes(lowerSearch) || + (attr.DisplayName && attr.DisplayName.toLowerCase().includes(lowerSearch)) + ); + return entityMatch || attrMatch; + }); + if (filteredEntities.length > 0) { + items.push({ type: 'group', group }); + for (const entity of filteredEntities) { + items.push({ type: 'entity', group, entity }); + } + } } return items; - }, []); + }, [filteredItems, search, groups]); const rowVirtualizer = useVirtualizer({ count: flatItems.length, getScrollElement: () => parentRef.current, overscan: 20, - estimateSize: () => 300, + estimateSize: (index) => { + const item = flatItems[index]; + if (item.type === 'group') return 92; + return 300; + }, }); const scrollToSection = useCallback((sectionId: string) => { @@ -172,16 +210,23 @@ export const List = ({ }: IListProps) => { }; }, [dispatch, scrollToSection]); + useEffect(() => { + // When the current section is in view, set loading to false + if (datamodelView.currentSection) { + // Check if the current section is rendered in the virtualizer + const isInView = rowVirtualizer.getVirtualItems().some(vi => { + const item = flatItems[vi.index]; + return item.type === 'entity' && item.entity.SchemaName === datamodelView.currentSection; + }); + if (isInView) { + console.log("List: setting loading false"); + dispatch({ type: 'SET_LOADING', payload: false }); + } + } + }, [datamodelView.currentSection, flatItems, rowVirtualizer, dispatch]); + return (
- {isScrollingToSection && ( -
-
- - Loading section... -
-
- )}
{ entity={item.entity} group={item.group} onContentChange={() => remeasureSection(item.entity.SchemaName)} + search={search} />
)} diff --git a/Website/components/datamodelview/Relationships.tsx b/Website/components/datamodelview/Relationships.tsx index 98a9044..38cfe0c 100644 --- a/Website/components/datamodelview/Relationships.tsx +++ b/Website/components/datamodelview/Relationships.tsx @@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { Input } from "../ui/input" import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext" import React from "react" +import { highlightMatch } from "../datamodelview/List"; type SortDirection = 'asc' | 'desc' | null type SortColumn = 'name' | 'tableSchema' | 'lookupField' | 'type' | 'behavior' | 'schemaName' | null @@ -19,7 +20,7 @@ interface IRelationshipsProps { onVisibleCountChange?: (count: number) => void; } -export const Relationships = ({ entity, onVisibleCountChange }: IRelationshipsProps) => { +export const Relationships = ({ entity, onVisibleCountChange, search = "" }: IRelationshipsProps & { search?: string }) => { const [sortColumn, setSortColumn] = useState("name") const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") @@ -250,7 +251,9 @@ export const Relationships = ({ entity, onVisibleCountChange }: IRelationshipsPr index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50' }`} > - {relationship.Name} + + {highlightMatch(relationship.Name, search)} + {relationship.LookupDisplayName} diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index d2b7e10..9e8a82f 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -14,9 +14,10 @@ interface ISectionProps { entity: EntityType; group: GroupType; onContentChange?: () => void; + search?: string; } -export const Section = React.memo(({ entity, group, onContentChange }: ISectionProps) => +export const Section = React.memo(({ entity, group, onContentChange, search }: ISectionProps) => { const [tab, setTab] = React.useState("attributes"); const [visibleAttributeCount, setVisibleAttributeCount] = React.useState(entity.Attributes.length); @@ -59,13 +60,13 @@ export const Section = React.memo(({ entity, group, onContentChange }: ISectionP - + - + - +
diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 01d0b72..48f189b 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -1,5 +1,4 @@ import { EntityType, GroupType } from "@/lib/Types"; -import { Groups } from "../../generated/Data" import { useTouch } from '../ui/hybridtooltop'; import { useSidebarDispatch } from '@/contexts/SidebarContext'; import { cn } from "@/lib/utils"; @@ -10,6 +9,7 @@ import { Link as LinkIcon } from "lucide-react"; import { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; +import { useDatamodelData } from "@/contexts/DatamodelDataContext"; interface ISidebarDatamodelViewProps { @@ -25,6 +25,8 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const dispatch = useSidebarDispatch(); const { currentSection, currentGroup, scrollToSection } = useDatamodelView(); const dataModelDispatch = useDatamodelViewDispatch(); + + const groups = useDatamodelData(); const [searchTerm, setSearchTerm] = useState(""); const [expandedGroups, setExpandedGroups] = useState>(new Set()); @@ -38,7 +40,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { setSearchTerm(term); if (term.trim()) { const newExpandedGroups = new Set(); - Groups.forEach(group => { + groups.forEach(group => { const hasMatchingEntity = group.Entities.some(entity => entity.SchemaName.toLowerCase().includes(term.toLowerCase()) || entity.DisplayName.toLowerCase().includes(term.toLowerCase()) @@ -80,6 +82,8 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { }; const handleSectionClick = (sectionId: string) => { + console.log("Loading true for section click"); + dataModelDispatch({ type: 'SET_LOADING', payload: true }); if (scrollToSection) { scrollToSection(sectionId); } @@ -205,7 +209,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
{ - Groups.map((group) => + groups.map((group) => ) } diff --git a/Website/components/datamodelview/dataLoaderWorker.js b/Website/components/datamodelview/dataLoaderWorker.js new file mode 100644 index 0000000..df2f774 --- /dev/null +++ b/Website/components/datamodelview/dataLoaderWorker.js @@ -0,0 +1,5 @@ +import { Groups } from '../../generated/Data'; + +self.onmessage = function() { + self.postMessage(Groups); +}; \ No newline at end of file diff --git a/Website/components/datamodelview/searchWorker.js b/Website/components/datamodelview/searchWorker.js new file mode 100644 index 0000000..7d70bff --- /dev/null +++ b/Website/components/datamodelview/searchWorker.js @@ -0,0 +1,34 @@ +let groups = null; + +self.onmessage = function(e) { + if (e.data && e.data.type === 'init') { + groups = e.data.groups; + return; + } + // e.data is the search string + const search = (e.data || '').trim().toLowerCase(); + if (!groups) { + self.postMessage([]); + return; + } + const items = []; + for (const group of groups) { + const filteredEntities = group.Entities.filter(entity => { + if (!search) return true; + const entityMatch = entity.SchemaName.toLowerCase().includes(search) || + (entity.DisplayName && entity.DisplayName.toLowerCase().includes(search)); + const attrMatch = entity.Attributes.some(attr => + attr.SchemaName.toLowerCase().includes(search) || + (attr.DisplayName && attr.DisplayName.toLowerCase().includes(search)) + ); + return entityMatch || attrMatch; + }); + if (filteredEntities.length > 0) { + items.push({ type: 'group', group }); + for (const entity of filteredEntities) { + items.push({ type: 'entity', group, entity }); + } + } + } + self.postMessage(items); +}; \ No newline at end of file diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx new file mode 100644 index 0000000..bf516c1 --- /dev/null +++ b/Website/contexts/DatamodelDataContext.tsx @@ -0,0 +1,28 @@ +'use client' + +import { GroupType } from "@/lib/Types"; +import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"; + +const DatamodelDataContext = createContext([]); + +export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => { + const [groups, setGroups] = useState([]); + + useEffect(() => { + const worker = new Worker(new URL("../components/datamodelview/dataLoaderWorker.js", import.meta.url)); + worker.onmessage = (e) => { + setGroups(e.data); + worker.terminate(); + }; + worker.postMessage({}); + return () => worker.terminate(); + }, []); + + return ( + + {children} + + ); +}; + +export const useDatamodelData = () => useContext(DatamodelDataContext); \ No newline at end of file diff --git a/Website/contexts/DatamodelViewContext.tsx b/Website/contexts/DatamodelViewContext.tsx index a67c9de..3d9b756 100644 --- a/Website/contexts/DatamodelViewContext.tsx +++ b/Website/contexts/DatamodelViewContext.tsx @@ -8,18 +8,21 @@ export interface DatamodelViewState { currentGroup: string | null; currentSection: string | null; scrollToSection: (sectionId: string) => void; + loading: boolean; } const initialState: DatamodelViewState = { currentGroup: null, currentSection: null, scrollToSection: () => { throw new Error("scrollToSection not initialized yet!"); }, + loading: true, } 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_LOADING', payload: boolean } const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAction): DatamodelViewState => { @@ -30,6 +33,8 @@ const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAc return { ...state, currentSection: action.payload } case 'SET_SCROLL_TO_SECTION': return { ...state, scrollToSection: action.payload } + case 'SET_LOADING': + return { ...state, loading: action.payload } default: return state; } From 6dfde3666ba29354ecbdeb8cce1abcd095f8eb60 Mon Sep 17 00:00:00 2001 From: boer Date: Tue, 22 Jul 2025 18:24:20 +0200 Subject: [PATCH 11/19] chore: moved search into data context --- .../datamodelview/DatamodelView.tsx | 52 ++++++------------- Website/components/datamodelview/List.tsx | 13 ++--- .../datamodelview/SidebarDatamodelView.tsx | 2 +- Website/contexts/DatamodelDataContext.tsx | 45 +++++++++++++--- 4 files changed, 59 insertions(+), 53 deletions(-) diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index 62d225f..7032da7 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -8,46 +8,28 @@ import { DatamodelViewProvider, useDatamodelView, useDatamodelViewDispatch } fro import { List } from "./List"; import React, { useState, useEffect, useRef } from "react"; import type { Dispatch, SetStateAction, RefObject } from "react"; -import { DatamodelDataProvider, useDatamodelData } from "@/contexts/DatamodelDataContext"; +import { useDatamodelData, useDatamodelDataDispatch } from "@/contexts/DatamodelDataContext"; export function DatamodelView() { const dispatch = useSidebarDispatch(); - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [filtered, setFiltered] = useState([]); // will be passed to List - const workerRef = useRef(null); useEffect(() => { - dispatch({ type: "SET_ELEMENT", payload: }) + dispatch({ type: "SET_ELEMENT", payload: }); }, []); return ( - + ); } -function DatamodelViewContent({ search, setSearch, debouncedSearch, setDebouncedSearch, filtered, setFiltered, workerRef }: { - search: string; - setSearch: Dispatch>; - debouncedSearch: string; - setDebouncedSearch: Dispatch>; - filtered: any[]; - setFiltered: Dispatch>; - workerRef: { current: Worker | null }; -}) { +function DatamodelViewContent() { const { loading } = useDatamodelView(); const datamodelDispatch = useDatamodelViewDispatch(); - const groups = useDatamodelData(); + const { groups, search, filtered } = useDatamodelData(); + const datamodelDataDispatch = useDatamodelDataDispatch(); + const workerRef = useRef(null); useEffect(() => { if (!workerRef.current && groups) { @@ -59,24 +41,24 @@ function DatamodelViewContent({ search, setSearch, debouncedSearch, setDebounced if (!worker) return; const handleMessage = (e: MessageEvent) => { - setFiltered(e.data); - datamodelDispatch({ type: 'SET_LOADING', payload: false }); + datamodelDataDispatch({ type: "SET_FILTERED", payload: e.data }); + datamodelDispatch({ type: "SET_LOADING", payload: false }); }; worker.addEventListener("message", handleMessage); return () => worker.removeEventListener("message", handleMessage); - }, [datamodelDispatch, setFiltered, workerRef]); + }, [datamodelDispatch, datamodelDataDispatch, groups, workerRef]); useEffect(() => { - datamodelDispatch({ type: 'SET_LOADING', payload: true }); + datamodelDispatch({ type: "SET_LOADING", payload: true }); const handler = setTimeout(() => { if (workerRef.current && groups) { workerRef.current.postMessage(search.length >= 3 ? search : ""); } - setDebouncedSearch(search.length >= 3 ? search : ""); + datamodelDataDispatch({ type: "SET_SEARCH", payload: search.length >= 3 ? search : "" }); }, 200); return () => clearTimeout(handler); - }, [search, datamodelDispatch, setDebouncedSearch, workerRef]); + }, [search, datamodelDispatch, datamodelDataDispatch, groups, workerRef]); if (!groups) { return ( @@ -100,7 +82,7 @@ function DatamodelViewContent({ search, setSearch, debouncedSearch, setDebounced return (
-
+
{/* LOADING BAR */} {loading && ( @@ -126,15 +108,15 @@ function DatamodelViewContent({ search, setSearch, debouncedSearch, setDebounced type="text" value={search} onChange={e => { - setSearch(e.target.value); - datamodelDispatch({ type: 'SET_LOADING', payload: true }); + datamodelDataDispatch({ type: "SET_SEARCH", payload: e.target.value }); + datamodelDispatch({ type: "SET_LOADING", payload: true }); }} placeholder="Search attributes or entities..." className="w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" />
- +
diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 98ca4a7..1047a99 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -6,11 +6,6 @@ import { Section } from "./Section"; import { useDatamodelData } from "@/contexts/DatamodelDataContext"; interface IListProps { - filteredItems?: Array< - | { type: 'group'; group: any } - | { type: 'entity'; group: any; entity: any } - >; - search?: string; } // Helper to highlight search matches @@ -21,12 +16,12 @@ 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 = ({ filteredItems, search = "" }: IListProps) => { +export const List = ({ }: IListProps) => { const dispatch = useDatamodelViewDispatch(); const datamodelView = useDatamodelView(); const [isScrollingToSection, setIsScrollingToSection] = useState(false); - const groups = useDatamodelData(); + const { groups, filtered, search } = useDatamodelData(); const parentRef = useRef(null); const lastSectionRef = useRef(null); @@ -44,7 +39,7 @@ export const List = ({ filteredItems, search = "" }: IListProps) => { // Compute filtered items based on search const flatItems = useMemo(() => { - if (filteredItems && filteredItems.length > 0) return filteredItems; + if (filtered && filtered.length > 0) return filtered; const lowerSearch = search.trim().toLowerCase(); const items: Array< | { type: 'group'; group: any } @@ -72,7 +67,7 @@ export const List = ({ filteredItems, search = "" }: IListProps) => { } } return items; - }, [filteredItems, search, groups]); + }, [filtered, search, groups]); const rowVirtualizer = useVirtualizer({ count: flatItems.length, diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 48f189b..79df0c4 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -26,7 +26,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const { currentSection, currentGroup, scrollToSection } = useDatamodelView(); const dataModelDispatch = useDatamodelViewDispatch(); - const groups = useDatamodelData(); + const { groups } = useDatamodelData(); const [searchTerm, setSearchTerm] = useState(""); const [expandedGroups, setExpandedGroups] = useState>(new Set()); diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index bf516c1..3d1c840 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -1,17 +1,43 @@ 'use client' +import React, { createContext, useContext, useReducer, ReactNode } from "react"; import { GroupType } from "@/lib/Types"; -import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"; -const DatamodelDataContext = createContext([]); +interface DatamodelDataState { + groups: GroupType[]; + search: string; + filtered: any[]; +} + +const initialState: DatamodelDataState = { + groups: [], + search: "", + filtered: [] +}; + +const DatamodelDataContext = createContext(initialState); +const DatamodelDataDispatchContext = createContext>(() => {}); + +const datamodelDataReducer = (state: DatamodelDataState, action: any): DatamodelDataState => { + switch (action.type) { + case "SET_GROUPS": + return { ...state, groups: action.payload }; + case "SET_SEARCH": + return { ...state, search: action.payload }; + case "SET_FILTERED": + return { ...state, filtered: action.payload }; + default: + return state; + } +}; export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => { - const [groups, setGroups] = useState([]); + const [state, dispatch] = useReducer(datamodelDataReducer, initialState); - useEffect(() => { + React.useEffect(() => { const worker = new Worker(new URL("../components/datamodelview/dataLoaderWorker.js", import.meta.url)); worker.onmessage = (e) => { - setGroups(e.data); + dispatch({ type: "SET_GROUPS", payload: e.data }); worker.terminate(); }; worker.postMessage({}); @@ -19,10 +45,13 @@ export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => }, []); return ( - - {children} + + + {children} + ); }; -export const useDatamodelData = () => useContext(DatamodelDataContext); \ No newline at end of file +export const useDatamodelData = () => useContext(DatamodelDataContext); +export const useDatamodelDataDispatch = () => useContext(DatamodelDataDispatchContext); \ No newline at end of file From 4385826efe68f1472c09a88f1378af5d3230e342 Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 26 Jul 2025 11:36:19 +0200 Subject: [PATCH 12/19] fix: Moved searchbar and progressbar to seperate DOM tree, with a new performance context to schedule updates. --- .../datamodelview/DatamodelView.tsx | 100 ++++++------ Website/components/datamodelview/List.tsx | 130 ++++++++-------- .../components/datamodelview/SearchBar.tsx | 115 ++++++++++++++ Website/components/datamodelview/Section.tsx | 38 +++-- .../datamodelview/TimeSlicedSearch.tsx | 145 ++++++++++++++++++ .../components/datamodelview/searchWorker.js | 38 ++++- Website/contexts/DatamodelDataContext.tsx | 2 + Website/contexts/SearchPerformanceContext.tsx | 63 ++++++++ Website/lib/utils.ts | 51 ++++++ 9 files changed, 562 insertions(+), 120 deletions(-) create mode 100644 Website/components/datamodelview/SearchBar.tsx create mode 100644 Website/components/datamodelview/TimeSlicedSearch.tsx create mode 100644 Website/contexts/SearchPerformanceContext.tsx diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index 7032da7..3ea6f1e 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -5,10 +5,14 @@ import { TooltipProvider } from "../ui/tooltip"; import { useSidebarDispatch } from "@/contexts/SidebarContext"; import { SidebarDatamodelView } from "./SidebarDatamodelView"; import { DatamodelViewProvider, useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; +import { SearchPerformanceProvider } from "@/contexts/SearchPerformanceContext"; import { List } from "./List"; -import React, { useState, useEffect, useRef } from "react"; +import { SearchBar } from "./SearchBar"; +import { TimeSlicedSearch } from "./TimeSlicedSearch"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import type { Dispatch, SetStateAction, RefObject } from "react"; import { useDatamodelData, useDatamodelDataDispatch } from "@/contexts/DatamodelDataContext"; +import { debounce } from "@/lib/utils"; export function DatamodelView() { const dispatch = useSidebarDispatch(); @@ -18,9 +22,11 @@ export function DatamodelView() { }, []); return ( - - - + + + + + ); } @@ -30,6 +36,19 @@ function DatamodelViewContent() { const { groups, search, filtered } = useDatamodelData(); const datamodelDataDispatch = useDatamodelDataDispatch(); const workerRef = useRef(null); + const [searchProgress, setSearchProgress] = useState(0); + + // Isolated search handlers - these don't depend on component state + const handleSearch = useCallback((searchValue: string) => { + if (workerRef.current && groups) { + workerRef.current.postMessage(searchValue.length >= 3 ? searchValue : ""); + } + datamodelDataDispatch({ type: "SET_SEARCH", payload: searchValue.length >= 3 ? searchValue : "" }); + }, [groups, datamodelDataDispatch]); + + const handleLoadingChange = useCallback((isLoading: boolean) => { + datamodelDispatch({ type: "SET_LOADING", payload: isLoading }); + }, [datamodelDispatch]); useEffect(() => { if (!workerRef.current && groups) { @@ -41,24 +60,35 @@ function DatamodelViewContent() { if (!worker) return; const handleMessage = (e: MessageEvent) => { - datamodelDataDispatch({ type: "SET_FILTERED", payload: e.data }); - datamodelDispatch({ type: "SET_LOADING", payload: false }); + const message = e.data; + + if (message.type === 'started') { + datamodelDispatch({ type: "SET_LOADING", payload: true }); + setSearchProgress(0); + // Start with empty results to show loading state + datamodelDataDispatch({ type: "SET_FILTERED", payload: [] }); + } + else if (message.type === 'results') { + setSearchProgress(message.progress || 0); + + // For chunked results, append to existing + if (message.complete) { + datamodelDataDispatch({ type: "SET_FILTERED", payload: message.data }); + datamodelDispatch({ type: "SET_LOADING", payload: false }); + } else { + datamodelDataDispatch({ type: "APPEND_FILTERED", payload: message.data }); + } + } + else { + // Handle legacy format for backward compatibility + datamodelDataDispatch({ type: "SET_FILTERED", payload: message }); + datamodelDispatch({ type: "SET_LOADING", payload: false }); + } }; worker.addEventListener("message", handleMessage); return () => worker.removeEventListener("message", handleMessage); - }, [datamodelDispatch, datamodelDataDispatch, groups, workerRef]); - - useEffect(() => { - datamodelDispatch({ type: "SET_LOADING", payload: true }); - const handler = setTimeout(() => { - if (workerRef.current && groups) { - workerRef.current.postMessage(search.length >= 3 ? search : ""); - } - datamodelDataDispatch({ type: "SET_SEARCH", payload: search.length >= 3 ? search : "" }); - }, 200); - return () => clearTimeout(handler); - }, [search, datamodelDispatch, datamodelDataDispatch, groups, workerRef]); + }, [datamodelDispatch, datamodelDataDispatch, groups]); if (!groups) { return ( @@ -88,33 +118,17 @@ function DatamodelViewContent() { {loading && (
-
- +
)} - {/* SEARCH BAR */} -
- { - datamodelDataDispatch({ type: "SET_SEARCH", payload: e.target.value }); - datamodelDispatch({ type: "SET_LOADING", payload: true }); - }} - placeholder="Search attributes or entities..." - className="w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" - /> -
+ {/* HIGH-PERFORMANCE TIME-SLICED SEARCH BAR */} + diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 1047a99..f14edc8 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -4,6 +4,7 @@ import React from "react"; import { useVirtualizer } from '@tanstack/react-virtual'; import { Section } from "./Section"; import { useDatamodelData } from "@/contexts/DatamodelDataContext"; +import { throttle } from "@/lib/utils"; interface IListProps { } @@ -20,24 +21,23 @@ export const List = ({ }: IListProps) => { const dispatch = useDatamodelViewDispatch(); const datamodelView = useDatamodelView(); const [isScrollingToSection, setIsScrollingToSection] = useState(false); - const { groups, filtered, search } = useDatamodelData(); - const parentRef = useRef(null); + const lastScrollHandleTime = useRef(0); const lastSectionRef = useRef(null); const scrollTimeoutRef = useRef(); - - // Map of SchemaName to element const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + const getSectionRefCallback = (schemaName: string) => (el: HTMLDivElement | null) => { sectionRefs.current[schemaName] = el; }; + const remeasureSection = (schemaName: string) => { const el = sectionRefs.current[schemaName]; if (el) rowVirtualizer.measureElement(el); }; - // Compute filtered items based on search + // Only recalculate items when filtered or search changes const flatItems = useMemo(() => { if (filtered && filtered.length > 0) return filtered; const lowerSearch = search.trim().toLowerCase(); @@ -72,11 +72,11 @@ export const List = ({ }: IListProps) => { const rowVirtualizer = useVirtualizer({ count: flatItems.length, getScrollElement: () => parentRef.current, - overscan: 20, + overscan: 5, // Reduce overscan to improve performance estimateSize: (index) => { const item = flatItems[index]; - if (item.type === 'group') return 92; - return 300; + if (!item) return 100; + return item.type === 'group' ? 92 : 300; }, }); @@ -134,66 +134,54 @@ export const List = ({ }: IListProps) => { }); }, [flatItems]); + // Throttled scroll handler to reduce calculations + const handleScroll = useCallback(() => { + const now = Date.now(); + if (now - lastScrollHandleTime.current < 100) return; // Only process every 100ms + lastScrollHandleTime.current = now; + + const scrollElement = parentRef.current; + if (!scrollElement || isScrollingToSection) return; + + const scrollOffset = scrollElement.scrollTop; + const virtualItems = rowVirtualizer.getVirtualItems(); + + // Find the first visible item + const firstVisibleItem = virtualItems.find(v => { + return v.start <= scrollOffset && v.end >= scrollOffset; + }); + + if (firstVisibleItem) { + const item = flatItems[firstVisibleItem.index]; + if (item?.type === 'entity') { + if (item.entity.SchemaName !== datamodelView.currentSection) { + dispatch({ type: "SET_CURRENT_GROUP", payload: item.group.Name }); + dispatch({ type: "SET_CURRENT_SECTION", payload: item.entity.SchemaName }); + } + } + } + }, [dispatch, flatItems, rowVirtualizer, datamodelView.currentSection, isScrollingToSection]); + + // Throttled scroll event listener useEffect(() => { const scrollElement = parentRef.current; if (!scrollElement) return; - - let ticking = false; - let lastScrollTop = 0; - - const handleScroll = () => { - if (!ticking) { - ticking = true; - window.requestAnimationFrame(() => { - const currentScrollTop = scrollElement.scrollTop; - if (Math.abs(currentScrollTop - lastScrollTop) < 10) { - ticking = false; - return; - } - lastScrollTop = currentScrollTop; - - const virtualItems = rowVirtualizer.getVirtualItems(); - const scrollOffset = scrollElement.scrollTop; - - let best: { id: string; group: string; distance: number } | null = null; - - for (const v of virtualItems) { - const item = flatItems[v.index]; - if (item.type !== "entity") continue; - - const itemTop = v.start; - const distance = Math.abs(itemTop - scrollOffset); - - if (!best || distance < best.distance) { - best = { - id: item.entity.SchemaName, - group: item.group.Name, - distance, - }; - } - - if (distance > best.distance) break; - } - - if (best && best.id !== lastSectionRef.current) { - lastSectionRef.current = best.id; - dispatch({ type: "SET_CURRENT_GROUP", payload: best.group }); - dispatch({ type: "SET_CURRENT_SECTION", payload: best.id }); - } - - ticking = false; - }); - } + let scrollTimeout: number; + const throttledScrollHandler = () => { + if (scrollTimeout) return; + scrollTimeout = window.setTimeout(() => { + handleScroll(); + scrollTimeout = 0; + }, 100); }; - - scrollElement.addEventListener("scroll", handleScroll, { passive: true }); - handleScroll(); - + + scrollElement.addEventListener("scroll", throttledScrollHandler, { passive: true }); return () => { - scrollElement.removeEventListener("scroll", handleScroll); + scrollElement.removeEventListener("scroll", throttledScrollHandler); + clearTimeout(scrollTimeout); }; - }, [dispatch, flatItems, rowVirtualizer]); + }, [handleScroll]); useEffect(() => { dispatch({ type: 'SET_SCROLL_TO_SECTION', payload: scrollToSection }); @@ -222,11 +210,31 @@ export const List = ({ }: IListProps) => { return (
+ {/* Add skeleton loading state */} + {flatItems.length === 0 && datamodelView.loading && ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Virtualized list */}
{rowVirtualizer.getVirtualItems().map((virtualItem) => { diff --git a/Website/components/datamodelview/SearchBar.tsx b/Website/components/datamodelview/SearchBar.tsx new file mode 100644 index 0000000..e6ddc3a --- /dev/null +++ b/Website/components/datamodelview/SearchBar.tsx @@ -0,0 +1,115 @@ +'use client' + +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { debounce } from '@/lib/utils'; +import { useSearchPerformance } from '@/contexts/SearchPerformanceContext'; + +interface SearchBarProps { + onSearch: (value: string) => void; + onLoadingChange: (loading: boolean) => void; + placeholder?: string; + className?: string; +} + +// Internal search component +const SearchInput = React.memo(({ + onSearch, + onLoadingChange, + placeholder = "Search attributes or entities...", + className = "w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" +}: SearchBarProps) => { + // Local state - completely independent from parent + const [localValue, setLocalValue] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const { scheduleImmediateUpdate, scheduleBackgroundUpdate } = useSearchPerformance(); + + // Debounced callback to parent - only sends updates, doesn't depend on parent state + const debouncedOnSearch = useRef( + debounce((value: string) => { + // Schedule search as background task to not block UI + scheduleBackgroundUpdate(() => { + onSearch(value); + scheduleImmediateUpdate(() => setIsTyping(false)); + }); + }, 350) + ).current; + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + // Immediate UI update with highest priority + scheduleImmediateUpdate(() => { + setLocalValue(value); + + if (!isTyping) { + setIsTyping(true); + onLoadingChange(true); + } + }); + + debouncedOnSearch(value); + }, [debouncedOnSearch, onLoadingChange, isTyping, scheduleImmediateUpdate]); + + return ( +
+ + {isTyping && ( +
+
+
+ )} +
+ ); +}); + +SearchInput.displayName = 'SearchInput'; + +// Portal wrapper for complete isolation +export const SearchBar = (props: SearchBarProps) => { + const [portalRoot, setPortalRoot] = useState(null); + + useEffect(() => { + // Create a dedicated container for the search bar + let container = document.getElementById('search-bar-portal'); + if (!container) { + container = document.createElement('div'); + container.id = 'search-bar-portal'; + container.style.position = 'fixed'; + container.style.top = '0'; + container.style.left = '0'; + container.style.width = '100%'; + container.style.height = '100%'; + container.style.pointerEvents = 'none'; + container.style.zIndex = '1000'; + document.body.appendChild(container); + } + + // Create a specific div for this search bar instance + const searchContainer = document.createElement('div'); + searchContainer.style.pointerEvents = 'auto'; + container.appendChild(searchContainer); + setPortalRoot(searchContainer); + + return () => { + if (searchContainer && container?.contains(searchContainer)) { + container.removeChild(searchContainer); + } + }; + }, []); + + if (!portalRoot) return null; + + return createPortal(, portalRoot); +}; diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index 9e8a82f..9452c2f 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -17,16 +17,27 @@ interface ISectionProps { search?: string; } -export const Section = React.memo(({ entity, group, onContentChange, search }: ISectionProps) => - { +export const Section = React.memo( + ({ entity, group, onContentChange, search }: ISectionProps) => { + // Use useRef to track previous props for comparison + const prevSearch = React.useRef(search); + const [tab, setTab] = React.useState("attributes"); - const [visibleAttributeCount, setVisibleAttributeCount] = React.useState(entity.Attributes.length); - const [visibleRelationshipCount, setVisibleRelationshipCount] = React.useState(entity.Relationships.length); - const [visibleKeyCount, setVisibleKeyCount] = React.useState(entity.Keys.length); + + // 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(() => { - onContentChange && onContentChange(); - }, [tab, visibleAttributeCount, visibleRelationshipCount, visibleKeyCount, onContentChange]); + if (onContentChange && + (prevSearch.current !== search || + tab !== "attributes")) { + prevSearch.current = search; + onContentChange(); + } + }, [tab, search, onContentChange]); return (
@@ -60,19 +71,26 @@ export const Section = React.memo(({ entity, group, onContentChange, search }: I - + - + - +
) + }, + // 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; } ); diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx new file mode 100644 index 0000000..4e7df0d --- /dev/null +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -0,0 +1,145 @@ +'use client' + +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +interface TimeSlicedSearchProps { + onSearch: (value: string) => void; + onLoadingChange: (loading: boolean) => void; + placeholder?: string; + className?: string; +} + +// Time-sliced input that maintains 60fps regardless of background work +export const TimeSlicedSearch = ({ + onSearch, + onLoadingChange, + placeholder = "Search attributes or tables...", + className = "w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" +}: TimeSlicedSearchProps) => { + const [localValue, setLocalValue] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const [portalRoot, setPortalRoot] = useState(null); + + const searchTimeoutRef = useRef(); + const typingTimeoutRef = useRef(); + const frameRef = useRef(); + + // Time-sliced debouncing using requestAnimationFrame + const scheduleSearch = useCallback((value: string) => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = window.setTimeout(() => { + // Use MessageChannel for immediate callback without blocking main thread + const channel = new MessageChannel(); + channel.port2.onmessage = () => { + onSearch(value); + + // Reset typing state in next frame + frameRef.current = requestAnimationFrame(() => { + setIsTyping(false); + }); + }; + channel.port1.postMessage(null); + }, 350); + }, [onSearch]); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + // Immediate visual update (highest priority) + setLocalValue(value); + + // Manage typing state + if (!isTyping) { + setIsTyping(true); + onLoadingChange(true); + } + + // Reset typing timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + // Schedule search + scheduleSearch(value); + + // Auto-reset typing state if user stops typing + typingTimeoutRef.current = window.setTimeout(() => { + setIsTyping(false); + }, 2000); + + }, [isTyping, onLoadingChange, scheduleSearch]); + + // Cleanup + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + } + }; + }, []); + + // Portal setup + useEffect(() => { + let container = document.getElementById('time-sliced-search-portal'); + if (!container) { + container = document.createElement('div'); + container.id = 'time-sliced-search-portal'; + container.style.position = 'fixed'; + container.style.top = '0'; + container.style.left = '0'; + container.style.width = '100%'; + container.style.height = '100%'; + container.style.pointerEvents = 'none'; + container.style.zIndex = '9999'; + document.body.appendChild(container); + } + + const searchContainer = document.createElement('div'); + searchContainer.style.pointerEvents = 'auto'; + container.appendChild(searchContainer); + setPortalRoot(searchContainer); + + return () => { + if (searchContainer && container?.contains(searchContainer)) { + container.removeChild(searchContainer); + } + }; + }, []); + + const searchInput = ( +
+ + {isTyping && ( +
+
+
+ )} +
+ ); + + return portalRoot ? createPortal(searchInput, portalRoot) : null; +}; diff --git a/Website/components/datamodelview/searchWorker.js b/Website/components/datamodelview/searchWorker.js index 7d70bff..ee506e6 100644 --- a/Website/components/datamodelview/searchWorker.js +++ b/Website/components/datamodelview/searchWorker.js @@ -1,17 +1,24 @@ let groups = null; +const CHUNK_SIZE = 20; // Process results in chunks self.onmessage = function(e) { if (e.data && e.data.type === 'init') { groups = e.data.groups; return; } - // e.data is the search string + const search = (e.data || '').trim().toLowerCase(); if (!groups) { - self.postMessage([]); + self.postMessage({ type: 'results', data: [], complete: true }); return; } - const items = []; + + // First quickly send back a "started" message + self.postMessage({ type: 'started' }); + + const allItems = []; + + // Find all matches for (const group of groups) { const filteredEntities = group.Entities.filter(entity => { if (!search) return true; @@ -23,12 +30,31 @@ self.onmessage = function(e) { ); return entityMatch || attrMatch; }); + if (filteredEntities.length > 0) { - items.push({ type: 'group', group }); + allItems.push({ type: 'group', group }); for (const entity of filteredEntities) { - items.push({ type: 'entity', group, entity }); + allItems.push({ type: 'entity', group, entity }); } } } - self.postMessage(items); + + // 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; + + self.postMessage({ + type: 'results', + data: chunk, + complete: isLastChunk, + progress: Math.min(100, Math.round((i + CHUNK_SIZE) / allItems.length * 100)) + }); + + // Small delay between chunks to let the UI breathe + if (!isLastChunk) { + // Use setTimeout to yield control back to browser + setTimeout(() => {}, 5); + } + } }; \ No newline at end of file diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index 3d1c840..708f8e8 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -26,6 +26,8 @@ const datamodelDataReducer = (state: DatamodelDataState, action: any): Datamodel return { ...state, search: action.payload }; case "SET_FILTERED": return { ...state, filtered: action.payload }; + case "APPEND_FILTERED": + return { ...state, filtered: [...state.filtered, ...action.payload] }; default: return state; } diff --git a/Website/contexts/SearchPerformanceContext.tsx b/Website/contexts/SearchPerformanceContext.tsx new file mode 100644 index 0000000..2db02e2 --- /dev/null +++ b/Website/contexts/SearchPerformanceContext.tsx @@ -0,0 +1,63 @@ +'use client' + +import React, { createContext, useContext, useRef, useCallback, ReactNode } from 'react'; + +interface SearchPerformanceContextType { + scheduleImmediateUpdate: (callback: () => void) => void; + scheduleBackgroundUpdate: (callback: () => void) => void; + cancelScheduledUpdate: (id: number) => void; +} + +const SearchPerformanceContext = createContext(null); + +export const SearchPerformanceProvider = ({ children }: { children: ReactNode }) => { + const immediateUpdatesRef = useRef void>>(new Set()); + const backgroundUpdatesRef = useRef void>>(new Set()); + + const scheduleImmediateUpdate = useCallback((callback: () => void) => { + // Use MessageChannel for immediate, non-blocking updates + const channel = new MessageChannel(); + channel.port2.onmessage = () => { + callback(); + }; + channel.port1.postMessage(null); + }, []); + + const scheduleBackgroundUpdate = useCallback((callback: () => void) => { + // Use requestIdleCallback for background updates when browser is idle + if ('requestIdleCallback' in window) { + (window as any).requestIdleCallback(callback, { timeout: 1000 }); + } else { + // Fallback for browsers without requestIdleCallback + setTimeout(callback, 0); + } + }, []); + + const cancelScheduledUpdate = useCallback((id: number) => { + if ('cancelIdleCallback' in window) { + (window as any).cancelIdleCallback(id); + } else { + clearTimeout(id); + } + }, []); + + const value = { + scheduleImmediateUpdate, + scheduleBackgroundUpdate, + cancelScheduledUpdate + }; + + return ( + + {children} + + ); +}; + +export const useSearchPerformance = () => { + const context = useContext(SearchPerformanceContext); + if (!context) { + throw new Error('useSearchPerformance must be used within SearchPerformanceProvider'); + } + return context; +}; diff --git a/Website/lib/utils.ts b/Website/lib/utils.ts index 7493c66..f75f089 100644 --- a/Website/lib/utils.ts +++ b/Website/lib/utils.ts @@ -10,4 +10,55 @@ export function formatNumberSeperator(value: number): string { const parts = value.toString().split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.'); return parts.join(','); +} + +/** + * Creates a debounced function that delays invoking func until after wait milliseconds + */ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + + return function(...args: Parameters): void { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout) clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +/** + * Creates a throttled function that only invokes func at most once per every wait milliseconds + */ +export function throttle any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + let previous = 0; + + return function(...args: Parameters): void { + const now = Date.now(); + const remaining = wait - (now - previous); + + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + func(...args); + } else if (!timeout) { + timeout = setTimeout(() => { + previous = Date.now(); + timeout = null; + func(...args); + }, remaining); + } + }; } \ No newline at end of file From 59aaaa7327c6dbaf10c8e5b26737c2d9130f563d Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 26 Jul 2025 11:42:58 +0200 Subject: [PATCH 13/19] chore: added icon and removed old searchbar --- .../datamodelview/DatamodelView.tsx | 1 - .../components/datamodelview/SearchBar.tsx | 115 ------------------ .../datamodelview/TimeSlicedSearch.tsx | 28 ++--- 3 files changed, 13 insertions(+), 131 deletions(-) delete mode 100644 Website/components/datamodelview/SearchBar.tsx diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index 3ea6f1e..98a25ff 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -124,7 +124,6 @@ function DatamodelViewContent() { />
)} - {/* HIGH-PERFORMANCE TIME-SLICED SEARCH BAR */} void; - onLoadingChange: (loading: boolean) => void; - placeholder?: string; - className?: string; -} - -// Internal search component -const SearchInput = React.memo(({ - onSearch, - onLoadingChange, - placeholder = "Search attributes or entities...", - className = "w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" -}: SearchBarProps) => { - // Local state - completely independent from parent - const [localValue, setLocalValue] = useState(''); - const [isTyping, setIsTyping] = useState(false); - const { scheduleImmediateUpdate, scheduleBackgroundUpdate } = useSearchPerformance(); - - // Debounced callback to parent - only sends updates, doesn't depend on parent state - const debouncedOnSearch = useRef( - debounce((value: string) => { - // Schedule search as background task to not block UI - scheduleBackgroundUpdate(() => { - onSearch(value); - scheduleImmediateUpdate(() => setIsTyping(false)); - }); - }, 350) - ).current; - - const handleChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value; - - // Immediate UI update with highest priority - scheduleImmediateUpdate(() => { - setLocalValue(value); - - if (!isTyping) { - setIsTyping(true); - onLoadingChange(true); - } - }); - - debouncedOnSearch(value); - }, [debouncedOnSearch, onLoadingChange, isTyping, scheduleImmediateUpdate]); - - return ( -
- - {isTyping && ( -
-
-
- )} -
- ); -}); - -SearchInput.displayName = 'SearchInput'; - -// Portal wrapper for complete isolation -export const SearchBar = (props: SearchBarProps) => { - const [portalRoot, setPortalRoot] = useState(null); - - useEffect(() => { - // Create a dedicated container for the search bar - let container = document.getElementById('search-bar-portal'); - if (!container) { - container = document.createElement('div'); - container.id = 'search-bar-portal'; - container.style.position = 'fixed'; - container.style.top = '0'; - container.style.left = '0'; - container.style.width = '100%'; - container.style.height = '100%'; - container.style.pointerEvents = 'none'; - container.style.zIndex = '1000'; - document.body.appendChild(container); - } - - // Create a specific div for this search bar instance - const searchContainer = document.createElement('div'); - searchContainer.style.pointerEvents = 'auto'; - container.appendChild(searchContainer); - setPortalRoot(searchContainer); - - return () => { - if (searchContainer && container?.contains(searchContainer)) { - container.removeChild(searchContainer); - } - }; - }, []); - - if (!portalRoot) return null; - - return createPortal(, portalRoot); -}; diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 4e7df0d..bb3711b 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -2,6 +2,8 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { Input } from '../ui/input'; +import { Search } from 'lucide-react'; interface TimeSlicedSearchProps { onSearch: (value: string) => void; @@ -118,21 +120,17 @@ export const TimeSlicedSearch = ({ const searchInput = (
- + + {isTyping && (
From 5b5837b4194f2ec37bc6858ff81429de3be6dedf Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 26 Jul 2025 11:58:12 +0200 Subject: [PATCH 14/19] chore: next, previous and clear buttons. And also auto jump to first search result. --- .../datamodelview/DatamodelView.tsx | 74 +++++++++++- Website/components/datamodelview/Section.tsx | 2 +- .../datamodelview/TimeSlicedSearch.tsx | 107 ++++++++++++++++-- 3 files changed, 169 insertions(+), 14 deletions(-) diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index 98a25ff..f7a9ca8 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -31,12 +31,17 @@ export function DatamodelView() { } function DatamodelViewContent() { - const { loading } = useDatamodelView(); + const { loading, scrollToSection } = useDatamodelView(); const datamodelDispatch = useDatamodelViewDispatch(); const { groups, search, filtered } = useDatamodelData(); const datamodelDataDispatch = useDatamodelDataDispatch(); const workerRef = useRef(null); const [searchProgress, setSearchProgress] = useState(0); + const [currentSearchIndex, setCurrentSearchIndex] = useState(0); + const [searchResults, setSearchResults] = useState([]); + + // Calculate total search results + const totalResults = filtered.length > 0 ? filtered.filter(item => item.type === 'entity').length : 0; // Isolated search handlers - these don't depend on component state const handleSearch = useCallback((searchValue: string) => { @@ -44,12 +49,50 @@ function DatamodelViewContent() { workerRef.current.postMessage(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]); const handleLoadingChange = useCallback((isLoading: boolean) => { datamodelDispatch({ type: "SET_LOADING", payload: isLoading }); }, [datamodelDispatch]); + // 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); + } + } + }, [currentSearchIndex, totalResults, filtered, datamodelDispatch, 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); + } + } + }, [currentSearchIndex, filtered, datamodelDispatch, scrollToSection]); + useEffect(() => { if (!workerRef.current && groups) { workerRef.current = new Worker(new URL("./searchWorker.js", import.meta.url)); @@ -65,6 +108,7 @@ function DatamodelViewContent() { if (message.type === 'started') { datamodelDispatch({ type: "SET_LOADING", payload: true }); setSearchProgress(0); + setCurrentSearchIndex(0); // Start with empty results to show loading state datamodelDataDispatch({ type: "SET_FILTERED", payload: [] }); } @@ -75,6 +119,18 @@ function DatamodelViewContent() { if (message.complete) { datamodelDataDispatch({ type: "SET_FILTERED", payload: message.data }); datamodelDispatch({ type: "SET_LOADING", payload: false }); + // Set to first result if we have any and auto-navigate to it + const entityResults = message.data.filter((item: any) => item.type === 'entity'); + if (entityResults.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 }); + // Small delay to ensure virtual list is ready + setTimeout(() => { + scrollToSection(firstEntity.entity.SchemaName); + }, 100); + } } else { datamodelDataDispatch({ type: "APPEND_FILTERED", payload: message.data }); } @@ -83,6 +139,18 @@ 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: any) => item.type === 'entity'); + if (entityResults.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 }); + // Small delay to ensure virtual list is ready + setTimeout(() => { + scrollToSection(firstEntity.entity.SchemaName); + }, 100); + } } }; @@ -127,6 +195,10 @@ function DatamodelViewContent() { diff --git a/Website/components/datamodelview/Section.tsx b/Website/components/datamodelview/Section.tsx index 9452c2f..9536ec7 100644 --- a/Website/components/datamodelview/Section.tsx +++ b/Website/components/datamodelview/Section.tsx @@ -41,7 +41,7 @@ export const Section = React.memo( return (
-
+
{/* Removed conditional styling and indicator */}
{entity.SecurityRoles.length > 0 && ( diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index bb3711b..947a4a5 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -3,11 +3,15 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { Input } from '../ui/input'; -import { Search } from 'lucide-react'; +import { Search, ChevronUp, ChevronDown } from 'lucide-react'; interface TimeSlicedSearchProps { onSearch: (value: string) => void; onLoadingChange: (loading: boolean) => void; + onNavigateNext?: () => void; + onNavigatePrevious?: () => void; + currentIndex?: number; + totalResults?: number; placeholder?: string; className?: string; } @@ -15,8 +19,12 @@ interface TimeSlicedSearchProps { // Time-sliced input that maintains 60fps regardless of background work export const TimeSlicedSearch = ({ onSearch, - onLoadingChange, - placeholder = "Search attributes or tables...", + onLoadingChange, + onNavigateNext, + onNavigatePrevious, + currentIndex = 0, + totalResults = 0, + placeholder = "Search attributes...", className = "w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" }: TimeSlicedSearchProps) => { const [localValue, setLocalValue] = useState(''); @@ -75,6 +83,33 @@ export const TimeSlicedSearch = ({ }, [isTyping, onLoadingChange, scheduleSearch]); + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onNavigateNext?.(); + // Optional: Add haptic feedback on mobile + if ('vibrate' in navigator) { + navigator.vibrate(50); + } + } else if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + onNavigatePrevious?.(); + if ('vibrate' in navigator) { + navigator.vibrate(50); + } + } else if (e.key === 'ArrowDown' && e.ctrlKey) { + e.preventDefault(); + onNavigateNext?.(); + } else if (e.key === 'ArrowUp' && e.ctrlKey) { + e.preventDefault(); + onNavigatePrevious?.(); + } + }, [onNavigateNext, onNavigatePrevious]); + + const hasResults = totalResults > 0; + const showNavigation = hasResults && localValue.length >= 3; + // Cleanup useEffect(() => { return () => { @@ -119,21 +154,69 @@ export const TimeSlicedSearch = ({ }, []); const searchInput = ( -
- - + {/* Search Input Container */} +
+
+ + - {isTyping && ( -
-
+ autoCapitalize="off" + /> + {isTyping && ( +
+
+
+ )} +
+ + {/* Navigation Buttons */} + {showNavigation && ( +
+ + + +
+ )} +
+ + {/* Results Counter */} + {showNavigation && ( +
+ + {totalResults > 0 ? ( + `${currentIndex} of ${totalResults} sections` + ) : ( + 'No results' + )} + +
+ Enter next section • + Shift+Enter prev section • + Ctrl+↑↓ navigate +
)}
From 3371c94198f129e05df5b322423c80507ca2ea71 Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 26 Jul 2025 12:20:41 +0200 Subject: [PATCH 15/19] fix: ensure groups data is loaded before creating worker --- Website/components/datamodelview/DatamodelView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index f7a9ca8..e3e69d4 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -94,8 +94,12 @@ function DatamodelViewContent() { }, [currentSearchIndex, filtered, datamodelDispatch, scrollToSection]); useEffect(() => { - if (!workerRef.current && groups) { + if (!workerRef.current) { workerRef.current = new Worker(new URL("./searchWorker.js", import.meta.url)); + } + + // Initialize or re-initialize worker with groups when groups change + if (workerRef.current && groups && groups.length > 0) { workerRef.current.postMessage({ type: "init", groups }); } From 8da319564236ab5af89eaf83437f1178627a790e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 26 Jul 2025 12:31:24 +0200 Subject: [PATCH 16/19] Update Website/components/datamodelview/searchWorker.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Website/components/datamodelview/searchWorker.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Website/components/datamodelview/searchWorker.js b/Website/components/datamodelview/searchWorker.js index ee506e6..df5111a 100644 --- a/Website/components/datamodelview/searchWorker.js +++ b/Website/components/datamodelview/searchWorker.js @@ -53,8 +53,13 @@ self.onmessage = function(e) { // Small delay between chunks to let the UI breathe if (!isLastChunk) { - // Use setTimeout to yield control back to browser - setTimeout(() => {}, 5); + // Use a proper yielding mechanism to let the UI breathe + await sleep(5); } } + + // Helper function to pause execution for a specified duration + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } }; \ No newline at end of file From e5855e6d0a582976afe0411be4587d96352aebba Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 26 Jul 2025 12:32:24 +0200 Subject: [PATCH 17/19] Update Website/contexts/SearchPerformanceContext.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Website/contexts/SearchPerformanceContext.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Website/contexts/SearchPerformanceContext.tsx b/Website/contexts/SearchPerformanceContext.tsx index 2db02e2..4825503 100644 --- a/Website/contexts/SearchPerformanceContext.tsx +++ b/Website/contexts/SearchPerformanceContext.tsx @@ -14,27 +14,38 @@ export const SearchPerformanceProvider = ({ children }: { children: ReactNode }) const immediateUpdatesRef = useRef void>>(new Set()); const backgroundUpdatesRef = useRef void>>(new Set()); - const scheduleImmediateUpdate = useCallback((callback: () => void) => { - // Use MessageChannel for immediate, non-blocking updates + const immediateUpdateMap = useRef>(new Map()); + const scheduleImmediateUpdate = useCallback((callback: () => void): number => { + const id = Date.now() + Math.random(); // Generate a unique id const channel = new MessageChannel(); channel.port2.onmessage = () => { callback(); + immediateUpdateMap.current.delete(id); // Clean up after execution }; + immediateUpdateMap.current.set(id, channel); channel.port1.postMessage(null); + return id; }, []); - const scheduleBackgroundUpdate = useCallback((callback: () => void) => { - // Use requestIdleCallback for background updates when browser is idle + const scheduleBackgroundUpdate = useCallback((callback: () => void): number => { if ('requestIdleCallback' in window) { - (window as any).requestIdleCallback(callback, { timeout: 1000 }); + const id = (window as any).requestIdleCallback(callback, { timeout: 1000 }); + return id; } else { - // Fallback for browsers without requestIdleCallback - setTimeout(callback, 0); + const id = setTimeout(callback, 0); + return id as unknown as number; // Ensure consistent type } }, []); const cancelScheduledUpdate = useCallback((id: number) => { - if ('cancelIdleCallback' in window) { + if (immediateUpdateMap.current.has(id)) { + const channel = immediateUpdateMap.current.get(id); + if (channel) { + channel.port1.close(); + channel.port2.close(); + } + immediateUpdateMap.current.delete(id); + } else if ('cancelIdleCallback' in window) { (window as any).cancelIdleCallback(id); } else { clearTimeout(id); From fd24a18e9ab6372cf00bc3b0a5fa8b92bf4e9d2c Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 26 Jul 2025 12:45:19 +0200 Subject: [PATCH 18/19] fix: ESLint errors --- .../components/attributes/StatusAttribute.tsx | 4 +--- .../components/datamodelview/Attributes.tsx | 3 +-- .../datamodelview/DatamodelView.tsx | 23 ++++++++----------- Website/components/datamodelview/List.tsx | 18 +++++++-------- .../datamodelview/SidebarDatamodelView.tsx | 8 ++++--- .../datamodelview/TimeSlicedSearch.tsx | 3 --- Website/lib/utils.ts | 4 ++-- 7 files changed, 27 insertions(+), 36 deletions(-) diff --git a/Website/components/attributes/StatusAttribute.tsx b/Website/components/attributes/StatusAttribute.tsx index c2f0b38..4d05c28 100644 --- a/Website/components/attributes/StatusAttribute.tsx +++ b/Website/components/attributes/StatusAttribute.tsx @@ -1,10 +1,8 @@ -import { useIsMobile } from "@/hooks/use-mobile"; import { StatusAttributeType, StatusOption } from "@/lib/Types"; import { formatNumberSeperator } from "@/lib/utils"; -import { CheckCircle, Circle } from "lucide-react"; +import { Circle } from "lucide-react"; export default function StatusAttribute({ attribute }: { attribute: StatusAttributeType }) { - const isMobile = useIsMobile(); const groupedOptions = attribute.Options.reduce((acc, option) => { if (!acc[option.State]) { acc[option.State] = []; diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index d8cd7ab..c89d934 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -4,9 +4,8 @@ import { EntityType, AttributeType } from "@/lib/Types" import { TableHeader, TableRow, TableHead, TableBody, TableCell, Table } from "../ui/table" import { Button } from "../ui/button" import { useState } from "react" -import { ArrowUpDown, ArrowUp, ArrowDown, Search, X, EyeOff, Eye } from "lucide-react" +import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff, Eye } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./../ui/select" -import { Input } from "./../ui/input" import { AttributeDetails } from "./../entity/AttributeDetails" import BooleanAttribute from "./../attributes/BooleanAttribute" import ChoiceAttribute from "./../attributes/ChoiceAttribute" diff --git a/Website/components/datamodelview/DatamodelView.tsx b/Website/components/datamodelview/DatamodelView.tsx index e3e69d4..f6daa03 100644 --- a/Website/components/datamodelview/DatamodelView.tsx +++ b/Website/components/datamodelview/DatamodelView.tsx @@ -7,12 +7,9 @@ import { SidebarDatamodelView } from "./SidebarDatamodelView"; import { DatamodelViewProvider, useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; import { SearchPerformanceProvider } from "@/contexts/SearchPerformanceContext"; import { List } from "./List"; -import { SearchBar } from "./SearchBar"; import { TimeSlicedSearch } from "./TimeSlicedSearch"; import React, { useState, useEffect, useRef, useCallback } from "react"; -import type { Dispatch, SetStateAction, RefObject } from "react"; import { useDatamodelData, useDatamodelDataDispatch } from "@/contexts/DatamodelDataContext"; -import { debounce } from "@/lib/utils"; export function DatamodelView() { const dispatch = useSidebarDispatch(); @@ -31,14 +28,12 @@ export function DatamodelView() { } function DatamodelViewContent() { - const { loading, scrollToSection } = useDatamodelView(); + const { scrollToSection } = useDatamodelView(); const datamodelDispatch = useDatamodelViewDispatch(); - const { groups, search, filtered } = useDatamodelData(); + const { groups, filtered } = useDatamodelData(); const datamodelDataDispatch = useDatamodelDataDispatch(); const workerRef = useRef(null); - const [searchProgress, setSearchProgress] = useState(0); const [currentSearchIndex, setCurrentSearchIndex] = useState(0); - const [searchResults, setSearchResults] = useState([]); // Calculate total search results const totalResults = filtered.length > 0 ? filtered.filter(item => item.type === 'entity').length : 0; @@ -111,20 +106,20 @@ function DatamodelViewContent() { if (message.type === 'started') { datamodelDispatch({ type: "SET_LOADING", payload: true }); - setSearchProgress(0); + // setSearchProgress(0); setCurrentSearchIndex(0); // Start with empty results to show loading state datamodelDataDispatch({ type: "SET_FILTERED", payload: [] }); } else if (message.type === 'results') { - setSearchProgress(message.progress || 0); + // setSearchProgress(message.progress || 0); // For chunked results, append to existing if (message.complete) { datamodelDataDispatch({ type: "SET_FILTERED", payload: message.data }); datamodelDispatch({ type: "SET_LOADING", payload: false }); // Set to first result if we have any and auto-navigate to it - const entityResults = message.data.filter((item: any) => item.type === 'entity'); + const entityResults = message.data.filter((item: { type: string }) => item.type === 'entity'); if (entityResults.length > 0) { setCurrentSearchIndex(1); const firstEntity = entityResults[0]; @@ -144,7 +139,7 @@ function DatamodelViewContent() { 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: any) => item.type === 'entity'); + const entityResults = message.filter((item: { type: string }) => item.type === 'entity'); if (entityResults.length > 0) { setCurrentSearchIndex(1); const firstEntity = entityResults[0]; @@ -186,8 +181,8 @@ function DatamodelViewContent() {
- {/* LOADING BAR */} - {loading && ( + {/* LOADING BAR - currently deprecated */} + {/* {loading && (
- )} + )} */} { const { groups, filtered, search } = useDatamodelData(); const parentRef = useRef(null); const lastScrollHandleTime = useRef(0); - const lastSectionRef = useRef(null); const scrollTimeoutRef = useRef(); const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); @@ -42,18 +41,19 @@ export const List = ({ }: IListProps) => { if (filtered && filtered.length > 0) return filtered; const lowerSearch = search.trim().toLowerCase(); const items: Array< - | { type: 'group'; group: any } - | { type: 'entity'; group: any; entity: any } + | { type: 'group'; group: GroupType } + | { type: 'entity'; group: GroupType; entity: EntityType } > = []; for (const group of groups) { // Filter entities in this group - const filteredEntities = group.Entities.filter((entity: any) => { + const filteredEntities = group.Entities.filter((entity: EntityType) => { + const typedEntity = entity; if (!lowerSearch) return true; // Match entity schema or display name - const entityMatch = entity.SchemaName.toLowerCase().includes(lowerSearch) || - (entity.DisplayName && entity.DisplayName.toLowerCase().includes(lowerSearch)); + const entityMatch = typedEntity.SchemaName.toLowerCase().includes(lowerSearch) || + (typedEntity.DisplayName && typedEntity.DisplayName.toLowerCase().includes(lowerSearch)); // Match any attribute schema or display name - const attrMatch = entity.Attributes.some((attr: any) => + const attrMatch = typedEntity.Attributes.some((attr: AttributeType) => attr.SchemaName.toLowerCase().includes(lowerSearch) || (attr.DisplayName && attr.DisplayName.toLowerCase().includes(lowerSearch)) ); @@ -247,7 +247,7 @@ export const List = ({ }: IListProps) => { data-index={virtualItem.index} ref={item.type === 'entity' ? el => { - sectionRef && sectionRef(el); + if (sectionRef) sectionRef(el); if (el) rowVirtualizer.measureElement(el); } : rowVirtualizer.measureElement diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 79df0c4..b55545b 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -4,8 +4,7 @@ import { useSidebarDispatch } from '@/contexts/SidebarContext'; import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@radix-ui/react-collapsible"; import { Slot } from "@radix-ui/react-slot"; -import { ChevronDown, ExternalLink, Puzzle, Search, X } from "lucide-react"; -import { Link as LinkIcon } from "lucide-react"; +import { ExternalLink, Puzzle, Search, X } from "lucide-react"; import { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; @@ -142,7 +141,10 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { aria-label={`Link to first entity in ${group.Name}`} tabIndex={0} > - + { + e.stopPropagation(); + if (group.Entities.length > 0) handleGroupClick(group.Name); + }} /> diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index 947a4a5..3b195dd 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -13,7 +13,6 @@ interface TimeSlicedSearchProps { currentIndex?: number; totalResults?: number; placeholder?: string; - className?: string; } // Time-sliced input that maintains 60fps regardless of background work @@ -25,7 +24,6 @@ export const TimeSlicedSearch = ({ currentIndex = 0, totalResults = 0, placeholder = "Search attributes...", - className = "w-full px-4 py-2 rounded-lg border border-gray-300 shadow focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" }: TimeSlicedSearchProps) => { const [localValue, setLocalValue] = useState(''); const [isTyping, setIsTyping] = useState(false); @@ -88,7 +86,6 @@ export const TimeSlicedSearch = ({ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onNavigateNext?.(); - // Optional: Add haptic feedback on mobile if ('vibrate' in navigator) { navigator.vibrate(50); } diff --git a/Website/lib/utils.ts b/Website/lib/utils.ts index f75f089..16a0c90 100644 --- a/Website/lib/utils.ts +++ b/Website/lib/utils.ts @@ -15,7 +15,7 @@ export function formatNumberSeperator(value: number): string { /** * Creates a debounced function that delays invoking func until after wait milliseconds */ -export function debounce any>( +export function debounce unknown>( func: T, wait: number ): (...args: Parameters) => void { @@ -35,7 +35,7 @@ export function debounce any>( /** * Creates a throttled function that only invokes func at most once per every wait milliseconds */ -export function throttle any>( +export function throttle unknown>( func: T, wait: number ): (...args: Parameters) => void { From b2aa09883d9f56168b4985e76c58043fcc8d8f6b Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 26 Jul 2025 12:48:57 +0200 Subject: [PATCH 19/19] chore: forgotten async function declaration --- Website/components/datamodelview/searchWorker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Website/components/datamodelview/searchWorker.js b/Website/components/datamodelview/searchWorker.js index df5111a..2ac5573 100644 --- a/Website/components/datamodelview/searchWorker.js +++ b/Website/components/datamodelview/searchWorker.js @@ -1,7 +1,7 @@ let groups = null; const CHUNK_SIZE = 20; // Process results in chunks -self.onmessage = function(e) { +self.onmessage = async function(e) { if (e.data && e.data.type === 'init') { groups = e.data.groups; return;